Skip to content

Commit 372a8f5

Browse files
authored
Merge branch 'main' into @ksienkiewicz/fix-link-copy-pasting
2 parents a2e3800 + 56f47d4 commit 372a8f5

47 files changed

Lines changed: 5337 additions & 3593 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ jobs:
205205
- name: Set up Xcode
206206
uses: maxim-lobanov/setup-xcode@v1
207207
with:
208-
xcode-version: '16.4'
208+
xcode-version: '26.6'
209209

210210
- name: Reset build folder and pods
211211
run: rm -rf apps/example/build apps/example/ios/Pods apps/example/ios/Podfile.lock
-65 Bytes
Loading
-65 Bytes
Loading
-71 Bytes
Loading

.maestro/scripts/setup-android-emulator.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ until adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | grep -q "^
7777
done
7878

7979
adb -s "$SERIAL" shell pm disable-user --user 0 com.google.android.inputmethod.latin
80+
adb -s "$SERIAL" shell settings put secure spell_checker_enabled 0
8081

8182
echo "Emulator ready: $AVD_NAME ($SERIAL)"
8283
echo "DEVICE_ID=$SERIAL"

.maestro/scripts/setup-ios-simulator.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ if [ -z "$UDID" ]; then
2525
exit 1
2626
fi
2727

28+
# disable automatic text manipulation: auto-correction, spelling-check and auto-capitalization
29+
SIM_PREFS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Preferences"
30+
mkdir -p "$SIM_PREFS_DIR"
31+
32+
PLIST="$SIM_PREFS_DIR/com.apple.keyboard.preferences.plist"
33+
34+
/usr/libexec/PlistBuddy -c "Add :KeyboardAutocorrection bool false" "$PLIST" 2>/dev/null || \
35+
/usr/libexec/PlistBuddy -c "Set :KeyboardAutocorrection bool false" "$PLIST"
36+
37+
/usr/libexec/PlistBuddy -c "Add :KeyboardCheckSpelling bool false" "$PLIST" 2>/dev/null || \
38+
/usr/libexec/PlistBuddy -c "Set :KeyboardCheckSpelling bool false" "$PLIST"
39+
40+
/usr/libexec/PlistBuddy -c "Add :KeyboardAutocapitalization bool false" "$PLIST" 2>/dev/null || \
41+
/usr/libexec/PlistBuddy -c "Set :KeyboardAutocapitalization bool false" "$PLIST"
42+
2843
STATE=$(xcrun simctl list devices | grep "$UDID" | grep -oE '\(Booted\)|\(Shutdown\)' || true)
2944
if [ "$STATE" != "(Booted)" ]; then
3045
echo "Booting '$DEVICE_NAME' ($UDID)..."

.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+
});

0 commit comments

Comments
 (0)