feat(history): add undo redo stack

This commit is contained in:
syntaxbullet
2026-07-03 17:30:31 +02:00
parent fedeaffa57
commit 0ffbc4f68b
12 changed files with 139 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
import type { AppState } from "@editor/state";
import type { AppState, HistorySnapshot } from "@editor/state";
import type { CommandContext } from "./command";
import { commandIds } from "./ids";
import type { CommandId, CommandPayloads } from "./payloads";
import type { CommandRegistry } from "./registry";
@@ -21,10 +22,31 @@ export function createCommandDispatcher(options: {
throw new Error(`Unknown command: ${commandId}`);
}
const context: CommandContext = { state: options.getState() };
const nextState = command.execute(context, payload);
const currentState = options.getState();
const context: CommandContext = { state: currentState };
const executedState = command.execute(context, payload);
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
options.setState(nextState);
return nextState;
},
};
}
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
return currentState.document !== nextState.document;
}
function recordHistory(currentState: AppState, nextState: AppState): AppState {
return {
...nextState,
history: {
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
future: [],
},
};
}
function snapshot(state: AppState): HistorySnapshot {
return { document: state.document, editor: state.editor };
}

29
commands/history.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { documentAddArtboardCommand } from "./document";
import { historyCommands } from "./history";
import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry";
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
describe("history commands", () => {
test("records document changes and undoes/redoes them", () => {
const store = createAppStore(createInitialAppState("Test"), registry);
store.dispatch(commandIds.documentAddArtboard, { id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 } });
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
expect(store.getState().history.past).toHaveLength(1);
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards).toEqual([]);
expect(store.getState().history.future).toHaveLength(1);
store.dispatch(commandIds.historyRedo, undefined);
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
});
});

42
commands/history.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { Command } from "./command";
import { commandIds } from "./ids";
export const historyUndoCommand: Command = {
id: commandIds.historyUndo,
name: "Undo",
execute({ state }) {
const previous = state.history.past.at(-1);
if (!previous) return state;
return {
...state,
document: previous.document,
editor: previous.editor,
history: {
past: state.history.past.slice(0, -1),
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
},
};
},
};
export const historyRedoCommand: Command = {
id: commandIds.historyRedo,
name: "Redo",
execute({ state }) {
const next = state.history.future[0];
if (!next) return state;
return {
...state,
document: next.document,
editor: next.editor,
history: {
past: [...state.history.past, { document: state.document, editor: state.editor }],
future: state.history.future.slice(1),
},
};
},
};
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];

View File

@@ -32,4 +32,6 @@ export const commandIds = {
viewportSetSize: "viewport.setSize",
viewportReset: "viewport.reset",
viewportFitArtboard: "viewport.fitArtboard",
historyUndo: "history.undo",
historyRedo: "history.redo",
} as const;

View File

@@ -38,6 +38,7 @@ export type {
DocumentSetLayerVisiblePayload,
DocumentUngroupLayerPayload,
} from "./document";
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads";
export { createCommandDispatcher } from "./dispatcher";

View File

@@ -63,6 +63,8 @@ export type CommandPayloads = {
[commandIds.viewportSetSize]: ViewportSetSizePayload;
[commandIds.viewportReset]: void;
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
[commandIds.historyUndo]: void;
[commandIds.historyRedo]: void;
};
export type CommandId = keyof CommandPayloads;