···1+import type { Camera } from "./camera";
2+import type { ShapeRecord } from "./model";
3+import type { EditorState } from "./reactivity";
4+5+/**
6+ * Command interface for undo/redo operations
7+ *
8+ * All user-visible changes must be wrapped as commands that can be undone/redone.
9+ */
10+export interface Command {
11+ /** Display name for this command (shown in history UI) */
12+ readonly name: string;
13+14+ /**
15+ * Execute the command and return the new state
16+ * @param state - Current editor state
17+ * @returns New editor state with command applied
18+ */
19+ do(state: EditorState): EditorState;
20+21+ /**
22+ * Undo the command and return the previous state
23+ * @param state - Current editor state
24+ * @returns New editor state with command undone
25+ */
26+ undo(state: EditorState): EditorState;
27+}
28+29+/**
30+ * Create a shape command
31+ */
32+export class CreateShapeCommand implements Command {
33+ readonly name: string;
34+35+ constructor(private readonly shape: ShapeRecord, private readonly pageId: string) {
36+ this.name = `Create ${shape.type}`;
37+ }
38+39+ do(state: EditorState): EditorState {
40+ const page = state.doc.pages[this.pageId];
41+ if (!page) {
42+ return state;
43+ }
44+45+ return {
46+ ...state,
47+ doc: {
48+ ...state.doc,
49+ shapes: { ...state.doc.shapes, [this.shape.id]: this.shape },
50+ pages: { ...state.doc.pages, [this.pageId]: { ...page, shapeIds: [...page.shapeIds, this.shape.id] } },
51+ },
52+ };
53+ }
54+55+ undo(state: EditorState): EditorState {
56+ const page = state.doc.pages[this.pageId];
57+ if (!page) {
58+ return state;
59+ }
60+61+ const { [this.shape.id]: _, ...remainingShapes } = state.doc.shapes;
62+63+ return {
64+ ...state,
65+ doc: {
66+ ...state.doc,
67+ shapes: remainingShapes,
68+ pages: {
69+ ...state.doc.pages,
70+ [this.pageId]: { ...page, shapeIds: page.shapeIds.filter((id) => id !== this.shape.id) },
71+ },
72+ },
73+ };
74+ }
75+}
76+77+/**
78+ * Update shape command (stores before/after snapshots)
79+ */
80+export class UpdateShapeCommand implements Command {
81+ readonly name: string;
82+83+ constructor(
84+ private readonly shapeId: string,
85+ private readonly before: ShapeRecord,
86+ private readonly after: ShapeRecord,
87+ ) {
88+ this.name = `Update ${after.type}`;
89+ }
90+91+ do(state: EditorState): EditorState {
92+ return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.shapeId]: this.after } } };
93+ }
94+95+ undo(state: EditorState): EditorState {
96+ return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.shapeId]: this.before } } };
97+ }
98+}
99+100+/**
101+ * Delete shapes command (can delete multiple shapes)
102+ */
103+export class DeleteShapesCommand implements Command {
104+ readonly name: string;
105+106+ constructor(private readonly shapes: ShapeRecord[], private readonly pageId: string) {
107+ this.name = shapes.length === 1 ? `Delete ${shapes[0].type}` : `Delete ${shapes.length} shapes`;
108+ }
109+110+ do(state: EditorState): EditorState {
111+ const page = state.doc.pages[this.pageId];
112+ if (!page) {
113+ return state;
114+ }
115+116+ const shapeIdsToDelete = new Set(this.shapes.map((s) => s.id));
117+ const remainingShapes = { ...state.doc.shapes };
118+119+ for (const id of shapeIdsToDelete) {
120+ delete remainingShapes[id];
121+ }
122+123+ return {
124+ ...state,
125+ doc: {
126+ ...state.doc,
127+ shapes: remainingShapes,
128+ pages: {
129+ ...state.doc.pages,
130+ [this.pageId]: { ...page, shapeIds: page.shapeIds.filter((id) => !shapeIdsToDelete.has(id)) },
131+ },
132+ },
133+ };
134+ }
135+136+ undo(state: EditorState): EditorState {
137+ const page = state.doc.pages[this.pageId];
138+ if (!page) {
139+ return state;
140+ }
141+142+ const restoredShapes = { ...state.doc.shapes };
143+ const shapeIds = this.shapes.map((s) => s.id);
144+145+ for (const shape of this.shapes) {
146+ restoredShapes[shape.id] = shape;
147+ }
148+149+ return {
150+ ...state,
151+ doc: {
152+ ...state.doc,
153+ shapes: restoredShapes,
154+ pages: { ...state.doc.pages, [this.pageId]: { ...page, shapeIds: [...page.shapeIds, ...shapeIds] } },
155+ },
156+ };
157+ }
158+}
159+160+/**
161+ * Set selection command
162+ */
163+export class SetSelectionCommand implements Command {
164+ readonly name = "Change selection";
165+166+ constructor(private readonly before: string[], private readonly after: string[]) {}
167+168+ do(state: EditorState): EditorState {
169+ return { ...state, ui: { ...state.ui, selectionIds: this.after } };
170+ }
171+172+ undo(state: EditorState): EditorState {
173+ return { ...state, ui: { ...state.ui, selectionIds: this.before } };
174+ }
175+}
176+177+/**
178+ * Set camera command
179+ */
180+export class SetCameraCommand implements Command {
181+ readonly name = "Move camera";
182+183+ constructor(private readonly before: Camera, private readonly after: Camera) {}
184+185+ do(state: EditorState): EditorState {
186+ return { ...state, camera: this.after };
187+ }
188+189+ undo(state: EditorState): EditorState {
190+ return { ...state, camera: this.before };
191+ }
192+}
193+194+/**
195+ * History entry (command with timestamp)
196+ */
197+export type HistoryEntry = { command: Command; timestamp: number };
198+199+/**
200+ * History manager state
201+ */
202+export type HistoryState = { undoStack: HistoryEntry[]; redoStack: HistoryEntry[] };
203+204+/**
205+ * History namespace for managing undo/redo stacks
206+ */
207+export const History = {
208+ /**
209+ * Create empty history state
210+ */
211+ create(): HistoryState {
212+ return { undoStack: [], redoStack: [] };
213+ },
214+215+ /**
216+ * Execute a command and add it to history
217+ *
218+ * @param history - Current history state
219+ * @param state - Current editor state
220+ * @param command - Command to execute
221+ * @returns Tuple of [new history state, new editor state]
222+ */
223+ execute(history: HistoryState, state: EditorState, command: Command): [HistoryState, EditorState] {
224+ const newState = command.do(state);
225+226+ const entry: HistoryEntry = { command, timestamp: Date.now() };
227+228+ return [{ undoStack: [...history.undoStack, entry], redoStack: [] }, newState];
229+ },
230+231+ /**
232+ * Undo the last command
233+ *
234+ * @param history - Current history state
235+ * @param state - Current editor state
236+ * @returns Tuple of [new history state, new editor state] or null if nothing to undo
237+ */
238+ undo(history: HistoryState, state: EditorState): [HistoryState, EditorState] | null {
239+ if (history.undoStack.length === 0) {
240+ return null;
241+ }
242+243+ const entry = history.undoStack.at(-1)!;
244+ const newState = entry!.command.undo(state);
245+246+ return [{ undoStack: history.undoStack.slice(0, -1), redoStack: [...history.redoStack, entry] }, newState];
247+ },
248+249+ /**
250+ * Redo the last undone command
251+ *
252+ * @param history - Current history state
253+ * @param state - Current editor state
254+ * @returns Tuple of [new history state, new editor state] or null if nothing to redo
255+ */
256+ redo(history: HistoryState, state: EditorState): [HistoryState, EditorState] | null {
257+ if (history.redoStack.length === 0) {
258+ return null;
259+ }
260+261+ const entry = history.redoStack.at(-1)!;
262+ const newState = entry!.command.do(state);
263+264+ return [{ undoStack: [...history.undoStack, entry], redoStack: history.redoStack.slice(0, -1) }, newState];
265+ },
266+267+ /**
268+ * Check if there are commands to undo
269+ */
270+ canUndo(history: HistoryState): boolean {
271+ return history.undoStack.length > 0;
272+ },
273+274+ /**
275+ * Check if there are commands to redo
276+ */
277+ canRedo(history: HistoryState): boolean {
278+ return history.redoStack.length > 0;
279+ },
280+281+ /**
282+ * Get all history entries (undo + redo stacks combined)
283+ */
284+ getAllEntries(history: HistoryState): HistoryEntry[] {
285+ return [...history.undoStack, ...history.redoStack];
286+ },
287+288+ /**
289+ * Clear all history
290+ */
291+ clear(): HistoryState {
292+ return History.create();
293+ },
294+};
+1
packages/core/src/index.ts
···1export * from "./actions";
2export * from "./camera";
3export * from "./geom";
04export * from "./math";
5export * from "./model";
6export * from "./reactivity";
···1export * from "./actions";
2export * from "./camera";
3export * from "./geom";
4+export * from "./history";
5export * from "./math";
6export * from "./model";
7export * from "./reactivity";
+2-4
packages/core/src/model.ts
···92export type BindingType = "arrow-end";
93export type BindingHandle = "start" | "end";
9495-export type BindingAnchor = {
96- // TODO: 'edge', 'corner', etc.
97- kind: "center";
98-};
99100export type BindingRecord = {
101 id: string;
···92export type BindingType = "arrow-end";
93export type BindingHandle = "start" | "end";
9495+// TODO: 'edge', 'corner', etc.
96+export type BindingAnchor = { kind: "center" };
009798export type BindingRecord = {
99 id: string;
+89
packages/core/src/reactivity.ts
···1import { BehaviorSubject, type Subscription } from "rxjs";
2import type { Camera } from "./camera";
3import { Camera as CameraOps } from "./camera";
04import type { Document, PageRecord, ShapeRecord } from "./model";
5import { Document as DocumentOps } from "./model";
6···45 * - Immutable state updates
46 * - Invariant enforcement (repairs invalid state)
47 * - Subscription management
048 */
49export class Store {
50 private readonly state$: BehaviorSubject<EditorState>;
05152 constructor(initialState?: EditorState) {
53 this.state$ = new BehaviorSubject(initialState ?? EditorState.create());
054 }
5556 /**
···66 * The updater receives the current state and returns a new state.
67 * Invariants are enforced after the update.
68 *
0069 * @param updater - Function that transforms current state to new state
70 */
71 setState(updater: StateUpdater): void {
···73 const newState = updater(currentState);
74 const repairedState = enforceInvariants(newState);
75 this.state$.next(repairedState);
0000000000000000000000000000000000000000000000000000000000000000000000000000000000076 }
7778 /**
···1import { BehaviorSubject, type Subscription } from "rxjs";
2import type { Camera } from "./camera";
3import { Camera as CameraOps } from "./camera";
4+import { type Command, History, type HistoryState } from "./history";
5import type { Document, PageRecord, ShapeRecord } from "./model";
6import { Document as DocumentOps } from "./model";
7···46 * - Immutable state updates
47 * - Invariant enforcement (repairs invalid state)
48 * - Subscription management
49+ * - Undo/redo history support
50 */
51export class Store {
52 private readonly state$: BehaviorSubject<EditorState>;
53+ private history: HistoryState;
5455 constructor(initialState?: EditorState) {
56 this.state$ = new BehaviorSubject(initialState ?? EditorState.create());
57+ this.history = History.create();
58 }
5960 /**
···70 * The updater receives the current state and returns a new state.
71 * Invariants are enforced after the update.
72 *
73+ * Note: This bypasses history. Use executeCommand() for undoable changes.
74+ *
75 * @param updater - Function that transforms current state to new state
76 */
77 setState(updater: StateUpdater): void {
···79 const newState = updater(currentState);
80 const repairedState = enforceInvariants(newState);
81 this.state$.next(repairedState);
82+ }
83+84+ /**
85+ * Execute a command and add it to history
86+ *
87+ * This is the preferred way to make undoable changes to the state.
88+ *
89+ * @param command - Command to execute
90+ */
91+ executeCommand(command: Command): void {
92+ const currentState = this.state$.value;
93+ const [newHistory, newState] = History.execute(this.history, currentState, command);
94+ this.history = newHistory;
95+ const repairedState = enforceInvariants(newState);
96+ this.state$.next(repairedState);
97+ }
98+99+ /**
100+ * Undo the last command
101+ *
102+ * @returns True if undo was successful, false if nothing to undo
103+ */
104+ undo(): boolean {
105+ const currentState = this.state$.value;
106+ const result = History.undo(this.history, currentState);
107+108+ if (!result) {
109+ return false;
110+ }
111+112+ const [newHistory, newState] = result;
113+ this.history = newHistory;
114+ const repairedState = enforceInvariants(newState);
115+ this.state$.next(repairedState);
116+ return true;
117+ }
118+119+ /**
120+ * Redo the last undone command
121+ *
122+ * @returns True if redo was successful, false if nothing to redo
123+ */
124+ redo(): boolean {
125+ const currentState = this.state$.value;
126+ const result = History.redo(this.history, currentState);
127+128+ if (!result) {
129+ return false;
130+ }
131+132+ const [newHistory, newState] = result;
133+ this.history = newHistory;
134+ const repairedState = enforceInvariants(newState);
135+ this.state$.next(repairedState);
136+ return true;
137+ }
138+139+ /**
140+ * Check if undo is available
141+ */
142+ canUndo(): boolean {
143+ return History.canUndo(this.history);
144+ }
145+146+ /**
147+ * Check if redo is available
148+ */
149+ canRedo(): boolean {
150+ return History.canRedo(this.history);
151+ }
152+153+ /**
154+ * Get the history state (for debugging/UI)
155+ */
156+ getHistory(): HistoryState {
157+ return this.history;
158+ }
159+160+ /**
161+ * Clear all history
162+ */
163+ clearHistory(): void {
164+ this.history = History.clear();
165 }
166167 /**