feat(history): add undo redo stack
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { documentCommands } from "@commands/document";
|
import { documentCommands } from "@commands/document";
|
||||||
|
import { historyCommands } from "@commands/history";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { createCommandRegistry } from "@commands/registry";
|
import { createCommandRegistry } from "@commands/registry";
|
||||||
import { selectionCommands } from "@commands/selection";
|
import { selectionCommands } from "@commands/selection";
|
||||||
@@ -11,7 +12,7 @@ import { createAppStore } from "@editor/store";
|
|||||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||||
|
|
||||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
||||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands]);
|
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands, ...historyCommands]);
|
||||||
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||||
|
|
||||||
if (options?.createDefaultArtboard !== false) {
|
if (options?.createDefaultArtboard !== false) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AppState } from "@editor/state";
|
import type { AppState, HistorySnapshot } from "@editor/state";
|
||||||
import type { CommandContext } from "./command";
|
import type { CommandContext } 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";
|
||||||
|
|
||||||
@@ -21,10 +22,31 @@ export function createCommandDispatcher(options: {
|
|||||||
throw new Error(`Unknown command: ${commandId}`);
|
throw new Error(`Unknown command: ${commandId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const context: CommandContext = { state: options.getState() };
|
const currentState = options.getState();
|
||||||
const nextState = command.execute(context, payload);
|
const context: CommandContext = { state: currentState };
|
||||||
|
const executedState = command.execute(context, payload);
|
||||||
|
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
|
||||||
options.setState(nextState);
|
options.setState(nextState);
|
||||||
return 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
29
commands/history.test.ts
Normal 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
42
commands/history.ts
Normal 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>[];
|
||||||
@@ -32,4 +32,6 @@ export const commandIds = {
|
|||||||
viewportSetSize: "viewport.setSize",
|
viewportSetSize: "viewport.setSize",
|
||||||
viewportReset: "viewport.reset",
|
viewportReset: "viewport.reset",
|
||||||
viewportFitArtboard: "viewport.fitArtboard",
|
viewportFitArtboard: "viewport.fitArtboard",
|
||||||
|
historyUndo: "history.undo",
|
||||||
|
historyRedo: "history.redo",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type {
|
|||||||
DocumentSetLayerVisiblePayload,
|
DocumentSetLayerVisiblePayload,
|
||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
|
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
|
||||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||||
export type { CommandId, CommandPayloads } from "./payloads";
|
export type { CommandId, CommandPayloads } from "./payloads";
|
||||||
export { createCommandDispatcher } from "./dispatcher";
|
export { createCommandDispatcher } from "./dispatcher";
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export type CommandPayloads = {
|
|||||||
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
||||||
[commandIds.viewportReset]: void;
|
[commandIds.viewportReset]: void;
|
||||||
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
||||||
|
[commandIds.historyUndo]: void;
|
||||||
|
[commandIds.historyRedo]: void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandId = keyof CommandPayloads;
|
export type CommandId = keyof CommandPayloads;
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ export function createInitialAppState(name = "Untitled"): AppState {
|
|||||||
assets: [],
|
assets: [],
|
||||||
},
|
},
|
||||||
editor: initialEditorState,
|
editor: initialEditorState,
|
||||||
|
history: { past: [], future: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,18 @@ export type EditorState = {
|
|||||||
transformSession?: TransformSession;
|
transformSession?: TransformSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type HistorySnapshot = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
editor: EditorState;
|
editor: EditorState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HistoryState = {
|
||||||
|
past: HistorySnapshot[];
|
||||||
|
future: HistorySnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppState = {
|
||||||
|
document: ImageDocument;
|
||||||
|
editor: EditorState;
|
||||||
|
history: HistoryState;
|
||||||
|
};
|
||||||
|
|||||||
12
input/history.ts
Normal file
12
input/history.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
|
export function handleHistoryKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
|
||||||
|
if (options.event.altKey) return false;
|
||||||
|
const modifier = options.event.metaKey || options.event.ctrlKey;
|
||||||
|
if (!modifier || options.event.key.toLowerCase() !== "z") return false;
|
||||||
|
|
||||||
|
options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
} from "./dom";
|
} from "./dom";
|
||||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||||
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
||||||
|
export { handleHistoryKey } from "./history";
|
||||||
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
||||||
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
|
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
|
||||||
export { handleArtboardSelection } from "./selection";
|
export { handleArtboardSelection } from "./selection";
|
||||||
|
|||||||
12
view/App.tsx
12
view/App.tsx
@@ -6,7 +6,7 @@ import { LayersSheet } from "./LayersSheet";
|
|||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { labelForTool } from "./toolLabels";
|
import { labelForTool } from "./toolLabels";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import { handleDeleteSelectionKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
@@ -31,7 +31,15 @@ export function App({ app }: AppProps) {
|
|||||||
target instanceof HTMLElement &&
|
target instanceof HTMLElement &&
|
||||||
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement);
|
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement);
|
||||||
|
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || editableTarget) return;
|
if (editableTarget) return;
|
||||||
|
|
||||||
|
const historyConsumed = handleHistoryKey({ event: keybindEventFromKeyboardEvent(event), dispatch: app.store.dispatch });
|
||||||
|
if (historyConsumed) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
|
||||||
if (event.key.toLowerCase() === "l") {
|
if (event.key.toLowerCase() === "l") {
|
||||||
setLayersOpen(true);
|
setLayersOpen(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user