import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { NodeElement, useEditor, useNode } from '@craftjs/core'; import { useDevice } from '../state/DeviceContext'; import { hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; import type { DeviceMode } from '../types'; interface RenderNodeProps { render: React.ReactElement; } /** * Craft.js `` override -- wraps every node's render output. * For the currently-selected node it portals a floating badge (component * displayName + a "select parent" chevron) positioned over the node's real * DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass * 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`. * - `data-wsb-overridden`: set while the current device mode (tablet/ * mobile, from DeviceContext) has per-breakpoint overrides for this node * -- i.e. the canvas is showing something other than the desktop style. * Counts only SET values (not ''/null placeholders), like export. * - `data-hide-desktop|tablet|mobile` / `data-craft-selected` (#37): mirror * the node's hide flags and selection for the device-emulation CSS. * * Per-breakpoint styles (#45/#37): when the device is not desktop and the * node carries `styleTablet`/`styleMobile`, this is the ONE place the * effective style is resolved (`resolveStyleForDevice`), so no component * needs to know about devices. Craft hands `onRender` an opaque * `` element that reads the node's props itself, so the * override cannot be applied with `cloneElement(render, {style})` -- instead * every component node renders through `ResolvedRender`, a copy of Craft's * DefaultRender (`createElement(type, props, children)`) that * swaps in the resolved `style`. It is used on EVERY device and whether or * not the node has overrides, so the element type never changes: switching * device or adding the first override only updates the style, it never * remounts the component (#45 -- a remount dropped inline-edit focus/caret * and blanked a selected Heading/Text, whose text is only written to the DOM * while unselected). Raw-tag nodes keep Craft's own `render`. */ const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => { const { type, props, childIds, hydrationTimestamp } = useNode((node) => ({ type: node.data.type, props: node.data.props, childIds: node.data.nodes, hydrationTimestamp: (node as any)._hydrationTimestamp, })); // Only nodes with overrides depend on the device, so a device switch // re-renders just those (plain nodes keep their memoised element). const styleDevice: DeviceMode = hasAnyOverride(props as ResponsiveProps) ? device : 'desktop'; return useMemo(() => { const children = childIds && childIds.length > 0 ? ( <> {childIds.map((childId: string) => ( ))} ) : ( props.children ); const resolved = styleDevice !== 'desktop' ? { ...props, style: resolveStyleForDevice(props as ResponsiveProps, styleDevice) } : props; return React.createElement(type as React.ElementType, resolved, children); }, [type, props, childIds, hydrationTimestamp, styleDevice]); }; export const RenderNode: React.FC = ({ render }) => { const { actions } = useEditor(); const { device } = useDevice(); const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile } = useNode((node) => { // Keep the collector cheap (booleans only); ResolvedRender collects the // node's type/props/children itself, as Craft's DefaultRender does. const props = node.data.props as ResponsiveProps | undefined; const type = node.data.type as unknown; return { isComponent: !!type && typeof type !== 'string', tabletOverride: hasActiveOverride(props, 'tablet'), mobileOverride: hasActiveOverride(props, 'mobile'), 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, hideDesktop: !!node.data.props?.hideOnDesktop, hideTablet: !!node.data.props?.hideOnTablet, hideMobile: !!node.data.props?.hideOnMobile, }; }); // Overrides that apply on the current device (mobile inherits tablet's). const activeOverride = device === 'tablet' ? tabletOverride : device === 'mobile' ? mobileOverride : false; const badgeRef = useRef(null); const active = isSelected && id !== 'ROOT' && !!dom; const updatePosition = useCallback(() => { if (!dom || !badgeRef.current) return; const rect = dom.getBoundingClientRect(); const badgeHeight = 22; badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`; badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`; }, [dom]); useEffect(() => { if (!active) return; updatePosition(); window.addEventListener('resize', updatePosition); document.addEventListener('scroll', updatePosition, true); return () => { window.removeEventListener('resize', updatePosition); document.removeEventListener('scroll', updatePosition, true); }; }, [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]); // #37: mirror the export's visibility flags and the selection onto the dom; // editor.css hides `[data-hide-]` for the active device (the // viewport media queries cannot fire inside a narrowed frame) but keeps the // selected node visible (dimmed) so it stays editable. useEffect(() => { if (!dom) return; const flags: Array<[string, boolean]> = [ ['data-hide-desktop', hideDesktop], ['data-hide-tablet', hideTablet], ['data-hide-mobile', hideMobile], ['data-craft-selected', isSelected], ]; for (const [attr, on] of flags) { if (on) dom.setAttribute(attr, ''); else dom.removeAttribute(attr); } }, [dom, hideDesktop, hideTablet, hideMobile, isSelected]); useEffect(() => { if (!dom) return; if (activeOverride) { dom.setAttribute('data-wsb-overridden', ''); } else { dom.removeAttribute('data-wsb-overridden'); } }, [dom, activeOverride]); // String-typed (plain DOM) nodes go through Craft's own SimpleElement // connector wrapper, which is not exported -- leave those untouched. Every // resolver component is a React component (see ResolvedRender above). const output: React.ReactElement = isComponent ? : render; if (!active) return <>{output}; return ( <> {output} {createPortal(
{name} {parent && ( )}
, document.body )} ); };