Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
85029c0
feat(web): autolinks base implementation
pkaramon May 8, 2026
a8db549
fix: fixed bugs around edges and link detection
pkaramon May 11, 2026
4a3875f
fix: ref null potential issue
pkaramon May 12, 2026
b33599b
docs: checkpoint autolink e2e plan
pkaramon May 13, 2026
3d3d919
test: added tests
pkaramon May 13, 2026
f66ccd3
chore: remove useless .md file
pkaramon May 13, 2026
5a0afc2
chore: tiny fixes
pkaramon May 13, 2026
15cf741
fix: remove not needed stripAutolinkMarksOnPaste
pkaramon May 13, 2026
415c2e5
fix: auto https prefix in links
hejsztynx Jun 10, 2026
03cff9b
refactor: consistent autolink plugin creation
hejsztynx Jun 10, 2026
8431bd6
refactor: typed plugin options
hejsztynx Jun 10, 2026
39080f7
fix: clearing link event
hejsztynx Jun 10, 2026
aa091a8
refactor: dead code
hejsztynx Jun 11, 2026
ae28c8f
Merge remote-tracking branch 'origin/main' into @pkaramon/feat-web-au…
exploIF Jun 11, 2026
6ce5c3a
refactor: autlink plugin cleanup
hejsztynx Jun 12, 2026
f83c8f2
fix: manual links disappear when they dont match the linkRegex
hejsztynx Jun 12, 2026
8e1b476
fix: tiptap internal link detection on paste
hejsztynx Jun 12, 2026
ba40ae5
fix: onLinkDetect event recalls
hejsztynx Jun 12, 2026
c3cde91
fix: unnecessary autolink plugin transaction run on only selection ch…
hejsztynx Jun 12, 2026
2668e07
fix: short default regex for TLDs
hejsztynx Jun 12, 2026
e354b6f
feat: cleanup of unneccesary data-auto=false
hejsztynx Jun 12, 2026
0492efc
feat: dropped data-auto attribute
hejsztynx Jun 14, 2026
ebceba8
feat: internal tiptap link regex overwritten
hejsztynx Jun 15, 2026
ed676ce
test: fix e2e tests
hejsztynx Jun 18, 2026
a3d2727
Merge branch 'main' into @pkaramon/feat-web-autolink
hejsztynx Jun 18, 2026
23dd676
refactor: lint cleanup
hejsztynx Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .playwright/helpers/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,43 @@ export async function pasteInto(locator: Locator): Promise<void> {
await locator.page().keyboard.press('ControlOrMeta+V');
}

export async function copyWholeContent(locator: Locator): Promise<void> {
await locator.click();
await locator.page().waitForTimeout(100);
await locator.page().keyboard.press('ControlOrMeta+A');
await locator.page().keyboard.press('ControlOrMeta+C');
}

export async function pasteIntoWholeContent(locator: Locator): Promise<void> {
await locator.click();
await locator.page().waitForTimeout(100);
await locator.page().keyboard.press('ControlOrMeta+A');
await locator.page().keyboard.press('ControlOrMeta+V');
}

export async function copyAndPasteBetween(
source: Locator,
dest: Locator
): Promise<void> {
await copySelectionFrom(source);
await pasteInto(dest);
}

export async function pastePlainTextIntoEditor(
editorInnerLocator: Locator,
text: string
): Promise<void> {
const pm = editorInnerLocator.locator('.ProseMirror');
await pm.click();
await pm.evaluate((el, t) => {
const dt = new DataTransfer();
dt.setData('text/plain', t);
el.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
})
);
}, text);
}
1 change: 1 addition & 0 deletions .playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default defineConfig({
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
permissions: ['clipboard-read', 'clipboard-write'],
},

webServer: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import {
gotoVisualRegression,
setEditorHtml,
} from '../helpers/visual-regression';
import {
copyWholeContent,
pasteIntoWholeContent,
pastePlainTextIntoEditor,
} from '../helpers/clipboard';

test.setTimeout(90_000);

Expand All @@ -27,6 +32,8 @@ const sel = {
onLinkDetectedPayload: '[data-testid="on-link-detected-payload"]',
editorInner: '[data-testid="test-links-editor"] .eti-editor',
editorScreenshot: '[data-testid="test-links-editor"]',
linkRegexMode: '[data-testid="test-links-link-regex-mode"]',
linkRegexPattern: '[data-testid="test-links-link-regex-pattern"]',
} as const;

async function gotoTestLinks(page: Page): Promise<void> {
Expand Down Expand Up @@ -352,3 +359,158 @@ test.describe('test-links onLinkDetected', () => {
});
});
});

test.describe('test-links autolink', () => {
async function resetEditorAndSetLinkRegexMode(
page: Page,
mode: 'default' | 'disabled' | 'custom'
): Promise<void> {
await gotoTestLinks(page);
await page.locator(sel.linkRegexMode).selectOption(mode);
await setTestLinksEditorHtml(page, '<html><p></p></html>');
}

test('creates link while typing with default URL regex', async ({ page }) => {
await resetEditorAndSetLinkRegexMode(page, 'default');

const editor = page.locator(sel.editorInner);
await editor.click();
await expect(editor.locator('.ProseMirror')).toBeFocused();
await page.keyboard.type('Visit https://example.com');

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="https://example.com">https://example.com</a>');
});

test('creates link while typing with custom regex', async ({ page }) => {
await gotoTestLinks(page);
await page.locator(sel.linkRegexMode).selectOption('custom');
await page.fill(sel.linkRegexPattern, String.raw`issue-\d+`);
await setTestLinksEditorHtml(page, '<html><p></p></html>');

const editor = page.locator(sel.editorInner);
await editor.click();
await expect(editor.locator('.ProseMirror')).toBeFocused();
await page.keyboard.type('tick issue-123 done');

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="issue-123">issue-123</a>');
});

test('creates link when pasting plain URL with default regex', async ({
page,
}) => {
await resetEditorAndSetLinkRegexMode(page, 'default');

await pastePlainTextIntoEditor(
page.locator(sel.editorInner),
'https://example.com'
);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="https://example.com">https://example.com</a>');
});

test('does not autolink when link regex is disabled', async ({ page }) => {
await resetEditorAndSetLinkRegexMode(page, 'disabled');

const editor = page.locator(sel.editorInner);
await editor.click();
await expect(editor.locator('.ProseMirror')).toBeFocused();
await page.keyboard.type('https://example.com');

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.not.toContain('<a href');
});
});

test.describe('test-links copy-paste', () => {
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });

test('manual link (href ≠ text) survives copy-paste', async ({ page }) => {
await gotoTestLinks(page);
const editor = page.locator(sel.editorInner);

await setTestLinksEditorHtml(
page,
'<html><p><a href="https://example.com">Click here</a></p></html>'
);

await copyWholeContent(editor);
await setTestLinksEditorHtml(page, '<html><p></p></html>');
await pasteIntoWholeContent(editor);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="https://example.com">Click here</a>');
});

test('autolink (href == text, matches regex) survives copy-paste', async ({
page,
}) => {
await gotoTestLinks(page);
await page.locator(sel.linkRegexMode).selectOption('default');
const editor = page.locator(sel.editorInner);

await setTestLinksEditorHtml(
page,
'<html><p><a href="https://example.com">https://example.com</a></p></html>'
);

await copyWholeContent(editor);
await setTestLinksEditorHtml(page, '<html><p></p></html>');
await pasteIntoWholeContent(editor);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="https://example.com">https://example.com</a>');
});

test('manual link where href == text is not removed by autolink', async ({
page,
}) => {
await gotoTestLinks(page);
await page.locator(sel.linkRegexMode).selectOption('disabled');
const editor = page.locator(sel.editorInner);

await setTestLinksEditorHtml(
page,
'<html><p><a href="custom://link">custom://link</a></p></html>'
);

await copyWholeContent(editor);
await setTestLinksEditorHtml(page, '<html><p></p></html>');
await pasteIntoWholeContent(editor);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="custom://link">custom://link</a>');
});
});

test.describe('test-links manual link editing', () => {
test('typing inside a manual link keeps the link covering the typed text', async ({
page,
}) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(
page,
'<html><p><a href="https://example.com">Hello</a></p></html>'
);

await page.fill(sel.selectionStart, '3');
await page.fill(sel.selectionEnd, '3');
await page.click(sel.applySelection);

await page.waitForTimeout(100);
await page.keyboard.type('TEST', { delay: 80 });

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="https://example.com">HelTESTlo</a>');
});
});
3 changes: 3 additions & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const DEFAULT_LINK_STATE: OnLinkDetected = {
start: 0,
end: 0,
};
const LINK_REGEX =
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;

function App() {
const ref = useRef<EnrichedTextInputInstance>(null);
Expand Down Expand Up @@ -261,6 +263,7 @@ function App() {
onMentionDetected={handleOnMentionDetected}
mentionIndicators={['@', '#']}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
useHtmlNormalizer
/>
<MentionPopup
Expand Down
56 changes: 55 additions & 1 deletion apps/example-web/src/testScreens/TestLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState, type ChangeEvent } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import {
EnrichedTextInput,
type EnrichedInputStyle,
Expand All @@ -12,10 +12,18 @@ function toInteger(value: string): number {
return Number.isNaN(parsed) ? 0 : parsed;
}

type LinkRegexMode = 'default' | 'disabled' | 'custom';

export function TestLinks() {
const ref = useRef<EnrichedTextInputInstance>(null);
const [htmlInput, setHtmlInput] = useState('<html><p></p></html>');
const [editorHtml, setEditorHtml] = useState('');
const [linkRegexMode, setLinkRegexMode] = useState<LinkRegexMode>('default');
const [linkRegexPattern, setLinkRegexPattern] = useState(
String.raw`issue-\d+`
);
const [appliedLinkRegex, setAppliedLinkRegex] = useState<RegExp | null>();
const [linkRegexError, setLinkRegexError] = useState('');
const [startInput, setStartInput] = useState('6');
const [endInput, setEndInput] = useState('11');
const [linkTextInput, setLinkTextInput] = useState('world');
Expand All @@ -27,6 +35,23 @@ export function TestLinks() {
const [lastOnLinkDetected, setLastOnLinkDetected] =
useState<OnLinkDetected | null>(null);

useEffect(() => {
setLinkRegexError('');
if (linkRegexMode === 'default') {
setAppliedLinkRegex(undefined);
return;
}
if (linkRegexMode === 'disabled') {
setAppliedLinkRegex(null);
return;
}
try {
setAppliedLinkRegex(new RegExp(linkRegexPattern, 'g'));
} catch (e) {
setLinkRegexError(e instanceof Error ? e.message : 'Invalid regex');
}
}, [linkRegexMode, linkRegexPattern]);

return (
<div data-testid="test-links-root">
<div data-testid="test-links-editor" onClick={() => ref.current?.focus()}>
Expand All @@ -43,9 +68,38 @@ export function TestLinks() {
onLinkDetected={(e) => {
setLastOnLinkDetected(e);
}}
linkRegex={appliedLinkRegex}
/>
</div>

<div>
<label>
Autolink regex mode{' '}
<select
data-testid="test-links-link-regex-mode"
value={linkRegexMode}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
setLinkRegexMode(e.target.value as LinkRegexMode);
}}
>
<option value="default">default</option>
<option value="disabled">disabled</option>
<option value="custom">custom</option>
</select>
</label>
{linkRegexMode === 'custom' ? (
<input
data-testid="test-links-link-regex-pattern"
value={linkRegexPattern}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setLinkRegexPattern(e.target.value);
}}
aria-label="Custom link regex pattern"
/>
) : null}
<span data-testid="test-links-link-regex-error">{linkRegexError}</span>
</div>

<textarea
data-testid="test-links-html-input"
value={htmlInput}
Expand Down
2 changes: 1 addition & 1 deletion docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Web support is still experimental. APIs and behavior can change in future releas
- Images (via `setImage` ref method and optional `onPasteImages` when pasting image data)
- Manual links (via `setLink` ref method)
- Mentions
- Automatic link detection
- `getHTML`, `setValue`, selection mapping
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
- Submit props: `submitBehavior` and `onSubmitEditing`. `returnKeyType` is only a hint, it maps to [enterkeyhint](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint) (`done`, `go`, `next`, `previous`, `search`, `send`, `default`/`enter`). Not all values of `ReturnKeyTypeOptions` are supported, the behavior of this prop is heavily dependent on the browser's capabilities.
Expand All @@ -25,7 +26,6 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
## Unsupported

- **`returnKeyLabel`**: ignored on web, it's not possible to set it inside a browser.
- **Automatic link detection**: `linkRegex` is ignored. Links only work when set explicitly via the `setLink` ref method.
- **Context menu**: `contextMenuItems` is ignored.
- **RN layout ref methods**: `measure`, `measureInWindow`, `measureLayout`, and `setNativeProps` are no-ops.
- **`EnrichedText`**: The read-only component is not exported on web.
Expand Down
Loading
Loading