- GuidedStyles classifies Opening Hours / Call Bar / Reviews / Booking to BusinessStylePanel (#60) and shows a Regenerate button in the type header, hidden when edit.regenerate-section's when() is false (#58) - LayersPanel TYPE_ICONS for the four business blocks (FA 4.7) (#60) - Context menu entries 'Save as section…' and 'Regenerate this section' invoke site.save-as-section / edit.regenerate-section (#52, #58) - commands: useCommandState(id) hook (registered/enabled/run) - Layers: keep dropEffect 'move' on refused targets so drop fires and the refusal notice shows; renaming to the default name stores no custom.label (#47) Refs #60 Refs #58 Refs #52 Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
631 lines
24 KiB
TypeScript
631 lines
24 KiB
TypeScript
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
import { useEditor } from '@craftjs/core';
|
|
import { useNotices } from '../../state/NoticeContext';
|
|
import { getAncestorIds } from '../../utils/craft-helpers';
|
|
import { canMove, computeDrop, moveLayer, type DropWhere } from './layers-dnd';
|
|
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
|
|
* same human label `LayerNode` already resolves and shows as the row text).
|
|
* Mirrors the icon choices in `BlocksPanel.tsx` where a block exists for the
|
|
* type; the remaining entries (Navbar, Container, Section, Columns,
|
|
* Background Section, Map, Video, Image, form fields, and the richer
|
|
* section blocks not draggable from the Basic/Sections categories) get a
|
|
* sensible FA4 icon of their own. Unknown/future types fall back to
|
|
* `DEFAULT_ICON`.
|
|
*/
|
|
const TYPE_ICONS: Record<string, string> = {
|
|
// Layout
|
|
Container: 'fa-square-o',
|
|
Section: 'fa-window-maximize',
|
|
Columns: 'fa-th-large',
|
|
'Background Section': 'fa-picture-o',
|
|
// Basic
|
|
Heading: 'fa-header',
|
|
Text: 'fa-paragraph',
|
|
Button: 'fa-square',
|
|
Logo: 'fa-bookmark',
|
|
Menu: 'fa-bars',
|
|
Navbar: 'fa-bars',
|
|
Footer: 'fa-window-minimize',
|
|
Divider: 'fa-minus',
|
|
Spacer: 'fa-arrows-v',
|
|
Icon: 'fa-star',
|
|
'Star Rating': 'fa-star-half-o',
|
|
'Social Links': 'fa-share-alt',
|
|
'Search Bar': 'fa-search',
|
|
HTML: 'fa-code',
|
|
// Media
|
|
Image: 'fa-image',
|
|
Video: 'fa-play-circle',
|
|
Map: 'fa-map-marker',
|
|
// Sections
|
|
Hero: 'fa-star-o',
|
|
'Features Grid': 'fa-th-large',
|
|
'CTA Section': 'fa-bullhorn',
|
|
'Call to Action': 'fa-bullhorn',
|
|
Countdown: 'fa-clock-o',
|
|
Testimonials: 'fa-quote-left',
|
|
'Content Slider': 'fa-sliders',
|
|
'Number Counter': 'fa-sort-numeric-asc',
|
|
Accordion: 'fa-list',
|
|
Tabs: 'fa-folder-o',
|
|
'Pricing Table': 'fa-usd',
|
|
Gallery: 'fa-th',
|
|
// Forms
|
|
Form: 'fa-wpforms',
|
|
Input: 'fa-i-cursor',
|
|
Textarea: 'fa-align-left',
|
|
'Submit Button': 'fa-paper-plane',
|
|
'Contact Form': 'fa-envelope',
|
|
'Subscribe Form': 'fa-paper-plane',
|
|
// Local business (#60)
|
|
'Opening Hours': 'fa-clock-o',
|
|
'Call Bar': 'fa-phone',
|
|
Reviews: 'fa-star',
|
|
Booking: 'fa-calendar',
|
|
};
|
|
|
|
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>
|
|
);
|
|
|
|
/**
|
|
* Panel-local UI state (collapsed ids, drag/drop line, inline rename). Lives
|
|
* outside the Craft store AND outside React state on purpose: rows subscribe
|
|
* to a primitive slice of it, so a dragover only re-renders the two rows whose
|
|
* slice changed instead of the whole tree (#40's isolation property).
|
|
*/
|
|
interface LayersUiState {
|
|
collapsed: ReadonlySet<string>;
|
|
dropTargetId: string | null;
|
|
dropWhere: DropWhere | null;
|
|
draggingId: string | null;
|
|
renamingId: string | null;
|
|
}
|
|
|
|
export interface LayersUi {
|
|
get(): LayersUiState;
|
|
set(patch: Partial<LayersUiState>): void;
|
|
subscribe(cb: () => void): () => void;
|
|
}
|
|
|
|
export function createLayersUi(): LayersUi {
|
|
let state: LayersUiState = {
|
|
collapsed: new Set(), dropTargetId: null, dropWhere: null, draggingId: null, renamingId: null,
|
|
};
|
|
const subs = new Set<() => void>();
|
|
return {
|
|
get: () => state,
|
|
set(patch) {
|
|
const next = { ...state, ...patch };
|
|
if ((Object.keys(patch) as (keyof LayersUiState)[]).every((k) => state[k] === next[k])) return;
|
|
state = next;
|
|
subs.forEach((cb) => cb());
|
|
},
|
|
subscribe(cb) { subs.add(cb); return () => { subs.delete(cb); }; },
|
|
};
|
|
}
|
|
|
|
const LayersUiContext = createContext<LayersUi>(createLayersUi());
|
|
|
|
function useLayersUiValue<T>(ui: LayersUi, select: (s: LayersUiState) => T): T {
|
|
return useSyncExternalStore(ui.subscribe, () => select(ui.get()));
|
|
}
|
|
|
|
/** Inline rename field. Enter/blur commit, Escape cancels. */
|
|
const RenameInput: React.FC<{
|
|
initial: string;
|
|
onCommit: (v: string) => void;
|
|
onCancel: () => void;
|
|
}> = ({ initial, onCommit, onCancel }) => {
|
|
const done = useRef(false);
|
|
const finish = (fn: () => void) => { if (!done.current) { done.current = true; fn(); } };
|
|
return (
|
|
<input
|
|
className="layer-rename-input"
|
|
aria-label="Layer name"
|
|
defaultValue={initial}
|
|
autoFocus
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onDoubleClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === 'Enter') { e.preventDefault(); finish(() => onCommit(e.currentTarget.value)); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); finish(onCancel); }
|
|
}}
|
|
onBlur={(e) => finish(() => onCommit(e.currentTarget.value))}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface LayerNodeProps {
|
|
nodeId: string;
|
|
depth: number;
|
|
}
|
|
|
|
/** Test-only seam: set `.current` to observe every LayerNode render. */
|
|
export const layerNodeRenderProbe: { current: ((nodeId: string) => void) | null } = { current: null };
|
|
|
|
/** Comma-joined child ids (real children first, then linked nodes). */
|
|
function childIdsOf(data: { nodes?: string[]; linkedNodes?: Record<string, string> }): string {
|
|
return [...(data.nodes || []), ...(Object.values(data.linkedNodes || {}) as string[])].join(',');
|
|
}
|
|
|
|
const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|
layerNodeRenderProbe.current?.(nodeId);
|
|
const { requestFocus } = useLayerFocus();
|
|
// The collector returns PRIMITIVES only, so Craft's shallow-equal check
|
|
// skips the re-render for hover / unrelated prop edits. Returning the whole
|
|
// node object would re-render this row on every prop change anywhere in it.
|
|
const ui = useContext(LayersUiContext);
|
|
const { notify } = useNotices();
|
|
const { exists, displayName, customLabel, isHidden, isLinked, childIdsSig, virtualSig, isSelected, actions, query } = useEditor((state) => {
|
|
const n = state.nodes[nodeId];
|
|
if (!n) return { exists: false, displayName: '', customLabel: '', isHidden: false, isLinked: false, childIdsSig: '', virtualSig: '', isSelected: false };
|
|
const name = (n.data.props?.aiName as string) || n.data.displayName || (n.data.type as any)?.resolvedName || 'Node';
|
|
// Virtual rows come from array props; collect them as one string so only
|
|
// a change in the row labels (not any other prop) re-renders the row.
|
|
const virtualSig = VIRTUAL_CHILD_PROPS[name] ? JSON.stringify(deriveVirtualRows(name, n.data.props || {})) : '';
|
|
const selectedIds = state.events.selected;
|
|
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
|
|
const parent = n.data.parent ? state.nodes[n.data.parent] : null;
|
|
const isLinked = !!parent && (Object.values(parent.data.linkedNodes || {}) as string[]).includes(nodeId);
|
|
return {
|
|
exists: true,
|
|
displayName: name,
|
|
customLabel: (n.data.custom?.label as string) || '',
|
|
isHidden: !!n.data.hidden,
|
|
isLinked,
|
|
childIdsSig: childIdsOf(n.data),
|
|
virtualSig,
|
|
isSelected: selId === nodeId,
|
|
};
|
|
});
|
|
const allChildren = useMemo(() => (childIdsSig ? childIdsSig.split(',') : []), [childIdsSig]);
|
|
const virtualRows = useMemo<{ index: number; label: string }[]>(() => (virtualSig ? JSON.parse(virtualSig) : []), [virtualSig]);
|
|
|
|
const isRoot = nodeId === 'ROOT';
|
|
// ROOT and linked slots (Section inner, Columns col-N) are structural.
|
|
const structural = isRoot || isLinked;
|
|
const isCollapsed = useLayersUiValue(ui, (u) => u.collapsed.has(nodeId));
|
|
const isRenaming = useLayersUiValue(ui, (u) => u.renamingId === nodeId);
|
|
const dropWhere = useLayersUiValue(ui, (u) => (u.dropTargetId === nodeId ? u.dropWhere : null));
|
|
const isDragging = useLayersUiValue(ui, (u) => u.draggingId === nodeId);
|
|
|
|
const handleActivate = useCallback(() => {
|
|
actions.selectNode(nodeId);
|
|
}, [actions, nodeId]);
|
|
|
|
// Layers-panel <-> canvas hover sync (Item 12): tags the target node's own
|
|
// DOM element (found via `query`) with `data-layer-hovered` directly.
|
|
// `RenderNode.tsx` mirrors Craft's *own* `hovered` node event onto
|
|
// `data-craft-hovered` for the (separate, mouse-driven) canvas hover
|
|
// outline -- editor.css's rule matches both attributes. This panel can't
|
|
// reuse that path: `setNodeEvent` is a private action that `useEditor()`
|
|
// strips from its `actions` return at runtime (see
|
|
// @craftjs/core/dist/esm's `xe()` action stripper, not just its TypeScript
|
|
// type), so calling it here throws. Writing our own attribute directly is
|
|
// a supported, version-stable way to get the same highlight.
|
|
const hoveredDomRef = useRef<HTMLElement | null>(null);
|
|
|
|
const handleMouseEnter = useCallback(
|
|
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
|
|
const dom = query.node(nodeId).get()?.dom;
|
|
if (dom) {
|
|
dom.setAttribute('data-layer-hovered', '');
|
|
hoveredDomRef.current = dom;
|
|
}
|
|
if (!isSelected) {
|
|
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
|
}
|
|
},
|
|
[query, nodeId, isSelected],
|
|
);
|
|
|
|
const handleMouseLeave = useCallback(
|
|
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
|
|
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
|
|
hoveredDomRef.current = null;
|
|
if (!isSelected) {
|
|
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
|
}
|
|
},
|
|
[isSelected],
|
|
);
|
|
|
|
// Safety net: if this row unmounts (e.g. the user switches away from the
|
|
// Layers tab, or the tree re-shuffles) while still hovered, clear the
|
|
// attribute so it doesn't get stuck highlighted on the canvas forever.
|
|
useEffect(() => {
|
|
return () => {
|
|
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
|
|
hoveredDomRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const toggleCollapsed = useCallback((e: React.SyntheticEvent) => {
|
|
e.stopPropagation();
|
|
const next = new Set(ui.get().collapsed);
|
|
if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId);
|
|
ui.set({ collapsed: next });
|
|
}, [ui, nodeId]);
|
|
|
|
const startRename = useCallback(() => {
|
|
if (!structural) ui.set({ renamingId: nodeId });
|
|
}, [ui, nodeId, structural]);
|
|
|
|
const commitRename = useCallback((value: string) => {
|
|
ui.set({ renamingId: null });
|
|
const typed = value.trim();
|
|
// Typing the default name back means "no custom label": never store a
|
|
// redundant custom.label equal to the type name.
|
|
const v = typed === displayName ? '' : typed;
|
|
if (v === customLabel) return;
|
|
actions.setCustom(nodeId, (c: any) => {
|
|
if (v) c.label = v; else delete c.label;
|
|
});
|
|
}, [ui, actions, nodeId, customLabel, displayName]);
|
|
|
|
const toggleHidden = useCallback((e: React.SyntheticEvent) => {
|
|
e.stopPropagation();
|
|
actions.setHidden(nodeId, !isHidden);
|
|
}, [actions, nodeId, isHidden]);
|
|
|
|
// Where would a drop at this pointer position land? Resolved from the live
|
|
// tree at event time, so no extra collector fields are needed.
|
|
const resolveDrop = useCallback((e: React.DragEvent<HTMLElement>) => {
|
|
const node = query.node(nodeId).get();
|
|
if (!node) return null;
|
|
const parentId: string | null = node.data.parent || null;
|
|
const siblings: string[] = parentId ? query.node(parentId).get()?.data?.nodes || [] : [];
|
|
return computeDrop(
|
|
{ clientY: e.clientY, rect: e.currentTarget.getBoundingClientRect() },
|
|
{
|
|
id: nodeId,
|
|
parentId,
|
|
index: siblings.indexOf(nodeId),
|
|
isCanvas: !!node.data.isCanvas,
|
|
childCount: (node.data.nodes || []).length,
|
|
},
|
|
);
|
|
}, [query, nodeId]);
|
|
|
|
const handleDragStart = (e: React.DragEvent<HTMLElement>) => {
|
|
if (structural || isRenaming) { e.preventDefault(); return; }
|
|
e.dataTransfer?.setData('text/plain', nodeId);
|
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
|
ui.set({ draggingId: nodeId });
|
|
};
|
|
const clearDrag = () => ui.set({ draggingId: null, dropTargetId: null, dropWhere: null });
|
|
const handleDragOver = (e: React.DragEvent<HTMLElement>) => {
|
|
const dragId = ui.get().draggingId;
|
|
if (!dragId) return;
|
|
e.preventDefault();
|
|
const drop = resolveDrop(e);
|
|
const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId);
|
|
// Always 'move': with dropEffect 'none' the browser never fires `drop`, so
|
|
// a refused target could not explain itself. Disallowed targets get no drop
|
|
// line here and are refused (with a notice) in handleDrop via moveLayer.
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null });
|
|
};
|
|
const handleDrop = (e: React.DragEvent<HTMLElement>) => {
|
|
const dragId = ui.get().draggingId;
|
|
clearDrag();
|
|
if (!dragId) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const drop = resolveDrop(e);
|
|
if (!drop || dragId === nodeId) return;
|
|
const res = moveLayer(query, actions, dragId, drop.parentId, drop.index);
|
|
if (!res.ok) notify({ kind: 'error', message: res.error || "That layer can't be moved there.", timeoutMs: 4000 });
|
|
};
|
|
|
|
if (!exists) return null;
|
|
|
|
const label = customLabel || displayName;
|
|
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
|
|
|
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
|
|
const hasDisclosure = allChildren.length + virtualRows.length > 0;
|
|
const rowClickable = clickableProps(handleActivate);
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
{...rowClickable}
|
|
onKeyDown={(e) => {
|
|
if (e.target === e.currentTarget && e.key === 'F2') { e.preventDefault(); startRename(); return; }
|
|
rowClickable.onKeyDown(e);
|
|
}}
|
|
onDoubleClick={startRename}
|
|
draggable={!structural && !isRenaming}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
onDragEnd={clearDrag}
|
|
data-layer-id={nodeId}
|
|
data-drop={dropWhere || undefined}
|
|
aria-selected={isSelected}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
className={`layer-node-row${isHidden ? ' layer-hidden' : ''}${isDragging ? ' layer-dragging' : ''}`}
|
|
style={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
padding: '5px 8px',
|
|
paddingLeft: `${8 + depth * 16}px`,
|
|
fontSize: 12,
|
|
fontWeight: isSelected ? 600 : 400,
|
|
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
|
|
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
|
|
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
|
|
cursor: 'pointer',
|
|
transition: 'all var(--transition-fast)',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
userSelect: 'none',
|
|
}}
|
|
onFocus={handleMouseEnter}
|
|
onBlur={handleMouseLeave}
|
|
>
|
|
{/* Indent guides: one vertical line per ancestor depth level,
|
|
aligned under each ancestor's disclosure/icon column so nested
|
|
rows read as a tree instead of a flat, ever-further-indented list. */}
|
|
{Array.from({ length: depth }).map((_, level) => (
|
|
<span
|
|
key={level}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${8 + level * 16 + 4}px`,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 1,
|
|
background: 'var(--color-border)',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Disclosure chevron: real expand/collapse toggle */}
|
|
{hasDisclosure ? (
|
|
<span
|
|
role="button"
|
|
className="layer-chevron"
|
|
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
|
aria-expanded={!isCollapsed}
|
|
onClick={toggleCollapsed}
|
|
style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0, cursor: 'pointer' }}
|
|
>
|
|
{isCollapsed ? '\u25B6' : '\u25BC'}
|
|
</span>
|
|
) : (
|
|
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
|
|
▼
|
|
</span>
|
|
)}
|
|
|
|
{/* Component type icon */}
|
|
<i
|
|
className={`fa ${icon}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
marginRight: 6,
|
|
fontSize: 11,
|
|
width: 12,
|
|
textAlign: 'center',
|
|
flexShrink: 0,
|
|
color: isSelected ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
|
}}
|
|
/>
|
|
|
|
{/* Name (inline input while renaming) */}
|
|
{isRenaming ? (
|
|
<RenameInput initial={customLabel || displayName} onCommit={commitRename} onCancel={() => ui.set({ renamingId: null })} />
|
|
) : (
|
|
<span className="layer-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{isRoot ? 'Canvas (Root)' : label}
|
|
</span>
|
|
)}
|
|
|
|
{/* Hide / show on canvas (undoable; hidden blocks are skipped on export) */}
|
|
{!structural && (
|
|
<button
|
|
type="button"
|
|
className="layer-eye-btn"
|
|
aria-label={isHidden ? `Show ${label}` : `Hide ${label}`}
|
|
aria-pressed={isHidden}
|
|
title={isHidden ? 'Hidden on all devices - click to show' : 'Hide'}
|
|
onClick={toggleHidden}
|
|
onDoubleClick={(e) => e.stopPropagation()}
|
|
>
|
|
<i className={`fa ${isHidden ? 'fa-eye-slash' : 'fa-eye'}`} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Virtual rows: array-prop items (composite content Craft doesn't see
|
|
as child nodes), rendered before real children. */}
|
|
{!isCollapsed && 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 */}
|
|
{!isCollapsed && allChildren.map((childId) => (
|
|
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** Dragover feedback check: same rules `moveLayer` enforces on drop. */
|
|
function moveLayerAllowed(query: any, nodeId: string, parentId: string): boolean {
|
|
return canMove(query, nodeId, parentId);
|
|
}
|
|
|
|
const LayerNode = React.memo(LayerNodeImpl);
|
|
|
|
export const LayersPanel: React.FC = () => {
|
|
// The collector returns two strings so Craft's shallow-equal check skips
|
|
// the re-render for hover / selection / unrelated prop edits. Only a
|
|
// structural change (ids, children, linked nodes, hidden) alters `sig`.
|
|
const cache = useRef<{ sig: string; unplacedSig: string } | null>(null);
|
|
const { sig, unplacedSig } = useEditor((state) => {
|
|
const ids = Object.keys(state.nodes);
|
|
const sig = ids.map((id) => {
|
|
const d = state.nodes[id].data;
|
|
return `${id}:${(d.nodes || []).join(',')}|${(Object.values(d.linkedNodes || {}) as string[]).join(',')}|${d.hidden ? 1 : 0}`;
|
|
}).join(';');
|
|
if (cache.current?.sig === sig) return cache.current;
|
|
const serializable: Record<string, any> = {};
|
|
for (const id of ids) {
|
|
const d = state.nodes[id].data;
|
|
serializable[id] = { nodes: d.nodes || [], linkedNodes: d.linkedNodes || {} };
|
|
}
|
|
const next = { sig, unplacedSig: findUnreachableNodeIds(serializable).join(',') };
|
|
cache.current = next;
|
|
return next;
|
|
});
|
|
// Panel-local UI store (collapsed ids, drop line, rename) - see LayersUi.
|
|
const ui = useMemo(() => createLayersUi(), []);
|
|
// Selecting a node (canvas click, breadcrumb, Layers) reveals it: drop its
|
|
// ancestors from the collapsed set. Primitive collector, so only this thin
|
|
// shell re-renders on selection; the memoised rows are untouched.
|
|
const { selectedId, query } = useEditor((state) => {
|
|
const sel = state.events.selected;
|
|
return { selectedId: (sel ? Array.from(sel)[0] : '') as string };
|
|
});
|
|
useEffect(() => {
|
|
if (!selectedId) return;
|
|
const collapsed = ui.get().collapsed;
|
|
if (collapsed.size === 0) return;
|
|
const hit = [...getAncestorIds(query, selectedId), 'ROOT'].filter((id) => collapsed.has(id));
|
|
if (hit.length === 0) return;
|
|
const next = new Set(collapsed);
|
|
hit.forEach((id) => next.delete(id));
|
|
ui.set({ collapsed: next });
|
|
}, [selectedId, query, ui]);
|
|
const nodeIds = useMemo(() => sig.split(';').map((e) => e.slice(0, e.indexOf(':'))), [sig]);
|
|
const unplacedIds = useMemo(() => (unplacedSig ? unplacedSig.split(',') : []), [unplacedSig]);
|
|
|
|
const hasRoot = nodeIds.includes('ROOT');
|
|
|
|
if (!hasRoot) {
|
|
return (
|
|
<div className="panel-placeholder">
|
|
No content on canvas
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<LayersUiContext.Provider value={ui}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
|
|
<div
|
|
style={{
|
|
padding: '8px 12px',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase',
|
|
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>
|
|
</LayersUiContext.Provider>
|
|
);
|
|
};
|