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.
editor-shortcuts/ from public services layer to services-internal where it belongsCurrent 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:
editor-shortcuts/ is in public services/ tree but never exported — layer violationgetKeymapPrecedence)if emacs check)useInsertMarkdownElementsgraph 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:
research.md — Pattern A)markdown-utils/ shared by both public hooks and internal keymapseditor-shortcuts/ relocated to services-internal/ to match its actual visibilityappendExtensions lifecycle| 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.
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:
KeymapResult.precedence is a function (Prec.high or Prec.low) applied by the consumeroverrides array flows to shortcut registration for data-driven exclusion| 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 | — |
| 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 | — | — |
| Field | Detail |
|---|---|
| Intent | Structured return type encapsulating keymap extension, precedence, and override metadata |
| Requirements | 1.2, 1.4, 3.1 |
Contracts: Type [x]
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>;
| Field | Detail |
|---|---|
| Intent | Group KeyBindings by ShortcutCategory for data-driven exclusion |
| Requirements | 3.2 |
Contracts: Type [x]
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.
services-internal/markdown-utils/)Pure functions usable by both public hooks and internal keymaps. No React dependencies.
| Field | Detail |
|---|---|
| Intent | Pure function to wrap/unwrap selected text with markdown symbols |
| Requirements | 2.1, 2.2, 2.3 |
Contracts: Service [x]
/**
* 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
emacs.ts local functionuseInsertMarkdownElements hook in services/ becomes a thin wrapper: useCallback((p, s) => toggleMarkdownSymbol(view, p, s), [view])packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts| Field | Detail |
|---|---|
| Intent | Pure function to insert/toggle prefix at line beginnings |
| Requirements | 5.1, 5.3, 5.4, 5.5 |
Contracts: Service [x]
/**
* 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
useInsertPrefix hookpackages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.tsservices/ (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.
| Field | Detail |
|---|---|
| Intent | Default CodeMirror keymap module |
| Requirements | 1.1 |
Contracts: Service [x]
const defaultKeymap: KeymapFactory;
// Returns:
// - extension: keymap.of(defaultKeymap from @codemirror/commands)
// - precedence: Prec.low
// - overrides: [] (no overrides)
| Field | Detail |
|---|---|
| Intent | VSCode keymap module |
| Requirements | 1.1 |
Contracts: Service [x]
const vscodeKeymap: KeymapFactory;
// Returns:
// - extension: keymap.of(vscodeKeymap from @replit/codemirror-vscode-keymap)
// - precedence: Prec.low
// - overrides: [] (no overrides)
| Field | Detail |
|---|---|
| Intent | Vim keymap module with side effects encapsulated in factory |
| Requirements | 1.1, 7.1, 7.2 |
Responsibilities & Constraints
Vim.map('jj', '<Esc>', 'insert') and Vim.map('jk', '<Esc>', 'insert') inside factory:w ex-command inside factory when onSave providedContracts: Service [x]
const vimKeymap: KeymapFactory;
// Returns:
// - extension: vim()
// - precedence: Prec.high
// - overrides: [] (Vim uses its own modal system, no standard shortcut conflicts)
| 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:
type EmacsBindingRegistrar = (
EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
options?: { onSave?: () => void },
) => void;
emacs/index.ts — Factory & Composition:
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 |
| Field | Detail |
|---|---|
| Intent | Thin routing dispatcher delegating to keymap modules |
| Requirements | 1.2, 1.3 |
Contracts: Service [x]
const getKeymap: (
keyMapName?: KeyMapMode,
onSave?: () => void,
) => Promise<KeymapResult>;
Implementation is a simple switch delegating to each module's factory. No inline keymap construction.
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:
// 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:
generateAddMarkdownSymbolCommand refactored to use pure toggleMarkdownSymbol directly instead of via hookservices/use-codemirror-editor/utils/editor-shortcuts/ → services-internal/editor-shortcuts/| Field | Detail |
|---|---|
| Intent | Data-driven shortcut registration using keymap override metadata |
| Requirements | 3.1, 3.2, 3.3 |
Contracts: State [x]
const useEditorShortcuts: (
codeMirrorEditor?: UseCodeMirrorEditor,
overrides?: readonly ShortcutCategory[],
) => void;
Key Change: Parameter changes from keymapModeName?: KeyMapMode to overrides?: readonly ShortcutCategory[].
Exclusion logic:
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);
| Field | Detail |
|---|---|
| Intent | Keymap lifecycle with simplified precedence handling |
| Requirements | 1.4 |
Key Change: Remove getKeymapPrecedence() function. Use keymapResult.precedence directly:
// Before:
const wrapWithPrecedence = getKeymapPrecedence(keymapMode);
codeMirrorEditor?.appendExtensions(wrapWithPrecedence(keymapExtension));
// After:
codeMirrorEditor?.appendExtensions(keymapResult.precedence(keymapResult.extension));
Pass keymapResult.overrides to useEditorShortcuts instead of keymapMode.
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)
editor-shortcuts/ RelocationMoving 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.
No data model changes. EditorSettings interface and localStorage persistence remain unchanged.
toggleMarkdownSymbol: wrap, unwrap, empty selection, nested symbols — 5+ casesinsertLinePrefix: single line, multi-line, toggle off, with indent — 4+ casesKeymapResult shape (precedence, overrides)CategorizedKeyBindings exclusion logic with various override combinationsplaywright/23-editor/vim-keymap.spec.ts pattern for Emacs keybindings