feat(site-builder): command registry, shortcut matcher, tree walkers and top-bar command menu

Library only; nothing is mounted. Adds minimal DeviceContext/NoticeContext
stubs (owned by W0-breakpoints / W0-registry-notices) that expose the
agreed interfaces.

Refs #39 #49 #34

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 11:14:52 -07:00
1 parent 97ccbee461
commit 73aa35ac96
11 files changed
+1005

No files matched your search

+200
View File
@@ -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<boolean>) | 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<Command> & { id: string }): Command => ({
title: over.id,
group: 'edit',
run: () => {},
...over,
});
beforeEach(() => {
__resetCommandsForTests();
ctxRef = { current: null };
runRef = { current: null };
h = renderEditorHarness();
h.mountChild(<Probe />);
});
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(<><Probe /><Feature /></>);
expect(getCommand('feat')).toBeDefined();
h.mountChild(<Probe />);
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);
});
});
+197
View File
@@ -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<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;
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;
}
}, []);
}
/**
* 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
}
@@ -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(<TopBarCommandMenu />);
});
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');
});
});
@@ -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<HTMLDivElement>(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 (
<div className="topbar-command-menu" ref={wrapRef}>
<button
type="button"
className="topbar-command-btn"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={open}
title="More actions"
onClick={() => setOpen((o) => !o)}
>
<i className="fa fa-ellipsis-h" aria-hidden="true" />
</button>
{open && (
<div className="topbar-command-dropdown" role="menu">
{items.length === 0 && <div className="topbar-command-empty">No actions available</div>}
{items.map((c) => {
let enabled = true;
try {
enabled = c.when ? !!c.when(ctx) : true;
} catch {
enabled = false;
}
return (
<button
key={c.id}
type="button"
role="menuitem"
className="topbar-command-item"
disabled={!enabled}
onClick={() => {
close();
void run(c.id);
}}
>
<span className="topbar-command-title">{c.title}</span>
{c.shortcut && <span className="topbar-command-shortcut">{formatShortcut(c.shortcut)}</span>}
</button>
);
})}
</div>
)}
</div>
);
};
+22
View File
@@ -0,0 +1,22 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DeviceMode } from '../types';
/**
* MINIMAL STUB added by W0-commands so the command registry can code against
* the `{device, setDevice}` interface. W0-breakpoints owns the real
* implementation of this file; on merge, take theirs (the API is the same).
*/
interface DeviceContextValue {
device: DeviceMode;
setDevice: (d: DeviceMode) => void;
}
const DeviceCtx = createContext<DeviceContextValue>({ device: 'desktop', setDevice: () => {} });
export const useDevice = () => useContext(DeviceCtx);
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [device, setDevice] = useState<DeviceMode>('desktop');
const value = useMemo(() => ({ device, setDevice }), [device]);
return <DeviceCtx.Provider value={value}>{children}</DeviceCtx.Provider>;
};
+34
View File
@@ -0,0 +1,34 @@
import { createContext, useContext } from 'react';
/**
* MINIMAL STUB added by W0-commands so the command registry can code against
* the `useNotices().notify` interface. W0-registry-notices owns the real
* implementation (provider, host, timeouts); on merge, take theirs. The
* default (no provider) is a console-only no-op, matching their brief.
*/
export interface Notice {
id?: string;
kind: 'info' | 'success' | 'error';
message: string;
actionLabel?: string;
onAction?(): void;
timeoutMs?: number;
}
interface NoticeContextValue {
notify(n: Notice): string;
dismiss(id: string): void;
notices: Array<Notice & { id: string }>;
}
const NoticeCtx = createContext<NoticeContextValue>({
notify: (n) => {
// eslint-disable-next-line no-console
console.warn(`[notice:${n.kind}] ${n.message}`);
return n.id ?? '';
},
dismiss: () => {},
notices: [],
});
export const useNotices = () => useContext(NoticeCtx);
+44
View File
@@ -2054,3 +2054,47 @@ body {
padding: 4px 12px;
}
.conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; }
/* W0-commands: topbar command menu */
.topbar-command-menu { position: relative; display: inline-flex; }
.topbar-command-btn {
background: transparent;
border: 1px solid transparent;
color: inherit;
border-radius: var(--radius-lg);
padding: 4px 10px;
cursor: pointer;
}
.topbar-command-btn:hover, .topbar-command-btn[aria-expanded="true"] { border-color: var(--color-border); }
.topbar-command-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: var(--z-overflow, 1000);
min-width: 240px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.topbar-command-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: transparent;
border: 0;
color: inherit;
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-md, 6px);
cursor: pointer;
}
.topbar-command-item:hover:not(:disabled) { background: var(--color-bg-hover); }
.topbar-command-item:disabled { opacity: 0.45; cursor: default; }
.topbar-command-shortcut { font-size: 12px; opacity: 0.6; }
.topbar-command-empty { padding: 8px 10px; font-size: 12px; opacity: 0.6; }
+88
View File
@@ -0,0 +1,88 @@
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { buildNodeTree } from './craft-tree';
import {
getParentId,
getChildIds,
getSiblingIds,
getAncestorIds,
getIndexInParent,
getNodeLabel,
} from './craft-helpers';
let h: EditorHarness;
// ROOT > sec (Section) > [section-inner] > cols (ColumnLayout) > [col-0 = c1 > btn, col-1 = c2]
beforeEach(() => {
h = renderEditorHarness();
const tree = buildNodeTree(h.query, {
type: { resolvedName: 'Section' },
props: { node_id: 'sec' },
nodes: [
{
type: { resolvedName: 'ColumnLayout' },
props: { node_id: 'cols', columns: 2 },
nodes: [
{
type: { resolvedName: 'Container' },
props: { node_id: 'c1' },
nodes: [{ type: { resolvedName: 'ButtonLink' }, props: { node_id: 'btn', aiName: 'Hero CTA' }, nodes: [] }],
},
{ type: { resolvedName: 'Container' }, props: { node_id: 'c2' }, nodes: [] },
],
},
],
} as any);
h.act(() => h.actions.addNodeTree(tree, 'ROOT'));
});
afterEach(() => h.unmount());
describe('craft tree walkers (real editor)', () => {
test('getParentId', () => {
expect(getParentId(h.query, 'btn')).toBe('c1');
expect(getParentId(h.query, 'c1')).toBe('cols');
expect(getParentId(h.query, 'sec')).toBe('ROOT');
expect(getParentId(h.query, 'ROOT')).toBeNull();
expect(getParentId(h.query, 'nope')).toBeNull();
});
test('getChildIds returns nodes then linked nodes', () => {
expect(getChildIds(h.query, 'c1')).toEqual(['btn']);
expect(getChildIds(h.query, 'cols')).toEqual(['c1', 'c2']); // linked col-0, col-1
const inner = h.query.node('sec').get().data.linkedNodes['section-inner'];
expect(getChildIds(h.query, 'sec')).toEqual([inner]);
expect(getChildIds(h.query, 'ROOT')).toEqual(['sec']);
expect(getChildIds(h.query, 'nope')).toEqual([]);
});
test('getSiblingIds for normal and linked nodes', () => {
expect(getSiblingIds(h.query, 'btn')).toEqual(['btn']);
expect(getSiblingIds(h.query, 'c1')).toEqual(['c1', 'c2']);
expect(getSiblingIds(h.query, 'c2')).toEqual(['c1', 'c2']);
expect(getSiblingIds(h.query, 'sec')).toEqual(['sec']);
expect(getSiblingIds(h.query, 'ROOT')).toEqual([]);
});
test('getIndexInParent', () => {
expect(getIndexInParent(h.query, 'c1')).toBe(0);
expect(getIndexInParent(h.query, 'c2')).toBe(1);
expect(getIndexInParent(h.query, 'sec')).toBe(0);
expect(getIndexInParent(h.query, 'ROOT')).toBe(-1);
});
test('getAncestorIds nearest first, excluding ROOT', () => {
const inner = h.query.node('sec').get().data.linkedNodes['section-inner'];
expect(getAncestorIds(h.query, 'btn')).toEqual(['c1', 'cols', inner, 'sec']);
expect(getAncestorIds(h.query, 'sec')).toEqual([]);
expect(getAncestorIds(h.query, 'ROOT')).toEqual([]);
});
test('getNodeLabel precedence and ROOT', () => {
expect(getNodeLabel(h.query, 'ROOT')).toBe('Canvas');
expect(getNodeLabel(h.query, 'btn')).toBe('Hero CTA'); // props.aiName
expect(getNodeLabel(h.query, 'c2')).toBe('Container'); // displayName
h.act(() => h.actions.setCustom('c2', (c: any) => { c.label = 'Right column'; }));
expect(getNodeLabel(h.query, 'c2')).toBe('Right column'); // custom.label wins
expect(getNodeLabel(h.query, 'nope')).toBe('Node');
});
});
+59
View File
@@ -26,3 +26,62 @@ export function findDeletableTarget(query: any, nodeId: string | null | undefine
return null;
}
}
// ---- Tree walkers (shared by commands, breadcrumb, palette) ----
function nodeData(query: any, id: string): any {
try {
return query.node(id).get()?.data ?? null;
} catch {
return null;
}
}
export function getParentId(query: any, id: string): string | null {
if (!id || id === 'ROOT') return null;
return nodeData(query, id)?.parent ?? null;
}
/** Children in order: `nodes` first, then linked nodes (Section inner, columns). */
export function getChildIds(query: any, id: string): string[] {
const d = nodeData(query, id);
if (!d) return [];
return [...((d.nodes as string[] | undefined) || []), ...Object.values((d.linkedNodes || {}) as Record<string, string>)];
}
/** Siblings including the node itself, in order. For a linked node these are
* the parent's linked values; empty for ROOT / unknown nodes. */
export function getSiblingIds(query: any, id: string): string[] {
const parentId = getParentId(query, id);
if (!parentId) return [];
const pd = nodeData(query, parentId);
if (!pd) return [];
const nodes: string[] = pd.nodes || [];
if (nodes.includes(id)) return [...nodes];
const linked = Object.values((pd.linkedNodes || {}) as Record<string, string>);
return linked.includes(id) ? linked : [];
}
/** Nearest first, ROOT excluded. */
export function getAncestorIds(query: any, id: string): string[] {
const out: string[] = [];
const seen = new Set<string>([id]);
let cur = getParentId(query, id);
while (cur && cur !== 'ROOT' && !seen.has(cur)) {
out.push(cur);
seen.add(cur);
cur = getParentId(query, cur);
}
return out;
}
export function getIndexInParent(query: any, id: string): number {
return getSiblingIds(query, id).indexOf(id);
}
export function getNodeLabel(query: any, id: string): string {
if (id === 'ROOT') return 'Canvas';
const d = nodeData(query, id);
if (!d) return 'Node';
return d.custom?.label || d.props?.aiName || d.displayName || 'Node';
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import { parseShortcut, matchShortcut, formatShortcut, isTypingTarget } from './shortcuts';
function setPlatform(platform: string, ua = '') {
vi.stubGlobal('navigator', { platform, userAgent: ua });
}
afterEach(() => vi.unstubAllGlobals());
function ev(init: KeyboardEventInit): KeyboardEvent {
return new KeyboardEvent('keydown', init);
}
describe('parseShortcut', () => {
test.each([
['mod+d', { key: 'd', mod: true }],
['mod+shift+z', { key: 'z', mod: true, shift: true }],
['alt+arrowup', { key: 'arrowup', alt: true }],
['escape', { key: 'escape' }],
['shift+tab', { key: 'tab', shift: true }],
['1', { key: '1' }],
['mod+/', { key: '/', mod: true }],
['mod++', { key: '+', mod: true }],
])('%s', (spec, expected) => {
expect(parseShortcut(spec)).toEqual(expected);
});
});
describe('matchShortcut', () => {
test('non-mac: mod is ctrl only, exact modifiers', () => {
setPlatform('Linux x86_64');
expect(matchShortcut(ev({ key: 'd', ctrlKey: true }), 'mod+d')).toBe(true);
expect(matchShortcut(ev({ key: 'D', ctrlKey: true }), 'mod+d')).toBe(true);
expect(matchShortcut(ev({ key: 'd', metaKey: true }), 'mod+d')).toBe(false);
expect(matchShortcut(ev({ key: 'd' }), 'mod+d')).toBe(false);
expect(matchShortcut(ev({ key: 'd', ctrlKey: true, shiftKey: true }), 'mod+d')).toBe(false);
expect(matchShortcut(ev({ key: 'Z', ctrlKey: true, shiftKey: true }), 'mod+shift+z')).toBe(true);
expect(matchShortcut(ev({ key: 'z', ctrlKey: true }), 'mod+shift+z')).toBe(false);
});
test('mac: mod is cmd, ctrl also accepted', () => {
setPlatform('MacIntel');
expect(matchShortcut(ev({ key: 'd', metaKey: true }), 'mod+d')).toBe(true);
expect(matchShortcut(ev({ key: 'd', ctrlKey: true }), 'mod+d')).toBe(true);
expect(matchShortcut(ev({ key: 'd' }), 'mod+d')).toBe(false);
});
test('modifier-less specs reject ctrl/meta; plain keys match', () => {
setPlatform('Linux x86_64');
expect(matchShortcut(ev({ key: 'Escape' }), 'escape')).toBe(true);
expect(matchShortcut(ev({ key: 'Escape', ctrlKey: true }), 'escape')).toBe(false);
expect(matchShortcut(ev({ key: '1' }), '1')).toBe(true);
expect(matchShortcut(ev({ key: 'Tab', shiftKey: true }), 'shift+tab')).toBe(true);
expect(matchShortcut(ev({ key: 'Tab' }), 'shift+tab')).toBe(false);
expect(matchShortcut(ev({ key: '/', ctrlKey: true }), 'mod+/')).toBe(true);
});
test('alt uses physical code when key is a composed glyph (mac)', () => {
setPlatform('MacIntel');
expect(matchShortcut(ev({ key: '∂', code: 'KeyD', altKey: true }), 'alt+d')).toBe(true);
expect(matchShortcut(ev({ key: 'ArrowUp', altKey: true }), 'alt+arrowup')).toBe(true);
});
});
describe('formatShortcut', () => {
test('non-mac', () => {
setPlatform('Linux x86_64');
expect(formatShortcut('mod+d')).toBe('Ctrl+D');
expect(formatShortcut('mod+shift+z')).toBe('Ctrl+Shift+Z');
expect(formatShortcut('alt+arrowup')).toBe('Alt+↑');
expect(formatShortcut('escape')).toBe('Esc');
expect(formatShortcut('shift+tab')).toBe('Shift+Tab');
expect(formatShortcut('mod+/')).toBe('Ctrl+/');
});
test('mac', () => {
setPlatform('MacIntel');
expect(formatShortcut('mod+d')).toBe('⌘D');
expect(formatShortcut('mod+shift+z')).toBe('⌘⇧Z');
expect(formatShortcut('alt+arrowup')).toBe('⌥↑');
expect(formatShortcut('escape')).toBe('Esc');
expect(formatShortcut('shift+tab')).toBe('⇧Tab');
});
});
describe('isTypingTarget', () => {
test('inputs, textarea, select, contentEditable', () => {
expect(isTypingTarget(null)).toBe(false);
expect(isTypingTarget(document.createElement('input'))).toBe(true);
expect(isTypingTarget(document.createElement('textarea'))).toBe(true);
expect(isTypingTarget(document.createElement('select'))).toBe(true);
expect(isTypingTarget(document.createElement('div'))).toBe(false);
expect(isTypingTarget(document.createElement('button'))).toBe(false);
// jsdom does not implement isContentEditable; model the real browser getter.
const ce = document.createElement('div');
Object.defineProperty(ce, 'isContentEditable', { value: true });
expect(isTypingTarget(ce)).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Keyboard shortcut spec helpers shared by the command registry, the palette
* and the menus. A spec is a '+'-joined string: modifiers (`mod`, `shift`,
* `alt`) then a key, all lower-case -- 'mod+d', 'mod+shift+z', 'alt+arrowup',
* 'escape', 'shift+tab', '1', 'mod+/'.
*/
export interface ShortcutSpec {
/** KeyboardEvent.key lower-cased ('d', 'arrowup', 'escape', 'tab', '/', '1'). */
key: string;
mod?: boolean;
shift?: boolean;
alt?: boolean;
}
export function isMac(): boolean {
if (typeof navigator === 'undefined') return false;
const p = (navigator as any).platform || '';
const ua = navigator.userAgent || '';
return /Mac|iPhone|iPad|iPod/i.test(p) || /Mac OS X|iPhone|iPad|iPod/i.test(ua);
}
export function parseShortcut(spec: string): ShortcutSpec {
const out: ShortcutSpec = { key: '' };
// A trailing '+' is the plus key itself ('mod++').
let body = spec.trim();
let key = '';
if (body.endsWith('+')) {
key = '+';
body = body.slice(0, -1).replace(/\+$/, '');
}
const parts = body ? body.split('+') : [];
if (!key) key = (parts.pop() || '').toLowerCase();
for (const raw of parts) {
const m = raw.trim().toLowerCase();
if (m === 'mod' || m === 'cmd' || m === 'ctrl' || m === 'meta') out.mod = true;
else if (m === 'shift') out.shift = true;
else if (m === 'alt' || m === 'option') out.alt = true;
}
out.key = key === 'esc' ? 'escape' : key === 'space' ? ' ' : key;
return out;
}
/** The key a keydown event represents. With Alt held on macOS `e.key` is a
* composed glyph ('∂' for Alt+D), so fall back to the physical `code`. */
function eventKey(e: KeyboardEvent): string {
const code = e.code || '';
if (e.altKey) {
const letter = /^Key([A-Z])$/.exec(code);
if (letter) return letter[1].toLowerCase();
const digit = /^Digit([0-9])$/.exec(code);
if (digit) return digit[1];
}
return (e.key || '').toLowerCase();
}
export function matchShortcut(e: KeyboardEvent, spec: string): boolean {
const s = parseShortcut(spec);
if (!s.key || eventKey(e) !== s.key) return false;
if (!!s.shift !== e.shiftKey || !!s.alt !== e.altKey) return false;
if (s.mod) {
// mac: Cmd is the modifier; Ctrl also counts so existing habits keep working.
return isMac() ? (e.metaKey || e.ctrlKey) : e.ctrlKey && !e.metaKey;
}
return !e.ctrlKey && !e.metaKey;
}
const KEY_LABELS: Record<string, [string, string]> = {
// [mac glyph form, plain form]
arrowup: ['↑', '↑'],
arrowdown: ['↓', '↓'],
arrowleft: ['←', '←'],
arrowright: ['→', '→'],
escape: ['Esc', 'Esc'],
tab: ['Tab', 'Tab'],
delete: ['Del', 'Del'],
backspace: ['⌫', 'Backspace'],
enter: ['↩', 'Enter'],
' ': ['Space', 'Space'],
};
export function formatShortcut(spec: string): string {
const s = parseShortcut(spec);
const mac = isMac();
const label = KEY_LABELS[s.key]?.[mac ? 0 : 1] ?? (s.key.length === 1 ? s.key.toUpperCase() : s.key);
const mods: string[] = [];
if (mac) {
if (s.mod) mods.push('⌘');
if (s.alt) mods.push('⌥');
if (s.shift) mods.push('⇧');
return mods.join('') + label;
}
if (s.mod) mods.push('Ctrl');
if (s.alt) mods.push('Alt');
if (s.shift) mods.push('Shift');
return [...mods, label].join('+');
}
/** True when keystrokes there belong to text entry (input/textarea/select/contentEditable). */
export function isTypingTarget(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}