Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/super-editor/src/ui/create-super-doc-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()`,
Expand Down
148 changes: 148 additions & 0 deletions packages/super-editor/src/ui/custom-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).presentationEditor ?? {};
(stubs.editor as unknown as { presentationEditor: Record<string, unknown> }).presentationEditor = {
getActiveEditor: () => stubs.editor,
...existing,
visibleHost: host,
};
return { ...stubs, host };
}

function fireKey(target: Node, init: Partial<KeyboardEventInit> & { 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();
});
});
66 changes: 65 additions & 1 deletion packages/super-editor/src/ui/custom-commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizeShortcut } from './keyboard-shortcuts.js';
import type {
ContextMenuContribution,
ContextMenuItem,
Expand All @@ -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
Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string, string>();

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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -561,6 +624,7 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps):
entries.clear();
handleCache.clear();
subscribableCache.clear();
shortcutIndex.clear();
},
};

Expand Down
Loading
Loading