diff --git a/craft/src/editor/selection-commands.test.tsx b/craft/src/editor/selection-commands.test.tsx
new file mode 100644
index 0000000..3ffdf2f
--- /dev/null
+++ b/craft/src/editor/selection-commands.test.tsx
@@ -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();
+ });
+});
diff --git a/craft/src/editor/selection-commands.ts b/craft/src/editor/selection-commands.ts
new file mode 100644
index 0000000..c78438a
--- /dev/null
+++ b/craft/src/editor/selection-commands.ts
@@ -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);
+}
diff --git a/craft/src/editor/selection-fixture.ts b/craft/src/editor/selection-fixture.ts
new file mode 100644
index 0000000..9961543
--- /dev/null
+++ b/craft/src/editor/selection-fixture.ts
@@ -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
};
+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'),
+});
diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx
index 5e43105..bf94eed 100644
--- a/craft/src/hooks/useKeyboardShortcuts.test.tsx
+++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx
@@ -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();
+ 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();
+ });
+ 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 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']);
+ });
+});
diff --git a/craft/src/hooks/useKeyboardShortcuts.ts b/craft/src/hooks/useKeyboardShortcuts.ts
index 84988ff..35688a4 100644
--- a/craft/src/hooks/useKeyboardShortcuts.ts
+++ b/craft/src/hooks/useKeyboardShortcuts.ts
@@ -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
}
diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css
index 70ce5ed..cba2233 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -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;
}