Files
site-builder/craft/src/editor/RenderNode.tsx
T
shadowdao c2a90cd7a7 feat(site-builder): tablet/mobile style overrides that never touch desktop
On tablet/mobile the useNodeProp style setters now route responsive keys
(spacing, font size, line height, letter spacing, alignment, size, flex
layout) into styleTablet/styleMobile; colours and other keys still write
style. An empty value removes the override. Continuous-burst keys include
the device so a desktop drag and a mobile drag never merge.

- shared.tsx: device-aware setters, new setResponsiveStyleProp /
  clearResponsiveStyleProp exports, setPropStylesContinuous
- responsive-panel.tsx: useResolvedStyle, useOverrideState, DeviceBanner
- OverrideDot: filled dot resets the override (one undo step), hollow dot
  marks an inherited value; wired into Text/Button/Container/Image/Media/
  BackgroundSection panels and both box-model sections
- Columns: "Stack on this device" toggle (flexDirection override)
- GuidedStyles: device banner under the type header
- RenderNode: every component node renders through one stable
  ResolvedRender so gaining/losing an override or switching device no
  longer remounts the node (the remount dropped inline-edit focus/caret
  and blanked a selected Heading)

Refs #45
2026-10-07 11:51:53 -07:00

216 lines
9.3 KiB
TypeScript

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 `<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.
*
* 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
);
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<RenderNodeProps> = ({ 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<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]);
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 ? <ResolvedRender device={device} /> : render;
if (!active) return <>{output}</>;
return (
<>
{output}
{createPortal(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
<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
)}
</>
);
};