React assigns '' for a blank style value and a blank longhand after a shorthand
({padding:'20px', paddingTop:''}) wipes the shorthand in the canvas; the exporter
drops blanks. ResolvedRender is the one place node style reaches React, so
normalise there.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { NodeElement, useEditor, useNode } from '@craftjs/core';
|
|
import { useDevice } from '../state/DeviceContext';
|
|
import { dropBlankStyle, baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
|
|
import type { DeviceMode } from '../types';
|
|
import { GlobalInstanceContext } from '../state/GlobalInstanceContext';
|
|
|
|
interface RenderNodeProps {
|
|
render: React.ReactElement;
|
|
}
|
|
|
|
/**
|
|
* Craft.js `<Editor onRender>` 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.
|
|
*
|
|
* - `data-wsb-global` / `data-wsb-global-inner` (#57): global-section
|
|
* instance root (dashed accent outline) / a node inside one (locked
|
|
* styling; content edits become overrides). Every node is wrapped in a
|
|
* `GlobalInstanceContext` provider (always, so the element type never
|
|
* changes) carrying the nearest instance root id.
|
|
*
|
|
* 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
|
|
* `<DefaultRender/>` 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, <NodeElement/> 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) => (
|
|
<NodeElement id={childId} key={childId} />
|
|
))}
|
|
</>
|
|
) : (
|
|
props.children
|
|
);
|
|
// The ONE place node style reaches React: drop blank values like the exporter does
|
|
// (see dropBlankStyle) so the canvas can't wipe a shorthand with a blank longhand.
|
|
const style = dropBlankStyle(
|
|
styleDevice !== 'desktop' ? resolveStyleForDevice(props as ResponsiveProps, styleDevice) : (props as ResponsiveProps).style,
|
|
);
|
|
const resolved = styleDevice !== 'desktop' || style !== (props as ResponsiveProps).style ? { ...props, style } : props;
|
|
return React.createElement(type as React.ElementType, resolved, children);
|
|
}, [type, props, childIds, hydrationTimestamp, styleDevice]);
|
|
};
|
|
|
|
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
|
const { actions } = useEditor();
|
|
const { device } = useDevice();
|
|
const parentInstance = useContext(GlobalInstanceContext);
|
|
const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot, baseStack } = 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.custom?.label as string) || (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,
|
|
isInstanceRoot: !!(node.data.custom?.global as { libraryId?: string } | undefined)?.libraryId,
|
|
baseStack: baseStacksOnMobile(props, node.dom?.style),
|
|
};
|
|
});
|
|
const instanceRoot = isInstanceRoot ? id : parentInstance;
|
|
const isInstanceInner = !isInstanceRoot && !!parentInstance;
|
|
|
|
// Overrides that apply on the current device (mobile inherits tablet's).
|
|
const activeOverride = device === 'tablet' ? tabletOverride : device === 'mobile' ? mobileOverride : false;
|
|
|
|
const badgeRef = useRef<HTMLDivElement>(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-<device>]` 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]);
|
|
|
|
// Mobile stacking follows the DESKTOP style when published (see
|
|
// baseStacksOnMobile); editor.css keys off this instead of the resolved inline.
|
|
useEffect(() => {
|
|
if (!dom) return;
|
|
if (baseStack === null) dom.removeAttribute('data-wsb-base-stack');
|
|
else dom.setAttribute('data-wsb-base-stack', baseStack ? '1' : '0');
|
|
}, [dom, baseStack]);
|
|
|
|
useEffect(() => {
|
|
if (!dom) return;
|
|
if (activeOverride) {
|
|
dom.setAttribute('data-wsb-overridden', '');
|
|
} else {
|
|
dom.removeAttribute('data-wsb-overridden');
|
|
}
|
|
}, [dom, activeOverride]);
|
|
|
|
useEffect(() => {
|
|
if (!dom) return;
|
|
if (isInstanceRoot) dom.setAttribute('data-wsb-global', '');
|
|
else dom.removeAttribute('data-wsb-global');
|
|
if (isInstanceInner) dom.setAttribute('data-wsb-global-inner', '');
|
|
else dom.removeAttribute('data-wsb-global-inner');
|
|
}, [dom, isInstanceRoot, isInstanceInner]);
|
|
|
|
// 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 = (
|
|
<GlobalInstanceContext.Provider value={instanceRoot}>
|
|
{isComponent ? <ResolvedRender device={device} /> : render}
|
|
</GlobalInstanceContext.Provider>
|
|
);
|
|
|
|
if (!active) return output;
|
|
|
|
return (
|
|
<>
|
|
{output}
|
|
{createPortal(
|
|
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
|
{isInstanceRoot && (
|
|
<span className="component-indicator-global" data-testid="global-badge" title="Global section: edit the master to change it everywhere">
|
|
<i className="fa fa-globe" aria-hidden /> Global
|
|
</span>
|
|
)}
|
|
{isInstanceInner && (
|
|
<i className="fa fa-lock component-indicator-lock" aria-label="Part of a global section" title="Part of a global section: text and images are per-page overrides; design comes from the master" />
|
|
)}
|
|
<span>{name}</span>
|
|
{parent && (
|
|
<button
|
|
type="button"
|
|
className="component-indicator-parent-btn"
|
|
title="Select parent"
|
|
aria-label={`Select parent of ${name}`}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
actions.selectNode(parent);
|
|
}}
|
|
>
|
|
<i className="fa fa-chevron-up" aria-hidden />
|
|
</button>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
};
|