@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +801 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. package/src/context.test.jsx +13 -0
@@ -39,6 +39,29 @@
39
39
  top: 12px;
40
40
  left: 16px;
41
41
  z-index: 10;
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 8px;
45
+ }
46
+
47
+ .canvasTitleWrap {
48
+ display: inline-grid;
49
+ }
50
+
51
+ .canvasTitleWrap > * {
52
+ grid-area: 1 / 1;
53
+ }
54
+
55
+ .canvasTitleMeasure {
56
+ visibility: hidden;
57
+ white-space: pre;
58
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
59
+ font-size: 14px;
60
+ font-weight: 600;
61
+ padding: 4px 8px;
62
+ border: 1px solid transparent;
63
+ min-width: 80px;
64
+ pointer-events: none;
42
65
  }
43
66
 
44
67
  .canvasTitleInput {
@@ -50,9 +73,10 @@
50
73
  border: 1px solid transparent;
51
74
  border-radius: 6px;
52
75
  padding: 4px 8px;
76
+ margin: 0;
53
77
  outline: none;
54
- min-width: 80px;
55
- max-width: 300px;
78
+ width: 100%;
79
+ min-width: 0;
56
80
  transition: border-color 150ms, background-color 150ms, color 150ms;
57
81
  }
58
82
 
@@ -68,7 +92,28 @@
68
92
  background: var(--bgColor-default, #ffffff);
69
93
  }
70
94
 
95
+ .canvasTitleStatic {
96
+ composes: canvasTitleInput;
97
+ cursor: default;
98
+ pointer-events: none;
99
+ }
100
+
71
101
  /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
72
102
  :global(.tc-draggable-inner) {
73
103
  overflow: visible;
74
104
  }
105
+
106
+ .localEditingLabel {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ padding: 4px 12px;
110
+ background: hsl(212, 92%, 45%);
111
+ color: #fff;
112
+ font-size: 13px;
113
+ font-weight: 600;
114
+ border-radius: 6px;
115
+ letter-spacing: 0.01em;
116
+ white-space: nowrap;
117
+ pointer-events: none;
118
+ user-select: none;
119
+ }
@@ -0,0 +1,345 @@
1
+ import { fireEvent, render, screen, act } from '@testing-library/react'
2
+ import CanvasPage from './CanvasPage.jsx'
3
+ import { updateCanvas, removeWidget } from './canvasApi.js'
4
+
5
+ const MOCK_UNDO_REDO = {
6
+ snapshot: vi.fn(),
7
+ undo: vi.fn(),
8
+ redo: vi.fn(),
9
+ reset: vi.fn(),
10
+ canUndo: false,
11
+ canRedo: false,
12
+ }
13
+
14
+ vi.mock('./useUndoRedo.js', () => ({
15
+ default: () => MOCK_UNDO_REDO,
16
+ }))
17
+
18
+ // Expose drag callbacks so tests can trigger drags with specific IDs
19
+ let capturedOnDragStart = null
20
+ let capturedOnDrag = null
21
+ let capturedOnDragEnd = null
22
+ vi.mock('@dfosco/tiny-canvas', () => ({
23
+ Canvas: ({ children, onDragStart, onDrag, onDragEnd }) => {
24
+ capturedOnDragStart = onDragStart
25
+ capturedOnDrag = onDrag
26
+ capturedOnDragEnd = onDragEnd
27
+ return <div data-testid="tiny-canvas">{children}</div>
28
+ },
29
+ }))
30
+
31
+ const mockCanvas = {
32
+ title: 'Multi-Select Test',
33
+ widgets: [
34
+ { id: 'w1', type: 'sticky-note', position: { x: 100, y: 100 }, props: {} },
35
+ { id: 'w2', type: 'sticky-note', position: { x: 300, y: 100 }, props: {} },
36
+ { id: 'w3', type: 'markdown', position: { x: 500, y: 200 }, props: {} },
37
+ ],
38
+ sources: [],
39
+ centered: false,
40
+ dotted: false,
41
+ grid: false,
42
+ gridSize: 18,
43
+ colorMode: 'auto',
44
+ }
45
+
46
+ vi.mock('./useCanvas.js', () => ({
47
+ useCanvas: () => ({
48
+ canvas: mockCanvas,
49
+ jsxExports: null,
50
+ loading: false,
51
+ }),
52
+ }))
53
+
54
+ vi.mock('./widgets/index.js', () => ({
55
+ getWidgetComponent: () => function MockWidget({ id }) {
56
+ return <div data-testid={`widget-content-${id}`}>widget</div>
57
+ },
58
+ }))
59
+
60
+ // WidgetChrome mock that exposes onSelect with shift parameter
61
+ vi.mock('./widgets/WidgetChrome.jsx', () => ({
62
+ default: ({ children, onSelect, selected, multiSelected, widgetId }) => (
63
+ <div
64
+ data-testid={`chrome-${widgetId}`}
65
+ data-selected={selected || undefined}
66
+ data-multi-selected={multiSelected || undefined}
67
+ >
68
+ {children}
69
+ <button
70
+ className="tc-drag-handle"
71
+ data-testid={`select-${widgetId}`}
72
+ onClick={(e) => { e.stopPropagation(); onSelect?.(false) }}
73
+ >
74
+ select
75
+ </button>
76
+ <button
77
+ className="tc-drag-handle"
78
+ data-testid={`shift-select-${widgetId}`}
79
+ onClick={(e) => { e.stopPropagation(); onSelect?.(true) }}
80
+ >
81
+ shift-select
82
+ </button>
83
+ </div>
84
+ ),
85
+ }))
86
+
87
+ vi.mock('./widgets/widgetProps.js', () => ({
88
+ schemas: {},
89
+ getDefaults: () => ({}),
90
+ }))
91
+
92
+ vi.mock('./widgets/widgetConfig.js', () => ({
93
+ getFeatures: () => [],
94
+ isResizable: () => false,
95
+ schemas: {},
96
+ getMenuWidgetTypes: () => [],
97
+ }))
98
+
99
+ vi.mock('./widgets/figmaUrl.js', () => ({
100
+ isFigmaUrl: () => false,
101
+ sanitizeFigmaUrl: (url) => url,
102
+ }))
103
+
104
+ vi.mock('./canvasApi.js', () => ({
105
+ addWidget: vi.fn(),
106
+ updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
107
+ removeWidget: vi.fn(() => Promise.resolve({ success: true })),
108
+ uploadImage: vi.fn(),
109
+ }))
110
+
111
+ describe('CanvasPage multi-select', () => {
112
+ beforeEach(() => {
113
+ delete window.__storyboardCanvasBridgeState
114
+ window.__SB_LOCAL_DEV__ = true
115
+ vi.clearAllMocks()
116
+ capturedOnDragStart = null
117
+ capturedOnDrag = null
118
+ capturedOnDragEnd = null
119
+ })
120
+
121
+ afterEach(() => {
122
+ delete window.__SB_LOCAL_DEV__
123
+ })
124
+
125
+ it('shift+click on select handle adds widget to selection', async () => {
126
+ render(<CanvasPage name="test-canvas" />)
127
+
128
+ // Select first widget
129
+ fireEvent.click(screen.getByTestId('select-w1'))
130
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
131
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
132
+
133
+ // Shift+select second widget
134
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
135
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
136
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
137
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
138
+ })
139
+
140
+ it('shift+click on already selected widget removes it from selection', async () => {
141
+ render(<CanvasPage name="test-canvas" />)
142
+
143
+ // Select both
144
+ fireEvent.click(screen.getByTestId('select-w1'))
145
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
146
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
147
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
148
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
149
+
150
+ // Shift+click w1 again to remove it
151
+ fireEvent.click(screen.getByTestId('shift-select-w1'))
152
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
153
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
154
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
155
+ })
156
+
157
+ it('normal click replaces multi-selection with single', async () => {
158
+ render(<CanvasPage name="test-canvas" />)
159
+
160
+ // Multi-select
161
+ fireEvent.click(screen.getByTestId('select-w1'))
162
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
163
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
164
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
165
+
166
+ // Normal click on w1 clears multi-select
167
+ fireEvent.click(screen.getByTestId('select-w1'))
168
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
169
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
170
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
171
+ expect(screen.getByTestId('chrome-w3').dataset.selected).toBeUndefined()
172
+ })
173
+
174
+ it('sets multiSelected on all selected widgets when multiple are selected', async () => {
175
+ render(<CanvasPage name="test-canvas" />)
176
+
177
+ fireEvent.click(screen.getByTestId('select-w1'))
178
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
179
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
180
+
181
+ expect(screen.getByTestId('chrome-w1').dataset.multiSelected).toBeDefined()
182
+ expect(screen.getByTestId('chrome-w2').dataset.multiSelected).toBeDefined()
183
+ // Unselected widget should not have multiSelected
184
+ expect(screen.getByTestId('chrome-w3').dataset.multiSelected).toBeUndefined()
185
+ })
186
+
187
+ it('Escape clears all selection', async () => {
188
+ render(<CanvasPage name="test-canvas" />)
189
+
190
+ fireEvent.click(screen.getByTestId('select-w1'))
191
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
192
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
193
+
194
+ fireEvent.keyDown(document, { key: 'Escape' })
195
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
196
+
197
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
198
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
199
+ })
200
+
201
+ it('Delete removes all selected widgets and calls updateCanvas', async () => {
202
+ render(<CanvasPage name="test-canvas" />)
203
+
204
+ // Multi-select w1 and w2
205
+ fireEvent.click(screen.getByTestId('select-w1'))
206
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
207
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
208
+
209
+ // Press Delete
210
+ fireEvent.keyDown(document, { key: 'Delete' })
211
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
212
+
213
+ // Should call updateCanvas with only w3 remaining
214
+ expect(updateCanvas).toHaveBeenCalledWith(
215
+ 'test-canvas',
216
+ expect.objectContaining({
217
+ widgets: [expect.objectContaining({ id: 'w3' })],
218
+ })
219
+ )
220
+ // Should NOT use individual removeWidget API for multi-delete
221
+ expect(removeWidget).not.toHaveBeenCalled()
222
+ // Should snapshot for undo
223
+ expect(MOCK_UNDO_REDO.snapshot).toHaveBeenCalled()
224
+ })
225
+
226
+ it('single-select Delete uses removeWidget API', async () => {
227
+ render(<CanvasPage name="test-canvas" />)
228
+
229
+ fireEvent.click(screen.getByTestId('select-w1'))
230
+ fireEvent.keyDown(document, { key: 'Backspace' })
231
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
232
+
233
+ expect(removeWidget).toHaveBeenCalledWith('test-canvas', 'w1')
234
+ })
235
+
236
+ it('multi-select move applies delta to all selected widgets', async () => {
237
+ render(<CanvasPage name="test-canvas" />)
238
+
239
+ // Multi-select w1 (100,100) and w2 (300,100)
240
+ fireEvent.click(screen.getByTestId('select-w1'))
241
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
242
+
243
+ // Wait for selectedIdsRef to sync
244
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
245
+
246
+ // Drag w1 to (150, 200) → delta is (+50, +100)
247
+ expect(capturedOnDragEnd).toBeTruthy()
248
+ act(() => {
249
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
250
+ })
251
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
252
+
253
+ // w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200)
254
+ expect(updateCanvas).toHaveBeenCalledWith(
255
+ 'test-canvas',
256
+ expect.objectContaining({
257
+ widgets: expect.arrayContaining([
258
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 200 } }),
259
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 200 } }),
260
+ // w3 unchanged
261
+ expect.objectContaining({ id: 'w3', position: { x: 500, y: 200 } }),
262
+ ]),
263
+ })
264
+ )
265
+ })
266
+
267
+ it('multi-select drag captures peer articles on drag start', async () => {
268
+ render(<CanvasPage name="test-canvas" />)
269
+
270
+ // Multi-select w1 and w2
271
+ fireEvent.click(screen.getByTestId('select-w1'))
272
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
273
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
274
+
275
+ // All drag callbacks should be captured
276
+ expect(capturedOnDragStart).toBeTruthy()
277
+ expect(capturedOnDrag).toBeTruthy()
278
+
279
+ // Trigger drag start — should not throw
280
+ act(() => {
281
+ capturedOnDragStart('w1', { x: 100, y: 100 })
282
+ })
283
+
284
+ // Drag tick — peers stay put (no live preview), should not throw
285
+ act(() => {
286
+ capturedOnDrag('w1', { x: 150, y: 200 })
287
+ })
288
+ })
289
+
290
+ it('multi-select drag preserves selection after drag end', async () => {
291
+ render(<CanvasPage name="test-canvas" />)
292
+
293
+ // Multi-select w1 and w2
294
+ fireEvent.click(screen.getByTestId('select-w1'))
295
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
296
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
297
+
298
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
299
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
300
+
301
+ // Simulate full drag: start → drag → end
302
+ act(() => {
303
+ capturedOnDragStart('w1', { x: 100, y: 100 })
304
+ })
305
+ act(() => {
306
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
307
+ })
308
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
309
+
310
+ // Selection should still include both widgets (justDraggedRef prevents collapse)
311
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
312
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
313
+ })
314
+
315
+ it('any selected widget can serve as drag handler for the group', async () => {
316
+ render(<CanvasPage name="test-canvas" />)
317
+
318
+ // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
+ fireEvent.click(screen.getByTestId('select-w1'))
320
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
321
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
322
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
323
+
324
+ // Drag w2 (the middle one) to (350, 150) → delta (+50, +50)
325
+ act(() => {
326
+ capturedOnDragStart('w2', { x: 300, y: 100 })
327
+ })
328
+ act(() => {
329
+ capturedOnDragEnd('w2', { x: 350, y: 150 })
330
+ })
331
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
332
+
333
+ // All selected widgets should move by the same delta (+50, +50)
334
+ expect(updateCanvas).toHaveBeenCalledWith(
335
+ 'test-canvas',
336
+ expect.objectContaining({
337
+ widgets: expect.arrayContaining([
338
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
339
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
340
+ expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
341
+ ]),
342
+ })
343
+ )
344
+ })
345
+ })
@@ -39,3 +39,11 @@ export function addWidget(name, { type, props, position }) {
39
39
  export function removeWidget(name, widgetId) {
40
40
  return request('/widget', 'DELETE', { name, widgetId })
41
41
  }
42
+
43
+ export function uploadImage(dataUrl, canvasName) {
44
+ return request('/image', 'POST', { dataUrl, canvasName })
45
+ }
46
+
47
+ export function toggleImagePrivacy(filename) {
48
+ return request('/image/toggle-private', 'POST', { filename })
49
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ // computeCanvasBounds is not exported, so we replicate it here for unit testing.
4
+ // This keeps the test decoupled from CanvasPage internals while validating the algorithm.
5
+
6
+ const WIDGET_FALLBACK_SIZES = {
7
+ 'sticky-note': { width: 180, height: 60 },
8
+ 'markdown': { width: 360, height: 200 },
9
+ 'prototype': { width: 800, height: 600 },
10
+ 'link-preview': { width: 320, height: 120 },
11
+ 'figma-embed': { width: 800, height: 450 },
12
+ 'component': { width: 200, height: 150 },
13
+ 'image': { width: 400, height: 300 },
14
+ }
15
+
16
+ function computeCanvasBounds(widgets, sources, jsxExports) {
17
+ let minX = Infinity
18
+ let minY = Infinity
19
+ let maxX = -Infinity
20
+ let maxY = -Infinity
21
+ let hasItems = false
22
+
23
+ for (const w of (widgets ?? [])) {
24
+ const x = w?.position?.x ?? 0
25
+ const y = w?.position?.y ?? 0
26
+ const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
27
+ const width = w.props?.width ?? fallback.width
28
+ const height = w.props?.height ?? fallback.height
29
+ minX = Math.min(minX, x)
30
+ minY = Math.min(minY, y)
31
+ maxX = Math.max(maxX, x + width)
32
+ maxY = Math.max(maxY, y + height)
33
+ hasItems = true
34
+ }
35
+
36
+ const sourceMap = Object.fromEntries(
37
+ (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
38
+ )
39
+ if (jsxExports) {
40
+ for (const exportName of Object.keys(jsxExports)) {
41
+ const sourceData = sourceMap[exportName] || {}
42
+ const x = sourceData.position?.x ?? 0
43
+ const y = sourceData.position?.y ?? 0
44
+ const fallback = WIDGET_FALLBACK_SIZES['component']
45
+ const width = sourceData.width ?? fallback.width
46
+ const height = sourceData.height ?? fallback.height
47
+ minX = Math.min(minX, x)
48
+ minY = Math.min(minY, y)
49
+ maxX = Math.max(maxX, x + width)
50
+ maxY = Math.max(maxY, y + height)
51
+ hasItems = true
52
+ }
53
+ }
54
+
55
+ return hasItems ? { minX, minY, maxX, maxY } : null
56
+ }
57
+
58
+ describe('computeCanvasBounds', () => {
59
+ it('returns null for empty canvas', () => {
60
+ expect(computeCanvasBounds([], [], null)).toBeNull()
61
+ expect(computeCanvasBounds(null, null, null)).toBeNull()
62
+ })
63
+
64
+ it('computes bounds for a single widget using props dimensions', () => {
65
+ const widgets = [
66
+ { type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 300, height: 100 } },
67
+ ]
68
+ const bounds = computeCanvasBounds(widgets, [], null)
69
+ expect(bounds).toEqual({ minX: 100, minY: 200, maxX: 400, maxY: 300 })
70
+ })
71
+
72
+ it('uses fallback dimensions when props are missing', () => {
73
+ const widgets = [
74
+ { type: 'sticky-note', position: { x: 50, y: 50 }, props: {} },
75
+ ]
76
+ const bounds = computeCanvasBounds(widgets, [], null)
77
+ expect(bounds).toEqual({ minX: 50, minY: 50, maxX: 230, maxY: 110 }) // 50+180, 50+60
78
+ })
79
+
80
+ it('computes bounds spanning multiple widgets', () => {
81
+ const widgets = [
82
+ { type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 180, height: 60 } },
83
+ { type: 'markdown', position: { x: 500, y: 300 }, props: { width: 360, height: 200 } },
84
+ ]
85
+ const bounds = computeCanvasBounds(widgets, [], null)
86
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 860, maxY: 500 })
87
+ })
88
+
89
+ it('includes JSX sources in bounds', () => {
90
+ const sources = [{ export: 'Hero', position: { x: -100, y: -50 }, width: 400, height: 300 }]
91
+ const jsxExports = { Hero: () => null }
92
+ const bounds = computeCanvasBounds([], sources, jsxExports)
93
+ expect(bounds).toEqual({ minX: -100, minY: -50, maxX: 300, maxY: 250 })
94
+ })
95
+
96
+ it('combines widgets and JSX sources', () => {
97
+ const widgets = [
98
+ { type: 'sticky-note', position: { x: 200, y: 200 }, props: { width: 180, height: 60 } },
99
+ ]
100
+ const sources = [{ export: 'Nav', position: { x: 0, y: 0 }, width: 100, height: 100 }]
101
+ const jsxExports = { Nav: () => null }
102
+ const bounds = computeCanvasBounds(widgets, sources, jsxExports)
103
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 380, maxY: 260 })
104
+ })
105
+
106
+ it('handles widgets with missing position (defaults to 0,0)', () => {
107
+ const widgets = [
108
+ { type: 'sticky-note', props: { width: 180, height: 60 } },
109
+ ]
110
+ const bounds = computeCanvasBounds(widgets, [], null)
111
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 180, maxY: 60 })
112
+ })
113
+
114
+ it('uses component fallback for JSX sources without explicit size', () => {
115
+ const sources = [{ export: 'Card', position: { x: 10, y: 10 } }]
116
+ const jsxExports = { Card: () => null }
117
+ const bounds = computeCanvasBounds([], sources, jsxExports)
118
+ // component fallback: 200x150
119
+ expect(bounds).toEqual({ minX: 10, minY: 10, maxX: 210, maxY: 160 })
120
+ })
121
+ })
@@ -9,7 +9,8 @@ async function fetchCanvasFromServer(name) {
9
9
  try {
10
10
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
11
11
  const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(name)}`)
12
- if (res.ok) return res.json()
12
+ const contentType = res.headers.get('content-type') || ''
13
+ if (res.ok && contentType.includes('application/json')) return res.json()
13
14
  } catch { /* fall back to build-time data */ }
14
15
  return null
15
16
  }
@@ -0,0 +1,86 @@
1
+ import { useRef, useState, useCallback } from 'react'
2
+
3
+ const MAX_HISTORY = 100
4
+ const COALESCE_MS = 2000
5
+
6
+ /**
7
+ * Snapshot-based undo/redo history for canvas widgets.
8
+ *
9
+ * Tracks past/future stacks of widget array clones. The present state
10
+ * is always the live `localWidgets` — this hook only manages history.
11
+ *
12
+ * Edit coalescing: continuous edits to the same widget within COALESCE_MS
13
+ * are merged into one undo step (like Figma).
14
+ */
15
+ export default function useUndoRedo() {
16
+ const pastRef = useRef([])
17
+ const futureRef = useRef([])
18
+ const lastActionRef = useRef({ type: null, widgetId: null, time: 0 })
19
+ // State counter drives canUndo/canRedo re-renders without cloning the stacks
20
+ const [counts, setCounts] = useState({ past: 0, future: 0 })
21
+
22
+ const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
23
+ const widgets = currentWidgets ?? []
24
+
25
+ // Edit coalescing — skip snapshot if same edit target within timeout
26
+ if (actionType === 'edit' && widgetId) {
27
+ const last = lastActionRef.current
28
+ const now = Date.now()
29
+ if (
30
+ last.type === 'edit' &&
31
+ last.widgetId === widgetId &&
32
+ now - last.time < COALESCE_MS
33
+ ) {
34
+ lastActionRef.current = { type: 'edit', widgetId, time: now }
35
+ return
36
+ }
37
+ }
38
+
39
+ pastRef.current.push(structuredClone(widgets))
40
+ if (pastRef.current.length > MAX_HISTORY) pastRef.current.shift()
41
+ futureRef.current = []
42
+ lastActionRef.current = {
43
+ type: actionType,
44
+ widgetId: widgetId || null,
45
+ time: Date.now(),
46
+ }
47
+ setCounts({ past: pastRef.current.length, future: 0 })
48
+ }, [])
49
+
50
+ const undo = useCallback((currentWidgets) => {
51
+ if (pastRef.current.length === 0) return null
52
+ futureRef.current.push(structuredClone(currentWidgets))
53
+ const previous = pastRef.current.pop()
54
+ lastActionRef.current = { type: 'undo', widgetId: null, time: Date.now() }
55
+ setCounts({ past: pastRef.current.length, future: futureRef.current.length })
56
+ return previous
57
+ }, [])
58
+
59
+ const redo = useCallback((currentWidgets) => {
60
+ if (futureRef.current.length === 0) return null
61
+ pastRef.current.push(structuredClone(currentWidgets))
62
+ const next = futureRef.current.pop()
63
+ lastActionRef.current = { type: 'redo', widgetId: null, time: Date.now() }
64
+ setCounts({ past: pastRef.current.length, future: futureRef.current.length })
65
+ return next
66
+ }, [])
67
+
68
+ const reset = useCallback(() => {
69
+ pastRef.current = []
70
+ futureRef.current = []
71
+ lastActionRef.current = { type: null, widgetId: null, time: 0 }
72
+ setCounts((prev) => {
73
+ if (prev.past === 0 && prev.future === 0) return prev
74
+ return { past: 0, future: 0 }
75
+ })
76
+ }, [])
77
+
78
+ return {
79
+ snapshot,
80
+ undo,
81
+ redo,
82
+ reset,
83
+ canUndo: counts.past > 0,
84
+ canRedo: counts.future > 0,
85
+ }
86
+ }