diff --git a/craft/src/panels/left/LayersPanel.test.tsx b/craft/src/panels/left/LayersPanel.test.tsx new file mode 100644 index 0000000..f0eec8d --- /dev/null +++ b/craft/src/panels/left/LayersPanel.test.tsx @@ -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, 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(); + + // 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(); + 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(); + 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: '

stranded

', style: {} }, displayName: 'HTML', + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost', + }; + const harness = renderEditorHarness({ + initialState: stateWith({ h1: heading, stray }, ['h1']), + }); + harness.mountChild(); + + 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(); + }); +}); diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx index b057af6..020044b 100644 --- a/craft/src/panels/left/LayersPanel.tsx +++ b/craft/src/panels/left/LayersPanel.tsx @@ -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 = { 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 }) => ( +
+ + {label} + {index + 1} +
+); + interface LayerNodeProps { nodeId: string; depth: number; } const LayerNode: React.FC = ({ 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 = ({ 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 (
= ({ nodeId, depth }) => { ))} {/* Indentation/disclosure indicator */} - {allChildren.length > 0 ? ( + {hasDisclosure ? ( @@ -217,6 +263,23 @@ const LayerNode: React.FC = ({ nodeId, depth }) => {
+ {/* Virtual rows: array-prop items (composite content Craft doesn't see + as child nodes), rendered before real children. */} + {virtualSpec && virtualRows.map((row) => ( + { + actions.selectNode(nodeId); + requestFocus(nodeId, virtualSpec.prop, row.index); + }} + /> + ))} + {/* Render children */} {allChildren.map((childId) => ( @@ -226,9 +289,17 @@ const LayerNode: React.FC = ({ nodeId, depth }) => { }; export const LayersPanel: React.FC = () => { - const { nodeIds } = useEditor((state) => { + const { nodeIds, unplacedIds } = useEditor((state) => { + const serializable: Record = {}; + 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 ( -
+
{ letterSpacing: '0.5px', color: 'var(--color-text-muted)', borderBottom: '1px solid var(--color-border)', + flexShrink: 0, }} > Component Tree
- + + {/* Own scroll container: a deep or long tree must stay fully reachable + regardless of how the parent tab panel is sized. */} +
+ + + {/* 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 && ( + <> +
+ Unplaced ({unplacedIds.length}) +
+ {unplacedIds.map((id) => ( + + ))} + + )} +
); }; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 1d5072d..48a9061 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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); +}