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:
1 parent
2180f9a776
commit
ec071c4c39
7 files changed
+582
-76
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user