Files
site-builder/craft/src/editor/commands.ts
T
shadowdao c68e459430 feat(site-builder): complete shortcut set on the command registry with a cheat sheet
Core editor shortcuts become registry commands run by the single
useCommandShortcuts dispatcher: undo/redo, delete, duplicate (after +
select), copy/paste, Alt+arrow move, 1/2/3 device, Cmd+S/Shift+P/P site
commands and Cmd+/ cheat sheet. Tab/Esc no longer fire from buttons or
dialogs. Context menu labels come from formatShortcut.

Refs #39
2026-10-07 11:47:55 -07:00

216 lines
7.2 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import type { DeviceMode } from '../types';
import { useDevice } from '../state/DeviceContext';
import { usePages } from '../state/PageContext';
import { useSitesmithModal } from '../state/SitesmithContext';
import { useNotices } from '../state/NoticeContext';
import { useMobileChrome } from '../state/MobileChromeContext';
import { isTypingTarget, matchShortcut } from '../utils/shortcuts';
/**
* Command registry: one place where features register what they can do, shared
* by the keyboard layer, the Cmd+K palette, the top-bar "More" menu and the
* breadcrumb. Module-level (like hooks/clipboard.ts); features register from an
* effect via `useRegisterCommands` and never edit TopBar / useKeyboardShortcuts.
*/
export type CommandGroup = 'edit' | 'select' | 'view' | 'insert' | 'page' | 'site' | 'help';
export interface CommandContext {
query: any;
actions: any;
selectedIds: string[];
device: DeviceMode;
setDevice(d: DeviceMode): void;
pages: ReturnType<typeof usePages>;
notify: ReturnType<typeof useNotices>['notify'];
openSitesmith(target?: any): void;
openTemplates(): void;
openHeadCode(): void;
}
export interface Command {
id: string;
title: string;
group: CommandGroup;
keywords?: string[];
/** Shortcut spec string, see utils/shortcuts.ts ('mod+d', 'alt+arrowup'). */
shortcut?: string;
/** Extra shortcut specs that trigger the same command ('mod+y' for redo). */
shortcuts?: string[];
when?(ctx: CommandContext): boolean;
run(ctx: CommandContext): void | Promise<void>;
/** Not shown in the palette / menu (still runnable by shortcut and id). */
hidden?: boolean;
/** Fire the shortcut even while an input / contentEditable is focused. */
allowWhileTyping?: boolean;
}
const registry = new Map<string, Command>();
const listeners = new Set<() => void>();
let snapshot: Command[] | null = null;
function changed() {
snapshot = null;
listeners.forEach((cb) => cb());
}
export function registerCommand(cmd: Command): () => void {
registry.set(cmd.id, cmd);
changed();
return () => {
// Only remove our own registration; a later re-register of the same id wins.
if (registry.get(cmd.id) === cmd) {
registry.delete(cmd.id);
changed();
}
};
}
export function registerCommands(cmds: Command[]): () => void {
const offs = cmds.map(registerCommand);
return () => offs.forEach((off) => off());
}
/** Stable registration order; the same array instance until the registry changes. */
export function getCommands(): Command[] {
if (!snapshot) snapshot = Array.from(registry.values());
return snapshot;
}
export function getCommand(id: string): Command | undefined {
return registry.get(id);
}
export function subscribeCommands(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
export function __resetCommandsForTests(): void {
registry.clear();
listeners.clear();
snapshot = null;
}
export function useCommandContext(): CommandContext {
// Collect a string key (not an array) so the hook only re-renders when the
// selection actually changes.
const { actions, query, selKey } = useEditor((state) => ({
selKey: Array.from(state.events?.selected || []).join('\n'),
}));
const { device, setDevice } = useDevice();
const pages = usePages();
const { notify } = useNotices();
const { open: openSitesmith } = useSitesmithModal();
const { setTemplateModalOpen, setHeadCodeModalOpen } = useMobileChrome();
return useMemo<CommandContext>(
() => ({
query,
actions,
selectedIds: selKey ? selKey.split('\n') : [],
device,
setDevice,
pages,
notify,
openSitesmith,
openTemplates: () => setTemplateModalOpen(true),
openHeadCode: () => setHeadCodeModalOpen(true),
}),
[query, actions, selKey, device, setDevice, pages, notify, openSitesmith, setTemplateModalOpen, setHeadCodeModalOpen],
);
}
/** Live selection read; collectors are snapshots and can lag a keydown. */
function liveContext(ctx: CommandContext): CommandContext {
try {
return { ...ctx, selectedIds: ctx.query.getEvent('selected').all() };
} catch {
return ctx;
}
}
function isEnabled(cmd: Command, ctx: CommandContext): boolean {
try {
return cmd.when ? !!cmd.when(ctx) : true;
} catch {
return false;
}
}
export function useRunCommand(): (id: string) => Promise<boolean> {
const ctx = useCommandContext();
const ref = useRef(ctx);
ref.current = ctx;
return useCallback(async (id: string) => {
const cmd = getCommand(id);
if (!cmd) return false;
const c = liveContext(ref.current);
if (!isEnabled(cmd, c)) return false;
try {
await cmd.run(c);
return true;
} catch (err) {
console.error(`Command ${id} failed:`, err);
return false;
}
}, []);
}
/** Keys that are also how focus / dialogs are driven: Tab walks focus and Esc
* dismisses. They only belong to the canvas when focus is on it (or the body),
* never while a button, link or dialog holds focus. */
function focusOutsideCanvas(el: Element | null): boolean {
// An open modal owns Esc / Tab even when focus has not moved into it.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return true;
if (!el || el === document.body) return false;
if (el.closest('[role="dialog"], [aria-modal="true"], .modal-backdrop')) return true;
return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]');
}
function shortcutsOf(cmd: Command): string[] {
return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : [];
}
/**
* Document-level keydown dispatcher: first registered command whose shortcut
* matches and whose `when()` passes runs, with preventDefault. Mounted once, by
* useKeyboardShortcuts (#39), so two listeners never double-fire.
*/
export function useCommandShortcuts(): void {
const ctx = useCommandContext();
const ref = useRef(ctx);
ref.current = ctx;
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const typing = isTypingTarget(document.activeElement);
const c = liveContext(ref.current);
for (const cmd of getCommands()) {
const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec));
if (!hit) continue;
if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue;
if (typing && !cmd.allowWhileTyping) continue;
if (!isEnabled(cmd, c)) continue;
e.preventDefault();
try {
Promise.resolve(cmd.run(c)).catch((err) => console.error(`Command ${cmd.id} failed:`, err));
} catch (err) {
console.error(`Command ${cmd.id} failed:`, err);
}
return;
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
}
/** Register commands for the lifetime of a component; re-registers when `deps` change. */
export function useRegisterCommands(cmds: Command[], deps: unknown[]): void {
useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps
}