perf(history): batch transform drag undo
This commit is contained in:
@@ -4,8 +4,14 @@ export type CommandContext = {
|
||||
state: AppState;
|
||||
};
|
||||
|
||||
export type CommandHistoryPolicy =
|
||||
| { mode: "auto" }
|
||||
| { mode: "ignore" }
|
||||
| { mode: "deferred"; phase: "begin" | "update" | "commit" };
|
||||
|
||||
export type Command<TPayload = void> = {
|
||||
id: string;
|
||||
name: string;
|
||||
history?: CommandHistoryPolicy;
|
||||
execute(context: CommandContext, payload: TPayload): AppState;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppState, HistorySnapshot } from "@editor/state";
|
||||
import type { CommandContext } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
import type { CommandContext, CommandHistoryPolicy } from "./command";
|
||||
import type { CommandId, CommandPayloads } from "./payloads";
|
||||
import type { CommandRegistry } from "./registry";
|
||||
|
||||
@@ -15,6 +14,8 @@ export function createCommandDispatcher(options: {
|
||||
getState: () => AppState;
|
||||
setState: (state: AppState) => void;
|
||||
}): CommandDispatcher {
|
||||
let deferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined;
|
||||
|
||||
return {
|
||||
dispatch(commandId, payload) {
|
||||
const command = options.registry.get(commandId);
|
||||
@@ -23,28 +24,89 @@ export function createCommandDispatcher(options: {
|
||||
}
|
||||
|
||||
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 executedState = command.execute(context, payload);
|
||||
if (executedState === currentState) {
|
||||
if (historyPolicy.mode === "deferred" && historyPolicy.phase === "commit") deferredHistory = undefined;
|
||||
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);
|
||||
return nextState;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
|
||||
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
|
||||
const defaultHistoryPolicy: CommandHistoryPolicy = { mode: "auto" };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function recordHistory(currentState: AppState, nextState: AppState): AppState {
|
||||
function recordHistory(historySnapshot: HistorySnapshot, nextState: AppState): AppState {
|
||||
return {
|
||||
...nextState,
|
||||
history: {
|
||||
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
|
||||
past: [...nextState.history.past, historySnapshot].slice(-100),
|
||||
future: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import { documentAddArtboardCommand } from "./document";
|
||||
import { historyCommands } from "./history";
|
||||
import { commandIds } from "./ids";
|
||||
import { createCommandRegistry } from "./registry";
|
||||
import { transformCommands } from "./transform";
|
||||
|
||||
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
|
||||
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
|
||||
|
||||
describe("history commands", () => {
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { commandIds } from "./ids";
|
||||
export const historyUndoCommand: Command = {
|
||||
id: commandIds.historyUndo,
|
||||
name: "Undo",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }) {
|
||||
const previous = state.history.past.at(-1);
|
||||
if (!previous) return state;
|
||||
@@ -23,6 +24,7 @@ export const historyUndoCommand: Command = {
|
||||
export const historyRedoCommand: Command = {
|
||||
id: commandIds.historyRedo,
|
||||
name: "Redo",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }) {
|
||||
const next = state.history.future[0];
|
||||
if (!next) return state;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TransformSetBoundsPayload = {
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
history: { mode: "deferred", phase: "begin" },
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
@@ -43,6 +44,7 @@ export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
||||
id: commandIds.transformUpdate,
|
||||
name: "Update transform",
|
||||
history: { mode: "deferred", phase: "update" },
|
||||
execute({ state }, payload) {
|
||||
const session = state.editor.transformSession;
|
||||
if (!session) return state;
|
||||
@@ -78,6 +80,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
history: { mode: "deferred", phase: "commit" },
|
||||
execute({ state }) {
|
||||
if (!state.editor.transformSession) return state;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user