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
199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
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, COMMIT_IDLE_MS } 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(); });
|
|
afterEach(() => {
|
|
closeContinuousBurst();
|
|
vi.useRealTimers();
|
|
harness?.unmount();
|
|
harness = null;
|
|
});
|
|
|
|
const props = () => harness!.query.node('h-1').get().data.props;
|
|
|
|
/** How many undos until nothing is left (history is cleared after mount). */
|
|
function undoDepth(): number {
|
|
let n = 0;
|
|
while (harness!.query.history.canUndo() && n < 100) {
|
|
harness!.act(() => { harness!.actions.history.undo(); });
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function setNative(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
|
const proto = input instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(input, value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
const tick = (ms: number) => harness!.act(() => { vi.advanceTimersByTime(ms); });
|
|
const edit = (el: HTMLInputElement, v: string) => harness!.act(() => { setNative(el, v); });
|
|
|
|
function mountFresh(ui: React.ReactNode) {
|
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
|
harness.mountChild(ui);
|
|
harness.act(() => { harness!.actions.history.clear(); });
|
|
}
|
|
|
|
const Probe: React.FC = () => {
|
|
const { setPropStyleContinuous, setPropContinuous, setPropStyle, setPropStyles } = useNodeProp('h-1');
|
|
const st = props().style;
|
|
return (
|
|
<div>
|
|
<ColorPickerField label="Color" value={st.color} onChange={(v) => setPropStyleContinuous('color', v)} />
|
|
<input data-testid="slider" type="range" min={0} max={100} value={50}
|
|
onChange={(e) => setPropStyleContinuous('opacity', e.target.value)} />
|
|
<TextInputField label="Text" value={props().text} onChange={(v) => setPropContinuous('text', v)} />
|
|
<button data-testid="preset" onClick={() => setPropStyle('fontWeight', '700')}>preset</button>
|
|
<button data-testid="ratio" onClick={() => setPropStyles({ aspectRatio: '16/9', objectFit: 'cover', height: '' })}>ratio</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 })); });
|
|
const color = () => harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
|
|
const textField = () => Array.from(harness!.container.querySelectorAll('input[type="text"]')).pop() as HTMLInputElement; // last: ColorPickerField has its own hex text input
|
|
const hex = (i: number) => `#00${(i % 256).toString(16).padStart(2, '0')}00`;
|
|
|
|
describe('undo granularity (real Craft history)', () => {
|
|
test('a 2-second colour drag (50ms ticks) is one undo step restoring the original', () => {
|
|
mountFresh(<Probe />);
|
|
for (let i = 1; i <= 40; i++) { edit(color(), hex(i)); tick(50); }
|
|
expect(props().style.color).toBe(hex(40));
|
|
expect(undoDepth()).toBe(1);
|
|
expect(props().style.color).toBe('#000000');
|
|
});
|
|
|
|
test('typing 30 characters at 100ms intervals is one undo step', () => {
|
|
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');
|
|
});
|
|
|
|
test('a slider drag is one undo step', () => {
|
|
mountFresh(<Probe />);
|
|
const slider = q('slider') as HTMLInputElement;
|
|
for (let i = 1; i <= 30; i++) { edit(slider, String(i)); tick(40); }
|
|
expect(undoDepth()).toBe(1);
|
|
expect(props().style.opacity).toBeUndefined();
|
|
});
|
|
|
|
test('pointerup closes the burst even when the next edit follows immediately', () => {
|
|
mountFresh(<Probe />);
|
|
edit(color(), '#111111');
|
|
edit(color(), '#222222');
|
|
harness!.act(() => { document.dispatchEvent(new Event('pointerup', { bubbles: true })); });
|
|
edit(color(), '#333333');
|
|
expect(undoDepth()).toBe(2);
|
|
});
|
|
|
|
test('after the idle window the same control starts a new step', () => {
|
|
mountFresh(<Probe />);
|
|
edit(color(), '#111111');
|
|
tick(CONTINUOUS_EDIT_IDLE_MS + 1);
|
|
edit(color(), '#222222');
|
|
expect(undoDepth()).toBe(2);
|
|
});
|
|
|
|
test('a discrete preset click 100ms after a colour drag is its own step', () => {
|
|
mountFresh(<Probe />);
|
|
for (let i = 1; i <= 10; i++) { edit(color(), hex(i)); tick(20); }
|
|
tick(100);
|
|
clickEl(q('preset'));
|
|
expect(props().style.fontWeight).toBe('700');
|
|
harness!.act(() => { harness!.actions.history.undo(); });
|
|
expect(props().style.fontWeight).toBeUndefined();
|
|
expect(props().style.color).toBe(hex(10)); // drag still intact
|
|
expect(undoDepth()).toBe(1);
|
|
});
|
|
|
|
test('two different controls edited back-to-back are two steps', () => {
|
|
mountFresh(<Probe />);
|
|
edit(color(), '#111111');
|
|
edit(color(), '#121212');
|
|
edit(textField(), 'abc');
|
|
edit(textField(), 'abcd');
|
|
tick(COMMIT_IDLE_MS);
|
|
expect(undoDepth()).toBe(2);
|
|
});
|
|
|
|
test('applying an aspect ratio (3 style keys) is one undo step', () => {
|
|
mountFresh(<Probe />);
|
|
clickEl(q('ratio'));
|
|
expect(props().style.aspectRatio).toBe('16/9');
|
|
expect(undoDepth()).toBe(1);
|
|
expect(props().style.aspectRatio).toBeUndefined();
|
|
});
|
|
|
|
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(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();
|
|
});
|
|
});
|