diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 25a46af..36bdf42 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -8,6 +8,8 @@ import { exportBodyHtml } from '../utils/html-export'; interface CanvasProps { device: DeviceMode; + /** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */ + showGuides: boolean; } /** @@ -120,7 +122,7 @@ export const EmptyCanvasHint: React.FC = () => { ); }; -export const Canvas: React.FC = ({ device }) => { +export const Canvas: React.FC = ({ device, showGuides }) => { const width = DEVICE_WIDTHS[device]; const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); @@ -137,7 +139,7 @@ export const Canvas: React.FC = ({ device }) => { return (
{ const [device, setDevice] = useState('desktop'); + // Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted + // so the choice survives a reload. Lifted here (rather than owned by + // TopBar or Canvas alone) because the toggle button lives in TopBar but + // the `.guides-off` class it drives is applied to Canvas's + // `.canvas-device-frame`, mirroring how `device` is already lifted for + // the same reason. + const [showGuides, setShowGuidesState] = useState(loadShowGuides); + const setShowGuides = useCallback((next: boolean) => { + setShowGuidesState(next); + try { + window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0'); + } catch { + // Storage unavailable (private browsing, etc.) -- in-memory state still works. + } + }, []); const { menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { query } = useEditor(); @@ -34,11 +60,16 @@ export const EditorShell: React.FC = () => { return (
- + setShowGuides(!showGuides)} + />
- +
diff --git a/craft/src/editor/RenderNode.test.tsx b/craft/src/editor/RenderNode.test.tsx index 6984d4d..9c29d74 100644 --- a/craft/src/editor/RenderNode.test.tsx +++ b/craft/src/editor/RenderNode.test.tsx @@ -10,9 +10,11 @@ import { act } from 'react-dom/test-utils'; let mockNode: { id: string; selected: boolean; + hovered: boolean; dom: HTMLElement | null; displayName: string; parent: string | null; + isCanvas: boolean; }; const selectNodeSpy = vi.fn(); @@ -20,9 +22,14 @@ vi.mock('@craftjs/core', () => ({ useEditor: () => ({ actions: { selectNode: selectNodeSpy } }), useNode: (collect?: (node: any) => any) => { const node = { - events: { selected: mockNode.selected }, + events: { selected: mockNode.selected, hovered: mockNode.hovered }, dom: mockNode.dom, - data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent }, + data: { + custom: {}, + displayName: mockNode.displayName, + parent: mockNode.parent, + isCanvas: mockNode.isCanvas, + }, }; return { id: mockNode.id, ...(collect ? collect(node) : {}) }; }, @@ -46,7 +53,15 @@ function render(ui: React.ReactElement) { beforeEach(() => { nodeDom = document.createElement('div'); document.body.appendChild(nodeDom); - mockNode = { id: 'node-1', selected: false, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' }; + mockNode = { + id: 'node-1', + selected: false, + hovered: false, + dom: nodeDom, + displayName: 'Heading', + parent: 'ROOT', + isCanvas: false, + }; selectNodeSpy.mockClear(); }); @@ -105,4 +120,50 @@ describe('RenderNode (Editor onRender override)', () => { nodeDom.remove(); document.querySelector('.component-indicator')?.remove(); }); + + test('tags a droppable (isCanvas) node dom with data-craft-node', () => { + mockNode.isCanvas = true; + render(); + expect(nodeDom.hasAttribute('data-craft-node')).toBe(true); + container.remove(); + nodeDom.remove(); + }); + + test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => { + mockNode.isCanvas = false; + render(); + expect(nodeDom.hasAttribute('data-craft-node')).toBe(false); + container.remove(); + nodeDom.remove(); + }); + + test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => { + mockNode.id = 'ROOT'; + mockNode.isCanvas = true; + render(); + expect(nodeDom.hasAttribute('data-craft-node')).toBe(false); + container.remove(); + nodeDom.remove(); + }); + + test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => { + mockNode.hovered = true; + render(); + expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true); + container.remove(); + nodeDom.remove(); + }); + + test('removes data-craft-hovered once the hovered event clears', () => { + mockNode.hovered = true; + render(); + expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true); + mockNode.hovered = false; + act(() => { + root.render(); + }); + expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false); + container.remove(); + nodeDom.remove(); + }); }); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index df567cc..8c27340 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -14,14 +14,36 @@ interface RenderNodeProps { * straight through as a Fragment, so this never touches layout, never * appears in `toHtml` export (that walks the Craft node tree, not this * portal), and doesn't wrap every node in extra DOM. + * + * It also imperatively tags each node's real DOM element with two + * editor-only data attributes (never part of `toHtml` export, which walks + * the Craft node tree, not the live DOM): + * - `data-craft-node`: set on actual Craft.js droppable containers + * (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide" + * outlines target this attribute instead of blanket tag selectors + * (div/section/header/...), so a component's own internal wrapper markup + * no longer picks up a guide outline it isn't a real drop target for. + * - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover + * event set). Craft's own `connectors.connect()` (called by every + * component) wires a native mouseover/mouseleave listener to this event + * internally, so a plain mouse hover over any connected node sets it -- + * this attribute is a real-mouse-hover canvas highlight. (The Layers + * panel's row-hover -> canvas-highlight sync, item 12, uses a sibling + * `data-layer-hovered` attribute written directly by LayersPanel.tsx + * instead of this event, since the action that would drive it here + * -- `actions.setNodeEvent` -- is stripped from the public `useEditor()` + * API at runtime.) editor.css matches both attributes for the same + * outline and suppresses both under `.guides-off`. */ export const RenderNode: React.FC = ({ render }) => { const { actions } = useEditor(); - const { id, isSelected, dom, name, parent } = useNode((node) => ({ + const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({ isSelected: node.events.selected, + isHovered: node.events.hovered, dom: node.dom, name: (node.data.props?.aiName as string) || node.data.displayName, parent: node.data.parent, + isCanvas: node.data.isCanvas, })); const badgeRef = useRef(null); @@ -46,6 +68,24 @@ export const RenderNode: React.FC = ({ render }) => { }; }, [active, updatePosition]); + useEffect(() => { + if (!dom) return; + if (isCanvas && id !== 'ROOT') { + dom.setAttribute('data-craft-node', ''); + } else { + dom.removeAttribute('data-craft-node'); + } + }, [dom, isCanvas, id]); + + useEffect(() => { + if (!dom) return; + if (isHovered) { + dom.setAttribute('data-craft-hovered', ''); + } else { + dom.removeAttribute('data-craft-hovered'); + } + }, [dom, isHovered]); + if (!active) return <>{render}; return ( diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx index 68d41f3..ad23cfe 100644 --- a/craft/src/panels/left/LayersPanel.tsx +++ b/craft/src/panels/left/LayersPanel.tsx @@ -1,14 +1,74 @@ -import React, { useCallback } from 'react'; +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 } = useEditor((state) => { + 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; @@ -18,28 +78,76 @@ const LayerNode: React.FC = ({ nodeId, depth }) => { }; }); + 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 isSelected = selectedId === nodeId; - const nodeType = node.data.type; - const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType - ? (nodeType as any).resolvedName - : typeof nodeType === 'string' ? nodeType : undefined; 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 (
= ({ nodeId, depth }) => { textOverflow: 'ellipsis', userSelect: 'none', }} - onMouseEnter={(e) => { - if (!isSelected) { - (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)'; - } - }} - onMouseLeave={(e) => { - if (!isSelected) { - (e.currentTarget as HTMLElement).style.background = 'transparent'; - } - }} + onFocus={handleMouseEnter} + onBlur={handleMouseLeave} > - {/* Indentation indicator */} - {allChildren.length > 0 && ( - + {/* 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) => ( +