diff --git a/craft/src/editor/commands.test.tsx b/craft/src/editor/commands.test.tsx new file mode 100644 index 0000000..eae67b4 --- /dev/null +++ b/craft/src/editor/commands.test.tsx @@ -0,0 +1,200 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { buildNodeTree } from '../utils/craft-tree'; +import { + registerCommand, + registerCommands, + getCommands, + getCommand, + subscribeCommands, + useCommandContext, + useCommandShortcuts, + useRunCommand, + useRegisterCommands, + __resetCommandsForTests, + type CommandContext, + type Command, +} from './commands'; + +let h: EditorHarness; +let ctxRef: { current: CommandContext | null }; +let runRef: { current: ((id: string) => Promise) | null }; + +const Probe: React.FC = () => { + useCommandShortcuts(); + ctxRef.current = useCommandContext(); + runRef.current = useRunCommand(); + return null; +}; + +function key(init: KeyboardEventInit, target: EventTarget = document.body) { + const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + h.act(() => { + target.dispatchEvent(e); + }); + return e; +} + +const cmd = (over: Partial & { id: string }): Command => ({ + title: over.id, + group: 'edit', + run: () => {}, + ...over, +}); + +beforeEach(() => { + __resetCommandsForTests(); + ctxRef = { current: null }; + runRef = { current: null }; + h = renderEditorHarness(); + h.mountChild(); +}); +afterEach(() => { + h.unmount(); + __resetCommandsForTests(); + document.body.innerHTML = ''; +}); + +describe('registry', () => { + test('order, replace by id, unregister, subscribe', () => { + const cb = vi.fn(); + const off = subscribeCommands(cb); + const offA = registerCommand(cmd({ id: 'a' })); + registerCommands([cmd({ id: 'b' }), cmd({ id: 'c' })]); + expect(getCommands().map((c) => c.id)).toEqual(['a', 'b', 'c']); + expect(getCommands()).toBe(getCommands()); // stable snapshot + const a2 = cmd({ id: 'a', title: 'A2' }); + registerCommand(a2); + expect(getCommand('a')).toBe(a2); + expect(getCommands().map((c) => c.id)).toEqual(['a', 'b', 'c']); + offA(); // stale unregister must not remove the replacement + expect(getCommand('a')).toBe(a2); + expect(cb).toHaveBeenCalled(); + off(); + }); + + test('useRegisterCommands registers on mount and unregisters on unmount', () => { + const Feature: React.FC = () => { + useRegisterCommands([cmd({ id: 'feat' })], []); + return null; + }; + h.mountChild(<>); + expect(getCommand('feat')).toBeDefined(); + h.mountChild(); + expect(getCommand('feat')).toBeUndefined(); + }); +}); + +describe('useCommandShortcuts', () => { + test('mod+d runs the matching command once, preventDefault called', () => { + const dup = vi.fn(); + const other = vi.fn(); + registerCommands([ + cmd({ id: 'edit.other', shortcut: 'mod+e', run: other }), + cmd({ id: 'edit.duplicate', shortcut: 'mod+d', run: dup }), + ]); + const e = key({ key: 'd', ctrlKey: true }); + expect(dup).toHaveBeenCalledTimes(1); + expect(other).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(true); + expect(dup.mock.calls[0][0].query).toBeDefined(); + }); + + test('not run when a focused input is the target', () => { + const run = vi.fn(); + registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run })); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const e = key({ key: 'd', ctrlKey: true }, input); + expect(run).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + test('allowWhileTyping commands still fire in an input', () => { + const run = vi.fn(); + registerCommand(cmd({ id: 'x', shortcut: 'mod+k', allowWhileTyping: true, run })); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + key({ key: 'k', ctrlKey: true }, input); + expect(run).toHaveBeenCalledTimes(1); + }); + + test('when() false: not run, event not prevented, next match may run', () => { + const blocked = vi.fn(); + const fallback = vi.fn(); + registerCommands([ + cmd({ id: 'blocked', shortcut: 'mod+d', when: () => false, run: blocked }), + cmd({ id: 'fallback', shortcut: 'mod+d', run: fallback }), + ]); + const e = key({ key: 'd', ctrlKey: true }); + expect(blocked).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + + __resetCommandsForTests(); + registerCommand(cmd({ id: 'only', shortcut: 'mod+d', when: () => false, run: blocked })); + const e2 = key({ key: 'd', ctrlKey: true }); + expect(blocked).not.toHaveBeenCalled(); + expect(e2.defaultPrevented).toBe(false); + }); + + test('unregister stops it', () => { + const run = vi.fn(); + const off = registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run })); + key({ key: 'd', ctrlKey: true }); + expect(run).toHaveBeenCalledTimes(1); + off(); + const e = key({ key: 'd', ctrlKey: true }); + expect(run).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(false); + }); + + test('run sees the LIVE selection even before the collector re-renders', () => { + const tree = buildNodeTree(h.query, { type: { resolvedName: 'Container' }, props: { node_id: 'n1' }, nodes: [] } as any); + h.act(() => h.actions.addNodeTree(tree, 'ROOT')); + let seen: string[] = []; + registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run: (c) => { seen = c.selectedIds; } })); + // Same act() batch: React has not re-rendered, so only a live read can see it. + h.act(() => { + h.actions.selectNode('n1'); + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', ctrlKey: true, bubbles: true, cancelable: true })); + }); + expect(seen).toEqual(['n1']); + }); +}); + +describe('useCommandContext / useRunCommand', () => { + test('selectedIds reflects actions.selectNode', () => { + const tree = buildNodeTree(h.query, { type: { resolvedName: 'Container' }, props: { node_id: 'n1' }, nodes: [] } as any); + h.act(() => h.actions.addNodeTree(tree, 'ROOT')); + expect(ctxRef.current!.selectedIds).toEqual([]); + h.act(() => h.actions.selectNode('n1')); + expect(ctxRef.current!.selectedIds).toEqual(['n1']); + h.act(() => h.actions.clearEvents()); + expect(ctxRef.current!.selectedIds).toEqual([]); + }); + + test('exposes the composed contexts', () => { + const c = ctxRef.current!; + expect(c.device).toBe('desktop'); + for (const fn of ['setDevice', 'notify', 'openSitesmith', 'openTemplates', 'openHeadCode'] as const) { + expect(typeof c[fn]).toBe('function'); + } + expect(c.pages).toBeDefined(); + }); + + test('useRunCommand: runs by id; false when missing or when() fails', async () => { + const run = vi.fn(); + registerCommands([ + cmd({ id: 'ok', run }), + cmd({ id: 'off', when: () => false, run }), + ]); + expect(await runRef.current!('ok')).toBe(true); + expect(await runRef.current!('off')).toBe(false); + expect(await runRef.current!('missing')).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/craft/src/editor/commands.ts b/craft/src/editor/commands.ts new file mode 100644 index 0000000..beec9e3 --- /dev/null +++ b/craft/src/editor/commands.ts @@ -0,0 +1,197 @@ +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; + notify: ReturnType['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; + when?(ctx: CommandContext): boolean; + run(ctx: CommandContext): void | Promise; + /** 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(); +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( + () => ({ + 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 { + 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; + } + }, []); +} + +/** + * Document-level keydown dispatcher: first registered command whose shortcut + * matches and whose `when()` passes runs, with preventDefault. NOT mounted by + * W0-commands -- issue #39 mounts it (and retires useKeyboardShortcuts) 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()) { + if (!cmd.shortcut || !matchShortcut(e, cmd.shortcut)) 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 +} diff --git a/craft/src/panels/topbar/TopBarCommandMenu.test.tsx b/craft/src/panels/topbar/TopBarCommandMenu.test.tsx new file mode 100644 index 0000000..f0e5ddd --- /dev/null +++ b/craft/src/panels/topbar/TopBarCommandMenu.test.tsx @@ -0,0 +1,79 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { registerCommands, __resetCommandsForTests } from '../../editor/commands'; +import { TopBarCommandMenu } from './TopBarCommandMenu'; + +let h: EditorHarness; +const run = vi.fn(); + +beforeEach(() => { + __resetCommandsForTests(); + run.mockReset(); + registerCommands([ + { id: 'site.download', title: 'Download site', group: 'site', shortcut: 'mod+shift+e', run }, + { id: 'site.disabled', title: 'Disabled thing', group: 'site', when: () => false, run }, + { id: 'site.hidden', title: 'Hidden thing', group: 'site', hidden: true, run }, + { id: 'edit.duplicate', title: 'Duplicate', group: 'edit', run }, + ]); + h = renderEditorHarness(); + h.mountChild(); +}); +afterEach(() => { + h.unmount(); + __resetCommandsForTests(); +}); + +const button = () => h.container.querySelector('.topbar-command-btn') as HTMLButtonElement; +const items = () => Array.from(h.container.querySelectorAll('.topbar-command-item')) as HTMLButtonElement[]; + +describe('TopBarCommandMenu', () => { + test('lists only visible site commands, with shortcut labels and disabled state', () => { + expect(items()).toHaveLength(0); + h.act(() => button().click()); + const titles = items().map((i) => i.querySelector('.topbar-command-title')!.textContent); + expect(titles).toEqual(['Download site', 'Disabled thing']); + expect(items()[0].querySelector('.topbar-command-shortcut')!.textContent).toMatch(/Shift\+E$|⇧E$/); + expect(items()[0].disabled).toBe(false); + expect(items()[1].disabled).toBe(true); + }); + + test('click runs the command and closes the menu', async () => { + h.act(() => button().click()); + await h.act(async () => { + items()[0].click(); + }); + expect(run).toHaveBeenCalledTimes(1); + expect(items()).toHaveLength(0); + }); + + test('disabled item does not run', async () => { + h.act(() => button().click()); + await h.act(async () => { + items()[1].click(); + }); + expect(run).not.toHaveBeenCalled(); + }); + + test('Escape closes; outside mousedown closes', () => { + h.act(() => button().click()); + expect(items().length).toBeGreaterThan(0); + h.act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(items()).toHaveLength(0); + h.act(() => button().click()); + h.act(() => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(items()).toHaveLength(0); + }); + + test('late registration shows up without reopening', () => { + h.act(() => button().click()); + h.act(() => { + registerCommands([{ id: 'site.late', title: 'Late one', group: 'site', run }]); + }); + expect(items().map((i) => i.textContent)).toContain('Late one'); + }); +}); diff --git a/craft/src/panels/topbar/TopBarCommandMenu.tsx b/craft/src/panels/topbar/TopBarCommandMenu.tsx new file mode 100644 index 0000000..8234e29 --- /dev/null +++ b/craft/src/panels/topbar/TopBarCommandMenu.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { getCommands, subscribeCommands, useCommandContext, useRunCommand } from '../../editor/commands'; +import { formatShortcut } from '../../utils/shortcuts'; + +/** + * Top-bar "⋯" menu listing every registered `site` command. Features add their + * entry with `registerCommand({group: 'site', ...})` instead of editing the + * TopBar. Closes on Escape / outside click (same pattern as TopBarOverflowMenu). + */ +export const TopBarCommandMenu: React.FC = () => { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + const commands = useSyncExternalStore(subscribeCommands, getCommands, getCommands); + const ctx = useCommandContext(); + const run = useRunCommand(); + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) close(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open, close]); + + const items = commands.filter((c) => c.group === 'site' && !c.hidden); + + return ( +
+