mirror of
https://github.com/vadimmelnicuk/meo.git
synced 2026-09-25 09:25:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Vendored
+2
-1
@@ -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 }> }
|
||||
|
||||
Reference in New Issue
Block a user