diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 0300131ed..0563b9d7a 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,61 @@ test.describe('test-links setLink table', () => { } }); +test.describe('test-links setLink round-trips onChangeSelection text', () => { + test('linking a selection across a block boundary keeps both paragraphs', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml(page, '
hello
world
'); + + 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: 'lo\nwo' })); + + await page.click(sel.applySetLinkFromSelection); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain( + 'hello
' + + 'world
' + ); + }); + + test('linking a selection across a block boundary preserves inline marks', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml( + page, + 'hello
world
' + ); + + 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: 'lo\nwo' })); + + await page.click(sel.applySetLinkFromSelection); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain( + 'hello
' + + 'world
' + ); + }); +}); + test.describe('test-links removeLink table', () => { const cases: { name: string; 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 &&
+ {JSON.stringify(lastSelection)}
+
+
{JSON.stringify(lastOnLinkDetected)}
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
diff --git a/src/web/formats/EnrichedLink.ts b/src/web/formats/EnrichedLink.ts
index 3e0476b8e..e47fa2a6d 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';
@@ -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 });
@@ -162,7 +170,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),