T-EXQ7-md-engine-command-catalogue
State: closed/done · Impact: high · Complexity: medium
The editor has four commands and a hand-written keymap, and markdown() installs a second keymap
that binds the same keys. Toolbar, bubble menu, slash menu and command palette all need one typed
list of commands with active/enabled state. This task adds the command catalogue, commandStates,
chord derivation, the lists plugin, and makes the catalogue the single keymap source.
| Location | Role today |
|---|---|
packages/ts/obsidian-editor/src/commands.ts | toggleBold, toggleItalic, insertLink, save; obsidianKeymap binds Mod-b, Mod-i, Mod-Shift-k, Mod-s, Enter, Backspace, Tab |
packages/ts/obsidian-editor/src/extensions.ts#obsidianEditorExtensions | markdown({ base, extensions }) with the default addKeymap: true, so markdownKeymap and obsidianKeymap both bind Enter and Backspace |
packages/ts/obsidian-editor/src/vue/index.ts#useObsidianEditor | Returns view, focus, cursorLine; nothing about commands |
packages/ts/obsidian-editor/src/decorations.ts | List items get no decoration beyond the task checkbox |
packages/ts/obsidian-editor/test/commands.test.ts | Tests for the four commands |
packages/ts/app-frame/app/utils/chords.ts#parseChord | The portable chord grammar (mod+shift+k) that keybinding strings follow |
packages/ts/app-frame/app/composables/useContributions.ts#CommandContribution | Palette entry shape (id, title, run, keybinding?) the projection matches structurally |
The EditorCommand type comes from T-4TE2-md-engine-plugin-contract’s plugin module.
Proposed
Section titled “Proposed”packages/ts/obsidian-editor/src/commands/catalogue.ts:
interface EditorCommand { id: string // 'format.bold', 'heading.2', 'list.task', 'block.callout.warning', 'insert.link', 'editor.toggle-mode' label: string; icon?: string /* 'i-lucide-bold' */; keybinding?: string /* portable chord 'mod+shift+k' */ group: 'format'|'heading'|'list'|'block'|'insert'|'link'|'editor' run: (target: CommandTarget, payload?: unknown) => boolean // StateCommand shape; EditorView satisfies it isActive?(state): boolean; isEnabled?(state): boolean slash?: { keywords?: string[]; detail?: string } | false}CATALOGUE: EditorCommand[]commandStates(state): Record<id, { active: boolean; enabled: boolean }> // pure; tree-drivenrunCommand(view, id, payload?): boolean; findCommand(state, id): EditorCommand | undefinedtoCodeMirrorKey('mod+shift+k') → 'Mod-Shift-k'; keymapFromCommands(cmds); chordLabel(chord) // src/keymap.tstoCommandContributions(cmds, () => view) // src/commands/contributions.ts — structurally a CommandContributionCatalogue contents:
| Group | Ids | Default chord |
|---|---|---|
format | format.bold, format.italic, format.strikethrough, format.highlight, format.code | mod+b, mod+i, mod+shift+x, mod+shift+h, none |
heading | heading.1 … heading.6, heading.clear | none |
list | list.bullet, list.ordered, list.task, list.indent, list.outdent | none, none, mod+l, tab, shift+tab |
block | block.quote, block.callout.note, block.callout.info, block.callout.tip, block.callout.warning, block.callout.danger, block.callout.quote, block.code | none |
insert | insert.link, insert.wikilink, insert.hr | mod+shift+k, none, none |
link | link.open | none |
editor | editor.save, editor.toggle-mode, editor.find | mod+s, none, mod+f (only when onFind is set) |
isActive reads the tree: enclosing(state, 'StrongEmphasis') for format.bold, ATXHeading2 at
the line start for heading.2, a ListItem ancestor with a Task child for list.task, and so
on. Block and heading commands are changeByRange edits on the marker prefixes of the selected
lines only; nothing outside the selection is rewritten. commandStates compares equal for equal
states. run takes an optional payload so T-0BFS-md-engine-attachments can pass files.
packages/ts/obsidian-editor/src/plugins/lists.ts: ListMark hidden behind a ListBulletWidget
in live mode (ordered numbers stay visible), the five list.* commands, and the keymap entries that
leave the old hand-written keymap: Enter → insertNewlineContinueMarkup, Backspace →
deleteMarkupBackward, Tab / Shift-Tab → list.indent / list.outdent.
obsidianEditorExtensions passes addKeymap: false to markdown() and builds the keymap from
keymapFromCommands(compiled.commands) plus plugin keymaps. The obsidianKeymap export becomes
keymapFromCommands(CATALOGUE); toggleBold, toggleItalic, insertLink, save stay exported;
ALL_COMMANDS becomes CATALOGUE.map((c) => c.run) so the swap-writer corpus covers every command.
useObsidianEditor returns commands: ShallowRef<EditorCommand[]>, commandStates: ShallowRef<Record<string, { active; enabled }>> (recomputed on selection, doc and reconfigure) and
runCommand(id, payload?).
Approach
Section titled “Approach”- Write
packages/ts/obsidian-editor/test/commands/catalogue.test.tsfirst: ids unique;isActivefor one command per group over inline fixtures; each block and heading command applied to a two-line selection inside a five-line fixture leaves the other three lines byte-identical;commandStatesreturns equal objects for equal states and never touches a view. - Write
packages/ts/obsidian-editor/test/keymap.test.ts:toCodeMirrorKeyformod,shift,alt,tab,enter,left→ArrowLeft;keymapFromCommandsskips commands without a chord;chordLabelrendersmod+shift+kas⌘⇧Kon mac andCtrl+Shift+Kelsewhere. - Write
packages/ts/obsidian-editor/test/commands/contributions.test.ts: output hasid,title,run,keybinding;runinvokes the command on the current view. - Implement the catalogue, the run implementations
(
packages/ts/obsidian-editor/src/commands/editor.ts), the keymap module, the contributions module. - Implement the
listsplugin withpackages/ts/obsidian-editor/test/plugins/lists.plugin.test.ts(bullet widget in live mode, visible in source mode and on the active line; Enter continues a list; Tab indents); add it toBUILTIN_PLUGINS. - Rewire
obsidianEditorExtensions(addKeymap: false, derived keymap); redefineobsidianKeymapandALL_COMMANDS; keep the existing command tests green. - Extend
useObsidianEditorand the Vue test (commandslists the catalogue;commandStatesupdates when the cursor enters**bold**;runCommand('format.bold')edits the doc). bun test,tsc --noEmit,moon run obsidian-editor:bench.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/obsidian-editor/src/commands/catalogue.ts | new | CATALOGUE, commandStates, runCommand, findCommand |
packages/ts/obsidian-editor/src/commands/editor.ts | new | Run implementations: format toggles, heading set/clear, list toggles, block prefixes, inserts, editor commands |
packages/ts/obsidian-editor/src/commands/contributions.ts | new | toCommandContributions |
packages/ts/obsidian-editor/src/keymap.ts | new | toCodeMirrorKey, keymapFromCommands, chordLabel |
packages/ts/obsidian-editor/src/plugins/lists.ts | new | lists plugin: bullet widget, list.* commands, Enter/Backspace/Tab keymap |
packages/ts/obsidian-editor/src/plugins/index.ts | new | Created by T-4TE2-md-engine-plugin-contract; lists added to BUILTIN_PLUGINS |
packages/ts/obsidian-editor/src/commands.ts | modify | obsidianKeymap = keymapFromCommands(CATALOGUE); ALL_COMMANDS over the catalogue; existing exports kept |
packages/ts/obsidian-editor/src/extensions.ts | modify | addKeymap: false; keymap derived from compiled commands |
packages/ts/obsidian-editor/src/vue/index.ts | modify | commands, commandStates, runCommand |
packages/ts/obsidian-editor/src/index.ts | modify | Export catalogue, states, keymap and contributions helpers |
packages/ts/obsidian-editor/test/commands/catalogue.test.ts | new | Catalogue and state tests |
packages/ts/obsidian-editor/test/commands/contributions.test.ts | new | Projection tests |
packages/ts/obsidian-editor/test/keymap.test.ts | new | Chord derivation tests |
packages/ts/obsidian-editor/test/plugins/lists.plugin.test.ts | new | Lists plugin tests |
packages/ts/obsidian-editor/test/vue.test.ts | modify | commands, commandStates, runCommand |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
grep -n "addKeymap: false"overpackages/ts/obsidian-editor/src/extensions.tsmatches once, andgrep -rn "markdownKeymap"overpackages/ts/obsidian-editor/srcreturns nothing. - AC-2:
bun test packages/ts/obsidian-editorpasses, including the four new suites and the unchangedpackages/ts/obsidian-editor/test/commands.test.ts. - AC-3: every id in the catalogue table above exists in
CATALOGUE(asserted by name in the catalogue test) and ids are unique. - AC-4: for every
block.*andheading.*command, applying it to a two-line selection in the test’s five-line fixture leaves the other three lines byte-identical. - AC-5:
toCodeMirrorKey('mod+shift+k') === 'Mod-Shift-k'andtoCodeMirrorKey('shift+tab') === 'Shift-Tab'. - AC-6:
toCommandContributions(CATALOGUE, () => view)is assignable toCommandContribution[](a type-level test over a local copy of the app-frame interface). - AC-7:
commandStates(state)returns deep-equal results for two states built from the same document and selection. - AC-8:
useObsidianEditorreturnscommands,commandStates,runCommand; the Vue test provescommandStates.value['format.bold'].activeflips when the cursor moves into**bold**. - AC-9:
moon run obsidian-editor:benchstays within 1.5× of the recorded baseline.
Out of scope
Section titled “Out of scope”insert.tableandtable.*commands (T-UYTR-md-engine-tables).insert.attachment(T-0BFS-md-engine-attachments).block.details(T-DSVW-md-engine-html-details).- The slash completion source that lists the catalogue (T-N58P-md-engine-slash-and-heading-completion).
- Toolbar and palette chrome over the catalogue (T-98EU-md-ui-toolbar-insert-frontmatter).
Dependencies
Section titled “Dependencies”- T-4TE2-md-engine-plugin-contract for
EditorCommand,CompiledPlugins.commandsand the built-in plugin roster thelistsplugin joins. - D-7VMX:
isActiveand every edit read the Lezer tree, never a regex over the document.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; engine step E3 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.
Depends on
Section titled “Depends on”T-4TE2-md-engine-plugin-contract