feat(site-builder): Layers shows array-prop items, unplaced nodes, and scrolls

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 07:15:17 -07:00
co-authored by Claude Opus 5
parent f9561c8c54
commit 2438777462
3 changed files with 220 additions and 10 deletions
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { LayersPanel } from './LayersPanel';
import { LayerFocusProvider } from './LayerFocusContext';
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
return JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
},
...extra,
});
}
const featuresNode = {
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
displayName: 'Features Grid',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
describe('LayersPanel', () => {
test('shows virtual rows for a Features Grid array prop', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
// Scoped to `.layer-virtual-row` rather than the whole container:
// `FeaturesGrid` itself renders `feat.title` on the live canvas (which
// is also mounted inside `harness.container`, alongside the panel), so
// asserting on `container.textContent` alone would pass even if
// LayersPanel never rendered a single virtual row -- the "Fast"/"Secure"
// text would already be there from the canvas. Scoping to the virtual
// row elements themselves makes the assertion actually exercise
// LayersPanel's own rendering.
const virtualRows = harness.container.querySelectorAll('.layer-virtual-row');
expect(virtualRows).toHaveLength(2);
const virtualText = Array.from(virtualRows).map((el) => el.textContent).join(' | ');
expect(virtualText).toContain('Fast');
expect(virtualText).toContain('Secure');
// The real node row for the parent is still shown too.
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
expect(nodeText).toContain('Features Grid');
harness.unmount();
});
test('virtual rows are not rendered for a component with no registry entry', () => {
const heading = {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
harness.unmount();
});
test('the Unplaced group does not render for a healthy tree', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).not.toContain('Unplaced');
harness.unmount();
});
test('the Unplaced group appears and lists a node unreachable from ROOT', () => {
// 'stray' is a real node in the deserialized state, but no node's
// `nodes`/`linkedNodes` list references it -- exactly the "dropped
// outside the page" scenario findUnreachableNodeIds exists to catch.
const heading = {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const stray = {
type: { resolvedName: 'HtmlBlock' }, isCanvas: false,
props: { code: '<p>stranded</p>', style: {} }, displayName: 'HTML',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
};
const harness = renderEditorHarness({
initialState: stateWith({ h1: heading, stray }, ['h1']),
});
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).toContain('Unplaced');
expect(harness.container.textContent).toContain('Unplaced (1)');
// The orphan itself is rendered as a selectable/deletable LayerNode row
// (displayName 'HTML') underneath the Unplaced heading, not silently
// dropped from the tree.
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
expect(nodeText).toContain('HTML');
harness.unmount();
});
});
+108 -10
View File
@@ -1,6 +1,9 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
import { useLayerFocus } from './LayerFocusContext';
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
/** /**
* Per-type icon lookup keyed by the component's `craft.displayName` (the * Per-type icon lookup keyed by the component's `craft.displayName` (the
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
const DEFAULT_ICON = 'fa-cube'; const DEFAULT_ICON = 'fa-cube';
const ROOT_ICON = 'fa-desktop'; const ROOT_ICON = 'fa-desktop';
/**
* A display-only row for one item of a composite's array prop (a Features
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
* deleted. Clicking it selects the PARENT and asks the array editor to
* scroll that item's card into view.
*/
const VirtualRowNode: React.FC<{
parentId: string;
prop: string;
index: number;
label: string;
depth: number;
onActivate: () => void;
}> = ({ index, label, depth, onActivate }) => (
<div
{...clickableProps(onActivate)}
className="layer-virtual-row"
title={label}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 11,
color: 'var(--color-text-dim)',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
>
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">&#9642;</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
</div>
);
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
depth: number; depth: number;
} }
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => { const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { requestFocus } = useLayerFocus();
const { node, selectedId, actions, query } = useEditor((state) => { const { node, selectedId, actions, query } = useEditor((state) => {
const n = state.nodes[nodeId]; const n = state.nodes[nodeId];
const selectedIds = state.events.selected; const selectedIds = state.events.selected;
@@ -140,6 +182,10 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const isRoot = nodeId === 'ROOT'; const isRoot = nodeId === 'ROOT';
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON; const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
const hasDisclosure = allChildren.length + virtualRows.length > 0;
return ( return (
<div> <div>
<div <div
@@ -187,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
))} ))}
{/* Indentation/disclosure indicator */} {/* Indentation/disclosure indicator */}
{allChildren.length > 0 ? ( {hasDisclosure ? (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}> <span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
&#9660; &#9660;
</span> </span>
@@ -217,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
</span> </span>
</div> </div>
{/* Virtual rows: array-prop items (composite content Craft doesn't see
as child nodes), rendered before real children. */}
{virtualSpec && virtualRows.map((row) => (
<VirtualRowNode
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
parentId={nodeId}
prop={virtualSpec.prop}
index={row.index}
label={row.label}
depth={depth + 1}
onActivate={() => {
actions.selectNode(nodeId);
requestFocus(nodeId, virtualSpec.prop, row.index);
}}
/>
))}
{/* Render children */} {/* Render children */}
{allChildren.map((childId) => ( {allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} /> <LayerNode key={childId} nodeId={childId} depth={depth + 1} />
@@ -226,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
export const LayersPanel: React.FC = () => { export const LayersPanel: React.FC = () => {
const { nodeIds } = useEditor((state) => { const { nodeIds, unplacedIds } = useEditor((state) => {
const serializable: Record<string, any> = {};
for (const [id, n] of Object.entries(state.nodes)) {
serializable[id] = {
nodes: n.data.nodes || [],
linkedNodes: n.data.linkedNodes || {},
};
}
return { return {
nodeIds: Object.keys(state.nodes), nodeIds: Object.keys(state.nodes),
unplacedIds: findUnreachableNodeIds(serializable),
}; };
}); });
@@ -243,13 +314,7 @@ export const LayersPanel: React.FC = () => {
} }
return ( return (
<div <div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
style={{
display: 'flex',
flexDirection: 'column',
margin: '-12px',
}}
>
<div <div
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
@@ -259,11 +324,44 @@ export const LayersPanel: React.FC = () => {
letterSpacing: '0.5px', letterSpacing: '0.5px',
color: 'var(--color-text-muted)', color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}} }}
> >
Component Tree Component Tree
</div> </div>
<LayerNode nodeId="ROOT" depth={0} />
{/* Own scroll container: a deep or long tree must stay fully reachable
regardless of how the parent tab panel is sized. */}
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
<LayerNode nodeId="ROOT" depth={0} />
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
these on load, so this should stay empty -- it exists so an
element stranded mid-session is still selectable and deletable
rather than invisible. */}
{unplacedIds.length > 0 && (
<>
<div
style={{
padding: '8px 12px',
marginTop: 8,
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-warning, #f59e0b)',
borderTop: '1px solid var(--color-border)',
}}
title="These elements are not attached to the page. Select one to delete it."
>
Unplaced ({unplacedIds.length})
</div>
{unplacedIds.map((id) => (
<LayerNode key={id} nodeId={id} depth={1} />
))}
</>
)}
</div>
</div> </div>
); );
}; };
+10
View File
@@ -1919,3 +1919,13 @@ body {
cursor: pointer; cursor: pointer;
padding: 0 4px; padding: 0 4px;
} }
/* --------------------------------------------------------------------------
Layers: display-only rows derived from a composite's array props
(Features Grid features, Tabs tabs, ...). Dimmer than real node rows and
with no disclosure column, so they don't read as draggable Craft nodes.
-------------------------------------------------------------------------- */
.layer-virtual-row:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}