2026-07-12 20:55:52 -07:00
|
|
|
import React, { useCallback, useEffect, useRef } from 'react';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { useEditor } from '@craftjs/core';
|
2026-07-12 14:50:50 -07:00
|
|
|
import { clickableProps } from '../../utils/a11y';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ICON = 'fa-cube';
|
|
|
|
|
const ROOT_ICON = 'fa-desktop';
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
interface LayerNodeProps {
|
|
|
|
|
nodeId: string;
|
|
|
|
|
depth: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
2026-07-12 20:55:52 -07:00
|
|
|
const { node, selectedId, actions, query } = useEditor((state) => {
|
2026-04-05 18:31:16 -07:00
|
|
|
const n = state.nodes[nodeId];
|
|
|
|
|
const selectedIds = state.events.selected;
|
|
|
|
|
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
|
|
|
|
|
return {
|
|
|
|
|
node: n,
|
|
|
|
|
selectedId: selId,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
const isSelected = selectedId === nodeId;
|
|
|
|
|
|
2026-07-12 14:50:50 -07:00
|
|
|
const handleActivate = useCallback(() => {
|
2026-04-05 18:31:16 -07:00
|
|
|
actions.selectNode(nodeId);
|
|
|
|
|
}, [actions, nodeId]);
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
// 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;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
if (!node) return null;
|
|
|
|
|
|
2026-05-23 14:25:43 -07:00
|
|
|
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
|
2026-04-05 18:31:16 -07:00
|
|
|
const childNodeIds: string[] = node.data.nodes || [];
|
|
|
|
|
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
|
|
|
|
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
|
|
|
|
const isRoot = nodeId === 'ROOT';
|
2026-07-12 20:55:52 -07:00
|
|
|
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div
|
2026-07-12 14:50:50 -07:00
|
|
|
{...clickableProps(handleActivate)}
|
2026-07-12 20:55:52 -07:00
|
|
|
onMouseEnter={handleMouseEnter}
|
|
|
|
|
onMouseLeave={handleMouseLeave}
|
2026-07-13 08:07:51 -07:00
|
|
|
className="layer-node-row"
|
2026-04-05 18:31:16 -07:00
|
|
|
style={{
|
2026-07-12 20:55:52 -07:00
|
|
|
position: 'relative',
|
2026-04-05 18:31:16 -07:00
|
|
|
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',
|
|
|
|
|
}}
|
2026-07-12 20:55:52 -07:00
|
|
|
onFocus={handleMouseEnter}
|
|
|
|
|
onBlur={handleMouseLeave}
|
2026-04-05 18:31:16 -07:00
|
|
|
>
|
2026-07-12 20:55:52 -07:00
|
|
|
{/* 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)',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Indentation/disclosure indicator */}
|
|
|
|
|
{allChildren.length > 0 ? (
|
|
|
|
|
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
2026-04-05 18:31:16 -07:00
|
|
|
▼
|
|
|
|
|
</span>
|
2026-07-12 20:55:52 -07:00
|
|
|
) : (
|
|
|
|
|
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
|
2026-04-05 18:31:16 -07:00
|
|
|
▼
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
{/* 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 */}
|
2026-04-05 18:31:16 -07:00
|
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
|
|
|
{isRoot ? 'Canvas (Root)' : displayName}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Render children */}
|
|
|
|
|
{allChildren.map((childId) => (
|
|
|
|
|
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const LayersPanel: React.FC = () => {
|
|
|
|
|
const { nodeIds } = useEditor((state) => {
|
|
|
|
|
return {
|
|
|
|
|
nodeIds: Object.keys(state.nodes),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hasRoot = nodeIds.includes('ROOT');
|
|
|
|
|
|
|
|
|
|
if (!hasRoot) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="panel-placeholder">
|
|
|
|
|
No content on canvas
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
margin: '-12px',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Component Tree
|
|
|
|
|
</div>
|
|
|
|
|
<LayerNode nodeId="ROOT" depth={0} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|