commit
68a3734eaf
6 files changed
+646
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }}>
|
||||
▼
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user