Merge W0-commands (Refs #39 #49 #34)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:20:25 -07:00
commit ae871c9cdc
11 files changed
+1004

No files matched your search

+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;
}