2026-07-12 20:15:08 -07:00
|
|
|
import React, { useCallback, useEffect, useRef } from 'react';
|
|
|
|
|
import { createPortal } from 'react-dom';
|
|
|
|
|
import { useEditor, useNode } from '@craftjs/core';
|
|
|
|
|
|
|
|
|
|
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.
|
2026-07-12 20:55:52 -07:00
|
|
|
*
|
|
|
|
|
* 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`.
|
2026-07-12 20:15:08 -07:00
|
|
|
*/
|
|
|
|
|
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
|
|
|
|
const { actions } = useEditor();
|
2026-07-12 20:55:52 -07:00
|
|
|
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
|
2026-07-12 20:15:08 -07:00
|
|
|
isSelected: node.events.selected,
|
2026-07-12 20:55:52 -07:00
|
|
|
isHovered: node.events.hovered,
|
2026-07-12 20:15:08 -07:00
|
|
|
dom: node.dom,
|
2026-07-12 20:22:11 -07:00
|
|
|
name: (node.data.props?.aiName as string) || node.data.displayName,
|
2026-07-12 20:15:08 -07:00
|
|
|
parent: node.data.parent,
|
2026-07-12 20:55:52 -07:00
|
|
|
isCanvas: node.data.isCanvas,
|
2026-07-12 20:15:08 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
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]);
|
|
|
|
|
|
2026-07-12 20:15:08 -07:00
|
|
|
if (!active) return <>{render}</>;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{render}
|
|
|
|
|
{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
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|