Yuki Takei 1 неделя назад
Родитель
Сommit
ac6af71b00
22 измененных файлов с 963 добавлено и 380 удалено
  1. 2 2
      .kiro/specs/editor-keymaps/spec.json
  2. 32 32
      .kiro/specs/editor-keymaps/tasks.md
  3. 15 0
      packages/editor/src/client/services-internal/keymaps/default.ts
  4. 0 74
      packages/editor/src/client/services-internal/keymaps/emacs.ts
  5. 40 0
      packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts
  6. 30 0
      packages/editor/src/client/services-internal/keymaps/emacs/index.ts
  7. 251 0
      packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts
  8. 108 0
      packages/editor/src/client/services-internal/keymaps/emacs/structural.ts
  9. 8 9
      packages/editor/src/client/services-internal/keymaps/index.ts
  10. 48 0
      packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts
  11. 11 0
      packages/editor/src/client/services-internal/keymaps/types.ts
  12. 19 7
      packages/editor/src/client/services-internal/keymaps/vim.ts
  13. 15 0
      packages/editor/src/client/services-internal/keymaps/vscode.ts
  14. 2 0
      packages/editor/src/client/services-internal/markdown-utils/index.ts
  15. 59 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts
  16. 164 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts
  17. 61 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts
  18. 33 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts
  19. 3 42
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts
  20. 4 161
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts
  21. 15 23
      packages/editor/src/client/stores/use-editor-settings.ts
  22. 43 30
      packages/editor/src/client/stores/use-editor-shortcuts.ts

+ 2 - 2
.kiro/specs/editor-keymaps/spec.json

@@ -15,8 +15,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": false
+      "approved": true
     }
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }

+ 32 - 32
.kiro/specs/editor-keymaps/tasks.md

@@ -1,141 +1,141 @@
 # Implementation Plan
 
-- [ ] 1. Extract shared markdown utility functions
-- [ ] 1.1 Create the toggle markdown symbol utility
+- [x] 1. Extract shared markdown utility functions
+- [x] 1.1 Create the toggle markdown symbol utility
   - Extract the inline markdown wrap/unwrap logic from the current Emacs keymap module into a standalone pure function
   - Handle three cases: wrap selection, unwrap existing symbols, and insert empty symbols with cursor positioning
   - Ensure no React or hook dependencies — pure CodeMirror state/view operations only
   - _Requirements: 2.1, 2.3_
 
-- [ ] 1.2 (P) Create the line prefix utility
+- [x] 1.2 (P) Create the line prefix utility
   - Extract line-prefix insertion logic into a standalone pure function alongside the toggle utility
   - Support single-line and multi-line selections, toggle-off when all lines already have the prefix
   - _Requirements: 2.1_
 
-- [ ] 1.3 Rewire existing public hooks to delegate to the new shared utilities
+- [x] 1.3 Rewire existing public hooks to delegate to the new shared utilities
   - Update the insert-markdown-elements hook to become a thin wrapper calling the shared toggle function
   - Update the insert-prefix hook to delegate to the shared line-prefix function
   - Verify that existing editor behavior (bold, italic, etc. via toolbar/shortcuts) remains unchanged
   - _Requirements: 2.2, 2.3_
 
-- [ ] 2. Define keymap type system and refactor the dispatcher
-- [ ] 2.1 Define the keymap result interface, factory type, and shortcut category types
+- [x] 2. Define keymap type system and refactor the dispatcher
+- [x] 2.1 Define the keymap result interface, factory type, and shortcut category types
   - Introduce a structured return type that bundles extension, precedence wrapper, and override category declarations
   - Define the shortcut category union type and the categorized key-bindings grouping type
   - Place all types in a dedicated types module within the keymaps directory
   - _Requirements: 1.2, 1.4, 3.1_
 
-- [ ] 2.2 Simplify the keymap dispatcher to a thin router
+- [x] 2.2 Simplify the keymap dispatcher to a thin router
   - Remove all inline keymap construction logic (default and vscode mode handling) from the dispatcher
   - Replace with a simple switch that delegates to each mode's factory function
   - Ensure the dispatcher returns the structured keymap result to callers
   - _Requirements: 1.2, 1.3_
 
-- [ ] 3. Create dedicated keymap modules for each mode
-- [ ] 3.1 (P) Create the default keymap module
+- [x] 3. Create dedicated keymap modules for each mode
+- [x] 3.1 (P) Create the default keymap module
   - Implement as an async factory returning the standard CodeMirror default keymap with low precedence and no overrides
   - _Requirements: 1.1_
 
-- [ ] 3.2 (P) Create the VSCode keymap module
+- [x] 3.2 (P) Create the VSCode keymap module
   - Implement as an async factory returning the VSCode keymap extension with low precedence and no overrides
   - _Requirements: 1.1_
 
-- [ ] 3.3 Refactor the Vim keymap module for structural consistency
+- [x] 3.3 Refactor the Vim keymap module for structural consistency
   - Move top-level side effects (key mappings like jj/jk escape, :w ex-command) inside the factory function
   - Add an idempotency guard to prevent duplicate registration on re-import
   - Return high precedence and empty overrides (Vim uses its own modal system)
   - Accept the optional onSave callback and register `:w` ex-command when provided
   - _Requirements: 1.1, 7.1, 7.2_
 
-- [ ] 4. Build the Emacs keymap module with formatting submodule
-- [ ] 4.1 Create the Emacs module structure and factory entry point
+- [x] 4. Build the Emacs keymap module with formatting submodule
+- [x] 4.1 Create the Emacs module structure and factory entry point
   - Set up the Emacs subdirectory with an index module that dynamically imports the Emacs extension
   - The factory composes all submodule registrations, registers save binding, and returns high precedence with formatting and structural overrides declared
   - _Requirements: 1.1, 1.4_
 
-- [ ] 4.2 Implement the formatting bindings submodule
+- [x] 4.2 Implement the formatting bindings submodule
   - Register C-c C-s prefix bindings for bold, italic, inline code, strikethrough, and code block
   - Delegate all formatting operations to the shared toggle-markdown-symbol utility
   - Support both lowercase and uppercase variants where specified (bold: b/B, italic: i/I)
   - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
 
-- [ ] 5. Relocate editor shortcuts and introduce category-based grouping
-- [ ] 5.1 Move the editor-shortcuts directory from the public services layer to services-internal
+- [x] 5. Relocate editor shortcuts and introduce category-based grouping
+- [x] 5.1 Move the editor-shortcuts directory from the public services layer to services-internal
   - Physically relocate the directory and update all import paths in the consuming store module (10 imports)
   - Verify build passes after relocation
   - _Requirements: 3.2_
 
-- [ ] 5.2 Wrap each shortcut group with categorized key-bindings metadata
+- [x] 5.2 Wrap each shortcut group with categorized key-bindings metadata
   - Group formatting shortcuts (bold, italic, strikethrough, code) under the formatting category
   - Group structural shortcuts (numbered list, bullet list, blockquote, link) under the structural category
   - Group always-on shortcuts (multi-cursor) with null category so they are never excluded
   - _Requirements: 3.2, 3.3_
 
-- [ ] 6. Refactor store layer for data-driven shortcut registration
-- [ ] 6.1 Update the editor shortcuts store to use category-based exclusion
+- [x] 6. Refactor store layer for data-driven shortcut registration
+- [x] 6.1 Update the editor shortcuts store to use category-based exclusion
   - Replace the hard-coded emacs mode check with data-driven filtering using the override categories from the keymap result
   - Change the parameter from keymap mode name to an array of shortcut categories to exclude
   - Filter categorized binding groups: include groups with null category always, exclude groups whose category appears in the overrides
   - _Requirements: 3.1, 3.2, 3.3_
 
-- [ ] 6.2 Simplify the editor settings store to use keymap result metadata
+- [x] 6.2 Simplify the editor settings store to use keymap result metadata
   - Remove the standalone precedence-determination function
   - Apply precedence directly from the keymap result's encapsulated precedence wrapper
   - Pass the keymap result's override declarations to the editor shortcuts store
   - _Requirements: 1.4_
 
-- [ ] 7. Implement Emacs structural editing bindings
-- [ ] 7.1 (P) Implement blockquote, link, and horizontal rule bindings
+- [x] 7. Implement Emacs structural editing bindings
+- [x] 7.1 (P) Implement blockquote, link, and horizontal rule bindings
   - Register C-c C-s q for blockquote toggle using the shared line-prefix utility
   - Register C-c C-l for markdown link insertion using the shared toggle utility
   - Register C-c C-s - for horizontal rule insertion
   - _Requirements: 5.1, 5.2, 5.3_
 
-- [ ] 7.2 (P) Implement heading bindings
+- [x] 7.2 (P) Implement heading bindings
   - Register C-c C-s h for auto-determined heading level insertion
   - Register C-c C-s 1 through C-c C-s 6 for explicit heading level insertion using the line-prefix utility
   - _Requirements: 5.4, 5.5_
 
-- [ ] 7.3 (P) Implement list item and fenced code block bindings
+- [x] 7.3 (P) Implement list item and fenced code block bindings
   - Register C-c C-j for context-aware new list item insertion (detect bullet vs numbered from current context)
   - Register C-c C-s C (shift-c) for GFM fenced code block insertion
   - _Requirements: 5.6, 5.7_
 
-- [ ] 8. Implement Emacs save binding
+- [x] 8. Implement Emacs save binding
   - Register C-x C-s as a two-stroke key sequence that invokes the onSave callback passed to the Emacs factory
   - Silently ignore the binding when no save callback is provided
   - Verify the same save mechanism used by Vim's :w command
   - _Requirements: 6.1, 6.2_
 
-- [ ] 9. Implement Emacs extended navigation and editing bindings
-- [ ] 9.1 (P) Implement heading navigation bindings
+- [x] 9. Implement Emacs extended navigation and editing bindings
+- [x] 9.1 (P) Implement heading navigation bindings
   - Register C-c C-n / C-c C-p to navigate to the next/previous heading at any level
   - Register C-c C-f / C-c C-b to navigate to the next/previous heading at the same level
   - Register C-c C-u to navigate up to the parent heading
   - Use regex-based heading detection to scan document structure
   - _Requirements: 9.3, 9.4, 9.5_
 
-- [ ] 9.2 (P) Implement promotion and demotion bindings
+- [x] 9.2 (P) Implement promotion and demotion bindings
   - Register C-c C-- to promote (outdent) the current element: decrease heading level or outdent list item
   - Register C-c C-= to demote (indent) the current element: increase heading level or indent list item
   - Detect element type at cursor to apply the appropriate operation
   - _Requirements: 9.1, 9.2_
 
-- [ ] 9.3 (P) Implement kill, image, table, and footnote bindings
+- [x] 9.3 (P) Implement kill, image, table, and footnote bindings
   - Register C-c C-k to kill (delete) the element at point and copy its text content to the clipboard
   - Register C-c C-i to insert a markdown image template
   - Register C-c C-s t to insert a markdown table template
   - Register C-c C-s f to insert a footnote marker and definition pair
   - _Requirements: 9.6, 9.7, 9.8, 9.9_
 
-- [ ] 10. Integration verification and UI consistency check
-- [ ] 10.1 Verify keymap selection UI displays all modes correctly
+- [x] 10. Integration verification and UI consistency check
+- [x] 10.1 Verify keymap selection UI displays all modes correctly
   - Confirm the keymap selector shows all four modes with appropriate labels
   - Verify switching between modes applies immediately without page reload
   - Confirm the selected mode persists across sessions via existing storage mechanism
   - _Requirements: 8.1, 8.2, 8.3_
 
-- [ ] 10.2 Add integration tests for keymap mode switching and shortcut exclusion
+- [x] 10.2 Add integration tests for keymap mode switching and shortcut exclusion
   - Test that formatting shortcuts are excluded in Emacs mode but present in default mode
   - Test that mode switching preserves document content
   - Test that C-x C-s triggers save in Emacs mode and :w triggers save in Vim mode

+ 15 - 0
packages/editor/src/client/services-internal/keymaps/default.ts

@@ -0,0 +1,15 @@
+import { Prec } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+
+import type { KeymapFactory } from './types';
+
+export const defaultKeymap: KeymapFactory = async () => {
+  const { defaultKeymap: cmDefaultKeymap } = await import(
+    '@codemirror/commands'
+  );
+  return {
+    extension: keymap.of(cmDefaultKeymap),
+    precedence: Prec.low,
+    overrides: [],
+  };
+};

+ 0 - 74
packages/editor/src/client/services-internal/keymaps/emacs.ts

@@ -1,74 +0,0 @@
-import type { Extension } from '@codemirror/state';
-import type { EditorView } from '@codemirror/view';
-
-// Toggle markdown symbols around the current selection.
-// If the selection is already wrapped with the symbols, remove them (toggle off).
-const toggleMarkdownSymbol = (
-  view: EditorView,
-  prefix: string,
-  suffix: string,
-): void => {
-  const { from, to, head } = view.state.selection.main;
-  const selectedText = view.state.sliceDoc(from, to);
-
-  let insertText: string;
-  if (selectedText.startsWith(prefix) && selectedText.endsWith(suffix)) {
-    insertText = selectedText.slice(prefix.length, -suffix.length || undefined);
-  } else {
-    insertText = prefix + selectedText + suffix;
-  }
-
-  const selection =
-    from === to
-      ? { anchor: from + prefix.length }
-      : { anchor: from, head: from + insertText.length };
-
-  const transaction = view.state.replaceSelection(insertText);
-  if (head == null) return;
-  view.dispatch(transaction);
-  view.dispatch({ selection });
-  view.focus();
-};
-
-// Register Emacs markdown-mode style commands and keybindings.
-// Uses EmacsHandler.bindKey for 3-stroke key chains (C-c C-s <key>)
-// which are processed natively by the emacs plugin's key chain mechanism.
-const registerMarkdownModeBindings = (
-  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
-): void => {
-  EmacsHandler.addCommands({
-    markdownBold(handler: { view: EditorView }) {
-      toggleMarkdownSymbol(handler.view, '**', '**');
-    },
-    markdownItalic(handler: { view: EditorView }) {
-      toggleMarkdownSymbol(handler.view, '*', '*');
-    },
-    markdownCode(handler: { view: EditorView }) {
-      toggleMarkdownSymbol(handler.view, '`', '`');
-    },
-    markdownStrikethrough(handler: { view: EditorView }) {
-      toggleMarkdownSymbol(handler.view, '~~', '~~');
-    },
-    markdownCodeBlock(handler: { view: EditorView }) {
-      toggleMarkdownSymbol(handler.view, '```\n', '\n```');
-    },
-  });
-
-  // Keybindings following Emacs markdown-mode conventions:
-  //   C-c C-s b / C-c C-s B  → Bold
-  //   C-c C-s i / C-c C-s I  → Italic
-  //   C-c C-s c              → Code (inline)
-  //   C-c C-s s              → Strikethrough
-  //   C-c C-s p              → Pre (code block)
-  EmacsHandler.bindKey('C-c C-s b|C-c C-s S-b', 'markdownBold');
-  EmacsHandler.bindKey('C-c C-s i|C-c C-s S-i', 'markdownItalic');
-  EmacsHandler.bindKey('C-c C-s c', 'markdownCode');
-  EmacsHandler.bindKey('C-c C-s s', 'markdownStrikethrough');
-  EmacsHandler.bindKey('C-c C-s p', 'markdownCodeBlock');
-};
-
-export const emacsKeymap = async (): Promise<Extension> => {
-  const { EmacsHandler, emacs } = await import('@replit/codemirror-emacs');
-  registerMarkdownModeBindings(EmacsHandler);
-  return emacs();
-};

+ 40 - 0
packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts

@@ -0,0 +1,40 @@
+import type { EditorView } from '@codemirror/view';
+
+import { toggleMarkdownSymbol } from '../../markdown-utils';
+
+/**
+ * Register Emacs markdown-mode formatting commands and keybindings.
+ * Uses C-c C-s prefix following Emacs markdown-mode conventions.
+ */
+export const registerFormattingBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownBold(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '**', '**');
+    },
+    markdownItalic(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '*', '*');
+    },
+    markdownCode(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '`', '`');
+    },
+    markdownStrikethrough(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '~~', '~~');
+    },
+    markdownCodeBlock(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '```\n', '\n```');
+    },
+  });
+
+  // C-c C-s b / C-c C-s B → Bold
+  // C-c C-s i / C-c C-s I → Italic
+  // C-c C-s c → Code (inline)
+  // C-c C-s s → Strikethrough
+  // C-c C-s p → Pre (code block)
+  EmacsHandler.bindKey('C-c C-s b|C-c C-s S-b', 'markdownBold');
+  EmacsHandler.bindKey('C-c C-s i|C-c C-s S-i', 'markdownItalic');
+  EmacsHandler.bindKey('C-c C-s c', 'markdownCode');
+  EmacsHandler.bindKey('C-c C-s s', 'markdownStrikethrough');
+  EmacsHandler.bindKey('C-c C-s p', 'markdownCodeBlock');
+};

+ 30 - 0
packages/editor/src/client/services-internal/keymaps/emacs/index.ts

@@ -0,0 +1,30 @@
+import { Prec } from '@codemirror/state';
+
+import type { KeymapFactory } from '../types';
+import { registerFormattingBindings } from './formatting';
+import { registerNavigationBindings } from './navigation';
+import { registerStructuralBindings } from './structural';
+
+export const emacsKeymap: KeymapFactory = async (onSave) => {
+  const { EmacsHandler, emacs } = await import('@replit/codemirror-emacs');
+
+  registerFormattingBindings(EmacsHandler);
+  registerStructuralBindings(EmacsHandler);
+  registerNavigationBindings(EmacsHandler);
+
+  // C-x C-s → Save
+  if (onSave != null) {
+    EmacsHandler.addCommands({
+      save() {
+        onSave();
+      },
+    });
+    EmacsHandler.bindKey('C-x C-s', 'save');
+  }
+
+  return {
+    extension: emacs(),
+    precedence: Prec.high,
+    overrides: ['formatting', 'structural'],
+  };
+};

+ 251 - 0
packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts

@@ -0,0 +1,251 @@
+import type { EditorView } from '@codemirror/view';
+
+const HEADING_RE = /^(#{1,6})\s/;
+
+const findHeading = (
+  view: EditorView,
+  from: number,
+  direction: 'forward' | 'backward',
+  levelFilter?: number,
+): number | null => {
+  const doc = view.state.doc;
+  const startLine = doc.lineAt(from).number;
+
+  if (direction === 'forward') {
+    for (let i = startLine + 1; i <= doc.lines; i++) {
+      const line = doc.line(i);
+      const match = line.text.match(HEADING_RE);
+      if (match && (levelFilter == null || match[1].length === levelFilter)) {
+        return line.from;
+      }
+    }
+  } else {
+    for (let i = startLine - 1; i >= 1; i--) {
+      const line = doc.line(i);
+      const match = line.text.match(HEADING_RE);
+      if (match && (levelFilter == null || match[1].length === levelFilter)) {
+        return line.from;
+      }
+    }
+  }
+  return null;
+};
+
+const getCurrentHeadingLevel = (view: EditorView): number | null => {
+  const doc = view.state.doc;
+  const curLine = doc.lineAt(view.state.selection.main.head).number;
+
+  for (let i = curLine; i >= 1; i--) {
+    const line = doc.line(i);
+    const match = line.text.match(HEADING_RE);
+    if (match) return match[1].length;
+  }
+  return null;
+};
+
+/**
+ * Register Emacs markdown-mode navigation and extended editing commands.
+ */
+export const registerNavigationBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownNextHeading(handler: { view: EditorView }) {
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'forward',
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPrevHeading(handler: { view: EditorView }) {
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownNextSiblingHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null) return;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'forward',
+        level,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPrevSiblingHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null) return;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+        level,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownUpHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null || level <= 1) return;
+      const parentLevel = level - 1;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+        parentLevel,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPromote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const headingMatch = line.text.match(HEADING_RE);
+      if (headingMatch && headingMatch[1].length > 1) {
+        const newPrefix = '#'.repeat(headingMatch[1].length - 1);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + headingMatch[1].length,
+            insert: newPrefix,
+          },
+        });
+        return;
+      }
+      // List outdent
+      const listMatch = line.text.match(/^(\s{2,})([-*+]|\d+\.)\s/);
+      if (listMatch) {
+        const newIndent = listMatch[1].slice(2);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + listMatch[1].length,
+            insert: newIndent,
+          },
+        });
+      }
+    },
+    markdownDemote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const headingMatch = line.text.match(HEADING_RE);
+      if (headingMatch && headingMatch[1].length < 6) {
+        const newPrefix = '#'.repeat(headingMatch[1].length + 1);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + headingMatch[1].length,
+            insert: newPrefix,
+          },
+        });
+        return;
+      }
+      // List indent
+      const listMatch = line.text.match(/^(\s*)([-*+]|\d+\.)\s/);
+      if (listMatch) {
+        view.dispatch({
+          changes: { from: line.from, insert: '  ' },
+        });
+      }
+    },
+    markdownKill(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const text = line.text;
+
+      // Copy to clipboard
+      if (typeof navigator !== 'undefined' && navigator.clipboard) {
+        navigator.clipboard.writeText(text).catch(() => {});
+      }
+
+      // Delete the line (including newline if not last line)
+      const from = line.from;
+      const to =
+        line.number < view.state.doc.lines
+          ? line.to + 1
+          : line.from > 0
+            ? line.from - 1
+            : line.to;
+      view.dispatch({
+        changes: { from, to, insert: '' },
+      });
+    },
+    markdownImage(handler: { view: EditorView }) {
+      toggleMarkdownImageSymbol(handler.view);
+    },
+    markdownTable(handler: { view: EditorView }) {
+      const { view } = handler;
+      const pos = view.state.selection.main.head;
+      const template =
+        '| Header | Header |\n| ------ | ------ |\n| Cell   | Cell   |';
+      view.dispatch({
+        changes: { from: pos, insert: template },
+        selection: { anchor: pos + 2 },
+      });
+    },
+    markdownFootnote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const pos = view.state.selection.main.head;
+      const doc = view.state.doc;
+
+      // Find next available footnote number
+      let maxNum = 0;
+      for (let i = 1; i <= doc.lines; i++) {
+        const line = doc.line(i);
+        const matches = line.text.matchAll(/\[\^(\d+)\]/g);
+        for (const m of matches) {
+          const num = Number.parseInt(m[1], 10);
+          if (num > maxNum) maxNum = num;
+        }
+      }
+      const nextNum = maxNum + 1;
+
+      // Insert marker at cursor and definition at end of document
+      const marker = `[^${nextNum}]`;
+      const definition = `\n[^${nextNum}]: `;
+      view.dispatch({
+        changes: [
+          { from: pos, insert: marker },
+          { from: doc.length, insert: definition },
+        ],
+        selection: { anchor: pos + marker.length },
+      });
+    },
+  });
+
+  const toggleMarkdownImageSymbol = (view: EditorView): void => {
+    const { from, to } = view.state.selection.main;
+    const selectedText = view.state.sliceDoc(from, to);
+
+    const insert = `![${selectedText}]()`;
+    view.dispatch({
+      changes: { from, to, insert },
+      selection: { anchor: from + 2 + selectedText.length + 2 },
+    });
+  };
+
+  EmacsHandler.bindKey('C-c C--', 'markdownPromote');
+  EmacsHandler.bindKey('C-c C-=', 'markdownDemote');
+  EmacsHandler.bindKey('C-c C-n', 'markdownNextHeading');
+  EmacsHandler.bindKey('C-c C-p', 'markdownPrevHeading');
+  EmacsHandler.bindKey('C-c C-f', 'markdownNextSiblingHeading');
+  EmacsHandler.bindKey('C-c C-b', 'markdownPrevSiblingHeading');
+  EmacsHandler.bindKey('C-c C-u', 'markdownUpHeading');
+  EmacsHandler.bindKey('C-c C-k', 'markdownKill');
+  EmacsHandler.bindKey('C-c C-i', 'markdownImage');
+  EmacsHandler.bindKey('C-c C-s t', 'markdownTable');
+  EmacsHandler.bindKey('C-c C-s f', 'markdownFootnote');
+};

+ 108 - 0
packages/editor/src/client/services-internal/keymaps/emacs/structural.ts

@@ -0,0 +1,108 @@
+import type { EditorView } from '@codemirror/view';
+
+import { insertLinePrefix, toggleMarkdownSymbol } from '../../markdown-utils';
+
+/**
+ * Register Emacs markdown-mode structural editing commands and keybindings.
+ * Covers headings, blockquote, link, horizontal rule, list items, and fenced code blocks.
+ */
+export const registerStructuralBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownBlockquote(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '>');
+    },
+    markdownLink(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '[', ']()');
+    },
+    markdownHorizontalRule(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const insert = line.text.trim() === '' ? '---' : '\n---\n';
+      view.dispatch({
+        changes: { from: line.from, to: line.to, insert },
+        selection: { anchor: line.from + insert.length },
+      });
+    },
+    markdownHeadingDwim(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const match = line.text.match(/^(#{1,6})\s/);
+      const currentLevel = match ? match[1].length : 0;
+      const nextLevel = currentLevel >= 6 ? 1 : currentLevel + 1;
+      const prefix = '#'.repeat(nextLevel);
+      const content = line.text.replace(/^#{1,6}\s*/, '');
+      view.dispatch({
+        changes: {
+          from: line.from,
+          to: line.to,
+          insert: `${prefix} ${content}`,
+        },
+        selection: { anchor: line.from + prefix.length + 1 + content.length },
+      });
+    },
+    markdownHeading1(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '#', true);
+    },
+    markdownHeading2(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '##', true);
+    },
+    markdownHeading3(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '###', true);
+    },
+    markdownHeading4(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '####', true);
+    },
+    markdownHeading5(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '#####', true);
+    },
+    markdownHeading6(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '######', true);
+    },
+    markdownNewListItem(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const bulletMatch = line.text.match(/^(\s*)([-*+])\s/);
+      const numberedMatch = line.text.match(/^(\s*)(\d+)\.\s/);
+
+      let insert: string;
+      if (bulletMatch) {
+        insert = `\n${bulletMatch[1]}${bulletMatch[2]} `;
+      } else if (numberedMatch) {
+        const nextNum = Number.parseInt(numberedMatch[2], 10) + 1;
+        insert = `\n${numberedMatch[1]}${nextNum}. `;
+      } else {
+        insert = '\n- ';
+      }
+
+      view.dispatch({
+        changes: { from: line.to, insert },
+        selection: { anchor: line.to + insert.length },
+      });
+    },
+    markdownFencedCodeBlock(handler: { view: EditorView }) {
+      const { view } = handler;
+      const { from, to } = view.state.selection.main;
+      const selectedText = view.state.sliceDoc(from, to);
+      const insert = `\`\`\`\n${selectedText}\n\`\`\``;
+      view.dispatch({
+        changes: { from, to, insert },
+        selection: { anchor: from + 3 },
+      });
+    },
+  });
+
+  EmacsHandler.bindKey('C-c C-s q', 'markdownBlockquote');
+  EmacsHandler.bindKey('C-c C-l', 'markdownLink');
+  EmacsHandler.bindKey('C-c C-s -', 'markdownHorizontalRule');
+  EmacsHandler.bindKey('C-c C-s h', 'markdownHeadingDwim');
+  EmacsHandler.bindKey('C-c C-s 1', 'markdownHeading1');
+  EmacsHandler.bindKey('C-c C-s 2', 'markdownHeading2');
+  EmacsHandler.bindKey('C-c C-s 3', 'markdownHeading3');
+  EmacsHandler.bindKey('C-c C-s 4', 'markdownHeading4');
+  EmacsHandler.bindKey('C-c C-s 5', 'markdownHeading5');
+  EmacsHandler.bindKey('C-c C-s 6', 'markdownHeading6');
+  EmacsHandler.bindKey('C-c C-j', 'markdownNewListItem');
+  EmacsHandler.bindKey('C-c C-s S-c', 'markdownFencedCodeBlock');
+};

+ 8 - 9
packages/editor/src/client/services-internal/keymaps/index.ts

@@ -1,21 +1,20 @@
-import type { Extension } from '@codemirror/state';
-import { keymap } from '@codemirror/view';
-
 import type { KeyMapMode } from '../../../consts';
+import type { KeymapResult } from './types';
+
+export type { KeymapFactory, KeymapResult, ShortcutCategory } from './types';
 
 export const getKeymap = async (
   keyMapName?: KeyMapMode,
   onSave?: () => void,
-): Promise<Extension> => {
+): Promise<KeymapResult> => {
   switch (keyMapName) {
     case 'vim':
       return (await import('./vim')).vimKeymap(onSave);
     case 'emacs':
-      return (await import('./emacs')).emacsKeymap();
+      return (await import('./emacs')).emacsKeymap(onSave);
     case 'vscode':
-      return keymap.of(
-        (await import('@replit/codemirror-vscode-keymap')).vscodeKeymap,
-      );
+      return (await import('./vscode')).vscodeKeymap();
+    default:
+      return (await import('./default')).defaultKeymap();
   }
-  return keymap.of((await import('@codemirror/commands')).defaultKeymap);
 };

+ 48 - 0
packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts

@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { Prec } from '@codemirror/state';
+import { describe, expect, it, vi } from 'vitest';
+
+import { getKeymap } from './index';
+
+describe('getKeymap', () => {
+  it('should return low precedence and no overrides for default mode', async () => {
+    const result = await getKeymap();
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.low);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return low precedence and no overrides for vscode mode', async () => {
+    const result = await getKeymap('vscode');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.low);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return high precedence and no overrides for vim mode', async () => {
+    const result = await getKeymap('vim');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.high);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return high precedence with formatting and structural overrides for emacs mode', async () => {
+    const result = await getKeymap('emacs');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.high);
+    expect(result.overrides).toContain('formatting');
+    expect(result.overrides).toContain('structural');
+  });
+
+  it('should pass onSave to vim mode and register :w command', async () => {
+    const onSave = vi.fn();
+    const result = await getKeymap('vim', onSave);
+    expect(result.extension).toBeDefined();
+  });
+
+  it('should pass onSave to emacs mode for C-x C-s binding', async () => {
+    const onSave = vi.fn();
+    const result = await getKeymap('emacs', onSave);
+    expect(result.extension).toBeDefined();
+  });
+});

+ 11 - 0
packages/editor/src/client/services-internal/keymaps/types.ts

@@ -0,0 +1,11 @@
+import type { Extension } from '@codemirror/state';
+
+export type ShortcutCategory = 'formatting' | 'structural' | 'navigation';
+
+export interface KeymapResult {
+  readonly extension: Extension;
+  readonly precedence: (ext: Extension) => Extension;
+  readonly overrides: readonly ShortcutCategory[];
+}
+
+export type KeymapFactory = (onSave?: () => void) => Promise<KeymapResult>;

+ 19 - 7
packages/editor/src/client/services-internal/keymaps/vim.ts

@@ -1,13 +1,25 @@
-import type { Extension } from '@codemirror/state';
-import { Vim, vim } from '@replit/codemirror-vim';
+import { Prec } from '@codemirror/state';
 
-// vim useful keymap custom
-Vim.map('jj', '<Esc>', 'insert');
-Vim.map('jk', '<Esc>', 'insert');
+import type { KeymapFactory } from './types';
+
+let initialized = false;
+
+export const vimKeymap: KeymapFactory = async (onSave) => {
+  const { Vim, vim } = await import('@replit/codemirror-vim');
+
+  if (!initialized) {
+    Vim.map('jj', '<Esc>', 'insert');
+    Vim.map('jk', '<Esc>', 'insert');
+    initialized = true;
+  }
 
-export const vimKeymap = (onSave?: () => void): Extension => {
   if (onSave != null) {
     Vim.defineEx('write', 'w', onSave);
   }
-  return vim();
+
+  return {
+    extension: vim(),
+    precedence: Prec.high,
+    overrides: [],
+  };
 };

+ 15 - 0
packages/editor/src/client/services-internal/keymaps/vscode.ts

@@ -0,0 +1,15 @@
+import { Prec } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+
+import type { KeymapFactory } from './types';
+
+export const vscodeKeymap: KeymapFactory = async () => {
+  const { vscodeKeymap: cmVscodeKeymap } = await import(
+    '@replit/codemirror-vscode-keymap'
+  );
+  return {
+    extension: keymap.of(cmVscodeKeymap),
+    precedence: Prec.low,
+    overrides: [],
+  };
+};

+ 2 - 0
packages/editor/src/client/services-internal/markdown-utils/index.ts

@@ -0,0 +1,2 @@
+export { insertLinePrefix } from './insert-line-prefix';
+export { toggleMarkdownSymbol } from './toggle-markdown-symbol';

+ 59 - 0
packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts

@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { EditorSelection, EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { describe, expect, it } from 'vitest';
+
+import { insertLinePrefix } from './insert-line-prefix';
+
+const createView = (doc: string, anchor: number, head?: number): EditorView => {
+  const state = EditorState.create({
+    doc,
+    selection: EditorSelection.create([
+      EditorSelection.range(anchor, head ?? anchor),
+    ]),
+  });
+  return new EditorView({ state });
+};
+
+describe('insertLinePrefix', () => {
+  it('should add prefix to a single line', () => {
+    const view = createView('hello', 0, 5);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> hello');
+  });
+
+  it('should add prefix to an empty line', () => {
+    const view = createView('', 0);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> ');
+  });
+
+  it('should add prefix to multiple lines', () => {
+    const doc = 'line one\nline two\nline three';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe(
+      '> line one\n> line two\n> line three',
+    );
+  });
+
+  it('should remove prefix when all non-empty lines already have it', () => {
+    const doc = '> line one\n> line two';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('line one\nline two');
+  });
+
+  it('should skip empty lines when adding prefix', () => {
+    const doc = 'line one\n\nline three';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> line one\n\n> line three');
+  });
+
+  it('should handle heading prefix (#)', () => {
+    const view = createView('hello', 0, 5);
+    insertLinePrefix(view, '#');
+    expect(view.state.doc.toString()).toBe('# hello');
+  });
+});

+ 164 - 0
packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts

@@ -0,0 +1,164 @@
+import type { ChangeSpec, Line, Text } from '@codemirror/state';
+import type { EditorView } from '@codemirror/view';
+
+// https://regex101.com/r/5ILXUX/1
+const LEADING_SPACES = /^\s*/;
+// https://regex101.com/r/ScAXzy/1
+const createPrefixPattern = (prefix: string) =>
+  new RegExp(`^\\s*(${prefix}+)\\s*`);
+
+const removePrefix = (text: string, prefix: string): string => {
+  if (text.startsWith(prefix)) {
+    return text.slice(prefix.length).trimStart();
+  }
+  return text;
+};
+
+const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line): boolean => {
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    if (line.text.trim() !== '') {
+      return false;
+    }
+  }
+  return true;
+};
+
+const allLinesHavePrefix = (
+  doc: Text,
+  startLine: Line,
+  endLine: Line,
+  prefix: string,
+): boolean => {
+  let hasNonEmptyLine = false;
+
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    const trimmedLine = line.text.trim();
+
+    if (trimmedLine !== '') {
+      hasNonEmptyLine = true;
+      if (!trimmedLine.startsWith(prefix)) {
+        return false;
+      }
+    }
+  }
+
+  return hasNonEmptyLine;
+};
+
+/**
+ * Insert or toggle a prefix at the beginning of the current line(s).
+ * Handles multi-line selections. Removes prefix if all lines already have it.
+ */
+export const insertLinePrefix = (
+  view: EditorView,
+  prefix: string,
+  noSpaceIfPrefixExists = false,
+): void => {
+  const { from, to } = view.state.selection.main;
+  const doc = view.state.doc;
+  const startLine = doc.lineAt(from);
+  const endLine = doc.lineAt(to);
+
+  const changes: ChangeSpec[] = [];
+  let totalLengthChange = 0;
+
+  const isPrefixRemoval = allLinesHavePrefix(doc, startLine, endLine, prefix);
+
+  if (allLinesEmpty(doc, startLine, endLine)) {
+    for (let i = startLine.number; i <= endLine.number; i++) {
+      const line = view.state.doc.line(i);
+      const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+      const insertText = `${leadingSpaces}${prefix} `;
+
+      const change = {
+        from: line.from,
+        to: line.to,
+        insert: insertText,
+      };
+
+      changes.push(change);
+      totalLengthChange += insertText.length - (line.to - line.from);
+    }
+
+    view.dispatch({ changes });
+    view.dispatch({
+      selection: {
+        anchor: from + totalLengthChange,
+        head: to + totalLengthChange,
+      },
+    });
+    view.focus();
+    return;
+  }
+
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = view.state.doc.line(i);
+    const trimmedLine = line.text.trim();
+    const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+    const contentTrimmed = line.text.trimStart();
+
+    if (trimmedLine === '') {
+      continue;
+    }
+
+    let newLine = '';
+    let lengthChange = 0;
+
+    if (isPrefixRemoval) {
+      const prefixPattern = createPrefixPattern(prefix);
+      const contentStartMatch = line.text.match(prefixPattern);
+
+      if (contentStartMatch) {
+        if (noSpaceIfPrefixExists) {
+          const existingPrefixes = contentStartMatch[1];
+          const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+          const newIndent = ' '.repeat(indentLevel);
+          newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
+        } else {
+          const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+          const newIndent = ' '.repeat(indentLevel);
+          const prefixRemovedText = removePrefix(contentTrimmed, prefix);
+          newLine = `${newIndent}${prefixRemovedText}`;
+        }
+
+        lengthChange = newLine.length - (line.to - line.from);
+
+        changes.push({
+          from: line.from,
+          to: line.to,
+          insert: newLine,
+        });
+      }
+    } else {
+      if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
+        newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
+      } else {
+        newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
+      }
+
+      lengthChange = newLine.length - (line.to - line.from);
+
+      changes.push({
+        from: line.from,
+        to: line.to,
+        insert: newLine,
+      });
+    }
+
+    totalLengthChange += lengthChange;
+  }
+
+  if (changes.length > 0) {
+    view.dispatch({ changes });
+
+    view.dispatch({
+      selection: {
+        anchor: from,
+        head: to + totalLengthChange,
+      },
+    });
+    view.focus();
+  }
+};

+ 61 - 0
packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts

@@ -0,0 +1,61 @@
+// @vitest-environment jsdom
+import { EditorSelection, EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { describe, expect, it } from 'vitest';
+
+import { toggleMarkdownSymbol } from './toggle-markdown-symbol';
+
+const createView = (doc: string, anchor: number, head?: number): EditorView => {
+  const state = EditorState.create({
+    doc,
+    selection: EditorSelection.create([
+      EditorSelection.range(anchor, head ?? anchor),
+    ]),
+  });
+  return new EditorView({ state });
+};
+
+describe('toggleMarkdownSymbol', () => {
+  it('should wrap selected text with prefix and suffix', () => {
+    const view = createView('hello world', 0, 5);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('**hello** world');
+  });
+
+  it('should unwrap text already wrapped with prefix and suffix', () => {
+    const view = createView('**hello** world', 0, 9);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('hello world');
+  });
+
+  it('should insert prefix+suffix and place cursor between them when no selection', () => {
+    const view = createView('hello world', 5);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('hello**** world');
+    expect(view.state.selection.main.head).toBe(7);
+  });
+
+  it('should handle single-char symbols (backtick)', () => {
+    const view = createView('code', 0, 4);
+    toggleMarkdownSymbol(view, '`', '`');
+    expect(view.state.doc.toString()).toBe('`code`');
+  });
+
+  it('should unwrap single-char symbols', () => {
+    const view = createView('`code`', 0, 6);
+    toggleMarkdownSymbol(view, '`', '`');
+    expect(view.state.doc.toString()).toBe('code');
+  });
+
+  it('should handle multiline prefix/suffix (code block)', () => {
+    const view = createView('some code', 0, 9);
+    toggleMarkdownSymbol(view, '```\n', '\n```');
+    expect(view.state.doc.toString()).toBe('```\nsome code\n```');
+  });
+
+  it('should handle asymmetric prefix and suffix (link)', () => {
+    const view = createView('text', 0, 4);
+    toggleMarkdownSymbol(view, '[', ']()');
+    expect(view.state.doc.toString()).toBe('[text]()');
+  });
+});

+ 33 - 0
packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts

@@ -0,0 +1,33 @@
+import type { EditorView } from '@codemirror/view';
+
+/**
+ * Toggle markdown symbols around the current selection.
+ * If the selection is already wrapped with prefix/suffix, remove them.
+ * If no text is selected, insert prefix+suffix and position cursor between them.
+ */
+export const toggleMarkdownSymbol = (
+  view: EditorView,
+  prefix: string,
+  suffix: string,
+): void => {
+  const { from, to, head } = view.state.selection.main;
+  const selectedText = view.state.sliceDoc(from, to);
+
+  let insertText: string;
+  if (selectedText.startsWith(prefix) && selectedText.endsWith(suffix)) {
+    insertText = selectedText.slice(prefix.length, -suffix.length || undefined);
+  } else {
+    insertText = prefix + selectedText + suffix;
+  }
+
+  const selection =
+    from === to
+      ? { anchor: from + prefix.length }
+      : { anchor: from, head: from + insertText.length };
+
+  const transaction = view.state.replaceSelection(insertText);
+  if (head == null) return;
+  view.dispatch(transaction);
+  view.dispatch({ selection });
+  view.focus();
+};

+ 3 - 42
packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts

@@ -1,21 +1,9 @@
 import { useCallback } from 'react';
 import type { EditorView } from '@codemirror/view';
 
-export type InsertMarkdownElements = (prefix: string, suffix: string) => void;
-
-const removeSymbol = (text: string, prefix: string, suffix: string): string => {
-  let result = text;
-
-  if (result.startsWith(prefix)) {
-    result = result.slice(prefix.length);
-  }
-
-  if (result.endsWith(suffix)) {
-    result = result.slice(0, -suffix.length);
-  }
+import { toggleMarkdownSymbol } from '../../../services-internal/markdown-utils';
 
-  return result;
-};
+export type InsertMarkdownElements = (prefix: string, suffix: string) => void;
 
 export const useInsertMarkdownElements = (
   view?: EditorView,
@@ -23,34 +11,7 @@ export const useInsertMarkdownElements = (
   return useCallback(
     (prefix, suffix) => {
       if (view == null) return;
-
-      const from = view?.state.selection.main.from;
-      const to = view?.state.selection.main.to;
-
-      const selectedText = view?.state.sliceDoc(from, to);
-      const cursorPos = view?.state.selection.main.head;
-
-      let insertText: string;
-
-      if (selectedText?.startsWith(prefix) && selectedText?.endsWith(suffix)) {
-        insertText = removeSymbol(selectedText, prefix, suffix);
-      } else {
-        insertText = prefix + selectedText + suffix;
-      }
-
-      const selection =
-        from === to
-          ? { anchor: from + prefix.length }
-          : { anchor: from, head: from + insertText.length };
-
-      const transaction = view?.state.replaceSelection(insertText);
-
-      if (transaction == null || cursorPos == null) {
-        return;
-      }
-      view?.dispatch(transaction);
-      view?.dispatch({ selection });
-      view?.focus();
+      toggleMarkdownSymbol(view, prefix, suffix);
     },
     [view],
   );

+ 4 - 161
packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts

@@ -1,175 +1,18 @@
 import { useCallback } from 'react';
-import type { ChangeSpec, Line, Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 
+import { insertLinePrefix } from '../../../services-internal/markdown-utils';
+
 export type InsertPrefix = (
   prefix: string,
   noSpaceIfPrefixExists?: boolean,
 ) => void;
 
-// https:// regex101.com/r/5ILXUX/1
-const LEADING_SPACES = /^\s*/;
-// https://regex101.com/r/ScAXzy/1
-const createPrefixPattern = (prefix: string) =>
-  new RegExp(`^\\s*(${prefix}+)\\s*`);
-
-const removePrefix = (text: string, prefix: string): string => {
-  if (text.startsWith(prefix)) {
-    return text.slice(prefix.length).trimStart();
-  }
-  return text;
-};
-
-const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line) => {
-  for (let i = startLine.number; i <= endLine.number; i++) {
-    const line = doc.line(i);
-    if (line.text.trim() !== '') {
-      return false;
-    }
-  }
-  return true;
-};
-
-const allLinesHavePrefix = (
-  doc: Text,
-  startLine: Line,
-  endLine: Line,
-  prefix: string,
-) => {
-  let hasNonEmptyLine = false;
-
-  for (let i = startLine.number; i <= endLine.number; i++) {
-    const line = doc.line(i);
-    const trimmedLine = line.text.trim();
-
-    if (trimmedLine !== '') {
-      hasNonEmptyLine = true;
-      if (!trimmedLine.startsWith(prefix)) {
-        return false;
-      }
-    }
-  }
-
-  return hasNonEmptyLine;
-};
-
 export const useInsertPrefix = (view?: EditorView): InsertPrefix => {
   return useCallback(
     (prefix: string, noSpaceIfPrefixExists = false) => {
-      if (view == null) {
-        return;
-      }
-
-      const { from, to } = view.state.selection.main;
-      const doc = view.state.doc;
-      const startLine = doc.lineAt(from);
-      const endLine = doc.lineAt(to);
-
-      const changes: ChangeSpec[] = [];
-      let totalLengthChange = 0;
-
-      const isPrefixRemoval = allLinesHavePrefix(
-        doc,
-        startLine,
-        endLine,
-        prefix,
-      );
-
-      if (allLinesEmpty(doc, startLine, endLine)) {
-        for (let i = startLine.number; i <= endLine.number; i++) {
-          const line = view.state.doc.line(i);
-          const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
-          const insertText = `${leadingSpaces}${prefix} `;
-
-          const change = {
-            from: line.from,
-            to: line.to,
-            insert: insertText,
-          };
-
-          changes.push(change);
-          totalLengthChange += insertText.length - (line.to - line.from);
-        }
-
-        view.dispatch({ changes });
-        view.dispatch({
-          selection: {
-            anchor: from + totalLengthChange,
-            head: to + totalLengthChange,
-          },
-        });
-        view.focus();
-        return;
-      }
-
-      for (let i = startLine.number; i <= endLine.number; i++) {
-        const line = view.state.doc.line(i);
-        const trimmedLine = line.text.trim();
-        const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
-        const contentTrimmed = line.text.trimStart();
-
-        if (trimmedLine === '') {
-          continue;
-        }
-
-        let newLine = '';
-        let lengthChange = 0;
-
-        if (isPrefixRemoval) {
-          const prefixPattern = createPrefixPattern(prefix);
-          const contentStartMatch = line.text.match(prefixPattern);
-
-          if (contentStartMatch) {
-            if (noSpaceIfPrefixExists) {
-              const existingPrefixes = contentStartMatch[1];
-              const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
-              const newIndent = ' '.repeat(indentLevel);
-              newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
-            } else {
-              const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
-              const newIndent = ' '.repeat(indentLevel);
-              const prefixRemovedText = removePrefix(contentTrimmed, prefix);
-              newLine = `${newIndent}${prefixRemovedText}`;
-            }
-
-            lengthChange = newLine.length - (line.to - line.from);
-
-            changes.push({
-              from: line.from,
-              to: line.to,
-              insert: newLine,
-            });
-          }
-        } else {
-          if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
-            newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
-          } else {
-            newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
-          }
-
-          lengthChange = newLine.length - (line.to - line.from);
-
-          changes.push({
-            from: line.from,
-            to: line.to,
-            insert: newLine,
-          });
-        }
-
-        totalLengthChange += lengthChange;
-      }
-
-      if (changes.length > 0) {
-        view.dispatch({ changes });
-
-        view.dispatch({
-          selection: {
-            anchor: from,
-            head: to + totalLengthChange,
-          },
-        });
-        view.focus();
-      }
+      if (view == null) return;
+      insertLinePrefix(view, prefix, noSpaceIfPrefixExists);
     },
     [view],
   );

+ 15 - 23
packages/editor/src/client/stores/use-editor-settings.ts

@@ -17,6 +17,7 @@ import {
   insertNewRowToMarkdownTable,
   isInTable,
 } from '../services-internal';
+import type { KeymapResult } from '../services-internal/keymaps';
 import { useEditorShortcuts } from './use-editor-shortcuts';
 
 const useStyleActiveLine = (
@@ -84,26 +85,12 @@ const useThemeExtension = (
   }, [codeMirrorEditor, themeExtension]);
 };
 
-// Emacs and Vim plugins use ViewPlugin DOM event handlers (keydown) to intercept keys.
-// They must run BEFORE CodeMirror's built-in keymap handler (Prec.default) to prevent
-// conflicts with defaultKeymap Mac Ctrl-* bindings and completionKeymap Ctrl-Space.
-// VSCode and default keymaps use keymap.of() which integrates with the keymap handler directly,
-// so Prec.low is appropriate for them.
-const getKeymapPrecedence = (
-  keymapMode?: KeyMapMode,
-): ((ext: Extension) => Extension) => {
-  if (keymapMode === 'emacs' || keymapMode === 'vim') {
-    return Prec.high;
-  }
-  return Prec.low;
-};
-
 const useKeymapExtension = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   keymapMode?: KeyMapMode,
   onSave?: () => void,
-): void => {
-  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(
+): KeymapResult | undefined => {
+  const [keymapResult, setKeymapResult] = useState<KeymapResult | undefined>(
     undefined,
   );
 
@@ -118,21 +105,22 @@ const useKeymapExtension = (
     const settingKeyMap = async (name?: KeyMapMode) => {
       // Pass a stable wrapper function that delegates to the ref
       const stableOnSave = () => onSaveRef.current?.();
-      setKeymapExtension(await getKeymap(name, stableOnSave));
+      setKeymapResult(await getKeymap(name, stableOnSave));
     };
     settingKeyMap(keymapMode);
   }, [keymapMode]);
 
   useEffect(() => {
-    if (keymapExtension == null) {
+    if (keymapResult == null) {
       return;
     }
-    const wrapWithPrecedence = getKeymapPrecedence(keymapMode);
     const cleanupFunction = codeMirrorEditor?.appendExtensions(
-      wrapWithPrecedence(keymapExtension),
+      keymapResult.precedence(keymapResult.extension),
     );
     return cleanupFunction;
-  }, [codeMirrorEditor, keymapExtension, keymapMode]);
+  }, [codeMirrorEditor, keymapResult]);
+
+  return keymapResult;
 };
 
 export const useEditorSettings = (
@@ -140,9 +128,13 @@ export const useEditorSettings = (
   editorSettings?: EditorSettings,
   onSave?: () => void,
 ): void => {
-  useEditorShortcuts(codeMirrorEditor, editorSettings?.keymapMode);
+  const keymapResult = useKeymapExtension(
+    codeMirrorEditor,
+    editorSettings?.keymapMode,
+    onSave,
+  );
+  useEditorShortcuts(codeMirrorEditor, keymapResult?.overrides);
   useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
   useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
   useThemeExtension(codeMirrorEditor, editorSettings?.theme);
-  useKeymapExtension(codeMirrorEditor, editorSettings?.keymapMode, onSave);
 };

+ 43 - 30
packages/editor/src/client/stores/use-editor-shortcuts.ts

@@ -2,7 +2,6 @@ import { useEffect } from 'react';
 import type { EditorView } from '@codemirror/view';
 import { type KeyBinding, keymap } from '@codemirror/view';
 
-import type { KeyMapMode } from '../../consts';
 import type { UseCodeMirrorEditor } from '../services';
 import { useAddMultiCursorKeyBindings } from '../services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor';
 import { useInsertBlockquoteKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-blockquote';
@@ -14,57 +13,71 @@ import { useMakeTextCodeKeyBinding } from '../services/use-codemirror-editor/uti
 import { useMakeCodeBlockExtension } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-code-block';
 import { useMakeTextItalicKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-italic';
 import { useMakeTextStrikethroughKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-strikethrough';
+import type { ShortcutCategory } from '../services-internal/keymaps';
+
+interface CategorizedKeyBindings {
+  readonly category: ShortcutCategory | null;
+  readonly bindings: readonly KeyBinding[];
+}
 
 const useKeyBindings = (
   view?: EditorView,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): KeyBinding[] => {
-  // Standard formatting keybindings (used for non-emacs modes)
-  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(
-    view,
-    keymapModeName,
-  );
+  // Formatting keybindings
+  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(view);
   const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
   const makeTextStrikethroughKeyBinding =
     useMakeTextStrikethroughKeyBinding(view);
   const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
 
-  // Shared keybindings (no conflicts with any keymap mode)
+  // Structural keybindings
   const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
   const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
   const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
   const insertLinkKeyBinding = useInsertLinkKeyBinding(view);
+
+  // Always-on keybindings
   const multiCursorKeyBindings = useAddMultiCursorKeyBindings();
 
-  const sharedKeyBindings: KeyBinding[] = [
-    insertNumberedKeyBinding,
-    insertBulletListKeyBinding,
-    insertBlockquoteKeyBinding,
-    insertLinkKeyBinding,
-    ...multiCursorKeyBindings,
+  const allGroups: CategorizedKeyBindings[] = [
+    {
+      category: 'formatting',
+      bindings: [
+        makeTextBoldKeyBinding,
+        makeTextItalicKeyBinding,
+        makeTextStrikethroughKeyBinding,
+        makeTextCodeCommand,
+      ],
+    },
+    {
+      category: 'structural',
+      bindings: [
+        insertNumberedKeyBinding,
+        insertBulletListKeyBinding,
+        insertBlockquoteKeyBinding,
+        insertLinkKeyBinding,
+      ],
+    },
+    {
+      category: null,
+      bindings: multiCursorKeyBindings,
+    },
   ];
 
-  // In emacs mode, formatting keybindings (bold, italic, strikethrough, code)
-  // are registered in the emacs plugin via EmacsHandler.bindKey (C-c C-s prefix).
-  // Exclude them here to avoid conflicts with Emacs navigation keys.
-  if (keymapModeName === 'emacs') {
-    return sharedKeyBindings;
-  }
-
-  return [
-    makeTextBoldKeyBinding,
-    makeTextItalicKeyBinding,
-    makeTextStrikethroughKeyBinding,
-    makeTextCodeCommand,
-    ...sharedKeyBindings,
-  ];
+  return allGroups
+    .filter(
+      (group) =>
+        group.category === null || !overrides?.includes(group.category),
+    )
+    .flatMap((group) => [...group.bindings]);
 };
 
 export const useEditorShortcuts = (
   codeMirrorEditor?: UseCodeMirrorEditor,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): void => {
-  const keyBindings = useKeyBindings(codeMirrorEditor?.view, keymapModeName);
+  const keyBindings = useKeyBindings(codeMirrorEditor?.view, overrides);
 
   // Since key combinations of 4 or more keys cannot be implemented with CodeMirror's keybinding, they are implemented as Extensions.
   const makeCodeBlockExtension = useMakeCodeBlockExtension();