···11+import type { Camera } from "./camera";
22+import type { ShapeRecord } from "./model";
33+import type { EditorState } from "./reactivity";
44+55+/**
66+ * Command interface for undo/redo operations
77+ *
88+ * All user-visible changes must be wrapped as commands that can be undone/redone.
99+ */
1010+export interface Command {
1111+ /** Display name for this command (shown in history UI) */
1212+ readonly name: string;
1313+1414+ /**
1515+ * Execute the command and return the new state
1616+ * @param state - Current editor state
1717+ * @returns New editor state with command applied
1818+ */
1919+ do(state: EditorState): EditorState;
2020+2121+ /**
2222+ * Undo the command and return the previous state
2323+ * @param state - Current editor state
2424+ * @returns New editor state with command undone
2525+ */
2626+ undo(state: EditorState): EditorState;
2727+}
2828+2929+/**
3030+ * Create a shape command
3131+ */
3232+export class CreateShapeCommand implements Command {
3333+ readonly name: string;
3434+3535+ constructor(private readonly shape: ShapeRecord, private readonly pageId: string) {
3636+ this.name = `Create ${shape.type}`;
3737+ }
3838+3939+ do(state: EditorState): EditorState {
4040+ const page = state.doc.pages[this.pageId];
4141+ if (!page) {
4242+ return state;
4343+ }
4444+4545+ return {
4646+ ...state,
4747+ doc: {
4848+ ...state.doc,
4949+ shapes: { ...state.doc.shapes, [this.shape.id]: this.shape },
5050+ pages: { ...state.doc.pages, [this.pageId]: { ...page, shapeIds: [...page.shapeIds, this.shape.id] } },
5151+ },
5252+ };
5353+ }
5454+5555+ undo(state: EditorState): EditorState {
5656+ const page = state.doc.pages[this.pageId];
5757+ if (!page) {
5858+ return state;
5959+ }
6060+6161+ const { [this.shape.id]: _, ...remainingShapes } = state.doc.shapes;
6262+6363+ return {
6464+ ...state,
6565+ doc: {
6666+ ...state.doc,
6767+ shapes: remainingShapes,
6868+ pages: {
6969+ ...state.doc.pages,
7070+ [this.pageId]: { ...page, shapeIds: page.shapeIds.filter((id) => id !== this.shape.id) },
7171+ },
7272+ },
7373+ };
7474+ }
7575+}
7676+7777+/**
7878+ * Update shape command (stores before/after snapshots)
7979+ */
8080+export class UpdateShapeCommand implements Command {
8181+ readonly name: string;
8282+8383+ constructor(
8484+ private readonly shapeId: string,
8585+ private readonly before: ShapeRecord,
8686+ private readonly after: ShapeRecord,
8787+ ) {
8888+ this.name = `Update ${after.type}`;
8989+ }
9090+9191+ do(state: EditorState): EditorState {
9292+ return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.shapeId]: this.after } } };
9393+ }
9494+9595+ undo(state: EditorState): EditorState {
9696+ return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.shapeId]: this.before } } };
9797+ }
9898+}
9999+100100+/**
101101+ * Delete shapes command (can delete multiple shapes)
102102+ */
103103+export class DeleteShapesCommand implements Command {
104104+ readonly name: string;
105105+106106+ constructor(private readonly shapes: ShapeRecord[], private readonly pageId: string) {
107107+ this.name = shapes.length === 1 ? `Delete ${shapes[0].type}` : `Delete ${shapes.length} shapes`;
108108+ }
109109+110110+ do(state: EditorState): EditorState {
111111+ const page = state.doc.pages[this.pageId];
112112+ if (!page) {
113113+ return state;
114114+ }
115115+116116+ const shapeIdsToDelete = new Set(this.shapes.map((s) => s.id));
117117+ const remainingShapes = { ...state.doc.shapes };
118118+119119+ for (const id of shapeIdsToDelete) {
120120+ delete remainingShapes[id];
121121+ }
122122+123123+ return {
124124+ ...state,
125125+ doc: {
126126+ ...state.doc,
127127+ shapes: remainingShapes,
128128+ pages: {
129129+ ...state.doc.pages,
130130+ [this.pageId]: { ...page, shapeIds: page.shapeIds.filter((id) => !shapeIdsToDelete.has(id)) },
131131+ },
132132+ },
133133+ };
134134+ }
135135+136136+ undo(state: EditorState): EditorState {
137137+ const page = state.doc.pages[this.pageId];
138138+ if (!page) {
139139+ return state;
140140+ }
141141+142142+ const restoredShapes = { ...state.doc.shapes };
143143+ const shapeIds = this.shapes.map((s) => s.id);
144144+145145+ for (const shape of this.shapes) {
146146+ restoredShapes[shape.id] = shape;
147147+ }
148148+149149+ return {
150150+ ...state,
151151+ doc: {
152152+ ...state.doc,
153153+ shapes: restoredShapes,
154154+ pages: { ...state.doc.pages, [this.pageId]: { ...page, shapeIds: [...page.shapeIds, ...shapeIds] } },
155155+ },
156156+ };
157157+ }
158158+}
159159+160160+/**
161161+ * Set selection command
162162+ */
163163+export class SetSelectionCommand implements Command {
164164+ readonly name = "Change selection";
165165+166166+ constructor(private readonly before: string[], private readonly after: string[]) {}
167167+168168+ do(state: EditorState): EditorState {
169169+ return { ...state, ui: { ...state.ui, selectionIds: this.after } };
170170+ }
171171+172172+ undo(state: EditorState): EditorState {
173173+ return { ...state, ui: { ...state.ui, selectionIds: this.before } };
174174+ }
175175+}
176176+177177+/**
178178+ * Set camera command
179179+ */
180180+export class SetCameraCommand implements Command {
181181+ readonly name = "Move camera";
182182+183183+ constructor(private readonly before: Camera, private readonly after: Camera) {}
184184+185185+ do(state: EditorState): EditorState {
186186+ return { ...state, camera: this.after };
187187+ }
188188+189189+ undo(state: EditorState): EditorState {
190190+ return { ...state, camera: this.before };
191191+ }
192192+}
193193+194194+/**
195195+ * History entry (command with timestamp)
196196+ */
197197+export type HistoryEntry = { command: Command; timestamp: number };
198198+199199+/**
200200+ * History manager state
201201+ */
202202+export type HistoryState = { undoStack: HistoryEntry[]; redoStack: HistoryEntry[] };
203203+204204+/**
205205+ * History namespace for managing undo/redo stacks
206206+ */
207207+export const History = {
208208+ /**
209209+ * Create empty history state
210210+ */
211211+ create(): HistoryState {
212212+ return { undoStack: [], redoStack: [] };
213213+ },
214214+215215+ /**
216216+ * Execute a command and add it to history
217217+ *
218218+ * @param history - Current history state
219219+ * @param state - Current editor state
220220+ * @param command - Command to execute
221221+ * @returns Tuple of [new history state, new editor state]
222222+ */
223223+ execute(history: HistoryState, state: EditorState, command: Command): [HistoryState, EditorState] {
224224+ const newState = command.do(state);
225225+226226+ const entry: HistoryEntry = { command, timestamp: Date.now() };
227227+228228+ return [{ undoStack: [...history.undoStack, entry], redoStack: [] }, newState];
229229+ },
230230+231231+ /**
232232+ * Undo the last command
233233+ *
234234+ * @param history - Current history state
235235+ * @param state - Current editor state
236236+ * @returns Tuple of [new history state, new editor state] or null if nothing to undo
237237+ */
238238+ undo(history: HistoryState, state: EditorState): [HistoryState, EditorState] | null {
239239+ if (history.undoStack.length === 0) {
240240+ return null;
241241+ }
242242+243243+ const entry = history.undoStack.at(-1)!;
244244+ const newState = entry!.command.undo(state);
245245+246246+ return [{ undoStack: history.undoStack.slice(0, -1), redoStack: [...history.redoStack, entry] }, newState];
247247+ },
248248+249249+ /**
250250+ * Redo the last undone command
251251+ *
252252+ * @param history - Current history state
253253+ * @param state - Current editor state
254254+ * @returns Tuple of [new history state, new editor state] or null if nothing to redo
255255+ */
256256+ redo(history: HistoryState, state: EditorState): [HistoryState, EditorState] | null {
257257+ if (history.redoStack.length === 0) {
258258+ return null;
259259+ }
260260+261261+ const entry = history.redoStack.at(-1)!;
262262+ const newState = entry!.command.do(state);
263263+264264+ return [{ undoStack: [...history.undoStack, entry], redoStack: history.redoStack.slice(0, -1) }, newState];
265265+ },
266266+267267+ /**
268268+ * Check if there are commands to undo
269269+ */
270270+ canUndo(history: HistoryState): boolean {
271271+ return history.undoStack.length > 0;
272272+ },
273273+274274+ /**
275275+ * Check if there are commands to redo
276276+ */
277277+ canRedo(history: HistoryState): boolean {
278278+ return history.redoStack.length > 0;
279279+ },
280280+281281+ /**
282282+ * Get all history entries (undo + redo stacks combined)
283283+ */
284284+ getAllEntries(history: HistoryState): HistoryEntry[] {
285285+ return [...history.undoStack, ...history.redoStack];
286286+ },
287287+288288+ /**
289289+ * Clear all history
290290+ */
291291+ clear(): HistoryState {
292292+ return History.create();
293293+ },
294294+};
+1
packages/core/src/index.ts
···11export * from "./actions";
22export * from "./camera";
33export * from "./geom";
44+export * from "./history";
45export * from "./math";
56export * from "./model";
67export * from "./reactivity";
+2-4
packages/core/src/model.ts
···9292export type BindingType = "arrow-end";
9393export type BindingHandle = "start" | "end";
94949595-export type BindingAnchor = {
9696- // TODO: 'edge', 'corner', etc.
9797- kind: "center";
9898-};
9595+// TODO: 'edge', 'corner', etc.
9696+export type BindingAnchor = { kind: "center" };
999710098export type BindingRecord = {
10199 id: string;
+89
packages/core/src/reactivity.ts
···11import { BehaviorSubject, type Subscription } from "rxjs";
22import type { Camera } from "./camera";
33import { Camera as CameraOps } from "./camera";
44+import { type Command, History, type HistoryState } from "./history";
45import type { Document, PageRecord, ShapeRecord } from "./model";
56import { Document as DocumentOps } from "./model";
67···4546 * - Immutable state updates
4647 * - Invariant enforcement (repairs invalid state)
4748 * - Subscription management
4949+ * - Undo/redo history support
4850 */
4951export class Store {
5052 private readonly state$: BehaviorSubject<EditorState>;
5353+ private history: HistoryState;
51545255 constructor(initialState?: EditorState) {
5356 this.state$ = new BehaviorSubject(initialState ?? EditorState.create());
5757+ this.history = History.create();
5458 }
55595660 /**
···6670 * The updater receives the current state and returns a new state.
6771 * Invariants are enforced after the update.
6872 *
7373+ * Note: This bypasses history. Use executeCommand() for undoable changes.
7474+ *
6975 * @param updater - Function that transforms current state to new state
7076 */
7177 setState(updater: StateUpdater): void {
···7379 const newState = updater(currentState);
7480 const repairedState = enforceInvariants(newState);
7581 this.state$.next(repairedState);
8282+ }
8383+8484+ /**
8585+ * Execute a command and add it to history
8686+ *
8787+ * This is the preferred way to make undoable changes to the state.
8888+ *
8989+ * @param command - Command to execute
9090+ */
9191+ executeCommand(command: Command): void {
9292+ const currentState = this.state$.value;
9393+ const [newHistory, newState] = History.execute(this.history, currentState, command);
9494+ this.history = newHistory;
9595+ const repairedState = enforceInvariants(newState);
9696+ this.state$.next(repairedState);
9797+ }
9898+9999+ /**
100100+ * Undo the last command
101101+ *
102102+ * @returns True if undo was successful, false if nothing to undo
103103+ */
104104+ undo(): boolean {
105105+ const currentState = this.state$.value;
106106+ const result = History.undo(this.history, currentState);
107107+108108+ if (!result) {
109109+ return false;
110110+ }
111111+112112+ const [newHistory, newState] = result;
113113+ this.history = newHistory;
114114+ const repairedState = enforceInvariants(newState);
115115+ this.state$.next(repairedState);
116116+ return true;
117117+ }
118118+119119+ /**
120120+ * Redo the last undone command
121121+ *
122122+ * @returns True if redo was successful, false if nothing to redo
123123+ */
124124+ redo(): boolean {
125125+ const currentState = this.state$.value;
126126+ const result = History.redo(this.history, currentState);
127127+128128+ if (!result) {
129129+ return false;
130130+ }
131131+132132+ const [newHistory, newState] = result;
133133+ this.history = newHistory;
134134+ const repairedState = enforceInvariants(newState);
135135+ this.state$.next(repairedState);
136136+ return true;
137137+ }
138138+139139+ /**
140140+ * Check if undo is available
141141+ */
142142+ canUndo(): boolean {
143143+ return History.canUndo(this.history);
144144+ }
145145+146146+ /**
147147+ * Check if redo is available
148148+ */
149149+ canRedo(): boolean {
150150+ return History.canRedo(this.history);
151151+ }
152152+153153+ /**
154154+ * Get the history state (for debugging/UI)
155155+ */
156156+ getHistory(): HistoryState {
157157+ return this.history;
158158+ }
159159+160160+ /**
161161+ * Clear all history
162162+ */
163163+ clearHistory(): void {
164164+ this.history = History.clear();
76165 }
7716678167 /**