Просмотр исходного кода

Merge pull request #10980 from growilabs/claude/fix-codemirror-customization-2Ancf

feat(editor): refactor keymap architecture and add full Emacs markdown-mode keybindings
mergify[bot] 3 дней назад
Родитель
Сommit
1b2618ff64
25 измененных файлов с 2173 добавлено и 247 удалено
  1. 638 0
      .kiro/specs/editor-keymaps/design.md
  2. 189 0
      .kiro/specs/editor-keymaps/requirements.md
  3. 118 0
      .kiro/specs/editor-keymaps/research.md
  4. 22 0
      .kiro/specs/editor-keymaps/spec.json
  5. 147 0
      .kiro/specs/editor-keymaps/tasks.md
  6. 128 0
      apps/app/playwright/23-editor/emacs-keymap.spec.ts
  7. 15 0
      packages/editor/src/client/services-internal/keymaps/default.ts
  8. 40 0
      packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts
  9. 30 0
      packages/editor/src/client/services-internal/keymaps/emacs/index.ts
  10. 251 0
      packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts
  11. 108 0
      packages/editor/src/client/services-internal/keymaps/emacs/structural.ts
  12. 8 9
      packages/editor/src/client/services-internal/keymaps/index.ts
  13. 48 0
      packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts
  14. 11 0
      packages/editor/src/client/services-internal/keymaps/types.ts
  15. 19 7
      packages/editor/src/client/services-internal/keymaps/vim.ts
  16. 15 0
      packages/editor/src/client/services-internal/keymaps/vscode.ts
  17. 2 0
      packages/editor/src/client/services-internal/markdown-utils/index.ts
  18. 59 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts
  19. 164 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts
  20. 61 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts
  21. 33 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts
  22. 3 42
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts
  23. 4 161
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts
  24. 15 8
      packages/editor/src/client/stores/use-editor-settings.ts
  25. 45 20
      packages/editor/src/client/stores/use-editor-shortcuts.ts

+ 638 - 0
.kiro/specs/editor-keymaps/design.md

@@ -0,0 +1,638 @@
+# Design Document: editor-keymaps
+
+## Overview
+
+**Purpose**: This feature refactors the GROWI editor's keymap system into a clean, uniform module architecture and extends Emacs keybindings to cover the full range of markdown-mode operations.
+
+**Users**: Developers maintaining the editor codebase benefit from consistent module boundaries. End users using Emacs keymap mode gain a complete markdown-mode editing experience.
+
+**Impact**: Changes the internal structure of `packages/editor/src/client/services-internal/` and `stores/use-editor-shortcuts.ts`. No external API changes; EditorSettings interface and UI selector remain unchanged.
+
+### Goals
+- Uniform factory interface for all 4 keymap modules with encapsulated precedence and override declarations
+- Eliminate markdown toggle logic duplication between emacs.ts and editor-shortcuts
+- Data-driven shortcut exclusion replacing hard-coded mode checks
+- Relocate `editor-shortcuts/` from public services layer to services-internal where it belongs
+- Complete Emacs markdown-mode keybindings (formatting, structural, navigation, save)
+
+### Non-Goals
+- Changing the keymap selection UI or persistence mechanism (Requirement 8 is verification-only)
+- Adding new keymap modes beyond the existing 4
+- Modifying Vim keybindings beyond structural consistency
+- Full Emacs M-x command palette
+
+## Architecture
+
+### Existing Architecture Analysis
+
+Current module layout and problems:
+
+```
+services/ (PUBLIC API)
+  use-codemirror-editor/
+    utils/
+      insert-markdown-elements.ts   ← hook, exposed via public API ✓
+      insert-prefix.ts              ← hook, exposed via public API ✓
+      editor-shortcuts/             ← NOT exported, only used by stores/ ✗ MISPLACED
+        make-text-bold.ts
+        make-text-italic.ts
+        ...
+
+services-internal/ (INTERNAL)
+  keymaps/
+    index.ts        ← Dispatcher with inline default/vscode logic
+    vim.ts          ← Top-level side effects (Vim.map at module scope)
+    emacs.ts        ← Local toggleMarkdownSymbol duplicating hook logic
+
+stores/
+  use-editor-settings.ts   ← Contains getKeymapPrecedence() mode branching
+  use-editor-shortcuts.ts  ← Hard-coded `if (mode === 'emacs')` exclusion
+```
+
+**Problems**:
+1. `editor-shortcuts/` is in public `services/` tree but never exported — layer violation
+2. No dedicated module for default/vscode modes
+3. Precedence logic leaked to consumer (`getKeymapPrecedence`)
+4. Override knowledge leaked to shortcut registration (`if emacs` check)
+5. Markdown toggle duplicated in emacs.ts vs `useInsertMarkdownElements`
+6. emacs.ts will accumulate 19+ commands in a single file — low cohesion
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph consts
+        KeyMapMode[KeyMapMode type]
+        KeymapResult[KeymapResult interface]
+        ShortcutCategory[ShortcutCategory type]
+    end
+
+    subgraph services-internal
+        subgraph markdown-utils
+            ToggleSymbol[toggleMarkdownSymbol]
+            LinePrefix[insertLinePrefix]
+        end
+        subgraph keymaps
+            Dispatcher[index.ts dispatcher]
+            DefaultMod[default.ts]
+            VscodeMod[vscode.ts]
+            VimMod[vim.ts]
+            subgraph emacs
+                EmacsIndex[emacs/index.ts]
+                EmacsFormatting[emacs/formatting.ts]
+                EmacsStructural[emacs/structural.ts]
+                EmacsNavigation[emacs/navigation.ts]
+            end
+        end
+        subgraph editor-shortcuts
+            ShortcutDefs[CategorizedKeyBindings definitions]
+        end
+    end
+
+    subgraph services-public[services - public]
+        InsertMdHook[useInsertMarkdownElements]
+        InsertPrefixHook[useInsertPrefix]
+    end
+
+    subgraph stores
+        EditorSettings[useEditorSettings]
+        EditorShortcuts[useEditorShortcuts]
+    end
+
+    Dispatcher --> DefaultMod
+    Dispatcher --> VscodeMod
+    Dispatcher --> VimMod
+    Dispatcher --> EmacsIndex
+    EmacsIndex --> EmacsFormatting
+    EmacsIndex --> EmacsStructural
+    EmacsIndex --> EmacsNavigation
+    EmacsFormatting --> ToggleSymbol
+    EmacsStructural --> LinePrefix
+    InsertMdHook --> ToggleSymbol
+    InsertPrefixHook --> LinePrefix
+    EditorSettings --> Dispatcher
+    EditorSettings --> EditorShortcuts
+    EditorShortcuts --> ShortcutDefs
+    EditorShortcuts -.->|reads overrides| KeymapResult
+```
+
+**Architecture Integration**:
+- Selected pattern: Factory with structured return object (see `research.md` — Pattern A)
+- Domain boundaries: Each keymap module owns its bindings, precedence, and override declarations
+- Emacs module split into submodules by responsibility (formatting / structural / navigation)
+- Pure functions in `markdown-utils/` shared by both public hooks and internal keymaps
+- `editor-shortcuts/` relocated to `services-internal/` to match its actual visibility
+- Existing patterns preserved: Async lazy loading, `appendExtensions` lifecycle
+- Steering compliance: Feature-based organization, named exports, immutability, high cohesion
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | CodeMirror 6 (@codemirror/view, @codemirror/state) | Extension system, keymap API | Existing |
+| Frontend | @replit/codemirror-emacs 6.1.0 | EmacsHandler.bindKey/addCommands | Existing |
+| Frontend | @replit/codemirror-vim 6.2.1 | Vim.map/defineEx | Existing |
+| Frontend | @replit/codemirror-vscode-keymap 6.0.2 | VSCode keybindings | Existing |
+
+No new dependencies introduced.
+
+## System Flows
+
+### Keymap Loading Flow
+
+```mermaid
+sequenceDiagram
+    participant Settings as useEditorSettings
+    participant Dispatcher as getKeymap
+    participant Module as KeymapModule
+    participant CM as CodeMirror
+
+    Settings->>Dispatcher: getKeymap(mode, onSave)
+    Dispatcher->>Module: module.create(onSave?)
+    Module-->>Dispatcher: KeymapResult
+    Dispatcher-->>Settings: KeymapResult
+    Settings->>CM: appendExtensions(result.precedence(result.extension))
+    Settings->>CM: pass result.overrides to useEditorShortcuts
+```
+
+Key decisions:
+- Dispatcher is a thin router; all logic lives in modules
+- `KeymapResult.precedence` is a function (`Prec.high` or `Prec.low`) applied by the consumer
+- `overrides` array flows to shortcut registration for data-driven exclusion
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Dedicated module per mode | default.ts, vscode.ts, vim.ts, emacs/ | KeymapFactory | Keymap Loading |
+| 1.2 | Uniform async factory interface | All keymap modules | KeymapFactory | Keymap Loading |
+| 1.3 | No inline logic in dispatcher | keymaps/index.ts | — | Keymap Loading |
+| 1.4 | Encapsulated precedence | KeymapResult interface | KeymapResult | Keymap Loading |
+| 2.1 | Shared toggle utility | markdown-utils/toggleMarkdownSymbol | — | — |
+| 2.2 | Emacs uses shared logic | emacs/formatting.ts | — | — |
+| 2.3 | No duplicate toggle impl | Remove local emacs.ts toggle | — | — |
+| 3.1 | Keymap declares overrides | KeymapResult.overrides | ShortcutCategory | — |
+| 3.2 | Shortcut registration consults overrides | useEditorShortcuts | CategorizedKeyBindings | — |
+| 3.3 | New modes need no shortcut changes | Data-driven exclusion | ShortcutCategory | — |
+| 4.1-4.5 | Existing Emacs formatting bindings | emacs/formatting.ts | EmacsHandler | — |
+| 5.1-5.7 | Emacs structural bindings | emacs/structural.ts | EmacsHandler | — |
+| 6.1-6.2 | Emacs C-x C-s save | emacs/index.ts | KeymapFactory (onSave) | — |
+| 7.1-7.2 | Vim module consistency | vim.ts | KeymapFactory | — |
+| 8.1-8.3 | UI consistency | OptionsSelector | — | — |
+| 9.1-9.9 | Extended markdown-mode bindings | emacs/navigation.ts | EmacsHandler | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------------|--------|--------------|------------------|-----------|
+| KeymapResult | consts | Structured return type for keymap factories | 1.2, 1.4, 3.1 | — | Type |
+| ShortcutCategory | consts | Override category type | 3.1, 3.2, 3.3 | — | Type |
+| CategorizedKeyBindings | consts | KeyBindings grouped by category | 3.2 | — | Type |
+| toggleMarkdownSymbol | markdown-utils | Pure function for markdown wrap/unwrap | 2.1, 2.2, 2.3 | @codemirror/state | Service |
+| insertLinePrefix | markdown-utils | Pure function for line prefix operations | 5.1, 5.3, 5.4, 5.5 | @codemirror/state | Service |
+| keymaps/default.ts | keymaps | Default keymap module | 1.1 | @codemirror/commands | Service |
+| keymaps/vscode.ts | keymaps | VSCode keymap module | 1.1 | @replit/codemirror-vscode-keymap | Service |
+| keymaps/vim.ts | keymaps | Vim keymap module (refactored) | 1.1, 7.1, 7.2 | @replit/codemirror-vim | Service |
+| keymaps/emacs/ | keymaps | Emacs keymap module (split by responsibility) | 1.1, 4-6, 9 | @replit/codemirror-emacs | Service |
+| keymaps/index.ts | keymaps | Thin dispatcher | 1.2, 1.3 | All keymap modules | Service |
+| editor-shortcuts/ | services-internal | Categorized shortcut definitions | 3.2 | markdown-utils | Service |
+| useEditorShortcuts | stores | Data-driven shortcut registration | 3.1, 3.2, 3.3 | editor-shortcuts, KeymapResult | State |
+| useEditorSettings | stores | Keymap lifecycle (simplified) | 1.4 | getKeymap | State |
+| OptionsSelector | UI | Keymap selector (no changes) | 8.1-8.3 | — | — |
+
+### Consts Layer
+
+#### KeymapResult Interface
+
+| Field | Detail |
+|-------|--------|
+| Intent | Structured return type encapsulating keymap extension, precedence, and override metadata |
+| Requirements | 1.2, 1.4, 3.1 |
+
+**Contracts**: Type [x]
+
+```typescript
+type ShortcutCategory = 'formatting' | 'structural' | 'navigation';
+
+interface KeymapResult {
+  readonly extension: Extension;
+  readonly precedence: (ext: Extension) => Extension; // Prec.high or Prec.low
+  readonly overrides: readonly ShortcutCategory[];
+}
+
+type KeymapFactory = (onSave?: () => void) => Promise<KeymapResult>;
+```
+
+#### CategorizedKeyBindings Type
+
+| Field | Detail |
+|-------|--------|
+| Intent | Group KeyBindings by ShortcutCategory for data-driven exclusion |
+| Requirements | 3.2 |
+
+**Contracts**: Type [x]
+
+```typescript
+interface CategorizedKeyBindings {
+  readonly category: ShortcutCategory | null; // null = always included (e.g., multiCursor)
+  readonly bindings: readonly KeyBinding[];
+}
+```
+
+Each shortcut definition module returns a `CategorizedKeyBindings` object instead of raw `KeyBinding[]`. `null` category means always active regardless of overrides.
+
+### Shared Utils Layer (`services-internal/markdown-utils/`)
+
+Pure functions usable by both public hooks and internal keymaps. No React dependencies.
+
+#### toggleMarkdownSymbol
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function to wrap/unwrap selected text with markdown symbols |
+| Requirements | 2.1, 2.2, 2.3 |
+
+**Contracts**: Service [x]
+
+```typescript
+/**
+ * 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.
+ */
+const toggleMarkdownSymbol: (
+  view: EditorView,
+  prefix: string,
+  suffix: string,
+) => void;
+```
+
+**Implementation Notes**
+- Extracted from current `emacs.ts` local function
+- `useInsertMarkdownElements` hook in `services/` becomes a thin wrapper: `useCallback((p, s) => toggleMarkdownSymbol(view, p, s), [view])`
+- Location: `packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts`
+
+#### insertLinePrefix
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function to insert/toggle prefix at line beginnings |
+| Requirements | 5.1, 5.3, 5.4, 5.5 |
+
+**Contracts**: Service [x]
+
+```typescript
+/**
+ * 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.
+ */
+const insertLinePrefix: (
+  view: EditorView,
+  prefix: string,
+  noSpaceIfPrefixExists?: boolean,
+) => void;
+```
+
+**Implementation Notes**
+- Extracted from current `useInsertPrefix` hook
+- Hook becomes a thin wrapper
+- Location: `packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts`
+
+#### Dependency Direction
+
+```
+services/ (public hooks)
+  useInsertMarkdownElements ──imports──> services-internal/markdown-utils/toggleMarkdownSymbol
+  useInsertPrefix           ──imports──> services-internal/markdown-utils/insertLinePrefix
+
+services-internal/ (internal)
+  keymaps/emacs/formatting  ──imports──> services-internal/markdown-utils/toggleMarkdownSymbol
+  keymaps/emacs/structural  ──imports──> services-internal/markdown-utils/insertLinePrefix
+  editor-shortcuts/         ──imports──> services-internal/markdown-utils/ (via pure functions)
+```
+
+Both public hooks and internal modules depend on the same internal pure functions.
+
+**Layer Rule Exception**: `markdown-utils/` is explicitly designated as a **shared pure-function sublayer** within `services-internal/`. Public hooks in `services/` are permitted to import from `services-internal/markdown-utils/` as thin wrappers. This exception is scoped to pure functions with no React dependencies — other `services-internal/` modules remain off-limits to `services/`. This pattern avoids duplication (Req 2.3) while keeping the public API surface minimal.
+
+### Keymaps Layer
+
+#### keymaps/default.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Default CodeMirror keymap module |
+| Requirements | 1.1 |
+
+**Contracts**: Service [x]
+
+```typescript
+const defaultKeymap: KeymapFactory;
+// Returns:
+// - extension: keymap.of(defaultKeymap from @codemirror/commands)
+// - precedence: Prec.low
+// - overrides: [] (no overrides)
+```
+
+#### keymaps/vscode.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | VSCode keymap module |
+| Requirements | 1.1 |
+
+**Contracts**: Service [x]
+
+```typescript
+const vscodeKeymap: KeymapFactory;
+// Returns:
+// - extension: keymap.of(vscodeKeymap from @replit/codemirror-vscode-keymap)
+// - precedence: Prec.low
+// - overrides: [] (no overrides)
+```
+
+#### keymaps/vim.ts (Refactored)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Vim keymap module with side effects encapsulated in factory |
+| Requirements | 1.1, 7.1, 7.2 |
+
+**Responsibilities & Constraints**
+- Moves `Vim.map('jj', '<Esc>', 'insert')` and `Vim.map('jk', '<Esc>', 'insert')` inside factory
+- Registers `:w` ex-command inside factory when onSave provided
+- Uses idempotency guard to prevent duplicate registration on re-import
+
+**Contracts**: Service [x]
+
+```typescript
+const vimKeymap: KeymapFactory;
+// Returns:
+// - extension: vim()
+// - precedence: Prec.high
+// - overrides: [] (Vim uses its own modal system, no standard shortcut conflicts)
+```
+
+#### keymaps/emacs/ (Split Module)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Emacs keymap module split by responsibility for high cohesion |
+| Requirements | 1.1, 4.1-4.5, 5.1-5.7, 6.1-6.2, 9.1-9.9 |
+
+**Module Structure**:
+```
+keymaps/emacs/
+├── index.ts          ← Factory: composes submodules, registers with EmacsHandler, returns KeymapResult
+├── formatting.ts     ← C-c C-s formatting commands (bold, italic, code, strikethrough, code block)
+├── structural.ts     ← C-c C-s/C-c C- structural commands (headings, lists, blockquote, link, HR)
+└── navigation.ts     ← C-c C- navigation/editing commands (heading nav, promote/demote, kill, image, table)
+```
+
+**Submodule Responsibilities**:
+
+Each submodule exports a registration function:
+```typescript
+type EmacsBindingRegistrar = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+  options?: { onSave?: () => void },
+) => void;
+```
+
+**emacs/index.ts** — Factory & Composition:
+```typescript
+const emacsKeymap: KeymapFactory;
+// 1. Dynamically imports @replit/codemirror-emacs
+// 2. Calls registerFormattingBindings(EmacsHandler)
+// 3. Calls registerStructuralBindings(EmacsHandler)
+// 4. Calls registerNavigationBindings(EmacsHandler)
+// 5. Registers save: C-x C-s → onSave callback
+// 6. Returns { extension: emacs(), precedence: Prec.high, overrides: ['formatting', 'structural'] }
+```
+
+**emacs/formatting.ts** — Req 4.1-4.5:
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownBold | `C-c C-s b\|C-c C-s S-b` | toggleMarkdownSymbol(view, '**', '**') |
+| markdownItalic | `C-c C-s i\|C-c C-s S-i` | toggleMarkdownSymbol(view, '*', '*') |
+| markdownCode | `C-c C-s c` | toggleMarkdownSymbol(view, '`', '`') |
+| markdownStrikethrough | `C-c C-s s` | toggleMarkdownSymbol(view, '~~', '~~') |
+| markdownCodeBlock | `C-c C-s p` | toggleMarkdownSymbol(view, '```\n', '\n```') |
+
+**emacs/structural.ts** — Req 5.1-5.7:
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownBlockquote | `C-c C-s q` | insertLinePrefix(view, '>') |
+| markdownLink | `C-c C-l` | toggleMarkdownSymbol(view, '[', ']()') |
+| markdownHorizontalRule | `C-c C-s -` | Insert '---' at current line |
+| markdownHeadingDwim | `C-c C-s h` | Auto-determine heading level |
+| markdownHeading1-6 | `C-c C-s 1`~`6` | insertLinePrefix(view, '# '...'###### ') |
+| markdownNewListItem | `C-c C-j` | Insert new list item matching context |
+| markdownFencedCodeBlock | `C-c C-s S-c` | Insert GFM fenced code block |
+
+**emacs/navigation.ts** — Req 9.1-9.9:
+
+**Multi-Key Prefix Compatibility Note**: All `C-c C-{key}` bindings use the same 2-stroke prefix mechanism validated in PR #10980 (`C-c C-s` prefix). `EmacsHandler.bindKey` supports multi-key sequences where `C-c` acts as a prefix map — subsequent keystrokes (`C-n`, `C-f`, `C-b`, `C-p`, etc.) are dispatched from the prefix map, not as standalone Emacs commands. This has been confirmed working with the `C-c C-s` prefix in production. If any binding conflicts with a base Emacs command (e.g., `C-c C-f` shadowing `forward-char` after `C-c`), the prefix map takes priority by design — the base command remains accessible without the `C-c` prefix.
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownPromote | `C-c C--` | Decrease heading level or outdent list |
+| markdownDemote | `C-c C-=` | Increase heading level or indent list |
+| markdownNextHeading | `C-c C-n` | Navigate to next heading |
+| markdownPrevHeading | `C-c C-p` | Navigate to previous heading |
+| markdownNextSiblingHeading | `C-c C-f` | Navigate to next heading at same level |
+| markdownPrevSiblingHeading | `C-c C-b` | Navigate to previous heading at same level |
+| markdownUpHeading | `C-c C-u` | Navigate to parent heading |
+| markdownKill | `C-c C-k` | Kill element at point |
+| markdownImage | `C-c C-i` | Insert image template |
+| markdownTable | `C-c C-s t` | Insert table template |
+| markdownFootnote | `C-c C-s f` | Insert footnote pair |
+
+#### keymaps/index.ts (Simplified Dispatcher)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Thin routing dispatcher delegating to keymap modules |
+| Requirements | 1.2, 1.3 |
+
+**Contracts**: Service [x]
+
+```typescript
+const getKeymap: (
+  keyMapName?: KeyMapMode,
+  onSave?: () => void,
+) => Promise<KeymapResult>;
+```
+
+Implementation is a simple switch delegating to each module's factory. No inline keymap construction.
+
+### Editor Shortcuts Layer (`services-internal/editor-shortcuts/`)
+
+Relocated from `services/use-codemirror-editor/utils/editor-shortcuts/`.
+
+| Field | Detail |
+|-------|--------|
+| Intent | Categorized shortcut definitions for data-driven registration |
+| Requirements | 3.2 |
+
+**Key Change**: Each shortcut module returns `CategorizedKeyBindings` instead of raw `KeyBinding`:
+
+```typescript
+// Example: formatting shortcuts
+const formattingKeyBindings: (view?: EditorView, keymapMode?: KeyMapMode) => CategorizedKeyBindings;
+// Returns: { category: 'formatting', bindings: [bold, italic, strikethrough, code] }
+
+// Example: structural shortcuts
+const structuralKeyBindings: (view?: EditorView) => CategorizedKeyBindings;
+// Returns: { category: 'structural', bindings: [numbered, bullet, blockquote, link] }
+
+// Example: always-on shortcuts
+const alwaysOnKeyBindings: () => CategorizedKeyBindings;
+// Returns: { category: null, bindings: [...multiCursor] }
+```
+
+**Implementation Notes**:
+- Individual shortcut files (make-text-bold.ts, etc.) remain as-is internally but are grouped by the categorized wrapper
+- `generateAddMarkdownSymbolCommand` refactored to use pure `toggleMarkdownSymbol` directly instead of via hook
+- Move path: `services/use-codemirror-editor/utils/editor-shortcuts/` → `services-internal/editor-shortcuts/`
+
+### Stores Layer
+
+#### useEditorShortcuts (Refactored)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Data-driven shortcut registration using keymap override metadata |
+| Requirements | 3.1, 3.2, 3.3 |
+
+**Contracts**: State [x]
+
+```typescript
+const useEditorShortcuts: (
+  codeMirrorEditor?: UseCodeMirrorEditor,
+  overrides?: readonly ShortcutCategory[],
+) => void;
+```
+
+**Key Change**: Parameter changes from `keymapModeName?: KeyMapMode` to `overrides?: readonly ShortcutCategory[]`.
+
+Exclusion logic:
+```typescript
+const allGroups: CategorizedKeyBindings[] = [
+  formattingKeyBindings(view, keymapMode),
+  structuralKeyBindings(view),
+  alwaysOnKeyBindings(),
+];
+
+const activeBindings = allGroups
+  .filter(group => group.category === null || !overrides?.includes(group.category))
+  .flatMap(group => group.bindings);
+```
+
+#### useEditorSettings (Simplified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Keymap lifecycle with simplified precedence handling |
+| Requirements | 1.4 |
+
+**Key Change**: Remove `getKeymapPrecedence()` function. Use `keymapResult.precedence` directly:
+
+```typescript
+// Before:
+const wrapWithPrecedence = getKeymapPrecedence(keymapMode);
+codeMirrorEditor?.appendExtensions(wrapWithPrecedence(keymapExtension));
+
+// After:
+codeMirrorEditor?.appendExtensions(keymapResult.precedence(keymapResult.extension));
+```
+
+Pass `keymapResult.overrides` to `useEditorShortcuts` instead of `keymapMode`.
+
+## Target Directory Structure
+
+```
+packages/editor/src/client/
+├── services/                              (PUBLIC API — unchanged contract)
+│   └── use-codemirror-editor/
+│       ├── use-codemirror-editor.ts       (hook: wraps pure functions for public API)
+│       └── utils/
+│           ├── insert-markdown-elements.ts (thin wrapper → markdown-utils/toggleMarkdownSymbol)
+│           ├── insert-prefix.ts           (thin wrapper → markdown-utils/insertLinePrefix)
+│           └── ...                        (other utils unchanged)
+│           (editor-shortcuts/ REMOVED — moved to services-internal/)
+│
+├── services-internal/
+│   ├── markdown-utils/                    (NEW: pure functions, no React deps)
+│   │   ├── index.ts
+│   │   ├── toggle-markdown-symbol.ts
+│   │   └── insert-line-prefix.ts
+│   ├── keymaps/
+│   │   ├── index.ts                       (thin dispatcher)
+│   │   ├── types.ts                       (KeymapResult, KeymapFactory, ShortcutCategory)
+│   │   ├── default.ts                     (NEW)
+│   │   ├── vscode.ts                      (NEW)
+│   │   ├── vim.ts                         (refactored: side effects inside factory)
+│   │   └── emacs/                         (SPLIT from single file)
+│   │       ├── index.ts                   (factory + composition)
+│   │       ├── formatting.ts              (C-c C-s formatting bindings)
+│   │       ├── structural.ts              (C-c C-s/C-c C- structural bindings)
+│   │       └── navigation.ts              (C-c C- navigation/editing bindings)
+│   ├── editor-shortcuts/                  (MOVED from services/)
+│   │   ├── index.ts                       (re-exports CategorizedKeyBindings groups)
+│   │   ├── types.ts                       (CategorizedKeyBindings)
+│   │   ├── formatting.ts                  (bold, italic, strikethrough, code)
+│   │   ├── structural.ts                  (numbered, bullet, blockquote, link)
+│   │   ├── always-on.ts                   (multiCursor)
+│   │   ├── make-code-block-extension.ts   (4-key combo as Extension)
+│   │   └── generate-add-markdown-symbol-command.ts
+│   └── ...                                (other services-internal unchanged)
+│
+└── stores/
+    ├── use-editor-settings.ts             (simplified: no getKeymapPrecedence)
+    └── use-editor-shortcuts.ts            (refactored: category-based exclusion)
+```
+
+### Affected Files: `editor-shortcuts/` Relocation
+
+Moving `editor-shortcuts/` from `services/use-codemirror-editor/utils/` to `services-internal/` affects the following import paths:
+
+| File | Import Count | Change |
+|------|-------------|--------|
+| `stores/use-editor-shortcuts.ts` | 10 imports | Rewrite all `../services/use-codemirror-editor/utils/editor-shortcuts/` → `../services-internal/editor-shortcuts/` |
+| `stores/use-editor-settings.ts` | 1 import (indirect via `use-editor-shortcuts`) | No change needed (imports `useEditorShortcuts` hook, not shortcuts directly) |
+
+No other files in the codebase import from `editor-shortcuts/`. The relocation is self-contained within `stores/use-editor-shortcuts.ts` import rewrites plus the physical directory move.
+
+## Data Models
+
+No data model changes. EditorSettings interface and localStorage persistence remain unchanged.
+
+## Error Handling
+
+### Error Strategy
+- EmacsHandler command registration failures: Log warning, continue with base emacs bindings
+- Missing onSave callback: Silently ignore C-x C-s / :w (6.2)
+- Duplicate command registration: Idempotency guard prevents double-registration
+
+## Testing Strategy
+
+### Unit Tests
+- `toggleMarkdownSymbol`: wrap, unwrap, empty selection, nested symbols — 5+ cases
+- `insertLinePrefix`: single line, multi-line, toggle off, with indent — 4+ cases
+- Each keymap factory returns correct `KeymapResult` shape (precedence, overrides)
+- `CategorizedKeyBindings` exclusion logic with various override combinations
+- Emacs submodule registration: formatting, structural, navigation each register expected commands
+
+### Integration Tests
+- Emacs mode: C-c C-s b toggles bold in editor
+- Emacs mode: C-x C-s triggers save callback
+- Vim mode: :w triggers save callback
+- Mode switching preserves document content
+- Shortcut exclusion: formatting shortcuts absent in Emacs mode, present in default mode
+
+### E2E Tests
+- Extend existing `playwright/23-editor/vim-keymap.spec.ts` pattern for Emacs keybindings
+- Keymap selector switches modes without reload

+ 189 - 0
.kiro/specs/editor-keymaps/requirements.md

@@ -0,0 +1,189 @@
+# Requirements Document
+
+## Introduction
+
+GROWI のエディタは CodeMirror 6 をベースに、4 つのキーマップモード(default, vscode, vim, emacs)をサポートしている。本仕様では以下の 2 つの目的を達成する:
+
+1. **モジュール構成のリファクタリング**: 各キーマップモードの責務・モジュール境界を整理し、一貫性のあるクリーンなアーキテクチャにリファクタする
+2. **Emacs キーバインディングの拡充**: PR #10980 で導入された Emacs markdown-mode バインディング(`C-c C-s` プレフィックス)を拡張し、本家 [jrblevin/markdown-mode](https://github.com/jrblevin/markdown-mode) を参考にした網羅的な Markdown 操作バインディングを提供する
+
+### Priority Order
+
+- **高優先**: Requirement 1-3 (モジュールリファクタリング) → Requirement 6-7 (save/vim 一貫性) → Requirement 8 (UI)
+- **中優先**: Requirement 4 (既存 formatting bindings の維持)
+- **低優先**: Requirement 5, 9 (追加 Emacs バインディング) — 本家 markdown-mode 準拠の拡充は最後に対応
+
+### Current State (PR #10980)
+
+- `packages/editor/src/client/services-internal/keymaps/` に vim.ts, emacs.ts が存在し、index.ts がディスパッチャ
+- default と vscode は index.ts 内でインラインに処理されており、独立モジュールがない
+- `toggleMarkdownSymbol` が emacs.ts 内にローカル実装されており、既存の `generateAddMarkdownSymbolCommand` / `useInsertMarkdownElements` と責務が重複
+- `use-editor-shortcuts.ts` が emacs モード判定のための条件分岐を持ち、各キーマップの差異を外部から管理している
+- Emacs モードでは formatting 系ショートカット(bold, italic, strikethrough, code)のみ C-c C-s で提供、リスト・引用・リンク等は未対応
+
+### Reference: jrblevin/markdown-mode Keybindings
+
+本家 Emacs markdown-mode の主要キーバインド(実装対象の参照用):
+
+**Text Styling (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s i` | Italic |
+| `C-c C-s b` | Bold |
+| `C-c C-s c` | Inline code |
+| `C-c C-s k` | `<kbd>` tag |
+| `C-c C-s q` / `C-c C-s Q` | Blockquote (word / region) |
+| `C-c C-s p` / `C-c C-s P` | Preformatted code block (word / region) |
+| `C-c C-s C` | GFM fenced code block |
+| `C-c C-s s` | Strikethrough (GROWI extension, not in original) |
+
+**Headings (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s h` / `C-c C-s H` | Auto heading (atx / setext) |
+| `C-c C-s 1` ~ `C-c C-s 6` | ATX heading level 1-6 |
+| `C-c C-s !` | Setext heading level 1 |
+| `C-c C-s @` | Setext heading level 2 |
+
+**Links & Images (C-c C-)**
+| Key | Command |
+|-----|---------|
+| `C-c C-l` | Insert/edit link |
+| `C-c C-i` | Insert/edit image |
+
+**Horizontal Rule & Footnotes (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s -` | Horizontal rule |
+| `C-c C-s f` | Footnote |
+| `C-c C-s w` | Wiki link |
+| `C-c C-s t` | Table |
+
+**Promotion & Demotion**
+| Key | Command |
+|-----|---------|
+| `C-c C--` / `C-c LEFT` | Promote (outdent) |
+| `C-c C-=` / `C-c RIGHT` | Demote (indent) |
+
+**List Editing**
+| Key | Command |
+|-----|---------|
+| `M-RET` / `C-c C-j` | New list item |
+| `C-c UP/DOWN` | Move list item up/down |
+
+**Outline Navigation**
+| Key | Command |
+|-----|---------|
+| `C-c C-n` / `C-c C-p` | Next/previous heading (any level) |
+| `C-c C-f` / `C-c C-b` | Next/previous heading (same level) |
+| `C-c C-u` | Up to parent heading |
+
+**Other**
+| Key | Command |
+|-----|---------|
+| `C-c C-k` | Kill element at point |
+| `C-c C-o` | Open link at point |
+| `C-c C-x C-s` / `C-x C-s` | Save |
+
+## Requirements
+
+### Requirement 1: Uniform Keymap Module Structure
+
+**Objective:** As a developer, I want each keymap mode to have a consistent module structure, so that adding or modifying keymaps follows a predictable pattern and reduces coupling.
+
+#### Acceptance Criteria
+
+1. The Editor shall provide a dedicated module file for each keymap mode (default, vscode, vim, emacs) under `keymaps/` directory.
+2. When a keymap mode is loaded, the Keymap Dispatcher shall delegate to the corresponding module via the same async factory interface (`() => Promise<Extension>`).
+3. The Editor shall not contain inline keymap construction logic in the dispatcher; all mode-specific logic shall reside in each mode's dedicated module.
+4. Each keymap module shall encapsulate its own precedence requirement (high/low) so that the consumer does not need mode-specific branching for precedence.
+
+### Requirement 2: Shared Markdown Formatting Utility
+
+**Objective:** As a developer, I want markdown symbol toggling logic to be shared across keymap modules and editor shortcuts, so that formatting behavior is consistent and not duplicated.
+
+#### Acceptance Criteria
+
+1. The Editor shall provide a single shared utility for toggling markdown symbols (wrap/unwrap with prefix/suffix) that can be used by both keymap modules and editor shortcut hooks.
+2. When the Emacs keymap module applies markdown formatting, the Editor shall use the same toggling logic as the standard editor shortcuts.
+3. The Editor shall not have duplicate implementations of markdown symbol toggling in separate modules.
+
+### Requirement 3: Keymap-Aware Shortcut Registration
+
+**Objective:** As a developer, I want each keymap module to declare which standard shortcuts it overrides, so that the shortcut registration layer can exclude conflicts without hard-coded mode checks.
+
+#### Acceptance Criteria
+
+1. Each keymap module shall declare which categories of editor shortcuts it handles internally (e.g., formatting, navigation).
+2. When editor shortcuts are registered, the Shortcut Registration Hook shall consult the active keymap's declared overrides to exclude conflicting bindings.
+3. If a new keymap mode is added, the Shortcut Registration Hook shall not require code changes to handle the new mode's overrides.
+
+### Requirement 4: Emacs Markdown-Mode Formatting Bindings (Existing)
+
+**Objective:** As an Emacs user, I want C-c C-s prefix keybindings for markdown formatting, so that I can use familiar Emacs markdown-mode conventions in the GROWI editor.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C-s b` or `C-c C-s B`, the Editor shall toggle bold formatting (`**`) around the selection or at the cursor.
+2. While Emacs keymap mode is active, when the user types `C-c C-s i` or `C-c C-s I`, the Editor shall toggle italic formatting (`*`) around the selection or at the cursor.
+3. While Emacs keymap mode is active, when the user types `C-c C-s c`, the Editor shall toggle inline code formatting (`` ` ``) around the selection or at the cursor.
+4. While Emacs keymap mode is active, when the user types `C-c C-s s`, the Editor shall toggle strikethrough formatting (`~~`) around the selection or at the cursor.
+5. While Emacs keymap mode is active, when the user types `C-c C-s p`, the Editor shall toggle code block formatting (` ``` `) around the selection or at the cursor.
+
+### Requirement 5: Emacs Structural Editing Bindings
+
+**Objective:** As an Emacs user, I want C-c prefix keybindings for structural markdown operations (lists, blockquotes, links, headings), so that I can perform all common markdown editing without leaving Emacs-style key sequences.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C-s q`, the Editor shall insert or toggle a blockquote prefix (`>`) on the current line, consistent with markdown-mode `markdown-insert-blockquote`.
+2. While Emacs keymap mode is active, when the user types `C-c C-l`, the Editor shall insert a markdown link template (`[]()`) around the selection or at the cursor, consistent with markdown-mode `markdown-insert-link`.
+3. While Emacs keymap mode is active, when the user types `C-c C-s -`, the Editor shall insert a horizontal rule (`---`) at the current line, consistent with markdown-mode `markdown-insert-hr`.
+4. While Emacs keymap mode is active, when the user types `C-c C-s h`, the Editor shall insert an ATX heading with auto-determined level based on context, consistent with markdown-mode `markdown-insert-header-dwim`.
+5. While Emacs keymap mode is active, when the user types `C-c C-s 1` through `C-c C-s 6`, the Editor shall insert or replace the corresponding heading level (`#` through `######`) at the beginning of the current line.
+6. While Emacs keymap mode is active, when the user types `C-c C-j`, the Editor shall insert a new list item appropriate to the current list context (bullet or numbered).
+7. While Emacs keymap mode is active, when the user types `C-c C-s C`, the Editor shall insert a GFM-style fenced code block with language specifier prompt.
+
+### Requirement 6: Emacs Save Binding
+
+**Objective:** As an Emacs user, I want `C-x C-s` to save the page, so that the standard Emacs save keybinding works in the GROWI editor.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-x C-s`, the Editor shall invoke the save action (same as the existing onSave callback used by Vim's `:w`).
+2. If no save callback is provided, the Editor shall silently ignore `C-x C-s` without error.
+
+### Requirement 7: Vim Keymap Module Consistency
+
+**Objective:** As a developer, I want the Vim keymap module to follow the same structural pattern as other keymap modules, so that the codebase is consistent.
+
+#### Acceptance Criteria
+
+1. The Vim keymap module shall follow the same factory interface pattern as all other keymap modules.
+2. The Vim keymap module shall encapsulate its top-level side effects (e.g., `Vim.map` calls) within the factory function rather than at module scope.
+
+### Requirement 8: Keymap Selection UI Consistency
+
+**Objective:** As a user, I want the keymap selector UI to accurately represent all available keymap modes, so that I can choose my preferred editing style.
+
+#### Acceptance Criteria
+
+1. The Keymap Selector shall display all registered keymap modes with appropriate labels and icons.
+2. When the user selects a keymap mode, the Editor shall switch to that mode without requiring a page reload.
+3. The Editor shall persist the selected keymap mode across sessions.
+
+### Requirement 9: Emacs Extended Markdown-Mode Bindings
+
+**Objective:** As an Emacs power user, I want additional markdown-mode keybindings for navigation, promotion/demotion, and advanced editing, so that the GROWI editor feels as close to native Emacs markdown-mode as possible.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C--`, the Editor shall promote (outdent) the current element (heading level decrease or list outdent).
+2. While Emacs keymap mode is active, when the user types `C-c C-=`, the Editor shall demote (indent) the current element (heading level increase or list indent).
+3. While Emacs keymap mode is active, when the user types `C-c C-n` / `C-c C-p`, the Editor shall navigate to the next/previous heading.
+4. While Emacs keymap mode is active, when the user types `C-c C-f` / `C-c C-b`, the Editor shall navigate to the next/previous heading at the same level.
+5. While Emacs keymap mode is active, when the user types `C-c C-u`, the Editor shall navigate up to the parent heading.
+6. While Emacs keymap mode is active, when the user types `C-c C-k`, the Editor shall kill (delete) the element at point and add text content to the clipboard.
+7. While Emacs keymap mode is active, when the user types `C-c C-i`, the Editor shall insert a markdown image template (`![]()`).
+8. While Emacs keymap mode is active, when the user types `C-c C-s t`, the Editor shall insert a markdown table template.
+9. While Emacs keymap mode is active, when the user types `C-c C-s f`, the Editor shall insert a footnote marker and definition pair.

+ 118 - 0
.kiro/specs/editor-keymaps/research.md

@@ -0,0 +1,118 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: editor-keymaps
+- **Discovery Scope**: Extension (existing keymap system)
+- **Key Findings**:
+  - `@replit/codemirror-emacs` EmacsHandler supports multi-stroke key chains natively via `bindKey` and `addCommands`; no C-x C-s save built-in
+  - Existing `toggleMarkdownSymbol` in emacs.ts duplicates logic from `useInsertMarkdownElements` hook; both perform wrap/unwrap but with different APIs (EditorView direct vs hook-based)
+  - Current dispatcher (`getKeymap`) mixes mode-specific concerns (inline vscode/default construction, precedence branching in consumer)
+
+## Research Log
+
+### @replit/codemirror-emacs API Surface
+- **Context**: Need to understand what multi-stroke bindings are possible for C-c C-s, C-c C-, C-x C-s
+- **Sources Consulted**: `node_modules/@replit/codemirror-emacs/dist/index.d.ts`, compiled source
+- **Findings**:
+  - `EmacsHandler.bindKey(keyGroup: string, command: any)` supports pipe-separated alternatives and multi-stroke chains
+  - `EmacsHandler.addCommands(commands: object)` registers named commands; command receives `{ view: EditorView }`
+  - Key chain state tracked via `$data.keyChain`; intermediate keys store `null` in binding map
+  - Built-in bindings include C-k (kill line), C-w (kill region), C-y (yank), C-Space (set mark), but NOT C-x C-s
+  - Package version: 6.1.0
+- **Implications**: C-x C-s must be explicitly registered. All proposed Emacs bindings are achievable via the existing API.
+
+### Markdown Symbol Toggle Duplication
+- **Context**: emacs.ts has `toggleMarkdownSymbol(view, prefix, suffix)` while editor-shortcuts use `useInsertMarkdownElements` hook
+- **Sources Consulted**: `insert-markdown-elements.ts`, `emacs.ts`, `generate-add-markdown-symbol-command.ts`
+- **Findings**:
+  - `useInsertMarkdownElements` is a React hook returning `(prefix: string, suffix: string) => void`
+  - `toggleMarkdownSymbol` is a pure function taking `(view: EditorView, prefix: string, suffix: string) => void`
+  - Both implement wrap/unwrap toggle logic but with slightly different selection handling
+  - Emacs commands receive handler object with `view` property, not a React context
+  - Hook-based approach cannot be used inside `EmacsHandler.addCommands` since it's not a React component
+- **Implications**: Need a shared pure function (non-hook) that both the hook and Emacs commands can use. The hook wraps the pure function; Emacs calls it directly.
+
+### Prefix Insertion for Structural Bindings
+- **Context**: Need to support blockquote, list, heading insertion from Emacs commands
+- **Sources Consulted**: `insert-prefix.ts`, `insert-blockquote.ts`, `insert-numbered-list.ts`
+- **Findings**:
+  - `useInsertPrefix` is also a React hook: `(prefix: string, noSpaceIfPrefixExists?: boolean) => void`
+  - Handles multi-line selections, indentation-aware
+  - Same constraint: cannot be used inside EmacsHandler commands directly
+- **Implications**: Need pure function extraction for prefix operations too, callable with EditorView directly.
+
+### Precedence Architecture
+- **Context**: Emacs/Vim use Prec.high, default/vscode use Prec.low; currently branched in consumer
+- **Sources Consulted**: `use-editor-settings.ts` lines 87-99
+- **Findings**:
+  - Emacs/Vim use ViewPlugin DOM event handlers intercepting at keydown level
+  - Must run before CodeMirror's keymap handler to avoid Mac Ctrl-* and completionKeymap conflicts
+  - VSCode/default use `keymap.of()` which integrates with CodeMirror's handler directly
+- **Implications**: Precedence is inherent to the keymap type. Encapsulating it in the module return value eliminates consumer branching.
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| A: Return-object factory | Each module returns `{ extension, precedence, overrides }` | Clean interface, no consumer branching | Slightly more complex return type | Preferred |
+| B: Pre-wrapped extension | Each module returns `Prec.high(extension)` directly | Simplest consumer code | Consumer loses control over precedence | Less flexible |
+| C: Config registry | Central registry maps mode → config | Extensible | Over-engineering for 4 modes | Rejected |
+
+## Design Decisions
+
+### Decision: Pure Function Extraction for Markdown Operations
+- **Context**: Emacs commands need markdown toggle/prefix but can't use React hooks
+- **Alternatives Considered**:
+  1. Extract pure functions from hooks, hooks become thin wrappers
+  2. Create entirely new utility functions for Emacs
+  3. Use CodeMirror commands directly in Emacs module
+- **Selected Approach**: Option 1 — Extract pure functions, hooks wrap them
+- **Rationale**: Eliminates duplication, both hooks and Emacs commands share the same logic
+- **Trade-offs**: Slight refactoring of existing hooks, but no behavioral change
+- **Follow-up**: Verify existing tests still pass after extraction
+
+### Decision: Factory Return Object Pattern
+- **Context**: Need to encapsulate precedence and override declarations per keymap
+- **Alternatives Considered**:
+  1. Return `{ extension, precedence, overrides }` object
+  2. Return pre-wrapped extension with separate metadata query
+- **Selected Approach**: Option 1 — Structured return object
+- **Rationale**: Single source of truth per keymap; consumer code becomes a simple loop
+- **Trade-offs**: Breaking change to getKeymap interface, but internal-only API
+
+### Decision: Override Categories for Shortcut Exclusion
+- **Context**: Need to replace `if (keymapModeName === 'emacs')` hard-coding
+- **Selected Approach**: Each keymap declares `overrides: ShortcutCategory[]` where categories are `'formatting' | 'navigation' | 'structural'`
+- **Rationale**: New keymaps can declare their overrides without modifying shortcut registration code
+- **Binding Mechanism**: `CategorizedKeyBindings` wrapper type groups `KeyBinding[]` with a `category` field, allowing `useEditorShortcuts` to filter by category match against overrides
+
+### Decision: Emacs Submodule Split
+- **Context**: emacs.ts accumulates 19+ commands spanning formatting, structural, navigation, and save — low cohesion
+- **Alternatives Considered**:
+  1. Single file with sections (current approach)
+  2. Split into `emacs/` directory with submodules per responsibility
+  3. Split by binding prefix (C-c C-s vs C-c C-)
+- **Selected Approach**: Option 2 — submodules by responsibility (formatting, structural, navigation)
+- **Rationale**: Each submodule has a single reason to change. Adding a new heading command only touches structural.ts. Adding navigation only touches navigation.ts.
+- **Trade-offs**: More files, but each is small (<80 lines) and focused
+
+### Decision: Relocate editor-shortcuts to services-internal
+- **Context**: `editor-shortcuts/` is currently under `services/use-codemirror-editor/utils/` (public layer) but is never exported — only consumed by `stores/use-editor-shortcuts.ts`
+- **Alternatives Considered**:
+  1. Keep in services/, add explicit non-export marker
+  2. Move to services-internal/editor-shortcuts/
+  3. Inline into stores/use-editor-shortcuts.ts
+- **Selected Approach**: Option 2 — move to services-internal/
+- **Rationale**: Aligns actual visibility with directory convention. services/ = public API, services-internal/ = internal only. The shortcut definitions are internal implementation details that should not be importable by external consumers.
+- **Trade-offs**: Requires updating import paths in use-editor-shortcuts.ts and any internal consumers
+- **Follow-up**: Verify no external package imports from this path
+
+## Risks & Mitigations
+- EmacsHandler.addCommands is called at module load time (static method); ensure idempotency if module is re-imported → Mitigation: guard with registration flag
+- Multi-stroke key chains may conflict with browser shortcuts on some platforms → Mitigation: Test on Mac/Windows/Linux; C-c C-s prefix is safe since C-c alone is intercepted by Emacs plugin
+- Pure function extraction may subtly change selection behavior → Mitigation: Write unit tests for toggle behavior before refactoring
+
+## References
+- [@replit/codemirror-emacs](https://github.com/nicknisi/replit-codemirror-emacs) — v6.1.0, EmacsHandler API
+- [jrblevin/markdown-mode](https://github.com/jrblevin/markdown-mode) — Reference for Emacs markdown-mode keybindings
+- [CodeMirror 6 Keymap API](https://codemirror.net/docs/ref/#view.keymap) — Precedence and extension system

+ 22 - 0
.kiro/specs/editor-keymaps/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "editor-keymaps",
+  "created_at": "2026-04-08T00:00:00.000Z",
+  "updated_at": "2026-04-08T00:00:00.000Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 147 - 0
.kiro/specs/editor-keymaps/tasks.md

@@ -0,0 +1,147 @@
+# Implementation Plan
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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_
+
+- [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
+  - _Requirements: 1.4, 3.2, 6.1_
+
+- [x] 10.3 (P) Add E2E tests for Emacs keybindings
+  - Extend the existing Playwright editor test pattern to cover Emacs formatting bindings (C-c C-s b for bold, etc.)
+  - Cover at least one structural binding (C-c C-l for link) and one navigation binding (C-c C-n for next heading)
+  - _Requirements: 4.1, 5.2, 9.3_

+ 128 - 0
apps/app/playwright/23-editor/emacs-keymap.spec.ts

@@ -0,0 +1,128 @@
+import { expect, test } from '@playwright/test';
+
+/**
+ * Tests for Emacs keymap functionality in the editor.
+ * Verifies that the registered EmacsHandler bindings produce the expected
+ * markdown output in the editor source — i.e. the observable contract
+ * (content changes) rather than internal implementation details.
+ *
+ * Keymap isolation strategy: page.route intercepts GET /_api/v3/personal-setting/editor-settings
+ * and returns keymapMode:'emacs' without touching the database.  PUT requests are swallowed for
+ * the same reason.  Because the route is scoped to the test's page instance, no other test file
+ * is affected and no afterEach cleanup is required.
+ *
+ * @see packages/editor/src/client/services-internal/keymaps/emacs/
+ * Requirements: 4.1, 5.2, 9.3
+ */
+
+const EDITOR_SETTINGS_ROUTE = '**/_api/v3/personal-setting/editor-settings';
+
+test.describe
+  .serial('Emacs keymap mode', () => {
+    test.beforeEach(async ({ page }) => {
+      // Return keymapMode:'emacs' for every settings fetch without writing to DB.
+      // PUT requests (e.g. from UI interactions) are also swallowed so the DB stays clean.
+      await page.route(EDITOR_SETTINGS_ROUTE, async (route) => {
+        if (route.request().method() === 'GET') {
+          await route.fulfill({
+            contentType: 'application/json',
+            body: JSON.stringify({ keymapMode: 'emacs' }),
+          });
+        } else {
+          await route.fulfill({
+            status: 200,
+            contentType: 'application/json',
+            body: '{}',
+          });
+        }
+      });
+
+      await page.goto('/Sandbox/emacs-keymap-test-page');
+
+      // Open Editor
+      await expect(page.getByTestId('editor-button')).toBeVisible();
+      await page.getByTestId('editor-button').click();
+      await expect(page.locator('.cm-content')).toBeVisible();
+      await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+    });
+
+    test('C-c C-s b should wrap text in bold markdown markers (Req 4.1)', async ({
+      page,
+    }) => {
+      // Focus the editor
+      await page.locator('.cm-content').click();
+
+      // With no selection, C-c C-s b inserts ** markers and positions cursor between them
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+s');
+      await page.keyboard.press('b');
+
+      // Type text inside the inserted markers
+      await page.keyboard.type('bold text');
+
+      // Verify: bold markdown markers surround the typed text in the editor source
+      await expect(page.locator('.cm-content')).toContainText('**bold text**');
+    });
+
+    test('C-c C-l should insert a markdown link template (Req 5.2)', async ({
+      page,
+    }) => {
+      // Focus the editor
+      await page.locator('.cm-content').click();
+
+      // With no selection, C-c C-l inserts []() and positions cursor after [
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+l');
+
+      // Type the link display text inside the brackets
+      await page.keyboard.type('link text');
+
+      // Verify: link template with typed display text appears in the editor source
+      await expect(page.locator('.cm-content')).toContainText('[link text]()');
+    });
+
+    test('C-c C-n should navigate cursor to the next heading (Req 9.3)', async ({
+      page,
+    }) => {
+      // Set up document with two headings.
+      // Fill directly and wait for the rendered heading text (without # markers) to appear in the
+      // preview, because appendTextToEditorUntilContains checks raw text which markdown headings
+      // strip on render.
+      await page
+        .locator('.cm-content')
+        .fill('# First Heading\n\n## Second Heading');
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        'Second Heading',
+      );
+
+      // Click on the first line to position cursor before "## Second Heading"
+      await page.locator('.cm-line').first().click();
+
+      // Navigate to the next heading with C-c C-n
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+n');
+
+      // Cursor is now at the beginning of "## Second Heading".
+      // Move to end of that line and append a unique marker to verify cursor position.
+      await page.keyboard.press('End');
+      await page.keyboard.type(' NAVIGATED');
+
+      // Verify: the marker was appended at the second heading, not the first
+      await expect(page.locator('.cm-content')).toContainText(
+        '## Second Heading NAVIGATED',
+      );
+    });
+
+    test('C-x C-s should save the page (Req 6.1)', async ({ page }) => {
+      // Type content to ensure there is something to save
+      await page.locator('.cm-content').click();
+      await page.keyboard.type('Emacs save test');
+
+      // Save with the Emacs two-stroke save binding
+      await page.keyboard.press('Control+x');
+      await page.keyboard.press('Control+s');
+
+      // Expect a success toast notification confirming the page was saved
+      await expect(page.locator('.Toastify__toast--success')).toBeVisible();
+    });
+  });

+ 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: [],
+  };
+};

+ 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('@replit/codemirror-emacs')).emacs();
+      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 - 8
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 = (
@@ -88,8 +89,8 @@ const useKeymapExtension = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   keymapMode?: KeyMapMode,
   onSave?: () => void,
-): void => {
-  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(
+): KeymapResult | undefined => {
+  const [keymapResult, setKeymapResult] = useState<KeymapResult | undefined>(
     undefined,
   );
 
@@ -104,20 +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 cleanupFunction = codeMirrorEditor?.appendExtensions(
-      Prec.low(keymapExtension),
+      keymapResult.precedence(keymapResult.extension),
     );
     return cleanupFunction;
-  }, [codeMirrorEditor, keymapExtension]);
+  }, [codeMirrorEditor, keymapResult]);
+
+  return keymapResult;
 };
 
 export const useEditorSettings = (
@@ -125,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);
 };

+ 45 - 20
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,45 +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[] => {
-  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(
-    view,
-    keymapModeName,
-  );
+  // Formatting keybindings
+  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(view);
   const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
   const makeTextStrikethroughKeyBinding =
     useMakeTextStrikethroughKeyBinding(view);
   const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
+
+  // Structural keybindings
   const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
   const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
   const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
-  const InsertLinkKeyBinding = useInsertLinkKeyBinding(view);
+  const insertLinkKeyBinding = useInsertLinkKeyBinding(view);
+
+  // Always-on keybindings
   const multiCursorKeyBindings = useAddMultiCursorKeyBindings();
 
-  const keyBindings: KeyBinding[] = [
-    makeTextBoldKeyBinding,
-    makeTextItalicKeyBinding,
-    makeTextStrikethroughKeyBinding,
-    makeTextCodeCommand,
-    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,
+    },
   ];
 
-  return keyBindings;
+  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();