From 78be031948f277433e4b8138b4df5f6ad73b9d82 Mon Sep 17 00:00:00 2001 From: Ardit Zubaku Date: Mon, 31 Aug 2026 00:31:07 +0200 Subject: [PATCH 1/4] Refactor(editor): expose the completion keymap as a highest-precedence extension --- packages/app-core/src/lib/cm-completion-nav.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/lib/cm-completion-nav.ts b/packages/app-core/src/lib/cm-completion-nav.ts index 853e48b9..b4f56625 100644 --- a/packages/app-core/src/lib/cm-completion-nav.ts +++ b/packages/app-core/src/lib/cm-completion-nav.ts @@ -6,7 +6,7 @@ import { selectedCompletion } from '@codemirror/autocomplete' import { Prec } from '@codemirror/state' -import { EditorView, type KeyBinding } from '@codemirror/view' +import { EditorView, keymap, type KeyBinding } from '@codemirror/view' /** * macOS AltGr-style keyboard layouts (custom Ukelele `.keylayout` files, a @@ -26,6 +26,16 @@ export const completionKeymapForEditor: readonly KeyBinding[] = completionKeymap (binding) => !(typeof binding.mac === 'string' && MAC_TEXT_ENTRY_CHORDS.has(binding.mac)) ) +/** + * Mount this instead of spreading the bindings into an editor's general + * `keymap.of([...])`, where they lose to anything listed earlier — the arrows + * then move the caret, which closes the popup. `Prec.highest` is what + * `@codemirror/autocomplete` gives its own keymap. + */ +export const completionKeymapExtension = Prec.highest( + keymap.of([...completionKeymapForEditor]) +) + /** * Direction a Ctrl-based chord should move the autocomplete selection, * or `null` when the event isn't one of our nav chords. From 0577241766ed7b7ef151d3574394246a1207b0bc Mon Sep 17 00:00:00 2001 From: Ardit Zubaku Date: Mon, 31 Aug 2026 00:31:07 +0200 Subject: [PATCH 2/4] Fix(editor): arrow keys navigate the @, / and [[ menus again --- packages/app-core/src/components/EditorPane.tsx | 6 +++--- packages/app-core/src/components/PinnedReferencePane.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7bbc1a04..e89a8284 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -62,7 +62,7 @@ import { forwardOnCheckboxArrow } from '../lib/cm-forward-task' import { markerHopCommands } from '../lib/cm-marker-hop' import { isInMarkdownCode } from '../lib/cm-auto-pairs' import { toggleCheckbox } from '../lib/cm-toggle-checkbox' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' import { isVimAwaitingArgument } from '../lib/vim-nav' import { toCodeMirrorKey, vimHalfPageKeymap } from '../lib/vim-half-page-keymap' @@ -427,8 +427,7 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens indentWithTab, ...vimAwareDefaultKeymap(vimMode), ...historyKeymap, - ...searchKeymap, - ...completionKeymapForEditor + ...searchKeymap ]) } @@ -1834,6 +1833,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } }), completionNavKeymap, + completionKeymapExtension, editorKeymapCompartment.of(buildEditorKeymap(s0.vimMode, s0.keymapOverrides)), EditorView.domEventHandlers({ mousedown: (event, view) => { diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index b7a71c07..9654c1d5 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -56,7 +56,7 @@ import { } from '../lib/cm-wikilinks' import { hashtagSource } from '../lib/cm-hashtag-complete' import { frontmatterTagSource } from '../lib/cm-frontmatter-tag-complete' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { classifyLocalAssetHref, hrefFragment, type LocalAssetKind } from '../lib/local-assets' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PanelLeftIcon, PinIcon } from './icons' @@ -262,6 +262,7 @@ export function PinnedReferencePane(): JSX.Element | null { } }), completionNavKeymap, + completionKeymapExtension, keymap.of([ { key: 'Mod-f', @@ -275,8 +276,7 @@ export function PinnedReferencePane(): JSX.Element | null { indentWithTab, ...vimAwareDefaultKeymap(s0.vimMode), ...historyKeymap, - ...searchKeymap, - ...completionKeymapForEditor + ...searchKeymap ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return From 5ab7a93aaacacf7cfd523331c7a3dccb7b09b234 Mon Sep 17 00:00:00 2001 From: Ardit Zubaku Date: Mon, 31 Aug 2026 00:31:07 +0200 Subject: [PATCH 3/4] Refactor(editor): Quick Note and the template editor mount the shared completion keymap --- packages/app-core/src/components/QuickCaptureApp.tsx | 4 ++-- packages/app-core/src/components/TemplateEditorModal.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 2051dca4..51418278 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -61,7 +61,7 @@ import { closeCompletion, completionStatus } from '@codemirror/autocomplete' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { slashCommandRender, templateSlashCommandSource } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' import type { NoteMeta } from '@shared/ipc' @@ -500,6 +500,7 @@ export function QuickCaptureApp(): JSX.Element { : 'slash-cmd-option' }), completionNavKeymap, + completionKeymapExtension, // Esc closes an open slash menu instead of bubbling to the window-level // Esc that saves + hides the capture window. Runs before everything, // and only when a completion is actually open. @@ -517,7 +518,6 @@ export function QuickCaptureApp(): JSX.Element { ), keymap.of([ indentWithTab, - ...completionKeymapForEditor, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, ...searchKeymap diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 2684ceb1..3fdd7144 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -29,7 +29,7 @@ import { editorTabSize } from '../lib/editor-tab-size' import { templateVariableSource, TEMPLATE_VARIABLES } from '../lib/cm-template-variables' import { templateSlashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' -import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -158,9 +158,9 @@ export function TemplateEditorModal({ : 'slash-cmd-option' }), completionNavKeymap, + completionKeymapExtension, keymap.of([ indentWithTab, - ...completionKeymapForEditor, ...vimAwareDefaultKeymap(vimModeRef.current), ...historyKeymap ]), From 105bf734eebf6621dd8f35e1eb9e5fb881044990 Mon Sep 17 00:00:00 2001 From: Ardit Zubaku Date: Mon, 31 Aug 2026 00:31:07 +0200 Subject: [PATCH 4/4] Test(editor): arrow keys move the highlighted completion, not the caret --- .../src/lib/cm-completion-nav-arrows.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/app-core/src/lib/cm-completion-nav-arrows.test.ts diff --git a/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts b/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts new file mode 100644 index 00000000..ca9d6ab9 --- /dev/null +++ b/packages/app-core/src/lib/cm-completion-nav-arrows.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { + autocompletion, + currentCompletions, + selectedCompletionIndex, + startCompletion, + type CompletionContext, + type CompletionResult +} from '@codemirror/autocomplete' +import { defaultKeymap } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { completionKeymapExtension, completionNavKeymap } from './cm-completion-nav' + +/** Stands in for the `@` date/note sources: three options, no filtering. */ +function source(context: CompletionContext): CompletionResult | null { + const match = context.matchBefore(/@\w*/) + if (!match) return null + return { + from: match.from + 1, + options: [{ label: 'Today' }, { label: 'Tomorrow' }, { label: 'Yesterday' }], + filter: false + } +} + +function mount(): EditorView { + return new EditorView({ + state: EditorState.create({ + doc: 'line one\nline two\n@\nline four', + selection: { anchor: 19 }, + extensions: [ + autocompletion({ defaultKeymap: false, override: [source] }), + completionNavKeymap, + completionKeymapExtension, + keymap.of([...defaultKeymap]) + ] + }), + parent: document.body + }) +} + +function press(view: EditorView, key: string): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ) +} + +/** Past `interactionDelay` (75ms), before which the popup ignores navigation. */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 250)) + +describe('completion arrow navigation', () => { + it('moves the highlighted option instead of the caret', async () => { + const view = mount() + startCompletion(view) + await settle() + expect(currentCompletions(view.state).length).toBe(3) + expect(selectedCompletionIndex(view.state)).toBe(0) + const caret = view.state.selection.main.head + + press(view, 'ArrowDown') + expect(selectedCompletionIndex(view.state)).toBe(1) + press(view, 'ArrowDown') + expect(selectedCompletionIndex(view.state)).toBe(2) + press(view, 'ArrowUp') + expect(selectedCompletionIndex(view.state)).toBe(1) + + // Mounted below `defaultKeymap` instead, its ArrowUp/ArrowDown caret motions + // win and the caret move closes the menu — the regression this guards. + expect(currentCompletions(view.state).length).toBe(3) + expect(view.state.selection.main.head).toBe(caret) + + view.destroy() + }) + + it('lets the arrows through when no completion is open', () => { + let reached = 0 + const view = new EditorView({ + state: EditorState.create({ + doc: 'line one', + extensions: [ + autocompletion({ defaultKeymap: false, override: [source] }), + completionNavKeymap, + completionKeymapExtension, + keymap.of([ + { + key: 'ArrowDown', + run: () => { + reached += 1 + return true + } + } + ]) + ] + }), + parent: document.body + }) + + press(view, 'ArrowDown') + expect(reached).toBe(1) + + view.destroy() + }) +})