web based infinite canvas

feat: history & sheet (with dialog primitives

+2008 -21
+5 -12
TODO.txt
··· 338 338 Goal: every user-visible change is undoable. 339 339 340 340 History model (/packages/core/src/history): 341 - [ ] Define Command: 341 + [x] Define Command: 342 342 - do(state) -> state 343 343 - undo(state) -> state 344 344 345 - [ ] Wrap mutations as commands: 345 + [x] Wrap mutations as commands: 346 346 - CreateShapeCommand 347 347 - UpdateShapeCommand (with before/after snapshot) 348 348 - DeleteShapesCommand 349 349 - SetSelectionCommand 350 350 - SetCameraCommand 351 351 352 - [ ] Implement stacks: 352 + [x] Implement stacks: 353 353 - undoStack, redoStack 354 354 355 - [ ] Wire shortcuts: 355 + [x] Wire shortcuts: 356 356 - Ctrl/Cmd+Z undo 357 357 - Ctrl/Cmd+Shift+Z redo 358 358 359 - Tests: 360 - [ ] round-trip: do -> undo returns to identical state (deep equal) 361 - [ ] redo re-applies exactly 362 - 363 359 (DoD): 364 360 - Undo/redo works for create/move/delete and camera changes. 365 361 ··· 380 376 - ydoc.getMap("shapes"): Y.Map<shapeId, ShapeRecord> 381 377 - ydoc.getMap("bindings"): Y.Map<bindingId, BindingRecord> 382 378 - ydoc.getArray("pageOrder"): Y.Array<pageId> 383 - - per-page shape order: either 384 - a. PageRecord.shapeIds is source-of-truth, or 385 - b. ydoc.getArray(`page:${id}:shapeOrder`) 386 - Pick ONE and document the choice. 379 + - per-page shape order: ydoc.getArray(`page:${id}:shapeOrder`) 387 380 388 381 [ ] Implement WebDocRepo (board-level API): 389 382 - listBoards(): Promise<BoardMeta[]>
+28 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 18 18 } from 'inkfinite-core'; 19 19 import { createRenderer, type Renderer } from 'inkfinite-renderer'; 20 20 import { onDestroy, onMount } from 'svelte'; 21 + import HistoryViewer from '../components/HistoryViewer.svelte'; 21 22 import Toolbar from '../components/Toolbar.svelte'; 22 23 import { createInputAdapter, type InputAdapter } from '../input'; 23 24 ··· 68 69 const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 69 70 70 71 let currentToolId = $state<ToolId>('select'); 72 + let historyViewerOpen = $state(false); 71 73 72 74 store.subscribe((state) => { 73 75 currentToolId = state.ui.toolId; ··· 77 79 store.setState((state) => switchTool(state, toolId, tools)); 78 80 } 79 81 82 + function handleHistoryClick() { 83 + historyViewerOpen = true; 84 + } 85 + 86 + function handleHistoryClose() { 87 + historyViewerOpen = false; 88 + } 89 + 80 90 function handleAction(action: Action) { 91 + if (action.type === 'key-down') { 92 + const isPrimary = 93 + (action.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || 94 + (action.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); 95 + 96 + if (isPrimary && !action.modifiers.shift && (action.key === 'z' || action.key === 'Z')) { 97 + store.undo(); 98 + return; 99 + } 100 + 101 + if (isPrimary && action.modifiers.shift && (action.key === 'z' || action.key === 'Z')) { 102 + store.redo(); 103 + return; 104 + } 105 + } 106 + 81 107 store.setState((state) => routeAction(state, action, tools)); 82 108 } 83 109 ··· 107 133 </script> 108 134 109 135 <div class="editor"> 110 - <Toolbar currentTool={currentToolId} onToolChange={handleToolChange} /> 136 + <Toolbar currentTool={currentToolId} onToolChange={handleToolChange} onHistoryClick={handleHistoryClick} /> 111 137 <canvas bind:this={canvas}></canvas> 138 + <HistoryViewer {store} bind:open={historyViewerOpen} onClose={handleHistoryClose} /> 112 139 </div> 113 140 114 141 <style>
+124
apps/web/src/lib/components/Dialog.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + type Props = { 5 + /** Whether the dialog is open */ 6 + open: boolean; 7 + /** Callback when dialog should close */ 8 + onClose?: () => void; 9 + /** Dialog title (for accessibility) */ 10 + title?: string; 11 + /** Whether clicking backdrop closes dialog (default: true) */ 12 + closeOnBackdrop?: boolean; 13 + /** Whether escape key closes dialog (default: true) */ 14 + closeOnEscape?: boolean; 15 + /** Custom class for the dialog content */ 16 + class?: string; 17 + children?: Snippet; 18 + }; 19 + 20 + let { 21 + open = $bindable(false), 22 + onClose, 23 + title, 24 + children, 25 + closeOnBackdrop = true, 26 + closeOnEscape = true, 27 + class: className = '' 28 + }: Props = $props(); 29 + 30 + let dialogElement: HTMLDivElement | undefined = $state(); 31 + 32 + function handleBackdropClick(event: MouseEvent) { 33 + if (closeOnBackdrop && event.target === event.currentTarget) { 34 + handleClose(); 35 + } 36 + } 37 + 38 + function handleKeyDown(event: KeyboardEvent) { 39 + if (closeOnEscape && event.key === 'Escape') { 40 + event.preventDefault(); 41 + handleClose(); 42 + } 43 + } 44 + 45 + function handleClose() { 46 + open = false; 47 + onClose?.(); 48 + } 49 + 50 + $effect(() => { 51 + if (open && dialogElement) { 52 + dialogElement.focus(); 53 + 54 + const previouslyFocused = document.activeElement as HTMLElement; 55 + 56 + return () => { 57 + previouslyFocused?.focus(); 58 + }; 59 + } 60 + }); 61 + </script> 62 + 63 + {#if open} 64 + <div class="dialog-backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 65 + <div 66 + bind:this={dialogElement} 67 + class="dialog-content {className}" 68 + role="dialog" 69 + aria-modal="true" 70 + aria-label={title} 71 + tabindex="-1"> 72 + {@render children?.()} 73 + </div> 74 + </div> 75 + {/if} 76 + 77 + <style> 78 + .dialog-backdrop { 79 + position: fixed; 80 + top: 0; 81 + left: 0; 82 + width: 100vw; 83 + height: 100vh; 84 + background-color: rgba(0, 0, 0, 0.5); 85 + display: flex; 86 + align-items: center; 87 + justify-content: center; 88 + z-index: 1000; 89 + animation: fadeIn 0.15s ease-out; 90 + } 91 + 92 + .dialog-content { 93 + background-color: white; 94 + border-radius: 8px; 95 + box-shadow: 96 + 0 10px 25px rgba(0, 0, 0, 0.1), 97 + 0 4px 10px rgba(0, 0, 0, 0.08); 98 + max-width: 90vw; 99 + max-height: 90vh; 100 + overflow: auto; 101 + animation: slideIn 0.2s ease-out; 102 + outline: none; 103 + } 104 + 105 + @keyframes fadeIn { 106 + from { 107 + opacity: 0; 108 + } 109 + to { 110 + opacity: 1; 111 + } 112 + } 113 + 114 + @keyframes slideIn { 115 + from { 116 + transform: translateY(-20px); 117 + opacity: 0; 118 + } 119 + to { 120 + transform: translateY(0); 121 + opacity: 1; 122 + } 123 + } 124 + </style>
+202
apps/web/src/lib/components/HistoryViewer.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * History Viewer component 4 + * 5 + * Displays the undo/redo history in a Sheet (drawer). 6 + * Shows command names and timestamps. 7 + */ 8 + 9 + import type { Store } from 'inkfinite-core'; 10 + import Sheet from './Sheet.svelte'; 11 + 12 + type Props = { store: Store; open: boolean; onClose: () => void }; 13 + 14 + let { store, open = $bindable(false), onClose }: Props = $props(); 15 + 16 + let history = $derived.by(() => store.getHistory()); 17 + 18 + $effect(() => { 19 + const unsubscribe = store.subscribe(() => { 20 + history = store.getHistory(); 21 + }); 22 + 23 + return unsubscribe; 24 + }); 25 + 26 + function formatTimestamp(timestamp: number): string { 27 + const date = new Date(timestamp); 28 + return date.toLocaleTimeString(); 29 + } 30 + 31 + function handleUndo() { 32 + store.undo(); 33 + } 34 + 35 + function handleRedo() { 36 + store.redo(); 37 + } 38 + </script> 39 + 40 + <Sheet {open} {onClose} title="History" side="right" class="history-viewer"> 41 + <div class="history-content"> 42 + <div class="history-header"> 43 + <h2>History</h2> 44 + <div class="history-actions"> 45 + <button onclick={handleUndo} disabled={!store.canUndo()}>Undo</button> 46 + <button onclick={handleRedo} disabled={!store.canRedo()}>Redo</button> 47 + </div> 48 + </div> 49 + 50 + <div class="history-section"> 51 + <h3>Undo Stack ({history.undoStack.length})</h3> 52 + {#if history.undoStack.length === 0} 53 + <p class="empty-state">No actions to undo</p> 54 + {:else} 55 + <ul class="history-list"> 56 + {#each history.undoStack as entry, index} 57 + <li class="history-entry"> 58 + <div class="entry-info"> 59 + <span class="entry-name">{entry.command.name}</span> 60 + <span class="entry-time">{formatTimestamp(entry.timestamp)}</span> 61 + </div> 62 + <span class="entry-index">#{history.undoStack.length - index}</span> 63 + </li> 64 + {/each} 65 + </ul> 66 + {/if} 67 + </div> 68 + 69 + <div class="history-section"> 70 + <h3>Redo Stack ({history.redoStack.length})</h3> 71 + {#if history.redoStack.length === 0} 72 + <p class="empty-state">No actions to redo</p> 73 + {:else} 74 + <ul class="history-list"> 75 + {#each history.redoStack as entry, index} 76 + <li class="history-entry redo"> 77 + <div class="entry-info"> 78 + <span class="entry-name">{entry.command.name}</span> 79 + <span class="entry-time">{formatTimestamp(entry.timestamp)}</span> 80 + </div> 81 + <span class="entry-index">#{index + 1}</span> 82 + </li> 83 + {/each} 84 + </ul> 85 + {/if} 86 + </div> 87 + </div> 88 + </Sheet> 89 + 90 + <style> 91 + :global(.history-viewer) { 92 + padding: 0; 93 + } 94 + 95 + .history-content { 96 + display: flex; 97 + flex-direction: column; 98 + height: 100%; 99 + } 100 + 101 + .history-header { 102 + padding: 16px; 103 + border-bottom: 1px solid #e0e0e0; 104 + background-color: #f5f5f5; 105 + } 106 + 107 + .history-header h2 { 108 + margin: 0 0 12px 0; 109 + font-size: 18px; 110 + font-weight: 600; 111 + } 112 + 113 + .history-actions { 114 + display: flex; 115 + gap: 8px; 116 + } 117 + 118 + .history-actions button { 119 + padding: 6px 12px; 120 + border: 1px solid #ccc; 121 + border-radius: 4px; 122 + background-color: white; 123 + cursor: pointer; 124 + font-size: 14px; 125 + } 126 + 127 + .history-actions button:hover:not(:disabled) { 128 + background-color: #f0f0f0; 129 + } 130 + 131 + .history-actions button:disabled { 132 + opacity: 0.5; 133 + cursor: not-allowed; 134 + } 135 + 136 + .history-section { 137 + padding: 16px; 138 + border-bottom: 1px solid #e0e0e0; 139 + } 140 + 141 + .history-section h3 { 142 + margin: 0 0 12px 0; 143 + font-size: 14px; 144 + font-weight: 600; 145 + color: #666; 146 + text-transform: uppercase; 147 + letter-spacing: 0.5px; 148 + } 149 + 150 + .empty-state { 151 + margin: 0; 152 + padding: 12px; 153 + text-align: center; 154 + color: #999; 155 + font-size: 14px; 156 + font-style: italic; 157 + } 158 + 159 + .history-list { 160 + list-style: none; 161 + margin: 0; 162 + padding: 0; 163 + } 164 + 165 + .history-entry { 166 + display: flex; 167 + justify-content: space-between; 168 + align-items: center; 169 + padding: 8px 12px; 170 + margin-bottom: 4px; 171 + border-radius: 4px; 172 + background-color: #f9f9f9; 173 + border-left: 3px solid #4dabf7; 174 + } 175 + 176 + .history-entry.redo { 177 + border-left-color: #999; 178 + opacity: 0.7; 179 + } 180 + 181 + .entry-info { 182 + display: flex; 183 + flex-direction: column; 184 + gap: 2px; 185 + } 186 + 187 + .entry-name { 188 + font-size: 14px; 189 + font-weight: 500; 190 + } 191 + 192 + .entry-time { 193 + font-size: 12px; 194 + color: #666; 195 + } 196 + 197 + .entry-index { 198 + font-size: 12px; 199 + color: #999; 200 + font-weight: 500; 201 + } 202 + </style>
+200
apps/web/src/lib/components/Sheet.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + /** 5 + * Sheet (Drawer) component 6 + * 7 + * A sliding panel that appears from the side of the screen. 8 + * Built on top of Dialog primitive with custom positioning. 9 + * 10 + * Features: 11 + * - Slides in from left, right, top, or bottom 12 + * - Same accessibility features as Dialog 13 + * - Escape key and backdrop click to close 14 + */ 15 + 16 + type Side = 'left' | 'right' | 'top' | 'bottom'; 17 + 18 + type Props = { 19 + /** Whether the sheet is open */ 20 + open: boolean; 21 + /** Callback when sheet should close */ 22 + onClose?: () => void; 23 + /** Sheet title (for accessibility) */ 24 + title?: string; 25 + /** Which side the sheet slides in from (default: 'right') */ 26 + side?: Side; 27 + /** Whether clicking backdrop closes sheet (default: true) */ 28 + closeOnBackdrop?: boolean; 29 + /** Whether escape key closes sheet (default: true) */ 30 + closeOnEscape?: boolean; 31 + /** Custom class for the sheet content */ 32 + class?: string; 33 + children?: Snippet; 34 + }; 35 + 36 + let { 37 + open = $bindable(false), 38 + onClose, 39 + title, 40 + children, 41 + side = 'right', 42 + closeOnBackdrop = true, 43 + closeOnEscape = true, 44 + class: className = '' 45 + }: Props = $props(); 46 + 47 + let sheetElement = $state<HTMLDivElement>(); 48 + 49 + function handleBackdropClick(event: MouseEvent) { 50 + if (closeOnBackdrop && event.target === event.currentTarget) { 51 + handleClose(); 52 + } 53 + } 54 + 55 + function handleKeyDown(event: KeyboardEvent) { 56 + if (closeOnEscape && event.key === 'Escape') { 57 + event.preventDefault(); 58 + handleClose(); 59 + } 60 + } 61 + 62 + function handleClose() { 63 + open = false; 64 + onClose?.(); 65 + } 66 + 67 + $effect(() => { 68 + if (open && sheetElement) { 69 + sheetElement.focus(); 70 + 71 + const previouslyFocused = document.activeElement as HTMLElement; 72 + 73 + return () => { 74 + previouslyFocused?.focus(); 75 + }; 76 + } 77 + }); 78 + </script> 79 + 80 + {#if open} 81 + <div class="sheet-backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 82 + <div 83 + bind:this={sheetElement} 84 + class="sheet-content sheet-{side} {className}" 85 + role="dialog" 86 + aria-modal="true" 87 + aria-label={title} 88 + tabindex="-1"> 89 + {@render children?.()} 90 + </div> 91 + </div> 92 + {/if} 93 + 94 + <style> 95 + .sheet-backdrop { 96 + position: fixed; 97 + top: 0; 98 + left: 0; 99 + width: 100vw; 100 + height: 100vh; 101 + background-color: rgba(0, 0, 0, 0.5); 102 + display: flex; 103 + z-index: 1000; 104 + animation: fadeIn 0.15s ease-out; 105 + } 106 + 107 + .sheet-content { 108 + background-color: white; 109 + box-shadow: 110 + 0 10px 25px rgba(0, 0, 0, 0.1), 111 + 0 4px 10px rgba(0, 0, 0, 0.08); 112 + overflow: auto; 113 + outline: none; 114 + } 115 + 116 + /* Right side (default) */ 117 + .sheet-right { 118 + position: fixed; 119 + top: 0; 120 + right: 0; 121 + height: 100vh; 122 + width: min(400px, 80vw); 123 + animation: slideInRight 0.2s ease-out; 124 + } 125 + 126 + /* Left side */ 127 + .sheet-left { 128 + position: fixed; 129 + top: 0; 130 + left: 0; 131 + height: 100vh; 132 + width: min(400px, 80vw); 133 + animation: slideInLeft 0.2s ease-out; 134 + } 135 + 136 + /* Top side */ 137 + .sheet-top { 138 + position: fixed; 139 + top: 0; 140 + left: 0; 141 + width: 100vw; 142 + height: min(400px, 80vh); 143 + animation: slideInTop 0.2s ease-out; 144 + } 145 + 146 + /* Bottom side */ 147 + .sheet-bottom { 148 + position: fixed; 149 + bottom: 0; 150 + left: 0; 151 + width: 100vw; 152 + height: min(400px, 80vh); 153 + animation: slideInBottom 0.2s ease-out; 154 + } 155 + 156 + @keyframes fadeIn { 157 + from { 158 + opacity: 0; 159 + } 160 + to { 161 + opacity: 1; 162 + } 163 + } 164 + 165 + @keyframes slideInRight { 166 + from { 167 + transform: translateX(100%); 168 + } 169 + to { 170 + transform: translateX(0); 171 + } 172 + } 173 + 174 + @keyframes slideInLeft { 175 + from { 176 + transform: translateX(-100%); 177 + } 178 + to { 179 + transform: translateX(0); 180 + } 181 + } 182 + 183 + @keyframes slideInTop { 184 + from { 185 + transform: translateY(-100%); 186 + } 187 + to { 188 + transform: translateY(0); 189 + } 190 + } 191 + 192 + @keyframes slideInBottom { 193 + from { 194 + transform: translateY(100%); 195 + } 196 + to { 197 + transform: translateY(0); 198 + } 199 + } 200 + </style>
+20 -2
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 2 import type { ToolId } from 'inkfinite-core'; 3 3 4 - type Props = { currentTool: ToolId; onToolChange: (toolId: ToolId) => void }; 4 + type Props = { currentTool: ToolId; onToolChange: (toolId: ToolId) => void; onHistoryClick?: () => void }; 5 5 6 - let { currentTool, onToolChange }: Props = $props(); 6 + let { currentTool, onToolChange, onHistoryClick }: Props = $props(); 7 7 8 8 const tools: Array<{ id: ToolId; label: string; icon: string }> = [ 9 9 { id: 'select', label: 'Select', icon: '⌖' }, ··· 32 32 <span class="tool-label">{tool.label}</span> 33 33 </button> 34 34 {/each} 35 + 36 + {#if onHistoryClick} 37 + <div class="toolbar-divider"></div> 38 + <button class="tool-button history-button" onclick={onHistoryClick} aria-label="History"> 39 + <span class="tool-icon">⏱</span> 40 + <span class="tool-label">History</span> 41 + </button> 42 + {/if} 35 43 </div> 36 44 37 45 <style> ··· 83 91 font-size: 11px; 84 92 line-height: 1; 85 93 white-space: nowrap; 94 + } 95 + 96 + .toolbar-divider { 97 + width: 1px; 98 + background-color: var(--border); 99 + margin: 0 8px; 100 + } 101 + 102 + .history-button { 103 + margin-left: auto; 86 104 } 87 105 </style>
+5 -2
apps/web/src/lib/tests/canvas.svelte.test.ts apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 72 72 const { container } = render(Canvas); 73 73 const toolButtons = container.querySelectorAll(".tool-button"); 74 74 75 - expect(toolButtons.length).toBe(6); 75 + expect(toolButtons.length).toBe(7); 76 76 77 77 const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 78 - expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 78 + expect(toolIds.slice(0, 6)).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 79 + 80 + const historyButton = container.querySelector(".tool-button.history-button"); 81 + expect(historyButton).toBeTruthy(); 79 82 }); 80 83 81 84 it("should have select tool active by default", () => {
+96
apps/web/src/lib/tests/components/Dialog.svelte.test.ts
··· 1 + import Dialog from "$lib/components/Dialog.svelte"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { render } from "vitest-browser-svelte"; 4 + import { page } from "vitest/browser"; 5 + 6 + describe("Dialog", () => { 7 + describe("visibility", () => { 8 + it("should render when open is true", async () => { 9 + render(Dialog, { open: true, title: "Test Dialog" }); 10 + 11 + const dialog = page.getByRole("dialog"); 12 + await expect.element(dialog).toBeInTheDocument(); 13 + }); 14 + 15 + it("should not render when open is false", async () => { 16 + render(Dialog, { open: false, title: "Test Dialog" }); 17 + 18 + const dialog = page.getByRole("dialog"); 19 + await expect.element(dialog).not.toBeInTheDocument(); 20 + }); 21 + }); 22 + 23 + describe("accessibility", () => { 24 + it("should have correct ARIA attributes", async () => { 25 + render(Dialog, { open: true, title: "Test Dialog" }); 26 + 27 + const dialog = page.getByRole("dialog"); 28 + await expect.element(dialog).toHaveAttribute("aria-modal", "true"); 29 + await expect.element(dialog).toHaveAttribute("aria-label", "Test Dialog"); 30 + }); 31 + 32 + it("should be focusable", async () => { 33 + render(Dialog, { open: true, title: "Test Dialog" }); 34 + 35 + const dialog = page.getByRole("dialog"); 36 + await expect.element(dialog).toHaveAttribute("tabindex", "-1"); 37 + }); 38 + }); 39 + 40 + describe("close behavior", () => { 41 + it("should call onClose when backdrop is clicked and closeOnBackdrop is true", async () => { 42 + const onClose = vi.fn(); 43 + 44 + render(Dialog, { open: true, onClose, closeOnBackdrop: true, title: "Test" }); 45 + 46 + const backdrop = page.getByRole("presentation"); 47 + await backdrop.click(); 48 + 49 + expect(onClose).toHaveBeenCalledOnce(); 50 + }); 51 + 52 + it("should call onClose when Escape key is pressed and closeOnEscape is true", async () => { 53 + const onClose = vi.fn(); 54 + 55 + render(Dialog, { open: true, onClose, closeOnEscape: true, title: "Test" }); 56 + 57 + const dialog = document.querySelector<HTMLElement>("[role=\"dialog\"]")!; 58 + 59 + dialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 60 + 61 + expect(onClose).toHaveBeenCalledOnce(); 62 + }); 63 + 64 + it("should not call onClose when backdrop is clicked and closeOnBackdrop is false", async () => { 65 + const onClose = vi.fn(); 66 + 67 + render(Dialog, { open: true, onClose, closeOnBackdrop: false, title: "Test" }); 68 + 69 + const backdrop = page.getByRole("presentation"); 70 + await backdrop.click(); 71 + 72 + expect(onClose).not.toHaveBeenCalled(); 73 + }); 74 + 75 + it("should not call onClose when Escape key is pressed and closeOnEscape is false", async () => { 76 + const onClose = vi.fn(); 77 + 78 + render(Dialog, { open: true, onClose, closeOnEscape: false, title: "Test" }); 79 + 80 + const dialog = document.querySelector<HTMLElement>("[role=\"dialog\"]")!; 81 + 82 + dialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 83 + 84 + expect(onClose).not.toHaveBeenCalled(); 85 + }); 86 + }); 87 + 88 + describe("styling", () => { 89 + it("should apply custom class to dialog content", async () => { 90 + render(Dialog, { open: true, title: "Test", class: "custom-class" }); 91 + 92 + const dialog = page.getByRole("dialog"); 93 + await expect.element(dialog).toHaveClass("custom-class"); 94 + }); 95 + }); 96 + });
+131
apps/web/src/lib/tests/components/Sheet.svelte.test.ts
··· 1 + import Sheet from "$lib/components/Sheet.svelte"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { render } from "vitest-browser-svelte"; 4 + import { page } from "vitest/browser"; 5 + 6 + describe("Sheet", () => { 7 + describe("visibility", () => { 8 + it("should render when open is true", async () => { 9 + render(Sheet, { open: true, title: "Test Sheet" }); 10 + const sheet = page.getByRole("dialog"); 11 + await expect.element(sheet).toBeInTheDocument(); 12 + }); 13 + 14 + it("should not render when open is false", async () => { 15 + render(Sheet, { open: false, title: "Test Sheet" }); 16 + const sheet = page.getByRole("dialog"); 17 + await expect.element(sheet).not.toBeInTheDocument(); 18 + }); 19 + }); 20 + 21 + describe("positioning", () => { 22 + it("should apply right side class", async () => { 23 + render(Sheet, { open: true, side: "right", title: "Test" }); 24 + 25 + const sheet = page.getByRole("dialog"); 26 + await expect.element(sheet).toHaveClass("sheet-right"); 27 + }); 28 + 29 + it("should apply left side class", async () => { 30 + render(Sheet, { open: true, side: "left", title: "Test" }); 31 + 32 + const sheet = page.getByRole("dialog"); 33 + await expect.element(sheet).toHaveClass("sheet-left"); 34 + }); 35 + 36 + it("should apply top side class", async () => { 37 + render(Sheet, { open: true, side: "top", title: "Test" }); 38 + 39 + const sheet = page.getByRole("dialog"); 40 + await expect.element(sheet).toHaveClass("sheet-top"); 41 + }); 42 + 43 + it("should apply bottom side class", async () => { 44 + render(Sheet, { open: true, side: "bottom", title: "Test" }); 45 + 46 + const sheet = page.getByRole("dialog"); 47 + await expect.element(sheet).toHaveClass("sheet-bottom"); 48 + }); 49 + 50 + it("should default to right side when no side is specified", async () => { 51 + render(Sheet, { open: true, title: "Test" }); 52 + 53 + const sheet = page.getByRole("dialog"); 54 + await expect.element(sheet).toHaveClass("sheet-right"); 55 + }); 56 + }); 57 + 58 + describe("accessibility", () => { 59 + it("should have correct ARIA attributes", async () => { 60 + render(Sheet, { open: true, title: "Test Sheet" }); 61 + 62 + const sheet = page.getByRole("dialog"); 63 + await expect.element(sheet).toHaveAttribute("aria-modal", "true"); 64 + await expect.element(sheet).toHaveAttribute("aria-label", "Test Sheet"); 65 + }); 66 + 67 + it("should be focusable", async () => { 68 + render(Sheet, { open: true, title: "Test Sheet" }); 69 + 70 + const sheet = page.getByRole("dialog"); 71 + await expect.element(sheet).toHaveAttribute("tabindex", "-1"); 72 + }); 73 + }); 74 + 75 + describe("close behavior", () => { 76 + it("should call onClose when backdrop is clicked and closeOnBackdrop is true", async () => { 77 + const onClose = vi.fn(); 78 + 79 + render(Sheet, { open: true, onClose, closeOnBackdrop: true, title: "Test" }); 80 + 81 + const backdrop = page.getByRole("presentation"); 82 + await backdrop.click({ position: { x: 5, y: 5 } }); 83 + 84 + expect(onClose).toHaveBeenCalledOnce(); 85 + }); 86 + 87 + it("should call onClose when Escape key is pressed and closeOnEscape is true", async () => { 88 + const onClose = vi.fn(); 89 + 90 + render(Sheet, { open: true, onClose, closeOnEscape: true, title: "Test" }); 91 + 92 + const sheet = document.querySelector<HTMLElement>("[role=\"dialog\"]")!; 93 + 94 + sheet.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 95 + 96 + expect(onClose).toHaveBeenCalledOnce(); 97 + }); 98 + 99 + it("should not call onClose when backdrop is clicked and closeOnBackdrop is false", async () => { 100 + const onClose = vi.fn(); 101 + 102 + render(Sheet, { open: true, onClose, closeOnBackdrop: false, title: "Test" }); 103 + 104 + const backdrop = page.getByRole("presentation"); 105 + await backdrop.click({ position: { x: 5, y: 5 } }); 106 + 107 + expect(onClose).not.toHaveBeenCalled(); 108 + }); 109 + 110 + it("should not call onClose when Escape key is pressed and closeOnEscape is false", async () => { 111 + const onClose = vi.fn(); 112 + 113 + render(Sheet, { open: true, onClose, closeOnEscape: false, title: "Test" }); 114 + 115 + const sheet = document.querySelector<HTMLElement>("[role=\"dialog\"]")!; 116 + 117 + sheet.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 118 + 119 + expect(onClose).not.toHaveBeenCalled(); 120 + }); 121 + }); 122 + 123 + describe("styling", () => { 124 + it("should apply custom class to sheet content", async () => { 125 + render(Sheet, { open: true, title: "Test", class: "custom-class" }); 126 + 127 + const sheet = page.getByRole("dialog"); 128 + await expect.element(sheet).toHaveClass("custom-class"); 129 + }); 130 + }); 131 + });
+294
packages/core/src/history.ts
··· 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
··· 1 1 export * from "./actions"; 2 2 export * from "./camera"; 3 3 export * from "./geom"; 4 + export * from "./history"; 4 5 export * from "./math"; 5 6 export * from "./model"; 6 7 export * from "./reactivity";
+2 -4
packages/core/src/model.ts
··· 92 92 export type BindingType = "arrow-end"; 93 93 export type BindingHandle = "start" | "end"; 94 94 95 - export type BindingAnchor = { 96 - // TODO: 'edge', 'corner', etc. 97 - kind: "center"; 98 - }; 95 + // TODO: 'edge', 'corner', etc. 96 + export type BindingAnchor = { kind: "center" }; 99 97 100 98 export type BindingRecord = { 101 99 id: string;
+89
packages/core/src/reactivity.ts
··· 1 1 import { BehaviorSubject, type Subscription } from "rxjs"; 2 2 import type { Camera } from "./camera"; 3 3 import { Camera as CameraOps } from "./camera"; 4 + import { type Command, History, type HistoryState } from "./history"; 4 5 import type { Document, PageRecord, ShapeRecord } from "./model"; 5 6 import { Document as DocumentOps } from "./model"; 6 7 ··· 45 46 * - Immutable state updates 46 47 * - Invariant enforcement (repairs invalid state) 47 48 * - Subscription management 49 + * - Undo/redo history support 48 50 */ 49 51 export class Store { 50 52 private readonly state$: BehaviorSubject<EditorState>; 53 + private history: HistoryState; 51 54 52 55 constructor(initialState?: EditorState) { 53 56 this.state$ = new BehaviorSubject(initialState ?? EditorState.create()); 57 + this.history = History.create(); 54 58 } 55 59 56 60 /** ··· 66 70 * The updater receives the current state and returns a new state. 67 71 * Invariants are enforced after the update. 68 72 * 73 + * Note: This bypasses history. Use executeCommand() for undoable changes. 74 + * 69 75 * @param updater - Function that transforms current state to new state 70 76 */ 71 77 setState(updater: StateUpdater): void { ··· 73 79 const newState = updater(currentState); 74 80 const repairedState = enforceInvariants(newState); 75 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(); 76 165 } 77 166 78 167 /**
+496
packages/core/tests/history.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Camera } from "../src/camera"; 3 + import { 4 + CreateShapeCommand, 5 + DeleteShapesCommand, 6 + History, 7 + SetCameraCommand, 8 + SetSelectionCommand, 9 + UpdateShapeCommand, 10 + } from "../src/history"; 11 + import { PageRecord, ShapeRecord } from "../src/model"; 12 + import { EditorState } from "../src/reactivity"; 13 + 14 + describe("History", () => { 15 + describe("History namespace", () => { 16 + it("should create empty history state", () => { 17 + const history = History.create(); 18 + 19 + expect(history.undoStack).toEqual([]); 20 + expect(history.redoStack).toEqual([]); 21 + }); 22 + 23 + it("should check if undo/redo are available", () => { 24 + const history = History.create(); 25 + 26 + expect(History.canUndo(history)).toBe(false); 27 + expect(History.canRedo(history)).toBe(false); 28 + }); 29 + }); 30 + 31 + describe("CreateShapeCommand", () => { 32 + it("should execute create shape command", () => { 33 + const page = PageRecord.create("Test Page", "page:1"); 34 + const shape = ShapeRecord.createRect("page:1", 10, 20, { 35 + w: 100, 36 + h: 50, 37 + fill: "#fff", 38 + stroke: "#000", 39 + radius: 0, 40 + }); 41 + 42 + let state = EditorState.create(); 43 + state = { ...state, doc: { ...state.doc, pages: { [page.id]: page } } }; 44 + 45 + const command = new CreateShapeCommand(shape, page.id); 46 + const history = History.create(); 47 + 48 + const [newHistory, newState] = History.execute(history, state, command); 49 + 50 + expect(newState.doc.shapes[shape.id]).toEqual(shape); 51 + expect(newState.doc.pages[page.id].shapeIds).toContain(shape.id); 52 + expect(newHistory.undoStack).toHaveLength(1); 53 + expect(newHistory.redoStack).toHaveLength(0); 54 + }); 55 + 56 + it("should round-trip: do -> undo returns to identical state", () => { 57 + const page = PageRecord.create("Test Page", "page:1"); 58 + const shape = ShapeRecord.createRect("page:1", 10, 20, { 59 + w: 100, 60 + h: 50, 61 + fill: "#fff", 62 + stroke: "#000", 63 + radius: 0, 64 + }); 65 + 66 + let state = EditorState.create(); 67 + state = { ...state, doc: { ...state.doc, pages: { [page.id]: page } } }; 68 + 69 + const originalState = EditorState.clone(state); 70 + const command = new CreateShapeCommand(shape, page.id); 71 + const history = History.create(); 72 + 73 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 74 + const result = History.undo(historyAfterDo, stateAfterDo); 75 + 76 + expect(result).not.toBeNull(); 77 + const [, stateAfterUndo] = result!; 78 + 79 + expect(stateAfterUndo).toEqual(originalState); 80 + }); 81 + 82 + it("should redo re-applies exactly", () => { 83 + const page = PageRecord.create("Test Page", "page:1"); 84 + const shape = ShapeRecord.createRect("page:1", 10, 20, { 85 + w: 100, 86 + h: 50, 87 + fill: "#fff", 88 + stroke: "#000", 89 + radius: 0, 90 + }); 91 + 92 + let state = EditorState.create(); 93 + state = { ...state, doc: { ...state.doc, pages: { [page.id]: page } } }; 94 + 95 + const command = new CreateShapeCommand(shape, page.id); 96 + const history = History.create(); 97 + 98 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 99 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 100 + expect(undoResult).not.toBeNull(); 101 + 102 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 103 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 104 + expect(redoResult).not.toBeNull(); 105 + 106 + const [, stateAfterRedo] = redoResult!; 107 + 108 + expect(stateAfterRedo).toEqual(stateAfterDo); 109 + }); 110 + }); 111 + 112 + describe("UpdateShapeCommand", () => { 113 + it("should execute update shape command", () => { 114 + const page = PageRecord.create("Test Page", "page:1"); 115 + const shape = ShapeRecord.createRect( 116 + "page:1", 117 + 10, 118 + 20, 119 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 120 + "shape:1", 121 + ); 122 + 123 + let state = EditorState.create(); 124 + state = { 125 + ...state, 126 + doc: { ...state.doc, pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape } }, 127 + }; 128 + 129 + const updatedShape = { ...shape, x: 100, y: 200 }; 130 + const command = new UpdateShapeCommand(shape.id, shape, updatedShape); 131 + const history = History.create(); 132 + 133 + const [, newState] = History.execute(history, state, command); 134 + 135 + expect(newState.doc.shapes[shape.id]).toEqual(updatedShape); 136 + }); 137 + 138 + it("should round-trip: do -> undo returns to identical state", () => { 139 + const page = PageRecord.create("Test Page", "page:1"); 140 + const shape = ShapeRecord.createRect( 141 + "page:1", 142 + 10, 143 + 20, 144 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 145 + "shape:1", 146 + ); 147 + 148 + let state = EditorState.create(); 149 + state = { 150 + ...state, 151 + doc: { ...state.doc, pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape } }, 152 + }; 153 + 154 + const originalState = EditorState.clone(state); 155 + const updatedShape = { ...shape, x: 100, y: 200 }; 156 + const command = new UpdateShapeCommand(shape.id, shape, updatedShape); 157 + const history = History.create(); 158 + 159 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 160 + const result = History.undo(historyAfterDo, stateAfterDo); 161 + 162 + expect(result).not.toBeNull(); 163 + const [, stateAfterUndo] = result!; 164 + 165 + expect(stateAfterUndo).toEqual(originalState); 166 + }); 167 + 168 + it("should redo re-applies exactly", () => { 169 + const page = PageRecord.create("Test Page", "page:1"); 170 + const shape = ShapeRecord.createRect( 171 + "page:1", 172 + 10, 173 + 20, 174 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 175 + "shape:1", 176 + ); 177 + 178 + let state = EditorState.create(); 179 + state = { 180 + ...state, 181 + doc: { ...state.doc, pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape } }, 182 + }; 183 + 184 + const updatedShape = { ...shape, x: 100, y: 200 }; 185 + const command = new UpdateShapeCommand(shape.id, shape, updatedShape); 186 + const history = History.create(); 187 + 188 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 189 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 190 + expect(undoResult).not.toBeNull(); 191 + 192 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 193 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 194 + expect(redoResult).not.toBeNull(); 195 + 196 + const [, stateAfterRedo] = redoResult!; 197 + 198 + expect(stateAfterRedo).toEqual(stateAfterDo); 199 + }); 200 + }); 201 + 202 + describe("DeleteShapesCommand", () => { 203 + it("should execute delete shapes command", () => { 204 + const page = PageRecord.create("Test Page", "page:1"); 205 + const shape1 = ShapeRecord.createRect("page:1", 10, 20, { 206 + w: 100, 207 + h: 50, 208 + fill: "#fff", 209 + stroke: "#000", 210 + radius: 0, 211 + }, "shape:1"); 212 + const shape2 = ShapeRecord.createRect("page:1", 30, 40, { 213 + w: 200, 214 + h: 100, 215 + fill: "#fff", 216 + stroke: "#000", 217 + radius: 0, 218 + }, "shape:2"); 219 + 220 + let state = EditorState.create(); 221 + state = { 222 + ...state, 223 + doc: { 224 + ...state.doc, 225 + pages: { [page.id]: { ...page, shapeIds: [shape1.id, shape2.id] } }, 226 + shapes: { [shape1.id]: shape1, [shape2.id]: shape2 }, 227 + }, 228 + }; 229 + 230 + const command = new DeleteShapesCommand([shape1], page.id); 231 + const history = History.create(); 232 + 233 + const [, newState] = History.execute(history, state, command); 234 + 235 + expect(newState.doc.shapes[shape1.id]).toBeUndefined(); 236 + expect(newState.doc.shapes[shape2.id]).toBeDefined(); 237 + expect(newState.doc.pages[page.id].shapeIds).not.toContain(shape1.id); 238 + }); 239 + 240 + it("should round-trip: do -> undo returns to identical state", () => { 241 + const page = PageRecord.create("Test Page", "page:1"); 242 + const shape = ShapeRecord.createRect( 243 + "page:1", 244 + 10, 245 + 20, 246 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 247 + "shape:1", 248 + ); 249 + 250 + let state = EditorState.create(); 251 + state = { 252 + ...state, 253 + doc: { ...state.doc, pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape } }, 254 + }; 255 + 256 + const originalState = EditorState.clone(state); 257 + const command = new DeleteShapesCommand([shape], page.id); 258 + const history = History.create(); 259 + 260 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 261 + const result = History.undo(historyAfterDo, stateAfterDo); 262 + 263 + expect(result).not.toBeNull(); 264 + const [, stateAfterUndo] = result!; 265 + 266 + expect(stateAfterUndo).toEqual(originalState); 267 + }); 268 + 269 + it("should redo re-applies exactly", () => { 270 + const page = PageRecord.create("Test Page", "page:1"); 271 + const shape = ShapeRecord.createRect( 272 + "page:1", 273 + 10, 274 + 20, 275 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 276 + "shape:1", 277 + ); 278 + 279 + let state = EditorState.create(); 280 + state = { 281 + ...state, 282 + doc: { ...state.doc, pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape } }, 283 + }; 284 + 285 + const command = new DeleteShapesCommand([shape], page.id); 286 + const history = History.create(); 287 + 288 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 289 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 290 + expect(undoResult).not.toBeNull(); 291 + 292 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 293 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 294 + expect(redoResult).not.toBeNull(); 295 + 296 + const [, stateAfterRedo] = redoResult!; 297 + 298 + expect(stateAfterRedo).toEqual(stateAfterDo); 299 + }); 300 + }); 301 + 302 + describe("SetSelectionCommand", () => { 303 + it("should execute set selection command", () => { 304 + const state = EditorState.create(); 305 + const command = new SetSelectionCommand([], ["shape:1", "shape:2"]); 306 + const history = History.create(); 307 + 308 + const [, newState] = History.execute(history, state, command); 309 + 310 + expect(newState.ui.selectionIds).toEqual(["shape:1", "shape:2"]); 311 + }); 312 + 313 + it("should round-trip: do -> undo returns to identical state", () => { 314 + const state = EditorState.create(); 315 + const originalState = EditorState.clone(state); 316 + const command = new SetSelectionCommand([], ["shape:1", "shape:2"]); 317 + const history = History.create(); 318 + 319 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 320 + const result = History.undo(historyAfterDo, stateAfterDo); 321 + 322 + expect(result).not.toBeNull(); 323 + const [, stateAfterUndo] = result!; 324 + 325 + expect(stateAfterUndo).toEqual(originalState); 326 + }); 327 + 328 + it("should redo re-applies exactly", () => { 329 + const state = EditorState.create(); 330 + const command = new SetSelectionCommand([], ["shape:1", "shape:2"]); 331 + const history = History.create(); 332 + 333 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 334 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 335 + expect(undoResult).not.toBeNull(); 336 + 337 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 338 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 339 + expect(redoResult).not.toBeNull(); 340 + 341 + const [, stateAfterRedo] = redoResult!; 342 + 343 + expect(stateAfterRedo).toEqual(stateAfterDo); 344 + }); 345 + }); 346 + 347 + describe("SetCameraCommand", () => { 348 + it("should execute set camera command", () => { 349 + const state = EditorState.create(); 350 + const beforeCamera = Camera.create(); 351 + const afterCamera = Camera.create(100, 200, 2); 352 + const command = new SetCameraCommand(beforeCamera, afterCamera); 353 + const history = History.create(); 354 + 355 + const [, newState] = History.execute(history, state, command); 356 + 357 + expect(newState.camera).toEqual(afterCamera); 358 + }); 359 + 360 + it("should round-trip: do -> undo returns to identical state", () => { 361 + const state = EditorState.create(); 362 + const originalState = EditorState.clone(state); 363 + const beforeCamera = Camera.create(); 364 + const afterCamera = Camera.create(100, 200, 2); 365 + const command = new SetCameraCommand(beforeCamera, afterCamera); 366 + const history = History.create(); 367 + 368 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 369 + const result = History.undo(historyAfterDo, stateAfterDo); 370 + 371 + expect(result).not.toBeNull(); 372 + const [, stateAfterUndo] = result!; 373 + 374 + expect(stateAfterUndo).toEqual(originalState); 375 + }); 376 + 377 + it("should redo re-applies exactly", () => { 378 + const state = EditorState.create(); 379 + const beforeCamera = Camera.create(); 380 + const afterCamera = Camera.create(100, 200, 2); 381 + const command = new SetCameraCommand(beforeCamera, afterCamera); 382 + const history = History.create(); 383 + 384 + const [historyAfterDo, stateAfterDo] = History.execute(history, state, command); 385 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 386 + expect(undoResult).not.toBeNull(); 387 + 388 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 389 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 390 + expect(redoResult).not.toBeNull(); 391 + 392 + const [, stateAfterRedo] = redoResult!; 393 + 394 + expect(stateAfterRedo).toEqual(stateAfterDo); 395 + }); 396 + }); 397 + 398 + describe("History stack operations", () => { 399 + it("should clear redo stack when new command is executed", () => { 400 + const state = EditorState.create(); 401 + const command1 = new SetSelectionCommand([], ["shape:1"]); 402 + const command2 = new SetSelectionCommand(["shape:1"], ["shape:2"]); 403 + let history = History.create(); 404 + 405 + [history] = History.execute(history, state, command1); 406 + 407 + const undoResult = History.undo(history, state); 408 + expect(undoResult).not.toBeNull(); 409 + [history] = undoResult!; 410 + 411 + expect(history.redoStack).toHaveLength(1); 412 + 413 + [history] = History.execute(history, state, command2); 414 + 415 + expect(history.redoStack).toHaveLength(0); 416 + }); 417 + 418 + it("should return null when undoing empty stack", () => { 419 + const state = EditorState.create(); 420 + const history = History.create(); 421 + 422 + const result = History.undo(history, state); 423 + 424 + expect(result).toBeNull(); 425 + }); 426 + 427 + it("should return null when redoing empty stack", () => { 428 + const state = EditorState.create(); 429 + const history = History.create(); 430 + 431 + const result = History.redo(history, state); 432 + 433 + expect(result).toBeNull(); 434 + }); 435 + 436 + it("should maintain command order through undo/redo", () => { 437 + const page = PageRecord.create("Test Page", "page:1"); 438 + const shape1 = ShapeRecord.createRect("page:1", 10, 20, { 439 + w: 100, 440 + h: 50, 441 + fill: "#fff", 442 + stroke: "#000", 443 + radius: 0, 444 + }, "shape:1"); 445 + const shape2 = ShapeRecord.createRect("page:1", 30, 40, { 446 + w: 200, 447 + h: 100, 448 + fill: "#fff", 449 + stroke: "#000", 450 + radius: 0, 451 + }, "shape:2"); 452 + 453 + let state = EditorState.create(); 454 + state = { ...state, doc: { ...state.doc, pages: { [page.id]: page } } }; 455 + 456 + const command1 = new CreateShapeCommand(shape1, page.id); 457 + const command2 = new CreateShapeCommand(shape2, page.id); 458 + let history = History.create(); 459 + 460 + let currentState = state; 461 + [history, currentState] = History.execute(history, currentState, command1); 462 + [history, currentState] = History.execute(history, currentState, command2); 463 + 464 + expect(currentState.doc.shapes[shape1.id]).toBeDefined(); 465 + expect(currentState.doc.shapes[shape2.id]).toBeDefined(); 466 + 467 + const undo1 = History.undo(history, currentState); 468 + expect(undo1).not.toBeNull(); 469 + [history, currentState] = undo1!; 470 + 471 + expect(currentState.doc.shapes[shape1.id]).toBeDefined(); 472 + expect(currentState.doc.shapes[shape2.id]).toBeUndefined(); 473 + 474 + const undo2 = History.undo(history, currentState); 475 + expect(undo2).not.toBeNull(); 476 + [history, currentState] = undo2!; 477 + 478 + expect(currentState.doc.shapes[shape1.id]).toBeUndefined(); 479 + expect(currentState.doc.shapes[shape2.id]).toBeUndefined(); 480 + 481 + const redo1 = History.redo(history, currentState); 482 + expect(redo1).not.toBeNull(); 483 + [history, currentState] = redo1!; 484 + 485 + expect(currentState.doc.shapes[shape1.id]).toBeDefined(); 486 + expect(currentState.doc.shapes[shape2.id]).toBeUndefined(); 487 + 488 + const redo2 = History.redo(history, currentState); 489 + expect(redo2).not.toBeNull(); 490 + [, currentState] = redo2!; 491 + 492 + expect(currentState.doc.shapes[shape1.id]).toBeDefined(); 493 + expect(currentState.doc.shapes[shape2.id]).toBeDefined(); 494 + }); 495 + }); 496 + });
+315
packages/core/tests/reactivity.test.ts
··· 1 1 import { describe, expect, it, vi } from "vitest"; 2 2 import { Camera } from "../src/camera"; 3 + import { CreateShapeCommand } from "../src/history"; 3 4 import { PageRecord, ShapeRecord } from "../src/model"; 4 5 import { 5 6 EditorState as EditorStateOps, ··· 819 820 expect(store.getState().ui.toolId).toBe("ellipse"); 820 821 }); 821 822 }); 823 + 824 + describe("History integration", () => { 825 + describe("executeCommand", () => { 826 + it("should execute command and add to history", () => { 827 + const store = new Store(); 828 + const page = PageRecord.create("Page 1", "page1"); 829 + 830 + store.setState((state) => ({ 831 + ...state, 832 + doc: { ...state.doc, pages: { page1: page } }, 833 + ui: { ...state.ui, currentPageId: "page1" }, 834 + })); 835 + 836 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 837 + 838 + const command = new CreateShapeCommand(shape, page.id); 839 + store.executeCommand(command); 840 + 841 + const state = store.getState(); 842 + expect(state.doc.shapes[shape.id]).toBeDefined(); 843 + expect(store.canUndo()).toBe(true); 844 + }); 845 + 846 + it("should notify subscribers when command is executed", () => { 847 + const store = new Store(); 848 + const listener = vi.fn(); 849 + 850 + store.subscribe(listener); 851 + listener.mockClear(); 852 + 853 + const page = PageRecord.create("Page 1", "page1"); 854 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 855 + 856 + listener.mockClear(); 857 + 858 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 859 + 860 + const command = new CreateShapeCommand(shape, page.id); 861 + store.executeCommand(command); 862 + 863 + expect(listener).toHaveBeenCalledTimes(1); 864 + }); 865 + }); 866 + 867 + describe("undo", () => { 868 + it("should undo last command", () => { 869 + const store = new Store(); 870 + const page = PageRecord.create("Page 1", "page1"); 871 + 872 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 873 + 874 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 875 + 876 + const command = new CreateShapeCommand(shape, page.id); 877 + store.executeCommand(command); 878 + 879 + expect(store.getState().doc.shapes[shape.id]).toBeDefined(); 880 + 881 + store.undo(); 882 + 883 + expect(store.getState().doc.shapes[shape.id]).toBeUndefined(); 884 + }); 885 + 886 + it("should return false when nothing to undo", () => { 887 + const store = new Store(); 888 + const result = store.undo(); 889 + 890 + expect(result).toBe(false); 891 + }); 892 + 893 + it("should notify subscribers when undoing", () => { 894 + const store = new Store(); 895 + const listener = vi.fn(); 896 + 897 + store.subscribe(listener); 898 + 899 + const page = PageRecord.create("Page 1", "page1"); 900 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 901 + 902 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 903 + 904 + const command = new CreateShapeCommand(shape, page.id); 905 + store.executeCommand(command); 906 + 907 + listener.mockClear(); 908 + 909 + store.undo(); 910 + 911 + expect(listener).toHaveBeenCalledTimes(1); 912 + }); 913 + }); 914 + 915 + describe("redo", () => { 916 + it("should redo last undone command", () => { 917 + const store = new Store(); 918 + const page = PageRecord.create("Page 1", "page1"); 919 + 920 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 921 + 922 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 923 + 924 + const command = new CreateShapeCommand(shape, page.id); 925 + store.executeCommand(command); 926 + store.undo(); 927 + 928 + expect(store.getState().doc.shapes[shape.id]).toBeUndefined(); 929 + 930 + store.redo(); 931 + 932 + expect(store.getState().doc.shapes[shape.id]).toBeDefined(); 933 + }); 934 + 935 + it("should return false when nothing to redo", () => { 936 + const store = new Store(); 937 + const result = store.redo(); 938 + 939 + expect(result).toBe(false); 940 + }); 941 + 942 + it("should notify subscribers when redoing", () => { 943 + const store = new Store(); 944 + const listener = vi.fn(); 945 + 946 + store.subscribe(listener); 947 + 948 + const page = PageRecord.create("Page 1", "page1"); 949 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 950 + 951 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 952 + 953 + const command = new CreateShapeCommand(shape, page.id); 954 + store.executeCommand(command); 955 + store.undo(); 956 + 957 + listener.mockClear(); 958 + 959 + store.redo(); 960 + 961 + expect(listener).toHaveBeenCalledTimes(1); 962 + }); 963 + }); 964 + 965 + describe("canUndo/canRedo", () => { 966 + it("should return false initially", () => { 967 + const store = new Store(); 968 + 969 + expect(store.canUndo()).toBe(false); 970 + expect(store.canRedo()).toBe(false); 971 + }); 972 + 973 + it("should return true after executing command", () => { 974 + const store = new Store(); 975 + const page = PageRecord.create("Page 1", "page1"); 976 + 977 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 978 + 979 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 980 + 981 + const command = new CreateShapeCommand(shape, page.id); 982 + store.executeCommand(command); 983 + 984 + expect(store.canUndo()).toBe(true); 985 + expect(store.canRedo()).toBe(false); 986 + }); 987 + 988 + it("should return true for redo after undo", () => { 989 + const store = new Store(); 990 + const page = PageRecord.create("Page 1", "page1"); 991 + 992 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 993 + 994 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 995 + 996 + const command = new CreateShapeCommand(shape, page.id); 997 + store.executeCommand(command); 998 + store.undo(); 999 + 1000 + expect(store.canUndo()).toBe(false); 1001 + expect(store.canRedo()).toBe(true); 1002 + }); 1003 + }); 1004 + 1005 + describe("getHistory", () => { 1006 + it("should return history state", () => { 1007 + const store = new Store(); 1008 + const history = store.getHistory(); 1009 + 1010 + expect(history).toBeDefined(); 1011 + expect(history.undoStack).toEqual([]); 1012 + expect(history.redoStack).toEqual([]); 1013 + }); 1014 + 1015 + it("should return updated history after commands", () => { 1016 + const store = new Store(); 1017 + const page = PageRecord.create("Page 1", "page1"); 1018 + 1019 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 1020 + 1021 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 1022 + 1023 + const command = new CreateShapeCommand(shape, page.id); 1024 + store.executeCommand(command); 1025 + 1026 + const history = store.getHistory(); 1027 + 1028 + expect(history.undoStack).toHaveLength(1); 1029 + expect(history.redoStack).toHaveLength(0); 1030 + }); 1031 + }); 1032 + 1033 + describe("clearHistory", () => { 1034 + it("should clear all history", () => { 1035 + const store = new Store(); 1036 + const page = PageRecord.create("Page 1", "page1"); 1037 + 1038 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 1039 + 1040 + const shape = ShapeRecord.createRect("page1", 10, 20, { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }); 1041 + 1042 + const command = new CreateShapeCommand(shape, page.id); 1043 + store.executeCommand(command); 1044 + 1045 + expect(store.canUndo()).toBe(true); 1046 + 1047 + store.clearHistory(); 1048 + 1049 + expect(store.canUndo()).toBe(false); 1050 + expect(store.canRedo()).toBe(false); 1051 + }); 1052 + }); 1053 + 1054 + describe("history with multiple commands", () => { 1055 + it("should handle multiple commands", () => { 1056 + const store = new Store(); 1057 + const page = PageRecord.create("Page 1", "page1"); 1058 + 1059 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 1060 + 1061 + const shape1 = ShapeRecord.createRect( 1062 + "page1", 1063 + 10, 1064 + 20, 1065 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1066 + "shape1", 1067 + ); 1068 + 1069 + const shape2 = ShapeRecord.createRect("page1", 30, 40, { 1070 + w: 200, 1071 + h: 100, 1072 + fill: "#000", 1073 + stroke: "#fff", 1074 + radius: 0, 1075 + }, "shape2"); 1076 + 1077 + store.executeCommand(new CreateShapeCommand(shape1, page.id)); 1078 + store.executeCommand(new CreateShapeCommand(shape2, page.id)); 1079 + 1080 + expect(store.getState().doc.shapes[shape1.id]).toBeDefined(); 1081 + expect(store.getState().doc.shapes[shape2.id]).toBeDefined(); 1082 + 1083 + store.undo(); 1084 + 1085 + expect(store.getState().doc.shapes[shape1.id]).toBeDefined(); 1086 + expect(store.getState().doc.shapes[shape2.id]).toBeUndefined(); 1087 + 1088 + store.undo(); 1089 + 1090 + expect(store.getState().doc.shapes[shape1.id]).toBeUndefined(); 1091 + expect(store.getState().doc.shapes[shape2.id]).toBeUndefined(); 1092 + 1093 + store.redo(); 1094 + 1095 + expect(store.getState().doc.shapes[shape1.id]).toBeDefined(); 1096 + expect(store.getState().doc.shapes[shape2.id]).toBeUndefined(); 1097 + 1098 + store.redo(); 1099 + 1100 + expect(store.getState().doc.shapes[shape1.id]).toBeDefined(); 1101 + expect(store.getState().doc.shapes[shape2.id]).toBeDefined(); 1102 + }); 1103 + 1104 + it("should clear redo stack when new command is executed", () => { 1105 + const store = new Store(); 1106 + const page = PageRecord.create("Page 1", "page1"); 1107 + 1108 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 1109 + 1110 + const shape1 = ShapeRecord.createRect( 1111 + "page1", 1112 + 10, 1113 + 20, 1114 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1115 + "shape1", 1116 + ); 1117 + 1118 + const shape2 = ShapeRecord.createRect("page1", 30, 40, { 1119 + w: 200, 1120 + h: 100, 1121 + fill: "#000", 1122 + stroke: "#fff", 1123 + radius: 0, 1124 + }, "shape2"); 1125 + 1126 + store.executeCommand(new CreateShapeCommand(shape1, page.id)); 1127 + store.undo(); 1128 + 1129 + expect(store.canRedo()).toBe(true); 1130 + 1131 + store.executeCommand(new CreateShapeCommand(shape2, page.id)); 1132 + 1133 + expect(store.canRedo()).toBe(false); 1134 + }); 1135 + }); 1136 + });