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
216 lines
7.2 KiB
TypeScript
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
|
|
}
|