Skip to content

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.

LocationRole today
packages/ts/obsidian-editor/src/commands.tstoggleBold, toggleItalic, insertLink, save; obsidianKeymap binds Mod-b, Mod-i, Mod-Shift-k, Mod-s, Enter, Backspace, Tab
packages/ts/obsidian-editor/src/extensions.ts#obsidianEditorExtensionsmarkdown({ base, extensions }) with the default addKeymap: true, so markdownKeymap and obsidianKeymap both bind Enter and Backspace
packages/ts/obsidian-editor/src/vue/index.ts#useObsidianEditorReturns view, focus, cursorLine; nothing about commands
packages/ts/obsidian-editor/src/decorations.tsList items get no decoration beyond the task checkbox
packages/ts/obsidian-editor/test/commands.test.tsTests for the four commands
packages/ts/app-frame/app/utils/chords.ts#parseChordThe portable chord grammar (mod+shift+k) that keybinding strings follow
packages/ts/app-frame/app/composables/useContributions.ts#CommandContributionPalette entry shape (id, title, run, keybinding?) the projection matches structurally

The EditorCommand type comes from T-4TE2-md-engine-plugin-contract’s plugin module.

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-driven
runCommand(view, id, payload?): boolean; findCommand(state, id): EditorCommand | undefined
toCodeMirrorKey('mod+shift+k') → 'Mod-Shift-k'; keymapFromCommands(cmds); chordLabel(chord) // src/keymap.ts
toCommandContributions(cmds, () => view) // src/commands/contributions.ts — structurally a CommandContribution

Catalogue contents:

GroupIdsDefault chord
formatformat.bold, format.italic, format.strikethrough, format.highlight, format.codemod+b, mod+i, mod+shift+x, mod+shift+h, none
headingheading.1 … heading.6, heading.clearnone
listlist.bullet, list.ordered, list.task, list.indent, list.outdentnone, none, mod+l, tab, shift+tab
blockblock.quote, block.callout.note, block.callout.info, block.callout.tip, block.callout.warning, block.callout.danger, block.callout.quote, block.codenone
insertinsert.link, insert.wikilink, insert.hrmod+shift+k, none, none
linklink.opennone
editoreditor.save, editor.toggle-mode, editor.findmod+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?).

  1. Write packages/ts/obsidian-editor/test/commands/catalogue.test.ts first: ids unique; isActive for 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; commandStates returns equal objects for equal states and never touches a view.
  2. Write packages/ts/obsidian-editor/test/keymap.test.ts: toCodeMirrorKey for mod, shift, alt, tab, enter, left → ArrowLeft; keymapFromCommands skips commands without a chord; chordLabel renders mod+shift+k as ⌘⇧K on mac and Ctrl+Shift+K elsewhere.
  3. Write packages/ts/obsidian-editor/test/commands/contributions.test.ts: output has id, title, run, keybinding; run invokes the command on the current view.
  4. Implement the catalogue, the run implementations (packages/ts/obsidian-editor/src/commands/editor.ts), the keymap module, the contributions module.
  5. Implement the lists plugin with packages/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 to BUILTIN_PLUGINS.
  6. Rewire obsidianEditorExtensions (addKeymap: false, derived keymap); redefine obsidianKeymap and ALL_COMMANDS; keep the existing command tests green.
  7. Extend useObsidianEditor and the Vue test (commands lists the catalogue; commandStates updates when the cursor enters **bold**; runCommand('format.bold') edits the doc).
  8. bun test, tsc --noEmit, moon run obsidian-editor:bench.
LocationKindChange
packages/ts/obsidian-editor/src/commands/catalogue.tsnewCATALOGUE, commandStates, runCommand, findCommand
packages/ts/obsidian-editor/src/commands/editor.tsnewRun implementations: format toggles, heading set/clear, list toggles, block prefixes, inserts, editor commands
packages/ts/obsidian-editor/src/commands/contributions.tsnewtoCommandContributions
packages/ts/obsidian-editor/src/keymap.tsnewtoCodeMirrorKey, keymapFromCommands, chordLabel
packages/ts/obsidian-editor/src/plugins/lists.tsnewlists plugin: bullet widget, list.* commands, Enter/Backspace/Tab keymap
packages/ts/obsidian-editor/src/plugins/index.tsnewCreated by T-4TE2-md-engine-plugin-contract; lists added to BUILTIN_PLUGINS
packages/ts/obsidian-editor/src/commands.tsmodifyobsidianKeymap = keymapFromCommands(CATALOGUE); ALL_COMMANDS over the catalogue; existing exports kept
packages/ts/obsidian-editor/src/extensions.tsmodifyaddKeymap: false; keymap derived from compiled commands
packages/ts/obsidian-editor/src/vue/index.tsmodifycommands, commandStates, runCommand
packages/ts/obsidian-editor/src/index.tsmodifyExport catalogue, states, keymap and contributions helpers
packages/ts/obsidian-editor/test/commands/catalogue.test.tsnewCatalogue and state tests
packages/ts/obsidian-editor/test/commands/contributions.test.tsnewProjection tests
packages/ts/obsidian-editor/test/keymap.test.tsnewChord derivation tests
packages/ts/obsidian-editor/test/plugins/lists.plugin.test.tsnewLists plugin tests
packages/ts/obsidian-editor/test/vue.test.tsmodifycommands, commandStates, runCommand
  • AC-1: grep -n "addKeymap: false" over packages/ts/obsidian-editor/src/extensions.ts matches once, and grep -rn "markdownKeymap" over packages/ts/obsidian-editor/src returns nothing.
  • AC-2: bun test packages/ts/obsidian-editor passes, including the four new suites and the unchanged packages/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.* and heading.* 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' and toCodeMirrorKey('shift+tab') === 'Shift-Tab'.
  • AC-6: toCommandContributions(CATALOGUE, () => view) is assignable to CommandContribution[] (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: useObsidianEditor returns commands, commandStates, runCommand; the Vue test proves commandStates.value['format.bold'].active flips when the cursor moves into **bold**.
  • AC-9: moon run obsidian-editor:bench stays within 1.5× of the recorded baseline.
  • T-4TE2-md-engine-plugin-contract for EditorCommand, CompiledPlugins.commands and the built-in plugin roster the lists plugin joins.
  • D-7VMX: isActive and every edit read the Lezer tree, never a regex over the document.

Member of milestone M-931L; engine step E3 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.

T-4TE2-md-engine-plugin-contract


← Back to Tasks