import React, { useCallback, useEffect, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import { clickableProps } from '../../utils/a11y'; /** * 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 = { // 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', }; const DEFAULT_ICON = 'fa-cube'; const ROOT_ICON = 'fa-desktop'; interface LayerNodeProps { nodeId: string; depth: number; } const LayerNode: React.FC = ({ nodeId, depth }) => { const { node, selectedId, actions, query } = useEditor((state) => { const n = state.nodes[nodeId]; const selectedIds = state.events.selected; const selId = selectedIds ? Array.from(selectedIds)[0] : null; return { node: n, selectedId: selId, }; }); const isSelected = selectedId === 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(null); const handleMouseEnter = useCallback( (e: React.MouseEvent | React.FocusEvent) => { 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 | React.FocusEvent) => { 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; }; }, []); if (!node) return null; const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node'; const childNodeIds: string[] = node.data.nodes || []; const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[]; const allChildren = [...childNodeIds, ...linkedNodeIds]; const isRoot = nodeId === 'ROOT'; const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON; return (
{/* 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) => (