diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ce234..3aeb84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Markdown Editor Optimized (MEO) --- +## Unreleased +- Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` (whitelist commands + `passthrough`) + ## 0.1.26 - Improved dark Mermaid diagram line contrast - Fixed live find matches and preserve active highlight diff --git a/README.md b/README.md index f072e05..ede9a20 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An optimized markdown editor with live editing mode for VS Code. ### Writing & Editing - **Live/Source modes** - Switch between clean writing and raw markdown in a single tab +- **Configurable keymap** - Override CodeMirror chords from settings (`passthrough` releases keys to VS Code) - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Spellcheck** - Fix issues with built-in spelling suggestions diff --git a/package.json b/package.json index c504a16..8b2e7a3 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,29 @@ "order": 6, "description": "Controls Vim emulation in Source and Live mode." }, + "markdownEditorOptimized.keymap": { + "type": "array", + "default": [], + "order": 6, + "markdownDescription": "Override MEO's CodeMirror key bindings. Each entry maps a key chord to a whitelisted command. Use `passthrough` to stop MEO from handling a key so VS Code `keybindings.json` can receive it (e.g. release Ctrl+Shift+F for Find in Files).\n\n**Key format:** `alt+up`, `Mod-Shift-f`, `Ctrl-Enter`. `Mod` means Cmd on macOS and Ctrl on Windows/Linux; `Cmd` and `Ctrl` stay platform-specific. Arrow aliases: `up`/`down`/`left`/`right`.\n\n**Commands (subset):** `passthrough`, `undo`, `redo`, `cursorLineUp`, `cursorLineDown`, `moveLineUp`, `moveLineDown`, `deleteLine`, `indentMore`, `indentLess`, `selectAll`, `foldCode`, `unfoldCode`, `toggleFold`, `foldAll`, `unfoldAll`, `toggleHeadingCollapse`, `openFind`, `openReplace`, `toggleMode`.", + "items": { + "type": "object", + "required": [ + "key", + "command" + ], + "properties": { + "key": { + "type": "string", + "description": "Key chord, e.g. alt+up or Mod-Shift-f." + }, + "command": { + "type": "string", + "description": "Whitelisted command name, or passthrough." + } + } + } + }, "markdownEditorOptimized.codeBlocks.useVscodeTheme": { "type": "boolean", "default": false, diff --git a/src/extension.ts b/src/extension.ts index 327ae98..efa97fe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,6 +58,8 @@ import { getVimKeybindings, getVimLeaderKey, getVimModeEnabled, + getKeymapBindings, + KEYMAP_SETTING_KEY, isMarkdownDocumentPath, migrateLegacyToggleSettings, resetThemeSettingsToDefault @@ -565,6 +567,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { this.broadcast({ type: 'vimModeChanged', enabled: getVimModeEnabled(this.context) }); } + if (event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${KEYMAP_SETTING_KEY}`)) { + this.broadcast({ type: 'keymapChanged', keymap: getKeymapBindings() }); + } + if ( event.affectsConfiguration('vim.normalModeKeyBindings') || event.affectsConfiguration('vim.normalModeKeyBindingsNonRecursive') || diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 08f2f6e..d995ba2 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -19,10 +19,12 @@ import { getVimKeybindings, getVimLeaderKey, getVimModeEnabled, + getKeymapBindings, getUseVscodeThemeForCodeBlocks, getCodeBlockVscodeTheme, type VimKeybinding } from '../shared/extensionConfig'; +import type { NormalizedKeymapBinding } from '../shared/keymapConfig'; import { openLink, resolveLocalLinkTargets, resolveWebviewImageSrc, resolveWikiLinkTargets } from '../shared/documentLinks'; import { GitDocumentState, hashGitBaselinePayload } from '../git/documentState'; import { openGitRevisionForLine, openGitWorktreeForLine, resolveGitBlameForRequest } from '../git/blameActions'; @@ -60,6 +62,7 @@ type InitMessage = { vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; + keymap: NormalizedKeymapBinding[]; findOptions: FindOptions; outlinePosition: OutlinePosition; outlineVisible: boolean; @@ -553,6 +556,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam vimMode: getVimModeEnabled(context), vimKeybindings: getVimKeybindings(), vimLeader: getVimLeaderKey(), + keymap: getKeymapBindings(), findOptions: getFindOptions(), outlinePosition: getOutlinePosition(), outlineVisible: getOutlineVisible(context), diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index f0c1f95..fb79bb9 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -1,4 +1,9 @@ import * as vscode from 'vscode'; +import { + KEYMAP_SETTING_KEY, + parseKeymapBindings, + type NormalizedKeymapBinding +} from './keymapConfig'; import { defaultThemeSettings, resolveTheme, @@ -75,6 +80,13 @@ export function getSpellCheckEnabled(): boolean { return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(SPELL_CHECK_SETTING_KEY, true); } +export function getKeymapBindings(): NormalizedKeymapBinding[] { + const raw = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(KEYMAP_SETTING_KEY, []); + return parseKeymapBindings(raw); +} + +export { KEYMAP_SETTING_KEY }; + export function getVimModeEnabled(context: vscode.ExtensionContext): boolean { const config = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION); const behavior = getVimModeBehavior(config); diff --git a/src/shared/keymapConfig.ts b/src/shared/keymapConfig.ts new file mode 100644 index 0000000..9878449 --- /dev/null +++ b/src/shared/keymapConfig.ts @@ -0,0 +1,283 @@ +/** + * Shared keymap config for markdownEditorOptimized.keymap. + * Used by the extension host (settings parse) and the webview (bindings). + */ + +export const KEYMAP_SETTING_KEY = 'keymap'; + +export const KEYMAP_COMMAND_WHITELIST = [ + 'passthrough', + // History + 'undo', + 'redo', + 'undoSelection', + 'redoSelection', + // Cursor and selection + 'cursorCharLeft', + 'cursorCharRight', + 'cursorCharForward', + 'cursorCharBackward', + 'cursorGroupLeft', + 'cursorGroupRight', + 'cursorGroupForward', + 'cursorGroupBackward', + 'cursorLineUp', + 'cursorLineDown', + 'cursorPageUp', + 'cursorPageDown', + 'cursorLineStart', + 'cursorLineEnd', + 'cursorDocStart', + 'cursorDocEnd', + 'selectCharLeft', + 'selectCharRight', + 'selectGroupLeft', + 'selectGroupRight', + 'selectLineUp', + 'selectLineDown', + 'selectPageUp', + 'selectPageDown', + 'selectLineStart', + 'selectLineEnd', + 'selectDocStart', + 'selectDocEnd', + 'selectAll', + 'selectLine', + // Editing + 'deleteCharBackward', + 'deleteCharForward', + 'deleteGroupBackward', + 'deleteGroupForward', + 'deleteLine', + 'deleteToLineStart', + 'deleteToLineEnd', + 'indentMore', + 'indentLess', + 'indentSelection', + 'insertNewlineAndIndent', + 'insertBlankLine', + 'transposeChars', + 'moveLineUp', + 'moveLineDown', + 'copyLineUp', + 'copyLineDown', + // CodeMirror language folding + 'foldCode', + 'unfoldCode', + 'toggleFold', + 'foldAll', + 'unfoldAll', + // MEO commands + 'toggleHeadingCollapse', + 'openFind', + 'openReplace', + 'toggleMode' +] as const; + +export type KeymapCommandName = typeof KEYMAP_COMMAND_WHITELIST[number]; + +/** Canonical CodeMirror key, such as Alt-ArrowUp or Mod-Shift-f. */ +export type NormalizedKeymapBinding = { + key: string; + command: KeymapCommandName; +}; + +const commandSet = new Set(KEYMAP_COMMAND_WHITELIST); + +const SPECIAL_KEY_ALIASES: Record = { + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + arrowup: 'ArrowUp', + arrowdown: 'ArrowDown', + arrowleft: 'ArrowLeft', + arrowright: 'ArrowRight', + enter: 'Enter', + return: 'Enter', + escape: 'Escape', + esc: 'Escape', + space: 'Space', + tab: 'Tab', + backspace: 'Backspace', + delete: 'Delete', + del: 'Delete', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + pgup: 'PageUp', + pgdn: 'PageDown' +}; + +const MODIFIER_ALIASES: Record = { + mod: 'Mod', + cmd: 'Cmd', + command: 'Cmd', + meta: 'Cmd', + ctrl: 'Ctrl', + control: 'Ctrl', + alt: 'Alt', + option: 'Alt', + shift: 'Shift' +}; + +/** + * Normalize user key strings ("alt+up", "Ctrl-Shift-F", "mod+shift+f") to CodeMirror form ("Alt-ArrowUp"). + */ +export function normalizeKeymapKey(raw: string): string | null { + if (typeof raw !== 'string') { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + const parts = trimmed + .split(/[+\-\s]+/) + .map((part) => part.trim()) + .filter(Boolean); + + if (!parts.length) { + return null; + } + + const modifiers: string[] = []; + let mainKey: string | null = null; + + for (const part of parts) { + const lower = part.toLowerCase(); + if (MODIFIER_ALIASES[lower]) { + const mod = MODIFIER_ALIASES[lower]; + if (!modifiers.includes(mod)) { + modifiers.push(mod); + } + continue; + } + if (mainKey) { + // Multiple main keys are invalid. + return null; + } + if (SPECIAL_KEY_ALIASES[lower]) { + mainKey = SPECIAL_KEY_ALIASES[lower]; + continue; + } + // Single character → keep letter lowercase for CM (f not F), except when length > 1 + if (part.length === 1) { + mainKey = part.toLowerCase(); + } else if (/^f\d{1,2}$/i.test(part)) { + mainKey = part.toUpperCase(); + } else { + // Pascal-case unknowns (ArrowUp already handled) + mainKey = part[0].toUpperCase() + part.slice(1); + } + } + + if (!mainKey) { + return null; + } + + const order = ['Mod', 'Ctrl', 'Cmd', 'Alt', 'Shift']; + modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b)); + + return [...modifiers, mainKey].join('-'); +} + +export function normalizeKeymapCommand(raw: unknown): KeymapCommandName | null { + if (typeof raw !== 'string') { + return null; + } + const command = raw.trim(); + if (!commandSet.has(command)) { + return null; + } + return command as KeymapCommandName; +} + +export function parseKeymapBindings(raw: unknown): NormalizedKeymapBinding[] { + if (!Array.isArray(raw)) { + return []; + } + + const seenKeys = new Set(); + const result: NormalizedKeymapBinding[] = []; + + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + const key = normalizeKeymapKey(typeof record.key === 'string' ? record.key : ''); + const command = normalizeKeymapCommand(record.command); + if (!key || !command) { + continue; + } + // Last binding for a key wins. + if (seenKeys.has(key)) { + const index = result.findIndex((item) => item.key === key); + if (index >= 0) { + result.splice(index, 1); + } + } + seenKeys.add(key); + result.push({ key, command }); + } + + return result; +} + +export function resolveKeymapKeyForPlatform(key: string, isMac: boolean): string { + const parts = key.split('-'); + if (parts.length === 1) { + return key; + } + + const main = parts.pop() as string; + const order = ['Ctrl', 'Cmd', 'Alt', 'Shift']; + const modifiers = parts + .map((modifier) => modifier === 'Mod' ? (isMac ? 'Cmd' : 'Ctrl') : modifier) + .sort((a, b) => order.indexOf(a) - order.indexOf(b)); + return [...new Set(modifiers), main].join('-'); +} + +/** Build a normalized physical key string from a browser KeyboardEvent. */ +export function keyEventToNormalizedKey(event: KeyboardEvent): string { + const modifiers: string[] = []; + if (event.ctrlKey) { + modifiers.push('Ctrl'); + } + if (event.metaKey) { + modifiers.push('Cmd'); + } + if (event.altKey) { + modifiers.push('Alt'); + } + if (event.shiftKey) { + modifiers.push('Shift'); + } + + let main: string; + const code = event.code || ''; + const key = event.key || ''; + + if (code.startsWith('Arrow') || ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) { + main = key.startsWith('Arrow') ? key : code; + } else if (code.startsWith('Key') && code.length === 4) { + main = code.slice(3).toLowerCase(); + } else if (code.startsWith('Digit') && code.length === 6) { + main = code.slice(5); + } else if (/^F\d{1,2}$/.test(code)) { + main = code; + } else if (key === ' ') { + main = 'Space'; + } else if (key.length === 1) { + main = key.toLowerCase(); + } else { + main = key.length ? key[0].toUpperCase() + key.slice(1) : code; + } + + const order = ['Ctrl', 'Cmd', 'Alt', 'Shift']; + const uniqueMods = [...new Set(modifiers)].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + return [...uniqueMods, main].join('-'); +} diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 94fd0bd..dc408a0 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1,13 +1,15 @@ -import { EditorState, Compartment, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; +import { EditorState, Compartment, Prec, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; import { EditorView, keymap, highlightActiveLine, lineNumbers, highlightActiveLineGutter, scrollPastEnd, Decoration, type ViewUpdate } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentMore, indentLess, undo, redo } from '@codemirror/commands'; import { markdown, markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'; -import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing } from '@codemirror/language'; +import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing, codeFolding } from '@codemirror/language'; import { vim, Vim } from '@replit/codemirror-vim'; import { highlightStyle } from './theme'; import { shikiCodeHighlight } from './helpers/shikiDecorations'; import { liveModeExtensions } from './liveMode'; import { headingCollapseSharedExtensions, headingCollapseSourceSpacerExtensions } from './helpers/headingCollapse'; +import { buildUserKeymapBindings, type KeymapCommandHandlers } from './helpers/userKeymap'; +import type { NormalizedKeymapBinding } from '../../src/shared/keymapConfig'; import { resolveCodeLanguage, insertCodeBlock, sourceCodeBlockField } from './helpers/codeBlocks'; import { sourceStrikeMarkerField } from './helpers/strikeMarkers'; import { sourceWikiMarkerField } from './helpers/wikiLinks'; @@ -170,7 +172,9 @@ export function createEditor({ initialVimMode = false, initialVimKeybindings = [], initialVimLeader = '\\', - initialDiagnostics = [] + initialDiagnostics = [], + initialKeymap = [], + keymapHandlers = {} }) { // VS Code webviews can hit cross-origin window access issues in the EditContext path. // Disable it explicitly for stability in embedded Chromium. @@ -179,12 +183,15 @@ export function createEditor({ const modeCompartment = new Compartment(); const gitGutterCompartment = new Compartment(); const vimCompartment = new Compartment(); + const userKeymapCompartment = new Compartment(); const startMode = initialMode === 'live' ? 'live' : 'source'; let lineNumbersVisible = initialLineNumbers !== false; let gitGutterVisible = initialGitGutter !== false; let vimModeEnabled = initialVimMode === true; let vimKeybindings = initialVimKeybindings; let vimLeader = initialVimLeader; + let userKeymapBindings: NormalizedKeymapBinding[] = Array.isArray(initialKeymap) ? [...initialKeymap] : []; + let userKeymapHandlers: KeymapCommandHandlers = keymapHandlers ?? {}; let appliedVimKeybindings: Array<{ before: string; mode: string }> = []; let currentDiagnostics: EditorDiagnostic[] = Array.isArray(initialDiagnostics) ? initialDiagnostics : []; let lastDiagnosticClick: { key: string; from: number; to: number } | null = null; @@ -267,6 +274,14 @@ export function createEditor({ return Math.max(0, Number(value)); }; const vimExtensionsForState = () => (vimModeEnabled ? vim() : []); + const userKeymapExtensions = () => { + const bindings = buildUserKeymapBindings(userKeymapBindings, userKeymapHandlers); + if (!bindings.length) { + return []; + } + // Highest precedence so user bindings override defaultKeymap / markdownKeymap. + return [Prec.highest(keymap.of(bindings))]; + }; const getLineStartOffset = (docText, targetLineNumber) => { const targetLine = Math.max(1, Math.floor(targetLineNumber)); if (targetLine === 1) { @@ -1453,6 +1468,9 @@ export function createEditor({ EditorState.tabSize.of(4), indentUnit.of(' '), vimCompartment.of(vimExtensionsForState()), + userKeymapCompartment.of(userKeymapExtensions()), + // Enable CM fold service so foldCode/toggleFold keymap commands work on foldable ranges. + codeFolding(), keymap.of([ { key: 'Tab', run: (view) => indentListByTwoSpaces(view) || indentMore(view) }, { key: 'Shift-Tab', run: (view) => outdentListByTwoSpaces(view) || indentLess(view) }, @@ -1984,6 +2002,15 @@ export function createEditor({ applyVimKeybindings(vimKeybindings, vimLeader); } }, + setKeymap(bindings: NormalizedKeymapBinding[], handlers?: KeymapCommandHandlers) { + userKeymapBindings = Array.isArray(bindings) ? [...bindings] : []; + if (handlers) { + userKeymapHandlers = handlers; + } + view.dispatch({ + effects: userKeymapCompartment.reconfigure(userKeymapExtensions()) + }); + }, insertFormat(action, level) { const activeTableInput = getActiveTableInput(); if (activeTableInput) { diff --git a/webview/src/helpers/headingCollapse.ts b/webview/src/helpers/headingCollapse.ts index 1e987ae..b147b04 100644 --- a/webview/src/helpers/headingCollapse.ts +++ b/webview/src/helpers/headingCollapse.ts @@ -55,7 +55,7 @@ function createDetailsCollapsibleSection(detailsBlock: DetailsBlockInfo): Collap }; } -function getCollapsibleHeadingSections(state: EditorState): HeadingSection[] { +export function getCollapsibleHeadingSections(state: EditorState): HeadingSection[] { return extractHeadingSections(state).filter((section) => isHeadingSectionCollapsible(state, section)); } diff --git a/webview/src/helpers/shortcuts.ts b/webview/src/helpers/shortcuts.ts index 2018546..35f57f0 100644 --- a/webview/src/helpers/shortcuts.ts +++ b/webview/src/helpers/shortcuts.ts @@ -20,6 +20,9 @@ export interface ShortcutHandlerContext { openFindPanel: (target: 'find' | 'replace') => void; applyMode: (mode: 'live' | 'source', options?: { userTriggered?: boolean; reason?: string }) => boolean; flushPendingChangesNow: () => void; + /** Normalized keys (e.g. Mod-Shift-f) owned by the configurable keymap. */ + userKeymapKeys?: Set; + keyEventToNormalizedKey?: (event: KeyboardEvent) => string; } export const handleEditorShortcut = ( @@ -27,11 +30,19 @@ export const handleEditorShortcut = ( context: ShortcutHandlerContext ): boolean => { const { editor, currentMode, vimModeEnabled, pendingText, syncedText } = context; - + if (!editor || event.isComposing) { return false; } - + + if (context.userKeymapKeys?.size && context.keyEventToNormalizedKey) { + const normalized = context.keyEventToNormalizedKey(event); + if (context.userKeymapKeys.has(normalized)) { + // Let the configured CodeMirror binding decide whether to handle or pass through the chord. + return false; + } + } + const hasPrimaryModifier = isPrimaryModifier(event); const editorFocused = editor.hasFocus(); const vimEditorFocused = vimModeEnabled && editorFocused; diff --git a/webview/src/helpers/userKeymap.ts b/webview/src/helpers/userKeymap.ts new file mode 100644 index 0000000..ed68193 --- /dev/null +++ b/webview/src/helpers/userKeymap.ts @@ -0,0 +1,251 @@ +import type { Command, KeyBinding } from '@codemirror/view'; +import { + undo, + redo, + undoSelection, + redoSelection, + cursorCharLeft, + cursorCharRight, + cursorCharForward, + cursorCharBackward, + cursorGroupLeft, + cursorGroupRight, + cursorGroupForward, + cursorGroupBackward, + cursorLineUp, + cursorLineDown, + cursorPageUp, + cursorPageDown, + cursorLineStart, + cursorLineEnd, + cursorDocStart, + cursorDocEnd, + selectCharLeft, + selectCharRight, + selectGroupLeft, + selectGroupRight, + selectLineUp, + selectLineDown, + selectPageUp, + selectPageDown, + selectLineStart, + selectLineEnd, + selectDocStart, + selectDocEnd, + selectAll, + selectLine, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLine, + deleteToLineStart, + deleteToLineEnd, + indentMore, + indentLess, + indentSelection, + insertNewlineAndIndent, + insertBlankLine, + transposeChars, + moveLineUp, + moveLineDown, + copyLineUp, + copyLineDown +} from '@codemirror/commands'; +import { foldCode, unfoldCode, toggleFold, foldAll, unfoldAll } from '@codemirror/language'; +import { + getCollapsibleHeadingSections, + toggleCollapsibleSection +} from './headingCollapse'; +import { + resolveKeymapKeyForPlatform, + type KeymapCommandName, + type NormalizedKeymapBinding +} from '../../../src/shared/keymapConfig'; + +export type KeymapCommandHandlers = { + openFind?: () => void; + openReplace?: () => void; + toggleMode?: () => void; +}; + +const toggleHeadingCollapseCommand: Command = (view) => { + const head = view.state.selection.main.head; + const sections = getCollapsibleHeadingSections(view.state); + if (!sections.length) { + return false; + } + + // Prefer the heading line itself, then the deepest section containing the cursor. + let target = sections.find((section) => { + const line = view.state.doc.lineAt(section.lineFrom); + return head >= line.from && head <= line.to; + }); + if (!target) { + target = [...sections].reverse().find( + (section) => head > section.collapseFrom && head < section.collapseTo + ); + } + if (!target) { + // Nearest heading above the cursor. + for (let i = sections.length - 1; i >= 0; i -= 1) { + if (sections[i].lineFrom <= head) { + target = sections[i]; + break; + } + } + } + if (!target) { + return false; + } + return toggleCollapsibleSection(view, target.lineFrom); +}; + +const BUILTIN_COMMANDS: Partial> = { + undo, + redo, + undoSelection, + redoSelection, + cursorCharLeft, + cursorCharRight, + cursorCharForward, + cursorCharBackward, + cursorGroupLeft, + cursorGroupRight, + cursorGroupForward, + cursorGroupBackward, + cursorLineUp, + cursorLineDown, + cursorPageUp, + cursorPageDown, + cursorLineStart, + cursorLineEnd, + cursorDocStart, + cursorDocEnd, + selectCharLeft, + selectCharRight, + selectGroupLeft, + selectGroupRight, + selectLineUp, + selectLineDown, + selectPageUp, + selectPageDown, + selectLineStart, + selectLineEnd, + selectDocStart, + selectDocEnd, + selectAll, + selectLine, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLine, + deleteToLineStart, + deleteToLineEnd, + indentMore, + indentLess, + indentSelection, + insertNewlineAndIndent, + insertBlankLine, + transposeChars, + moveLineUp, + moveLineDown, + copyLineUp, + copyLineDown, + foldCode, + unfoldCode, + toggleFold, + foldAll, + unfoldAll, + toggleHeadingCollapse: toggleHeadingCollapseCommand +}; + +/** + * Claim the key in CodeMirror so lower keymaps do not handle it, but allow the + * event to bubble to VS Code's webview keybinding forwarder. + */ +const passthroughBinding = (key: string): KeyBinding => ({ + key, + run: () => true, + preventDefault: false, + stopPropagation: false +}); + +const overridingBinding = (key: string, command: Command): KeyBinding => ({ + key, + run: (view) => { + command(view); + // A configured binding owns its chord even when the command has no effect in + // the current editor state. Do not fall through to the default keymaps. + return true; + }, + stopPropagation: true +}); + +export function buildUserKeymapBindings( + bindings: readonly NormalizedKeymapBinding[], + handlers: KeymapCommandHandlers = {} +): KeyBinding[] { + if (!bindings.length) { + return []; + } + + const result: KeyBinding[] = []; + + for (const binding of bindings) { + if (binding.command === 'passthrough') { + result.push(passthroughBinding(binding.key)); + continue; + } + + if (binding.command === 'openFind') { + if (handlers.openFind) { + result.push(overridingBinding(binding.key, () => { + handlers.openFind?.(); + return true; + })); + } + continue; + } + + if (binding.command === 'openReplace') { + if (handlers.openReplace) { + result.push(overridingBinding(binding.key, () => { + handlers.openReplace?.(); + return true; + })); + } + continue; + } + + if (binding.command === 'toggleMode') { + if (handlers.toggleMode) { + result.push(overridingBinding(binding.key, () => { + handlers.toggleMode?.(); + return true; + })); + } + continue; + } + + const command = BUILTIN_COMMANDS[binding.command]; + if (!command) { + continue; + } + result.push(overridingBinding(binding.key, command)); + } + + return result; +} + +export function collectUserKeymapKeys( + bindings: readonly NormalizedKeymapBinding[], + isMac: boolean +): Set { + const keys = new Set(); + for (const binding of bindings) { + keys.add(resolveKeymapKeyForPlatform(binding.key, isMac)); + } + return keys; +} diff --git a/webview/src/index.ts b/webview/src/index.ts index a488612..9a09178 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -9,6 +9,8 @@ import { applyThemeSettings } from './helpers/theme'; import { setShikiTheme, setShikiEnabled } from './helpers/shikiHighlighter'; import { createFailureNoticeManager, getErrorMessage, isTransientMermaidRuntimeError, shouldAutoFallbackToSourceForLiveError, logWebviewRenderError, type EditorNotice, type FailureNoticeManager } from './helpers/errors'; import { isPrimaryModifier, isShortcutKey, normalizeEol, handleEditorShortcut, type ShortcutHandlerContext } from './helpers/shortcuts'; +import { collectUserKeymapKeys } from './helpers/userKeymap'; +import { keyEventToNormalizedKey, type NormalizedKeymapBinding } from '../../src/shared/keymapConfig'; import { createFindPanel, createFindPanelController, type FindPanelController } from './helpers/findPanel'; import { createSelectionMenu, createSelectionMenuController, type SelectionMenuController } from './helpers/selectionMenu'; import { createExportHandler, type ExportHandlerContext } from './helpers/export'; @@ -148,6 +150,8 @@ taskBtn.appendChild(createElement(ListTodo, { width: 18, height: 18 })); let vimModeEnabled = false; let vimKeybindingsState: VimKeybinding[] = []; let vimLeaderState = '\\'; +let keymapBindings: NormalizedKeymapBinding[] = []; +let userKeymapKeys = new Set(); let lineNumbersVisible = true; let gitChangesGutterVisible = true; @@ -311,6 +315,22 @@ const setVimModeEnabled = (enabled) => { editor?.setVimMode(vimModeEnabled); }; +const isMacPlatform = /Mac|iPhone|iPad|iPod/.test(navigator.platform); + +const getKeymapHandlers = () => ({ + openFind: () => findPanelController.open('find'), + openReplace: () => findPanelController.open('replace'), + toggleMode: () => { + applyMode(currentMode === 'live' ? 'source' : 'live', { userTriggered: true, reason: 'keymap' }); + } +}); + +const syncKeymapBindings = (bindings: NormalizedKeymapBinding[]) => { + keymapBindings = Array.isArray(bindings) ? [...bindings] : []; + userKeymapKeys = collectUserKeymapKeys(keymapBindings, isMacPlatform); + editor?.setKeymap?.(keymapBindings, getKeymapHandlers()); +}; + const toggleLineNumbers = () => { setLineNumbersVisible(!lineNumbersVisible); }; @@ -1113,7 +1133,9 @@ const shortcutHandlerContext: ShortcutHandlerContext = { requestSave, openFindPanel: (target) => findPanelController.open(target), applyMode: (mode, options) => applyMode(mode, options), - flushPendingChangesNow + flushPendingChangesNow, + get userKeymapKeys() { return userKeymapKeys; }, + keyEventToNormalizedKey }; const queueChanges = (nextText: string) => { @@ -1250,6 +1272,8 @@ const mountInitialEditor = async () => { initialVimMode: vimModeEnabled, initialVimKeybindings: vimKeybindingsState, initialVimLeader: vimLeaderState, + initialKeymap: keymapBindings, + keymapHandlers: getKeymapHandlers(), initialDiagnostics: pendingDiagnostics, onApplyChanges: queueChanges, onOpenLink: (href: string) => { @@ -1381,6 +1405,9 @@ const handleInit = (message: any) => { if (typeof message.vimMode === 'boolean') { setVimModeEnabled(message.vimMode); } + if (Array.isArray(message.keymap)) { + syncKeymapBindings(message.keymap); + } if (Array.isArray(message.vimKeybindings)) { vimKeybindingsState = message.vimKeybindings; vimLeaderState = typeof message.vimLeader === 'string' ? message.vimLeader : '\\'; @@ -1655,6 +1682,13 @@ window.addEventListener('message', (event) => { return; } + if (message.type === 'keymapChanged') { + if (Array.isArray(message.keymap)) { + syncKeymapBindings(message.keymap); + } + return; + } + if (message.type === 'findOptionsChanged') { if (message.findOptions && typeof message.findOptions === 'object') { findPanelController.setSearchOptions(message.findOptions); diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index bb3bcbc..a12d90d 100644 --- a/webview/src/types.d.ts +++ b/webview/src/types.d.ts @@ -37,7 +37,7 @@ type VimKeybinding = { }; type ExtensionMessage = - | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } + | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; keymap?: Array<{ key: string; command: string }>; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } | { type: 'docChanged'; text: string; version: number } | { type: 'applied'; version: number } | { type: 'focusEditor' } @@ -53,6 +53,7 @@ type ExtensionMessage = | { type: 'contentMaxWidthChanged'; enabled: boolean } | { type: 'vimModeChanged'; enabled: boolean } | { type: 'vimKeybindingsChanged'; keybindings: VimKeybinding[]; leaderKey: string } + | { type: 'keymapChanged'; keymap: Array<{ key: string; command: string }> } | { type: 'findOptionsChanged'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } | { type: 'resolvedImageSrc'; requestId: string; resolvedUrl: string } | { type: 'resolvedWikiLinks'; requestId: string; results: Array<{ target: string; exists: boolean }> }