diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index e454bcdcd6..8a3a5c2877 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -16,6 +16,7 @@ import type { } from '@superdoc/document-api'; import { collectEntityHitsFromChain } from './entity-at.js'; import { shallowEqual } from './equality.js'; +import { shortcutFromEvent } from './keyboard-shortcuts.js'; import { scrollRangeIntoView } from './scroll-into-view.js'; import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js'; import { restoreSelection } from './selection-restore.js'; @@ -1021,6 +1022,60 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { customCommandsRegistry.destroy(); }); + // Keyboard shortcut dispatch for custom commands registered with a + // `shortcut` field. Two important shapes: + // + // - Bubble phase. ProseMirror's keymap plugin is bubble-phase too + // and `eventBelongsToView` bails on `event.defaultPrevented`. A + // capture-phase listener that calls preventDefault would silently + // suppress every built-in editor keymap (Bold, Enter, Backspace), + // contradicting the documented "fires alongside built-ins" + // contract. Running at bubble lets the editor's own keymap + // process the event first; we dispatch the custom command after. + // + // - Scope expanded to the editor's hidden ProseMirror DOM in + // addition to the painted host. Once the user clicks the document, + // native focus moves to the hidden contenteditable that PM owns, + // which lives outside `visibleHost`. Filtering only on + // `host.contains(target)` would drop every keystroke from the + // normal editing path. + if (typeof globalThis !== 'undefined' && (globalThis as { document?: Document }).document) { + const dom = (globalThis as { document: Document }).document; + const onKeyDown = (event: Event) => { + const ke = event as KeyboardEvent; + // Re-resolve every event because the editor mount can happen + // after `createSuperDocUI` runs; caching a missing host at + // construction time would never recover. + const editor = resolveRoutedEditor(superdoc) as + | (SuperDocEditorLike & { + view?: { dom?: HTMLElement }; + presentationEditor?: { visibleHost?: HTMLElement }; + }) + | null; + if (!editor) return; + const target = ke.target as Node | null; + if (!target) return; + const inHost = editor.presentationEditor?.visibleHost?.contains(target) === true; + const inPmDom = editor.view?.dom?.contains(target) === true; + if (!inHost && !inPmDom) return; + const combo = shortcutFromEvent(ke); + if (!combo) return; + const id = customCommandsRegistry.resolveShortcut(combo); + if (!id) return; + // Dispatch through the same path `ui.commands.get(id).execute()` + // uses. preventDefault runs AFTER dispatch so PM's keymap (which + // already ran in this bubble pass) isn't suppressed by an + // earlier defaultPrevented check; the call still blocks browser + // defaults that haven't run yet (the URL-bar shortcut, etc.). + customCommandsRegistry.execute(id); + ke.preventDefault(); + }; + dom.addEventListener('keydown', onKeyDown); + teardown.push(() => { + dom.removeEventListener('keydown', onKeyDown); + }); + } + /** * Single dispatch path for every `execute`-shaped surface on the * controller (`ui.toolbar.execute(id)`, `ui.commands.bold.execute()`, diff --git a/packages/super-editor/src/ui/custom-commands.test.ts b/packages/super-editor/src/ui/custom-commands.test.ts index 9a99d00e51..f65caa39aa 100644 --- a/packages/super-editor/src/ui/custom-commands.test.ts +++ b/packages/super-editor/src/ui/custom-commands.test.ts @@ -1146,3 +1146,151 @@ describe('ui.commands.getContextMenuItems', () => { ui.destroy(); }); }); + +describe('ui.commands.register — shortcut field', () => { + function makeStubsWithHost() { + const stubs = makeStubs(); + const host = document.createElement('div'); + document.body.appendChild(host); + // Preserve any existing presentationEditor surface the toolbar + // resolver expects (`getActiveEditor`) and add the `visibleHost` + // that the keydown listener scopes to. + const existing = + (stubs.editor as unknown as { presentationEditor?: Record }).presentationEditor ?? {}; + (stubs.editor as unknown as { presentationEditor: Record }).presentationEditor = { + getActiveEditor: () => stubs.editor, + ...existing, + visibleHost: host, + }; + return { ...stubs, host }; + } + + function fireKey(target: Node, init: Partial & { key: string }) { + const ev = new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true }); + target.dispatchEvent(ev); + return ev; + } + + it('dispatches the registered command when the matching combo fires inside the host', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + ui.commands.register({ id: 'company.insertClause', execute, shortcut: 'Mod-Shift-C' }); + + fireKey(host, { key: 'c', ctrlKey: true, shiftKey: true }); + + expect(execute).toHaveBeenCalledTimes(1); + host.remove(); + ui.destroy(); + }); + + it("dispatches when focus is in the routed editor's hidden PM DOM (the normal editing path)", () => { + const { superdoc, editor, host } = makeStubsWithHost(); + // Mount the hidden ProseMirror DOM directly under document.body + // (mirroring how PresentationEditor appends the hidden host outside + // the visible host) so a click-into-document keypress lands here. + const pmDom = document.createElement('div'); + document.body.appendChild(pmDom); + (editor as unknown as { view: { dom: HTMLElement } }).view = { dom: pmDom }; + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + ui.commands.register({ id: 'company.action', execute, shortcut: 'Mod-K' }); + + fireKey(pmDom, { key: 'k', ctrlKey: true }); + + expect(execute).toHaveBeenCalledTimes(1); + pmDom.remove(); + host.remove(); + ui.destroy(); + }); + + it('does not dispatch when focus is outside the painted host', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + ui.commands.register({ id: 'company.insertClause', execute, shortcut: 'Mod-Shift-C' }); + + const outside = document.createElement('input'); + document.body.appendChild(outside); + fireKey(outside, { key: 'c', ctrlKey: true, shiftKey: true }); + + expect(execute).not.toHaveBeenCalled(); + outside.remove(); + host.remove(); + ui.destroy(); + }); + + it('warns and replaces when two registrations claim the same shortcut', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const firstExecute = vi.fn(() => true); + const secondExecute = vi.fn(() => true); + ui.commands.register({ id: 'company.first', execute: firstExecute, shortcut: 'Mod-K' }); + ui.commands.register({ id: 'company.second', execute: secondExecute, shortcut: 'Mod-K' }); + + expect(warnSpy).toHaveBeenCalled(); + + fireKey(host, { key: 'k', ctrlKey: true }); + expect(firstExecute).not.toHaveBeenCalled(); + expect(secondExecute).toHaveBeenCalledTimes(1); + + host.remove(); + ui.destroy(); + }); + + it('drops the shortcut on unregister so later keypresses are no-ops', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + const reg = ui.commands.register({ id: 'company.toggle', execute, shortcut: 'Mod-J' }); + + fireKey(host, { key: 'j', ctrlKey: true }); + expect(execute).toHaveBeenCalledTimes(1); + + reg.unregister(); + fireKey(host, { key: 'j', ctrlKey: true }); + expect(execute).toHaveBeenCalledTimes(1); + + host.remove(); + ui.destroy(); + }); + + it('accepts a string[] for multiple shortcuts on the same command', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + ui.commands.register({ + id: 'company.action', + execute, + shortcut: ['Mod-1', 'Mod-Shift-1'], + }); + + fireKey(host, { key: '1', ctrlKey: true }); + fireKey(host, { key: '1', ctrlKey: true, shiftKey: true }); + + expect(execute).toHaveBeenCalledTimes(2); + host.remove(); + ui.destroy(); + }); + + it('warns on a malformed shortcut and ignores it', () => { + const { superdoc, host } = makeStubsWithHost(); + const ui = createSuperDocUI({ superdoc }); + + const execute = vi.fn(() => true); + ui.commands.register({ id: 'company.bad', execute, shortcut: 'Mod-Shift' }); + + expect(warnSpy).toHaveBeenCalled(); + fireKey(host, { key: 'Shift', ctrlKey: true }); + expect(execute).not.toHaveBeenCalled(); + + host.remove(); + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/custom-commands.ts b/packages/super-editor/src/ui/custom-commands.ts index ce6b4837e6..e3d247bc94 100644 --- a/packages/super-editor/src/ui/custom-commands.ts +++ b/packages/super-editor/src/ui/custom-commands.ts @@ -1,3 +1,4 @@ +import { normalizeShortcut } from './keyboard-shortcuts.js'; import type { ContextMenuContribution, ContextMenuItem, @@ -13,6 +14,12 @@ import type { ViewportEntityHit, } from './types.js'; +const DEFAULT_SHORTCUT_COLLISION_MESSAGE = (shortcut: string, oldId: string, newId: string) => + `[superdoc/ui] ui.commands.register(): shortcut '${shortcut}' was already bound to '${oldId}'. Replacing with '${newId}'.`; + +const DEFAULT_INVALID_SHORTCUT_MESSAGE = (id: string, raw: string) => + `[superdoc/ui] ui.commands.register(): id '${id}' carries an invalid shortcut '${raw}' — ignored. Use a string like 'Mod-Shift-K'.`; + /** * Built-in group ids in the order they render in the context menu. * Custom groups land after these, ranked by the smallest registration @@ -71,6 +78,12 @@ interface InternalCustomEntry { execute: CustomCommandRegistration['execute']; getState: CustomCommandRegistration['getState']; override: boolean; + /** + * Normalized shortcut strings claimed by this registration. + * Tracked so unregister/replacement can drop them from the + * shortcut → id index in one pass. + */ + shortcuts: string[]; contextMenu: ContextMenuContribution | null; /** * Monotonic counter at registration time; ties in `(group, order)` @@ -128,6 +141,14 @@ export interface CustomCommandsRegistry { */ getContextMenuItems(state: SuperDocUIState, entities: ViewportEntityHit[]): ContextMenuItem[]; + /** + * Look up the custom command id (if any) bound to a normalized + * shortcut string. Used by the controller's keydown listener to + * dispatch matched shortcuts. Returns `undefined` when nothing is + * registered for that combo. + */ + resolveShortcut(shortcut: string): string | undefined; + /** Drop every registration and tear down per-command Subscribables. */ destroy(): void; } @@ -179,6 +200,37 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): // are broken by registration time. Stable across snapshots, doesn't // reuse values when entries are unregistered + re-registered. let nextRegistrationSeq = 0; + // Normalized shortcut string → command id. Replacement / unregister + // mutate this through `releaseShortcuts` / `claimShortcuts` so a + // stale registration's shortcuts can never dispatch into a removed + // entry. + const shortcutIndex = new Map(); + + const releaseShortcuts = (entry: InternalCustomEntry) => { + for (const sc of entry.shortcuts) { + if (shortcutIndex.get(sc) === entry.id) shortcutIndex.delete(sc); + } + }; + + const claimShortcuts = (id: string, raw: string | string[] | undefined): string[] => { + if (raw === undefined) return []; + const list = Array.isArray(raw) ? raw : [raw]; + const claimed: string[] = []; + for (const item of list) { + const normalized = normalizeShortcut(item); + if (!normalized) { + console.warn(DEFAULT_INVALID_SHORTCUT_MESSAGE(id, item)); + continue; + } + const prior = shortcutIndex.get(normalized); + if (prior && prior !== id) { + console.warn(DEFAULT_SHORTCUT_COLLISION_MESSAGE(normalized, prior, id)); + } + shortcutIndex.set(normalized, id); + claimed.push(normalized); + } + return claimed; + }; // Active observer disposers per command id. Lets `unregister` (and // replacement) actively tear down inner subscriptions instead of // waiting for the observer wrapper's lazy `!entries.has(id)` check @@ -335,9 +387,14 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): // Existing observers attached to the prior registration must be // told their command is gone before we install the new one — the // observer's `entries.has(id)` short-circuit will then detach. - if (entries.has(id)) { + const priorEntry = entries.get(id); + if (priorEntry) { console.warn(DEFAULT_REPLACEMENT_MESSAGE(id)); disposeAllObservers(id); + // Drop the prior registration's shortcuts before claiming the + // new ones so a re-registration that drops a binding doesn't + // leave a stale shortcut → id mapping. + releaseShortcuts(priorEntry); } // Capture the entry by reference so this registration's @@ -351,6 +408,7 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): execute: execute as InternalCustomEntry['execute'], getState: getState as InternalCustomEntry['getState'], override, + shortcuts: claimShortcuts(id, registration.shortcut), contextMenu: registration.contextMenu ?? null, registrationSeq: nextRegistrationSeq++, lastErrorMessage: null, @@ -387,6 +445,7 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): entries.delete(id); handleCache.delete(id); subscribableCache.delete(id); + releaseShortcuts(ownEntry); // Actively detach every active observer for this id so they // stop holding the inner Subscribable. The observer wrapper's // lazy `!entries.has(id)` check would otherwise leave the @@ -552,6 +611,10 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): return items; }, + resolveShortcut(shortcut) { + return shortcutIndex.get(shortcut); + }, + destroy() { // Dispose every active observer before clearing maps so the // inner Subscribables release their selector subscriptions; just @@ -561,6 +624,7 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): entries.clear(); handleCache.clear(); subscribableCache.clear(); + shortcutIndex.clear(); }, }; diff --git a/packages/super-editor/src/ui/keyboard-shortcuts.test.ts b/packages/super-editor/src/ui/keyboard-shortcuts.test.ts new file mode 100644 index 0000000000..6a44789cfe --- /dev/null +++ b/packages/super-editor/src/ui/keyboard-shortcuts.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeShortcut, shortcutFromEvent } from './keyboard-shortcuts.js'; + +describe('normalizeShortcut', () => { + it('canonicalizes modifier order to Mod, Alt, Shift, KEY', () => { + expect(normalizeShortcut('Shift-Mod-K')).toBe('Mod-Shift-K'); + expect(normalizeShortcut('Alt-Mod-Enter')).toBe('Mod-Alt-Enter'); + expect(normalizeShortcut('Shift-Alt-Mod-Period')).toBe('Mod-Alt-Shift-Period'); + }); + + it('upper-cases single-character keys (case-insensitive registration)', () => { + expect(normalizeShortcut('Mod-k')).toBe('Mod-K'); + expect(normalizeShortcut('Mod-K')).toBe('Mod-K'); + }); + + it('treats Cmd / Ctrl / Meta / Mod as the same modifier', () => { + expect(normalizeShortcut('Mod-K')).toBe('Mod-K'); + expect(normalizeShortcut('Ctrl-K')).toBe('Mod-K'); + expect(normalizeShortcut('Meta-K')).toBe('Mod-K'); + expect(normalizeShortcut('Control-K')).toBe('Mod-K'); + }); + + it('returns null for malformed inputs', () => { + expect(normalizeShortcut('')).toBeNull(); + expect(normalizeShortcut('Mod')).toBeNull(); + expect(normalizeShortcut('Mod-Shift')).toBeNull(); + expect(normalizeShortcut('Shift')).toBeNull(); + }); + + it('rejects unknown modifier tokens rather than silently dropping them', () => { + // `Cmd` would silently drop and bind to bare `K` if not rejected, + // firing on every K keypress during normal typing. + expect(normalizeShortcut('Cmdd-K')).toBeNull(); + // Lowercase modifier names are typos, not aliases — refuse them. + expect(normalizeShortcut('mod-k')).toBeNull(); + expect(normalizeShortcut('shift-k')).toBeNull(); + }); + + it('accepts Cmd / Command as aliases for Mod', () => { + expect(normalizeShortcut('Cmd-K')).toBe('Mod-K'); + expect(normalizeShortcut('Command-K')).toBe('Mod-K'); + }); +}); + +describe('shortcutFromEvent', () => { + function event(init: Partial & { key: string }) { + return new KeyboardEvent('keydown', init); + } + + it('builds Mod when ctrlKey or metaKey is set', () => { + expect(shortcutFromEvent(event({ key: 'k', ctrlKey: true }))).toBe('Mod-K'); + expect(shortcutFromEvent(event({ key: 'k', metaKey: true }))).toBe('Mod-K'); + }); + + it('combines modifiers in canonical order', () => { + expect(shortcutFromEvent(event({ key: 'C', ctrlKey: true, shiftKey: true }))).toBe('Mod-Shift-C'); + expect(shortcutFromEvent(event({ key: 'Enter', altKey: true, ctrlKey: true }))).toBe('Mod-Alt-Enter'); + }); + + it('returns null while a modifier itself is being pressed', () => { + expect(shortcutFromEvent(event({ key: 'Control' }))).toBeNull(); + expect(shortcutFromEvent(event({ key: 'Meta' }))).toBeNull(); + expect(shortcutFromEvent(event({ key: 'Shift' }))).toBeNull(); + expect(shortcutFromEvent(event({ key: 'Alt' }))).toBeNull(); + }); + + it('round-trips through normalizeShortcut for a canonical event', () => { + const combo = shortcutFromEvent(event({ key: 'k', ctrlKey: true, shiftKey: true })); + expect(combo).not.toBeNull(); + expect(normalizeShortcut(combo!)).toBe(combo); + }); + + it('uses event.code to recover the unshifted base for shifted digits (US: Shift-1 → "!")', () => { + // The browser fires `key='!'` for Shift-1 on US layouts. Without + // `event.code` fallback the lookup would build 'Mod-Shift-!' and + // miss any `'Mod-Shift-1'` registration. + const combo = shortcutFromEvent(event({ key: '!', code: 'Digit1', ctrlKey: true, shiftKey: true })); + expect(combo).toBe('Mod-Shift-1'); + }); +}); diff --git a/packages/super-editor/src/ui/keyboard-shortcuts.ts b/packages/super-editor/src/ui/keyboard-shortcuts.ts new file mode 100644 index 0000000000..439800cd88 --- /dev/null +++ b/packages/super-editor/src/ui/keyboard-shortcuts.ts @@ -0,0 +1,115 @@ +/** + * Keyboard-shortcut parsing and matching for `ui.commands.register({ + * shortcut })`. Shortcut strings follow the ProseMirror / Tiptap + * convention so consumers don't have to relearn: + * + * `Mod-K` Cmd+K on macOS, Ctrl+K elsewhere + * `Mod-Shift-C` Cmd+Shift+C / Ctrl+Shift+C + * `Alt-Enter` Alt+Enter + * `Mod-Alt-1` Cmd+Option+1 / Ctrl+Alt+1 + * + * Modifier order in the input string doesn't matter; everything is + * normalized to canonical `Mod, Alt, Shift, KEY` order so registry + * lookups by event key and by registered string land in the same + * bucket. + */ + +/** Single-character keys are upper-cased so 'Mod-k' === 'Mod-K'. */ +function canonicalKey(key: string): string { + return key.length === 1 ? key.toUpperCase() : key; +} + +/** + * Modifier names accepted in a shortcut string. Anything else in a + * non-key position rejects the registration — silently dropping + * unknown tokens would cause `'Cmd-K'` (Cmd is not a recognized + * alias here) to bind to bare `K`, which would fire on every K + * keypress during normal typing. + */ +const MOD_ALIASES = new Set(['Mod', 'Meta', 'Cmd', 'Command']); +const ALT_ALIASES = new Set(['Alt', 'Option']); +const CTRL_ALIASES = new Set(['Control', 'Ctrl']); +const SHIFT_ALIASES = new Set(['Shift']); + +/** + * Normalize a shortcut string to canonical form. Returns `null` for + * malformed inputs (empty, missing key, only modifiers, unknown + * modifier names). + */ +export function normalizeShortcut(input: string): string | null { + if (typeof input !== 'string' || input.length === 0) return null; + const parts = input.split('-').filter((p) => p.length > 0); + if (parts.length === 0) return null; + const key = parts[parts.length - 1]!; + const modParts = parts.slice(0, -1); + // Reject if the "key" is itself a modifier (e.g. someone wrote + // "Mod-Shift" — there's no actual key to match). + const allMods = new Set([...MOD_ALIASES, ...ALT_ALIASES, ...CTRL_ALIASES, ...SHIFT_ALIASES]); + if (allMods.has(key)) return null; + + let hasMod = false; + let hasAlt = false; + let hasShift = false; + for (const part of modParts) { + if (MOD_ALIASES.has(part) || CTRL_ALIASES.has(part)) hasMod = true; + else if (ALT_ALIASES.has(part)) hasAlt = true; + else if (SHIFT_ALIASES.has(part)) hasShift = true; + // Unknown token (typo like 'Cmdd' or lowercase 'mod') — refuse + // rather than silently drop it. Returning the bare key would + // bind the command to plain typing. + else return null; + } + + const out: string[] = []; + if (hasMod) out.push('Mod'); + if (hasAlt) out.push('Alt'); + if (hasShift) out.push('Shift'); + out.push(canonicalKey(key)); + return out.join('-'); +} + +/** + * Derive the unshifted base of a printable key from `event.code`. + * Browsers report the *shifted* character in `event.key` for + * printable keys (`Shift-1` on US layouts produces `!`, `Shift-/` + * produces `?`), but consumers register `'Mod-Shift-1'` not + * `'Mod-Shift-!'`. `event.code` carries the layout-stable digit / + * letter id (`Digit1`, `KeyA`), so we use it when shift is held to + * keep registrations and runtime lookups aligned. Letters are the + * easy case — `event.key` already returns the base letter regardless + * of shift. + */ +function unshiftedPrintableFromCode(code: string | undefined): string | null { + if (!code) return null; + if (code.startsWith('Digit') && code.length === 6) return code.slice(5); + return null; +} + +/** + * Build the canonical shortcut string for a `KeyboardEvent`. Treats + * Cmd (macOS) and Ctrl (other platforms) as the same `Mod` so + * consumers can register one string per shortcut and have it match + * either platform's combo. Returns `null` for events whose `key` is + * itself a modifier (the user is still composing the chord). + */ +export function shortcutFromEvent(event: KeyboardEvent): string | null { + const rawKey = event.key; + if (!rawKey || rawKey === 'Control' || rawKey === 'Meta' || rawKey === 'Alt' || rawKey === 'Shift') { + return null; + } + // Shifted digits — fall back to the layout-stable code so + // 'Mod-Shift-1' matches the actual keypress that produces '!' on + // US keyboards (and the equivalent shifted glyph on other layouts). + let key = rawKey; + if (event.shiftKey && rawKey.length === 1) { + const unshifted = unshiftedPrintableFromCode(event.code); + if (unshifted) key = unshifted; + } + + const out: string[] = []; + if (event.metaKey || event.ctrlKey) out.push('Mod'); + if (event.altKey) out.push('Alt'); + if (event.shiftKey) out.push('Shift'); + out.push(canonicalKey(key)); + return out.join('-'); +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 7b48f53799..aea60fe988 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -1194,6 +1194,28 @@ export type CustomCommandRegistration = { * their own menu without losing the contribution model. */ contextMenu?: ContextMenuContribution; + /** + * Optional keyboard shortcut(s) bound to this command. Follows the + * ProseMirror / Tiptap convention: `'Mod-K'`, `'Mod-Shift-C'`, + * `'Alt-Enter'`. `Mod` is the platform-correct meta key (Cmd on + * macOS, Ctrl elsewhere). Pass an array for multiple bindings on + * the same command. + * + * The controller installs a single keydown listener on the editor + * host; matched shortcuts dispatch through the same path + * `ui.commands.get(id).execute()` uses, so the consumer never has + * to wire keyboard plumbing by hand. Shortcuts only fire while + * focus is inside the editor, so a Cmd-B in a sidebar input does + * not trigger Bold on the document. + * + * Custom-vs-custom collisions: when two registrations claim the + * same shortcut, the later one wins and the controller logs a + * warning. Built-in editor keymaps (Bold's Cmd-B, etc.) are owned + * by the editor's own keymap plugin and are not in scope for + * collision detection — registering `'Mod-B'` will fire alongside + * Bold, not in place of it. + */ + shortcut?: string | string[]; }; /**