diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 2e7a3000..09d63e10 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -14,6 +14,7 @@ import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' import { focusEditorNormalMode } from './lib/editor-focus' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' +import { shouldBlockGlobalPaletteNavShortcut } from './lib/palette-nav-shortcuts' import { appUpdateNoticeLabel, appUpdatePrimaryActionLabel, @@ -475,6 +476,11 @@ function App(): JSX.Element { } } } + if (shouldBlockGlobalPaletteNavShortcut(e)) { + e.preventDefault() + e.stopPropagation() + return + } if (matchesShortcut(e, overrides, 'global.commandPalette')) { // ⇧⌘P — command palette diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index fb445f1d..a99b9e6b 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -12,7 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { allLeaves, findLeafWithActiveTab, @@ -200,6 +200,7 @@ export function BufferPalette(): JSX.Element { const setActivePane = useStore((s) => s.setActivePane) const focusTabInPane = useStore((s) => s.focusTabInPane) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) // Select primitives separately so each selector returns a stable // reference; compute the derived entries list with useMemo. Returning // a freshly-built array from a single selector would trip zustand's @@ -343,7 +344,7 @@ export function BufferPalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move switch diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index d23bf427..32a559a5 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -12,7 +12,7 @@ import { RECENT_COMMAND_COUNT } from '../lib/command-history' import { rankItems } from '../lib/fuzzy-score' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes' import { buildVaultSwitcherEntries, @@ -36,6 +36,7 @@ export function CommandPalette(): JSX.Element { const remoteWorkspaceProfiles = useStore((s) => s.remoteWorkspaceProfiles) const currentVault = useStore((s) => s.vault) const workspaceMode = useStore((s) => s.workspaceMode) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo) const initialMode = useStore((s) => s.commandPaletteInitialMode) const refreshLocalVaults = useStore((s) => s.refreshLocalVaults) @@ -438,7 +439,7 @@ export function CommandPalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move {' '} diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index d2e1672f..a9f6e853 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -61,6 +61,18 @@ const DEFAULT_VIM_MAPPINGS_TO_CLEAR = [ 'zR' ] +/** + * Mappings that apply in ALL contexts (no `context` property set) and + * conflict with native keybindings. `Vim.unmap(key, 'insert')` can't + * remove them because the vim unmap check (`entry.context === ctx`) + * fails when both are undefined. Pass no ctx so `undefined === undefined` + * matches the context-less entry. + */ +const VIM_MAPPINGS_TO_CLEAR_ALL_CONTEXTS = [ + '', + '' +] + function clearKnownVimMappings(): void { for (const binding of DEFAULT_VIM_MAPPINGS_TO_CLEAR) { try { @@ -69,6 +81,13 @@ function clearKnownVimMappings(): void { /* ignore */ } } + for (const binding of VIM_MAPPINGS_TO_CLEAR_ALL_CONTEXTS) { + try { + Vim.unmap(binding, undefined as unknown as string) + } catch { + /* ignore */ + } + } } function toVimKeyName(base: string): string { diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 205bd75d..c54ebe71 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -60,7 +60,7 @@ import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@code import { headingFolding } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' import { searchKeymap } from '@codemirror/search' -import { autocompletion, completionKeymap } from '@codemirror/autocomplete' +import { autocompletion, completionKeymap, completionStatus, moveCompletionSelection } from '@codemirror/autocomplete' import { useStore } from '../store' import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' @@ -110,6 +110,7 @@ import { } from '../lib/editor-hydration' import { recordRendererPerf } from '../lib/perf' import { rememberTabScroll, recallTabScroll } from '../lib/tab-scroll-memory' +import { isPaletteNavShortcutKey, isPaletteNextKey, isPalettePreviousKey, paletteCompletionKeymaps, paletteNavModeClass } from '../lib/palette-nav' import { parseOutline } from '../lib/outline' import { findRenderedHeadingForOutlineLine, @@ -1298,10 +1299,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { override: [slashCommandSource, dateShortcutSource, wikilinkSource], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, - optionClass: (completion) => - (completion as { _kind?: string })._kind === 'wikilink' - ? 'wikilink-cmd-option' - : 'slash-cmd-option' + optionClass: (completion) => { + if ((completion as { _kind?: string })._kind !== 'wikilink') return 'slash-cmd-option' + return `wikilink-cmd-option ${paletteNavModeClass(useStore.getState().paletteNavKeys)}` + } }), keymap.of([ { @@ -1317,7 +1318,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ...defaultKeymap, ...historyKeymap, ...searchKeymap, - ...completionKeymap + ...completionKeymap, + ...paletteCompletionKeymaps() ]), EditorView.domEventHandlers({ mousedown: (event) => { @@ -1378,6 +1380,23 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return true } } + if (completionStatus(view.state) === 'active') { + if (isPaletteNextKey(event)) { + moveCompletionSelection(true)(view) + event.preventDefault() + return true + } + if (isPalettePreviousKey(event)) { + moveCompletionSelection(false)(view) + event.preventDefault() + return true + } + if (isPaletteNavShortcutKey(event)) { + event.preventDefault() + event.stopPropagation() + return true + } + } if (event.key !== 'Escape') return false if (!state.vimMode) return false const cm = getCM(view) diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index edb86f66..c8e9ba1c 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { parseOutline, type OutlineItem } from '../lib/outline' import { isHelpTabPath } from '@shared/help' import { isArchiveTabPath } from '@shared/archive' @@ -36,6 +36,7 @@ export function OutlinePalette(): JSX.Element { const setOpen = useStore((s) => s.setOutlinePaletteOpen) const selectedPath = useStore((s) => s.selectedPath) const noteContents = useStore((s) => s.noteContents) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) const body = selectedPath && !isVirtualPath(selectedPath) ? noteContents[selectedPath]?.body ?? '' : '' @@ -140,7 +141,7 @@ export function OutlinePalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move jump diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 366bb71b..22cc0bc1 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -40,6 +40,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview' import { headingFolding } from '../lib/cm-heading-fold' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { dateShortcutSource } from '../lib/cm-date-shortcuts' +import { paletteCompletionKeymaps, paletteNavModeClass } from '../lib/palette-nav' import { wikilinkSource } from '../lib/cm-wikilinks' import { classifyLocalAssetHref, type LocalAssetKind } from '../lib/local-assets' import { LazyPreview as Preview } from './LazyPreview' @@ -196,10 +197,10 @@ export function PinnedReferencePane(): JSX.Element | null { override: [slashCommandSource, dateShortcutSource, wikilinkSource], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, - optionClass: (completion) => - (completion as { _kind?: string })._kind === 'wikilink' - ? 'wikilink-cmd-option' - : 'slash-cmd-option' + optionClass: (completion) => { + if ((completion as { _kind?: string })._kind !== 'wikilink') return 'slash-cmd-option' + return `wikilink-cmd-option ${paletteNavModeClass(useStore.getState().paletteNavKeys)}` + } }), keymap.of([ { @@ -215,7 +216,8 @@ export function PinnedReferencePane(): JSX.Element | null { ...defaultKeymap, ...historyKeymap, ...searchKeymap, - ...completionKeymap + ...completionKeymap, + ...paletteCompletionKeymaps() ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index cbf3a8b4..2612563c 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import type { NoteMeta } from '@shared/ipc' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { buildNoteSearchIndex, parseNoteSearchQuery, @@ -14,6 +14,7 @@ export function SearchPalette(): JSX.Element { const notes = useStore((s) => s.notes) const setSearchOpen = useStore((s) => s.setSearchOpen) const selectNote = useStore((s) => s.selectNote) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) const [query, setQuery] = useState('') const [active, setActive] = useState(0) const inputRef = useRef(null) @@ -124,7 +125,7 @@ export function SearchPalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move open diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 005443b5..009e736a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -19,7 +19,7 @@ import { type McpServerRuntime } from '@shared/mcp-clients' import { useStore } from '../store' -import type { LineNumberMode, WhichKeyHintMode } from '../store' +import type { LineNumberMode, PaletteNavKeys, WhichKeyHintMode } from '../store' import type { KeymapDefinition, KeymapId, KeymapOverrides } from '../lib/keymaps' import { formatKeymapBinding, @@ -201,6 +201,8 @@ export function SettingsModal(): JSX.Element { const setVimMode = useStore((s) => s.setVimMode) const vimInsertEscape = useStore((s) => s.vimInsertEscape) const setVimInsertEscape = useStore((s) => s.setVimInsertEscape) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) + const setPaletteNavKeys = useStore((s) => s.setPaletteNavKeys) const keymapOverrides = useStore((s) => s.keymapOverrides) const setKeymapBinding = useStore((s) => s.setKeymapBinding) const resetAllKeymaps = useStore((s) => s.resetAllKeymaps) @@ -855,6 +857,12 @@ export function SettingsModal(): JSX.Element { description: 'Map a key sequence like jk or jj to Escape in insert mode.', keywords: ['vim', 'jk', 'jj', 'escape', 'insert mode', 'esc'] }, + { + id: 'palette-navigation-keys', + title: 'Panel navigation keys', + description: 'Choose whether Ctrl+N/P, Ctrl+J/K, or both move through panels and editor completions.', + keywords: ['palette', 'panel', 'completion', 'ctrl n', 'ctrl p', 'ctrl j', 'ctrl k', 'navigation'] + }, { id: 'leader-key-hints', title: 'Leader key hints', @@ -972,6 +980,18 @@ export function SettingsModal(): JSX.Element { settingId="vim-insert-escape" onChange={(next) => setVimInsertEscape(next ?? '')} /> + setPaletteNavKeys(next as PaletteNavKeys)} + /> 'slash-cmd-option' }), - keymap.of([indentWithTab, ...completionKeymap, ...defaultKeymap, ...historyKeymap]), + keymap.of([indentWithTab, ...completionKeymap, ...paletteCompletionKeymaps(), ...defaultKeymap, ...historyKeymap]), editorTheme, EditorView.updateListener.of((upd) => { if (upd.docChanged) setRaw(upd.state.doc.toString()) diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx index 941a67e3..d4d6f615 100644 --- a/packages/app-core/src/components/TemplatePalette.tsx +++ b/packages/app-core/src/components/TemplatePalette.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { focusEditorNormalMode } from '../lib/editor-focus' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { mergeTemplates } from '@shared/template-files' @@ -21,6 +21,7 @@ export function TemplatePalette(): JSX.Element { const createFromTemplate = useStore((s) => s.createFromTemplate) const customTemplates = useStore((s) => s.customTemplates) const mode = useStore((s) => s.templatePaletteMode) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) const templates = useMemo( () => mergeTemplates(BUILTIN_TEMPLATES, customTemplates), @@ -130,7 +131,7 @@ export function TemplatePalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move create diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index 09531bc3..16d4ecdf 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -6,7 +6,7 @@ import type { } from '@shared/ipc' import { useStore } from '../store' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' -import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isPaletteNextKey, isPalettePreviousKey, paletteNavHintLabel } from '../lib/palette-nav' import { recordRendererPerf } from '../lib/perf' import { focusEditorNormalMode } from '../lib/editor-focus' import { Modal } from './ui/Modal' @@ -145,6 +145,7 @@ export function VaultTextSearchPalette(): JSX.Element { const backend = useStore((s) => s.vaultTextSearchBackend) const ripgrepBinaryPath = useStore((s) => s.ripgrepBinaryPath) const fzfBinaryPath = useStore((s) => s.fzfBinaryPath) + const paletteNavKeys = useStore((s) => s.paletteNavKeys) const [capabilities, setCapabilities] = useState(null) const [query, setQuery] = useState('') const [results, setResults] = useState([]) @@ -466,7 +467,7 @@ export function VaultTextSearchPalette(): JSX.Element {
↑↓{' '} - Ctrl+N/P move + {paletteNavHintLabel(paletteNavKeys)} move open diff --git a/packages/app-core/src/lib/palette-nav-shortcuts.ts b/packages/app-core/src/lib/palette-nav-shortcuts.ts new file mode 100644 index 00000000..67e108b0 --- /dev/null +++ b/packages/app-core/src/lib/palette-nav-shortcuts.ts @@ -0,0 +1,22 @@ +type PaletteKeyboardEvent = Pick< + KeyboardEvent, + 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' +> + +export function isPaletteNavShortcutKey(event: PaletteKeyboardEvent): boolean { + return ( + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + ['n', 'p', 'j', 'k'].includes(event.key.toLowerCase()) + ) +} + +export function shouldBlockGlobalPaletteNavShortcut(event: KeyboardEvent, doc: Document = document): boolean { + if (!isPaletteNavShortcutKey(event)) return false + const active = doc.activeElement + if (!(active instanceof HTMLElement)) return false + if (active.closest('.cm-editor') === null) return false + return doc.querySelector('.cm-tooltip-autocomplete') !== null +} diff --git a/packages/app-core/src/lib/palette-nav.test.ts b/packages/app-core/src/lib/palette-nav.test.ts new file mode 100644 index 00000000..3c72fd42 --- /dev/null +++ b/packages/app-core/src/lib/palette-nav.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isPaletteNavShortcutKey, + isPaletteNextKey, + isPalettePreviousKey, + paletteNavHintLabel, + paletteNavModeClass +} from './palette-nav' +import { useStore } from '../store' +import { shouldBlockGlobalPaletteNavShortcut } from './palette-nav-shortcuts' + +function keyEvent(key: string) { + return { + key, + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + stopPropagation: vi.fn() + } +} + +function domKeyEvent(key: string) { + return new KeyboardEvent('keydown', { key, ctrlKey: true }) +} + +describe('palette navigation keys', () => { + beforeEach(() => { + useStore.setState({ paletteNavKeys: 'both' }) + }) + + it('formats the footer hint for each mode', () => { + expect(paletteNavHintLabel('ctrl-np')).toBe('Ctrl+N/P') + expect(paletteNavHintLabel('ctrl-jk')).toBe('Ctrl+J/K') + expect(paletteNavHintLabel('both')).toBe('Ctrl+N/P or J/K') + }) + + it('formats the CSS mode class for wikilink completion hints', () => { + expect(paletteNavModeClass('ctrl-np')).toBe('palette-nav-ctrl-np') + expect(paletteNavModeClass('ctrl-jk')).toBe('palette-nav-ctrl-jk') + expect(paletteNavModeClass('both')).toBe('palette-nav-both') + }) + + it('recognizes unconfigured completion navigation shortcuts for propagation blocking', () => { + useStore.setState({ paletteNavKeys: 'ctrl-jk' }) + + expect(isPalettePreviousKey(keyEvent('p'))).toBe(false) + expect(isPaletteNavShortcutKey(keyEvent('p'))).toBe(true) + }) + + it('blocks global palette shortcuts only while a CodeMirror autocomplete is active', () => { + const editor = document.createElement('div') + editor.className = 'cm-editor' + editor.tabIndex = -1 + const tooltip = document.createElement('div') + tooltip.className = 'cm-tooltip-autocomplete' + document.body.append(editor, tooltip) + editor.focus() + + expect(shouldBlockGlobalPaletteNavShortcut(domKeyEvent('p'))).toBe(true) + + tooltip.remove() + expect(shouldBlockGlobalPaletteNavShortcut(domKeyEvent('p'))).toBe(false) + + editor.remove() + }) + + it('uses Ctrl+N/P only when configured', () => { + useStore.setState({ paletteNavKeys: 'ctrl-np' }) + + expect(isPaletteNextKey(keyEvent('n'))).toBe(true) + expect(isPalettePreviousKey(keyEvent('p'))).toBe(true) + expect(isPaletteNextKey(keyEvent('j'))).toBe(false) + expect(isPalettePreviousKey(keyEvent('k'))).toBe(false) + }) + + it('uses Ctrl+J/K only when configured', () => { + useStore.setState({ paletteNavKeys: 'ctrl-jk' }) + + expect(isPaletteNextKey(keyEvent('j'))).toBe(true) + expect(isPalettePreviousKey(keyEvent('k'))).toBe(true) + expect(isPaletteNextKey(keyEvent('n'))).toBe(false) + expect(isPalettePreviousKey(keyEvent('p'))).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/palette-nav.ts b/packages/app-core/src/lib/palette-nav.ts index b3314d49..1b425fdd 100644 --- a/packages/app-core/src/lib/palette-nav.ts +++ b/packages/app-core/src/lib/palette-nav.ts @@ -1,17 +1,68 @@ import type { KeyboardEvent } from 'react' +import { completionStatus, moveCompletionSelection } from '@codemirror/autocomplete' +import { useStore, type PaletteNavKeys } from '../store' +import { isPaletteNavShortcutKey } from './palette-nav-shortcuts' -export function isPaletteNextKey(event: KeyboardEvent): boolean { - const key = event.key.toLowerCase() - return ( +type PaletteKeyboardEvent = Pick< + KeyboardEvent | globalThis.KeyboardEvent, + 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' | 'stopPropagation' +> + +export function paletteNavHintLabel(keys: PaletteNavKeys): string { + if (keys === 'ctrl-jk') return 'Ctrl+J/K' + if (keys === 'ctrl-np') return 'Ctrl+N/P' + return 'Ctrl+N/P or J/K' +} + +export function paletteNavModeClass(keys: PaletteNavKeys): string { + return `palette-nav-${keys}` +} + +function paletteNavKeys(): PaletteNavKeys { + return useStore.getState().paletteNavKeys +} + +export { isPaletteNavShortcutKey } from './palette-nav-shortcuts' + +function isPlainCtrlKey(event: PaletteKeyboardEvent, key: string): boolean { + return isPaletteNavShortcutKey(event) && event.key.toLowerCase() === key +} + +export function isPaletteNextKey(event: PaletteKeyboardEvent): boolean { + const keys = paletteNavKeys() + const match = event.key === 'ArrowDown' || - (event.ctrlKey && !event.metaKey && !event.altKey && key === 'n') - ) + ((keys === 'ctrl-np' || keys === 'both') && isPlainCtrlKey(event, 'n')) || + ((keys === 'ctrl-jk' || keys === 'both') && isPlainCtrlKey(event, 'j')) + if (match) event.stopPropagation() + return match } -export function isPalettePreviousKey(event: KeyboardEvent): boolean { - const key = event.key.toLowerCase() - return ( +export function isPalettePreviousKey(event: PaletteKeyboardEvent): boolean { + const keys = paletteNavKeys() + const match = event.key === 'ArrowUp' || - (event.ctrlKey && !event.metaKey && !event.altKey && key === 'p') - ) + ((keys === 'ctrl-np' || keys === 'both') && isPlainCtrlKey(event, 'p')) || + ((keys === 'ctrl-jk' || keys === 'both') && isPlainCtrlKey(event, 'k')) + if (match) event.stopPropagation() + return match +} + +function completionMove(forward: boolean, key: PaletteNavKeys) { + return (view: import('@codemirror/view').EditorView): boolean => { + const keys = paletteNavKeys() + const active = completionStatus(view.state) === 'active' + if (keys !== key && keys !== 'both') return active + if (!active) return false + return moveCompletionSelection(forward)(view) + } +} + +export function paletteCompletionKeymaps(): { key: string; run: (view: import('@codemirror/view').EditorView) => boolean }[] { + return [ + { key: 'Ctrl-n', run: completionMove(true, 'ctrl-np') }, + { key: 'Ctrl-p', run: completionMove(false, 'ctrl-np') }, + { key: 'Ctrl-j', run: completionMove(true, 'ctrl-jk') }, + { key: 'Ctrl-k', run: completionMove(false, 'ctrl-jk') } + ] } diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index a77c9a50..4c0e43fe 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -127,6 +127,7 @@ export type NoteSortOrder = export type LineNumberMode = 'off' | 'absolute' | 'relative' export type WhichKeyHintMode = 'timed' | 'sticky' +export type PaletteNavKeys = 'ctrl-np' | 'ctrl-jk' | 'both' export type CommandPaletteInitialMode = 'main' | 'vault' const PREFS_KEY = 'zen:prefs:v2' @@ -153,6 +154,7 @@ const VALID_SORTS: NoteSortOrder[] = [ ] const VALID_LINE_NUMBER_MODES: LineNumberMode[] = ['off', 'absolute', 'relative'] const VALID_WHICH_KEY_HINT_MODES: WhichKeyHintMode[] = ['timed', 'sticky'] +const VALID_PALETTE_NAV_KEYS: PaletteNavKeys[] = ['ctrl-np', 'ctrl-jk', 'both'] const VALID_VAULT_TEXT_SEARCH_BACKENDS: VaultTextSearchBackendPreference[] = [ 'auto', 'builtin', @@ -258,6 +260,8 @@ interface Prefs { /** Key sequence that exits insert mode (maps to ), e.g. "jk". * Empty disables it. */ vimInsertEscape: string + /** Which keys navigate palette results and editor autocompletions. */ + paletteNavKeys: PaletteNavKeys keymapOverrides: KeymapOverrides /** When true, pressing the leader key shows the next available Vim-style actions. */ whichKeyHints: boolean @@ -403,6 +407,7 @@ function normalizeKanbanColumnTitles(raw: unknown): Record { const DEFAULT_PREFS: Prefs = { vimMode: true, vimInsertEscape: '', + paletteNavKeys: 'both', keymapOverrides: {}, whichKeyHints: true, whichKeyHintMode: 'timed', @@ -480,6 +485,10 @@ function normalizePrefs(p: Partial): Prefs { typeof p.vimInsertEscape === 'string' ? p.vimInsertEscape.trim().slice(0, 5) : DEFAULT_PREFS.vimInsertEscape, + paletteNavKeys: + p.paletteNavKeys && VALID_PALETTE_NAV_KEYS.includes(p.paletteNavKeys) + ? p.paletteNavKeys + : DEFAULT_PREFS.paletteNavKeys, keymapOverrides: normalizeKeymapOverrides(p.keymapOverrides), whichKeyHints: typeof p.whichKeyHints === 'boolean' @@ -1021,65 +1030,11 @@ async function rewriteTagAcrossVault( } /** Snapshot prefs-shaped fields out of the live store. */ -function collectPrefs(s: { - vimMode: boolean - vimInsertEscape: string - keymapOverrides: KeymapOverrides - whichKeyHints: boolean - whichKeyHintMode: WhichKeyHintMode - whichKeyHintTimeoutMs: number - vaultTextSearchBackend: VaultTextSearchBackendPreference - ripgrepBinaryPath: string | null - fzfBinaryPath: string | null - livePreview: boolean - tabsEnabled: boolean - wrapTabs: boolean - themeId: string - themeFamily: ThemeFamily - themeMode: ThemeMode - editorFontSize: number - editorLineHeight: number - previewMaxWidth: number - lineNumberMode: LineNumberMode - interfaceFont: string | null - textFont: string | null - monoFont: string | null - systemFolderLabels: SystemFolderLabels - sidebarWidth: number - noteListWidth: number - noteSortOrder: NoteSortOrder - groupByKind: boolean - autoReveal: boolean - unifiedSidebar: boolean - darkSidebar: boolean - showSidebarChevrons: boolean - collapsedFolders: string[] - pinnedRefPath: string | null - pinnedRefVisible: boolean - pinnedRefWidth: number - panelWidths: PanelWidths - pinnedRefMode: 'edit' | 'preview' - quickNoteDateTitle: boolean - quickNoteTitlePrefix: string | null - wordWrap: boolean - previewSmoothScroll: boolean - editorMaxWidth: number - pdfEmbedInEditMode: 'compact' | 'full' - pinnedRefKind: 'note' | 'asset' - noteRefs: Record - contentAlign: 'center' | 'left' - tagsCollapsed: boolean - autoCalendarPanel: boolean - calendarWeekStart: CalendarWeekStart - calendarShowWeekNumbers: boolean - tasksViewMode: TasksViewMode - kanbanGroupBy: KanbanGroupBy - kanbanColumnTitles: Record - hasCompletedOnboarding: boolean -}): Prefs { +function collectPrefs(s: Prefs): Prefs { return { vimMode: s.vimMode, vimInsertEscape: s.vimInsertEscape, + paletteNavKeys: s.paletteNavKeys, keymapOverrides: s.keymapOverrides, whichKeyHints: s.whichKeyHints, whichKeyHintMode: s.whichKeyHintMode, @@ -1429,6 +1384,8 @@ interface Store { vimMode: boolean /** Key sequence that exits insert mode (maps to ), e.g. "jk". Persisted. */ vimInsertEscape: string + /** Which keys navigate palette results and editor autocompletions. Persisted. */ + paletteNavKeys: PaletteNavKeys keymapOverrides: KeymapOverrides whichKeyHints: boolean whichKeyHintMode: WhichKeyHintMode @@ -1706,6 +1663,7 @@ interface Store { setFocusMode: (focus: boolean) => void setVimMode: (on: boolean) => void setVimInsertEscape: (sequence: string) => void + setPaletteNavKeys: (keys: PaletteNavKeys) => void setKeymapBinding: (id: KeymapId, binding: string | null) => void resetAllKeymaps: () => void setWhichKeyHints: (on: boolean) => void @@ -2631,6 +2589,7 @@ export const useStore = create((set, get) => { zenRestoreState: null, vimMode: loadPrefs().vimMode, vimInsertEscape: loadPrefs().vimInsertEscape, + paletteNavKeys: loadPrefs().paletteNavKeys, keymapOverrides: loadPrefs().keymapOverrides, whichKeyHints: loadPrefs().whichKeyHints, whichKeyHintMode: loadPrefs().whichKeyHintMode, @@ -3959,6 +3918,11 @@ export const useStore = create((set, get) => { set({ vimInsertEscape: sequence.trim().slice(0, 5) }) savePrefs(collectPrefs(get())) }, + setPaletteNavKeys: (keys) => { + if (!VALID_PALETTE_NAV_KEYS.includes(keys)) return + set({ paletteNavKeys: keys }) + savePrefs(collectPrefs(get())) + }, setKeymapBinding: (id, binding) => { set((s) => { const nextOverrides = { ...s.keymapOverrides } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 252aff54..7319b92e 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3075,8 +3075,28 @@ textarea, max-height: 320px !important; } -.cm-tooltip-autocomplete:has(.wikilink-cmd-option)::after { - content: "Type | to change display text · Use /path/to/note for exact links"; +.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-ctrl-np)::after { + content: "Ctrl+N/P move · Type | to change display text · Use /path/to/note for exact links"; + display: block; + padding: 10px 12px 8px; + border-top: 1px solid rgb(var(--z-bg-3) / 0.7); + font-size: 12px; + color: rgb(var(--z-fg-2)); + text-align: center; +} + +.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-ctrl-jk)::after { + content: "Ctrl+J/K move · Type | to change display text · Use /path/to/note for exact links"; + display: block; + padding: 10px 12px 8px; + border-top: 1px solid rgb(var(--z-bg-3) / 0.7); + font-size: 12px; + color: rgb(var(--z-fg-2)); + text-align: center; +} + +.cm-tooltip-autocomplete:has(.wikilink-cmd-option.palette-nav-both)::after { + content: "Ctrl+N/P or J/K move · Type | to change display text · Use /path/to/note for exact links"; display: block; padding: 10px 12px 8px; border-top: 1px solid rgb(var(--z-bg-3) / 0.7);