feat(site-builder): debounce style inputs and stop Layers recomputing on hover

Text/number/hex inputs keep local state and commit on 150ms idle, blur or
Enter through the continuous setters; native colour pickers get an
onContinuous path; ButtonStylePanel colour routes through the shared
setters. LayersPanel collectors return structural signatures/primitives
so hover and unrelated prop edits no longer rebuild the tree.

Refs #40
This commit is contained in:
shadowdao committed 2026-10-07 11:28:47 -07:00
1 parent 2180f9a776
commit ec071c4c39
7 files changed
+582 -76

No files matched your search

+84 -2
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from 'vitest';
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { LayersPanel } from './LayersPanel';
import { LayersPanel, layerNodeRenderProbe } from './LayersPanel';
import { LayerFocusProvider } from './LayerFocusContext';
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
@@ -100,3 +100,85 @@ describe('LayersPanel', () => {
harness.unmount();
});
});
describe('LayersPanel re-render isolation (#40)', () => {
const heading = (parent: string) => ({
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent,
});
const box = {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
let renders: string[] = [];
afterEach(() => { layerNodeRenderProbe.current = null; });
// Craft's store updates reach React outside `act` here (no
// IS_REACT_ACT_ENVIRONMENT), so let pending renders land before measuring.
const settle = () => new Promise<void>((r) => setTimeout(r, 20));
async function mountPanel() {
const harness = renderEditorHarness({
initialState: stateWith({ h1: heading('ROOT'), h2: heading('ROOT'), box }, ['h1', 'h2', 'box']),
});
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
// Craft's subscription has no initial snapshot, so the FIRST store change
// re-collects (and re-renders) every subscriber once. Burn it first.
harness.act(() => { harness.actions.setProp('h2', (p: any) => { p.text = 'warm-up'; }); });
await settle();
renders = [];
layerNodeRenderProbe.current = (id) => { renders.push(id); };
return harness;
}
test('an unrelated prop edit re-renders no LayerNode', async () => {
const harness = await mountPanel();
harness.act(() => { harness.actions.setProp('h1', (p: any) => { p.style = { color: 'red' }; }); });
harness.act(() => { harness.actions.setProp('h1', (p: any) => { p.text = 'Changed'; }); });
await settle();
expect(renders).toEqual([]);
harness.unmount();
});
test('selecting a node re-renders only the previously and newly selected rows', async () => {
const harness = await mountPanel();
harness.act(() => { harness.actions.selectNode('h1'); });
await settle();
expect(renders.sort()).toEqual(['h1']);
renders.length = 0;
harness.act(() => { harness.actions.selectNode('h2'); });
await settle();
expect(renders.sort()).toEqual(['h1', 'h2']);
// the selected row actually shows as selected
const rows = Array.from(harness.container.querySelectorAll('.layer-node-row')) as HTMLElement[];
expect(rows.filter((r) => r.style.fontWeight === '600')).toHaveLength(1);
harness.unmount();
});
test('a structural move re-renders the affected rows', async () => {
const harness = await mountPanel();
harness.act(() => { harness.actions.move('h1', 'box', 0); });
await settle();
expect(renders).toContain('box');
expect(renders).toContain('ROOT');
expect(renders).not.toContain('h2');
// and the tree reflects it: h1 is now a child of box (depth 1 -> 2 indent)
const rows = Array.from(harness.container.querySelectorAll('.layer-node-row')) as HTMLElement[];
expect(rows).toHaveLength(4);
harness.unmount();
});
test('virtual row labels still update when the array prop changes', async () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
harness.act(() => { harness.actions.setProp('f1', (p: any) => { p.features = [{ title: 'Renamed' }]; }); });
await settle();
const text = Array.from(harness.container.querySelectorAll('.layer-virtual-row')).map((e) => e.textContent).join('|');
expect(text).toContain('Renamed');
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(1);
harness.unmount();
});
});
+49 -23
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
@@ -108,19 +108,39 @@ interface LayerNodeProps {
depth: number;
}
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
/** Test-only seam: set `.current` to observe every LayerNode render. */
export const layerNodeRenderProbe: { current: ((nodeId: string) => void) | null } = { current: null };
/** Comma-joined child ids (real children first, then linked nodes). */
function childIdsOf(data: { nodes?: string[]; linkedNodes?: Record<string, string> }): string {
return [...(data.nodes || []), ...(Object.values(data.linkedNodes || {}) as string[])].join(',');
}
const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
layerNodeRenderProbe.current?.(nodeId);
const { requestFocus } = useLayerFocus();
const { node, selectedId, actions, query } = useEditor((state) => {
// 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 n = state.nodes[nodeId];
if (!n) return { exists: false, displayName: '', 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;
return {
node: n,
selectedId: selId,
exists: true,
displayName: name,
childIdsSig: childIdsOf(n.data),
virtualSig,
isSelected: selId === nodeId,
};
});
const isSelected = selectedId === nodeId;
const allChildren = useMemo(() => (childIdsSig ? childIdsSig.split(',') : []), [childIdsSig]);
const virtualRows = useMemo<{ index: number; label: string }[]>(() => (virtualSig ? JSON.parse(virtualSig) : []), [virtualSig]);
const handleActivate = useCallback(() => {
actions.selectNode(nodeId);
@@ -173,17 +193,12 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
};
}, []);
if (!node) return null;
if (!exists) return null;
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds];
const isRoot = nodeId === 'ROOT';
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
const hasDisclosure = allChildren.length + virtualRows.length > 0;
return (
@@ -288,20 +303,31 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
);
};
const LayerNode = React.memo(LayerNodeImpl);
export const LayersPanel: React.FC = () => {
const { nodeIds, unplacedIds } = useEditor((state) => {
// The collector returns two strings so Craft's shallow-equal check skips
// the re-render for hover / selection / unrelated prop edits. Only a
// structural change (ids, children, linked nodes, hidden) alters `sig`.
const cache = useRef<{ sig: string; unplacedSig: string } | null>(null);
const { sig, unplacedSig } = useEditor((state) => {
const ids = Object.keys(state.nodes);
const sig = ids.map((id) => {
const d = state.nodes[id].data;
return `${id}:${(d.nodes || []).join(',')}|${(Object.values(d.linkedNodes || {}) as string[]).join(',')}|${d.hidden ? 1 : 0}`;
}).join(';');
if (cache.current?.sig === sig) return cache.current;
const serializable: Record<string, any> = {};
for (const [id, n] of Object.entries(state.nodes)) {
serializable[id] = {
nodes: n.data.nodes || [],
linkedNodes: n.data.linkedNodes || {},
};
for (const id of ids) {
const d = state.nodes[id].data;
serializable[id] = { nodes: d.nodes || [], linkedNodes: d.linkedNodes || {} };
}
return {
nodeIds: Object.keys(state.nodes),
unplacedIds: findUnreachableNodeIds(serializable),
};
const next = { sig, unplacedSig: findUnreachableNodeIds(serializable).join(',') };
cache.current = next;
return next;
});
const nodeIds = useMemo(() => sig.split(';').map((e) => e.slice(0, e.indexOf(':'))), [sig]);
const unplacedIds = useMemo(() => (unplacedSig ? unplacedSig.split(',') : []), [unplacedSig]);
const hasRoot = nodeIds.includes('ROOT');
@@ -26,6 +26,7 @@ import {
autoTextColor,
useNodeProp,
continuousSetProp,
discreteSetProp,
} from './shared';
function capitalize(s: string): string {
@@ -57,16 +58,20 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const applyButtonColor = (bgColor: string) => (props: any) => {
props.style = {
...props.style,
backgroundColor: bgColor,
color: autoTextColor(bgColor),
};
};
// Swatch / hex commit: its own undo step. Native picker drag: one burst.
const setButtonColor = useCallback(
(bgColor: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = {
...props.style,
backgroundColor: bgColor,
color: autoTextColor(bgColor),
};
});
},
(bgColor: string) => discreteSetProp(actions, selectedId, applyButtonColor(bgColor)),
[actions, selectedId],
);
const setButtonColorContinuous = useCallback(
(bgColor: string) => continuousSetProp(actions, selectedId, 'buttonColor', applyButtonColor(bgColor)),
[actions, selectedId],
);
@@ -78,6 +83,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
colors={BG_COLORS}
activeValue={style.backgroundColor as string}
onSelect={setButtonColor}
onContinuous={setButtonColorContinuous}
/>
</div>
<TextInputField
@@ -0,0 +1,283 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import {
TextInputField, NumericUnitInput, ColorPickerField, ColorSwatchGrid,
useNodeProp, closeContinuousBurst, COMMIT_IDLE_MS,
} from './shared';
/* Debounced text/number inputs (#40): local mirror, commit on 150ms idle /
blur / Enter, and composition with the continuous undo-burst model. */
let container: HTMLDivElement;
let root: Root;
function mount(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => { root = createRoot(container); root.render(ui); });
}
function rerender(ui: React.ReactElement) { act(() => { root.render(ui); }); }
function unmountDom() { act(() => { root.unmount(); }); container.remove(); }
function setNative(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const type = (el: HTMLInputElement, v: string) => act(() => { setNative(el, v); });
const tick = (ms: number) => act(() => { vi.advanceTimersByTime(ms); });
const blur = (el: Element) => act(() => { el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
const key = (el: Element, k: string) => act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })); });
const textInput = () => container.querySelector('input[type="text"]') as HTMLInputElement;
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { closeContinuousBurst(); vi.useRealTimers(); });
describe('TextInputField debounce', () => {
test('5 keystrokes -> onChange once, after 150ms, with the final value', () => {
const onChange = vi.fn();
mount(<TextInputField label="T" value="" onChange={onChange} />);
for (const v of ['a', 'ab', 'abc', 'abcd', 'abcde']) { type(textInput(), v); tick(40); }
expect(onChange).not.toHaveBeenCalled();
expect(textInput().value).toBe('abcde'); // local mirror updates immediately
tick(COMMIT_IDLE_MS);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('abcde');
unmountDom();
});
test('blur flushes immediately and does not double-fire later', () => {
const onChange = vi.fn();
mount(<TextInputField label="T" value="" onChange={onChange} />);
type(textInput(), 'hi');
blur(textInput());
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('hi');
tick(1000);
expect(onChange).toHaveBeenCalledTimes(1);
unmountDom();
});
test('Enter flushes immediately', () => {
const onChange = vi.fn();
mount(<TextInputField label="T" value="" onChange={onChange} />);
type(textInput(), 'go');
key(textInput(), 'Enter');
expect(onChange).toHaveBeenCalledWith('go');
unmountDom();
});
test('prop updates while idle are reflected in the field', () => {
mount(<TextInputField label="T" value="one" onChange={vi.fn()} />);
expect(textInput().value).toBe('one');
rerender(<TextInputField label="T" value="two" onChange={vi.fn()} />);
expect(textInput().value).toBe('two');
unmountDom();
});
test('unmount flushes the pending value', () => {
const onChange = vi.fn();
mount(<TextInputField label="T" value="" onChange={onChange} />);
type(textInput(), 'bye');
unmountDom();
expect(onChange).toHaveBeenCalledWith('bye');
});
test('selection change (value + handler swap) flushes to the ORIGINAL node, not the new one', () => {
const a = vi.fn();
const b = vi.fn();
mount(<TextInputField label="T" value="A" onChange={a} />);
type(textInput(), 'A!');
rerender(<TextInputField label="T" value="B" onChange={b} />); // same component instance, other node
expect(a).toHaveBeenCalledWith('A!');
expect(b).not.toHaveBeenCalled();
tick(1000);
expect(b).not.toHaveBeenCalled();
expect(textInput().value).toBe('B');
unmountDom();
});
test("commitMode='immediate' commits every keystroke", () => {
const onChange = vi.fn();
mount(<TextInputField label="T" value="" onChange={onChange} commitMode="immediate" />);
type(textInput(), 'a'); type(textInput(), 'ab');
expect(onChange).toHaveBeenCalledTimes(2);
unmountDom();
});
});
describe('NumericUnitInput debounce', () => {
const num = () => container.querySelector('[data-testid="numeric-unit-number"]') as HTMLInputElement;
const unitSel = () => container.querySelector('[data-testid="numeric-unit-unit"]') as HTMLSelectElement;
test('number commits once after idle with the recombined value', () => {
const onChange = vi.fn();
mount(<NumericUnitInput value="16px" onChange={onChange} />);
type(num(), '2'); type(num(), '24'); type(num(), '240');
expect(onChange).not.toHaveBeenCalled();
tick(COMMIT_IDLE_MS);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('240px');
unmountDom();
});
test("clearing still emits '' (after the idle window)", () => {
const onChange = vi.fn();
mount(<NumericUnitInput value="16px" onChange={onChange} />);
type(num(), '');
tick(COMMIT_IDLE_MS);
expect(onChange).toHaveBeenCalledWith('');
unmountDom();
});
test('changing the unit is immediate and folds in the un-committed number (one emit)', () => {
const onChange = vi.fn();
mount(<NumericUnitInput value="16px" onChange={onChange} />);
type(num(), '30');
act(() => {
Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value')!.set!.call(unitSel(), '%');
unitSel().dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('30%');
tick(1000);
expect(onChange).toHaveBeenCalledTimes(1);
unmountDom();
});
test('prop updates while idle are reflected', () => {
mount(<NumericUnitInput value="16px" onChange={vi.fn()} />);
rerender(<NumericUnitInput value="48px" onChange={vi.fn()} />);
expect(num().value).toBe('48');
unmountDom();
});
});
describe('colour inputs', () => {
const color = () => container.querySelector('input[type="color"]') as HTMLInputElement;
test('ColorPickerField: native picker uses onContinuous, hex text uses debounced onChange', () => {
const onChange = vi.fn();
const onContinuous = vi.fn();
mount(<ColorPickerField label="C" value="#000000" onChange={onChange} onContinuous={onContinuous} />);
type(color(), '#111111');
expect(onContinuous).toHaveBeenCalledWith('#111111');
expect(onChange).not.toHaveBeenCalled();
type(textInput(), '#222222');
tick(COMMIT_IDLE_MS);
expect(onChange).toHaveBeenCalledWith('#222222');
expect(onContinuous).toHaveBeenCalledTimes(1);
unmountDom();
});
test('without onContinuous the native picker falls back to onChange / onSelect', () => {
const onChange = vi.fn();
mount(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
type(color(), '#333333');
expect(onChange).toHaveBeenCalledWith('#333333');
unmountDom();
const onSelect = vi.fn();
mount(<ColorSwatchGrid colors={[]} activeValue="#000000" onSelect={onSelect} />);
type(color(), '#444444');
expect(onSelect).toHaveBeenCalledWith('#444444');
unmountDom();
});
test('ColorSwatchGrid: onContinuous gets drag ticks, swatch click stays on onSelect', () => {
const onSelect = vi.fn();
const onContinuous = vi.fn();
mount(<ColorSwatchGrid colors={[{ label: 'Red', value: '#ff0000' }]} activeValue="#000000" onSelect={onSelect} onContinuous={onContinuous} />);
type(color(), '#101010');
expect(onContinuous).toHaveBeenCalledWith('#101010');
act(() => { (container.querySelector('.preset-swatch') as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
expect(onSelect).toHaveBeenCalledWith('#ff0000');
unmountDom();
});
});
/* ---- Composition with the continuous undo-burst model (real Craft history) ---- */
const STATE = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['h-1'], linkedNodes: {} },
'h-1': { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text: 'Hi', level: 'h2', style: {} }, displayName: 'Heading', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {} },
});
describe('debounce + undo burst (real harness)', () => {
let h: EditorHarness;
const props = () => h.query.node('h-1').get().data.props;
// Stand-in for the app's `Editor onNodesChange={markDirty}`: counts how many
// times the node store's serialised state changes while typing.
let dirtyEvents = 0;
const DirtyProbe: React.FC = () => {
const { sig } = useEditor((state) => ({ sig: JSON.stringify(state.nodes['h-1']?.data.props) }));
const last = React.useRef(sig);
React.useEffect(() => { if (last.current !== sig) { last.current = sig; dirtyEvents++; } }, [sig]);
return null;
};
const Probe: React.FC = () => {
const { setPropContinuous } = useNodeProp('h-1');
return <><DirtyProbe /><TextInputField label="Text" value={props().text} onChange={(v) => setPropContinuous('text', v)} /></>;
};
const field = () => h.container.querySelector('input[type="text"]') as HTMLInputElement;
const hType = (v: string) => h.act(() => { setNative(field(), v); });
const hTick = (ms: number) => h.act(() => { vi.advanceTimersByTime(ms); });
function undoDepth() {
let n = 0;
while (h.query.history.canUndo() && n < 50) { h.act(() => { h.actions.history.undo(); }); n++; }
return n;
}
beforeEach(() => {
dirtyEvents = 0;
h = renderEditorHarness({ initialState: STATE });
h.mountChild(<Probe />);
h.act(() => { h.actions.history.clear(); });
});
afterEach(() => { h.unmount(); });
test('a typing session with 150ms+ pauses (several commits) is ONE undo step, dirty fires, text lands', () => {
let t = 'Hi';
for (let i = 0; i < 6; i++) {
t += 'x';
hType(t);
hTick(COMMIT_IDLE_MS + 20); // each pause commits -> several commits inside one 500ms-idle burst
}
expect(props().text).toBe(t); // commits really landed
expect(dirtyEvents).toBeGreaterThanOrEqual(2); // dirty tracking still fires for each commit
expect(undoDepth()).toBe(1);
expect(props().text).toBe('Hi');
});
test('blur right after a keystroke merges into the open burst (no extra undo step)', () => {
hType('Hi1');
hTick(COMMIT_IDLE_MS + 1); // commit #1 opens the burst
hType('Hi12'); // pending, not yet committed
h.act(() => { field().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
expect(props().text).toBe('Hi12');
expect(undoDepth()).toBe(1);
});
});
describe('ButtonStylePanel colour routing (real harness)', () => {
const BTN_STATE = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['b-1'], linkedNodes: {} },
'b-1': { type: { resolvedName: 'ButtonLink' }, isCanvas: false, props: { text: 'Go', href: '', style: { backgroundColor: '#000000' } }, displayName: 'Button', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {} },
});
test('native picker drag = one undo step; swatch click = its own step; text colour follows', async () => {
const { ButtonStylePanel } = await import('./ButtonStylePanel');
const h = renderEditorHarness({ initialState: BTN_STATE });
const style = () => h.query.node('b-1').get().data.props.style;
const Panel: React.FC = () => <ButtonStylePanel selectedId="b-1" nodeProps={h.query.node('b-1').get().data.props} />;
h.mountChild(<Panel />);
h.act(() => { h.actions.history.clear(); });
const color = h.container.querySelector('input[type="color"]') as HTMLInputElement;
for (const c of ['#111111', '#222222', '#ffffff']) { h.act(() => { setNative(color, c); }); h.act(() => { vi.advanceTimersByTime(40); }); }
expect(style().backgroundColor).toBe('#ffffff');
expect(style().color).toBe('#18181b'); // autoTextColor for a light bg
let n = 0;
while (h.query.history.canUndo() && n < 20) { h.act(() => { h.actions.history.undo(); }); n++; }
expect(n).toBe(1);
expect(style().backgroundColor).toBe('#000000');
h.unmount();
});
});
@@ -88,6 +88,7 @@ describe('NumericUnitInput', () => {
const onChange = vi.fn();
render(<NumericUnitInput value="16px" onChange={onChange} />);
setValue(q<HTMLInputElement>('numeric-unit-number')!, '32');
blur(q<HTMLInputElement>('numeric-unit-number')!); // number commits on blur (debounced field)
expect(onChange).toHaveBeenCalledWith('32px');
unmount();
});
@@ -104,6 +105,7 @@ describe('NumericUnitInput', () => {
const onChange = vi.fn();
render(<NumericUnitInput value="16px" onChange={onChange} />);
setValue(q<HTMLInputElement>('numeric-unit-number')!, '');
blur(q<HTMLInputElement>('numeric-unit-number')!); // number commits on blur (debounced field)
expect(onChange).toHaveBeenCalledWith('');
unmount();
});
@@ -131,6 +133,7 @@ describe('SizeControl', () => {
const onChange = vi.fn();
render(<SizeControl label="Width" value="" onChange={onChange} />);
setValue(q<HTMLInputElement>('size-control-custom-number')!, '400');
blur(q<HTMLInputElement>('size-control-custom-number')!); // number commits on blur (debounced field)
expect(onChange).toHaveBeenCalledWith('400px');
unmount();
});
@@ -223,6 +226,7 @@ describe('SpacingControl', () => {
const onChange = vi.fn();
render(<SpacingControl label="Margin" value={{}} onChange={onChange} />);
setValue(q<HTMLInputElement>('spacing-linked-number')!, '16');
blur(q<HTMLInputElement>('spacing-linked-number')!); // number commits on blur (debounced field)
expect(onChange).toHaveBeenCalledWith('top', '16px');
expect(onChange).toHaveBeenCalledWith('right', '16px');
expect(onChange).toHaveBeenCalledWith('bottom', '16px');
@@ -248,6 +252,7 @@ describe('SpacingControl', () => {
render(<SpacingControl label="Margin" value={{ top: '4px', right: '4px', bottom: '4px', left: '4px' }} onChange={onChange} />);
click(q('spacing-link-toggle'));
setValue(q<HTMLInputElement>('spacing-top-number')!, '20');
blur(q<HTMLInputElement>('spacing-top-number')!); // number commits on blur (debounced field)
expect(onChange).toHaveBeenCalledWith('top', '20px');
expect(onChange).toHaveBeenCalledTimes(1);
unmount();
+143 -41
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, CSSProperties } from 'react';
import React, { useState, useCallback, useEffect, useRef, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
GRADIENTS,
@@ -51,21 +51,29 @@ export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
interface ColorSwatchGridProps {
colors: { label: string; value: string }[];
activeValue: string | undefined;
/** Swatch click / hex-text commit: a discrete edit. */
onSelect: (value: string) => void;
/** Native picker drag ticks; wire to a `*Continuous` setter so a drag is one
* undo step. Falls back to `onSelect` when absent. */
onContinuous?: (value: string) => void;
}
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous }) => {
const hex = useDebouncedCommit(activeValue || '', onSelect);
return (
<div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="color"
value={activeValue || '#000000'}
onChange={(e) => onSelect(e.target.value)}
onChange={(e) => (onContinuous || onSelect)(e.target.value)}
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
<input
type="text"
value={activeValue || ''}
onChange={(e) => onSelect(e.target.value)}
value={hex.local}
onChange={(e) => hex.change(e.target.value)}
onBlur={hex.flush}
onKeyDown={hex.onKeyDown}
placeholder="#000000"
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
/>
@@ -82,7 +90,8 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
))}
</div>
</div>
);
);
};
interface PresetButtonGridProps {
presets: { label: string; value: string }[];
@@ -216,51 +225,137 @@ export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeVa
);
};
/* ---------- Debounced text commit ----------
Text/number style inputs keep a LOCAL mirror of the prop and commit it to
the node on a short idle (COMMIT_IDLE_MS), on blur and on Enter -- so
typing doesn't re-render the whole panel + canvas per keystroke. The
commit goes through the caller's `onChange`, which in the panels is a
`*Continuous` setter, so a typing session is still ONE undo step (the
burst's own idle window, CONTINUOUS_EDIT_IDLE_MS, is longer than ours).
The commit closure is captured when the keystroke is scheduled, so a
flush that lands after the selection changed still writes to the node
that was being edited. */
export const COMMIT_IDLE_MS = 150;
export type CommitMode = 'idle' | 'immediate';
function useDebouncedCommit(value: string, commit: (v: string) => void, mode: CommitMode = 'idle') {
const [local, setLocal] = useState(value);
const pending = useRef<{ v: string; commit: (v: string) => void } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCommitted = useRef<string | null>(null);
const detach = useRef(() => {});
const flush = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
detach.current();
const p = pending.current;
pending.current = null;
if (p) { lastCommitted.current = p.v; p.commit(p.v); }
}, []);
// Drop a pending commit without sending it (the caller emits a combined value).
const discard = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
detach.current();
pending.current = null;
}, []);
const change = (v: string) => {
setLocal(v);
if (mode === 'immediate') { lastCommitted.current = v; commit(v); return; }
pending.current = { v, commit };
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(flush, COMMIT_IDLE_MS);
if (typeof window !== 'undefined') {
// The shared burst closes on a document-capture `focusout`; flushing
// from a window-capture listener runs first, so a blur-flush still
// merges into the open burst instead of starting a second undo step.
window.removeEventListener('focusout', flush, true);
window.addEventListener('focusout', flush, true);
detach.current = () => window.removeEventListener('focusout', flush, true);
}
};
// External change (another node selected, undo, ...): commit anything still
// pending to its own node first, then show the new value. Echoes of our own
// commit are ignored so they can't clobber keystrokes typed since.
useEffect(() => {
if (value === lastCommitted.current) return;
lastCommitted.current = null;
flush();
setLocal(value);
}, [value, flush]);
useEffect(() => () => flush(), [flush]);
const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') flush(); };
return { local, change, flush, discard, onKeyDown };
}
interface TextInputFieldProps {
label: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
/** 'idle' (default) debounces; 'immediate' commits every keystroke. */
commitMode?: CommitMode;
}
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
<div className="guided-section">
<SectionLabel>{label}</SectionLabel>
<input
type="text"
className="guided-input"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange, commitMode }) => {
const { local, change, flush, onKeyDown } = useDebouncedCommit(value, onChange, commitMode);
return (
<div className="guided-section">
<SectionLabel>{label}</SectionLabel>
<input
type="text"
className="guided-input"
value={local}
placeholder={placeholder}
onChange={(e) => change(e.target.value)}
onBlur={flush}
onKeyDown={onKeyDown}
/>
</div>
);
};
/* ---------- Color picker with hex input ---------- */
interface ColorPickerFieldProps {
label: string;
value: string;
/** Hex-text commit (debounced). Also the native picker's handler unless
* `onContinuous` is given. */
onChange: (value: string) => void;
/** Native picker drag ticks; wire to a `*Continuous` setter so a drag is one
* undo step. Falls back to `onChange`. */
onContinuous?: (value: string) => void;
}
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
<div style={sectionGap}>
<label style={labelStyle}>{label}</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="color"
value={value || '#000000'}
onChange={(e) => onChange(e.target.value)}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="#000000"
style={{ ...inputStyle, flex: 1 }}
/>
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous }) => {
const hex = useDebouncedCommit(value || '', onChange);
return (
<div style={sectionGap}>
<label style={labelStyle}>{label}</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="color"
value={value || '#000000'}
onChange={(e) => (onContinuous || onChange)(e.target.value)}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
<input
type="text"
value={hex.local}
onChange={(e) => hex.change(e.target.value)}
onBlur={hex.flush}
onKeyDown={hex.onKeyDown}
placeholder="#000000"
style={{ ...inputStyle, flex: 1 }}
/>
</div>
</div>
</div>
);
);
};
/* ---------- Nav-family color fields ----------
The Colors section is shared across the whole nav family (Navbar / Logo /
@@ -482,33 +577,40 @@ export interface NumericUnitInputProps {
/** Base for the data-testid attrs (`${testId}`, `${testId}-number`,
* `${testId}-unit`); default 'numeric-unit'. */
testId?: string;
/** 'idle' (default) debounces the number; 'immediate' commits every keystroke. */
commitMode?: CommitMode;
}
export const NumericUnitInput: React.FC<NumericUnitInputProps> = ({
value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit',
value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit', commitMode,
}) => {
const { num, unit } = parseCssLength(value, units);
const emit = (nextNum: string, nextUnit: string) => {
if (nextNum === '') { onChange(''); return; }
onChange(`${nextNum}${nextUnit}`);
};
// The number is debounced; the unit is captured when the keystroke is
// scheduled. A unit change is a discrete pick: it flushes the number first.
const field = useDebouncedCommit(num, (n) => emit(n, unit), commitMode);
return (
<div style={{ display: 'flex', gap: 4 }} data-testid={testId}>
<input
type="number"
data-testid={`${testId}-number`}
value={num}
value={field.local}
min={min}
max={max}
step={step}
placeholder={placeholder}
onChange={(e) => emit(e.target.value, unit)}
onChange={(e) => field.change(e.target.value)}
onBlur={field.flush}
onKeyDown={field.onKeyDown}
style={{ ...inputStyle, flex: 1 }}
/>
{units.length > 1 && (
<select
data-testid={`${testId}-unit`}
value={unit}
onChange={(e) => emit(num, e.target.value)}
onChange={(e) => { field.discard(); emit(field.local, e.target.value); }}
style={{ ...inputStyle, width: 60, flex: 'none' }}
>
{units.map((u) => <option key={u} value={u}>{u}</option>)}
@@ -1,7 +1,7 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness';
import { useNodeProp, ColorPickerField, TextInputField, closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS } from '../../panels/right/styles/shared';
import { useNodeProp, ColorPickerField, TextInputField, closeContinuousBurst, CONTINUOUS_EDIT_IDLE_MS, COMMIT_IDLE_MS } from '../../panels/right/styles/shared';
import { AnimVisSection } from '../../panels/right/styles/containerBoxModel';
import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel';
@@ -115,6 +115,7 @@ describe('undo granularity (real Craft history)', () => {
mountFresh(<Probe />);
let t = '';
for (let i = 0; i < 30; i++) { t += 'x'; edit(textField(), t); tick(100); }
tick(COMMIT_IDLE_MS); // TextInputField commits on a 150ms idle
expect(props().text).toBe(t);
expect(undoDepth()).toBe(1);
expect(props().text).toBe('Hi');
@@ -163,6 +164,7 @@ describe('undo granularity (real Craft history)', () => {
edit(color(), '#121212');
edit(textField(), 'abc');
edit(textField(), 'abcd');
tick(COMMIT_IDLE_MS);
expect(undoDepth()).toBe(2);
});