@dfosco/storyboard-react 3.11.0-beta.4 → 3.11.1-beta.0

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.
@@ -8,11 +8,9 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
8
  import { getWidgetComponent } from './widgets/index.js'
9
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
10
  import { getFeatures } from './widgets/widgetConfig.js'
11
- import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
11
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
12
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
- import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
13
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
16
14
  import styles from './CanvasPage.module.css'
17
15
 
18
16
  const ZOOM_MIN = 25
@@ -49,40 +47,13 @@ function resolveCanvasThemeFromStorage() {
49
47
 
50
48
  /**
51
49
  * Debounce helper — returns a function that delays invocation.
52
- * Exposes `.cancel()` to abort pending calls (used by undo/redo).
53
50
  */
54
51
  function debounce(fn, ms) {
55
52
  let timer
56
- const debounced = (...args) => {
53
+ return (...args) => {
57
54
  clearTimeout(timer)
58
55
  timer = setTimeout(() => fn(...args), ms)
59
56
  }
60
- debounced.cancel = () => clearTimeout(timer)
61
- return debounced
62
- }
63
-
64
- /** Per-canvas viewport state persistence (zoom + scroll position). */
65
- function getViewportStorageKey(canvasName) {
66
- return `sb-canvas-viewport:${canvasName}`
67
- }
68
-
69
- function loadViewportState(canvasName) {
70
- try {
71
- const raw = localStorage.getItem(getViewportStorageKey(canvasName))
72
- if (!raw) return null
73
- const state = JSON.parse(raw)
74
- return {
75
- zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
76
- scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
77
- scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
78
- }
79
- } catch { return null }
80
- }
81
-
82
- function saveViewportState(canvasName, state) {
83
- try {
84
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
85
- } catch { /* quota exceeded — non-critical */ }
86
57
  }
87
58
 
88
59
  /**
@@ -103,13 +74,11 @@ function getViewportCenter(scrollEl, scale) {
103
74
 
104
75
  /** Fallback sizes for widget types without explicit width/height defaults. */
105
76
  const WIDGET_FALLBACK_SIZES = {
106
- 'sticky-note': { width: 270, height: 170 },
107
- 'markdown': { width: 530, height: 240 },
77
+ 'sticky-note': { width: 180, height: 60 },
78
+ 'markdown': { width: 360, height: 200 },
108
79
  'prototype': { width: 800, height: 600 },
109
80
  'link-preview': { width: 320, height: 120 },
110
- 'figma-embed': { width: 800, height: 450 },
111
81
  'component': { width: 200, height: 150 },
112
- 'image': { width: 400, height: 300 },
113
82
  }
114
83
 
115
84
  /**
@@ -130,77 +99,6 @@ function roundPosition(value) {
130
99
  return Math.round(value)
131
100
  }
132
101
 
133
- /** Snap a value to the nearest grid line. */
134
- function snapValue(value, gridSize) {
135
- return Math.round(value / gridSize) * gridSize
136
- }
137
-
138
- /** Snap a position to the grid if snapping is enabled. */
139
- function snapPosition(pos, gridSize, enabled) {
140
- if (!enabled || !gridSize) return pos
141
- return {
142
- x: Math.max(0, snapValue(pos.x, gridSize)),
143
- y: Math.max(0, snapValue(pos.y, gridSize)),
144
- }
145
- }
146
-
147
- /** Snap a dimension to the grid if snapping is enabled. */
148
- function snapDimension(value, gridSize, enabled, min = 0) {
149
- if (!enabled || !gridSize) return value
150
- return Math.max(min, snapValue(value, gridSize))
151
- }
152
-
153
- /** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
154
- const FIT_PADDING = 48
155
-
156
- /**
157
- * Compute the axis-aligned bounding box that contains every widget and source.
158
- * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
159
- */
160
- function computeCanvasBounds(widgets, sources, jsxExports) {
161
- let minX = Infinity
162
- let minY = Infinity
163
- let maxX = -Infinity
164
- let maxY = -Infinity
165
- let hasItems = false
166
-
167
- // JSON widgets
168
- for (const w of (widgets ?? [])) {
169
- const x = w?.position?.x ?? 0
170
- const y = w?.position?.y ?? 0
171
- const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
172
- const width = w.props?.width ?? fallback.width
173
- const height = w.props?.height ?? fallback.height
174
- minX = Math.min(minX, x)
175
- minY = Math.min(minY, y)
176
- maxX = Math.max(maxX, x + width)
177
- maxY = Math.max(maxY, y + height)
178
- hasItems = true
179
- }
180
-
181
- // JSX sources
182
- const sourceMap = Object.fromEntries(
183
- (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
184
- )
185
- if (jsxExports) {
186
- for (const exportName of Object.keys(jsxExports)) {
187
- const sourceData = sourceMap[exportName] || {}
188
- const x = sourceData.position?.x ?? 0
189
- const y = sourceData.position?.y ?? 0
190
- const fallback = WIDGET_FALLBACK_SIZES['component']
191
- const width = sourceData.width ?? fallback.width
192
- const height = sourceData.height ?? fallback.height
193
- minX = Math.min(minX, x)
194
- minY = Math.min(minY, y)
195
- maxX = Math.max(maxX, x + width)
196
- maxY = Math.max(maxY, y + height)
197
- hasItems = true
198
- }
199
- }
200
-
201
- return hasItems ? { minX, minY, maxX, maxY } : null
202
- }
203
-
204
102
  /** Renders a single JSON-defined widget by type lookup. */
205
103
  function WidgetRenderer({ widget, onUpdate, widgetRef }) {
206
104
  const Component = getWidgetComponent(widget.type)
@@ -227,7 +125,6 @@ function ChromeWrappedWidget({
227
125
  onDeselect,
228
126
  onUpdate,
229
127
  onRemove,
230
- onCopy,
231
128
  }) {
232
129
  const widgetRef = useRef(null)
233
130
  const features = getFeatures(widget.type)
@@ -235,10 +132,8 @@ function ChromeWrappedWidget({
235
132
  const handleAction = useCallback((actionId) => {
236
133
  if (actionId === 'delete') {
237
134
  onRemove(widget.id)
238
- } else if (actionId === 'copy') {
239
- onCopy(widget)
240
135
  }
241
- }, [widget, onRemove, onCopy])
136
+ }, [widget.id, onRemove])
242
137
 
243
138
  return (
244
139
  <WidgetChrome
@@ -275,40 +170,19 @@ export default function CanvasPage({ name }) {
275
170
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
276
171
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
277
172
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
278
- const initialViewport = loadViewportState(name)
279
- const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
280
- const zoomRef = useRef(initialViewport?.zoom ?? 100)
173
+ const [zoom, setZoom] = useState(100)
174
+ const zoomRef = useRef(100)
281
175
  const scrollRef = useRef(null)
282
- const pendingScrollRestore = useRef(initialViewport)
283
176
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
284
177
  const titleInputRef = useRef(null)
285
178
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
286
179
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
287
- const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
288
- const snapGridSize = canvas?.gridSize || 40
289
-
290
- // Undo/redo history — tracks both widgets and sources as a combined snapshot
291
- const undoRedo = useUndoRedo()
292
- const stateRef = useRef({ widgets: localWidgets, sources: localSources })
293
- useEffect(() => {
294
- stateRef.current = { widgets: localWidgets, sources: localSources }
295
- }, [localWidgets, localSources])
296
-
297
- // Serialized write queue — ensures JSONL events land in the right order
298
- const writeQueueRef = useRef(Promise.resolve())
299
- function queueWrite(fn) {
300
- writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
301
- console.error('[canvas] Write queue error:', err)
302
- )
303
- return writeQueueRef.current
304
- }
305
180
 
306
181
  if (canvas !== trackedCanvas) {
307
182
  setTrackedCanvas(canvas)
308
183
  setLocalWidgets(canvas?.widgets ?? null)
309
184
  setLocalSources(canvas?.sources ?? [])
310
185
  setCanvasTitle(canvas?.title || name)
311
- undoRedo.reset()
312
186
  }
313
187
 
314
188
  // Debounced save to server
@@ -342,59 +216,22 @@ export default function CanvasPage({ name }) {
342
216
  }, [])
343
217
 
344
218
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
345
- undoRedo.snapshot(stateRef.current, 'edit', widgetId)
346
- // Snap width/height to grid when snap is enabled
347
- const snapped = { ...updates }
348
- if (snapEnabled && snapGridSize) {
349
- if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
350
- if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
351
- }
352
219
  setLocalWidgets((prev) => {
353
220
  if (!prev) return prev
354
221
  const next = prev.map((w) =>
355
- w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
222
+ w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
356
223
  )
357
224
  debouncedSave(name, next)
358
225
  return next
359
226
  })
360
- }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
227
+ }, [name, debouncedSave])
361
228
 
362
229
  const handleWidgetRemove = useCallback((widgetId) => {
363
- undoRedo.snapshot(stateRef.current, 'remove', widgetId)
364
230
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
365
- queueWrite(() =>
366
- removeWidgetApi(name, widgetId).catch((err) =>
367
- console.error('[canvas] Failed to remove widget:', err)
368
- )
369
- )
370
- }, [name, undoRedo])
371
-
372
- const handleWidgetCopy = useCallback(async (widget) => {
373
- // Find the next free offset — check how many copies already exist at +n*40
374
- const baseX = widget.position?.x ?? 0
375
- const baseY = widget.position?.y ?? 0
376
- const occupied = new Set(
377
- (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
231
+ removeWidgetApi(name, widgetId).catch((err) =>
232
+ console.error('[canvas] Failed to remove widget:', err)
378
233
  )
379
- let n = 1
380
- while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
381
- n++
382
- }
383
- const position = { x: baseX + n * 40, y: baseY + n * 40 }
384
- try {
385
- undoRedo.snapshot(stateRef.current, 'add')
386
- const result = await addWidgetApi(name, {
387
- type: widget.type,
388
- props: { ...widget.props },
389
- position,
390
- })
391
- if (result.success && result.widget) {
392
- setLocalWidgets((prev) => [...(prev || []), result.widget])
393
- }
394
- } catch (err) {
395
- console.error('[canvas] Failed to copy widget:', err)
396
- }
397
- }, [name, localWidgets, undoRedo])
234
+ }, [name])
398
235
 
399
236
  const debouncedSourceSave = useRef(
400
237
  debounce((canvasName, sources) => {
@@ -405,7 +242,6 @@ export default function CanvasPage({ name }) {
405
242
  ).current
406
243
 
407
244
  const handleSourceUpdate = useCallback((exportName, updates) => {
408
- undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
409
245
  setLocalSources((prev) => {
410
246
  const current = Array.isArray(prev) ? prev : []
411
247
  const next = current.some((s) => s?.export === exportName)
@@ -414,149 +250,43 @@ export default function CanvasPage({ name }) {
414
250
  debouncedSourceSave(name, next)
415
251
  return next
416
252
  })
417
- }, [name, debouncedSourceSave, undoRedo])
253
+ }, [name, debouncedSourceSave])
418
254
 
419
255
  const handleItemDragEnd = useCallback((dragId, position) => {
420
256
  if (!dragId || !position) return
421
- const raw = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
422
- const rounded = snapPosition(raw, snapGridSize, snapEnabled)
257
+ const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
423
258
 
424
259
  if (dragId.startsWith('jsx-')) {
425
- undoRedo.snapshot(stateRef.current, 'move', dragId)
426
260
  const sourceExport = dragId.replace(/^jsx-/, '')
427
261
  setLocalSources((prev) => {
428
262
  const current = Array.isArray(prev) ? prev : []
429
263
  const next = current.some((s) => s?.export === sourceExport)
430
264
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
431
265
  : [...current, { export: sourceExport, position: rounded }]
432
- queueWrite(() =>
433
- updateCanvas(name, { sources: next }).catch((err) =>
434
- console.error('[canvas] Failed to save source position:', err)
435
- )
266
+ updateCanvas(name, { sources: next }).catch((err) =>
267
+ console.error('[canvas] Failed to save source position:', err)
436
268
  )
437
269
  return next
438
270
  })
439
271
  return
440
272
  }
441
273
 
442
- undoRedo.snapshot(stateRef.current, 'move', dragId)
443
274
  setLocalWidgets((prev) => {
444
275
  if (!prev) return prev
445
276
  const next = prev.map((w) =>
446
277
  w.id === dragId ? { ...w, position: rounded } : w
447
278
  )
448
- queueWrite(() =>
449
- updateCanvas(name, { widgets: next }).catch((err) =>
450
- console.error('[canvas] Failed to save widget position:', err)
451
- )
279
+ updateCanvas(name, { widgets: next }).catch((err) =>
280
+ console.error('[canvas] Failed to save widget position:', err)
452
281
  )
453
282
  return next
454
283
  })
455
- }, [name, undoRedo, snapEnabled, snapGridSize])
284
+ }, [name])
456
285
 
457
286
  useEffect(() => {
458
287
  zoomRef.current = zoom
459
288
  }, [zoom])
460
289
 
461
- // Restore scroll position from localStorage after first render
462
- useEffect(() => {
463
- const el = scrollRef.current
464
- const saved = pendingScrollRestore.current
465
- if (el && saved) {
466
- if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
467
- if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
468
- pendingScrollRestore.current = null
469
- }
470
- }, [name, loading])
471
-
472
- // Center on a specific widget if `?widget=<id>` is in the URL
473
- useEffect(() => {
474
- const params = new URLSearchParams(window.location.search)
475
- const targetId = params.get('widget')
476
- if (!targetId || loading) return
477
-
478
- const el = scrollRef.current
479
- if (!el) return
480
-
481
- let x, y, w, h
482
-
483
- // Check JSON widgets first
484
- const widgets = localWidgets ?? []
485
- const widget = widgets.find((wgt) => wgt.id === targetId)
486
- if (widget) {
487
- const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
488
- x = widget.position?.x ?? 0
489
- y = widget.position?.y ?? 0
490
- w = widget.props?.width ?? fallback.width
491
- h = widget.props?.height ?? fallback.height
492
- }
493
-
494
- // Check JSX sources (jsx-ExportName)
495
- if (!widget && targetId.startsWith('jsx-')) {
496
- const exportName = targetId.slice(4)
497
- const sourceMap = Object.fromEntries(
498
- (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
499
- )
500
- const sourceData = sourceMap[exportName]
501
- if (sourceData || (jsxExports && exportName in jsxExports)) {
502
- const fallback = WIDGET_FALLBACK_SIZES['component']
503
- x = sourceData?.position?.x ?? 0
504
- y = sourceData?.position?.y ?? 0
505
- w = sourceData?.width ?? fallback.width
506
- h = sourceData?.height ?? fallback.height
507
- }
508
- }
509
-
510
- if (x == null) return
511
-
512
- const scale = zoomRef.current / 100
513
- el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
514
- el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
515
-
516
- // Clean the URL param without triggering navigation
517
- const url = new URL(window.location.href)
518
- url.searchParams.delete('widget')
519
- window.history.replaceState({}, '', url.toString())
520
- }, [loading, localWidgets, localSources, jsxExports])
521
-
522
- // Persist viewport state (zoom + scroll) to localStorage on changes
523
- useEffect(() => {
524
- const el = scrollRef.current
525
- saveViewportState(name, {
526
- zoom,
527
- scrollLeft: el?.scrollLeft ?? 0,
528
- scrollTop: el?.scrollTop ?? 0,
529
- })
530
- }, [name, zoom])
531
-
532
- useEffect(() => {
533
- const el = scrollRef.current
534
- if (!el) return
535
- function handleScroll() {
536
- saveViewportState(name, {
537
- zoom: zoomRef.current,
538
- scrollLeft: el.scrollLeft,
539
- scrollTop: el.scrollTop,
540
- })
541
- }
542
- el.addEventListener('scroll', handleScroll, { passive: true })
543
-
544
- // Flush viewport state on page unload so a refresh never misses it
545
- function handleBeforeUnload() {
546
- saveViewportState(name, {
547
- zoom: zoomRef.current,
548
- scrollLeft: el.scrollLeft,
549
- scrollTop: el.scrollTop,
550
- })
551
- }
552
- window.addEventListener('beforeunload', handleBeforeUnload)
553
-
554
- return () => {
555
- el.removeEventListener('scroll', handleScroll)
556
- window.removeEventListener('beforeunload', handleBeforeUnload)
557
- }
558
- }, [name, loading])
559
-
560
290
  /**
561
291
  * Zoom to a new level, anchoring on an optional client-space point.
562
292
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -615,26 +345,6 @@ export default function CanvasPage({ name }) {
615
345
  }
616
346
  }, [name])
617
347
 
618
- // Tell the Vite dev server to suppress full-reloads while this canvas is active.
619
- // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
620
- // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
621
- useEffect(() => {
622
- if (!import.meta.hot) return
623
- const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
624
- if (hmrEnabled) return
625
-
626
- const msg = { active: true, hmrEnabled: false }
627
- import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
628
- const interval = setInterval(() => {
629
- import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
630
- }, 3000)
631
-
632
- return () => {
633
- clearInterval(interval)
634
- import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
635
- }
636
- }, [name])
637
-
638
348
  // Add a widget by type — used by CanvasControls and CoreUIBar event
639
349
  const addWidget = useCallback(async (type) => {
640
350
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -647,13 +357,12 @@ export default function CanvasPage({ name }) {
647
357
  position: pos,
648
358
  })
649
359
  if (result.success && result.widget) {
650
- undoRedo.snapshot(stateRef.current, 'add')
651
360
  setLocalWidgets((prev) => [...(prev || []), result.widget])
652
361
  }
653
362
  } catch (err) {
654
363
  console.error('[canvas] Failed to add widget:', err)
655
364
  }
656
- }, [name, undoRedo])
365
+ }, [name])
657
366
 
658
367
  // Listen for CoreUIBar add-widget events
659
368
  useEffect(() => {
@@ -676,60 +385,6 @@ export default function CanvasPage({ name }) {
676
385
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
677
386
  }, [])
678
387
 
679
- // Listen for snap-to-grid toggle from CoreUIBar
680
- useEffect(() => {
681
- function handleSnapToggle() {
682
- setSnapEnabled((prev) => {
683
- const next = !prev
684
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
685
- console.error('[canvas] Failed to persist snap setting:', err)
686
- )
687
- return next
688
- })
689
- }
690
- document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
691
- return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
692
- }, [name])
693
-
694
- // Broadcast snap state to Svelte toolbar
695
- useEffect(() => {
696
- document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
697
- detail: { snapEnabled }
698
- }))
699
- }, [snapEnabled])
700
-
701
- // Listen for zoom-to-fit from CoreUIBar
702
- useEffect(() => {
703
- function handleZoomToFit() {
704
- const el = scrollRef.current
705
- if (!el) return
706
-
707
- const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
708
- if (!bounds) return
709
-
710
- const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
711
- const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
712
-
713
- const viewW = el.clientWidth
714
- const viewH = el.clientHeight
715
-
716
- // Find the zoom level that fits the bounding box in the viewport
717
- const fitScale = Math.min(viewW / boxW, viewH / boxH)
718
- const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
719
- const newScale = fitZoom / 100
720
-
721
- // Apply zoom synchronously so DOM updates before we scroll
722
- zoomRef.current = fitZoom
723
- flushSync(() => setZoom(fitZoom))
724
-
725
- // Scroll so the bounding box top-left (with padding) is at viewport top-left
726
- el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
727
- el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
728
- }
729
- document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
730
- return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
731
- }, [localWidgets, localSources, jsxExports])
732
-
733
388
  // Canvas background should follow toolbar theme target.
734
389
  useEffect(() => {
735
390
  function readMode() {
@@ -765,10 +420,6 @@ export default function CanvasPage({ name }) {
765
420
  if (!selectedWidgetId) return
766
421
  const tag = e.target.tagName
767
422
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
768
- if (e.key === 'Escape') {
769
- e.preventDefault()
770
- setSelectedWidgetId(null)
771
- }
772
423
  if (e.key === 'Delete' || e.key === 'Backspace') {
773
424
  e.preventDefault()
774
425
  handleWidgetRemove(selectedWidgetId)
@@ -779,8 +430,7 @@ export default function CanvasPage({ name }) {
779
430
  return () => document.removeEventListener('keydown', handleKeyDown)
780
431
  }, [selectedWidgetId, handleWidgetRemove])
781
432
 
782
- // Paste handler — images become image widgets, same-origin URLs become prototypes,
783
- // other URLs become link previews, text becomes markdown
433
+ // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
784
434
  useEffect(() => {
785
435
  const origin = window.location.origin
786
436
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -811,82 +461,10 @@ export default function CanvasPage({ name }) {
811
461
  return pathname
812
462
  }
813
463
 
814
- function blobToDataUrl(blob) {
815
- return new Promise((resolve, reject) => {
816
- const reader = new FileReader()
817
- reader.onload = () => resolve(reader.result)
818
- reader.onerror = reject
819
- reader.readAsDataURL(blob)
820
- })
821
- }
822
-
823
- function getImageDimensions(dataUrl) {
824
- return new Promise((resolve) => {
825
- const img = new Image()
826
- img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
827
- img.onerror = () => resolve({ width: 400, height: 300 })
828
- img.src = dataUrl
829
- })
830
- }
831
-
832
- async function handleImagePaste(e) {
833
- const items = e.clipboardData?.items
834
- if (!items) return false
835
-
836
- for (const item of items) {
837
- if (!item.type.startsWith('image/')) continue
838
-
839
- const blob = item.getAsFile()
840
- if (!blob) continue
841
-
842
- e.preventDefault()
843
-
844
- try {
845
- const dataUrl = await blobToDataUrl(blob)
846
- const { width: natW, height: natH } = await getImageDimensions(dataUrl)
847
-
848
- // Display at 2x retina: halve natural dimensions, then cap at 600px
849
- const maxWidth = 600
850
- let displayW = Math.round(natW / 2)
851
- let displayH = Math.round(natH / 2)
852
- if (displayW > maxWidth) {
853
- displayH = Math.round(displayH * (maxWidth / displayW))
854
- displayW = maxWidth
855
- }
856
-
857
- const uploadResult = await uploadImage(dataUrl, name)
858
- if (!uploadResult.success) {
859
- console.error('[canvas] Image upload failed:', uploadResult.error)
860
- return true
861
- }
862
-
863
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
864
- const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
865
- const result = await addWidgetApi(name, {
866
- type: 'image',
867
- props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
868
- position: pos,
869
- })
870
- if (result.success && result.widget) {
871
- undoRedo.snapshot(stateRef.current, 'add')
872
- setLocalWidgets((prev) => [...(prev || []), result.widget])
873
- }
874
- } catch (err) {
875
- console.error('[canvas] Failed to paste image:', err)
876
- }
877
- return true
878
- }
879
- return false
880
- }
881
-
882
464
  async function handlePaste(e) {
883
465
  const tag = e.target.tagName
884
466
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
885
467
 
886
- // Image paste takes priority
887
- const handledImage = await handleImagePaste(e)
888
- if (handledImage) return
889
-
890
468
  const text = e.clipboardData?.getData('text/plain')?.trim()
891
469
  if (!text) return
892
470
 
@@ -895,14 +473,11 @@ export default function CanvasPage({ name }) {
895
473
  let type, props
896
474
  try {
897
475
  const parsed = new URL(text)
898
- if (isFigmaUrl(text)) {
899
- type = 'figma-embed'
900
- props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
901
- } else if (isSameOriginPrototype(text)) {
476
+ if (isSameOriginPrototype(text)) {
902
477
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
903
478
  const src = extractPrototypeSrc(pathPortion)
904
479
  type = 'prototype'
905
- props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
480
+ props = { src: src || '/', label: '', width: 800, height: 600 }
906
481
  } else {
907
482
  type = 'link-preview'
908
483
  props = { url: text, title: '' }
@@ -921,7 +496,6 @@ export default function CanvasPage({ name }) {
921
496
  position: pos,
922
497
  })
923
498
  if (result.success && result.widget) {
924
- undoRedo.snapshot(stateRef.current, 'add')
925
499
  setLocalWidgets((prev) => [...(prev || []), result.widget])
926
500
  }
927
501
  } catch (err) {
@@ -930,75 +504,7 @@ export default function CanvasPage({ name }) {
930
504
  }
931
505
  document.addEventListener('paste', handlePaste)
932
506
  return () => document.removeEventListener('paste', handlePaste)
933
- }, [name, undoRedo])
934
-
935
- // --- Undo / Redo ---
936
- const handleUndo = useCallback(() => {
937
- const previous = undoRedo.undo(stateRef.current)
938
- if (!previous) return
939
- debouncedSave.cancel()
940
- debouncedSourceSave.cancel()
941
- setLocalWidgets(previous.widgets)
942
- setLocalSources(previous.sources)
943
- queueWrite(() =>
944
- updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
945
- console.error('[canvas] Failed to persist undo:', err)
946
- )
947
- )
948
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
949
-
950
- const handleRedo = useCallback(() => {
951
- const next = undoRedo.redo(stateRef.current)
952
- if (!next) return
953
- debouncedSave.cancel()
954
- debouncedSourceSave.cancel()
955
- setLocalWidgets(next.widgets)
956
- setLocalSources(next.sources)
957
- queueWrite(() =>
958
- updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
959
- console.error('[canvas] Failed to persist redo:', err)
960
- )
961
- )
962
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
963
-
964
- // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
965
- useEffect(() => {
966
- if (!import.meta.hot) return
967
- function handleKeyDown(e) {
968
- const tag = e.target.tagName
969
- if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
970
- const mod = e.metaKey || e.ctrlKey
971
- if (mod && e.key === 'z' && !e.shiftKey) {
972
- e.preventDefault()
973
- handleUndo()
974
- }
975
- if (mod && e.key === 'z' && e.shiftKey) {
976
- e.preventDefault()
977
- handleRedo()
978
- }
979
- }
980
- document.addEventListener('keydown', handleKeyDown)
981
- return () => document.removeEventListener('keydown', handleKeyDown)
982
- }, [handleUndo, handleRedo])
983
-
984
- // Listen for undo/redo from CoreUIBar (Svelte toolbar)
985
- useEffect(() => {
986
- function handleUndoEvent() { handleUndo() }
987
- function handleRedoEvent() { handleRedo() }
988
- document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
989
- document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
990
- return () => {
991
- document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
992
- document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
993
- }
994
- }, [handleUndo, handleRedo])
995
-
996
- // Broadcast undo/redo availability to Svelte toolbar
997
- useEffect(() => {
998
- document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
999
- detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
1000
- }))
1001
- }, [undoRedo.canUndo, undoRedo.canRedo])
507
+ }, [name])
1002
508
 
1003
509
  // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
1004
510
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
@@ -1136,7 +642,6 @@ export default function CanvasPage({ name }) {
1136
642
  }}
1137
643
  >
1138
644
  <WidgetChrome
1139
- widgetId={`jsx-${exportName}`}
1140
645
  features={componentFeatures}
1141
646
  selected={selectedWidgetId === `jsx-${exportName}`}
1142
647
  onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
@@ -1176,7 +681,6 @@ export default function CanvasPage({ name }) {
1176
681
  onSelect={() => setSelectedWidgetId(widget.id)}
1177
682
  onDeselect={() => setSelectedWidgetId(null)}
1178
683
  onUpdate={handleWidgetUpdate}
1179
- onCopy={handleWidgetCopy}
1180
684
  onRemove={(id) => {
1181
685
  handleWidgetRemove(id)
1182
686
  setSelectedWidgetId(null)