diff --git a/.playwright/helpers/clipboard.ts b/.playwright/helpers/clipboard.ts index 311189a46..5e1dc5b54 100644 --- a/.playwright/helpers/clipboard.ts +++ b/.playwright/helpers/clipboard.ts @@ -12,6 +12,20 @@ export async function pasteInto(locator: Locator): Promise { await locator.page().keyboard.press('ControlOrMeta+V'); } +export async function copyWholeContent(locator: Locator): Promise { + 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 { + 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 @@ -19,3 +33,22 @@ export async function copyAndPasteBetween( await copySelectionFrom(source); await pasteInto(dest); } + +export async function pastePlainTextIntoEditor( + editorInnerLocator: Locator, + text: string +): Promise { + 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); +} diff --git a/.playwright/playwright.config.ts b/.playwright/playwright.config.ts index 1dd6f6a75..cf2e60e7c 100644 --- a/.playwright/playwright.config.ts +++ b/.playwright/playwright.config.ts @@ -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: { diff --git a/.playwright/tests/testLinks.spec.ts b/.playwright/tests/links.spec.ts similarity index 67% rename from .playwright/tests/testLinks.spec.ts rename to .playwright/tests/links.spec.ts index 82bef7368..6a9ab7641 100644 --- a/.playwright/tests/testLinks.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -6,6 +6,11 @@ import { gotoVisualRegression, setEditorHtml, } from '../helpers/visual-regression'; +import { + copyWholeContent, + pasteIntoWholeContent, + pastePlainTextIntoEditor, +} from '../helpers/clipboard'; test.setTimeout(90_000); @@ -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 { @@ -352,3 +359,158 @@ test.describe('test-links onLinkDetected', () => { }); }); }); + +test.describe('test-links autolink', () => { + async function resetEditorAndSetLinkRegexMode( + page: Page, + mode: 'default' | 'disabled' | 'custom' + ): Promise { + await gotoTestLinks(page); + await page.locator(sel.linkRegexMode).selectOption(mode); + await setTestLinksEditorHtml(page, '

'); + } + + 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('https://example.com'); + }); + + 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, '

'); + + 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('issue-123'); + }); + + 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('https://example.com'); + }); + + 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(' { + 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, + '

Click here

' + ); + + await copyWholeContent(editor); + await setTestLinksEditorHtml(page, '

'); + await pasteIntoWholeContent(editor); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain('Click here'); + }); + + 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, + '

https://example.com

' + ); + + await copyWholeContent(editor); + await setTestLinksEditorHtml(page, '

'); + await pasteIntoWholeContent(editor); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain('https://example.com'); + }); + + 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, + '

custom://link

' + ); + + await copyWholeContent(editor); + await setTestLinksEditorHtml(page, '

'); + await pasteIntoWholeContent(editor); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain('custom://link'); + }); +}); + +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, + '

Hello

' + ); + + 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('HelTESTlo'); + }); +}); diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8ad87c255..c973a7c94 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -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(null); @@ -261,6 +263,7 @@ function App() { onMentionDetected={handleOnMentionDetected} mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} + linkRegex={LINK_REGEX} useHtmlNormalizer /> (null); const [htmlInput, setHtmlInput] = useState('

'); const [editorHtml, setEditorHtml] = useState(''); + const [linkRegexMode, setLinkRegexMode] = useState('default'); + const [linkRegexPattern, setLinkRegexPattern] = useState( + String.raw`issue-\d+` + ); + const [appliedLinkRegex, setAppliedLinkRegex] = useState(); + const [linkRegexError, setLinkRegexError] = useState(''); const [startInput, setStartInput] = useState('6'); const [endInput, setEndInput] = useState('11'); const [linkTextInput, setLinkTextInput] = useState('world'); @@ -27,6 +35,23 @@ export function TestLinks() { const [lastOnLinkDetected, setLastOnLinkDetected] = useState(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 (
ref.current?.focus()}> @@ -43,9 +68,38 @@ export function TestLinks() { onLinkDetected={(e) => { setLastOnLinkDetected(e); }} + linkRegex={appliedLinkRegex} />
+
+ + {linkRegexMode === 'custom' ? ( + ) => { + setLinkRegexPattern(e.target.value); + }} + aria-label="Custom link regex pattern" + /> + ) : null} + {linkRegexError} +
+