# Conflicts: # craft/src/styles/editor.css
This commit is contained in:
commit
ae871c9cdc
11 files changed
+1004
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user