Merge issue-34 (Refs #34)

This commit is contained in:
shadowdao committed 2026-10-07 11:41:22 -07:00
commit 74df8a3b70
9 files changed
+541 -16

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { SELECTION_STATE } from './selection-fixture';
import { Breadcrumb, getCrumbIds } from './Breadcrumb';
import { __resetCommandsForTests, getCommand } from './commands';
let h: EditorHarness;
beforeEach(() => {
__resetCommandsForTests();
h = renderEditorHarness({ initialState: SELECTION_STATE });
h.mountChild(<Breadcrumb />);
});
afterEach(() => { h.unmount(); __resetCommandsForTests(); });
const select = (id: string) => h.act(() => { h.actions.selectNode(id); });
const crumbs = () => Array.from(h.container.querySelectorAll<HTMLButtonElement>('.canvas-breadcrumb-crumb'));
describe('Breadcrumb', () => {
test('renders nothing with no selection or ROOT selected', () => {
expect(h.container.querySelector('.canvas-breadcrumb')).toBeNull();
select('ROOT');
expect(h.container.querySelector('.canvas-breadcrumb')).toBeNull();
});
test('path ids collapse the Section inner container', () => {
expect(getCrumbIds(h.query, 'b1')).toEqual(['sec', 'cols', 'col-0', 'b1']);
// the inner container itself stays when it is the selection
expect(getCrumbIds(h.query, 'sec-in')).toEqual(['sec', 'sec-in']);
});
test('renders the path, marks current, labelled nav', () => {
select('b1');
const nav = h.container.querySelector('nav')!;
expect(nav.getAttribute('aria-label')).toBe('Selection path');
expect(crumbs().map((c) => c.dataset.nodeId)).toEqual(['sec', 'cols', 'col-0', 'b1']);
const current = crumbs().filter((c) => c.getAttribute('aria-current') === 'true');
expect(current.map((c) => c.dataset.nodeId)).toEqual(['b1']);
});
test('clicking a crumb selects that node and the path shortens', () => {
select('b1');
h.act(() => { crumbs()[1].click(); });
expect(h.query.getEvent('selected').first()).toBe('cols');
expect(crumbs().map((c) => c.dataset.nodeId)).toEqual(['sec', 'cols']);
});
test('hover sets data-layer-hovered on the target dom and clears on leave/unmount', () => {
select('b1');
const dom = h.query.node('col-0').get().dom as HTMLElement; // real rendered element
expect(dom).toBeTruthy();
const target = crumbs()[2];
h.act(() => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(dom.hasAttribute('data-layer-hovered')).toBe(true);
h.act(() => { target.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
expect(dom.hasAttribute('data-layer-hovered')).toBe(false);
h.act(() => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(dom.hasAttribute('data-layer-hovered')).toBe(true);
h.unmount();
expect(dom.hasAttribute('data-layer-hovered')).toBe(false);
h = renderEditorHarness(); // so afterEach unmount is valid
});
test('registers the select.* commands', () => {
expect(getCommand('select.parent')?.shortcut).toBe('escape');
expect(getCommand('select.next-sibling')).toBeDefined();
});
});
+83
View File
@@ -0,0 +1,83 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { getAncestorIds, getNodeLabel } from '../utils/craft-helpers';
import { isSectionInnerNode, registerSelectionCommands } from './selection-commands';
/** Ancestry of `id` root-first, ending with `id`; a Section's own inner
* container is collapsed into the Section (unless it is the node itself). */
export function getCrumbIds(query: any, id: string): string[] {
const ancestors = getAncestorIds(query, id)
.reverse()
.filter((a) => !isSectionInnerNode(query, a));
return [...ancestors, id];
}
/**
* Selection path strip between the top bar and the canvas. Keys are NOT
* handled here (they live in useKeyboardShortcuts); this only registers the
* select.* commands so the registry has them.
*/
export const Breadcrumb: React.FC = () => {
const { actions, query, sig } = useEditor((state, q) => {
const sel = state.events.selected ? Array.from(state.events.selected)[0] : null;
if (!sel || sel === 'ROOT' || !state.nodes[sel as string]) return { sig: '' };
const crumbs = getCrumbIds(q, sel as string).map((id) => [id, getNodeLabel(q, id)]);
return { sig: JSON.stringify(crumbs) };
});
useEffect(() => registerSelectionCommands(), []);
const hoveredRef = useRef<HTMLElement | null>(null);
const clearHover = () => {
hoveredRef.current?.removeAttribute('data-layer-hovered');
hoveredRef.current = null;
};
useEffect(() => clearHover, []);
// A selection change can remove the crumb under the pointer without a leave.
useEffect(() => {
clearHover();
}, [sig]);
if (!sig) return null;
const crumbs = JSON.parse(sig) as [string, string][];
const onEnter = (id: string) => {
clearHover();
let dom: HTMLElement | null = null;
try {
dom = query.node(id).get()?.dom ?? null;
} catch {
dom = null;
}
if (dom) {
dom.setAttribute('data-layer-hovered', '');
hoveredRef.current = dom;
}
};
return (
<nav aria-label="Selection path" className="canvas-breadcrumb">
{crumbs.map(([id, label], i) => {
const current = i === crumbs.length - 1;
return (
<React.Fragment key={id}>
{i > 0 && <span className="canvas-breadcrumb-sep" aria-hidden="true">›</span>}
<button
type="button"
className="canvas-breadcrumb-crumb"
data-node-id={id}
aria-current={current ? 'true' : undefined}
onClick={() => actions.selectNode(id)}
onMouseEnter={() => onEnter(id)}
onMouseLeave={clearHover}
onFocus={() => onEnter(id)}
onBlur={clearHover}
>
{label}
</button>
</React.Fragment>
);
})}
</nav>
);
};
+2
View File
@@ -14,6 +14,7 @@ import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { LoadGate } from './LoadGate';
import { DirtyTracker } from './DirtyTracker';
import { Breadcrumb } from './Breadcrumb';
import { DeviceProvider } from '../state/DeviceContext';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -90,6 +91,7 @@ const EditorShellBody: React.FC = () => {
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
{!isMobile && <Breadcrumb />}
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
@@ -0,0 +1,92 @@
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { SELECTION_STATE } from './selection-fixture';
import {
selectParent, escapeSelection, selectNextSibling, selectPrevSibling, selectFirstChild, selectionCommands,
} from './selection-commands';
import { __resetCommandsForTests, getCommand } from './commands';
let h: EditorHarness;
beforeEach(() => { h = renderEditorHarness({ initialState: SELECTION_STATE }); });
afterEach(() => { h.unmount(); });
const sel = () => h.query.getEvent('selected').all();
const run = (fn: () => unknown) => h.act(() => { fn(); });
describe('selectParent', () => {
test('climbs parent by parent, hopping the Section inner container, never ROOT', () => {
const path: string[] = [];
let cur = 'b1';
for (let i = 0; i < 6; i++) {
let next: string | null = null;
run(() => { next = selectParent(h.query, h.actions, cur); });
if (!next) break;
path.push(next);
cur = next;
}
expect(path).toEqual(['col-0', 'cols', 'sec']);
expect(sel()).toEqual(['sec']);
});
test('no-op on a top-level node and on ROOT', () => {
run(() => h.actions.selectNode('sec'));
run(() => { expect(selectParent(h.query, h.actions, 'sec')).toBeNull(); });
expect(sel()).toEqual(['sec']);
run(() => { expect(selectParent(h.query, h.actions, 'ROOT')).toBeNull(); });
expect(sel()).toEqual(['sec']);
});
test('escapeSelection clears at the top level', () => {
run(() => h.actions.selectNode('sec'));
run(() => escapeSelection(h.query, h.actions, 'sec'));
expect(sel()).toEqual([]);
});
});
describe('siblings', () => {
test('next wraps, prev reverses and wraps', () => {
run(() => h.actions.selectNode('b1'));
run(() => selectNextSibling(h.query, h.actions, 'b1'));
expect(sel()).toEqual(['b2']);
run(() => selectNextSibling(h.query, h.actions, 'b2'));
expect(sel()).toEqual(['b1']);
run(() => selectPrevSibling(h.query, h.actions, 'b1'));
expect(sel()).toEqual(['b2']);
});
test('linked column containers are siblings of each other', () => {
run(() => h.actions.selectNode('col-0'));
run(() => selectNextSibling(h.query, h.actions, 'col-0'));
expect(sel()).toEqual(['col-1']);
});
test('a lone child has nowhere to go', () => {
run(() => { expect(selectNextSibling(h.query, h.actions, 'cols')).toBeNull(); });
});
});
test('selectFirstChild goes to the first child, null for a leaf', () => {
run(() => { expect(selectFirstChild(h.query, h.actions, 'col-0')).toBe('b1'); });
expect(sel()).toEqual(['b1']);
run(() => { expect(selectFirstChild(h.query, h.actions, 'b1')).toBeNull(); });
});
describe('command definitions', () => {
test('ids, shortcuts and when()', () => {
const byId = Object.fromEntries(selectionCommands.map((c) => [c.id, c]));
expect(byId['select.parent'].shortcut).toBe('escape');
expect(byId['select.next-sibling'].shortcut).toBe('tab');
expect(byId['select.prev-sibling'].shortcut).toBe('shift+tab');
expect(byId['select.first-child'].shortcut).toBeUndefined();
const ctx = (ids: string[]) => ({ selectedIds: ids }) as any;
for (const c of selectionCommands) {
expect(c.group).toBe('select');
expect(c.when!(ctx(['b1']))).toBe(true);
expect(c.when!(ctx([]))).toBe(false);
expect(c.when!(ctx(['ROOT']))).toBe(false);
expect(c.when!(ctx(['a', 'b']))).toBe(false);
}
__resetCommandsForTests();
expect(getCommand('select.parent')).toBeUndefined();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { registerCommands, type Command } from './commands';
import { getParentId, getSiblingIds, getChildIds } from '../utils/craft-helpers';
/**
* Selection navigation (parent / siblings / first child), shared by the
* breadcrumb, the keyboard layer and (later) the command palette. Pure
* functions over `query` / `actions` so they are testable on a real editor.
*/
function nodeData(query: any, id: string): any {
try {
return query.node(id).get()?.data ?? null;
} catch {
return null;
}
}
/**
* True for a Section's own inner container (linked as `section-inner`). Only
* that key is skipped: Columns' `col-N` containers are real, user-meaningful
* steps in the ancestry and stay selectable.
*/
export function isSectionInnerNode(query: any, id: string): boolean {
const parentId = getParentId(query, id);
if (!parentId) return false;
return nodeData(query, parentId)?.linkedNodes?.['section-inner'] === id;
}
/** The node Esc / "select parent" should land on: the parent, hopping over a
* Section's inner container. Null when there is none (top level / ROOT). */
export function getSelectableParentId(query: any, id: string): string | null {
let parentId = getParentId(query, id);
if (parentId && parentId !== 'ROOT' && isSectionInnerNode(query, parentId)) {
parentId = getParentId(query, parentId);
}
return parentId && parentId !== 'ROOT' ? parentId : null;
}
/** Select the parent. Never selects ROOT. Returns the newly selected id. */
export function selectParent(query: any, actions: any, id: string): string | null {
if (!id || id === 'ROOT') return null;
const parentId = getSelectableParentId(query, id);
if (!parentId) return null;
actions.selectNode(parentId);
return parentId;
}
/** Esc behaviour: climb to the parent; at the top level clear the selection. */
export function escapeSelection(query: any, actions: any, id: string): void {
if (!selectParent(query, actions, id)) actions.clearEvents();
}
function selectSibling(query: any, actions: any, id: string, step: 1 | -1): string | null {
if (!id || id === 'ROOT') return null;
const sibs = getSiblingIds(query, id);
if (sibs.length < 2) return null;
const idx = sibs.indexOf(id);
if (idx === -1) return null;
const next = sibs[(idx + step + sibs.length) % sibs.length];
actions.selectNode(next);
return next;
}
export function selectNextSibling(query: any, actions: any, id: string): string | null {
return selectSibling(query, actions, id, 1);
}
export function selectPrevSibling(query: any, actions: any, id: string): string | null {
return selectSibling(query, actions, id, -1);
}
export function selectFirstChild(query: any, actions: any, id: string): string | null {
if (!id) return null;
const first = getChildIds(query, id)[0];
if (!first) return null;
actions.selectNode(first);
return first;
}
const oneNonRootSelected = (ctx: { selectedIds: string[] }) =>
ctx.selectedIds.length === 1 && ctx.selectedIds[0] !== 'ROOT';
export const selectionCommands: Command[] = [
{
id: 'select.parent',
title: 'Select parent',
group: 'select',
shortcut: 'escape',
when: oneNonRootSelected,
run: ({ query, actions, selectedIds }) => escapeSelection(query, actions, selectedIds[0]),
},
{
id: 'select.next-sibling',
title: 'Select next sibling',
group: 'select',
shortcut: 'tab',
when: oneNonRootSelected,
run: ({ query, actions, selectedIds }) => void selectNextSibling(query, actions, selectedIds[0]),
},
{
id: 'select.prev-sibling',
title: 'Select previous sibling',
group: 'select',
shortcut: 'shift+tab',
when: oneNonRootSelected,
run: ({ query, actions, selectedIds }) => void selectPrevSibling(query, actions, selectedIds[0]),
},
{
id: 'select.first-child',
title: 'Select first child',
group: 'select',
when: oneNonRootSelected,
run: ({ query, actions, selectedIds }) => void selectFirstChild(query, actions, selectedIds[0]),
},
];
export function registerSelectionCommands(): () => void {
return registerCommands(selectionCommands);
}
+24
View File
@@ -0,0 +1,24 @@
/** Test fixture: ROOT > sec (Section, inner `sec-in`) > cols (ColumnLayout) >
* col-0 (b1, b2) / col-1 (empty), plus a second top-level Section `sec2`. */
const base = { custom: {}, hidden: false, nodes: [] as string[], linkedNodes: {} as Record<string, string> };
const container = (id: string, parent: string, nodes: string[] = []) => ({
...base, type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' },
displayName: 'Container', parent, nodes,
});
const button = (parent: string, text: string) => ({
...base, type: { resolvedName: 'ButtonLink' }, isCanvas: false, props: { text, href: '#' },
displayName: 'ButtonLink', parent,
});
export const SELECTION_STATE = JSON.stringify({
ROOT: { ...container('ROOT', 'x', ['sec', 'sec2']), parent: undefined },
sec: { ...base, type: { resolvedName: 'Section' }, isCanvas: false, props: { style: {} }, displayName: 'Section', parent: 'ROOT', linkedNodes: { 'section-inner': 'sec-in' } },
'sec-in': container('sec-in', 'sec', ['cols']),
cols: { ...base, type: { resolvedName: 'ColumnLayout' }, isCanvas: false, props: {}, displayName: 'Columns', parent: 'sec-in', linkedNodes: { 'col-0': 'col-0', 'col-1': 'col-1' } },
'col-0': container('col-0', 'cols', ['b1', 'b2']),
'col-1': container('col-1', 'cols'),
b1: button('col-0', 'One'),
b2: button('col-0', 'Two'),
sec2: { ...base, type: { resolvedName: 'Section' }, isCanvas: false, props: { style: {} }, displayName: 'Section', parent: 'ROOT', linkedNodes: { 'section-inner': 'sec2-in' } },
'sec2-in': container('sec2-in', 'sec2'),
});
+93 -14
View File
@@ -89,21 +89,31 @@ function makeQueryNode(id: string) {
};
}
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: {
getEvent: () => ({ all: () => selectedIds }),
node: (id: string) => makeQueryNode(id),
// Delegates to the REAL useEditor when `useRealEditor` is set (the selection
// navigation suite below runs on the real harness); otherwise the fake above.
let useRealEditor = false;
vi.mock('@craftjs/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@craftjs/core')>();
return {
...actual,
useEditor: (...args: any[]) => {
if (useRealEditor) return (actual.useEditor as any)(...args);
return {
query: {
getEvent: () => ({ all: () => selectedIds }),
node: (id: string) => makeQueryNode(id),
},
actions: {
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(),
clearEvents: vi.fn(),
},
};
},
actions: {
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(),
clearEvents: vi.fn(),
},
}),
}));
};
});
// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
@@ -293,3 +303,72 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
activeElementSpy.mockRestore();
});
});
// ---- Selection navigation (Esc = parent, Tab = siblings) on the REAL editor ----
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { SELECTION_STATE } from '../editor/selection-fixture';
describe('selection navigation (real editor)', () => {
let h: EditorHarness;
const Probe: React.FC = () => { useKeyboardShortcuts(); return null; };
beforeEach(() => {
useRealEditor = true;
h = renderEditorHarness({ initialState: SELECTION_STATE });
h.mountChild(<Probe />);
});
afterEach(() => {
h.unmount();
useRealEditor = false;
(document.activeElement as HTMLElement | null)?.blur?.();
});
const sel = () => h.query.getEvent('selected').all();
const key = (k: string, init: KeyboardEventInit = {}) => {
const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init });
h.act(() => { document.dispatchEvent(ev); });
return ev;
};
test('Esc climbs button -> column -> columns -> section, then clears; never ROOT', () => {
h.act(() => h.actions.selectNode('b1'));
const seen: string[][] = [];
for (let i = 0; i < 4; i++) { key('Escape'); seen.push(sel()); }
expect(seen).toEqual([['col-0'], ['cols'], ['sec'], []]);
key('Escape');
expect(sel()).toEqual([]);
expect(sel()).not.toContain('ROOT');
});
test('Tab cycles siblings and wraps; Shift+Tab reverses; default prevented', () => {
h.act(() => h.actions.selectNode('b1'));
expect(key('Tab').defaultPrevented).toBe(true);
expect(sel()).toEqual(['b2']);
key('Tab');
expect(sel()).toEqual(['b1']);
key('Tab', { shiftKey: true });
expect(sel()).toEqual(['b2']);
key('Tab', { shiftKey: true });
expect(sel()).toEqual(['b1']);
});
test('Tab is not intercepted with an <input> focused', () => {
h.act(() => h.actions.selectNode('b1'));
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const ev = key('Tab');
input.remove();
expect(ev.defaultPrevented).toBe(false);
expect(sel()).toEqual(['b1']);
});
test('Tab with nothing selected, or a lone node, is left to the browser', () => {
expect(key('Tab').defaultPrevented).toBe(false);
h.act(() => h.actions.selectNode('cols'));
expect(key('Tab').defaultPrevented).toBe(false);
expect(sel()).toEqual(['cols']);
});
});
+26 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree';
import { escapeSelection, selectNextSibling, selectPrevSibling } from '../editor/selection-commands';
import { getClipboardTree, setClipboardTree } from './clipboard';
function isInputFocused(): boolean {
@@ -147,11 +148,34 @@ export function useKeyboardShortcuts() {
return;
}
// Escape: deselect all
// Escape: select the parent; at the top level (parent is ROOT) clear.
if (e.key === 'Escape') {
e.preventDefault();
try {
actions.clearEvents();
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
escapeSelection(query, actions, selected[0]);
} else {
actions.clearEvents();
}
} catch (err) {
// Ignore
}
return;
}
// Tab / Shift+Tab: cycle siblings of the selected node (wraps). Only
// intercepted when it will actually move the selection, so focus
// navigation still works with nothing selected or a lone node.
if (e.key === 'Tab' && !ctrl && !e.altKey) {
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
const moved = e.shiftKey
? selectPrevSibling(query, actions, selected[0])
: selectNextSibling(query, actions, selected[0]);
if (moved) e.preventDefault();
}
} catch (err) {
// Ignore
}
+35
View File
@@ -2391,6 +2391,32 @@ body {
color: var(--color-text);
background: transparent;
border: 1px solid transparent;
/* #34 breadcrumb */
.canvas-breadcrumb {
display: flex;
align-items: center;
gap: 2px;
flex: 0 0 28px;
height: 28px;
padding: 0 10px;
overflow-x: auto;
white-space: nowrap;
background: var(--color-bg-surface);
border-bottom: 1px solid var(--color-border);
font-size: 12px;
}
.canvas-breadcrumb-sep {
color: var(--color-text-dim);
user-select: none;
}
.canvas-breadcrumb-crumb {
padding: 2px 6px;
font: inherit;
color: var(--color-text-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
}
@@ -2399,4 +2425,13 @@ body {
.empty-container-add-item:focus-visible {
border-color: var(--color-accent);
outline: none;
.canvas-breadcrumb-crumb:hover {
color: var(--color-text);
background: var(--color-bg-hover);
}
.canvas-breadcrumb-crumb[aria-current='true'] {
color: var(--color-text);
background: var(--color-accent-subtle);
cursor: default;
}