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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
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
|
||||
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
|
||||
const DEFAULT_ICON = 'fa-cube';
|
||||
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">▪</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 {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const { requestFocus } = useLayerFocus();
|
||||
const { node, selectedId, actions, query } = useEditor((state) => {
|
||||
const n = state.nodes[nodeId];
|
||||
const selectedIds = state.events.selected;
|
||||
@@ -140,6 +182,10 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const isRoot = nodeId === 'ROOT';
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
@@ -187,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
))}
|
||||
|
||||
{/* Indentation/disclosure indicator */}
|
||||
{allChildren.length > 0 ? (
|
||||
{hasDisclosure ? (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
||||
▼
|
||||
</span>
|
||||
@@ -217,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
</span>
|
||||
</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 */}
|
||||
{allChildren.map((childId) => (
|
||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
@@ -226,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
};
|
||||
|
||||
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 {
|
||||
nodeIds: Object.keys(state.nodes),
|
||||
unplacedIds: findUnreachableNodeIds(serializable),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -243,13 +314,7 @@ export const LayersPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '-12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
@@ -259,11 +324,44 @@ export const LayersPanel: React.FC = () => {
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-muted)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Component Tree
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1919,3 +1919,13 @@ body {
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user