diff --git a/app/app.ts b/app/app.ts index 18e0e2b..6c67aab 100644 --- a/app/app.ts +++ b/app/app.ts @@ -1,4 +1,5 @@ import { documentCommands } from "@commands/document"; +import { historyCommands } from "@commands/history"; import { commandIds } from "@commands/ids"; import { createCommandRegistry } from "@commands/registry"; import { selectionCommands } from "@commands/selection"; @@ -11,7 +12,7 @@ import { createAppStore } from "@editor/store"; export type ImageStudioApp = ReturnType; 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); if (options?.createDefaultArtboard !== false) { diff --git a/commands/dispatcher.ts b/commands/dispatcher.ts index 1ce9a61..8c29475 100644 --- a/commands/dispatcher.ts +++ b/commands/dispatcher.ts @@ -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 }; +} diff --git a/commands/history.test.ts b/commands/history.test.ts new file mode 100644 index 0000000..b86d44f --- /dev/null +++ b/commands/history.test.ts @@ -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"]); + }); +}); diff --git a/commands/history.ts b/commands/history.ts new file mode 100644 index 0000000..3e7e9c5 --- /dev/null +++ b/commands/history.ts @@ -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[]; diff --git a/commands/ids.ts b/commands/ids.ts index 15bf322..e38c062 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -32,4 +32,6 @@ export const commandIds = { viewportSetSize: "viewport.setSize", viewportReset: "viewport.reset", viewportFitArtboard: "viewport.fitArtboard", + historyUndo: "history.undo", + historyRedo: "history.redo", } as const; diff --git a/commands/index.ts b/commands/index.ts index ab965c9..023357f 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -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"; diff --git a/commands/payloads.ts b/commands/payloads.ts index 4164d41..5e05dd1 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/editor/initial-state.ts b/editor/initial-state.ts index e91dba1..7d58ebc 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -25,5 +25,6 @@ export function createInitialAppState(name = "Untitled"): AppState { assets: [], }, editor: initialEditorState, + history: { past: [], future: [] }, }; } diff --git a/editor/state.ts b/editor/state.ts index 830bae7..a2bad84 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -23,7 +23,18 @@ export type EditorState = { transformSession?: TransformSession; }; -export type AppState = { +export type HistorySnapshot = { document: ImageDocument; editor: EditorState; }; + +export type HistoryState = { + past: HistorySnapshot[]; + future: HistorySnapshot[]; +}; + +export type AppState = { + document: ImageDocument; + editor: EditorState; + history: HistoryState; +}; diff --git a/input/history.ts b/input/history.ts new file mode 100644 index 0000000..ca319c7 --- /dev/null +++ b/input/history.ts @@ -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; +} diff --git a/input/index.ts b/input/index.ts index f75c7f1..9ed38f3 100644 --- a/input/index.ts +++ b/input/index.ts @@ -5,6 +5,7 @@ export { } from "./dom"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; +export { handleHistoryKey } from "./history"; export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel"; export type { LayerDropTarget, LayerInfo } from "./layers-panel"; export { handleArtboardSelection } from "./selection"; diff --git a/view/App.tsx b/view/App.tsx index 6500990..3727174 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -6,7 +6,7 @@ import { LayersSheet } from "./LayersSheet"; import { ToolOverlay } from "./ToolOverlay"; import { labelForTool } from "./toolLabels"; 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 { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; @@ -31,7 +31,15 @@ export function App({ app }: AppProps) { target instanceof HTMLElement && (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") { setLayersOpen(true);