feat: configurable CodeMirror keymap via settings (#77)

Adds configurable CodeMirror and MEO key bindings with live reload, a curated command list, and VS Code passthrough support.

Includes maintainer fixes for shortcut precedence, platform-specific modifiers, and heading collapse targeting.

Co-authored-by: Apostol Apostolov <theapoapostolov@gmail.com>

Closes #53
This commit is contained in:
Apostol Apostolov
2026-09-05 21:51:32 +01:00
committed by GitHub
parent c5c1616c5c
commit e254dfa5e9
13 changed files with 664 additions and 8 deletions
+3
View File
@@ -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
+1
View File
@@ -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
+23
View File
@@ -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,
+6
View File
@@ -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') ||
+4
View File
@@ -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),
+12
View File
@@ -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<boolean>(SPELL_CHECK_SETTING_KEY, true);
}
export function getKeymapBindings(): NormalizedKeymapBinding[] {
const raw = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get<unknown>(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);
+283
View File
@@ -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<string>(KEYMAP_COMMAND_WHITELIST);
const SPECIAL_KEY_ALIASES: Record<string, string> = {
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<string, string> = {
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<string>();
const result: NormalizedKeymapBinding[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
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('-');
}
+30 -3
View File
@@ -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) {
+1 -1
View File
@@ -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));
}
+13 -2
View File
@@ -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<string>;
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;
+251
View File
@@ -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<Record<KeymapCommandName, Command>> = {
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<string> {
const keys = new Set<string>();
for (const binding of bindings) {
keys.add(resolveKeymapKeyForPlatform(binding.key, isMac));
}
return keys;
}
+35 -1
View File
@@ -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<string>();
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);
+2 -1
View File
@@ -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 }> }