From 05e00c572db795bcacfb826ba10192612e29562f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 20:15:08 -0700 Subject: [PATCH] Show component-indicator selection badge in the canvas .component-indicator existed in editor.css but was never rendered anywhere. Add RenderNode.tsx as a Craft.js override and wire it in App.tsx: for the currently-selected node (excluding ROOT) it portals a floating badge showing the node's displayName plus a "select parent" chevron wired to actions.selectNode(parentId). Every other node's render passes through untouched (a Fragment, no extra DOM), and the badge portals to document.body positioned via getBoundingClientRect rather than wrapping nodes in extra DOM, so it can't perturb canvas layout and never appears in toHtml export. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/App.tsx | 3 +- craft/src/editor/RenderNode.test.tsx | 108 +++++++++++++++++++++++++++ craft/src/editor/RenderNode.tsx | 76 +++++++++++++++++++ craft/src/styles/editor.css | 30 +++++++- 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 craft/src/editor/RenderNode.test.tsx create mode 100644 craft/src/editor/RenderNode.tsx 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 -------------------------------------------------------------------------- */