Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(diagrams): operation-based undo/redo history' (#385) from feat/operation-history into main

scott 7d74f45c 9b1ae681

+1266
+7
src/diagrams/history.ts
··· 1 1 /** 2 2 * Undo/redo history for the diagrams whiteboard. 3 3 * 4 + * @deprecated Use {@link OperationHistory} from `./operation-history.ts` instead. 5 + * This snapshot-based approach overwrites the entire WhiteboardState on undo, 6 + * which breaks collaborative editing by erasing other users' concurrent changes. 7 + * The new operation-based history only reverses the local user's own operations. 8 + * 9 + * This class is kept for backward compatibility while main.ts is migrated. 10 + * 4 11 * Stores deep-cloned snapshots of WhiteboardState via structuredClone. 5 12 * structuredClone natively handles Maps, preserves undefined values, 6 13 * and correctly clones nested objects — avoiding the data-loss edge cases
+513
src/diagrams/operation-history.ts
··· 1 + /** 2 + * Operation-based undo/redo history for the diagrams whiteboard. 3 + * 4 + * Replaces the snapshot-based History class with an operation log that tracks 5 + * individual user actions and their inverses. This enables collaborative undo: 6 + * each client only undoes its own operations without overwriting remote changes. 7 + * 8 + * ## Integration guide (for main.ts — not yet wired) 9 + * 10 + * To integrate this module into the live editor: 11 + * 12 + * 1. Replace `history.push(wb)` calls with: 13 + * ```ts 14 + * const op = createOperation('move_shape', beforeState, afterState, shapeId); 15 + * operationHistory.push(op); 16 + * ``` 17 + * 18 + * 2. Replace `history.undo()` / `history.redo()` with: 19 + * ```ts 20 + * const inv = operationHistory.undo(); 21 + * if (inv) wb = applyOperation(wb, inv); 22 + * ``` 23 + * 24 + * 3. Sync operations via a Yjs Y.Array instead of full state snapshots: 25 + * ```ts 26 + * const yOps = ydoc.getArray<Operation>('ops'); 27 + * // On local push: 28 + * yOps.push([op]); 29 + * // On remote observe: 30 + * yOps.observe(e => { 31 + * const added = e.changes.added; 32 + * // loadOps merges and deduplicates 33 + * operationHistory.loadOps([...added]); 34 + * for (const remoteOp of added) wb = applyOperation(wb, remoteOp); 35 + * }); 36 + * ``` 37 + * 38 + * 4. The old History class (history.ts) remains functional for backward 39 + * compatibility but should be considered deprecated. 40 + */ 41 + 42 + import type { WhiteboardState, Shape, Arrow } from './whiteboard-types.js'; 43 + 44 + // --------------------------------------------------------------------------- 45 + // Types 46 + // --------------------------------------------------------------------------- 47 + 48 + export type OpType = 49 + | 'add_shape' | 'remove_shape' | 'move_shape' | 'resize_shape' 50 + | 'set_label' | 'set_style' | 'set_opacity' | 'rotate_shape' 51 + | 'add_arrow' | 'remove_arrow' 52 + | 'group' | 'ungroup'; 53 + 54 + export interface Operation { 55 + id: string; 56 + type: OpType; 57 + data: Record<string, unknown>; 58 + inverse: Record<string, unknown>; 59 + timestamp: number; 60 + clientId: string; 61 + } 62 + 63 + /** 64 + * Maps operation types to their inverse types. Most ops are self-inverse 65 + * (move undoes with a move to the old position). Add/remove are swapped. 66 + */ 67 + const INVERSE_TYPE: Record<OpType, OpType> = { 68 + add_shape: 'remove_shape', 69 + remove_shape: 'add_shape', 70 + move_shape: 'move_shape', 71 + resize_shape: 'resize_shape', 72 + set_label: 'set_label', 73 + set_style: 'set_style', 74 + set_opacity: 'set_opacity', 75 + rotate_shape: 'rotate_shape', 76 + add_arrow: 'remove_arrow', 77 + remove_arrow: 'add_arrow', 78 + group: 'ungroup', 79 + ungroup: 'group', 80 + }; 81 + 82 + // --------------------------------------------------------------------------- 83 + // ID generation 84 + // --------------------------------------------------------------------------- 85 + 86 + let _opCounter = 0; 87 + 88 + function generateOpId(): string { 89 + return `op-${Date.now()}-${++_opCounter}`; 90 + } 91 + 92 + function generateClientId(): string { 93 + return `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 94 + } 95 + 96 + // --------------------------------------------------------------------------- 97 + // OperationHistory 98 + // --------------------------------------------------------------------------- 99 + 100 + /** 101 + * Operation-based undo/redo stack with per-client filtering. 102 + * 103 + * Operations are pushed as they happen. Undo walks backward through the 104 + * caller's own operations (skipping remote ones), returning an inverse 105 + * operation that the caller applies to the current state. Redo re-applies 106 + * the original operation. 107 + */ 108 + export class OperationHistory { 109 + private ops: Operation[] = []; 110 + private undoneIds: Set<string> = new Set(); 111 + private readonly clientId: string; 112 + 113 + constructor(clientId?: string) { 114 + this.clientId = clientId ?? generateClientId(); 115 + } 116 + 117 + /** 118 + * Record a new operation. Returns the full Operation with id/timestamp/clientId 119 + * filled in. Clears any redo state for this client (push-after-undo branching). 120 + */ 121 + push( 122 + partial: Omit<Operation, 'id' | 'timestamp' | 'clientId'> | Operation, 123 + ): Operation { 124 + // Clear redo: remove undone entries that belong to this client and sit 125 + // after the last non-undone entry for this client. 126 + this.clearRedoForClient(); 127 + 128 + const op: Operation = { 129 + id: ('id' in partial && partial.id) ? partial.id : generateOpId(), 130 + type: partial.type, 131 + data: partial.data, 132 + inverse: partial.inverse, 133 + timestamp: ('timestamp' in partial && partial.timestamp) ? partial.timestamp : Date.now(), 134 + clientId: ('clientId' in partial && partial.clientId) ? partial.clientId : this.clientId, 135 + }; 136 + this.ops.push(op); 137 + this.undoneIds.delete(op.id); 138 + return op; 139 + } 140 + 141 + /** 142 + * Undo the most recent non-undone operation belonging to this client. 143 + * Returns an Operation whose `data` contains the inverse payload 144 + * (ready to pass to `applyOperation`), or null if nothing to undo. 145 + */ 146 + undo(): Operation | null { 147 + for (let i = this.ops.length - 1; i >= 0; i--) { 148 + const op = this.ops[i]!; 149 + if (op.clientId !== this.clientId) continue; 150 + if (this.undoneIds.has(op.id)) continue; 151 + 152 + this.undoneIds.add(op.id); 153 + // Return an operation with the inverse type and swapped data 154 + return { 155 + id: generateOpId(), 156 + type: INVERSE_TYPE[op.type], 157 + data: op.inverse, 158 + inverse: op.data, 159 + timestamp: Date.now(), 160 + clientId: this.clientId, 161 + }; 162 + } 163 + return null; 164 + } 165 + 166 + /** 167 + * Redo the most recently undone operation belonging to this client. 168 + * Returns the original operation (ready to pass to `applyOperation`), 169 + * or null if nothing to redo. 170 + */ 171 + redo(): Operation | null { 172 + for (let i = 0; i < this.ops.length; i++) { 173 + const op = this.ops[i]!; 174 + if (op.clientId !== this.clientId) continue; 175 + if (!this.undoneIds.has(op.id)) continue; 176 + 177 + // Check this is the *earliest* undone op for this client 178 + // (redo goes forward from the undo cursor) 179 + let isEarliest = true; 180 + for (let j = 0; j < i; j++) { 181 + const earlier = this.ops[j]!; 182 + if (earlier.clientId === this.clientId && this.undoneIds.has(earlier.id)) { 183 + isEarliest = false; 184 + break; 185 + } 186 + } 187 + if (!isEarliest) continue; 188 + 189 + this.undoneIds.delete(op.id); 190 + return { 191 + id: generateOpId(), 192 + type: op.type, 193 + data: op.data, 194 + inverse: op.inverse, 195 + timestamp: Date.now(), 196 + clientId: this.clientId, 197 + }; 198 + } 199 + return null; 200 + } 201 + 202 + canUndo(): boolean { 203 + for (let i = this.ops.length - 1; i >= 0; i--) { 204 + const op = this.ops[i]!; 205 + if (op.clientId === this.clientId && !this.undoneIds.has(op.id)) return true; 206 + } 207 + return false; 208 + } 209 + 210 + canRedo(): boolean { 211 + for (const op of this.ops) { 212 + if (op.clientId === this.clientId && this.undoneIds.has(op.id)) return true; 213 + } 214 + return false; 215 + } 216 + 217 + /** Get all operations in the log (for Yjs sync). */ 218 + getOps(): Operation[] { 219 + return [...this.ops]; 220 + } 221 + 222 + /** 223 + * Merge remote operations into the local log. Deduplicates by id. 224 + * Remote ops are appended but will be skipped by undo/redo (wrong clientId). 225 + */ 226 + loadOps(ops: Operation[]): void { 227 + const existing = new Set(this.ops.map((o) => o.id)); 228 + for (const op of ops) { 229 + if (!existing.has(op.id)) { 230 + this.ops.push(op); 231 + existing.add(op.id); 232 + } 233 + } 234 + } 235 + 236 + /** 237 + * When a new operation is pushed, discard redo entries for this client. 238 + * This mimics the branch-on-push semantics of the old snapshot History. 239 + */ 240 + private clearRedoForClient(): void { 241 + const toRemove: string[] = []; 242 + for (const op of this.ops) { 243 + if (op.clientId === this.clientId && this.undoneIds.has(op.id)) { 244 + toRemove.push(op.id); 245 + } 246 + } 247 + if (toRemove.length === 0) return; 248 + const removeSet = new Set(toRemove); 249 + this.ops = this.ops.filter((op) => !removeSet.has(op.id)); 250 + for (const id of toRemove) this.undoneIds.delete(id); 251 + } 252 + } 253 + 254 + // --------------------------------------------------------------------------- 255 + // applyOperation — single dispatch point for all op types 256 + // --------------------------------------------------------------------------- 257 + 258 + /** 259 + * Apply an operation to a WhiteboardState, returning the new state. 260 + * This is a pure function — it never mutates the input. 261 + */ 262 + export function applyOperation( 263 + state: WhiteboardState, 264 + op: Operation, 265 + ): WhiteboardState { 266 + const d = op.data; 267 + 268 + switch (op.type) { 269 + case 'add_shape': { 270 + const shape = d.shape as Shape; 271 + const shapeId = (d.shapeId as string) ?? shape.id; 272 + const shapes = new Map(state.shapes); 273 + shapes.set(shapeId, shape); 274 + return { ...state, shapes }; 275 + } 276 + 277 + case 'remove_shape': { 278 + const shapeId = d.shapeId as string; 279 + if (!state.shapes.has(shapeId)) return state; 280 + const shapes = new Map(state.shapes); 281 + shapes.delete(shapeId); 282 + // Also remove connected arrows 283 + const arrows = new Map(state.arrows); 284 + for (const [aid, arrow] of arrows) { 285 + const fromConnected = 'shapeId' in arrow.from && (arrow.from as { shapeId: string }).shapeId === shapeId; 286 + const toConnected = 'shapeId' in arrow.to && (arrow.to as { shapeId: string }).shapeId === shapeId; 287 + if (fromConnected || toConnected) arrows.delete(aid); 288 + } 289 + return { ...state, shapes, arrows }; 290 + } 291 + 292 + case 'move_shape': { 293 + const shapeId = d.shapeId as string; 294 + const shape = state.shapes.get(shapeId); 295 + if (!shape) return state; 296 + const shapes = new Map(state.shapes); 297 + shapes.set(shapeId, { ...shape, x: d.x as number, y: d.y as number }); 298 + return { ...state, shapes }; 299 + } 300 + 301 + case 'resize_shape': { 302 + const shapeId = d.shapeId as string; 303 + const shape = state.shapes.get(shapeId); 304 + if (!shape) return state; 305 + const shapes = new Map(state.shapes); 306 + shapes.set(shapeId, { ...shape, width: d.width as number, height: d.height as number }); 307 + return { ...state, shapes }; 308 + } 309 + 310 + case 'set_label': { 311 + const shapeId = d.shapeId as string; 312 + const shape = state.shapes.get(shapeId); 313 + if (!shape) return state; 314 + const shapes = new Map(state.shapes); 315 + shapes.set(shapeId, { ...shape, label: d.label as string }); 316 + return { ...state, shapes }; 317 + } 318 + 319 + case 'set_style': { 320 + const shapeId = d.shapeId as string; 321 + const shape = state.shapes.get(shapeId); 322 + if (!shape) return state; 323 + const shapes = new Map(state.shapes); 324 + shapes.set(shapeId, { ...shape, style: d.style as Record<string, string> }); 325 + return { ...state, shapes }; 326 + } 327 + 328 + case 'set_opacity': { 329 + const shapeId = d.shapeId as string; 330 + const shape = state.shapes.get(shapeId); 331 + if (!shape) return state; 332 + const shapes = new Map(state.shapes); 333 + shapes.set(shapeId, { ...shape, opacity: d.opacity as number }); 334 + return { ...state, shapes }; 335 + } 336 + 337 + case 'rotate_shape': { 338 + const shapeId = d.shapeId as string; 339 + const shape = state.shapes.get(shapeId); 340 + if (!shape) return state; 341 + const shapes = new Map(state.shapes); 342 + shapes.set(shapeId, { ...shape, rotation: d.rotation as number }); 343 + return { ...state, shapes }; 344 + } 345 + 346 + case 'add_arrow': { 347 + const arrow = d.arrow as Arrow; 348 + const arrowId = (d.arrowId as string) ?? arrow.id; 349 + const arrows = new Map(state.arrows); 350 + arrows.set(arrowId, arrow); 351 + return { ...state, arrows }; 352 + } 353 + 354 + case 'remove_arrow': { 355 + const arrowId = d.arrowId as string; 356 + if (!state.arrows.has(arrowId)) return state; 357 + const arrows = new Map(state.arrows); 358 + arrows.delete(arrowId); 359 + return { ...state, arrows }; 360 + } 361 + 362 + case 'group': { 363 + const groupId = d.groupId as string; 364 + const shapeIds = d.shapeIds as string[]; 365 + const shapes = new Map(state.shapes); 366 + for (const id of shapeIds) { 367 + const shape = shapes.get(id); 368 + if (shape) shapes.set(id, { ...shape, groupId }); 369 + } 370 + return { ...state, shapes }; 371 + } 372 + 373 + case 'ungroup': { 374 + const shapeIds = d.shapeIds as string[]; 375 + const shapes = new Map(state.shapes); 376 + for (const id of shapeIds) { 377 + const shape = shapes.get(id); 378 + if (shape) shapes.set(id, { ...shape, groupId: undefined }); 379 + } 380 + return { ...state, shapes }; 381 + } 382 + 383 + default: 384 + return state; 385 + } 386 + } 387 + 388 + // --------------------------------------------------------------------------- 389 + // createOperation — build operations from before/after state diffs 390 + // --------------------------------------------------------------------------- 391 + 392 + /** 393 + * Create an operation by comparing before and after WhiteboardState. 394 + * 395 + * @param type The operation type. 396 + * @param before State before the change. 397 + * @param after State after the change. 398 + * @param entityId The shape or arrow ID involved. 399 + * @returns An operation partial (without id/timestamp/clientId — pass to push()). 400 + */ 401 + export function createOperation( 402 + type: OpType, 403 + before: WhiteboardState, 404 + after: WhiteboardState, 405 + entityId: string, 406 + ): Omit<Operation, 'id' | 'timestamp' | 'clientId'> { 407 + switch (type) { 408 + case 'add_shape': { 409 + const shape = after.shapes.get(entityId)!; 410 + return { 411 + type, 412 + data: { shapeId: entityId, shape }, 413 + inverse: { shapeId: entityId }, 414 + }; 415 + } 416 + 417 + case 'remove_shape': { 418 + const shape = before.shapes.get(entityId)!; 419 + return { 420 + type, 421 + data: { shapeId: entityId }, 422 + inverse: { shapeId: entityId, shape }, 423 + }; 424 + } 425 + 426 + case 'move_shape': { 427 + const beforeShape = before.shapes.get(entityId)!; 428 + const afterShape = after.shapes.get(entityId)!; 429 + return { 430 + type, 431 + data: { shapeId: entityId, x: afterShape.x, y: afterShape.y }, 432 + inverse: { shapeId: entityId, x: beforeShape.x, y: beforeShape.y }, 433 + }; 434 + } 435 + 436 + case 'resize_shape': { 437 + const beforeShape = before.shapes.get(entityId)!; 438 + const afterShape = after.shapes.get(entityId)!; 439 + return { 440 + type, 441 + data: { shapeId: entityId, width: afterShape.width, height: afterShape.height }, 442 + inverse: { shapeId: entityId, width: beforeShape.width, height: beforeShape.height }, 443 + }; 444 + } 445 + 446 + case 'set_label': { 447 + const beforeShape = before.shapes.get(entityId)!; 448 + const afterShape = after.shapes.get(entityId)!; 449 + return { 450 + type, 451 + data: { shapeId: entityId, label: afterShape.label }, 452 + inverse: { shapeId: entityId, label: beforeShape.label }, 453 + }; 454 + } 455 + 456 + case 'set_style': { 457 + const beforeShape = before.shapes.get(entityId)!; 458 + const afterShape = after.shapes.get(entityId)!; 459 + return { 460 + type, 461 + data: { shapeId: entityId, style: { ...afterShape.style } }, 462 + inverse: { shapeId: entityId, style: { ...beforeShape.style } }, 463 + }; 464 + } 465 + 466 + case 'set_opacity': { 467 + const beforeShape = before.shapes.get(entityId)!; 468 + const afterShape = after.shapes.get(entityId)!; 469 + return { 470 + type, 471 + data: { shapeId: entityId, opacity: afterShape.opacity }, 472 + inverse: { shapeId: entityId, opacity: beforeShape.opacity }, 473 + }; 474 + } 475 + 476 + case 'rotate_shape': { 477 + const beforeShape = before.shapes.get(entityId)!; 478 + const afterShape = after.shapes.get(entityId)!; 479 + return { 480 + type, 481 + data: { shapeId: entityId, rotation: afterShape.rotation }, 482 + inverse: { shapeId: entityId, rotation: beforeShape.rotation }, 483 + }; 484 + } 485 + 486 + case 'add_arrow': { 487 + const arrow = after.arrows.get(entityId)!; 488 + return { 489 + type, 490 + data: { arrowId: entityId, arrow }, 491 + inverse: { arrowId: entityId }, 492 + }; 493 + } 494 + 495 + case 'remove_arrow': { 496 + const arrow = before.arrows.get(entityId)!; 497 + return { 498 + type, 499 + data: { arrowId: entityId }, 500 + inverse: { arrowId: entityId, arrow }, 501 + }; 502 + } 503 + 504 + case 'group': 505 + case 'ungroup': 506 + // Group/ungroup ops are typically constructed directly, not from diffs. 507 + // This fallback returns empty payloads — callers should build these manually. 508 + return { type, data: {}, inverse: {} }; 509 + 510 + default: 511 + return { type, data: {}, inverse: {} }; 512 + } 513 + }
+746
tests/operation-history.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + OperationHistory, 4 + applyOperation, 5 + createOperation, 6 + } from '../src/diagrams/operation-history'; 7 + import type { Operation, OpType } from '../src/diagrams/operation-history'; 8 + import { 9 + createWhiteboard, 10 + addShape, 11 + moveShape, 12 + resizeShape, 13 + setShapeLabel, 14 + addArrow, 15 + removeArrow, 16 + removeShape, 17 + } from '../src/diagrams/whiteboard'; 18 + import { 19 + rotateShape, 20 + setShapeStyle, 21 + setShapeOpacity, 22 + } from '../src/diagrams/whiteboard-transforms'; 23 + import type { WhiteboardState, Shape, Arrow } from '../src/diagrams/whiteboard'; 24 + 25 + // --------------------------------------------------------------------------- 26 + // Helpers 27 + // --------------------------------------------------------------------------- 28 + 29 + /** Create a whiteboard with snap-to-grid disabled for predictable coordinates. */ 30 + function noSnapBoard(): WhiteboardState { 31 + const wb = createWhiteboard(); 32 + return { ...wb, snapToGrid: false }; 33 + } 34 + 35 + /** Add a shape and return both the new state and the shape. */ 36 + function addTestShape( 37 + state: WhiteboardState, 38 + kind: Shape['kind'] = 'rectangle', 39 + x = 0, 40 + y = 0, 41 + ): { state: WhiteboardState; shape: Shape } { 42 + const next = addShape(state, kind, x, y); 43 + const shape = [...next.shapes.values()].find((s) => !state.shapes.has(s.id))!; 44 + return { state: next, shape }; 45 + } 46 + 47 + /** Add an arrow and return both the new state and the arrow. */ 48 + function addTestArrow( 49 + state: WhiteboardState, 50 + fromShapeId: string, 51 + toShapeId: string, 52 + ): { state: WhiteboardState; arrow: Arrow } { 53 + const next = addArrow( 54 + state, 55 + { shapeId: fromShapeId, anchor: 'right' }, 56 + { shapeId: toShapeId, anchor: 'left' }, 57 + 'test-arrow', 58 + ); 59 + const arrow = [...next.arrows.values()].find((a) => !state.arrows.has(a.id))!; 60 + return { state: next, arrow }; 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // OperationHistory — core stack behavior 65 + // --------------------------------------------------------------------------- 66 + 67 + describe('OperationHistory', () => { 68 + let history: OperationHistory; 69 + 70 + beforeEach(() => { 71 + history = new OperationHistory('client-1'); 72 + }); 73 + 74 + describe('push', () => { 75 + it('returns an Operation with id, timestamp, and clientId', () => { 76 + const op = history.push({ 77 + type: 'add_shape', 78 + data: { shapeId: 's1', shape: {} }, 79 + inverse: { shapeId: 's1' }, 80 + }); 81 + expect(op.id).toBeTruthy(); 82 + expect(op.timestamp).toBeGreaterThan(0); 83 + expect(op.clientId).toBe('client-1'); 84 + }); 85 + 86 + it('generates unique ids for each operation', () => { 87 + const a = history.push({ type: 'add_shape', data: {}, inverse: {} }); 88 + const b = history.push({ type: 'add_shape', data: {}, inverse: {} }); 89 + expect(a.id).not.toBe(b.id); 90 + }); 91 + }); 92 + 93 + describe('undo', () => { 94 + it('returns null on empty history', () => { 95 + expect(history.undo()).toBeNull(); 96 + }); 97 + 98 + it('returns the inverse operation for the most recent push', () => { 99 + history.push({ 100 + type: 'move_shape', 101 + data: { shapeId: 's1', x: 100, y: 200 }, 102 + inverse: { shapeId: 's1', x: 0, y: 0 }, 103 + }); 104 + const inv = history.undo(); 105 + expect(inv).not.toBeNull(); 106 + expect(inv!.type).toBe('move_shape'); 107 + expect(inv!.data).toEqual({ shapeId: 's1', x: 0, y: 0 }); 108 + }); 109 + 110 + it('returns null when all operations are already undone', () => { 111 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 112 + history.undo(); 113 + expect(history.undo()).toBeNull(); 114 + }); 115 + 116 + it('undoes operations in LIFO order', () => { 117 + history.push({ 118 + type: 'move_shape', 119 + data: { shapeId: 's1', x: 10, y: 10 }, 120 + inverse: { shapeId: 's1', x: 0, y: 0 }, 121 + }); 122 + history.push({ 123 + type: 'move_shape', 124 + data: { shapeId: 's1', x: 20, y: 20 }, 125 + inverse: { shapeId: 's1', x: 10, y: 10 }, 126 + }); 127 + 128 + const inv1 = history.undo()!; 129 + expect(inv1.data).toEqual({ shapeId: 's1', x: 10, y: 10 }); 130 + 131 + const inv2 = history.undo()!; 132 + expect(inv2.data).toEqual({ shapeId: 's1', x: 0, y: 0 }); 133 + }); 134 + }); 135 + 136 + describe('redo', () => { 137 + it('returns null with no undone operations', () => { 138 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 139 + expect(history.redo()).toBeNull(); 140 + }); 141 + 142 + it('returns null on empty history', () => { 143 + expect(history.redo()).toBeNull(); 144 + }); 145 + 146 + it('re-applies the original operation after undo', () => { 147 + history.push({ 148 + type: 'move_shape', 149 + data: { shapeId: 's1', x: 100, y: 200 }, 150 + inverse: { shapeId: 's1', x: 0, y: 0 }, 151 + }); 152 + history.undo(); 153 + const redo = history.redo(); 154 + expect(redo).not.toBeNull(); 155 + expect(redo!.type).toBe('move_shape'); 156 + expect(redo!.data).toEqual({ shapeId: 's1', x: 100, y: 200 }); 157 + }); 158 + 159 + it('supports multiple undo then redo in sequence', () => { 160 + history.push({ type: 'add_shape', data: { n: 1 }, inverse: { n: 1 } }); 161 + history.push({ type: 'add_shape', data: { n: 2 }, inverse: { n: 2 } }); 162 + history.push({ type: 'add_shape', data: { n: 3 }, inverse: { n: 3 } }); 163 + 164 + history.undo(); 165 + history.undo(); 166 + 167 + const r1 = history.redo()!; 168 + expect(r1.data).toEqual({ n: 2 }); 169 + const r2 = history.redo()!; 170 + expect(r2.data).toEqual({ n: 3 }); 171 + expect(history.redo()).toBeNull(); 172 + }); 173 + }); 174 + 175 + describe('push after undo clears redo stack', () => { 176 + it('discards redo entries when a new operation is pushed', () => { 177 + history.push({ type: 'add_shape', data: { n: 1 }, inverse: {} }); 178 + history.push({ type: 'add_shape', data: { n: 2 }, inverse: {} }); 179 + history.undo(); 180 + expect(history.canRedo()).toBe(true); 181 + 182 + history.push({ type: 'add_shape', data: { n: 3 }, inverse: {} }); 183 + expect(history.canRedo()).toBe(false); 184 + expect(history.redo()).toBeNull(); 185 + }); 186 + }); 187 + 188 + describe('canUndo / canRedo', () => { 189 + it('both false on empty history', () => { 190 + expect(history.canUndo()).toBe(false); 191 + expect(history.canRedo()).toBe(false); 192 + }); 193 + 194 + it('canUndo true after push, canRedo false', () => { 195 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 196 + expect(history.canUndo()).toBe(true); 197 + expect(history.canRedo()).toBe(false); 198 + }); 199 + 200 + it('canUndo false and canRedo true after undoing the only operation', () => { 201 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 202 + history.undo(); 203 + expect(history.canUndo()).toBe(false); 204 + expect(history.canRedo()).toBe(true); 205 + }); 206 + 207 + it('both true when in the middle of the stack', () => { 208 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 209 + history.push({ type: 'add_shape', data: {}, inverse: {} }); 210 + history.undo(); 211 + expect(history.canUndo()).toBe(true); 212 + expect(history.canRedo()).toBe(true); 213 + }); 214 + }); 215 + 216 + describe('client ID filtering', () => { 217 + it('only undoes operations from the same client', () => { 218 + const h = new OperationHistory('client-A'); 219 + 220 + h.push({ 221 + type: 'move_shape', 222 + data: { shapeId: 's1', x: 10, y: 10 }, 223 + inverse: { shapeId: 's1', x: 0, y: 0 }, 224 + }); 225 + 226 + const remoteOp: Operation = { 227 + id: 'remote-1', 228 + type: 'move_shape', 229 + data: { shapeId: 's2', x: 50, y: 50 }, 230 + inverse: { shapeId: 's2', x: 0, y: 0 }, 231 + timestamp: Date.now(), 232 + clientId: 'client-B', 233 + }; 234 + h.loadOps([remoteOp]); 235 + 236 + const inv = h.undo(); 237 + expect(inv).not.toBeNull(); 238 + expect(inv!.data).toEqual({ shapeId: 's1', x: 0, y: 0 }); 239 + }); 240 + 241 + it('returns null when only remote operations remain', () => { 242 + const h = new OperationHistory('client-A'); 243 + const remoteOp: Operation = { 244 + id: 'remote-1', 245 + type: 'add_shape', 246 + data: {}, 247 + inverse: {}, 248 + timestamp: Date.now(), 249 + clientId: 'client-B', 250 + }; 251 + h.loadOps([remoteOp]); 252 + expect(h.undo()).toBeNull(); 253 + }); 254 + }); 255 + 256 + describe('getOps / loadOps', () => { 257 + it('getOps returns all pushed operations', () => { 258 + history.push({ type: 'add_shape', data: { n: 1 }, inverse: {} }); 259 + history.push({ type: 'add_shape', data: { n: 2 }, inverse: {} }); 260 + const ops = history.getOps(); 261 + expect(ops).toHaveLength(2); 262 + expect(ops[0]!.data).toEqual({ n: 1 }); 263 + expect(ops[1]!.data).toEqual({ n: 2 }); 264 + }); 265 + 266 + it('loadOps merges remote operations into the log', () => { 267 + history.push({ type: 'add_shape', data: { n: 1 }, inverse: {} }); 268 + const remoteOps: Operation[] = [ 269 + { 270 + id: 'r1', 271 + type: 'move_shape', 272 + data: { n: 99 }, 273 + inverse: {}, 274 + timestamp: Date.now(), 275 + clientId: 'other', 276 + }, 277 + ]; 278 + history.loadOps(remoteOps); 279 + const all = history.getOps(); 280 + expect(all).toHaveLength(2); 281 + }); 282 + 283 + it('loadOps deduplicates by id', () => { 284 + const op = history.push({ type: 'add_shape', data: {}, inverse: {} }); 285 + history.loadOps([op]); 286 + expect(history.getOps()).toHaveLength(1); 287 + }); 288 + }); 289 + 290 + describe('generates a clientId when none provided', () => { 291 + it('auto-generates a non-empty clientId', () => { 292 + const h = new OperationHistory(); 293 + const op = h.push({ type: 'add_shape', data: {}, inverse: {} }); 294 + expect(op.clientId).toBeTruthy(); 295 + expect(op.clientId.length).toBeGreaterThan(0); 296 + }); 297 + }); 298 + }); 299 + 300 + // --------------------------------------------------------------------------- 301 + // applyOperation — every OpType 302 + // --------------------------------------------------------------------------- 303 + 304 + describe('applyOperation', () => { 305 + let wb: WhiteboardState; 306 + 307 + beforeEach(() => { 308 + wb = noSnapBoard(); 309 + }); 310 + 311 + describe('add_shape', () => { 312 + it('adds a shape to the whiteboard', () => { 313 + const shape: Shape = { 314 + id: 's1', kind: 'rectangle', x: 10, y: 20, width: 100, height: 50, 315 + rotation: 0, label: 'hi', style: {}, opacity: 1, 316 + }; 317 + const op: Operation = { 318 + id: 'op1', type: 'add_shape', timestamp: Date.now(), clientId: 'c1', 319 + data: { shapeId: 's1', shape }, 320 + inverse: { shapeId: 's1' }, 321 + }; 322 + const next = applyOperation(wb, op); 323 + expect(next.shapes.has('s1')).toBe(true); 324 + expect(next.shapes.get('s1')!.label).toBe('hi'); 325 + }); 326 + }); 327 + 328 + describe('remove_shape', () => { 329 + it('removes a shape from the whiteboard', () => { 330 + const { state, shape } = addTestShape(wb); 331 + const op: Operation = { 332 + id: 'op1', type: 'remove_shape', timestamp: Date.now(), clientId: 'c1', 333 + data: { shapeId: shape.id }, 334 + inverse: { shapeId: shape.id, shape }, 335 + }; 336 + const next = applyOperation(state, op); 337 + expect(next.shapes.has(shape.id)).toBe(false); 338 + }); 339 + 340 + it('is a no-op if the shape does not exist', () => { 341 + const op: Operation = { 342 + id: 'op1', type: 'remove_shape', timestamp: Date.now(), clientId: 'c1', 343 + data: { shapeId: 'nonexistent' }, 344 + inverse: { shapeId: 'nonexistent', shape: null }, 345 + }; 346 + const next = applyOperation(wb, op); 347 + expect(next.shapes.size).toBe(0); 348 + }); 349 + }); 350 + 351 + describe('move_shape', () => { 352 + it('moves a shape to new coordinates', () => { 353 + const { state, shape } = addTestShape(wb, 'rectangle', 0, 0); 354 + const op: Operation = { 355 + id: 'op1', type: 'move_shape', timestamp: Date.now(), clientId: 'c1', 356 + data: { shapeId: shape.id, x: 50, y: 75 }, 357 + inverse: { shapeId: shape.id, x: shape.x, y: shape.y }, 358 + }; 359 + const next = applyOperation(state, op); 360 + const moved = next.shapes.get(shape.id)!; 361 + expect(moved.x).toBe(50); 362 + expect(moved.y).toBe(75); 363 + }); 364 + }); 365 + 366 + describe('resize_shape', () => { 367 + it('resizes a shape to new dimensions', () => { 368 + const { state, shape } = addTestShape(wb); 369 + const op: Operation = { 370 + id: 'op1', type: 'resize_shape', timestamp: Date.now(), clientId: 'c1', 371 + data: { shapeId: shape.id, width: 200, height: 150 }, 372 + inverse: { shapeId: shape.id, width: shape.width, height: shape.height }, 373 + }; 374 + const next = applyOperation(state, op); 375 + const resized = next.shapes.get(shape.id)!; 376 + expect(resized.width).toBe(200); 377 + expect(resized.height).toBe(150); 378 + }); 379 + }); 380 + 381 + describe('set_label', () => { 382 + it('updates the label on a shape', () => { 383 + const { state, shape } = addTestShape(wb); 384 + const op: Operation = { 385 + id: 'op1', type: 'set_label', timestamp: Date.now(), clientId: 'c1', 386 + data: { shapeId: shape.id, label: 'new label' }, 387 + inverse: { shapeId: shape.id, label: shape.label }, 388 + }; 389 + const next = applyOperation(state, op); 390 + expect(next.shapes.get(shape.id)!.label).toBe('new label'); 391 + }); 392 + }); 393 + 394 + describe('set_style', () => { 395 + it('sets style properties on a shape', () => { 396 + const { state, shape } = addTestShape(wb); 397 + const op: Operation = { 398 + id: 'op1', type: 'set_style', timestamp: Date.now(), clientId: 'c1', 399 + data: { shapeId: shape.id, style: { fill: '#ff0000' } }, 400 + inverse: { shapeId: shape.id, style: { ...shape.style } }, 401 + }; 402 + const next = applyOperation(state, op); 403 + expect(next.shapes.get(shape.id)!.style.fill).toBe('#ff0000'); 404 + }); 405 + }); 406 + 407 + describe('set_opacity', () => { 408 + it('sets opacity on a shape', () => { 409 + const { state, shape } = addTestShape(wb); 410 + const op: Operation = { 411 + id: 'op1', type: 'set_opacity', timestamp: Date.now(), clientId: 'c1', 412 + data: { shapeId: shape.id, opacity: 0.5 }, 413 + inverse: { shapeId: shape.id, opacity: shape.opacity }, 414 + }; 415 + const next = applyOperation(state, op); 416 + expect(next.shapes.get(shape.id)!.opacity).toBe(0.5); 417 + }); 418 + }); 419 + 420 + describe('rotate_shape', () => { 421 + it('sets rotation on a shape', () => { 422 + const { state, shape } = addTestShape(wb); 423 + const op: Operation = { 424 + id: 'op1', type: 'rotate_shape', timestamp: Date.now(), clientId: 'c1', 425 + data: { shapeId: shape.id, rotation: 45 }, 426 + inverse: { shapeId: shape.id, rotation: shape.rotation }, 427 + }; 428 + const next = applyOperation(state, op); 429 + expect(next.shapes.get(shape.id)!.rotation).toBe(45); 430 + }); 431 + }); 432 + 433 + describe('add_arrow', () => { 434 + it('adds an arrow to the whiteboard', () => { 435 + const { state: s1, shape: shapeA } = addTestShape(wb); 436 + const { state: s2, shape: shapeB } = addTestShape(s1, 'ellipse', 200, 0); 437 + const arrow: Arrow = { 438 + id: 'a1', 439 + from: { shapeId: shapeA.id, anchor: 'right' }, 440 + to: { shapeId: shapeB.id, anchor: 'left' }, 441 + label: '', 442 + style: {}, 443 + }; 444 + const op: Operation = { 445 + id: 'op1', type: 'add_arrow', timestamp: Date.now(), clientId: 'c1', 446 + data: { arrowId: 'a1', arrow }, 447 + inverse: { arrowId: 'a1' }, 448 + }; 449 + const next = applyOperation(s2, op); 450 + expect(next.arrows.has('a1')).toBe(true); 451 + }); 452 + }); 453 + 454 + describe('remove_arrow', () => { 455 + it('removes an arrow from the whiteboard', () => { 456 + const { state: s1, shape: shapeA } = addTestShape(wb); 457 + const { state: s2, shape: shapeB } = addTestShape(s1, 'ellipse', 200, 0); 458 + const { state: s3, arrow } = addTestArrow(s2, shapeA.id, shapeB.id); 459 + 460 + const op: Operation = { 461 + id: 'op1', type: 'remove_arrow', timestamp: Date.now(), clientId: 'c1', 462 + data: { arrowId: arrow.id }, 463 + inverse: { arrowId: arrow.id, arrow }, 464 + }; 465 + const next = applyOperation(s3, op); 466 + expect(next.arrows.has(arrow.id)).toBe(false); 467 + }); 468 + }); 469 + 470 + describe('group', () => { 471 + it('assigns a groupId to shapes', () => { 472 + const { state: s1, shape: a } = addTestShape(wb, 'rectangle', 0, 0); 473 + const { state: s2, shape: b } = addTestShape(s1, 'ellipse', 100, 0); 474 + const op: Operation = { 475 + id: 'op1', type: 'group', timestamp: Date.now(), clientId: 'c1', 476 + data: { groupId: 'g1', shapeIds: [a.id, b.id] }, 477 + inverse: { groupId: 'g1', shapeIds: [a.id, b.id] }, 478 + }; 479 + const next = applyOperation(s2, op); 480 + expect(next.shapes.get(a.id)!.groupId).toBe('g1'); 481 + expect(next.shapes.get(b.id)!.groupId).toBe('g1'); 482 + }); 483 + }); 484 + 485 + describe('ungroup', () => { 486 + it('clears groupId from shapes', () => { 487 + const { state: s1, shape: a } = addTestShape(wb, 'rectangle', 0, 0); 488 + const { state: s2, shape: b } = addTestShape(s1, 'ellipse', 100, 0); 489 + const shapes = new Map(s2.shapes); 490 + shapes.set(a.id, { ...a, groupId: 'g1' }); 491 + shapes.set(b.id, { ...b, groupId: 'g1' }); 492 + const grouped = { ...s2, shapes }; 493 + 494 + const op: Operation = { 495 + id: 'op1', type: 'ungroup', timestamp: Date.now(), clientId: 'c1', 496 + data: { groupId: 'g1', shapeIds: [a.id, b.id] }, 497 + inverse: { groupId: 'g1', shapeIds: [a.id, b.id] }, 498 + }; 499 + const next = applyOperation(grouped, op); 500 + expect(next.shapes.get(a.id)!.groupId).toBeUndefined(); 501 + expect(next.shapes.get(b.id)!.groupId).toBeUndefined(); 502 + }); 503 + }); 504 + 505 + describe('unknown operation type', () => { 506 + it('returns state unchanged', () => { 507 + const op: Operation = { 508 + id: 'op1', type: 'nonexistent' as OpType, timestamp: Date.now(), clientId: 'c1', 509 + data: {}, inverse: {}, 510 + }; 511 + const next = applyOperation(wb, op); 512 + expect(next).toBe(wb); 513 + }); 514 + }); 515 + 516 + describe('missing shape is a no-op', () => { 517 + it('move_shape on missing shape returns state unchanged', () => { 518 + const op: Operation = { 519 + id: 'op1', type: 'move_shape', timestamp: Date.now(), clientId: 'c1', 520 + data: { shapeId: 'missing', x: 10, y: 10 }, 521 + inverse: { shapeId: 'missing', x: 0, y: 0 }, 522 + }; 523 + const next = applyOperation(wb, op); 524 + expect(next).toBe(wb); 525 + }); 526 + }); 527 + }); 528 + 529 + // --------------------------------------------------------------------------- 530 + // createOperation — automatic diff computation 531 + // --------------------------------------------------------------------------- 532 + 533 + describe('createOperation', () => { 534 + let wb: WhiteboardState; 535 + 536 + beforeEach(() => { 537 + wb = noSnapBoard(); 538 + }); 539 + 540 + describe('add_shape', () => { 541 + it('detects a newly added shape', () => { 542 + const after = addShape(wb, 'rectangle', 10, 20); 543 + const newShape = [...after.shapes.values()].find((s) => !wb.shapes.has(s.id))!; 544 + const op = createOperation('add_shape', wb, after, newShape.id); 545 + expect(op.type).toBe('add_shape'); 546 + expect((op.data as Record<string, unknown>).shapeId).toBe(newShape.id); 547 + expect((op.data as Record<string, unknown>).shape).toEqual(newShape); 548 + expect((op.inverse as Record<string, unknown>).shapeId).toBe(newShape.id); 549 + }); 550 + }); 551 + 552 + describe('remove_shape', () => { 553 + it('detects a removed shape', () => { 554 + const { state, shape } = addTestShape(wb); 555 + const after = removeShape(state, shape.id); 556 + const op = createOperation('remove_shape', state, after, shape.id); 557 + expect(op.type).toBe('remove_shape'); 558 + expect((op.data as Record<string, unknown>).shapeId).toBe(shape.id); 559 + expect((op.inverse as Record<string, unknown>).shape).toEqual(shape); 560 + }); 561 + }); 562 + 563 + describe('move_shape', () => { 564 + it('computes position diff', () => { 565 + const { state, shape } = addTestShape(wb, 'rectangle', 0, 0); 566 + const after = moveShape(state, shape.id, 100, 200); 567 + const op = createOperation('move_shape', state, after, shape.id); 568 + expect(op.type).toBe('move_shape'); 569 + const data = op.data as Record<string, unknown>; 570 + expect(data.x).toBe(100); 571 + expect(data.y).toBe(200); 572 + const inv = op.inverse as Record<string, unknown>; 573 + expect(inv.x).toBe(shape.x); 574 + expect(inv.y).toBe(shape.y); 575 + }); 576 + }); 577 + 578 + describe('resize_shape', () => { 579 + it('computes dimension diff', () => { 580 + const { state, shape } = addTestShape(wb); 581 + const after = resizeShape(state, shape.id, 300, 250); 582 + const op = createOperation('resize_shape', state, after, shape.id); 583 + expect(op.type).toBe('resize_shape'); 584 + const data = op.data as Record<string, unknown>; 585 + expect(data.width).toBe(300); 586 + expect(data.height).toBe(250); 587 + const inv = op.inverse as Record<string, unknown>; 588 + expect(inv.width).toBe(shape.width); 589 + expect(inv.height).toBe(shape.height); 590 + }); 591 + }); 592 + 593 + describe('set_label', () => { 594 + it('computes label diff', () => { 595 + const { state, shape } = addTestShape(wb); 596 + const after = setShapeLabel(state, shape.id, 'hello'); 597 + const op = createOperation('set_label', state, after, shape.id); 598 + expect((op.data as Record<string, unknown>).label).toBe('hello'); 599 + expect((op.inverse as Record<string, unknown>).label).toBe(shape.label); 600 + }); 601 + }); 602 + 603 + describe('set_style', () => { 604 + it('computes style diff', () => { 605 + const { state, shape } = addTestShape(wb); 606 + const after = setShapeStyle(state, [shape.id], { fill: '#00ff00' }); 607 + const op = createOperation('set_style', state, after, shape.id); 608 + expect((op.data as Record<string, unknown>).style).toEqual({ fill: '#00ff00' }); 609 + expect((op.inverse as Record<string, unknown>).style).toEqual(shape.style); 610 + }); 611 + }); 612 + 613 + describe('set_opacity', () => { 614 + it('computes opacity diff', () => { 615 + const { state, shape } = addTestShape(wb); 616 + const after = setShapeOpacity(state, [shape.id], 0.3); 617 + const op = createOperation('set_opacity', state, after, shape.id); 618 + expect((op.data as Record<string, unknown>).opacity).toBe(0.3); 619 + expect((op.inverse as Record<string, unknown>).opacity).toBe(1); 620 + }); 621 + }); 622 + 623 + describe('rotate_shape', () => { 624 + it('computes rotation diff', () => { 625 + const { state, shape } = addTestShape(wb); 626 + const after = rotateShape(state, shape.id, 90); 627 + const op = createOperation('rotate_shape', state, after, shape.id); 628 + expect((op.data as Record<string, unknown>).rotation).toBe(90); 629 + expect((op.inverse as Record<string, unknown>).rotation).toBe(0); 630 + }); 631 + }); 632 + 633 + describe('add_arrow', () => { 634 + it('detects a newly added arrow', () => { 635 + const { state: s1, shape: shapeA } = addTestShape(wb); 636 + const { state: s2, shape: shapeB } = addTestShape(s1, 'ellipse', 200, 0); 637 + const after = addArrow(s2, { shapeId: shapeA.id, anchor: 'right' }, { shapeId: shapeB.id, anchor: 'left' }); 638 + const newArrow = [...after.arrows.values()].find((a) => !s2.arrows.has(a.id))!; 639 + const op = createOperation('add_arrow', s2, after, newArrow.id); 640 + expect(op.type).toBe('add_arrow'); 641 + expect((op.data as Record<string, unknown>).arrowId).toBe(newArrow.id); 642 + expect((op.data as Record<string, unknown>).arrow).toEqual(newArrow); 643 + }); 644 + }); 645 + 646 + describe('remove_arrow', () => { 647 + it('detects a removed arrow', () => { 648 + const { state: s1, shape: shapeA } = addTestShape(wb); 649 + const { state: s2, shape: shapeB } = addTestShape(s1, 'ellipse', 200, 0); 650 + const { state: s3, arrow } = addTestArrow(s2, shapeA.id, shapeB.id); 651 + const after = removeArrow(s3, arrow.id); 652 + const op = createOperation('remove_arrow', s3, after, arrow.id); 653 + expect(op.type).toBe('remove_arrow'); 654 + expect((op.data as Record<string, unknown>).arrowId).toBe(arrow.id); 655 + expect((op.inverse as Record<string, unknown>).arrow).toEqual(arrow); 656 + }); 657 + }); 658 + }); 659 + 660 + // --------------------------------------------------------------------------- 661 + // Round-trip: push -> undo -> apply gives back original state 662 + // --------------------------------------------------------------------------- 663 + 664 + describe('round-trip undo/redo via applyOperation', () => { 665 + let wb: WhiteboardState; 666 + 667 + beforeEach(() => { 668 + wb = noSnapBoard(); 669 + }); 670 + 671 + it('undo add_shape restores original state', () => { 672 + const history = new OperationHistory('c1'); 673 + const after = addShape(wb, 'rectangle', 10, 20); 674 + const newShape = [...after.shapes.values()].find((s) => !wb.shapes.has(s.id))!; 675 + const partialOp = createOperation('add_shape', wb, after, newShape.id); 676 + history.push(partialOp); 677 + 678 + const inv = history.undo()!; 679 + const restored = applyOperation(after, inv); 680 + expect(restored.shapes.size).toBe(0); 681 + }); 682 + 683 + it('undo move_shape restores original position', () => { 684 + const history = new OperationHistory('c1'); 685 + const { state, shape } = addTestShape(wb, 'rectangle', 0, 0); 686 + const after = moveShape(state, shape.id, 100, 200); 687 + const partialOp = createOperation('move_shape', state, after, shape.id); 688 + history.push(partialOp); 689 + 690 + const inv = history.undo()!; 691 + const restored = applyOperation(after, inv); 692 + const restoredShape = restored.shapes.get(shape.id)!; 693 + expect(restoredShape.x).toBe(shape.x); 694 + expect(restoredShape.y).toBe(shape.y); 695 + }); 696 + 697 + it('redo after undo re-applies the move', () => { 698 + const history = new OperationHistory('c1'); 699 + const { state, shape } = addTestShape(wb, 'rectangle', 0, 0); 700 + const after = moveShape(state, shape.id, 100, 200); 701 + const partialOp = createOperation('move_shape', state, after, shape.id); 702 + history.push(partialOp); 703 + 704 + const inv = history.undo()!; 705 + const restored = applyOperation(after, inv); 706 + const redo = history.redo()!; 707 + const reapplied = applyOperation(restored, redo); 708 + const reappliedShape = reapplied.shapes.get(shape.id)!; 709 + expect(reappliedShape.x).toBe(100); 710 + expect(reappliedShape.y).toBe(200); 711 + }); 712 + 713 + it('undo remove_shape brings the shape back', () => { 714 + const history = new OperationHistory('c1'); 715 + const { state, shape } = addTestShape(wb, 'rectangle', 0, 0); 716 + const after = removeShape(state, shape.id); 717 + const partialOp = createOperation('remove_shape', state, after, shape.id); 718 + history.push(partialOp); 719 + 720 + const inv = history.undo()!; 721 + const restored = applyOperation(after, inv); 722 + expect(restored.shapes.has(shape.id)).toBe(true); 723 + expect(restored.shapes.get(shape.id)).toEqual(shape); 724 + }); 725 + 726 + it('multiple operations undo in correct order', () => { 727 + const history = new OperationHistory('c1'); 728 + const { state: s1, shape } = addTestShape(wb, 'rectangle', 0, 0); 729 + const s2 = moveShape(s1, shape.id, 50, 50); 730 + const s3 = setShapeLabel(s2, shape.id, 'hello'); 731 + 732 + history.push(createOperation('move_shape', s1, s2, shape.id)); 733 + history.push(createOperation('set_label', s2, s3, shape.id)); 734 + 735 + // Undo label change 736 + const inv1 = history.undo()!; 737 + const r1 = applyOperation(s3, inv1); 738 + expect(r1.shapes.get(shape.id)!.label).toBe(''); 739 + 740 + // Undo move 741 + const inv2 = history.undo()!; 742 + const r2 = applyOperation(r1, inv2); 743 + expect(r2.shapes.get(shape.id)!.x).toBe(shape.x); 744 + expect(r2.shapes.get(shape.id)!.y).toBe(shape.y); 745 + }); 746 + });