diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index 42603cc..67d31e5 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -68,7 +68,7 @@ export const RenderNode: React.FC = ({ render }) => { isSelected: node.events.selected, isHovered: node.events.hovered, dom: node.dom, - name: (node.data.props?.aiName as string) || node.data.displayName, + name: (node.data.custom?.label as string) || (node.data.props?.aiName as string) || node.data.displayName, parent: node.data.parent, isCanvas: node.data.isCanvas, overridden, diff --git a/craft/src/panels/left/LayersPanel.test.tsx b/craft/src/panels/left/LayersPanel.test.tsx index 55a45b1..73c410b 100644 --- a/craft/src/panels/left/LayersPanel.test.tsx +++ b/craft/src/panels/left/LayersPanel.test.tsx @@ -3,6 +3,10 @@ import React from 'react'; import { renderEditorHarness } from '../../test-utils/editorHarness'; import { LayersPanel, layerNodeRenderProbe } from './LayersPanel'; import { LayerFocusProvider } from './LayerFocusContext'; +import { exportBodyHtml } from '../../utils/html-export'; +import { NoticeProvider, useNotices } from '../../state/NoticeContext'; +import { DeviceProvider } from '../../state/DeviceContext'; +import { RenderNode } from '../../editor/RenderNode'; function stateWith(extra: Record, rootChildren: string[]) { return JSON.stringify({ @@ -182,3 +186,174 @@ describe('LayersPanel re-render isolation (#40)', () => { harness.unmount(); }); }); + +describe('LayersPanel drag / rename / hide (#47)', () => { + const mk = (resolvedName: string, displayName: string, parent: string, extra: any = {}) => ({ + type: { resolvedName }, isCanvas: false, props: {}, displayName, + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent, ...extra, + }); + const tree = () => stateWith({ + sec: mk('Section', 'Section', 'ROOT', { linkedNodes: { 'section-inner': 'inner' } }), + inner: mk('Container', 'Container', 'sec', { isCanvas: true, props: { style: {}, tag: 'div' }, nodes: ['h', 't'] }), + h: mk('Heading', 'Heading', 'inner', { props: { text: 'Head', level: 2 } }), + t: mk('TextBlock', 'Text', 'inner', { props: { text: 'Body', style: {} } }), + box: mk('Container', 'Container', 'ROOT', { isCanvas: true, props: { style: {}, tag: 'div' } }), + }, ['sec', 'box']); + const kids = (hn: any, id: string) => hn.query.node(id).get().data.nodes; + let seen: { kind: string; message: string }[] = []; + const NoticeSpy: React.FC = () => { seen = useNotices().notices; return null; }; + const mount = () => { + seen = []; + const hn = renderEditorHarness({ + initialState: tree(), + onRender: RenderNode, + wrap: (t) => {t}, + }); + hn.mountChild(); + return hn; + }; + const rowOf = (hn: any, id: string) => hn.container.querySelector(`.layer-node-row[data-layer-id="${id}"]`) as HTMLElement; + function fire(el: Element, type: string, init: any = {}) { + const ev = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(ev, { clientY: 0, dataTransfer: { setData() {}, dropEffect: '', effectAllowed: '' }, ...init }); + el.dispatchEvent(ev); + return ev; + } + const drag = (hn: any, from: string, to: string, y: number) => { + const target = rowOf(hn, to); + target.getBoundingClientRect = () => ({ top: 0, height: 30, left: 0, right: 0, bottom: 30, width: 0, x: 0, y: 0, toJSON() {} }) as DOMRect; + hn.act(() => { fire(rowOf(hn, from), 'dragstart'); }); + hn.act(() => { fire(target, 'dragover', { clientY: y }); }); + hn.act(() => { fire(target, 'drop', { clientY: y }); }); + }; + + test('dragging Text onto the top of Heading reorders; undo path via move back', () => { + const hn = mount(); + expect(kids(hn, 'inner')).toEqual(['h', 't']); + drag(hn, 't', 'h', 2); + expect(kids(hn, 'inner')).toEqual(['t', 'h']); + hn.unmount(); + }); + + test('dragging Heading to the bottom of Text moves it later (index semantics)', () => { + const hn = mount(); + drag(hn, 'h', 't', 28); + expect(kids(hn, 'inner')).toEqual(['t', 'h']); + hn.unmount(); + }); + + test('move into another container (middle of canvas row)', () => { + const hn = mount(); + drag(hn, 'h', 'box', 15); + expect(kids(hn, 'box')).toEqual(['h']); + expect(kids(hn, 'inner')).toEqual(['t']); + hn.unmount(); + }); + + test('undo restores the order after a drag reorder', () => { + const hn = mount(); + drag(hn, 't', 'h', 2); + expect(kids(hn, 'inner')).toEqual(['t', 'h']); + hn.act(() => { hn.actions.history.undo(); }); + expect(kids(hn, 'inner')).toEqual(['h', 't']); + hn.unmount(); + }); + + test('moving a node into its own descendant is refused without throwing', () => { + const hn = mount(); + // sec is a linked-slot owner; try to move `inner`'s parent chain: drop box into itself is a no-op, + // so move `sec` into `inner` (its own descendant) via the row drop. + expect(() => drag(hn, 'sec', 'inner', 15)).not.toThrow(); + expect(kids(hn, 'ROOT')).toEqual(['sec', 'box']); + expect(hn.query.node('sec').get().data.parent).toBe('ROOT'); + expect(seen.map((n) => n.kind)).toEqual(['error']); + expect(seen[0].message).toMatch(/inside itself/); + hn.unmount(); + }); + + test('linked slots are not draggable; normal rows are', () => { + const hn = mount(); + expect(rowOf(hn, 'inner').draggable).toBe(false); + expect(rowOf(hn, 'ROOT').draggable).toBe(false); + expect(rowOf(hn, 'h').draggable).toBe(true); + hn.unmount(); + }); + + test('rename via double-click commits custom.label, shows in canvas badge text source, survives serialize', () => { + const hn = mount(); + hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); }); + const input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement; + expect(input).toBeTruthy(); + input.value = 'Pricing'; + hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + expect(hn.query.node('h').get().data.custom.label).toBe('Pricing'); + expect(JSON.parse(hn.getSerialized()).h.custom.label).toBe('Pricing'); + expect(rowOf(hn, 'h').textContent).toContain('Pricing'); + // aiName / displayName untouched + expect(hn.query.node('h').get().data.displayName).toBe('Heading'); + hn.unmount(); + }); + + test('canvas name badge shows the custom label for the selected node', () => { + const hn = mount(); + hn.act(() => { hn.actions.selectNode('h'); }); + const badge = () => document.querySelector('.component-indicator span')?.textContent; + expect(badge()).toBe('Heading'); + hn.act(() => { hn.actions.setCustom('h', (c: any) => { c.label = 'Pricing'; }); }); + expect(badge()).toBe('Pricing'); + hn.unmount(); + }); + + test('Escape cancels rename; empty commit clears the label', () => { + const hn = mount(); + hn.act(() => { hn.actions.setCustom('h', (c: any) => { c.label = 'Old'; }); }); + hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); }); + let input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement; + input.value = 'Nope'; + hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); }); + expect(hn.query.node('h').get().data.custom.label).toBe('Old'); + hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); }); + input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement; + input.value = ' '; + hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + expect(hn.query.node('h').get().data.custom.label).toBeUndefined(); + hn.unmount(); + }); + + test('F2 on the focused row starts rename', () => { + const hn = mount(); + hn.act(() => { rowOf(hn, 't').dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', bubbles: true })); }); + expect(hn.container.querySelector('.layer-rename-input')).toBeTruthy(); + hn.unmount(); + }); + + test('eye toggle hides: row dimmed, canvas node gone, export omits; toggling back restores', () => { + const hn = mount(); + const headings = () => hn.container.querySelectorAll('h2').length; + expect(headings()).toBe(1); + const eye = () => rowOf(hn, 'h').querySelector('.layer-eye-btn') as HTMLElement; + hn.act(() => { eye().click(); }); + expect(hn.query.node('h').get().data.hidden).toBe(true); + expect(rowOf(hn, 'h').className).toContain('layer-hidden'); + expect(rowOf(hn, 'h').querySelector('.fa-eye-slash')).toBeTruthy(); + expect(headings()).toBe(0); + expect(exportBodyHtml(hn.getSerialized()).html).not.toContain('>Head<'); + expect(exportBodyHtml(hn.getSerialized()).html).toContain('Body'); + hn.act(() => { eye().click(); }); + expect(rowOf(hn, 'h').className).not.toContain('layer-hidden'); + expect(headings()).toBe(1); + expect(exportBodyHtml(hn.getSerialized()).html).toContain('>Head<'); + hn.unmount(); + }); + + test('chevron collapses and expands children; selecting a hidden descendant re-expands', () => { + const hn = mount(); + const chev = () => rowOf(hn, 'inner').querySelector('.layer-chevron') as HTMLElement; + expect(rowOf(hn, 'h')).toBeTruthy(); + hn.act(() => { chev().click(); }); + expect(rowOf(hn, 'h')).toBeNull(); + hn.act(() => { hn.actions.selectNode('h'); }); + expect(rowOf(hn, 'h')).toBeTruthy(); + hn.unmount(); + }); +}); diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx index 69882a5..a0fe604 100644 --- a/craft/src/panels/left/LayersPanel.tsx +++ b/craft/src/panels/left/LayersPanel.tsx @@ -1,5 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { useEditor } from '@craftjs/core'; +import { useNotices } from '../../state/NoticeContext'; +import { getAncestorIds } from '../../utils/craft-helpers'; +import { canMove, computeDrop, moveLayer, type DropWhere } from './layers-dnd'; import { clickableProps } from '../../utils/a11y'; import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows'; import { useLayerFocus } from './LayerFocusContext'; @@ -103,6 +106,76 @@ const VirtualRowNode: React.FC<{ ); +/** + * Panel-local UI state (collapsed ids, drag/drop line, inline rename). Lives + * outside the Craft store AND outside React state on purpose: rows subscribe + * to a primitive slice of it, so a dragover only re-renders the two rows whose + * slice changed instead of the whole tree (#40's isolation property). + */ +interface LayersUiState { + collapsed: ReadonlySet; + dropTargetId: string | null; + dropWhere: DropWhere | null; + draggingId: string | null; + renamingId: string | null; +} + +export interface LayersUi { + get(): LayersUiState; + set(patch: Partial): void; + subscribe(cb: () => void): () => void; +} + +export function createLayersUi(): LayersUi { + let state: LayersUiState = { + collapsed: new Set(), dropTargetId: null, dropWhere: null, draggingId: null, renamingId: null, + }; + const subs = new Set<() => void>(); + return { + get: () => state, + set(patch) { + const next = { ...state, ...patch }; + if ((Object.keys(patch) as (keyof LayersUiState)[]).every((k) => state[k] === next[k])) return; + state = next; + subs.forEach((cb) => cb()); + }, + subscribe(cb) { subs.add(cb); return () => { subs.delete(cb); }; }, + }; +} + +const LayersUiContext = createContext(createLayersUi()); + +function useLayersUiValue(ui: LayersUi, select: (s: LayersUiState) => T): T { + return useSyncExternalStore(ui.subscribe, () => select(ui.get())); +} + +/** Inline rename field. Enter/blur commit, Escape cancels. */ +const RenameInput: React.FC<{ + initial: string; + onCommit: (v: string) => void; + onCancel: () => void; +}> = ({ initial, onCommit, onCancel }) => { + const done = useRef(false); + const finish = (fn: () => void) => { if (!done.current) { done.current = true; fn(); } }; + return ( + e.currentTarget.select()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') { e.preventDefault(); finish(() => onCommit(e.currentTarget.value)); } + else if (e.key === 'Escape') { e.preventDefault(); finish(onCancel); } + }} + onBlur={(e) => finish(() => onCommit(e.currentTarget.value))} + /> + ); +}; + interface LayerNodeProps { nodeId: string; depth: number; @@ -122,18 +195,25 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { // The collector returns PRIMITIVES only, so Craft's shallow-equal check // skips the re-render for hover / unrelated prop edits. Returning the whole // node object would re-render this row on every prop change anywhere in it. - const { exists, displayName, childIdsSig, virtualSig, isSelected, actions, query } = useEditor((state) => { + const ui = useContext(LayersUiContext); + const { notify } = useNotices(); + const { exists, displayName, customLabel, isHidden, isLinked, childIdsSig, virtualSig, isSelected, actions, query } = useEditor((state) => { const n = state.nodes[nodeId]; - if (!n) return { exists: false, displayName: '', childIdsSig: '', virtualSig: '', isSelected: false }; + if (!n) return { exists: false, displayName: '', customLabel: '', isHidden: false, isLinked: false, childIdsSig: '', virtualSig: '', isSelected: false }; const name = (n.data.props?.aiName as string) || n.data.displayName || (n.data.type as any)?.resolvedName || 'Node'; // Virtual rows come from array props; collect them as one string so only // a change in the row labels (not any other prop) re-renders the row. const virtualSig = VIRTUAL_CHILD_PROPS[name] ? JSON.stringify(deriveVirtualRows(name, n.data.props || {})) : ''; const selectedIds = state.events.selected; const selId = selectedIds ? Array.from(selectedIds)[0] : null; + const parent = n.data.parent ? state.nodes[n.data.parent] : null; + const isLinked = !!parent && (Object.values(parent.data.linkedNodes || {}) as string[]).includes(nodeId); return { exists: true, displayName: name, + customLabel: (n.data.custom?.label as string) || '', + isHidden: !!n.data.hidden, + isLinked, childIdsSig: childIdsOf(n.data), virtualSig, isSelected: selId === nodeId, @@ -142,6 +222,14 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { const allChildren = useMemo(() => (childIdsSig ? childIdsSig.split(',') : []), [childIdsSig]); const virtualRows = useMemo<{ index: number; label: string }[]>(() => (virtualSig ? JSON.parse(virtualSig) : []), [virtualSig]); + const isRoot = nodeId === 'ROOT'; + // ROOT and linked slots (Section inner, Columns col-N) are structural. + const structural = isRoot || isLinked; + const isCollapsed = useLayersUiValue(ui, (u) => u.collapsed.has(nodeId)); + const isRenaming = useLayersUiValue(ui, (u) => u.renamingId === nodeId); + const dropWhere = useLayersUiValue(ui, (u) => (u.dropTargetId === nodeId ? u.dropWhere : null)); + const isDragging = useLayersUiValue(ui, (u) => u.draggingId === nodeId); + const handleActivate = useCallback(() => { actions.selectNode(nodeId); }, [actions, nodeId]); @@ -193,21 +281,107 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { }; }, []); + const toggleCollapsed = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + const next = new Set(ui.get().collapsed); + if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); + ui.set({ collapsed: next }); + }, [ui, nodeId]); + + const startRename = useCallback(() => { + if (!structural) ui.set({ renamingId: nodeId }); + }, [ui, nodeId, structural]); + + const commitRename = useCallback((value: string) => { + ui.set({ renamingId: null }); + const v = value.trim(); + if (v === customLabel) return; + actions.setCustom(nodeId, (c: any) => { + if (v) c.label = v; else delete c.label; + }); + }, [ui, actions, nodeId, customLabel]); + + const toggleHidden = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + actions.setHidden(nodeId, !isHidden); + }, [actions, nodeId, isHidden]); + + // Where would a drop at this pointer position land? Resolved from the live + // tree at event time, so no extra collector fields are needed. + const resolveDrop = useCallback((e: React.DragEvent) => { + const node = query.node(nodeId).get(); + if (!node) return null; + const parentId: string | null = node.data.parent || null; + const siblings: string[] = parentId ? query.node(parentId).get()?.data?.nodes || [] : []; + return computeDrop( + { clientY: e.clientY, rect: e.currentTarget.getBoundingClientRect() }, + { + id: nodeId, + parentId, + index: siblings.indexOf(nodeId), + isCanvas: !!node.data.isCanvas, + childCount: (node.data.nodes || []).length, + }, + ); + }, [query, nodeId]); + + const handleDragStart = (e: React.DragEvent) => { + if (structural || isRenaming) { e.preventDefault(); return; } + e.dataTransfer?.setData('text/plain', nodeId); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + ui.set({ draggingId: nodeId }); + }; + const clearDrag = () => ui.set({ draggingId: null, dropTargetId: null, dropWhere: null }); + const handleDragOver = (e: React.DragEvent) => { + const dragId = ui.get().draggingId; + if (!dragId) return; + e.preventDefault(); + const drop = resolveDrop(e); + const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId); + if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none'; + ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null }); + }; + const handleDrop = (e: React.DragEvent) => { + const dragId = ui.get().draggingId; + clearDrag(); + if (!dragId) return; + e.preventDefault(); + e.stopPropagation(); + const drop = resolveDrop(e); + if (!drop || dragId === nodeId) return; + const res = moveLayer(query, actions, dragId, drop.parentId, drop.index); + if (!res.ok) notify({ kind: 'error', message: res.error || "That layer can't be moved there.", timeoutMs: 4000 }); + }; + if (!exists) return null; - const isRoot = nodeId === 'ROOT'; + const label = customLabel || displayName; const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON; const virtualSpec = VIRTUAL_CHILD_PROPS[displayName]; const hasDisclosure = allChildren.length + virtualRows.length > 0; + const rowClickable = clickableProps(handleActivate); return (
{ + if (e.target === e.currentTarget && e.key === 'F2') { e.preventDefault(); startRename(); return; } + rowClickable.onKeyDown(e); + }} + onDoubleClick={startRename} + draggable={!structural && !isRenaming} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={clearDrag} + data-layer-id={nodeId} + data-drop={dropWhere || undefined} + aria-selected={isSelected} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} - className="layer-node-row" + className={`layer-node-row${isHidden ? ' layer-hidden' : ''}${isDragging ? ' layer-dragging' : ''}`} style={{ position: 'relative', display: 'flex', @@ -247,10 +421,17 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { /> ))} - {/* Indentation/disclosure indicator */} + {/* Disclosure chevron: real expand/collapse toggle */} {hasDisclosure ? ( - - ▼ + + {isCollapsed ? '\u25B6' : '\u25BC'} ) : ( @@ -272,15 +453,34 @@ const LayerNodeImpl: React.FC = ({ nodeId, depth }) => { }} /> - {/* Name */} - - {isRoot ? 'Canvas (Root)' : displayName} - + {/* Name (inline input while renaming) */} + {isRenaming ? ( + ui.set({ renamingId: null })} /> + ) : ( + + {isRoot ? 'Canvas (Root)' : label} + + )} + + {/* Hide / show on canvas (undoable; hidden blocks are skipped on export) */} + {!structural && ( +