From b7ce74ff221796cd6ba0eaa404e6e6580bcb4199 Mon Sep 17 00:00:00 2001 From: Temrjan Date: Wed, 25 Mar 2026 10:39:05 +0500 Subject: [PATCH] fix(Link): show link popup for single-character selections When adding a link to a 1-character selection, the cursor was placed at the left edge of the non-inclusive link mark. Because the mark has `inclusive: false`, `$from.marks()` at the boundary did not include the link mark, so `isMarkActive()` returned false and the tooltip was never shown. Fix: set `storedMarks` from the adjacent text node after positioning the cursor, ensuring the link mark is active regardless of position. Closes #789 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Link/actions/linkEnhanceActions.test.ts | 66 +++++++++++++++++++ .../Link/actions/linkEnhanceActions.ts | 11 +++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.test.ts 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..1daa710a9 --- /dev/null +++ b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.test.ts @@ -0,0 +1,66 @@ +import {EditorState, TextSelection} from 'prosemirror-state'; +import {builders} from 'prosemirror-test-builder'; + +import {ExtensionsManager} from '../../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../../base/specs'; +import {LinkAttr, LinkSpecs, linkMarkName, linkType} from '../LinkSpecs'; + +import {addEmptyLink} from './linkEnhanceActions'; + +const {schema} = new ExtensionsManager({ + extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(LinkSpecs), +}).buildDeps(); + +const {doc, p} = builders<'doc' | 'p'>(schema, { + doc: {nodeType: BaseNode.Doc}, + p: {nodeType: BaseNode.Paragraph}, +}); + +function createState(content: ReturnType) { + return EditorState.create({schema, doc: content}); +} + +describe('addEmptyLink', () => { + it('should add link mark to a single-character selection', () => { + const state = createState(doc(p('a'))); + // Select "a" (positions 1-2) + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 2)); + const stateWithSelection = state.apply(tr); + + let dispatched: EditorState | undefined; + addEmptyLink(stateWithSelection, (resultTr) => { + dispatched = stateWithSelection.apply(resultTr); + }); + + expect(dispatched).toBeDefined(); + // The link mark should be active after the command + const storedMarks = dispatched!.storedMarks; + const linkMark = storedMarks?.find((m) => m.type === linkType(schema)); + expect(linkMark).toBeDefined(); + expect(linkMark!.attrs[LinkAttr.IsPlaceholder]).toBe(true); + }); + + it('should add link mark to a multi-character selection', () => { + const state = createState(doc(p('hello'))); + // Select "hello" (positions 1-6) + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 6)); + const stateWithSelection = state.apply(tr); + + let dispatched: EditorState | undefined; + addEmptyLink(stateWithSelection, (resultTr) => { + dispatched = stateWithSelection.apply(resultTr); + }); + + expect(dispatched).toBeDefined(); + // Verify link mark is on the text + const textNode = dispatched!.doc.firstChild!.firstChild!; + const linkMark = textNode.marks.find((m) => m.type === linkType(schema)); + expect(linkMark).toBeDefined(); + }); + + it('should not activate on empty selection', () => { + const state = createState(doc(p('hello'))); + const result = addEmptyLink(state); + expect(result).toBe(false); + }); +}); diff --git a/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts index 51f9c8f41..0af1ef802 100644 --- a/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts +++ b/packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts @@ -42,7 +42,16 @@ export const addEmptyLink: Command = (state, dispatch) => { } else { const selectedText = state.doc.textBetween($from.pos, $to.pos); const countOfWhitespacesAtEnd = selectedText.length - selectedText.trimEnd().length; - tr.setSelection(TextSelection.create(tr.doc, $to.pos - countOfWhitespacesAtEnd - 1)); + const pos = $to.pos - countOfWhitespacesAtEnd - 1; + tr.setSelection(TextSelection.create(tr.doc, pos)); + // For short selections (e.g. 1 character), the cursor lands at the + // left edge of the non-inclusive link mark, so $from.marks() won't + // include it and the tooltip won't appear. Explicitly store the + // marks from the adjacent text node so isMarkActive() sees the link. + const nodeMarks = tr.doc.resolve(pos).nodeAfter?.marks; + if (nodeMarks) { + tr.setStoredMarks(nodeMarks); + } } dispatch?.(tr); return true;