diff --git a/craft/src/panels/left/LayersPanel.test.tsx b/craft/src/panels/left/LayersPanel.test.tsx index f0eec8d..55a45b1 100644 --- a/craft/src/panels/left/LayersPanel.test.tsx +++ b/craft/src/panels/left/LayersPanel.test.tsx @@ -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, 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((r) => setTimeout(r, 20)); + + async function mountPanel() { + const harness = renderEditorHarness({ + initialState: stateWith({ h1: heading('ROOT'), h2: heading('ROOT'), box }, ['h1', 'h2', 'box']), + }); + harness.mountChild(); + // 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(); + 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(); + }); +}); diff --git a/craft/src/panels/left/LayersPanel.tsx b/craft/src/panels/left/LayersPanel.tsx index 020044b..69882a5 100644 --- a/craft/src/panels/left/LayersPanel.tsx +++ b/craft/src/panels/left/LayersPanel.tsx @@ -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 = ({ 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 { + return [...(data.nodes || []), ...(Object.values(data.linkedNodes || {}) as string[])].join(','); +} + +const LayerNodeImpl: React.FC = ({ 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 = ({ 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 = ({ 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 = {}; - 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'); diff --git a/craft/src/panels/right/styles/ButtonStylePanel.tsx b/craft/src/panels/right/styles/ButtonStylePanel.tsx index 3deca91..73a3db4 100644 --- a/craft/src/panels/right/styles/ButtonStylePanel.tsx +++ b/craft/src/panels/right/styles/ButtonStylePanel.tsx @@ -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 = ({ 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 = ({ selectedId, nodePr colors={BG_COLORS} activeValue={style.backgroundColor as string} onSelect={setButtonColor} + onContinuous={setButtonColorContinuous} /> { 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(); + 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(); + 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(); + type(textInput(), 'go'); + key(textInput(), 'Enter'); + expect(onChange).toHaveBeenCalledWith('go'); + unmountDom(); + }); + + test('prop updates while idle are reflected in the field', () => { + mount(); + expect(textInput().value).toBe('one'); + rerender(); + expect(textInput().value).toBe('two'); + unmountDom(); + }); + + test('unmount flushes the pending value', () => { + const onChange = vi.fn(); + mount(); + 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(); + type(textInput(), 'A!'); + rerender(); // 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(); + 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(); + 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(); + 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(); + 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(); + rerender(); + 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(); + 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(); + type(color(), '#333333'); + expect(onChange).toHaveBeenCalledWith('#333333'); + unmountDom(); + const onSelect = vi.fn(); + mount(); + 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(); + 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 <> 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(); + 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 = () => ; + h.mountChild(); + 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(); + }); +}); diff --git a/craft/src/panels/right/styles/shared-controls.test.tsx b/craft/src/panels/right/styles/shared-controls.test.tsx index d49adbc..25b266b 100644 --- a/craft/src/panels/right/styles/shared-controls.test.tsx +++ b/craft/src/panels/right/styles/shared-controls.test.tsx @@ -88,6 +88,7 @@ describe('NumericUnitInput', () => { const onChange = vi.fn(); render(); setValue(q('numeric-unit-number')!, '32'); + blur(q('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(); setValue(q('numeric-unit-number')!, ''); + blur(q('numeric-unit-number')!); // number commits on blur (debounced field) expect(onChange).toHaveBeenCalledWith(''); unmount(); }); @@ -131,6 +133,7 @@ describe('SizeControl', () => { const onChange = vi.fn(); render(); setValue(q('size-control-custom-number')!, '400'); + blur(q('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(); setValue(q('spacing-linked-number')!, '16'); + blur(q('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(); click(q('spacing-link-toggle')); setValue(q('spacing-top-number')!, '20'); + blur(q('spacing-top-number')!); // number commits on blur (debounced field) expect(onChange).toHaveBeenCalledWith('top', '20px'); expect(onChange).toHaveBeenCalledTimes(1); unmount(); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 2ea2fec..b9230f2 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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 = ({ 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 = ({ colors, activeValue, onSelect }) => ( +export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous }) => { + const hex = useDebouncedCommit(activeValue || '', onSelect); + return (
onSelect(e.target.value)} + onChange={(e) => (onContinuous || onSelect)(e.target.value)} style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> 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 = ({ colors, active ))}
-); + ); +}; interface PresetButtonGridProps { presets: { label: string; value: string }[]; @@ -216,51 +225,137 @@ export const GradientSwatchGrid: React.FC = ({ 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 | null>(null); + const lastCommitted = useRef(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 = ({ label, value, placeholder, onChange }) => ( -
- {label} - onChange(e.target.value)} - /> -
-); +export const TextInputField: React.FC = ({ label, value, placeholder, onChange, commitMode }) => { + const { local, change, flush, onKeyDown } = useDebouncedCommit(value, onChange, commitMode); + return ( +
+ {label} + change(e.target.value)} + onBlur={flush} + onKeyDown={onKeyDown} + /> +
+ ); +}; /* ---------- 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 = ({ label, value, onChange }) => ( -
- -
- onChange(e.target.value)} - style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} - /> - onChange(e.target.value)} - placeholder="#000000" - style={{ ...inputStyle, flex: 1 }} - /> +export const ColorPickerField: React.FC = ({ label, value, onChange, onContinuous }) => { + const hex = useDebouncedCommit(value || '', onChange); + return ( +
+ +
+ (onContinuous || onChange)(e.target.value)} + style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} + /> + hex.change(e.target.value)} + onBlur={hex.flush} + onKeyDown={hex.onKeyDown} + placeholder="#000000" + style={{ ...inputStyle, flex: 1 }} + /> +
-
-); + ); +}; /* ---------- 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 = ({ - 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 (
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 && (