Merge issue-47 (Refs #47)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:58:26 -07:00
commit 68a3734eaf
6 files changed
+646 -16

No files matched your search

+1 -1
View File
@@ -68,7 +68,7 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ 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,
+175
View File
@@ -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<string, any>, 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) => <NoticeProvider><DeviceProvider>{t}<NoticeSpy /></DeviceProvider></NoticeProvider>,
});
hn.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
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();
});
});
+241 -15
View File
@@ -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<{
</div>
);
/**
* 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<string>;
dropTargetId: string | null;
dropWhere: DropWhere | null;
draggingId: string | null;
renamingId: string | null;
}
export interface LayersUi {
get(): LayersUiState;
set(patch: Partial<LayersUiState>): 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<LayersUi>(createLayersUi());
function useLayersUiValue<T>(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 (
<input
className="layer-rename-input"
aria-label="Layer name"
defaultValue={initial}
autoFocus
onFocus={(e) => 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<LayerNodeProps> = ({ 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<LayerNodeProps> = ({ 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<LayerNodeProps> = ({ 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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<div>
<div
{...clickableProps(handleActivate)}
{...rowClickable}
onKeyDown={(e) => {
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<LayerNodeProps> = ({ nodeId, depth }) => {
/>
))}
{/* Indentation/disclosure indicator */}
{/* Disclosure chevron: real expand/collapse toggle */}
{hasDisclosure ? (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
&#9660;
<span
role="button"
className="layer-chevron"
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
aria-expanded={!isCollapsed}
onClick={toggleCollapsed}
style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0, cursor: 'pointer' }}
>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
) : (
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
@@ -272,15 +453,34 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}}
/>
{/* Name */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : displayName}
</span>
{/* Name (inline input while renaming) */}
{isRenaming ? (
<RenameInput initial={customLabel || displayName} onCommit={commitRename} onCancel={() => ui.set({ renamingId: null })} />
) : (
<span className="layer-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : label}
</span>
)}
{/* Hide / show on canvas (undoable; hidden blocks are skipped on export) */}
{!structural && (
<button
type="button"
className="layer-eye-btn"
aria-label={isHidden ? `Show ${label}` : `Hide ${label}`}
aria-pressed={isHidden}
title={isHidden ? 'Hidden on all devices - click to show' : 'Hide'}
onClick={toggleHidden}
onDoubleClick={(e) => e.stopPropagation()}
>
<i className={`fa ${isHidden ? 'fa-eye-slash' : 'fa-eye'}`} aria-hidden="true" />
</button>
)}
</div>
{/* Virtual rows: array-prop items (composite content Craft doesn't see
as child nodes), rendered before real children. */}
{virtualSpec && virtualRows.map((row) => (
{!isCollapsed && virtualSpec && virtualRows.map((row) => (
<VirtualRowNode
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
parentId={nodeId}
@@ -296,13 +496,18 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
))}
{/* Render children */}
{allChildren.map((childId) => (
{!isCollapsed && allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</div>
);
};
/** Dragover feedback check: same rules `moveLayer` enforces on drop. */
function moveLayerAllowed(query: any, nodeId: string, parentId: string): boolean {
return canMove(query, nodeId, parentId);
}
const LayerNode = React.memo(LayerNodeImpl);
export const LayersPanel: React.FC = () => {
@@ -326,6 +531,25 @@ export const LayersPanel: React.FC = () => {
cache.current = next;
return next;
});
// Panel-local UI store (collapsed ids, drop line, rename) - see LayersUi.
const ui = useMemo(() => createLayersUi(), []);
// Selecting a node (canvas click, breadcrumb, Layers) reveals it: drop its
// ancestors from the collapsed set. Primitive collector, so only this thin
// shell re-renders on selection; the memoised rows are untouched.
const { selectedId, query } = useEditor((state) => {
const sel = state.events.selected;
return { selectedId: (sel ? Array.from(sel)[0] : '') as string };
});
useEffect(() => {
if (!selectedId) return;
const collapsed = ui.get().collapsed;
if (collapsed.size === 0) return;
const hit = [...getAncestorIds(query, selectedId), 'ROOT'].filter((id) => collapsed.has(id));
if (hit.length === 0) return;
const next = new Set(collapsed);
hit.forEach((id) => next.delete(id));
ui.set({ collapsed: next });
}, [selectedId, query, ui]);
const nodeIds = useMemo(() => sig.split(';').map((e) => e.slice(0, e.indexOf(':'))), [sig]);
const unplacedIds = useMemo(() => (unplacedSig ? unplacedSig.split(',') : []), [unplacedSig]);
@@ -340,6 +564,7 @@ export const LayersPanel: React.FC = () => {
}
return (
<LayersUiContext.Provider value={ui}>
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
<div
style={{
@@ -389,5 +614,6 @@ export const LayersPanel: React.FC = () => {
)}
</div>
</div>
</LayersUiContext.Provider>
);
};
+67
View File
@@ -0,0 +1,67 @@
import { describe, test, expect } from 'vitest';
import { computeDrop, canMove, type RowTarget } from './layers-dnd';
const rect = { top: 100, height: 30 };
const row = (over: Partial<RowTarget> = {}): RowTarget => ({
id: 't', parentId: 'p', index: 2, isCanvas: false, childCount: 0, ...over,
});
describe('computeDrop thirds', () => {
test('top third drops before (index = row index)', () => {
expect(computeDrop({ clientY: 103, rect }, row())).toEqual({ parentId: 'p', index: 2, where: 'before' });
});
test('bottom third drops after (index = row index + 1)', () => {
expect(computeDrop({ clientY: 127, rect }, row())).toEqual({ parentId: 'p', index: 3, where: 'after' });
});
test('middle of a canvas row drops inside at the end', () => {
expect(computeDrop({ clientY: 115, rect }, row({ isCanvas: true, childCount: 4 })))
.toEqual({ parentId: 't', index: 4, where: 'inside' });
});
test('middle of a non-canvas row falls to the nearer half', () => {
expect(computeDrop({ clientY: 112, rect }, row())?.where).toBe('before');
expect(computeDrop({ clientY: 118, rect }, row())?.where).toBe('after');
});
test('rows without a sibling slot (ROOT / linked) only accept inside', () => {
const root = row({ id: 'ROOT', parentId: null, index: -1, isCanvas: true, childCount: 3 });
expect(computeDrop({ clientY: 101, rect }, root)).toEqual({ parentId: 'ROOT', index: 3, where: 'inside' });
expect(computeDrop({ clientY: 129, rect }, root)?.where).toBe('inside');
});
test('non-canvas row with no sibling slot has no drop', () => {
expect(computeDrop({ clientY: 101, rect }, row({ parentId: null, index: -1 }))).toBeNull();
});
});
// A tiny fake query: n(id) -> {data:{parent,isCanvas,linkedNodes}}.
function fakeQuery(nodes: Record<string, any>, droppable = true) {
return {
node: (id: string) => ({
get: () => (nodes[id] ? { data: nodes[id] } : null),
isDroppable: () => droppable,
}),
};
}
describe('canMove', () => {
const nodes = {
ROOT: { parent: null, isCanvas: true, linkedNodes: {} },
sec: { parent: 'ROOT', isCanvas: false, linkedNodes: { inner: 'in' } },
in: { parent: 'sec', isCanvas: true, linkedNodes: {} },
a: { parent: 'in', isCanvas: true, linkedNodes: {} },
b: { parent: 'a', isCanvas: false, linkedNodes: {} },
};
const q = fakeQuery(nodes);
test('allows a move into another canvas', () => expect(canMove(q, 'b', 'ROOT')).toBe(true));
test('refuses ROOT, self, and descendants', () => {
expect(canMove(q, 'ROOT', 'a')).toBe(false);
expect(canMove(q, 'a', 'a')).toBe(false);
expect(canMove(q, 'sec', 'b')).toBe(false);
expect(canMove(q, 'in', 'b')).toBe(false);
});
test('refuses non-canvas targets and linked slots', () => {
expect(canMove(q, 'b', 'sec')).toBe(false);
expect(canMove(q, 'in', 'ROOT')).toBe(false);
});
test('honours Craft droppable rules', () => {
expect(canMove(fakeQuery(nodes, false), 'b', 'ROOT')).toBe(false);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Pure drag-and-drop helpers for the Layers panel (#47). No React: the panel
* wires HTML5 drag events to these, and tests drive them directly.
*/
export type DropWhere = 'before' | 'after' | 'inside';
export interface DropTarget {
parentId: string;
/** Insertion index in the parent's `nodes` BEFORE the moved node is removed
* (Craft's `move` marks the old slot, inserts, then removes the mark). */
index: number;
where: DropWhere;
}
export interface RowGeometry {
clientY: number;
rect: { top: number; height: number };
}
export interface RowTarget {
id: string;
/** Parent in the node tree, null for ROOT / unplaced rows. */
parentId: string | null;
/** Position within `parentId.nodes`; -1 when the row is not a real child
* (ROOT, a linked slot) - then only "inside" is possible. */
index: number;
isCanvas: boolean;
/** Number of real children (insertion index for "inside" = at end). */
childCount: number;
}
/**
* Map a pointer position over a row to a drop: top third = before, bottom
* third = after, middle of a canvas row = inside (append). On a non-canvas
* row the middle falls to the nearer half. Rows that cannot take siblings
* (ROOT, linked slots) only accept "inside".
*/
export function computeDrop(e: RowGeometry, target: RowTarget): DropTarget | null {
const canSibling = !!target.parentId && target.index >= 0;
const inside: DropTarget | null = target.isCanvas
? { parentId: target.id, index: target.childCount, where: 'inside' }
: null;
const h = e.rect.height;
const ratio = h > 0 ? (e.clientY - e.rect.top) / h : 0.5;
let where: DropWhere;
if (ratio < 1 / 3) where = 'before';
else if (ratio > 2 / 3) where = 'after';
else where = target.isCanvas ? 'inside' : ratio < 0.5 ? 'before' : 'after';
if ((where === 'before' || where === 'after') && !canSibling) where = 'inside';
if (where === 'inside') return inside;
return {
parentId: target.parentId as string,
index: where === 'before' ? target.index : target.index + 1,
where,
};
}
/** True when `maybeDescendant` is `ancestorId` or lives under it. */
export function isSelfOrDescendant(query: any, ancestorId: string, maybeDescendant: string): boolean {
let cur: string | null | undefined = maybeDescendant;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
if (cur === ancestorId) return true;
seen.add(cur);
cur = query.node(cur).get()?.data?.parent;
}
return false;
}
/** Linked nodes (Section inner, Columns slots) are structural: not movable. */
export function isLinkedNode(query: any, id: string): boolean {
const parentId = query.node(id).get()?.data?.parent;
if (!parentId) return false;
const parent = query.node(parentId).get();
return Object.values((parent?.data?.linkedNodes || {}) as Record<string, string>).includes(id);
}
/** Can `nodeId` be moved into `parentId`? Mirrors Craft's own checks. */
export function canMove(query: any, nodeId: string, parentId: string): boolean {
if (!nodeId || !parentId || nodeId === 'ROOT') return false;
const node = query.node(nodeId).get();
const parent = query.node(parentId).get();
if (!node || !parent) return false;
if (!parent.data.isCanvas) return false;
if (isSelfOrDescendant(query, nodeId, parentId)) return false;
if (isLinkedNode(query, nodeId)) return false;
try {
return query.node(parentId).isDroppable(nodeId, () => {}) !== false;
} catch {
return false;
}
}
export interface MoveResult {
ok: boolean;
/** User-facing reason when `ok` is false. */
error?: string;
}
/** Validate then `actions.move`; never throws. */
export function moveLayer(query: any, actions: any, nodeId: string, parentId: string, index: number): MoveResult {
if (nodeId === parentId || isSelfOrDescendant(query, nodeId, parentId)) {
return { ok: false, error: "A layer can't be moved inside itself." };
}
if (!canMove(query, nodeId, parentId)) {
return { ok: false, error: "That layer can't be moved there." };
}
try {
actions.move(nodeId, parentId, index);
return { ok: true };
} catch (err) {
return { ok: false, error: "That layer can't be moved there." };
}
}
+47
View File
@@ -2615,3 +2615,50 @@ body {
border-radius: var(--radius-sm);
}
/* ---- end #39 shortcuts modal ---- */
/* ==== #47 layers: drag reorder, rename, hide/show, collapse ==== */
.layer-node-row .layer-eye-btn {
margin-left: auto;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-text-dim);
font-size: 11px;
cursor: pointer;
opacity: 0.55;
flex-shrink: 0;
}
.layer-node-row:hover .layer-eye-btn,
.layer-node-row .layer-eye-btn:focus-visible,
.layer-node-row.layer-hidden .layer-eye-btn {
opacity: 1;
}
.layer-node-row.layer-hidden > .layer-name,
.layer-node-row.layer-hidden > i.fa {
opacity: 0.45;
text-decoration: line-through;
}
.layer-node-row.layer-dragging {
opacity: 0.4;
}
.layer-node-row[data-drop='before'] {
box-shadow: inset 0 2px 0 var(--color-accent);
}
.layer-node-row[data-drop='after'] {
box-shadow: inset 0 -2px 0 var(--color-accent);
}
.layer-node-row[data-drop='inside'] {
outline: 1px dashed var(--color-accent);
outline-offset: -2px;
background: var(--color-accent-subtle);
}
.layer-rename-input {
flex: 1;
min-width: 0;
font: inherit;
padding: 0 4px;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-accent);
border-radius: 3px;
}