@dfosco/storyboard-react 3.11.0-beta.0 → 3.11.0-beta.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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.0",
3
+ "version": "3.11.0-beta.2",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.0",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.0",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.2",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.2",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -2,16 +2,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
3
3
  import styles from './CanvasControls.module.css'
4
4
 
5
- const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
6
- export const ZOOM_MIN = ZOOM_STEPS[0]
7
- export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
8
-
9
5
  const WIDGET_TYPES = getMenuWidgetTypes()
10
6
 
11
7
  /**
12
- * Focused canvas toolbar — bottom-left controls for zoom and widget creation.
8
+ * Focused canvas toolbar — bottom-left add-widget control.
13
9
  */
14
- export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
10
+ export default function CanvasControls({ onAddWidget }) {
15
11
  const [menuOpen, setMenuOpen] = useState(false)
16
12
  const menuRef = useRef(null)
17
13
 
@@ -27,24 +23,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
27
23
  return () => document.removeEventListener('pointerdown', handlePointerDown)
28
24
  }, [menuOpen])
29
25
 
30
- const zoomIn = useCallback(() => {
31
- onZoomChange((z) => {
32
- const next = ZOOM_STEPS.find((s) => s > z)
33
- return next ?? ZOOM_MAX
34
- })
35
- }, [onZoomChange])
36
-
37
- const zoomOut = useCallback(() => {
38
- onZoomChange((z) => {
39
- const next = [...ZOOM_STEPS].reverse().find((s) => s < z)
40
- return next ?? ZOOM_MIN
41
- })
42
- }, [onZoomChange])
43
-
44
- const resetZoom = useCallback(() => {
45
- onZoomChange(100)
46
- }, [onZoomChange])
47
-
48
26
  const handleAddWidget = useCallback((type) => {
49
27
  onAddWidget(type)
50
28
  setMenuOpen(false)
@@ -52,7 +30,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
52
30
 
53
31
  return (
54
32
  <div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
55
- {/* Create widget */}
56
33
  <div ref={menuRef} className={styles.createGroup}>
57
34
  <button
58
35
  className={styles.btn}
@@ -81,40 +58,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
81
58
  </div>
82
59
  )}
83
60
  </div>
84
-
85
- <div className={styles.divider} />
86
-
87
- {/* Zoom controls */}
88
- <button
89
- className={styles.btn}
90
- onClick={zoomOut}
91
- disabled={zoom <= ZOOM_MIN}
92
- aria-label="Zoom out"
93
- title="Zoom out"
94
- >
95
- <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
96
- <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
97
- </svg>
98
- </button>
99
- <button
100
- className={styles.zoomLevel}
101
- onClick={resetZoom}
102
- title="Reset to 100%"
103
- aria-label={`Zoom ${zoom}%, click to reset`}
104
- >
105
- {zoom}%
106
- </button>
107
- <button
108
- className={styles.btn}
109
- onClick={zoomIn}
110
- disabled={zoom >= ZOOM_MAX}
111
- aria-label="Zoom in"
112
- title="Zoom in"
113
- >
114
- <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
115
- <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
116
- </svg>
117
- </button>
118
61
  </div>
119
62
  )
120
63
  }
@@ -50,35 +50,6 @@
50
50
  cursor: default;
51
51
  }
52
52
 
53
- .zoomLevel {
54
- all: unset;
55
- cursor: pointer;
56
- display: flex;
57
- align-items: center;
58
- justify-content: center;
59
- min-width: 44px;
60
- height: 32px;
61
- padding: 0 4px;
62
- border-radius: 8px;
63
- font-size: 12px;
64
- font-weight: 500;
65
- font-variant-numeric: tabular-nums;
66
- color: var(--fgColor-muted, #656d76);
67
- transition: background 120ms;
68
- }
69
-
70
- .zoomLevel:hover {
71
- background: var(--bgColor-muted, #f6f8fa);
72
- color: var(--fgColor-default, #1f2328);
73
- }
74
-
75
- .divider {
76
- width: 1px;
77
- height: 20px;
78
- margin: 0 2px;
79
- background: var(--borderColor-muted, #d8dee4);
80
- }
81
-
82
53
  /* Create widget menu */
83
54
  .createGroup {
84
55
  position: relative;
@@ -11,6 +11,7 @@ import { getFeatures } from './widgets/widgetConfig.js'
11
11
  import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
+ import useUndoRedo from './useUndoRedo.js'
14
15
  import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
15
16
  import styles from './CanvasPage.module.css'
16
17
 
@@ -48,13 +49,16 @@ function resolveCanvasThemeFromStorage() {
48
49
 
49
50
  /**
50
51
  * Debounce helper — returns a function that delays invocation.
52
+ * Exposes `.cancel()` to abort pending calls (used by undo/redo).
51
53
  */
52
54
  function debounce(fn, ms) {
53
55
  let timer
54
- return (...args) => {
56
+ const debounced = (...args) => {
55
57
  clearTimeout(timer)
56
58
  timer = setTimeout(() => fn(...args), ms)
57
59
  }
60
+ debounced.cancel = () => clearTimeout(timer)
61
+ return debounced
58
62
  }
59
63
 
60
64
  /** Per-canvas viewport state persistence (zoom + scroll position). */
@@ -99,8 +103,8 @@ function getViewportCenter(scrollEl, scale) {
99
103
 
100
104
  /** Fallback sizes for widget types without explicit width/height defaults. */
101
105
  const WIDGET_FALLBACK_SIZES = {
102
- 'sticky-note': { width: 180, height: 60 },
103
- 'markdown': { width: 360, height: 200 },
106
+ 'sticky-note': { width: 270, height: 170 },
107
+ 'markdown': { width: 530, height: 240 },
104
108
  'prototype': { width: 800, height: 600 },
105
109
  'link-preview': { width: 320, height: 120 },
106
110
  'figma-embed': { width: 800, height: 450 },
@@ -126,6 +130,57 @@ function roundPosition(value) {
126
130
  return Math.round(value)
127
131
  }
128
132
 
133
+ /** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
134
+ const FIT_PADDING = 48
135
+
136
+ /**
137
+ * Compute the axis-aligned bounding box that contains every widget and source.
138
+ * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
139
+ */
140
+ function computeCanvasBounds(widgets, sources, jsxExports) {
141
+ let minX = Infinity
142
+ let minY = Infinity
143
+ let maxX = -Infinity
144
+ let maxY = -Infinity
145
+ let hasItems = false
146
+
147
+ // JSON widgets
148
+ for (const w of (widgets ?? [])) {
149
+ const x = w?.position?.x ?? 0
150
+ const y = w?.position?.y ?? 0
151
+ const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
152
+ const width = w.props?.width ?? fallback.width
153
+ const height = w.props?.height ?? fallback.height
154
+ minX = Math.min(minX, x)
155
+ minY = Math.min(minY, y)
156
+ maxX = Math.max(maxX, x + width)
157
+ maxY = Math.max(maxY, y + height)
158
+ hasItems = true
159
+ }
160
+
161
+ // JSX sources
162
+ const sourceMap = Object.fromEntries(
163
+ (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
164
+ )
165
+ if (jsxExports) {
166
+ for (const exportName of Object.keys(jsxExports)) {
167
+ const sourceData = sourceMap[exportName] || {}
168
+ const x = sourceData.position?.x ?? 0
169
+ const y = sourceData.position?.y ?? 0
170
+ const fallback = WIDGET_FALLBACK_SIZES['component']
171
+ const width = sourceData.width ?? fallback.width
172
+ const height = sourceData.height ?? fallback.height
173
+ minX = Math.min(minX, x)
174
+ minY = Math.min(minY, y)
175
+ maxX = Math.max(maxX, x + width)
176
+ maxY = Math.max(maxY, y + height)
177
+ hasItems = true
178
+ }
179
+ }
180
+
181
+ return hasItems ? { minX, minY, maxX, maxY } : null
182
+ }
183
+
129
184
  /** Renders a single JSON-defined widget by type lookup. */
130
185
  function WidgetRenderer({ widget, onUpdate, widgetRef }) {
131
186
  const Component = getWidgetComponent(widget.type)
@@ -152,6 +207,7 @@ function ChromeWrappedWidget({
152
207
  onDeselect,
153
208
  onUpdate,
154
209
  onRemove,
210
+ onCopy,
155
211
  }) {
156
212
  const widgetRef = useRef(null)
157
213
  const features = getFeatures(widget.type)
@@ -159,8 +215,10 @@ function ChromeWrappedWidget({
159
215
  const handleAction = useCallback((actionId) => {
160
216
  if (actionId === 'delete') {
161
217
  onRemove(widget.id)
218
+ } else if (actionId === 'copy') {
219
+ onCopy(widget)
162
220
  }
163
- }, [widget.id, onRemove])
221
+ }, [widget, onRemove, onCopy])
164
222
 
165
223
  return (
166
224
  <WidgetChrome
@@ -207,11 +265,28 @@ export default function CanvasPage({ name }) {
207
265
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
208
266
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
209
267
 
268
+ // Undo/redo history — tracks both widgets and sources as a combined snapshot
269
+ const undoRedo = useUndoRedo()
270
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources })
271
+ useEffect(() => {
272
+ stateRef.current = { widgets: localWidgets, sources: localSources }
273
+ }, [localWidgets, localSources])
274
+
275
+ // Serialized write queue — ensures JSONL events land in the right order
276
+ const writeQueueRef = useRef(Promise.resolve())
277
+ function queueWrite(fn) {
278
+ writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
279
+ console.error('[canvas] Write queue error:', err)
280
+ )
281
+ return writeQueueRef.current
282
+ }
283
+
210
284
  if (canvas !== trackedCanvas) {
211
285
  setTrackedCanvas(canvas)
212
286
  setLocalWidgets(canvas?.widgets ?? null)
213
287
  setLocalSources(canvas?.sources ?? [])
214
288
  setCanvasTitle(canvas?.title || name)
289
+ undoRedo.reset()
215
290
  }
216
291
 
217
292
  // Debounced save to server
@@ -245,6 +320,7 @@ export default function CanvasPage({ name }) {
245
320
  }, [])
246
321
 
247
322
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
323
+ undoRedo.snapshot(stateRef.current, 'edit', widgetId)
248
324
  setLocalWidgets((prev) => {
249
325
  if (!prev) return prev
250
326
  const next = prev.map((w) =>
@@ -253,14 +329,44 @@ export default function CanvasPage({ name }) {
253
329
  debouncedSave(name, next)
254
330
  return next
255
331
  })
256
- }, [name, debouncedSave])
332
+ }, [name, debouncedSave, undoRedo])
257
333
 
258
334
  const handleWidgetRemove = useCallback((widgetId) => {
335
+ undoRedo.snapshot(stateRef.current, 'remove', widgetId)
259
336
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
260
- removeWidgetApi(name, widgetId).catch((err) =>
261
- console.error('[canvas] Failed to remove widget:', err)
337
+ queueWrite(() =>
338
+ removeWidgetApi(name, widgetId).catch((err) =>
339
+ console.error('[canvas] Failed to remove widget:', err)
340
+ )
262
341
  )
263
- }, [name])
342
+ }, [name, undoRedo])
343
+
344
+ const handleWidgetCopy = useCallback(async (widget) => {
345
+ // Find the next free offset — check how many copies already exist at +n*40
346
+ const baseX = widget.position?.x ?? 0
347
+ const baseY = widget.position?.y ?? 0
348
+ const occupied = new Set(
349
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
350
+ )
351
+ let n = 1
352
+ while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
353
+ n++
354
+ }
355
+ const position = { x: baseX + n * 40, y: baseY + n * 40 }
356
+ try {
357
+ undoRedo.snapshot(stateRef.current, 'add')
358
+ const result = await addWidgetApi(name, {
359
+ type: widget.type,
360
+ props: { ...widget.props },
361
+ position,
362
+ })
363
+ if (result.success && result.widget) {
364
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
365
+ }
366
+ } catch (err) {
367
+ console.error('[canvas] Failed to copy widget:', err)
368
+ }
369
+ }, [name, localWidgets, undoRedo])
264
370
 
265
371
  const debouncedSourceSave = useRef(
266
372
  debounce((canvasName, sources) => {
@@ -271,6 +377,7 @@ export default function CanvasPage({ name }) {
271
377
  ).current
272
378
 
273
379
  const handleSourceUpdate = useCallback((exportName, updates) => {
380
+ undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
274
381
  setLocalSources((prev) => {
275
382
  const current = Array.isArray(prev) ? prev : []
276
383
  const next = current.some((s) => s?.export === exportName)
@@ -279,38 +386,44 @@ export default function CanvasPage({ name }) {
279
386
  debouncedSourceSave(name, next)
280
387
  return next
281
388
  })
282
- }, [name, debouncedSourceSave])
389
+ }, [name, debouncedSourceSave, undoRedo])
283
390
 
284
391
  const handleItemDragEnd = useCallback((dragId, position) => {
285
392
  if (!dragId || !position) return
286
393
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
287
394
 
288
395
  if (dragId.startsWith('jsx-')) {
396
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
289
397
  const sourceExport = dragId.replace(/^jsx-/, '')
290
398
  setLocalSources((prev) => {
291
399
  const current = Array.isArray(prev) ? prev : []
292
400
  const next = current.some((s) => s?.export === sourceExport)
293
401
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
294
402
  : [...current, { export: sourceExport, position: rounded }]
295
- updateCanvas(name, { sources: next }).catch((err) =>
296
- console.error('[canvas] Failed to save source position:', err)
403
+ queueWrite(() =>
404
+ updateCanvas(name, { sources: next }).catch((err) =>
405
+ console.error('[canvas] Failed to save source position:', err)
406
+ )
297
407
  )
298
408
  return next
299
409
  })
300
410
  return
301
411
  }
302
412
 
413
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
303
414
  setLocalWidgets((prev) => {
304
415
  if (!prev) return prev
305
416
  const next = prev.map((w) =>
306
417
  w.id === dragId ? { ...w, position: rounded } : w
307
418
  )
308
- updateCanvas(name, { widgets: next }).catch((err) =>
309
- console.error('[canvas] Failed to save widget position:', err)
419
+ queueWrite(() =>
420
+ updateCanvas(name, { widgets: next }).catch((err) =>
421
+ console.error('[canvas] Failed to save widget position:', err)
422
+ )
310
423
  )
311
424
  return next
312
425
  })
313
- }, [name])
426
+ }, [name, undoRedo])
314
427
 
315
428
  useEffect(() => {
316
429
  zoomRef.current = zoom
@@ -327,6 +440,36 @@ export default function CanvasPage({ name }) {
327
440
  }
328
441
  }, [name, loading])
329
442
 
443
+ // Center on a specific widget if `?widget=<id>` is in the URL
444
+ useEffect(() => {
445
+ const params = new URLSearchParams(window.location.search)
446
+ const targetId = params.get('widget')
447
+ if (!targetId || loading) return
448
+
449
+ const widgets = localWidgets ?? []
450
+ const widget = widgets.find((w) => w.id === targetId)
451
+ if (!widget) return
452
+
453
+ const el = scrollRef.current
454
+ if (!el) return
455
+
456
+ const scale = zoomRef.current / 100
457
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
458
+ const wW = (widget.props?.width ?? fallback.width) * scale
459
+ const wH = (widget.props?.height ?? fallback.height) * scale
460
+ const wX = (widget.position?.x ?? 0) * scale
461
+ const wY = (widget.position?.y ?? 0) * scale
462
+
463
+ // Center the widget in the viewport
464
+ el.scrollLeft = wX + wW / 2 - el.clientWidth / 2
465
+ el.scrollTop = wY + wH / 2 - el.clientHeight / 2
466
+
467
+ // Clean the URL param without triggering navigation
468
+ const url = new URL(window.location.href)
469
+ url.searchParams.delete('widget')
470
+ window.history.replaceState({}, '', url.toString())
471
+ }, [loading, localWidgets])
472
+
330
473
  // Persist viewport state (zoom + scroll) to localStorage on changes
331
474
  useEffect(() => {
332
475
  const el = scrollRef.current
@@ -455,12 +598,13 @@ export default function CanvasPage({ name }) {
455
598
  position: pos,
456
599
  })
457
600
  if (result.success && result.widget) {
601
+ undoRedo.snapshot(stateRef.current, 'add')
458
602
  setLocalWidgets((prev) => [...(prev || []), result.widget])
459
603
  }
460
604
  } catch (err) {
461
605
  console.error('[canvas] Failed to add widget:', err)
462
606
  }
463
- }, [name])
607
+ }, [name, undoRedo])
464
608
 
465
609
  // Listen for CoreUIBar add-widget events
466
610
  useEffect(() => {
@@ -483,6 +627,38 @@ export default function CanvasPage({ name }) {
483
627
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
484
628
  }, [])
485
629
 
630
+ // Listen for zoom-to-fit from CoreUIBar
631
+ useEffect(() => {
632
+ function handleZoomToFit() {
633
+ const el = scrollRef.current
634
+ if (!el) return
635
+
636
+ const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
637
+ if (!bounds) return
638
+
639
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
640
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
641
+
642
+ const viewW = el.clientWidth
643
+ const viewH = el.clientHeight
644
+
645
+ // Find the zoom level that fits the bounding box in the viewport
646
+ const fitScale = Math.min(viewW / boxW, viewH / boxH)
647
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
648
+ const newScale = fitZoom / 100
649
+
650
+ // Apply zoom synchronously so DOM updates before we scroll
651
+ zoomRef.current = fitZoom
652
+ flushSync(() => setZoom(fitZoom))
653
+
654
+ // Scroll so the bounding box top-left (with padding) is at viewport top-left
655
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
656
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
657
+ }
658
+ document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
659
+ return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
660
+ }, [localWidgets, localSources, jsxExports])
661
+
486
662
  // Canvas background should follow toolbar theme target.
487
663
  useEffect(() => {
488
664
  function readMode() {
@@ -621,6 +797,7 @@ export default function CanvasPage({ name }) {
621
797
  position: pos,
622
798
  })
623
799
  if (result.success && result.widget) {
800
+ undoRedo.snapshot(stateRef.current, 'add')
624
801
  setLocalWidgets((prev) => [...(prev || []), result.widget])
625
802
  }
626
803
  } catch (err) {
@@ -654,7 +831,7 @@ export default function CanvasPage({ name }) {
654
831
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
655
832
  const src = extractPrototypeSrc(pathPortion)
656
833
  type = 'prototype'
657
- props = { src: src || '/', label: '', width: 800, height: 600 }
834
+ props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
658
835
  } else {
659
836
  type = 'link-preview'
660
837
  props = { url: text, title: '' }
@@ -673,6 +850,7 @@ export default function CanvasPage({ name }) {
673
850
  position: pos,
674
851
  })
675
852
  if (result.success && result.widget) {
853
+ undoRedo.snapshot(stateRef.current, 'add')
676
854
  setLocalWidgets((prev) => [...(prev || []), result.widget])
677
855
  }
678
856
  } catch (err) {
@@ -681,7 +859,75 @@ export default function CanvasPage({ name }) {
681
859
  }
682
860
  document.addEventListener('paste', handlePaste)
683
861
  return () => document.removeEventListener('paste', handlePaste)
684
- }, [name])
862
+ }, [name, undoRedo])
863
+
864
+ // --- Undo / Redo ---
865
+ const handleUndo = useCallback(() => {
866
+ const previous = undoRedo.undo(stateRef.current)
867
+ if (!previous) return
868
+ debouncedSave.cancel()
869
+ debouncedSourceSave.cancel()
870
+ setLocalWidgets(previous.widgets)
871
+ setLocalSources(previous.sources)
872
+ queueWrite(() =>
873
+ updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
874
+ console.error('[canvas] Failed to persist undo:', err)
875
+ )
876
+ )
877
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
878
+
879
+ const handleRedo = useCallback(() => {
880
+ const next = undoRedo.redo(stateRef.current)
881
+ if (!next) return
882
+ debouncedSave.cancel()
883
+ debouncedSourceSave.cancel()
884
+ setLocalWidgets(next.widgets)
885
+ setLocalSources(next.sources)
886
+ queueWrite(() =>
887
+ updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
888
+ console.error('[canvas] Failed to persist redo:', err)
889
+ )
890
+ )
891
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
892
+
893
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
894
+ useEffect(() => {
895
+ if (!import.meta.hot) return
896
+ function handleKeyDown(e) {
897
+ const tag = e.target.tagName
898
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
899
+ const mod = e.metaKey || e.ctrlKey
900
+ if (mod && e.key === 'z' && !e.shiftKey) {
901
+ e.preventDefault()
902
+ handleUndo()
903
+ }
904
+ if (mod && e.key === 'z' && e.shiftKey) {
905
+ e.preventDefault()
906
+ handleRedo()
907
+ }
908
+ }
909
+ document.addEventListener('keydown', handleKeyDown)
910
+ return () => document.removeEventListener('keydown', handleKeyDown)
911
+ }, [handleUndo, handleRedo])
912
+
913
+ // Listen for undo/redo from CoreUIBar (Svelte toolbar)
914
+ useEffect(() => {
915
+ function handleUndoEvent() { handleUndo() }
916
+ function handleRedoEvent() { handleRedo() }
917
+ document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
918
+ document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
919
+ return () => {
920
+ document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
921
+ document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
922
+ }
923
+ }, [handleUndo, handleRedo])
924
+
925
+ // Broadcast undo/redo availability to Svelte toolbar
926
+ useEffect(() => {
927
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
928
+ detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
929
+ }))
930
+ }, [undoRedo.canUndo, undoRedo.canRedo])
685
931
 
686
932
  // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
687
933
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
@@ -858,6 +1104,7 @@ export default function CanvasPage({ name }) {
858
1104
  onSelect={() => setSelectedWidgetId(widget.id)}
859
1105
  onDeselect={() => setSelectedWidgetId(null)}
860
1106
  onUpdate={handleWidgetUpdate}
1107
+ onCopy={handleWidgetCopy}
861
1108
  onRemove={(id) => {
862
1109
  handleWidgetRemove(id)
863
1110
  setSelectedWidgetId(null)