diff --git a/demo/tests/visual-tests/playground/Link.visual.test.tsx b/demo/tests/visual-tests/playground/Link.visual.test.tsx index dd3101519..3c0f8c0ba 100644 --- a/demo/tests/visual-tests/playground/Link.visual.test.tsx +++ b/demo/tests/visual-tests/playground/Link.visual.test.tsx @@ -13,6 +13,76 @@ test.describe('Link', () => { await editor.switchMode('wysiwyg'); }); + test('should save a single-character link @wysiwyg', async ({editor, actions, page}) => { + await editor.fill('before a after'); + await editor.press('ControlOrMeta+End'); + await actions.pressFocused('ArrowLeft', ' after'.length); + await actions.pressFocused('Shift+ArrowLeft'); + await expect(page.locator('.playground__pm-selection')).toHaveText( + /TextSelection\s*From: 8\s*To: 9/, + ); + await actions.pressFocused('ControlOrMeta+k'); + + await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible(); + await page.locator('.g-md-link-tooltip-view input').fill('https://example.com'); + await actions.pressFocused('Enter'); + + const link = editor.getBySelectorInContenteditable('a'); + await expect(link).toHaveText('a'); + await expect(link).toHaveAttribute('href', 'https://example.com'); + await expect(page.locator('.g-md-link-tooltip-view')).toBeHidden(); + }); + + test('should save a link with a trailing line break and spaces @wysiwyg', async ({ + editor, + actions, + page, + }) => { + await editor.fill('before word'); + await editor.press('ControlOrMeta+End'); + await editor.press('Shift+Enter'); + await editor.pressSequentially(' after'); + await actions.pressFocused('ArrowLeft', 'after'.length); + await actions.pressFocused('Shift+ArrowLeft', 'word'.length + 1 + 3); + await expect(page.locator('.playground__pm-selection')).toHaveText( + /TextSelection\s*From: 8\s*To: 16/, + ); + await actions.pressFocused('ControlOrMeta+k'); + + await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible(); + await page.locator('.g-md-link-tooltip-view input').fill('https://example.com'); + await actions.pressFocused('Enter'); + + const link = editor.getBySelectorInContenteditable('a').filter({hasText: 'word'}); + await expect(link).toHaveText('word'); + await expect(link).toHaveAttribute('href', 'https://example.com'); + await editor.switchMode('markup'); + await expect( + editor.getByTextInContenteditable('[word](https://example.com)'), + ).toBeVisible(); + }); + + test('should cancel a single-character link without leaving an empty link @wysiwyg', async ({ + editor, + actions, + page, + }) => { + await editor.fill('before a after'); + await editor.press('ControlOrMeta+End'); + await actions.pressFocused('ArrowLeft', ' after'.length); + await actions.pressFocused('Shift+ArrowLeft'); + await expect(page.locator('.playground__pm-selection')).toHaveText( + /TextSelection\s*From: 8\s*To: 9/, + ); + await actions.pressFocused('ControlOrMeta+k'); + await expect(page.locator('.g-md-link-tooltip-view')).toBeVisible(); + await actions.pressFocused('Escape'); + + await expect(page.locator('.g-md-link-tooltip-view')).toBeHidden(); + await expect(editor.getBySelectorInContenteditable('a')).toHaveCount(0); + await expect(editor.getByTextInContenteditable('before a after')).toBeVisible(); + }); + test('should insert via toolbar @wysiwyg', async ({browserName, editor, actions, wait}) => { test.skip(browserName === 'webkit', 'fillFocused does not work correctly in webkit'); diff --git a/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.test.ts b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.test.ts new file mode 100644 index 000000000..c13ea0fbb --- /dev/null +++ b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.test.ts @@ -0,0 +1,166 @@ +import {EditorState, TextSelection} from 'prosemirror-state'; +import {builders} from 'prosemirror-test-builder'; +import {DecorationSet, EditorView} from 'prosemirror-view'; +import {describe, expect, it, vi} from 'vitest'; + +import {ExtensionsManager} from '../../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../../base/specs'; +import {BreakNodeName, BreaksSpecs} from '../../Breaks/BreaksSpecs'; +import {ImageSpecs} from '../../Image/ImageSpecs'; +import {LinkAttr, LinkSpecs, linkMarkName} from '../LinkSpecs'; +import {linkTooltipPlugin} from '../plugins/LinkTooltipPlugin'; + +import {addEmptyLink, linkActionSpec2} from './linkEnhanceActions'; + +const deps = new ExtensionsManager({ + extensions: (builder) => + builder.use(BaseSchemaSpecs, {}).use(LinkSpecs).use(ImageSpecs).use(BreaksSpecs, {}), +}).buildDeps(); +const {schema} = deps; +const {doc, p, link, image, br} = builders<'doc' | 'p' | 'link' | 'image' | 'br'>(schema, { + image: {nodeType: 'image', src: 'image.png'}, + br: {nodeType: BreakNodeName.HardBreak}, + doc: {nodeType: BaseNode.Doc}, + p: {nodeType: BaseNode.Paragraph}, + link: {markType: linkMarkName, [LinkAttr.Href]: 'https://example.com'}, +}); +const tooltip = linkTooltipPlugin(deps); + +function decorations(state: EditorState) { + const result = tooltip.props.decorations?.call(tooltip, state); + return result instanceof DecorationSet ? result.find() : []; +} + +describe('addEmptyLink', () => { + it.each([ + ['two inline images', () => doc(p('before ', image(), image(), ' after'))], + [ + 'an image surrounded by whitespace', + () => doc(p('before ', image(), ' after')), + ], + ] as const)('should keep link creation enabled for %s', (_name, createDocument) => { + const document = createDocument(); + let state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.from, document.tag.to), + }); + + expect(addEmptyLink(state)).toBe(true); + expect( + addEmptyLink(state, (tr) => { + state = state.apply(tr); + }), + ).toBe(true); + + const objects: {pos: number; size: number}[] = []; + state.doc.nodesBetween(document.tag.from, document.tag.to, (node, pos) => { + if (node.isInline && !node.isText) { + expect(schema.marks[linkMarkName].isInSet(node.marks)?.attrs).toMatchObject({ + [LinkAttr.Href]: '', + [LinkAttr.IsPlaceholder]: true, + }); + objects.push({pos, size: node.nodeSize}); + } + }); + expect(objects.length).toBeGreaterThan(0); + const last = objects[objects.length - 1]; + expect(decorations(state)).toEqual([ + expect.objectContaining({from: last.pos, to: last.pos + last.size}), + ]); + }); + + it.each([ + ['a space', () => doc(p('before after'))], + ['whitespace and a line break', () => doc(p('before ', br(), ' \u00a0after'))], + ] as const)('should reject %s without dispatching', (_name, createDocument) => { + const document = createDocument(); + const state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.from, document.tag.to), + }); + const dispatch = vi.fn(); + + expect(addEmptyLink(state)).toBe(false); + expect(addEmptyLink(state, dispatch)).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + const view = new EditorView(null, {state}); + try { + expect(linkActionSpec2(deps).isEnable(state, undefined, view)).toBe(false); + } finally { + view.destroy(); + } + }); + + it.each([ + ['a', 8, 9], + [' a ', 9, 10], + ['word', 8, 12], + [' word ', 9, 13], + ] as const)('should provide a tooltip anchor for selected text %j', (text, from, to) => { + const document = doc(p('before ', text, ' after')); + let state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.from, document.tag.to), + }); + + expect( + addEmptyLink(state, (tr) => { + state = state.apply(tr); + }), + ).toBe(true); + expect(decorations(state)).toEqual([ + expect.objectContaining({ + from, + to, + }), + ]); + }); + + it('should anchor text before a trailing line break and spaces', () => { + const document = doc(p('before word', br(), ' after')); + let state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.from, document.tag.to), + }); + + expect( + addEmptyLink(state, (tr) => { + state = state.apply(tr); + }), + ).toBe(true); + expect(decorations(state)).toEqual([expect.objectContaining({from: 8, to: 12})]); + }); + + it('should anchor a new single-character link next to an existing link', () => { + const document = doc(p(link('existing'), 'a after')); + let state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.from, document.tag.to), + }); + + expect( + addEmptyLink(state, (tr) => { + state = state.apply(tr); + }), + ).toBe(true); + expect(decorations(state)).toEqual([ + expect.objectContaining({from: document.tag.from, to: document.tag.to}), + ]); + }); + + it('should keep a saved link tooltip hidden at its start', () => { + const document = doc(p('before ', link('a'), ' after')); + const state = EditorState.create({ + schema, + doc: document, + selection: TextSelection.create(document, document.tag.cursor), + }); + + expect(decorations(state)).toEqual([]); + }); +}); diff --git a/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts index 51f9c8f41..1a5de2116 100644 --- a/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts +++ b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts @@ -23,6 +23,8 @@ export const addEmptyLink: Command = (state, dispatch) => { const {$from, $to} = selection; // text selection inside one text node if ($from.parent !== $to.parent) return false; + if (selection instanceof TextSelection && isWhitespaceOnlySelection(selection)) return false; + let tr = state.tr; const toggleResult = toggleMark(linkMarkType, { [LinkAttr.Href]: '', @@ -40,7 +42,10 @@ export const addEmptyLink: Command = (state, dispatch) => { tr.setMeta(imageRendererKey, meta); } } else { - const selectedText = state.doc.textBetween($from.pos, $to.pos); + // Keep leaf offsets intact while treating trailing line breaks as whitespace. + const selectedText = state.doc.textBetween($from.pos, $to.pos, '', (node) => + node.type.spec.isBreak ? '\n' : '\uFFFC', + ); const countOfWhitespacesAtEnd = selectedText.length - selectedText.trimEnd().length; tr.setSelection(TextSelection.create(tr.doc, $to.pos - countOfWhitespacesAtEnd - 1)); } @@ -56,3 +61,11 @@ export const linkActionSpec2 = (deps: ExtensionDeps): ActionSpec => ({ isEnable: addLinkCmd2(deps), run: addLinkCmd2(deps), }); + +function isWhitespaceOnlySelection({from, to, $from}: TextSelection): boolean { + // Inline objects count as content; line breaks do not. + const text = $from.doc.textBetween(from, to, '', (node) => + node.type.spec.isBreak ? '' : '\uFFFC', + ); + return !text.trim(); +} diff --git a/packages/editor/src/extensions/markdown/Link/plugins/LinkTooltipPlugin/index.tsx b/packages/editor/src/extensions/markdown/Link/plugins/LinkTooltipPlugin/index.tsx index dc82c26c3..87a489386 100644 --- a/packages/editor/src/extensions/markdown/Link/plugins/LinkTooltipPlugin/index.tsx +++ b/packages/editor/src/extensions/markdown/Link/plugins/LinkTooltipPlugin/index.tsx @@ -19,12 +19,13 @@ const className = 'pm-link-focus-deco'; const placement: PopupPlacement = ['bottom-start', 'bottom-end', 'top-start', 'top-end']; function getTextNode(state: EditorState) { - if ( - !(state.selection instanceof TextSelection) || - !state.selection.empty || - !isMarkActive(state, linkType(state.schema)) - ) - return null; + if (!(state.selection instanceof TextSelection) || !state.selection.empty) return null; + + const markType = linkType(state.schema); + // A one-character placeholder leaves the cursor at the start of a non-inclusive mark. + const nextNode = state.selection.$from.nodeAfter; + const nextLink = nextNode && findMark(nextNode, markType); + if (!isMarkActive(state, markType) && !nextLink?.attrs[LinkAttr.IsPlaceholder]) return null; const parent = findParentNode(Boolean)(state.selection); @@ -32,17 +33,18 @@ function getTextNode(state: EditorState) { return null; } - const textNodes = findChildrenByMark(parent?.node, linkType(state.schema)); + const contentStart = parent.pos + 1; + const textNodes = findChildrenByMark(parent.node, markType); const textNode = textNodes.find((n: {node: Node; pos: number}) => { - const start = n.pos + parent.pos; + const start = n.pos + contentStart; const end = start + n.node.nodeSize; - return start <= state.selection.from && end >= state.selection.from; + return start <= state.selection.from && end > state.selection.from; })!; if (!textNode) return null; - const absolutePos = textNode.pos + parent.pos + 1; + const absolutePos = textNode.pos + contentStart; return { ...textNode,