fix(site-builder): one undo step per edit (throttle continuous inputs, single setProp for multi-prop toggles)

Refs #32

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 09:56:12 -07:00
1 parent 9ab01021be
commit ac490215ce
20 files changed
+209 -77

No files matched your search

@@ -0,0 +1,131 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness';
import { useNodeProp, ColorPickerField } from '../../panels/right/styles/shared';
import { AnimVisSection } from '../../panels/right/styles/containerBoxModel';
import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel';
/**
* Undo granularity against a REAL Craft.js history: one user edit = one undo
* step. Uses the real style-panel controls (ColorPickerField, AnimVisSection)
* wired through the real `useNodeProp`, so a regression to per-tick or
* per-prop `setProp` calls shows up as extra undo steps here.
*/
const INITIAL_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: { color: '#000000' } },
displayName: 'Heading',
custom: {},
hidden: false,
parent: 'ROOT',
nodes: [],
linkedNodes: {},
},
});
let harness: EditorHarness | null = null;
beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(1_000_000); });
afterEach(() => {
vi.useRealTimers();
harness?.unmount();
harness = null;
});
const props = () => harness!.query.node('h-1').get().data.props;
/** How many undos until nothing is left (ignores any pre-existing history). */
function undoDepth(): number {
let n = 0;
while (harness!.query.history.canUndo() && n < 50) {
harness!.act(() => { harness!.actions.history.undo(); });
n++;
}
return n;
}
function setNative(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function mountFresh(ui: React.ReactNode) {
harness = renderEditorHarness({ initialState: INITIAL_STATE });
harness.mountChild(ui);
harness.act(() => { harness!.actions.history.clear(); });
vi.setSystemTime(2_000_000); // well past the throttle window of any earlier edit
}
const ColorProbe: React.FC = () => {
const { setPropStyle } = useNodeProp('h-1');
return <ColorPickerField label="Color" value={props().style.color} onChange={(v) => setPropStyle('color', v)} />;
};
const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => {
const { setProps } = useNodeProp('h-1');
const Section = media ? MediaAnimVisSection : AnimVisSection;
return <Section nodeProps={props()} setProps={setProps} />;
};
function openSection() {
const toggle = Array.from(harness!.container.querySelectorAll('button')).find((b) => /Animation & Visibility/.test(b.textContent || ''));
harness!.act(() => { toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
const q = (id: string) => harness!.container.querySelector(`[data-testid="${id}"]`) as HTMLElement;
const clickEl = (el: Element) => harness!.act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
describe('undo granularity (real Craft history)', () => {
test('a drag of 10 color changes is one undo step restoring the original', () => {
mountFresh(<ColorProbe />);
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
for (let i = 1; i <= 10; i++) {
harness!.act(() => { setNative(colorInput, `#00${i.toString(16).padStart(2, '0')}00`); });
vi.setSystemTime(2_000_000 + i * 20); // ~20ms ticks, all inside the 500ms window
}
expect(props().style.color).toBe('#000a00');
expect(undoDepth()).toBe(1);
expect(props().style.color).toBe('#000000');
});
test('edits separated by more than the throttle window stay separate steps', () => {
mountFresh(<ColorProbe />);
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
harness!.act(() => { setNative(colorInput, '#111111'); });
vi.setSystemTime(2_001_000);
harness!.act(() => { setNative(colorInput, '#222222'); });
expect(undoDepth()).toBe(2);
});
test.each([false, true])('visibility toggle is one undo step (media=%s)', (media) => {
mountFresh(<AnimVisProbe media={media} />);
openSection();
clickEl(q('visibility-hideOnDesktop'));
expect(props().hideOnDesktop).toBe(true);
expect(props().hideOnTablet).toBe(false);
expect(undoDepth()).toBe(1);
expect(props().hideOnDesktop).toBeFalsy();
});
test.each([false, true])('animation change is one undo step (media=%s)', (media) => {
mountFresh(<AnimVisProbe media={media} />);
openSection();
const btn = Array.from(harness!.container.querySelectorAll('[data-testid="animation-control"] .preset-btn'))
.find((b) => /fade/i.test(b.textContent || ''))!;
clickEl(btn);
expect(props().animation).not.toBe('none');
expect(undoDepth()).toBe(1);
expect(props().animation).toBeFalsy();
});
});