@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.1

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,9 +8,11 @@ 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'
11
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
12
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
13
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
14
+ import useUndoRedo from './useUndoRedo.js'
15
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
14
16
  import styles from './CanvasPage.module.css'
15
17
 
16
18
  const ZOOM_MIN = 25
@@ -47,13 +49,40 @@ function resolveCanvasThemeFromStorage() {
47
49
 
48
50
  /**
49
51
  * Debounce helper — returns a function that delays invocation.
52
+ * Exposes `.cancel()` to abort pending calls (used by undo/redo).
50
53
  */
51
54
  function debounce(fn, ms) {
52
55
  let timer
53
- return (...args) => {
56
+ const debounced = (...args) => {
54
57
  clearTimeout(timer)
55
58
  timer = setTimeout(() => fn(...args), ms)
56
59
  }
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 */ }
57
86
  }
58
87
 
59
88
  /**
@@ -74,11 +103,13 @@ function getViewportCenter(scrollEl, scale) {
74
103
 
75
104
  /** Fallback sizes for widget types without explicit width/height defaults. */
76
105
  const WIDGET_FALLBACK_SIZES = {
77
- 'sticky-note': { width: 180, height: 60 },
78
- 'markdown': { width: 360, height: 200 },
106
+ 'sticky-note': { width: 270, height: 170 },
107
+ 'markdown': { width: 530, height: 240 },
79
108
  'prototype': { width: 800, height: 600 },
80
109
  'link-preview': { width: 320, height: 120 },
110
+ 'figma-embed': { width: 800, height: 450 },
81
111
  'component': { width: 200, height: 150 },
112
+ 'image': { width: 400, height: 300 },
82
113
  }
83
114
 
84
115
  /**
@@ -99,6 +130,57 @@ function roundPosition(value) {
99
130
  return Math.round(value)
100
131
  }
101
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
+
102
184
  /** Renders a single JSON-defined widget by type lookup. */
103
185
  function WidgetRenderer({ widget, onUpdate, widgetRef }) {
104
186
  const Component = getWidgetComponent(widget.type)
@@ -125,6 +207,7 @@ function ChromeWrappedWidget({
125
207
  onDeselect,
126
208
  onUpdate,
127
209
  onRemove,
210
+ onCopy,
128
211
  }) {
129
212
  const widgetRef = useRef(null)
130
213
  const features = getFeatures(widget.type)
@@ -132,8 +215,10 @@ function ChromeWrappedWidget({
132
215
  const handleAction = useCallback((actionId) => {
133
216
  if (actionId === 'delete') {
134
217
  onRemove(widget.id)
218
+ } else if (actionId === 'copy') {
219
+ onCopy(widget)
135
220
  }
136
- }, [widget.id, onRemove])
221
+ }, [widget, onRemove, onCopy])
137
222
 
138
223
  return (
139
224
  <WidgetChrome
@@ -170,19 +255,38 @@ export default function CanvasPage({ name }) {
170
255
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
171
256
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
172
257
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
173
- const [zoom, setZoom] = useState(100)
174
- const zoomRef = useRef(100)
258
+ const initialViewport = loadViewportState(name)
259
+ const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
260
+ const zoomRef = useRef(initialViewport?.zoom ?? 100)
175
261
  const scrollRef = useRef(null)
262
+ const pendingScrollRestore = useRef(initialViewport)
176
263
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
177
264
  const titleInputRef = useRef(null)
178
265
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
179
266
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
180
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
+
181
284
  if (canvas !== trackedCanvas) {
182
285
  setTrackedCanvas(canvas)
183
286
  setLocalWidgets(canvas?.widgets ?? null)
184
287
  setLocalSources(canvas?.sources ?? [])
185
288
  setCanvasTitle(canvas?.title || name)
289
+ undoRedo.reset()
186
290
  }
187
291
 
188
292
  // Debounced save to server
@@ -216,6 +320,7 @@ export default function CanvasPage({ name }) {
216
320
  }, [])
217
321
 
218
322
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
323
+ undoRedo.snapshot(stateRef.current, 'edit', widgetId)
219
324
  setLocalWidgets((prev) => {
220
325
  if (!prev) return prev
221
326
  const next = prev.map((w) =>
@@ -224,14 +329,44 @@ export default function CanvasPage({ name }) {
224
329
  debouncedSave(name, next)
225
330
  return next
226
331
  })
227
- }, [name, debouncedSave])
332
+ }, [name, debouncedSave, undoRedo])
228
333
 
229
334
  const handleWidgetRemove = useCallback((widgetId) => {
335
+ undoRedo.snapshot(stateRef.current, 'remove', widgetId)
230
336
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
231
- removeWidgetApi(name, widgetId).catch((err) =>
232
- 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
+ )
233
341
  )
234
- }, [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])
235
370
 
236
371
  const debouncedSourceSave = useRef(
237
372
  debounce((canvasName, sources) => {
@@ -242,6 +377,7 @@ export default function CanvasPage({ name }) {
242
377
  ).current
243
378
 
244
379
  const handleSourceUpdate = useCallback((exportName, updates) => {
380
+ undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
245
381
  setLocalSources((prev) => {
246
382
  const current = Array.isArray(prev) ? prev : []
247
383
  const next = current.some((s) => s?.export === exportName)
@@ -250,43 +386,98 @@ export default function CanvasPage({ name }) {
250
386
  debouncedSourceSave(name, next)
251
387
  return next
252
388
  })
253
- }, [name, debouncedSourceSave])
389
+ }, [name, debouncedSourceSave, undoRedo])
254
390
 
255
391
  const handleItemDragEnd = useCallback((dragId, position) => {
256
392
  if (!dragId || !position) return
257
393
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
258
394
 
259
395
  if (dragId.startsWith('jsx-')) {
396
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
260
397
  const sourceExport = dragId.replace(/^jsx-/, '')
261
398
  setLocalSources((prev) => {
262
399
  const current = Array.isArray(prev) ? prev : []
263
400
  const next = current.some((s) => s?.export === sourceExport)
264
401
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
265
402
  : [...current, { export: sourceExport, position: rounded }]
266
- updateCanvas(name, { sources: next }).catch((err) =>
267
- 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
+ )
268
407
  )
269
408
  return next
270
409
  })
271
410
  return
272
411
  }
273
412
 
413
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
274
414
  setLocalWidgets((prev) => {
275
415
  if (!prev) return prev
276
416
  const next = prev.map((w) =>
277
417
  w.id === dragId ? { ...w, position: rounded } : w
278
418
  )
279
- updateCanvas(name, { widgets: next }).catch((err) =>
280
- 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
+ )
281
423
  )
282
424
  return next
283
425
  })
284
- }, [name])
426
+ }, [name, undoRedo])
285
427
 
286
428
  useEffect(() => {
287
429
  zoomRef.current = zoom
288
430
  }, [zoom])
289
431
 
432
+ // Restore scroll position from localStorage after first render
433
+ useEffect(() => {
434
+ const el = scrollRef.current
435
+ const saved = pendingScrollRestore.current
436
+ if (el && saved) {
437
+ if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
438
+ if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
439
+ pendingScrollRestore.current = null
440
+ }
441
+ }, [name, loading])
442
+
443
+ // Persist viewport state (zoom + scroll) to localStorage on changes
444
+ useEffect(() => {
445
+ const el = scrollRef.current
446
+ saveViewportState(name, {
447
+ zoom,
448
+ scrollLeft: el?.scrollLeft ?? 0,
449
+ scrollTop: el?.scrollTop ?? 0,
450
+ })
451
+ }, [name, zoom])
452
+
453
+ useEffect(() => {
454
+ const el = scrollRef.current
455
+ if (!el) return
456
+ function handleScroll() {
457
+ saveViewportState(name, {
458
+ zoom: zoomRef.current,
459
+ scrollLeft: el.scrollLeft,
460
+ scrollTop: el.scrollTop,
461
+ })
462
+ }
463
+ el.addEventListener('scroll', handleScroll, { passive: true })
464
+
465
+ // Flush viewport state on page unload so a refresh never misses it
466
+ function handleBeforeUnload() {
467
+ saveViewportState(name, {
468
+ zoom: zoomRef.current,
469
+ scrollLeft: el.scrollLeft,
470
+ scrollTop: el.scrollTop,
471
+ })
472
+ }
473
+ window.addEventListener('beforeunload', handleBeforeUnload)
474
+
475
+ return () => {
476
+ el.removeEventListener('scroll', handleScroll)
477
+ window.removeEventListener('beforeunload', handleBeforeUnload)
478
+ }
479
+ }, [name, loading])
480
+
290
481
  /**
291
482
  * Zoom to a new level, anchoring on an optional client-space point.
292
483
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -345,6 +536,26 @@ export default function CanvasPage({ name }) {
345
536
  }
346
537
  }, [name])
347
538
 
539
+ // Tell the Vite dev server to suppress full-reloads while this canvas is active.
540
+ // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
541
+ // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
542
+ useEffect(() => {
543
+ if (!import.meta.hot) return
544
+ const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
545
+ if (hmrEnabled) return
546
+
547
+ const msg = { active: true, hmrEnabled: false }
548
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
549
+ const interval = setInterval(() => {
550
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
551
+ }, 3000)
552
+
553
+ return () => {
554
+ clearInterval(interval)
555
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
556
+ }
557
+ }, [name])
558
+
348
559
  // Add a widget by type — used by CanvasControls and CoreUIBar event
349
560
  const addWidget = useCallback(async (type) => {
350
561
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -357,12 +568,13 @@ export default function CanvasPage({ name }) {
357
568
  position: pos,
358
569
  })
359
570
  if (result.success && result.widget) {
571
+ undoRedo.snapshot(stateRef.current, 'add')
360
572
  setLocalWidgets((prev) => [...(prev || []), result.widget])
361
573
  }
362
574
  } catch (err) {
363
575
  console.error('[canvas] Failed to add widget:', err)
364
576
  }
365
- }, [name])
577
+ }, [name, undoRedo])
366
578
 
367
579
  // Listen for CoreUIBar add-widget events
368
580
  useEffect(() => {
@@ -385,6 +597,38 @@ export default function CanvasPage({ name }) {
385
597
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
386
598
  }, [])
387
599
 
600
+ // Listen for zoom-to-fit from CoreUIBar
601
+ useEffect(() => {
602
+ function handleZoomToFit() {
603
+ const el = scrollRef.current
604
+ if (!el) return
605
+
606
+ const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
607
+ if (!bounds) return
608
+
609
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
610
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
611
+
612
+ const viewW = el.clientWidth
613
+ const viewH = el.clientHeight
614
+
615
+ // Find the zoom level that fits the bounding box in the viewport
616
+ const fitScale = Math.min(viewW / boxW, viewH / boxH)
617
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
618
+ const newScale = fitZoom / 100
619
+
620
+ // Apply zoom synchronously so DOM updates before we scroll
621
+ zoomRef.current = fitZoom
622
+ flushSync(() => setZoom(fitZoom))
623
+
624
+ // Scroll so the bounding box top-left (with padding) is at viewport top-left
625
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
626
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
627
+ }
628
+ document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
629
+ return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
630
+ }, [localWidgets, localSources, jsxExports])
631
+
388
632
  // Canvas background should follow toolbar theme target.
389
633
  useEffect(() => {
390
634
  function readMode() {
@@ -420,6 +664,10 @@ export default function CanvasPage({ name }) {
420
664
  if (!selectedWidgetId) return
421
665
  const tag = e.target.tagName
422
666
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
667
+ if (e.key === 'Escape') {
668
+ e.preventDefault()
669
+ setSelectedWidgetId(null)
670
+ }
423
671
  if (e.key === 'Delete' || e.key === 'Backspace') {
424
672
  e.preventDefault()
425
673
  handleWidgetRemove(selectedWidgetId)
@@ -430,7 +678,8 @@ export default function CanvasPage({ name }) {
430
678
  return () => document.removeEventListener('keydown', handleKeyDown)
431
679
  }, [selectedWidgetId, handleWidgetRemove])
432
680
 
433
- // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
681
+ // Paste handler — images become image widgets, same-origin URLs become prototypes,
682
+ // other URLs become link previews, text becomes markdown
434
683
  useEffect(() => {
435
684
  const origin = window.location.origin
436
685
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -461,10 +710,82 @@ export default function CanvasPage({ name }) {
461
710
  return pathname
462
711
  }
463
712
 
713
+ function blobToDataUrl(blob) {
714
+ return new Promise((resolve, reject) => {
715
+ const reader = new FileReader()
716
+ reader.onload = () => resolve(reader.result)
717
+ reader.onerror = reject
718
+ reader.readAsDataURL(blob)
719
+ })
720
+ }
721
+
722
+ function getImageDimensions(dataUrl) {
723
+ return new Promise((resolve) => {
724
+ const img = new Image()
725
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
726
+ img.onerror = () => resolve({ width: 400, height: 300 })
727
+ img.src = dataUrl
728
+ })
729
+ }
730
+
731
+ async function handleImagePaste(e) {
732
+ const items = e.clipboardData?.items
733
+ if (!items) return false
734
+
735
+ for (const item of items) {
736
+ if (!item.type.startsWith('image/')) continue
737
+
738
+ const blob = item.getAsFile()
739
+ if (!blob) continue
740
+
741
+ e.preventDefault()
742
+
743
+ try {
744
+ const dataUrl = await blobToDataUrl(blob)
745
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
746
+
747
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
748
+ const maxWidth = 600
749
+ let displayW = Math.round(natW / 2)
750
+ let displayH = Math.round(natH / 2)
751
+ if (displayW > maxWidth) {
752
+ displayH = Math.round(displayH * (maxWidth / displayW))
753
+ displayW = maxWidth
754
+ }
755
+
756
+ const uploadResult = await uploadImage(dataUrl, name)
757
+ if (!uploadResult.success) {
758
+ console.error('[canvas] Image upload failed:', uploadResult.error)
759
+ return true
760
+ }
761
+
762
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
763
+ const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
764
+ const result = await addWidgetApi(name, {
765
+ type: 'image',
766
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
767
+ position: pos,
768
+ })
769
+ if (result.success && result.widget) {
770
+ undoRedo.snapshot(stateRef.current, 'add')
771
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
772
+ }
773
+ } catch (err) {
774
+ console.error('[canvas] Failed to paste image:', err)
775
+ }
776
+ return true
777
+ }
778
+ return false
779
+ }
780
+
464
781
  async function handlePaste(e) {
465
782
  const tag = e.target.tagName
466
783
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
467
784
 
785
+ // Image paste takes priority
786
+ const handledImage = await handleImagePaste(e)
787
+ if (handledImage) return
788
+
468
789
  const text = e.clipboardData?.getData('text/plain')?.trim()
469
790
  if (!text) return
470
791
 
@@ -473,11 +794,14 @@ export default function CanvasPage({ name }) {
473
794
  let type, props
474
795
  try {
475
796
  const parsed = new URL(text)
476
- if (isSameOriginPrototype(text)) {
797
+ if (isFigmaUrl(text)) {
798
+ type = 'figma-embed'
799
+ props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
800
+ } else if (isSameOriginPrototype(text)) {
477
801
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
478
802
  const src = extractPrototypeSrc(pathPortion)
479
803
  type = 'prototype'
480
- props = { src: src || '/', label: '', width: 800, height: 600 }
804
+ props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
481
805
  } else {
482
806
  type = 'link-preview'
483
807
  props = { url: text, title: '' }
@@ -496,6 +820,7 @@ export default function CanvasPage({ name }) {
496
820
  position: pos,
497
821
  })
498
822
  if (result.success && result.widget) {
823
+ undoRedo.snapshot(stateRef.current, 'add')
499
824
  setLocalWidgets((prev) => [...(prev || []), result.widget])
500
825
  }
501
826
  } catch (err) {
@@ -504,7 +829,75 @@ export default function CanvasPage({ name }) {
504
829
  }
505
830
  document.addEventListener('paste', handlePaste)
506
831
  return () => document.removeEventListener('paste', handlePaste)
507
- }, [name])
832
+ }, [name, undoRedo])
833
+
834
+ // --- Undo / Redo ---
835
+ const handleUndo = useCallback(() => {
836
+ const previous = undoRedo.undo(stateRef.current)
837
+ if (!previous) return
838
+ debouncedSave.cancel()
839
+ debouncedSourceSave.cancel()
840
+ setLocalWidgets(previous.widgets)
841
+ setLocalSources(previous.sources)
842
+ queueWrite(() =>
843
+ updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
844
+ console.error('[canvas] Failed to persist undo:', err)
845
+ )
846
+ )
847
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
848
+
849
+ const handleRedo = useCallback(() => {
850
+ const next = undoRedo.redo(stateRef.current)
851
+ if (!next) return
852
+ debouncedSave.cancel()
853
+ debouncedSourceSave.cancel()
854
+ setLocalWidgets(next.widgets)
855
+ setLocalSources(next.sources)
856
+ queueWrite(() =>
857
+ updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
858
+ console.error('[canvas] Failed to persist redo:', err)
859
+ )
860
+ )
861
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
862
+
863
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
864
+ useEffect(() => {
865
+ if (!import.meta.hot) return
866
+ function handleKeyDown(e) {
867
+ const tag = e.target.tagName
868
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
869
+ const mod = e.metaKey || e.ctrlKey
870
+ if (mod && e.key === 'z' && !e.shiftKey) {
871
+ e.preventDefault()
872
+ handleUndo()
873
+ }
874
+ if (mod && e.key === 'z' && e.shiftKey) {
875
+ e.preventDefault()
876
+ handleRedo()
877
+ }
878
+ }
879
+ document.addEventListener('keydown', handleKeyDown)
880
+ return () => document.removeEventListener('keydown', handleKeyDown)
881
+ }, [handleUndo, handleRedo])
882
+
883
+ // Listen for undo/redo from CoreUIBar (Svelte toolbar)
884
+ useEffect(() => {
885
+ function handleUndoEvent() { handleUndo() }
886
+ function handleRedoEvent() { handleRedo() }
887
+ document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
888
+ document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
889
+ return () => {
890
+ document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
891
+ document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
892
+ }
893
+ }, [handleUndo, handleRedo])
894
+
895
+ // Broadcast undo/redo availability to Svelte toolbar
896
+ useEffect(() => {
897
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
898
+ detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
899
+ }))
900
+ }, [undoRedo.canUndo, undoRedo.canRedo])
508
901
 
509
902
  // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
510
903
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
@@ -681,6 +1074,7 @@ export default function CanvasPage({ name }) {
681
1074
  onSelect={() => setSelectedWidgetId(widget.id)}
682
1075
  onDeselect={() => setSelectedWidgetId(null)}
683
1076
  onUpdate={handleWidgetUpdate}
1077
+ onCopy={handleWidgetCopy}
684
1078
  onRemove={(id) => {
685
1079
  handleWidgetRemove(id)
686
1080
  setSelectedWidgetId(null)
@@ -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
+ }