Skip to content

Commit 492d9f2

Browse files
Separate link handlers and expand selection
Add a mousedown handler to intercept link hits early (preventDefault/stopPropagation) and move cursor-placement logic to click only when the selection is empty, avoiding interference with drag-selection and focus behavior. Introduce expandSelectionOverHiddenSyntax which uses the syntax tree to grow non-empty selection ranges to include hidden link/wiki markers before deferred decoration rebuilds, and wire it into the queued rebuild flow. Also add an early-return in note-editor click handling when a selection exists. These changes fix cursor placement and selection mapping issues around collapsed link syntax.
1 parent a523326 commit 492d9f2

3 files changed

Lines changed: 106 additions & 21 deletions

File tree

app/src/features/editor/extensions/markdown-decorations/builders/links.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -655,10 +655,29 @@ async function openWikiLink(
655655
export function linkInteractions(noteId: string | null): Extension {
656656
return EditorView.domEventHandlers({
657657
mousedown(event, view) {
658+
if (!shouldOpenLink(event)) {
659+
return false;
660+
}
661+
662+
const hit = getLinkHitFromEvent(view, event);
663+
if (!hit) {
664+
return false;
665+
}
666+
667+
event.preventDefault();
668+
event.stopPropagation();
669+
return true;
670+
},
671+
click(event, view) {
658672
// When clicking to the right of a collapsed link, CM would place
659-
// the cursor before the hidden closing syntax. Intercept and place it
660-
// after the full link token instead.
661-
if (event.button === 0 && !event.shiftKey) {
673+
// the cursor before the hidden closing syntax. Correct it to after
674+
// the full link token. Handled on click (not mousedown) so that
675+
// drag-select starting at a link boundary still works.
676+
if (
677+
event.button === 0 &&
678+
!event.shiftKey &&
679+
view.state.selection.main.empty
680+
) {
662681
const contentRect = view.contentDOM.getBoundingClientRect();
663682
if (
664683
event.clientX >= contentRect.left &&
@@ -671,32 +690,16 @@ export function linkInteractions(noteId: string | null): Extension {
671690
if (pos != null) {
672691
const linkEnd = getLinkEndAtCursor(view.state, pos);
673692
if (linkEnd != null) {
674-
event.preventDefault();
675693
view.dispatch({
676694
selection: EditorSelection.cursor(linkEnd),
677695
scrollIntoView: false,
678696
});
679-
view.focus();
680697
return true;
681698
}
682699
}
683700
}
684701
}
685702

686-
if (!shouldOpenLink(event)) {
687-
return false;
688-
}
689-
690-
const hit = getLinkHitFromEvent(view, event);
691-
if (!hit) {
692-
return false;
693-
}
694-
695-
event.preventDefault();
696-
event.stopPropagation();
697-
return true;
698-
},
699-
click(event, view) {
700703
if (!shouldOpenLink(event) || !view.state.selection.main.empty) {
701704
return false;
702705
}

app/src/features/editor/extensions/markdown-decorations/plugin.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { RangeSetBuilder } from "@codemirror/state";
1+
import { EditorSelection, RangeSetBuilder } from "@codemirror/state";
22
import { syntaxTree } from "@codemirror/language";
3+
import type { SyntaxNode } from "@lezer/common";
34
import {
45
type DecorationSet,
56
Decoration,
@@ -212,14 +213,91 @@ function buildDecorations(
212213
};
213214
}
214215

216+
/**
217+
* After a mouse-drag with frozen decorations, the selection was mapped
218+
* using the collapsed layout. Hidden syntax (the `[` of `[text](url)`,
219+
* `[[`/`]]` of wikilinks) had zero visual width so the selection
220+
* boundary landed just inside the visible content. Expand each range
221+
* to include the hidden syntax at its edges before the deferred
222+
* decoration rebuild reveals them.
223+
*/
224+
function expandSelectionOverHiddenSyntax(
225+
view: EditorView,
226+
): EditorSelection | null {
227+
const { state } = view;
228+
const tree = syntaxTree(state);
229+
let changed = false;
230+
231+
const ranges = state.selection.ranges.map((range) => {
232+
if (range.empty) return range;
233+
234+
let { from, to } = range;
235+
236+
// Expand 'from' to include hidden opening syntax
237+
for (
238+
let n: SyntaxNode | null = tree.resolveInner(from, 1);
239+
n;
240+
n = n.parent
241+
) {
242+
if (n.name === "Link") {
243+
const marks = n.getChildren("LinkMark");
244+
if (marks.length >= 2 && from > n.from && from <= marks[0].to) {
245+
from = n.from;
246+
changed = true;
247+
}
248+
break;
249+
}
250+
if (n.name === "WikiLink" && from > n.from && from <= n.from + 2) {
251+
from = n.from;
252+
changed = true;
253+
break;
254+
}
255+
}
256+
257+
// Expand 'to' to include hidden closing syntax
258+
for (
259+
let n: SyntaxNode | null = tree.resolveInner(to, -1);
260+
n;
261+
n = n.parent
262+
) {
263+
if (n.name === "Link") {
264+
const marks = n.getChildren("LinkMark");
265+
if (marks.length >= 2 && to < n.to && to >= marks[1].from) {
266+
to = n.to;
267+
changed = true;
268+
}
269+
break;
270+
}
271+
if (n.name === "WikiLink" && to < n.to && to >= n.to - 2) {
272+
to = n.to;
273+
changed = true;
274+
break;
275+
}
276+
}
277+
278+
if (from !== range.from || to !== range.to) {
279+
return range.anchor <= range.head
280+
? EditorSelection.range(from, to)
281+
: EditorSelection.range(to, from);
282+
}
283+
284+
return range;
285+
});
286+
287+
return changed
288+
? EditorSelection.create(ranges, state.selection.mainIndex)
289+
: null;
290+
}
291+
215292
function queueDeferredSelectionRebuild(
216293
view: EditorView,
217294
pluginState: { pendingSelectionRebuild: boolean },
218295
) {
219296
setTimeout(() => {
220297
requestAnimationFrame(() => {
221298
if (pluginState.pendingSelectionRebuild) {
222-
view.dispatch({});
299+
const adjusted = expandSelectionOverHiddenSyntax(view);
300+
view.dispatch(adjusted ? { selection: adjusted } : {});
223301
}
224302
});
225303
}, 0);

app/src/features/editor/note-editor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,10 @@ export const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
481481
return false;
482482
},
483483
click(event, view) {
484+
if (!view.state.selection.main.empty) {
485+
return false;
486+
}
487+
484488
const lineStart = getLeadingPaddingClickLineStart(view, event);
485489
if (lineStart == null) {
486490
return false;

0 commit comments

Comments
 (0)