perf(history): batch transform drag undo

This commit is contained in:
syntaxbullet
2026-07-04 15:49:13 +02:00
parent a030f2065a
commit 1acfcbe4a5
5 changed files with 159 additions and 8 deletions

View File

@@ -4,8 +4,14 @@ export type CommandContext = {
state: AppState; state: AppState;
}; };
export type CommandHistoryPolicy =
| { mode: "auto" }
| { mode: "ignore" }
| { mode: "deferred"; phase: "begin" | "update" | "commit" };
export type Command<TPayload = void> = { export type Command<TPayload = void> = {
id: string; id: string;
name: string; name: string;
history?: CommandHistoryPolicy;
execute(context: CommandContext, payload: TPayload): AppState; execute(context: CommandContext, payload: TPayload): AppState;
}; };

View File

@@ -1,6 +1,5 @@
import type { AppState, HistorySnapshot } from "@editor/state"; import type { AppState, HistorySnapshot } from "@editor/state";
import type { CommandContext } from "./command"; import type { CommandContext, CommandHistoryPolicy } from "./command";
import { commandIds } from "./ids";
import type { CommandId, CommandPayloads } from "./payloads"; import type { CommandId, CommandPayloads } from "./payloads";
import type { CommandRegistry } from "./registry"; import type { CommandRegistry } from "./registry";
@@ -15,6 +14,8 @@ export function createCommandDispatcher(options: {
getState: () => AppState; getState: () => AppState;
setState: (state: AppState) => void; setState: (state: AppState) => void;
}): CommandDispatcher { }): CommandDispatcher {
let deferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined;
return { return {
dispatch(commandId, payload) { dispatch(commandId, payload) {
const command = options.registry.get(commandId); const command = options.registry.get(commandId);
@@ -23,28 +24,89 @@ export function createCommandDispatcher(options: {
} }
const currentState = options.getState(); const currentState = options.getState();
const historyPolicy = command.history ?? defaultHistoryPolicy;
const deferredSnapshot = historyPolicy.mode === "deferred" && historyPolicy.phase === "begin" ? snapshot(currentState) : undefined;
const context: CommandContext = { state: currentState }; const context: CommandContext = { state: currentState };
const executedState = command.execute(context, payload); const executedState = command.execute(context, payload);
if (executedState === currentState) { if (executedState === currentState) {
if (historyPolicy.mode === "deferred" && historyPolicy.phase === "commit") deferredHistory = undefined;
return currentState; return currentState;
} }
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
const nextState = applyHistoryPolicy({
currentState,
nextState: executedState,
historyPolicy,
deferredSnapshot,
getDeferredHistory: () => deferredHistory,
setDeferredHistory: (nextDeferredHistory) => {
deferredHistory = nextDeferredHistory;
},
});
options.setState(nextState); options.setState(nextState);
return nextState; return nextState;
}, },
}; };
} }
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) { const defaultHistoryPolicy: CommandHistoryPolicy = { mode: "auto" };
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
function applyHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: CommandHistoryPolicy;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
if (options.historyPolicy.mode === "ignore") {
options.setDeferredHistory(undefined);
return options.nextState;
}
if (options.historyPolicy.mode === "deferred") {
return applyDeferredHistoryPolicy(options);
}
return shouldRecordHistory(options.currentState, options.nextState) ? recordHistory(snapshot(options.currentState), options.nextState) : options.nextState;
}
function applyDeferredHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: Extract<CommandHistoryPolicy, { mode: "deferred" }>;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
switch (options.historyPolicy.phase) {
case "begin":
options.setDeferredHistory(options.deferredSnapshot ? { snapshot: options.deferredSnapshot, changed: false } : undefined);
return options.nextState;
case "update": {
const deferredHistory = options.getDeferredHistory();
if (deferredHistory && options.currentState.document !== options.nextState.document) {
options.setDeferredHistory({ ...deferredHistory, changed: true });
}
return options.nextState;
}
case "commit": {
const deferredHistory = options.getDeferredHistory();
options.setDeferredHistory(undefined);
return deferredHistory?.changed ? recordHistory(deferredHistory.snapshot, options.nextState) : options.nextState;
}
}
}
function shouldRecordHistory(currentState: AppState, nextState: AppState) {
return currentState.document !== nextState.document; return currentState.document !== nextState.document;
} }
function recordHistory(currentState: AppState, nextState: AppState): AppState { function recordHistory(historySnapshot: HistorySnapshot, nextState: AppState): AppState {
return { return {
...nextState, ...nextState,
history: { history: {
past: [...currentState.history.past, snapshot(currentState)].slice(-100), past: [...nextState.history.past, historySnapshot].slice(-100),
future: [], future: [],
}, },
}; };

View File

@@ -5,8 +5,9 @@ import { documentAddArtboardCommand } from "./document";
import { historyCommands } from "./history"; import { historyCommands } from "./history";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry"; import { createCommandRegistry } from "./registry";
import { transformCommands } from "./transform";
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]); const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
describe("history commands", () => { describe("history commands", () => {
test("records document changes and undoes/redoes them", () => { test("records document changes and undoes/redoes them", () => {
@@ -26,4 +27,81 @@ describe("history commands", () => {
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]); expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
}); });
test("records one history entry for a transform drag", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
}); });
store.dispatch(commandIds.transformUpdate, { point: { x: 5, y: 10 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 15, y: 25 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 15, y: 25, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.past).toHaveLength(1);
expect(store.getState().history.past[0]?.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past[0]?.editor.transformSession).toBeUndefined();
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.future).toHaveLength(1);
});
test("keeps direct transform bounds edits normally undoable", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformSetBounds, { target: { type: "artboard", id: "a1" }, bounds: { x: 12, y: 24, w: 120, h: 90 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 12, y: 24, w: 120, h: 90 });
expect(store.getState().history.past).toHaveLength(1);
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
});
test("does not record history for transform update or end without a session", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
expect(store.getState().history.future).toHaveLength(0);
});
test("does not record history for a transform session with no document update", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
});
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
});
});
function artboardState() {
return documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 } },
);
}

View File

@@ -4,6 +4,7 @@ import { commandIds } from "./ids";
export const historyUndoCommand: Command = { export const historyUndoCommand: Command = {
id: commandIds.historyUndo, id: commandIds.historyUndo,
name: "Undo", name: "Undo",
history: { mode: "ignore" },
execute({ state }) { execute({ state }) {
const previous = state.history.past.at(-1); const previous = state.history.past.at(-1);
if (!previous) return state; if (!previous) return state;
@@ -23,6 +24,7 @@ export const historyUndoCommand: Command = {
export const historyRedoCommand: Command = { export const historyRedoCommand: Command = {
id: commandIds.historyRedo, id: commandIds.historyRedo,
name: "Redo", name: "Redo",
history: { mode: "ignore" },
execute({ state }) { execute({ state }) {
const next = state.history.future[0]; const next = state.history.future[0];
if (!next) return state; if (!next) return state;

View File

@@ -24,6 +24,7 @@ export type TransformSetBoundsPayload = {
export const transformBeginCommand: Command<TransformBeginPayload> = { export const transformBeginCommand: Command<TransformBeginPayload> = {
id: commandIds.transformBegin, id: commandIds.transformBegin,
name: "Begin transform", name: "Begin transform",
history: { mode: "deferred", phase: "begin" },
execute({ state }, payload) { execute({ state }, payload) {
return { return {
...state, ...state,
@@ -43,6 +44,7 @@ export const transformBeginCommand: Command<TransformBeginPayload> = {
export const transformUpdateCommand: Command<TransformUpdatePayload> = { export const transformUpdateCommand: Command<TransformUpdatePayload> = {
id: commandIds.transformUpdate, id: commandIds.transformUpdate,
name: "Update transform", name: "Update transform",
history: { mode: "deferred", phase: "update" },
execute({ state }, payload) { execute({ state }, payload) {
const session = state.editor.transformSession; const session = state.editor.transformSession;
if (!session) return state; if (!session) return state;
@@ -78,6 +80,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
export const transformEndCommand: Command = { export const transformEndCommand: Command = {
id: commandIds.transformEnd, id: commandIds.transformEnd,
name: "End transform", name: "End transform",
history: { mode: "deferred", phase: "commit" },
execute({ state }) { execute({ state }) {
if (!state.editor.transformSession) return state; if (!state.editor.transformSession) return state;