From c500d37fdbc8c78f630007f142c0bec975f42b17 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 13 May 2026 11:21:37 -0700 Subject: [PATCH] Fix arrow key navigation with footnotes and stored marks (#300) * clear cursor marks when arrow keys hit block boundary inside a mark When the cursor sits at the start or end of a text block and is inside a mark (e.g. bold), the first ArrowLeft/ArrowRight press now clears the stored marks instead of navigating to the adjacent block. A second press moves between blocks as before. This lets users escape an inclusive mark without first typing a character. * step the cursor past footnotes when arrow keys are pressed Footnotes render as empty elements (with the number coming from a CSS ::after pseudo-element), so browser-native arrow navigation can skip past the span without stopping on either side. Detect when $from.nodeBefore/nodeAfter is a footnote and explicitly move the selection across it. * extract skipFootnote and clearStoredMarks helpers ArrowLeft and ArrowRight had two copies each of the footnote-skip and stored-marks-clearing logic. Pull them into small helpers and use state.selection.empty instead of allocating a Slice via content().size. --------- Co-authored-by: Claude --- components/Blocks/TextBlock/keymap.ts | 42 ++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index 3f9c1402..928fe9cb 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -111,9 +111,11 @@ export const TextBlockKeymap = ( ArrowUp: moveCursorUp(propsRef, repRef), "Ctrl-j": moveCursorDown(propsRef, repRef, true), ArrowDown: moveCursorDown(propsRef, repRef), - ArrowLeft: (state, tr, view) => { - if (state.selection.content().size > 0) return false; + ArrowLeft: (state, dispatch, view) => { + if (!state.selection.empty) return false; + if (skipFootnote(state, dispatch, "before")) return true; if (state.selection.anchor > 1) return false; + if (clearStoredMarks(state, dispatch)) return true; let block = propsRef.current.previousBlock; if (block) { view?.dom.blur(); @@ -121,9 +123,11 @@ export const TextBlockKeymap = ( } return true; }, - ArrowRight: (state, tr, view) => { - if (state.selection.content().size > 0) return false; + ArrowRight: (state, dispatch, view) => { + if (!state.selection.empty) return false; + if (skipFootnote(state, dispatch, "after")) return true; if (state.doc.content.size - state.selection.anchor > 1) return false; + if (clearStoredMarks(state, dispatch)) return true; let block = propsRef.current.nextBlock; if (block) { view?.dom.blur(); @@ -153,6 +157,36 @@ export const TextBlockKeymap = ( "Meta-Enter": CtrlEnter(propsRef, repRef), }) as { [key: string]: Command }; +const skipFootnote = ( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + side: "before" | "after", +) => { + let node = + side === "before" + ? state.selection.$from.nodeBefore + : state.selection.$from.nodeAfter; + if (node?.type !== schema.nodes.footnote) return false; + let delta = side === "before" ? -node.nodeSize : node.nodeSize; + if (dispatch) + dispatch( + state.tr.setSelection( + TextSelection.create(state.doc, state.selection.from + delta), + ), + ); + return true; +}; + +const clearStoredMarks = ( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, +) => { + let marks = state.storedMarks ?? state.selection.$from.marks(); + if (marks.length === 0) return false; + if (dispatch) dispatch(state.tr.setStoredMarks([])); + return true; +}; + const moveCursorDown = ( propsRef: PropsRef, -- 2.51.2