Skip to content

Commit b7ce74f

Browse files
temrjanclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 821c638 commit b7ce74f

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {EditorState, TextSelection} from 'prosemirror-state';
2+
import {builders} from 'prosemirror-test-builder';
3+
4+
import {ExtensionsManager} from '../../../../core';
5+
import {BaseNode, BaseSchemaSpecs} from '../../../base/specs';
6+
import {LinkAttr, LinkSpecs, linkMarkName, linkType} from '../LinkSpecs';
7+
8+
import {addEmptyLink} from './linkEnhanceActions';
9+
10+
const {schema} = new ExtensionsManager({
11+
extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(LinkSpecs),
12+
}).buildDeps();
13+
14+
const {doc, p} = builders<'doc' | 'p'>(schema, {
15+
doc: {nodeType: BaseNode.Doc},
16+
p: {nodeType: BaseNode.Paragraph},
17+
});
18+
19+
function createState(content: ReturnType<typeof doc>) {
20+
return EditorState.create({schema, doc: content});
21+
}
22+
23+
describe('addEmptyLink', () => {
24+
it('should add link mark to a single-character selection', () => {
25+
const state = createState(doc(p('a')));
26+
// Select "a" (positions 1-2)
27+
const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 2));
28+
const stateWithSelection = state.apply(tr);
29+
30+
let dispatched: EditorState | undefined;
31+
addEmptyLink(stateWithSelection, (resultTr) => {
32+
dispatched = stateWithSelection.apply(resultTr);
33+
});
34+
35+
expect(dispatched).toBeDefined();
36+
// The link mark should be active after the command
37+
const storedMarks = dispatched!.storedMarks;
38+
const linkMark = storedMarks?.find((m) => m.type === linkType(schema));
39+
expect(linkMark).toBeDefined();
40+
expect(linkMark!.attrs[LinkAttr.IsPlaceholder]).toBe(true);
41+
});
42+
43+
it('should add link mark to a multi-character selection', () => {
44+
const state = createState(doc(p('hello')));
45+
// Select "hello" (positions 1-6)
46+
const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 6));
47+
const stateWithSelection = state.apply(tr);
48+
49+
let dispatched: EditorState | undefined;
50+
addEmptyLink(stateWithSelection, (resultTr) => {
51+
dispatched = stateWithSelection.apply(resultTr);
52+
});
53+
54+
expect(dispatched).toBeDefined();
55+
// Verify link mark is on the text
56+
const textNode = dispatched!.doc.firstChild!.firstChild!;
57+
const linkMark = textNode.marks.find((m) => m.type === linkType(schema));
58+
expect(linkMark).toBeDefined();
59+
});
60+
61+
it('should not activate on empty selection', () => {
62+
const state = createState(doc(p('hello')));
63+
const result = addEmptyLink(state);
64+
expect(result).toBe(false);
65+
});
66+
});

packages/editor/src/extensions/markdown/Link/actions/linkEnhanceActions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,16 @@ export const addEmptyLink: Command = (state, dispatch) => {
4242
} else {
4343
const selectedText = state.doc.textBetween($from.pos, $to.pos);
4444
const countOfWhitespacesAtEnd = selectedText.length - selectedText.trimEnd().length;
45-
tr.setSelection(TextSelection.create(tr.doc, $to.pos - countOfWhitespacesAtEnd - 1));
45+
const pos = $to.pos - countOfWhitespacesAtEnd - 1;
46+
tr.setSelection(TextSelection.create(tr.doc, pos));
47+
// For short selections (e.g. 1 character), the cursor lands at the
48+
// left edge of the non-inclusive link mark, so $from.marks() won't
49+
// include it and the tooltip won't appear. Explicitly store the
50+
// marks from the adjacent text node so isMarkActive() sees the link.
51+
const nodeMarks = tr.doc.resolve(pos).nodeAfter?.marks;
52+
if (nodeMarks) {
53+
tr.setStoredMarks(nodeMarks);
54+
}
4655
}
4756
dispatch?.(tr);
4857
return true;

0 commit comments

Comments
 (0)