Skip to content

Commit e97c7ac

Browse files
Merge branch 'main' into @kacperzolkiewski/upgrade-tiptap
2 parents 01a0b7b + 4eed699 commit e97c7ac

16 files changed

Lines changed: 765 additions & 95 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Include any visual proof that helps reviewers understand the change — UI updat
2929
| ------- | :---------: |
3030
| iOS | ✅❌ |
3131
| Android | ✅❌ |
32+
| Web | ✅❌ |
3233

3334
## Checklist
3435

.playwright/helpers/clipboard.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,43 @@ export async function pasteInto(locator: Locator): Promise<void> {
1212
await locator.page().keyboard.press('ControlOrMeta+V');
1313
}
1414

15+
export async function copyWholeContent(locator: Locator): Promise<void> {
16+
await locator.click();
17+
await locator.page().waitForTimeout(100);
18+
await locator.page().keyboard.press('ControlOrMeta+A');
19+
await locator.page().keyboard.press('ControlOrMeta+C');
20+
}
21+
22+
export async function pasteIntoWholeContent(locator: Locator): Promise<void> {
23+
await locator.click();
24+
await locator.page().waitForTimeout(100);
25+
await locator.page().keyboard.press('ControlOrMeta+A');
26+
await locator.page().keyboard.press('ControlOrMeta+V');
27+
}
28+
1529
export async function copyAndPasteBetween(
1630
source: Locator,
1731
dest: Locator
1832
): Promise<void> {
1933
await copySelectionFrom(source);
2034
await pasteInto(dest);
2135
}
36+
37+
export async function pastePlainTextIntoEditor(
38+
editorInnerLocator: Locator,
39+
text: string
40+
): Promise<void> {
41+
const pm = editorInnerLocator.locator('.ProseMirror');
42+
await pm.click();
43+
await pm.evaluate((el, t) => {
44+
const dt = new DataTransfer();
45+
dt.setData('text/plain', t);
46+
el.dispatchEvent(
47+
new ClipboardEvent('paste', {
48+
clipboardData: dt,
49+
bubbles: true,
50+
cancelable: true,
51+
})
52+
);
53+
}, text);
54+
}

.playwright/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default defineConfig({
2626
trace: 'on-first-retry',
2727
screenshot: 'only-on-failure',
2828
video: 'retain-on-failure',
29+
permissions: ['clipboard-read', 'clipboard-write'],
2930
},
3031

3132
webServer: {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
gotoVisualRegression,
77
setEditorHtml,
88
} from '../helpers/visual-regression';
9+
import {
10+
copyWholeContent,
11+
pasteIntoWholeContent,
12+
pastePlainTextIntoEditor,
13+
} from '../helpers/clipboard';
914

1015
test.setTimeout(90_000);
1116

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

3239
async function gotoTestLinks(page: Page): Promise<void> {
@@ -352,3 +359,158 @@ test.describe('test-links onLinkDetected', () => {
352359
});
353360
});
354361
});
362+
363+
test.describe('test-links autolink', () => {
364+
async function resetEditorAndSetLinkRegexMode(
365+
page: Page,
366+
mode: 'default' | 'disabled' | 'custom'
367+
): Promise<void> {
368+
await gotoTestLinks(page);
369+
await page.locator(sel.linkRegexMode).selectOption(mode);
370+
await setTestLinksEditorHtml(page, '<html><p></p></html>');
371+
}
372+
373+
test('creates link while typing with default URL regex', async ({ page }) => {
374+
await resetEditorAndSetLinkRegexMode(page, 'default');
375+
376+
const editor = page.locator(sel.editorInner);
377+
await editor.click();
378+
await expect(editor.locator('.ProseMirror')).toBeFocused();
379+
await page.keyboard.type('Visit https://example.com');
380+
381+
await expect
382+
.poll(async () => getTestLinksSerializedHtml(page))
383+
.toContain('<a href="https://example.com">https://example.com</a>');
384+
});
385+
386+
test('creates link while typing with custom regex', async ({ page }) => {
387+
await gotoTestLinks(page);
388+
await page.locator(sel.linkRegexMode).selectOption('custom');
389+
await page.fill(sel.linkRegexPattern, String.raw`issue-\d+`);
390+
await setTestLinksEditorHtml(page, '<html><p></p></html>');
391+
392+
const editor = page.locator(sel.editorInner);
393+
await editor.click();
394+
await expect(editor.locator('.ProseMirror')).toBeFocused();
395+
await page.keyboard.type('tick issue-123 done');
396+
397+
await expect
398+
.poll(async () => getTestLinksSerializedHtml(page))
399+
.toContain('<a href="issue-123">issue-123</a>');
400+
});
401+
402+
test('creates link when pasting plain URL with default regex', async ({
403+
page,
404+
}) => {
405+
await resetEditorAndSetLinkRegexMode(page, 'default');
406+
407+
await pastePlainTextIntoEditor(
408+
page.locator(sel.editorInner),
409+
'https://example.com'
410+
);
411+
412+
await expect
413+
.poll(async () => getTestLinksSerializedHtml(page))
414+
.toContain('<a href="https://example.com">https://example.com</a>');
415+
});
416+
417+
test('does not autolink when link regex is disabled', async ({ page }) => {
418+
await resetEditorAndSetLinkRegexMode(page, 'disabled');
419+
420+
const editor = page.locator(sel.editorInner);
421+
await editor.click();
422+
await expect(editor.locator('.ProseMirror')).toBeFocused();
423+
await page.keyboard.type('https://example.com');
424+
425+
await expect
426+
.poll(async () => getTestLinksSerializedHtml(page))
427+
.not.toContain('<a href');
428+
});
429+
});
430+
431+
test.describe('test-links copy-paste', () => {
432+
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });
433+
434+
test('manual link (href ≠ text) survives copy-paste', async ({ page }) => {
435+
await gotoTestLinks(page);
436+
const editor = page.locator(sel.editorInner);
437+
438+
await setTestLinksEditorHtml(
439+
page,
440+
'<html><p><a href="https://example.com">Click here</a></p></html>'
441+
);
442+
443+
await copyWholeContent(editor);
444+
await setTestLinksEditorHtml(page, '<html><p></p></html>');
445+
await pasteIntoWholeContent(editor);
446+
447+
await expect
448+
.poll(async () => getTestLinksSerializedHtml(page))
449+
.toContain('<a href="https://example.com">Click here</a>');
450+
});
451+
452+
test('autolink (href == text, matches regex) survives copy-paste', async ({
453+
page,
454+
}) => {
455+
await gotoTestLinks(page);
456+
await page.locator(sel.linkRegexMode).selectOption('default');
457+
const editor = page.locator(sel.editorInner);
458+
459+
await setTestLinksEditorHtml(
460+
page,
461+
'<html><p><a href="https://example.com">https://example.com</a></p></html>'
462+
);
463+
464+
await copyWholeContent(editor);
465+
await setTestLinksEditorHtml(page, '<html><p></p></html>');
466+
await pasteIntoWholeContent(editor);
467+
468+
await expect
469+
.poll(async () => getTestLinksSerializedHtml(page))
470+
.toContain('<a href="https://example.com">https://example.com</a>');
471+
});
472+
473+
test('manual link where href == text is not removed by autolink', async ({
474+
page,
475+
}) => {
476+
await gotoTestLinks(page);
477+
await page.locator(sel.linkRegexMode).selectOption('disabled');
478+
const editor = page.locator(sel.editorInner);
479+
480+
await setTestLinksEditorHtml(
481+
page,
482+
'<html><p><a href="custom://link">custom://link</a></p></html>'
483+
);
484+
485+
await copyWholeContent(editor);
486+
await setTestLinksEditorHtml(page, '<html><p></p></html>');
487+
await pasteIntoWholeContent(editor);
488+
489+
await expect
490+
.poll(async () => getTestLinksSerializedHtml(page))
491+
.toContain('<a href="custom://link">custom://link</a>');
492+
});
493+
});
494+
495+
test.describe('test-links manual link editing', () => {
496+
test('typing inside a manual link keeps the link covering the typed text', async ({
497+
page,
498+
}) => {
499+
await gotoTestLinks(page);
500+
await setTestLinksEditorHtml(
501+
page,
502+
'<html><p><a href="https://example.com">Hello</a></p></html>'
503+
);
504+
505+
await page.fill(sel.selectionStart, '3');
506+
await page.fill(sel.selectionEnd, '3');
507+
await page.click(sel.applySelection);
508+
509+
await page.waitForTimeout(100);
510+
await page.keyboard.type('TEST', { delay: 80 });
511+
512+
await expect
513+
.poll(async () => getTestLinksSerializedHtml(page))
514+
.toContain('<a href="https://example.com">HelTESTlo</a>');
515+
});
516+
});

apps/example-web/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const DEFAULT_LINK_STATE: OnLinkDetected = {
3434
start: 0,
3535
end: 0,
3636
};
37+
const LINK_REGEX =
38+
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;
3739

3840
function App() {
3941
const ref = useRef<EnrichedTextInputInstance>(null);
@@ -261,6 +263,7 @@ function App() {
261263
onMentionDetected={handleOnMentionDetected}
262264
mentionIndicators={['@', '#']}
263265
htmlStyle={WEB_DEFAULT_HTML_STYLE}
266+
linkRegex={LINK_REGEX}
264267
useHtmlNormalizer
265268
/>
266269
<MentionPopup

apps/example-web/src/testScreens/TestLinks.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState, type ChangeEvent } from 'react';
1+
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
22
import {
33
EnrichedTextInput,
44
type EnrichedInputStyle,
@@ -12,10 +12,18 @@ function toInteger(value: string): number {
1212
return Number.isNaN(parsed) ? 0 : parsed;
1313
}
1414

15+
type LinkRegexMode = 'default' | 'disabled' | 'custom';
16+
1517
export function TestLinks() {
1618
const ref = useRef<EnrichedTextInputInstance>(null);
1719
const [htmlInput, setHtmlInput] = useState('<html><p></p></html>');
1820
const [editorHtml, setEditorHtml] = useState('');
21+
const [linkRegexMode, setLinkRegexMode] = useState<LinkRegexMode>('default');
22+
const [linkRegexPattern, setLinkRegexPattern] = useState(
23+
String.raw`issue-\d+`
24+
);
25+
const [appliedLinkRegex, setAppliedLinkRegex] = useState<RegExp | null>();
26+
const [linkRegexError, setLinkRegexError] = useState('');
1927
const [startInput, setStartInput] = useState('6');
2028
const [endInput, setEndInput] = useState('11');
2129
const [linkTextInput, setLinkTextInput] = useState('world');
@@ -27,6 +35,23 @@ export function TestLinks() {
2735
const [lastOnLinkDetected, setLastOnLinkDetected] =
2836
useState<OnLinkDetected | null>(null);
2937

38+
useEffect(() => {
39+
setLinkRegexError('');
40+
if (linkRegexMode === 'default') {
41+
setAppliedLinkRegex(undefined);
42+
return;
43+
}
44+
if (linkRegexMode === 'disabled') {
45+
setAppliedLinkRegex(null);
46+
return;
47+
}
48+
try {
49+
setAppliedLinkRegex(new RegExp(linkRegexPattern, 'g'));
50+
} catch (e) {
51+
setLinkRegexError(e instanceof Error ? e.message : 'Invalid regex');
52+
}
53+
}, [linkRegexMode, linkRegexPattern]);
54+
3055
return (
3156
<div data-testid="test-links-root">
3257
<div data-testid="test-links-editor" onClick={() => ref.current?.focus()}>
@@ -43,9 +68,38 @@ export function TestLinks() {
4368
onLinkDetected={(e) => {
4469
setLastOnLinkDetected(e);
4570
}}
71+
linkRegex={appliedLinkRegex}
4672
/>
4773
</div>
4874

75+
<div>
76+
<label>
77+
Autolink regex mode{' '}
78+
<select
79+
data-testid="test-links-link-regex-mode"
80+
value={linkRegexMode}
81+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
82+
setLinkRegexMode(e.target.value as LinkRegexMode);
83+
}}
84+
>
85+
<option value="default">default</option>
86+
<option value="disabled">disabled</option>
87+
<option value="custom">custom</option>
88+
</select>
89+
</label>
90+
{linkRegexMode === 'custom' ? (
91+
<input
92+
data-testid="test-links-link-regex-pattern"
93+
value={linkRegexPattern}
94+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
95+
setLinkRegexPattern(e.target.value);
96+
}}
97+
aria-label="Custom link regex pattern"
98+
/>
99+
) : null}
100+
<span data-testid="test-links-link-regex-error">{linkRegexError}</span>
101+
</div>
102+
49103
<textarea
50104
data-testid="test-links-html-input"
51105
value={htmlInput}

docs/WEB.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Web support is still experimental. APIs and behavior can change in future releas
1111
- Images (via `setImage` ref method and optional `onPasteImages` when pasting image data)
1212
- Manual links (via `setLink` ref method)
1313
- Mentions
14+
- Automatic link detection
1415
- `getHTML`, `setValue`, selection mapping
1516
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
1617
- 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.
@@ -25,7 +26,6 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
2526
## Unsupported
2627

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

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@
146146
"apps/example",
147147
"apps/example-web"
148148
],
149+
"resolutions": {
150+
"shell-quote": "1.8.4"
151+
},
149152
"packageManager": "yarn@4.13.0",
150153
"jest": {
151154
"projects": [

0 commit comments

Comments
 (0)