From 936d00a614a55bcc64235800f252fd9ab96d7236 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 11 Aug 2026 17:47:59 +0200 Subject: [PATCH 1/6] feat: e2e regression tests --- .playwright/tests/links.spec.ts | 64 +++++++++++++++++++ .../example-web/src/testScreens/TestLinks.tsx | 25 ++++++++ 2 files changed, 89 insertions(+) diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 0300131ed..9cd2a7837 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -29,6 +29,9 @@ const sel = { selectionStart: '[data-testid="test-links-selection-start"]', selectionEnd: '[data-testid="test-links-selection-end"]', applySelection: '[data-testid="test-links-apply-selection-button"]', + applySetLinkFromSelection: + '[data-testid="test-links-apply-setlink-from-selection-button"]', + selectionPayload: '[data-testid="test-links-selection-payload"]', onLinkDetectedPayload: '[data-testid="on-link-detected-payload"]', editorInner: '[data-testid="test-links-editor"] .eti-editor', editorScreenshot: '[data-testid="test-links-editor"]', @@ -251,6 +254,67 @@ test.describe('test-links setLink table', () => { } }); +test.describe('test-links setLink round-trips onChangeSelection text', () => { + // Regression: onChangeSelection reports the selected text with '\n' at block + // boundaries and '' for inline leaves. setLink used to compare that + // against a plain textBetween serialization, so feeding the event's own text + // straight back mismatched, took the destructive replace branch, merged the + // blocks into one paragraph and rendered the '\n' as a literal glyph. + test('linking a selection across a block boundary keeps both paragraphs', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml(page, '

siema

czesc

'); + + // "ma\ncz" - crosses the paragraph boundary. + await page.fill(sel.selectionStart, '3'); + await page.fill(sel.selectionEnd, '8'); + await page.fill(sel.setLinkUrl, 'https://swmansion.com'); + await page.click(sel.applySelection); + + await expect + .poll(async () => page.locator(sel.selectionPayload).textContent()) + .toBe(JSON.stringify({ start: 3, end: 8, text: 'ma\ncz' })); + + await page.click(sel.applySetLinkFromSelection); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain( + '

siema

' + + '

czesc

' + ); + }); + + test('linking a selection across a block boundary preserves inline marks', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml( + page, + '

siema

czesc

' + ); + + await page.fill(sel.selectionStart, '3'); + await page.fill(sel.selectionEnd, '8'); + await page.fill(sel.setLinkUrl, 'https://swmansion.com'); + await page.click(sel.applySelection); + + await expect + .poll(async () => page.locator(sel.selectionPayload).textContent()) + .toBe(JSON.stringify({ start: 3, end: 8, text: 'ma\ncz' })); + + await page.click(sel.applySetLinkFromSelection); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain( + '

siema

' + + '

czesc

' + ); + }); +}); + test.describe('test-links removeLink table', () => { const cases: { name: string; diff --git a/apps/example-web/src/testScreens/TestLinks.tsx b/apps/example-web/src/testScreens/TestLinks.tsx index dc43aa102..27635af6b 100644 --- a/apps/example-web/src/testScreens/TestLinks.tsx +++ b/apps/example-web/src/testScreens/TestLinks.tsx @@ -3,6 +3,7 @@ import { EnrichedTextInput, type EnrichedInputStyle, type EnrichedTextInputInstance, + type OnChangeSelectionEvent, type OnLinkDetected, } from 'react-native-enriched-html'; import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; @@ -34,6 +35,8 @@ export function TestLinks() { const [selEndInput, setSelEndInput] = useState('0'); const [lastOnLinkDetected, setLastOnLinkDetected] = useState(null); + const [lastSelection, setLastSelection] = + useState(null); useEffect(() => { setLinkRegexError(''); @@ -68,6 +71,9 @@ export function TestLinks() { onLinkDetected={(e) => { setLastOnLinkDetected(e); }} + onChangeSelection={(e) => { + setLastSelection(e.nativeEvent); + }} linkRegex={appliedLinkRegex} /> @@ -223,8 +229,27 @@ export function TestLinks() { > setSelection + +
+        {JSON.stringify(lastSelection)}
+      
+
         {JSON.stringify(lastOnLinkDetected)}
       
From 690dd3ec08058435d80058f30c23f4d3b7db897b Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 11 Aug 2026 17:48:28 +0200 Subject: [PATCH 2/6] fix: consistent setLink comparison --- src/web/formats/EnrichedLink.ts | 4 ++-- src/web/useOnLinkDetected.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/web/formats/EnrichedLink.ts b/src/web/formats/EnrichedLink.ts index 3e0476b8e..f3078b2c1 100644 --- a/src/web/formats/EnrichedLink.ts +++ b/src/web/formats/EnrichedLink.ts @@ -2,7 +2,7 @@ import Link, { type LinkOptions } from '@tiptap/extension-link'; import { mergeAttributes, type CommandProps } from '@tiptap/core'; import type { Editor } from '@tiptap/react'; -import { nativePosToTiptapPos } from '../positionMapping'; +import { nativeLeafText, nativePosToTiptapPos } from '../positionMapping'; import { isLinkBlocked } from './formatRules'; import { findAutolinkRangesInWord } from '../pmPlugins/AutolinkPlugin/autolinkRegex'; @@ -162,7 +162,7 @@ export function setLink( const marksWithLink = linkMark.addToSet(marksAtRangeStart); tr.insert(from, s.schema.text(text, marksWithLink)); } else { - const currentText = doc.textBetween(from, to); + const currentText = nativeLeafText(doc, from, to); if (text !== currentText) { const marksAtRangeStart = doc.resolve(from).marks(); diff --git a/src/web/useOnLinkDetected.ts b/src/web/useOnLinkDetected.ts index 4373faf36..509f0aeaf 100644 --- a/src/web/useOnLinkDetected.ts +++ b/src/web/useOnLinkDetected.ts @@ -4,7 +4,7 @@ import { getMarkRange, getMarksBetween } from '@tiptap/core'; import type { EditorState } from '@tiptap/pm/state'; import type { MarkType } from '@tiptap/pm/model'; import { emitLinkDetected, type LinkEmitterRef } from './emitLinkDetected'; -import { tiptapPosToNativePos } from './positionMapping'; +import { nativeLeafText, tiptapPosToNativePos } from './positionMapping'; function findLinkRangeAt( state: EditorState, @@ -53,7 +53,7 @@ export const useOnLinkDetected = ( if (!linkMark) return; emitLinkDetected(ref.current, { - text: state.doc.textBetween(range.from, range.to, '\n'), + text: nativeLeafText(state.doc, range.from, range.to), url: (linkMark.attrs.href as string | undefined) ?? '', start: tiptapPosToNativePos(state.doc, range.from), end: tiptapPosToNativePos(state.doc, range.to), From a4cce4f6d4672946deec9ec84d76ee5c922b65a3 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 11 Aug 2026 17:52:30 +0200 Subject: [PATCH 3/6] refactor: tweak e2e tests --- .playwright/tests/links.spec.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 9cd2a7837..0563b9d7a 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -255,18 +255,12 @@ test.describe('test-links setLink table', () => { }); test.describe('test-links setLink round-trips onChangeSelection text', () => { - // Regression: onChangeSelection reports the selected text with '\n' at block - // boundaries and '' for inline leaves. setLink used to compare that - // against a plain textBetween serialization, so feeding the event's own text - // straight back mismatched, took the destructive replace branch, merged the - // blocks into one paragraph and rendered the '\n' as a literal glyph. test('linking a selection across a block boundary keeps both paragraphs', async ({ page, }) => { await gotoTestLinks(page); - await setTestLinksEditorHtml(page, '

siema

czesc

'); + await setTestLinksEditorHtml(page, '

hello

world

'); - // "ma\ncz" - crosses the paragraph boundary. await page.fill(sel.selectionStart, '3'); await page.fill(sel.selectionEnd, '8'); await page.fill(sel.setLinkUrl, 'https://swmansion.com'); @@ -274,15 +268,15 @@ test.describe('test-links setLink round-trips onChangeSelection text', () => { await expect .poll(async () => page.locator(sel.selectionPayload).textContent()) - .toBe(JSON.stringify({ start: 3, end: 8, text: 'ma\ncz' })); + .toBe(JSON.stringify({ start: 3, end: 8, text: 'lo\nwo' })); await page.click(sel.applySetLinkFromSelection); await expect .poll(async () => getTestLinksSerializedHtml(page)) .toContain( - '

siema

' + - '

czesc

' + '

hello

' + + '

world

' ); }); @@ -292,7 +286,7 @@ test.describe('test-links setLink round-trips onChangeSelection text', () => { await gotoTestLinks(page); await setTestLinksEditorHtml( page, - '

siema

czesc

' + '

hello

world

' ); await page.fill(sel.selectionStart, '3'); @@ -302,15 +296,15 @@ test.describe('test-links setLink round-trips onChangeSelection text', () => { await expect .poll(async () => page.locator(sel.selectionPayload).textContent()) - .toBe(JSON.stringify({ start: 3, end: 8, text: 'ma\ncz' })); + .toBe(JSON.stringify({ start: 3, end: 8, text: 'lo\nwo' })); await page.click(sel.applySetLinkFromSelection); await expect .poll(async () => getTestLinksSerializedHtml(page)) .toContain( - '

siema

' + - '

czesc

' + '

hello

' + + '

world

' ); }); }); From 87a67fcfd193ccc63d9c6f271130e19330c1c86c Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 14 Aug 2026 11:39:35 +0200 Subject: [PATCH 4/6] feat: setLink removes content when passed text is empty --- src/web/formats/EnrichedLink.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/web/formats/EnrichedLink.ts b/src/web/formats/EnrichedLink.ts index f3078b2c1..e47fa2a6d 100644 --- a/src/web/formats/EnrichedLink.ts +++ b/src/web/formats/EnrichedLink.ts @@ -138,9 +138,6 @@ export function setLink( text: string, url: string ) { - if (url.length === 0 || text.length === 0) { - return; - } const { state } = editor; const doc = state.doc; const from = nativePosToTiptapPos(doc, start); @@ -150,6 +147,17 @@ export function setLink( return; } + if (text.length === 0) { + if (from !== to) { + editor.chain().focus().deleteRange({ from, to }).run(); + } + return; + } + + if (url.length === 0) { + return; + } + const linkType = state.schema.marks.link; if (!linkType) return; const linkMark = linkType.create({ href: url }); From ca2432d0fd1986e9f28a605b468dd53a3e62be85 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 14 Aug 2026 11:54:33 +0200 Subject: [PATCH 5/6] feat: temporary test button --- apps/example-web/src/App.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 3a6409919..05dc17b2a 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -333,6 +333,21 @@ function App() { Push Text + + {showHtmlOutput && } From 4307d556274229cd96e6f4c75be614a970650b94 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 14 Aug 2026 13:26:23 +0200 Subject: [PATCH 6/6] docs: update docs --- docs/docs/api-reference/enriched-text-input.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md index 28a896346..a600904f5 100644 --- a/docs/docs/api-reference/enriched-text-input.md +++ b/docs/docs/api-reference/enriched-text-input.md @@ -844,6 +844,12 @@ details. - `text: string` - displayed text of the link - `url: string` - URL of the link +:::note + +Using this method with `text=""` will result in removal of the text marked by the `start` and `end` indexes. + +;;; + ### `.removeLink()` ```ts