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 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">▪</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 }}>
|
||||||
▼
|
▼
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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} />
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user