diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 323ba61..bd6ec11 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Editor } from '@craftjs/core'; import { EditorShell } from './editor/EditorShell'; +import { RenderNode } from './editor/RenderNode'; import { componentResolver } from './components/resolver'; import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; @@ -23,7 +24,7 @@ export const App: React.FC = ({ whpConfig }) => { return ( - + diff --git a/craft/src/editor/RenderNode.test.tsx b/craft/src/editor/RenderNode.test.tsx new file mode 100644 index 0000000..6984d4d --- /dev/null +++ b/craft/src/editor/RenderNode.test.tsx @@ -0,0 +1,108 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core + so RenderNode (the override) can be driven without a + real Editor tree. document.body doubles as the portal target, same as + the component itself uses. */ +let mockNode: { + id: string; + selected: boolean; + dom: HTMLElement | null; + displayName: string; + parent: string | null; +}; +const selectNodeSpy = vi.fn(); + +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ actions: { selectNode: selectNodeSpy } }), + useNode: (collect?: (node: any) => any) => { + const node = { + events: { selected: mockNode.selected }, + dom: mockNode.dom, + data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent }, + }; + return { id: mockNode.id, ...(collect ? collect(node) : {}) }; + }, +})); + +import { RenderNode } from './RenderNode'; + +let container: HTMLDivElement; +let root: Root; +let nodeDom: HTMLElement; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + nodeDom = document.createElement('div'); + document.body.appendChild(nodeDom); + mockNode = { id: 'node-1', selected: false, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' }; + selectNodeSpy.mockClear(); +}); + +const rendered = hello; + +describe('RenderNode (Editor onRender override)', () => { + test('passes render through untouched when not selected', () => { + render(); + expect(container.querySelector('[data-testid="inner"]')).not.toBeNull(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('shows the badge with the displayName when selected', () => { + mockNode.selected = true; + render(); + const badge = document.querySelector('.component-indicator'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toContain('Heading'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('never shows a badge for ROOT even if "selected"', () => { + mockNode.selected = true; + mockNode.id = 'ROOT'; + render(); + expect(document.querySelector('.component-indicator')).toBeNull(); + container.remove(); + nodeDom.remove(); + }); + + test('chevron click selects the parent node', () => { + mockNode.selected = true; + mockNode.parent = 'parent-42'; + render(); + const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement; + expect(chevron).not.toBeNull(); + act(() => { + chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + expect(selectNodeSpy).toHaveBeenCalledWith('parent-42'); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); + + test('no chevron when there is no parent', () => { + mockNode.selected = true; + mockNode.parent = null; + render(); + expect(document.querySelector('.component-indicator-parent-btn')).toBeNull(); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx new file mode 100644 index 0000000..ca96216 --- /dev/null +++ b/craft/src/editor/RenderNode.tsx @@ -0,0 +1,76 @@ +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 `` 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. + */ +export const RenderNode: React.FC = ({ render }) => { + const { actions } = useEditor(); + const { id, isSelected, dom, name, parent } = useNode((node) => ({ + isSelected: node.events.selected, + dom: node.dom, + name: node.data.custom?.displayName || node.data.displayName, + parent: node.data.parent, + })); + + 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]); + + if (!active) return <>{render}; + + return ( + <> + {render} + {createPortal( +
+ {name} + {parent && ( + + )} +
, + document.body + )} + + ); +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index e2754b2..89f389c 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -1074,12 +1074,16 @@ body { outline-offset: -1px; } -/* Component indicator badge */ +/* Component indicator badge (floats over the selected node via a portal -- + position/top/left are set inline per-node, see RenderNode.tsx) */ .component-indicator { position: absolute; top: -22px; left: 0; - padding: 2px 8px; + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px 2px 8px; font-size: 10px; font-weight: 600; color: #ffffff; @@ -1090,6 +1094,28 @@ body { z-index: 10; } +.component-indicator-parent-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: none; + border-radius: 2px; + background: rgba(255, 255, 255, 0.2); + color: #ffffff; + font-size: 8px; + line-height: 1; + cursor: pointer; + pointer-events: auto; + transition: background var(--transition-fast); +} + +.component-indicator-parent-btn:hover { + background: rgba(255, 255, 255, 0.4); +} + /* -------------------------------------------------------------------------- Scrollbar Styling -------------------------------------------------------------------------- */