···11/**
22 * Undo/redo history for the diagrams whiteboard.
33 *
44+ * @deprecated Use {@link OperationHistory} from `./operation-history.ts` instead.
55+ * This snapshot-based approach overwrites the entire WhiteboardState on undo,
66+ * which breaks collaborative editing by erasing other users' concurrent changes.
77+ * The new operation-based history only reverses the local user's own operations.
88+ *
99+ * This class is kept for backward compatibility while main.ts is migrated.
1010+ *
411 * Stores deep-cloned snapshots of WhiteboardState via structuredClone.
512 * structuredClone natively handles Maps, preserves undefined values,
613 * and correctly clones nested objects — avoiding the data-loss edge cases
+513
src/diagrams/operation-history.ts
···11+/**
22+ * Operation-based undo/redo history for the diagrams whiteboard.
33+ *
44+ * Replaces the snapshot-based History class with an operation log that tracks
55+ * individual user actions and their inverses. This enables collaborative undo:
66+ * each client only undoes its own operations without overwriting remote changes.
77+ *
88+ * ## Integration guide (for main.ts — not yet wired)
99+ *
1010+ * To integrate this module into the live editor:
1111+ *
1212+ * 1. Replace `history.push(wb)` calls with:
1313+ * ```ts
1414+ * const op = createOperation('move_shape', beforeState, afterState, shapeId);
1515+ * operationHistory.push(op);
1616+ * ```
1717+ *
1818+ * 2. Replace `history.undo()` / `history.redo()` with:
1919+ * ```ts
2020+ * const inv = operationHistory.undo();
2121+ * if (inv) wb = applyOperation(wb, inv);
2222+ * ```
2323+ *
2424+ * 3. Sync operations via a Yjs Y.Array instead of full state snapshots:
2525+ * ```ts
2626+ * const yOps = ydoc.getArray<Operation>('ops');
2727+ * // On local push:
2828+ * yOps.push([op]);
2929+ * // On remote observe:
3030+ * yOps.observe(e => {
3131+ * const added = e.changes.added;
3232+ * // loadOps merges and deduplicates
3333+ * operationHistory.loadOps([...added]);
3434+ * for (const remoteOp of added) wb = applyOperation(wb, remoteOp);
3535+ * });
3636+ * ```
3737+ *
3838+ * 4. The old History class (history.ts) remains functional for backward
3939+ * compatibility but should be considered deprecated.
4040+ */
4141+4242+import type { WhiteboardState, Shape, Arrow } from './whiteboard-types.js';
4343+4444+// ---------------------------------------------------------------------------
4545+// Types
4646+// ---------------------------------------------------------------------------
4747+4848+export type OpType =
4949+ | 'add_shape' | 'remove_shape' | 'move_shape' | 'resize_shape'
5050+ | 'set_label' | 'set_style' | 'set_opacity' | 'rotate_shape'
5151+ | 'add_arrow' | 'remove_arrow'
5252+ | 'group' | 'ungroup';
5353+5454+export interface Operation {
5555+ id: string;
5656+ type: OpType;
5757+ data: Record<string, unknown>;
5858+ inverse: Record<string, unknown>;
5959+ timestamp: number;
6060+ clientId: string;
6161+}
6262+6363+/**
6464+ * Maps operation types to their inverse types. Most ops are self-inverse
6565+ * (move undoes with a move to the old position). Add/remove are swapped.
6666+ */
6767+const INVERSE_TYPE: Record<OpType, OpType> = {
6868+ add_shape: 'remove_shape',
6969+ remove_shape: 'add_shape',
7070+ move_shape: 'move_shape',
7171+ resize_shape: 'resize_shape',
7272+ set_label: 'set_label',
7373+ set_style: 'set_style',
7474+ set_opacity: 'set_opacity',
7575+ rotate_shape: 'rotate_shape',
7676+ add_arrow: 'remove_arrow',
7777+ remove_arrow: 'add_arrow',
7878+ group: 'ungroup',
7979+ ungroup: 'group',
8080+};
8181+8282+// ---------------------------------------------------------------------------
8383+// ID generation
8484+// ---------------------------------------------------------------------------
8585+8686+let _opCounter = 0;
8787+8888+function generateOpId(): string {
8989+ return `op-${Date.now()}-${++_opCounter}`;
9090+}
9191+9292+function generateClientId(): string {
9393+ return `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
9494+}
9595+9696+// ---------------------------------------------------------------------------
9797+// OperationHistory
9898+// ---------------------------------------------------------------------------
9999+100100+/**
101101+ * Operation-based undo/redo stack with per-client filtering.
102102+ *
103103+ * Operations are pushed as they happen. Undo walks backward through the
104104+ * caller's own operations (skipping remote ones), returning an inverse
105105+ * operation that the caller applies to the current state. Redo re-applies
106106+ * the original operation.
107107+ */
108108+export class OperationHistory {
109109+ private ops: Operation[] = [];
110110+ private undoneIds: Set<string> = new Set();
111111+ private readonly clientId: string;
112112+113113+ constructor(clientId?: string) {
114114+ this.clientId = clientId ?? generateClientId();
115115+ }
116116+117117+ /**
118118+ * Record a new operation. Returns the full Operation with id/timestamp/clientId
119119+ * filled in. Clears any redo state for this client (push-after-undo branching).
120120+ */
121121+ push(
122122+ partial: Omit<Operation, 'id' | 'timestamp' | 'clientId'> | Operation,
123123+ ): Operation {
124124+ // Clear redo: remove undone entries that belong to this client and sit
125125+ // after the last non-undone entry for this client.
126126+ this.clearRedoForClient();
127127+128128+ const op: Operation = {
129129+ id: ('id' in partial && partial.id) ? partial.id : generateOpId(),
130130+ type: partial.type,
131131+ data: partial.data,
132132+ inverse: partial.inverse,
133133+ timestamp: ('timestamp' in partial && partial.timestamp) ? partial.timestamp : Date.now(),
134134+ clientId: ('clientId' in partial && partial.clientId) ? partial.clientId : this.clientId,
135135+ };
136136+ this.ops.push(op);
137137+ this.undoneIds.delete(op.id);
138138+ return op;
139139+ }
140140+141141+ /**
142142+ * Undo the most recent non-undone operation belonging to this client.
143143+ * Returns an Operation whose `data` contains the inverse payload
144144+ * (ready to pass to `applyOperation`), or null if nothing to undo.
145145+ */
146146+ undo(): Operation | null {
147147+ for (let i = this.ops.length - 1; i >= 0; i--) {
148148+ const op = this.ops[i]!;
149149+ if (op.clientId !== this.clientId) continue;
150150+ if (this.undoneIds.has(op.id)) continue;
151151+152152+ this.undoneIds.add(op.id);
153153+ // Return an operation with the inverse type and swapped data
154154+ return {
155155+ id: generateOpId(),
156156+ type: INVERSE_TYPE[op.type],
157157+ data: op.inverse,
158158+ inverse: op.data,
159159+ timestamp: Date.now(),
160160+ clientId: this.clientId,
161161+ };
162162+ }
163163+ return null;
164164+ }
165165+166166+ /**
167167+ * Redo the most recently undone operation belonging to this client.
168168+ * Returns the original operation (ready to pass to `applyOperation`),
169169+ * or null if nothing to redo.
170170+ */
171171+ redo(): Operation | null {
172172+ for (let i = 0; i < this.ops.length; i++) {
173173+ const op = this.ops[i]!;
174174+ if (op.clientId !== this.clientId) continue;
175175+ if (!this.undoneIds.has(op.id)) continue;
176176+177177+ // Check this is the *earliest* undone op for this client
178178+ // (redo goes forward from the undo cursor)
179179+ let isEarliest = true;
180180+ for (let j = 0; j < i; j++) {
181181+ const earlier = this.ops[j]!;
182182+ if (earlier.clientId === this.clientId && this.undoneIds.has(earlier.id)) {
183183+ isEarliest = false;
184184+ break;
185185+ }
186186+ }
187187+ if (!isEarliest) continue;
188188+189189+ this.undoneIds.delete(op.id);
190190+ return {
191191+ id: generateOpId(),
192192+ type: op.type,
193193+ data: op.data,
194194+ inverse: op.inverse,
195195+ timestamp: Date.now(),
196196+ clientId: this.clientId,
197197+ };
198198+ }
199199+ return null;
200200+ }
201201+202202+ canUndo(): boolean {
203203+ for (let i = this.ops.length - 1; i >= 0; i--) {
204204+ const op = this.ops[i]!;
205205+ if (op.clientId === this.clientId && !this.undoneIds.has(op.id)) return true;
206206+ }
207207+ return false;
208208+ }
209209+210210+ canRedo(): boolean {
211211+ for (const op of this.ops) {
212212+ if (op.clientId === this.clientId && this.undoneIds.has(op.id)) return true;
213213+ }
214214+ return false;
215215+ }
216216+217217+ /** Get all operations in the log (for Yjs sync). */
218218+ getOps(): Operation[] {
219219+ return [...this.ops];
220220+ }
221221+222222+ /**
223223+ * Merge remote operations into the local log. Deduplicates by id.
224224+ * Remote ops are appended but will be skipped by undo/redo (wrong clientId).
225225+ */
226226+ loadOps(ops: Operation[]): void {
227227+ const existing = new Set(this.ops.map((o) => o.id));
228228+ for (const op of ops) {
229229+ if (!existing.has(op.id)) {
230230+ this.ops.push(op);
231231+ existing.add(op.id);
232232+ }
233233+ }
234234+ }
235235+236236+ /**
237237+ * When a new operation is pushed, discard redo entries for this client.
238238+ * This mimics the branch-on-push semantics of the old snapshot History.
239239+ */
240240+ private clearRedoForClient(): void {
241241+ const toRemove: string[] = [];
242242+ for (const op of this.ops) {
243243+ if (op.clientId === this.clientId && this.undoneIds.has(op.id)) {
244244+ toRemove.push(op.id);
245245+ }
246246+ }
247247+ if (toRemove.length === 0) return;
248248+ const removeSet = new Set(toRemove);
249249+ this.ops = this.ops.filter((op) => !removeSet.has(op.id));
250250+ for (const id of toRemove) this.undoneIds.delete(id);
251251+ }
252252+}
253253+254254+// ---------------------------------------------------------------------------
255255+// applyOperation — single dispatch point for all op types
256256+// ---------------------------------------------------------------------------
257257+258258+/**
259259+ * Apply an operation to a WhiteboardState, returning the new state.
260260+ * This is a pure function — it never mutates the input.
261261+ */
262262+export function applyOperation(
263263+ state: WhiteboardState,
264264+ op: Operation,
265265+): WhiteboardState {
266266+ const d = op.data;
267267+268268+ switch (op.type) {
269269+ case 'add_shape': {
270270+ const shape = d.shape as Shape;
271271+ const shapeId = (d.shapeId as string) ?? shape.id;
272272+ const shapes = new Map(state.shapes);
273273+ shapes.set(shapeId, shape);
274274+ return { ...state, shapes };
275275+ }
276276+277277+ case 'remove_shape': {
278278+ const shapeId = d.shapeId as string;
279279+ if (!state.shapes.has(shapeId)) return state;
280280+ const shapes = new Map(state.shapes);
281281+ shapes.delete(shapeId);
282282+ // Also remove connected arrows
283283+ const arrows = new Map(state.arrows);
284284+ for (const [aid, arrow] of arrows) {
285285+ const fromConnected = 'shapeId' in arrow.from && (arrow.from as { shapeId: string }).shapeId === shapeId;
286286+ const toConnected = 'shapeId' in arrow.to && (arrow.to as { shapeId: string }).shapeId === shapeId;
287287+ if (fromConnected || toConnected) arrows.delete(aid);
288288+ }
289289+ return { ...state, shapes, arrows };
290290+ }
291291+292292+ case 'move_shape': {
293293+ const shapeId = d.shapeId as string;
294294+ const shape = state.shapes.get(shapeId);
295295+ if (!shape) return state;
296296+ const shapes = new Map(state.shapes);
297297+ shapes.set(shapeId, { ...shape, x: d.x as number, y: d.y as number });
298298+ return { ...state, shapes };
299299+ }
300300+301301+ case 'resize_shape': {
302302+ const shapeId = d.shapeId as string;
303303+ const shape = state.shapes.get(shapeId);
304304+ if (!shape) return state;
305305+ const shapes = new Map(state.shapes);
306306+ shapes.set(shapeId, { ...shape, width: d.width as number, height: d.height as number });
307307+ return { ...state, shapes };
308308+ }
309309+310310+ case 'set_label': {
311311+ const shapeId = d.shapeId as string;
312312+ const shape = state.shapes.get(shapeId);
313313+ if (!shape) return state;
314314+ const shapes = new Map(state.shapes);
315315+ shapes.set(shapeId, { ...shape, label: d.label as string });
316316+ return { ...state, shapes };
317317+ }
318318+319319+ case 'set_style': {
320320+ const shapeId = d.shapeId as string;
321321+ const shape = state.shapes.get(shapeId);
322322+ if (!shape) return state;
323323+ const shapes = new Map(state.shapes);
324324+ shapes.set(shapeId, { ...shape, style: d.style as Record<string, string> });
325325+ return { ...state, shapes };
326326+ }
327327+328328+ case 'set_opacity': {
329329+ const shapeId = d.shapeId as string;
330330+ const shape = state.shapes.get(shapeId);
331331+ if (!shape) return state;
332332+ const shapes = new Map(state.shapes);
333333+ shapes.set(shapeId, { ...shape, opacity: d.opacity as number });
334334+ return { ...state, shapes };
335335+ }
336336+337337+ case 'rotate_shape': {
338338+ const shapeId = d.shapeId as string;
339339+ const shape = state.shapes.get(shapeId);
340340+ if (!shape) return state;
341341+ const shapes = new Map(state.shapes);
342342+ shapes.set(shapeId, { ...shape, rotation: d.rotation as number });
343343+ return { ...state, shapes };
344344+ }
345345+346346+ case 'add_arrow': {
347347+ const arrow = d.arrow as Arrow;
348348+ const arrowId = (d.arrowId as string) ?? arrow.id;
349349+ const arrows = new Map(state.arrows);
350350+ arrows.set(arrowId, arrow);
351351+ return { ...state, arrows };
352352+ }
353353+354354+ case 'remove_arrow': {
355355+ const arrowId = d.arrowId as string;
356356+ if (!state.arrows.has(arrowId)) return state;
357357+ const arrows = new Map(state.arrows);
358358+ arrows.delete(arrowId);
359359+ return { ...state, arrows };
360360+ }
361361+362362+ case 'group': {
363363+ const groupId = d.groupId as string;
364364+ const shapeIds = d.shapeIds as string[];
365365+ const shapes = new Map(state.shapes);
366366+ for (const id of shapeIds) {
367367+ const shape = shapes.get(id);
368368+ if (shape) shapes.set(id, { ...shape, groupId });
369369+ }
370370+ return { ...state, shapes };
371371+ }
372372+373373+ case 'ungroup': {
374374+ const shapeIds = d.shapeIds as string[];
375375+ const shapes = new Map(state.shapes);
376376+ for (const id of shapeIds) {
377377+ const shape = shapes.get(id);
378378+ if (shape) shapes.set(id, { ...shape, groupId: undefined });
379379+ }
380380+ return { ...state, shapes };
381381+ }
382382+383383+ default:
384384+ return state;
385385+ }
386386+}
387387+388388+// ---------------------------------------------------------------------------
389389+// createOperation — build operations from before/after state diffs
390390+// ---------------------------------------------------------------------------
391391+392392+/**
393393+ * Create an operation by comparing before and after WhiteboardState.
394394+ *
395395+ * @param type The operation type.
396396+ * @param before State before the change.
397397+ * @param after State after the change.
398398+ * @param entityId The shape or arrow ID involved.
399399+ * @returns An operation partial (without id/timestamp/clientId — pass to push()).
400400+ */
401401+export function createOperation(
402402+ type: OpType,
403403+ before: WhiteboardState,
404404+ after: WhiteboardState,
405405+ entityId: string,
406406+): Omit<Operation, 'id' | 'timestamp' | 'clientId'> {
407407+ switch (type) {
408408+ case 'add_shape': {
409409+ const shape = after.shapes.get(entityId)!;
410410+ return {
411411+ type,
412412+ data: { shapeId: entityId, shape },
413413+ inverse: { shapeId: entityId },
414414+ };
415415+ }
416416+417417+ case 'remove_shape': {
418418+ const shape = before.shapes.get(entityId)!;
419419+ return {
420420+ type,
421421+ data: { shapeId: entityId },
422422+ inverse: { shapeId: entityId, shape },
423423+ };
424424+ }
425425+426426+ case 'move_shape': {
427427+ const beforeShape = before.shapes.get(entityId)!;
428428+ const afterShape = after.shapes.get(entityId)!;
429429+ return {
430430+ type,
431431+ data: { shapeId: entityId, x: afterShape.x, y: afterShape.y },
432432+ inverse: { shapeId: entityId, x: beforeShape.x, y: beforeShape.y },
433433+ };
434434+ }
435435+436436+ case 'resize_shape': {
437437+ const beforeShape = before.shapes.get(entityId)!;
438438+ const afterShape = after.shapes.get(entityId)!;
439439+ return {
440440+ type,
441441+ data: { shapeId: entityId, width: afterShape.width, height: afterShape.height },
442442+ inverse: { shapeId: entityId, width: beforeShape.width, height: beforeShape.height },
443443+ };
444444+ }
445445+446446+ case 'set_label': {
447447+ const beforeShape = before.shapes.get(entityId)!;
448448+ const afterShape = after.shapes.get(entityId)!;
449449+ return {
450450+ type,
451451+ data: { shapeId: entityId, label: afterShape.label },
452452+ inverse: { shapeId: entityId, label: beforeShape.label },
453453+ };
454454+ }
455455+456456+ case 'set_style': {
457457+ const beforeShape = before.shapes.get(entityId)!;
458458+ const afterShape = after.shapes.get(entityId)!;
459459+ return {
460460+ type,
461461+ data: { shapeId: entityId, style: { ...afterShape.style } },
462462+ inverse: { shapeId: entityId, style: { ...beforeShape.style } },
463463+ };
464464+ }
465465+466466+ case 'set_opacity': {
467467+ const beforeShape = before.shapes.get(entityId)!;
468468+ const afterShape = after.shapes.get(entityId)!;
469469+ return {
470470+ type,
471471+ data: { shapeId: entityId, opacity: afterShape.opacity },
472472+ inverse: { shapeId: entityId, opacity: beforeShape.opacity },
473473+ };
474474+ }
475475+476476+ case 'rotate_shape': {
477477+ const beforeShape = before.shapes.get(entityId)!;
478478+ const afterShape = after.shapes.get(entityId)!;
479479+ return {
480480+ type,
481481+ data: { shapeId: entityId, rotation: afterShape.rotation },
482482+ inverse: { shapeId: entityId, rotation: beforeShape.rotation },
483483+ };
484484+ }
485485+486486+ case 'add_arrow': {
487487+ const arrow = after.arrows.get(entityId)!;
488488+ return {
489489+ type,
490490+ data: { arrowId: entityId, arrow },
491491+ inverse: { arrowId: entityId },
492492+ };
493493+ }
494494+495495+ case 'remove_arrow': {
496496+ const arrow = before.arrows.get(entityId)!;
497497+ return {
498498+ type,
499499+ data: { arrowId: entityId },
500500+ inverse: { arrowId: entityId, arrow },
501501+ };
502502+ }
503503+504504+ case 'group':
505505+ case 'ungroup':
506506+ // Group/ungroup ops are typically constructed directly, not from diffs.
507507+ // This fallback returns empty payloads — callers should build these manually.
508508+ return { type, data: {}, inverse: {} };
509509+510510+ default:
511511+ return { type, data: {}, inverse: {} };
512512+ }
513513+}