@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20

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 (44) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. package/src/vite/data-plugin.test.js +405 -5
@@ -1,4 +1,4 @@
1
- import { createElement, useCallback, useEffect, useRef, useState } from 'react'
1
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { Canvas } from '@dfosco/tiny-canvas'
4
4
  import '@dfosco/tiny-canvas/style.css'
@@ -8,21 +8,35 @@ 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, isResizable } from './widgets/widgetConfig.js'
11
- import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
11
+ import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
12
+ import { getPasteRules } from '@dfosco/storyboard-core'
12
13
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
14
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
15
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
16
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
17
+ import PageSelector from './PageSelector.jsx'
18
+ import { stories as storyIndex } from 'virtual:storyboard-data-index'
16
19
  import styles from './CanvasPage.module.css'
17
20
 
18
21
  const ZOOM_MIN = 25
19
22
  const ZOOM_MAX = 200
20
23
 
24
+ /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
25
+ const VIEWPORT_TTL_MS = 15 * 60 * 1000
26
+
21
27
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
22
28
 
23
29
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
24
30
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
25
31
 
32
+ // Build a reverse map from story route paths → { storyId, route }
33
+ const storyRouteIndex = new Map()
34
+ for (const [storyId, data] of Object.entries(storyIndex || {})) {
35
+ if (data?._route) {
36
+ storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
37
+ }
38
+ }
39
+
26
40
  function getToolbarColorMode(theme) {
27
41
  return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
28
42
  }
@@ -47,6 +61,36 @@ function resolveCanvasThemeFromStorage() {
47
61
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
48
62
  }
49
63
 
64
+ /**
65
+ * Get the copyable URL for a widget based on its type.
66
+ * Returns the most relevant URL/path for the widget content.
67
+ */
68
+ // eslint-disable-next-line no-unused-vars
69
+ function getWidgetCopyableUrl(widget) {
70
+ const { type, props = {} } = widget
71
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
72
+ switch (type) {
73
+ case 'prototype':
74
+ // Prototype src is a path like "/MyPrototype" - make it a full URL
75
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
76
+ case 'figma-embed':
77
+ return props.url || ''
78
+ case 'link-preview':
79
+ return props.url || ''
80
+ case 'image':
81
+ // Return the served image URL
82
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
83
+ case 'sticky-note':
84
+ // Sticky notes have text content, not a URL
85
+ return props.text || ''
86
+ case 'markdown':
87
+ // Markdown has content, not a URL
88
+ return props.content || ''
89
+ default:
90
+ return ''
91
+ }
92
+ }
93
+
50
94
  /**
51
95
  * Debounce helper — returns a function that delays invocation.
52
96
  * Exposes `.cancel()` to abort pending calls (used by undo/redo).
@@ -71,6 +115,11 @@ function loadViewportState(canvasName) {
71
115
  const raw = localStorage.getItem(getViewportStorageKey(canvasName))
72
116
  if (!raw) return null
73
117
  const state = JSON.parse(raw)
118
+ const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
119
+ if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
120
+ localStorage.removeItem(getViewportStorageKey(canvasName))
121
+ return null
122
+ }
74
123
  return {
75
124
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
76
125
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -81,7 +130,10 @@ function loadViewportState(canvasName) {
81
130
 
82
131
  function saveViewportState(canvasName, state) {
83
132
  try {
84
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
133
+ localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify({
134
+ ...state,
135
+ timestamp: Date.now(),
136
+ }))
85
137
  } catch { /* quota exceeded — non-critical */ }
86
138
  }
87
139
 
@@ -158,7 +210,7 @@ const FIT_PADDING = 48
158
210
  * Compute the axis-aligned bounding box that contains every widget and source.
159
211
  * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
160
212
  */
161
- function computeCanvasBounds(widgets, sources, jsxExports) {
213
+ function computeCanvasBounds(widgets, componentEntries) {
162
214
  let minX = Infinity
163
215
  let minY = Infinity
164
216
  let maxX = -Infinity
@@ -179,24 +231,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
179
231
  hasItems = true
180
232
  }
181
233
 
182
- // JSX sources
183
- const sourceMap = Object.fromEntries(
184
- (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
185
- )
186
- if (jsxExports) {
187
- for (const exportName of Object.keys(jsxExports)) {
188
- const sourceData = sourceMap[exportName] || {}
189
- const x = sourceData.position?.x ?? 0
190
- const y = sourceData.position?.y ?? 0
191
- const fallback = WIDGET_FALLBACK_SIZES['component']
192
- const width = sourceData.width ?? fallback.width
193
- const height = sourceData.height ?? fallback.height
194
- minX = Math.min(minX, x)
195
- minY = Math.min(minY, y)
196
- maxX = Math.max(maxX, x + width)
197
- maxY = Math.max(maxY, y + height)
198
- hasItems = true
199
- }
234
+ // Component widgets (from jsxExports or sources fallback)
235
+ for (const entry of componentEntries) {
236
+ const x = entry.sourceData?.position?.x ?? 0
237
+ const y = entry.sourceData?.position?.y ?? 0
238
+ const fallback = WIDGET_FALLBACK_SIZES['component']
239
+ const width = entry.sourceData?.width ?? fallback.width
240
+ const height = entry.sourceData?.height ?? fallback.height
241
+ minX = Math.min(minX, x)
242
+ minY = Math.min(minY, y)
243
+ maxX = Math.max(maxX, x + width)
244
+ maxY = Math.max(maxY, y + height)
245
+ hasItems = true
200
246
  }
201
247
 
202
248
  return hasItems ? { minX, minY, maxX, maxY } : null
@@ -234,13 +280,16 @@ function ChromeWrappedWidget({
234
280
  readOnly,
235
281
  }) {
236
282
  const widgetRef = useRef(null)
237
- const features = getFeatures(widget.type)
283
+ const features = getFeatures(widget.type, { isLocalDev: !readOnly })
238
284
 
239
285
  const handleAction = useCallback((actionId) => {
240
286
  if (actionId === 'delete') {
241
287
  onRemove?.(widget.id)
242
288
  } else if (actionId === 'copy') {
243
289
  onCopy?.(widget)
290
+ } else if (actionId === 'copy-text') {
291
+ const text = widget.props?.text || widget.props?.content || ''
292
+ navigator.clipboard?.writeText(text).catch(() => {})
244
293
  }
245
294
  }, [widget, onRemove, onCopy])
246
295
 
@@ -274,8 +323,8 @@ function ChromeWrappedWidget({
274
323
  *
275
324
  * @param {{ name: string }} props - Canvas name as indexed by the data plugin
276
325
  */
277
- export default function CanvasPage({ name }) {
278
- const { canvas, jsxExports, loading } = useCanvas(name)
326
+ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null }) {
327
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
279
328
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
280
329
 
281
330
  // Local mutable copy of widgets for instant UI updates
@@ -287,12 +336,45 @@ export default function CanvasPage({ name }) {
287
336
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
288
337
  const scrollRef = useRef(null)
289
338
  const pendingScrollRestore = useRef(initialViewport)
290
- const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
291
- const titleInputRef = useRef(null)
339
+ // Gate viewport persistence until initial positioning is complete.
340
+ // Tracks which canvas name was last initialized — save effects only
341
+ // write when this matches `name`, preventing cross-canvas corruption.
342
+ const viewportInitName = useRef(null)
292
343
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
293
344
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
294
345
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
295
346
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
347
+ // Refs for snap settings (used by drop handler inside effect closure)
348
+ const snapEnabledRef = useRef(snapEnabled)
349
+ const snapGridSizeRef = useRef(snapGridSize)
350
+
351
+ // Centralized list of component export names.
352
+ // When jsxExports is available, use it (discovers new exports not yet in sources).
353
+ // When jsxExports is null (module import failed), fall back to sources so iframes
354
+ // still render — the error is contained inside each iframe.
355
+ const componentEntries = useMemo(() => {
356
+ const sourceMap = Object.fromEntries(
357
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
358
+ )
359
+ if (jsxExports) {
360
+ return Object.keys(jsxExports).map((exportName) => ({
361
+ exportName,
362
+ Component: jsxExports[exportName],
363
+ sourceData: sourceMap[exportName] || {},
364
+ }))
365
+ }
366
+ // Fallback: use sources when module import failed (iframe isolation still works)
367
+ if (jsxError && canvas?._jsxModule) {
368
+ return (localSources || [])
369
+ .filter((s) => s?.export)
370
+ .map((s) => ({
371
+ exportName: s.export,
372
+ Component: null,
373
+ sourceData: s,
374
+ }))
375
+ }
376
+ return []
377
+ }, [jsxExports, jsxError, localSources, canvas?._jsxModule])
296
378
 
297
379
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
298
380
  const undoRedo = useUndoRedo()
@@ -349,13 +431,13 @@ export default function CanvasPage({ name }) {
349
431
  // Flag to suppress the click-based selection reset that fires after a drag
350
432
  const justDraggedRef = useRef(false)
351
433
 
352
- const handleItemDragStart = useCallback((dragId, position) => {
434
+ const handleItemDragStart = useCallback((dragId) => {
353
435
  const ids = selectedIdsRef.current
354
436
  peerArticlesRef.current.clear()
355
437
  if (ids.size <= 1 || !ids.has(dragId)) return
356
438
 
357
439
  // Suppress selection changes for the duration of the drag
358
- justDraggedRef.current = true
440
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
359
441
 
360
442
  // Collect peer article elements for transition on drag end
361
443
  for (const id of ids) {
@@ -394,8 +476,17 @@ export default function CanvasPage({ name }) {
394
476
  setTrackedCanvas(canvas)
395
477
  setLocalWidgets(canvas?.widgets ?? null)
396
478
  setLocalSources(canvas?.sources ?? [])
397
- setCanvasTitle(canvas?.title || name)
479
+ setSnapEnabled(canvas?.snapToGrid ?? false)
480
+ setSnapGridSize(canvas?.gridSize || 40)
398
481
  undoRedo.reset()
482
+ // Block saves until the new canvas's viewport is fully restored.
483
+ viewportInitName.current = null
484
+ const newViewport = loadViewportState(name)
485
+ pendingScrollRestore.current = newViewport
486
+ // Restore zoom from the new canvas's saved state
487
+ const newZoom = newViewport?.zoom ?? 100
488
+ zoomRef.current = newZoom
489
+ setZoom(newZoom)
399
490
  }
400
491
 
401
492
  // Debounced save to server
@@ -407,27 +498,6 @@ export default function CanvasPage({ name }) {
407
498
  }, 2000)
408
499
  ).current
409
500
 
410
- const debouncedTitleSave = useRef(
411
- debounce((canvasName, title) => {
412
- updateCanvas(canvasName, { settings: { title } }).catch((err) =>
413
- console.error('[canvas] Failed to save title:', err)
414
- )
415
- }, 1000)
416
- ).current
417
-
418
- const handleTitleChange = useCallback((e) => {
419
- const newTitle = e.target.value
420
- setCanvasTitle(newTitle)
421
- debouncedTitleSave(name, newTitle)
422
- }, [name, debouncedTitleSave])
423
-
424
- const handleTitleKeyDown = useCallback((e) => {
425
- if (e.key === 'Enter') {
426
- e.target.blur()
427
- }
428
- e.stopPropagation()
429
- }, [])
430
-
431
501
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
432
502
  undoRedo.snapshot(stateRef.current, 'edit', widgetId)
433
503
  // Snap width/height to grid when snap is enabled
@@ -521,7 +591,7 @@ export default function CanvasPage({ name }) {
521
591
  if (ids.size > 1 && ids.has(dragId)) {
522
592
  transitionPeers()
523
593
  // Suppress the click-based selection reset that fires after pointerup
524
- justDraggedRef.current = true
594
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
525
595
  requestAnimationFrame(() => { justDraggedRef.current = false })
526
596
  undoRedo.snapshot(stateRef.current, 'multi-move')
527
597
 
@@ -637,15 +707,38 @@ export default function CanvasPage({ name }) {
637
707
  zoomRef.current = zoom
638
708
  }, [zoom])
639
709
 
640
- // Restore scroll position from localStorage after first render
710
+ // Restore scroll position from localStorage after first render.
711
+ // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
712
+ // all objects so the user sees a useful overview instead of stale coordinates.
641
713
  useEffect(() => {
642
714
  const el = scrollRef.current
715
+ if (!el || loading) return
643
716
  const saved = pendingScrollRestore.current
644
- if (el && saved) {
717
+ if (saved) {
718
+ // Fresh saved viewport — restore exactly
645
719
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
646
720
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
647
721
  pendingScrollRestore.current = null
722
+ } else {
723
+ // No saved state or stale — zoom-to-fit all objects
724
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
725
+ if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
726
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
727
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
728
+ const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
729
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
730
+ const newScale = fitZoom / 100
731
+ zoomRef.current = fitZoom
732
+ flushSync(() => setZoom(fitZoom))
733
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
734
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
735
+ } else {
736
+ el.scrollLeft = 0
737
+ el.scrollTop = 0
738
+ }
648
739
  }
740
+ // Allow save effects for this canvas now that positioning is settled.
741
+ viewportInitName.current = name
649
742
  }, [name, loading])
650
743
 
651
744
  // Center on a specific widget if `?widget=<id>` is in the URL
@@ -673,16 +766,13 @@ export default function CanvasPage({ name }) {
673
766
  // Check JSX sources (jsx-ExportName)
674
767
  if (!widget && targetId.startsWith('jsx-')) {
675
768
  const exportName = targetId.slice(4)
676
- const sourceMap = Object.fromEntries(
677
- (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
678
- )
679
- const sourceData = sourceMap[exportName]
680
- if (sourceData || (jsxExports && exportName in jsxExports)) {
769
+ const entry = componentEntries.find((e) => e.exportName === exportName)
770
+ if (entry) {
681
771
  const fallback = WIDGET_FALLBACK_SIZES['component']
682
- x = sourceData?.position?.x ?? 0
683
- y = sourceData?.position?.y ?? 0
684
- w = sourceData?.width ?? fallback.width
685
- h = sourceData?.height ?? fallback.height
772
+ x = entry.sourceData?.position?.x ?? 0
773
+ y = entry.sourceData?.position?.y ?? 0
774
+ w = entry.sourceData?.width ?? fallback.width
775
+ h = entry.sourceData?.height ?? fallback.height
686
776
  }
687
777
  }
688
778
 
@@ -696,11 +786,18 @@ export default function CanvasPage({ name }) {
696
786
  const url = new URL(window.location.href)
697
787
  url.searchParams.delete('widget')
698
788
  window.history.replaceState({}, '', url.toString())
699
- }, [loading, localWidgets, localSources, jsxExports])
789
+ }, [loading, localWidgets, componentEntries])
700
790
 
701
- // Persist viewport state (zoom + scroll) to localStorage on changes
791
+ // Persist viewport state (zoom only) to localStorage on zoom changes.
792
+ // Scroll position is persisted separately by the debounced scroll handler,
793
+ // cleanup handler, and beforeunload — never here, because imperative zoom
794
+ // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
795
+ // scroll values would be stale at this point.
702
796
  useEffect(() => {
797
+ if (viewportInitName.current !== name) return
703
798
  const el = scrollRef.current
799
+ // Read current scroll so the zoom entry doesn't zero-out position,
800
+ // but the authoritative scroll save comes from the scroll handler.
704
801
  saveViewportState(name, {
705
802
  zoom,
706
803
  scrollLeft: el?.scrollLeft ?? 0,
@@ -711,28 +808,35 @@ export default function CanvasPage({ name }) {
711
808
  useEffect(() => {
712
809
  const el = scrollRef.current
713
810
  if (!el) return
714
- function handleScroll() {
811
+ const saveNow = () => {
812
+ if (viewportInitName.current !== name) return
715
813
  saveViewportState(name, {
716
814
  zoom: zoomRef.current,
717
815
  scrollLeft: el.scrollLeft,
718
816
  scrollTop: el.scrollTop,
719
817
  })
720
818
  }
819
+ const debouncedScrollSave = debounce(saveNow, 150)
820
+ function handleScroll() {
821
+ if (viewportInitName.current !== name) return
822
+ debouncedScrollSave()
823
+ }
721
824
  el.addEventListener('scroll', handleScroll, { passive: true })
722
825
 
723
826
  // Flush viewport state on page unload so a refresh never misses it
724
827
  function handleBeforeUnload() {
725
- saveViewportState(name, {
726
- zoom: zoomRef.current,
727
- scrollLeft: el.scrollLeft,
728
- scrollTop: el.scrollTop,
729
- })
828
+ debouncedScrollSave.cancel()
829
+ saveNow()
730
830
  }
731
831
  window.addEventListener('beforeunload', handleBeforeUnload)
732
832
 
733
833
  return () => {
834
+ debouncedScrollSave.cancel()
734
835
  el.removeEventListener('scroll', handleScroll)
735
836
  window.removeEventListener('beforeunload', handleBeforeUnload)
837
+ // Save final state on cleanup (covers SPA navigation where
838
+ // beforeunload doesn't fire).
839
+ saveNow()
736
840
  }
737
841
  }, [name, loading])
738
842
 
@@ -771,6 +875,17 @@ export default function CanvasPage({ name }) {
771
875
  // Scroll so the same canvas point stays under the anchor
772
876
  el.scrollLeft = canvasX * newScale - anchorX
773
877
  el.scrollTop = canvasY * newScale - anchorY
878
+
879
+ // Persist after both zoom and scroll are settled (the zoom effect
880
+ // fires inside flushSync before the scroll adjustment above, so it
881
+ // would capture stale scroll values).
882
+ if (viewportInitName.current === name) {
883
+ saveViewportState(name, {
884
+ zoom: clampedZoom,
885
+ scrollLeft: el.scrollLeft,
886
+ scrollTop: el.scrollTop,
887
+ })
888
+ }
774
889
  }
775
890
 
776
891
  // Signal canvas mount/unmount to CoreUIBar
@@ -834,14 +949,41 @@ export default function CanvasPage({ name }) {
834
949
  }
835
950
  }, [name, undoRedo])
836
951
 
952
+ // Add a story widget by storyId — used by CanvasControls story picker
953
+ const addStoryWidget = useCallback(async (storyId) => {
954
+ const storyProps = { storyId, exportName: '', width: 600, height: 400 }
955
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
956
+ const pos = centerPositionForWidget(center, 'story', storyProps)
957
+ try {
958
+ const result = await addWidgetApi(name, {
959
+ type: 'story',
960
+ props: storyProps,
961
+ position: pos,
962
+ })
963
+ if (result.success && result.widget) {
964
+ undoRedo.snapshot(stateRef.current, 'add')
965
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
966
+ }
967
+ } catch (err) {
968
+ console.error('[canvas] Failed to add story widget:', err)
969
+ }
970
+ }, [name, undoRedo])
971
+
837
972
  // Listen for CoreUIBar add-widget events
838
973
  useEffect(() => {
839
974
  function handleAddWidget(e) {
840
975
  addWidget(e.detail.type)
841
976
  }
977
+ function handleAddStoryWidget(e) {
978
+ addStoryWidget(e.detail.storyId)
979
+ }
842
980
  document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
843
- return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
844
- }, [addWidget])
981
+ document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
982
+ return () => {
983
+ document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
984
+ document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
985
+ }
986
+ }, [addWidget, addStoryWidget])
845
987
 
846
988
  // Listen for zoom changes from CoreUIBar
847
989
  useEffect(() => {
@@ -860,7 +1002,7 @@ export default function CanvasPage({ name }) {
860
1002
  function handleSnapToggle() {
861
1003
  setSnapEnabled((prev) => {
862
1004
  const next = !prev
863
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
1005
+ updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
864
1006
  console.error('[canvas] Failed to persist snap setting:', err)
865
1007
  )
866
1008
  return next
@@ -875,8 +1017,20 @@ export default function CanvasPage({ name }) {
875
1017
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
876
1018
  detail: { snapEnabled }
877
1019
  }))
1020
+ snapEnabledRef.current = snapEnabled
878
1021
  }, [snapEnabled])
879
1022
 
1023
+ // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
1024
+ useEffect(() => {
1025
+ function handleRequest() {
1026
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
1027
+ detail: { snapEnabled: snapEnabledRef.current }
1028
+ }))
1029
+ }
1030
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
1031
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
1032
+ }, [])
1033
+
880
1034
  // Listen for gridSize from Svelte toolbar config
881
1035
  useEffect(() => {
882
1036
  function handleGridSize(e) {
@@ -887,13 +1041,18 @@ export default function CanvasPage({ name }) {
887
1041
  return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
888
1042
  }, [])
889
1043
 
1044
+ // Keep snapGridSize ref in sync for drop handler
1045
+ useEffect(() => {
1046
+ snapGridSizeRef.current = snapGridSize
1047
+ }, [snapGridSize])
1048
+
890
1049
  // Listen for zoom-to-fit from CoreUIBar
891
1050
  useEffect(() => {
892
1051
  function handleZoomToFit() {
893
1052
  const el = scrollRef.current
894
1053
  if (!el) return
895
1054
 
896
- const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
1055
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
897
1056
  if (!bounds) return
898
1057
 
899
1058
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
@@ -914,10 +1073,19 @@ export default function CanvasPage({ name }) {
914
1073
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
915
1074
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
916
1075
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1076
+
1077
+ // Persist after both zoom and scroll are settled
1078
+ if (viewportInitName.current === name) {
1079
+ saveViewportState(name, {
1080
+ zoom: fitZoom,
1081
+ scrollLeft: el.scrollLeft,
1082
+ scrollTop: el.scrollTop,
1083
+ })
1084
+ }
917
1085
  }
918
1086
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
919
1087
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
920
- }, [localWidgets, localSources, jsxExports])
1088
+ }, [localWidgets, componentEntries])
921
1089
 
922
1090
  // Canvas background should follow toolbar theme target.
923
1091
  useEffect(() => {
@@ -958,6 +1126,14 @@ export default function CanvasPage({ name }) {
958
1126
  e.preventDefault()
959
1127
  setSelectedWidgetIds(new Set())
960
1128
  }
1129
+ // Copy shortcut (single widget selected):
1130
+ // cmd+c → copy canvasName::widgetId (for cross-canvas paste-duplicate)
1131
+ const mod = e.metaKey || e.ctrlKey
1132
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1133
+ const widgetId = [...selectedWidgetIds][0]
1134
+ e.preventDefault()
1135
+ navigator.clipboard.writeText(`${name}::${widgetId}`).catch(() => {})
1136
+ }
961
1137
  if (e.key === 'Delete' || e.key === 'Backspace') {
962
1138
  e.preventDefault()
963
1139
  if (selectedWidgetIds.size > 1) {
@@ -983,50 +1159,17 @@ export default function CanvasPage({ name }) {
983
1159
  }
984
1160
  document.addEventListener('keydown', handleKeyDown)
985
1161
  return () => document.removeEventListener('keydown', handleKeyDown)
986
- }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
1162
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
987
1163
 
988
- // Paste handler images become image widgets, same-origin URLs become prototypes,
1164
+ // Ref to store processImageFile for use by drop effect
1165
+ const processImageFileRef = useRef(null)
1166
+
1167
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
989
1168
  // other URLs become link previews, text becomes markdown
990
1169
  useEffect(() => {
991
1170
  const origin = window.location.origin
992
1171
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
993
- const baseUrl = origin + basePath
994
-
995
- // Check if a URL is same-origin, accounting for branch-deploy prefixes.
996
- // e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
997
- // are both same-origin prototype URLs.
998
- function isSameOriginPrototype(url) {
999
- if (!url.startsWith(origin)) return false
1000
- if (url.startsWith(baseUrl)) return true
1001
- // Match branch deploy URLs: origin + /branch--*/...
1002
- const pathAfterOrigin = url.slice(origin.length)
1003
- return BRANCH_PREFIX_RE.test(pathAfterOrigin)
1004
- }
1005
-
1006
- // Strip the base path (or any branch prefix) from a pathname to get a portable src.
1007
- function extractPrototypeSrc(pathname) {
1008
- // Strip current base path
1009
- if (basePath && pathname.startsWith(basePath)) {
1010
- return pathname.slice(basePath.length) || '/'
1011
- }
1012
- // Strip branch prefix: /branch--name/rest → /rest
1013
- const branchMatch = pathname.match(BRANCH_PREFIX_RE)
1014
- if (branchMatch) {
1015
- return pathname.slice(branchMatch[0].length) || '/'
1016
- }
1017
- return pathname
1018
- }
1019
-
1020
- /** Parse text as a web URL (http/https only). Returns URL object or null. */
1021
- function looksLikeWebUrl(text) {
1022
- try {
1023
- const url = new URL(text)
1024
- if (url.protocol === 'http:' || url.protocol === 'https:') return url
1025
- return null
1026
- } catch {
1027
- return null
1028
- }
1029
- }
1172
+ const pasteCtx = createPasteContext(origin, basePath)
1030
1173
 
1031
1174
  function blobToDataUrl(blob) {
1032
1175
  return new Promise((resolve, reject) => {
@@ -1046,6 +1189,59 @@ export default function CanvasPage({ name }) {
1046
1189
  })
1047
1190
  }
1048
1191
 
1192
+ /**
1193
+ * Process an image file (from paste or drop) and add it as a widget.
1194
+ * @param {File|Blob} file - Image file to process
1195
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
1196
+ */
1197
+ async function processImageFile(file, position = null) {
1198
+ try {
1199
+ const dataUrl = await blobToDataUrl(file)
1200
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1201
+
1202
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1203
+ const maxWidth = 600
1204
+ let displayW = Math.round(natW / 2)
1205
+ let displayH = Math.round(natH / 2)
1206
+ if (displayW > maxWidth) {
1207
+ displayH = Math.round(displayH * (maxWidth / displayW))
1208
+ displayW = maxWidth
1209
+ }
1210
+
1211
+ const uploadResult = await uploadImage(dataUrl, name)
1212
+ if (!uploadResult.success) {
1213
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1214
+ return false
1215
+ }
1216
+
1217
+ // Use provided position or fall back to viewport center
1218
+ let pos
1219
+ if (position) {
1220
+ pos = { x: position.x, y: position.y }
1221
+ } else {
1222
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1223
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1224
+ }
1225
+
1226
+ const result = await addWidgetApi(name, {
1227
+ type: 'image',
1228
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1229
+ position: pos,
1230
+ })
1231
+ if (result.success && result.widget) {
1232
+ undoRedo.snapshot(stateRef.current, 'add')
1233
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1234
+ }
1235
+ return true
1236
+ } catch (err) {
1237
+ console.error('[canvas] Failed to process image:', err)
1238
+ return false
1239
+ }
1240
+ }
1241
+
1242
+ // Store in ref for use by drag/drop effect
1243
+ processImageFileRef.current = processImageFile
1244
+
1049
1245
  async function handleImagePaste(e) {
1050
1246
  const items = e.clipboardData?.items
1051
1247
  if (!items) return false
@@ -1057,40 +1253,7 @@ export default function CanvasPage({ name }) {
1057
1253
  if (!blob) continue
1058
1254
 
1059
1255
  e.preventDefault()
1060
-
1061
- try {
1062
- const dataUrl = await blobToDataUrl(blob)
1063
- const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1064
-
1065
- // Display at 2x retina: halve natural dimensions, then cap at 600px
1066
- const maxWidth = 600
1067
- let displayW = Math.round(natW / 2)
1068
- let displayH = Math.round(natH / 2)
1069
- if (displayW > maxWidth) {
1070
- displayH = Math.round(displayH * (maxWidth / displayW))
1071
- displayW = maxWidth
1072
- }
1073
-
1074
- const uploadResult = await uploadImage(dataUrl, name)
1075
- if (!uploadResult.success) {
1076
- console.error('[canvas] Image upload failed:', uploadResult.error)
1077
- return true
1078
- }
1079
-
1080
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1081
- const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1082
- const result = await addWidgetApi(name, {
1083
- type: 'image',
1084
- props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1085
- position: pos,
1086
- })
1087
- if (result.success && result.widget) {
1088
- undoRedo.snapshot(stateRef.current, 'add')
1089
- setLocalWidgets((prev) => [...(prev || []), result.widget])
1090
- }
1091
- } catch (err) {
1092
- console.error('[canvas] Failed to paste image:', err)
1093
- }
1256
+ await processImageFile(blob, null)
1094
1257
  return true
1095
1258
  }
1096
1259
  return false
@@ -1107,28 +1270,48 @@ export default function CanvasPage({ name }) {
1107
1270
  const text = e.clipboardData?.getData('text/plain')?.trim()
1108
1271
  if (!text) return
1109
1272
 
1110
- e.preventDefault()
1111
-
1112
- let type, props
1113
- const url = looksLikeWebUrl(text)
1114
- if (url) {
1115
- if (isFigmaUrl(text)) {
1116
- type = 'figma-embed'
1117
- props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1118
- } else if (isSameOriginPrototype(text)) {
1119
- const pathPortion = url.pathname + url.search + url.hash
1120
- const src = extractPrototypeSrc(pathPortion)
1121
- type = 'prototype'
1122
- props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
1123
- } else {
1124
- type = 'link-preview'
1125
- props = { url: text, title: '' }
1273
+ // Detect canvasName::widgetId format for widget duplication (cross-canvas copy-paste)
1274
+ // Also supports legacy canvasName/widgetId for basenames without slashes,
1275
+ // but only when the second segment looks like a widget ID (type-hash).
1276
+ const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
1277
+ if (widgetRefMatch) {
1278
+ e.preventDefault()
1279
+ const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
1280
+ // Component widgets are code, not duplicable data silently consume the ref
1281
+ if (sourceWidgetId.startsWith('jsx-')) return
1282
+ try {
1283
+ let sourceWidget = null
1284
+ if (sourceCanvas === name) {
1285
+ sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1286
+ } else {
1287
+ const canvasData = await getCanvasApi(sourceCanvas)
1288
+ sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
1289
+ }
1290
+ if (sourceWidget) {
1291
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1292
+ const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1293
+ undoRedo.snapshot(stateRef.current, 'add')
1294
+ const result = await addWidgetApi(name, {
1295
+ type: sourceWidget.type,
1296
+ props: { ...sourceWidget.props },
1297
+ position: pos,
1298
+ })
1299
+ if (result.success && result.widget) {
1300
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1301
+ }
1302
+ }
1303
+ } catch (err) {
1304
+ console.error('[canvas] Failed to paste widget reference:', err)
1126
1305
  }
1127
- } else {
1128
- type = 'markdown'
1129
- props = { content: text }
1306
+ // Always consume the ref — never fall through to markdown creation
1307
+ return
1130
1308
  }
1131
1309
 
1310
+ e.preventDefault()
1311
+ const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1312
+ if (!resolved) return
1313
+ const { type, props } = resolved
1314
+
1132
1315
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1133
1316
  const pos = centerPositionForWidget(center, type, props)
1134
1317
  try {
@@ -1145,9 +1328,74 @@ export default function CanvasPage({ name }) {
1145
1328
  console.error('[canvas] Failed to add widget from paste:', err)
1146
1329
  }
1147
1330
  }
1331
+
1148
1332
  document.addEventListener('paste', handlePaste)
1149
1333
  return () => document.removeEventListener('paste', handlePaste)
1150
- }, [name, undoRedo])
1334
+ }, [name, undoRedo, localWidgets])
1335
+
1336
+ // --- Drag and drop handlers for images from Finder/file manager ---
1337
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
1338
+ useEffect(() => {
1339
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
1340
+
1341
+ const scrollEl = scrollRef.current
1342
+ if (!scrollEl) return
1343
+
1344
+ function handleDragOver(e) {
1345
+ // Only handle if dragging files (not internal widget drag)
1346
+ if (!e.dataTransfer?.types?.includes('Files')) return
1347
+ e.preventDefault()
1348
+ e.dataTransfer.dropEffect = 'copy'
1349
+ }
1350
+
1351
+ async function handleDrop(e) {
1352
+ // Only handle file drops, not internal widget drags
1353
+ if (!e.dataTransfer?.types?.includes('Files')) return
1354
+
1355
+ // Prevent browser default (opening file) immediately for any file drop
1356
+ e.preventDefault()
1357
+ e.stopPropagation()
1358
+
1359
+ const files = e.dataTransfer.files
1360
+ if (!files || files.length === 0) return
1361
+
1362
+ // Filter to image files only — non-images are silently ignored (default already prevented)
1363
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
1364
+ if (imageFiles.length === 0) return
1365
+
1366
+ // Convert drop coordinates to canvas coordinates
1367
+ const rect = scrollEl.getBoundingClientRect()
1368
+ const scale = zoomRef.current / 100
1369
+
1370
+ // Mouse position relative to scroll container
1371
+ const mouseX = e.clientX - rect.left
1372
+ const mouseY = e.clientY - rect.top
1373
+
1374
+ // Convert to canvas coordinates (account for scroll and zoom)
1375
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
1376
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
1377
+
1378
+ // Snap to grid if enabled, using current grid size
1379
+ const gridSize = snapGridSizeRef.current
1380
+ const shouldSnap = snapEnabledRef.current
1381
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
1382
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
1383
+
1384
+ // Process each image file, offsetting subsequent images
1385
+ for (let i = 0; i < imageFiles.length; i++) {
1386
+ const offset = shouldSnap ? i * gridSize : i * 24
1387
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
1388
+ }
1389
+ }
1390
+
1391
+ scrollEl.addEventListener('dragover', handleDragOver)
1392
+ scrollEl.addEventListener('drop', handleDrop)
1393
+
1394
+ return () => {
1395
+ scrollEl.removeEventListener('dragover', handleDragOver)
1396
+ scrollEl.removeEventListener('drop', handleDrop)
1397
+ }
1398
+ }, [loading])
1151
1399
 
1152
1400
  // --- Undo / Redo ---
1153
1401
  const handleUndo = useCallback(() => {
@@ -1235,6 +1483,55 @@ export default function CanvasPage({ name }) {
1235
1483
  return () => document.removeEventListener('wheel', handleWheel)
1236
1484
  }, [])
1237
1485
 
1486
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1487
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1488
+ useEffect(() => {
1489
+ const el = scrollRef.current
1490
+ if (!el) return
1491
+
1492
+ function getTouchDist(t1, t2) {
1493
+ const dx = t1.clientX - t2.clientX
1494
+ const dy = t1.clientY - t2.clientY
1495
+ return Math.sqrt(dx * dx + dy * dy)
1496
+ }
1497
+
1498
+ function handleTouchStart(e) {
1499
+ if (e.touches.length !== 2) return
1500
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1501
+ pinchState.current = {
1502
+ active: true,
1503
+ startDist: dist,
1504
+ startZoom: zoomRef.current,
1505
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
1506
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
1507
+ }
1508
+ }
1509
+
1510
+ function handleTouchMove(e) {
1511
+ if (!pinchState.current.active || e.touches.length !== 2) return
1512
+ e.preventDefault()
1513
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1514
+ const ratio = dist / pinchState.current.startDist
1515
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
1516
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
1517
+ }
1518
+
1519
+ function handleTouchEnd() {
1520
+ pinchState.current.active = false
1521
+ }
1522
+
1523
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
1524
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1525
+ el.addEventListener('touchend', handleTouchEnd)
1526
+ el.addEventListener('touchcancel', handleTouchEnd)
1527
+ return () => {
1528
+ el.removeEventListener('touchstart', handleTouchStart)
1529
+ el.removeEventListener('touchmove', handleTouchMove)
1530
+ el.removeEventListener('touchend', handleTouchEnd)
1531
+ el.removeEventListener('touchcancel', handleTouchEnd)
1532
+ }
1533
+ }, [])
1534
+
1238
1535
  // Space + drag to pan the canvas
1239
1536
  const [spaceHeld, setSpaceHeld] = useState(false)
1240
1537
  const isPanning = useRef(false)
@@ -1328,54 +1625,50 @@ export default function CanvasPage({ name }) {
1328
1625
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1329
1626
  const allChildren = []
1330
1627
 
1331
- const sourceDataByExport = Object.fromEntries(
1332
- (localSources || [])
1333
- .filter((source) => source?.export)
1334
- .map((source) => [source.export, source])
1335
- )
1336
-
1337
- // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
1338
- const componentFeatures = getFeatures('component')
1339
- if (jsxExports) {
1340
- for (const [exportName, Component] of Object.entries(jsxExports)) {
1341
- const sourceData = sourceDataByExport[exportName] || {}
1342
- const sourcePosition = sourceData.position || { x: 0, y: 0 }
1343
- allChildren.push(
1344
- <div
1345
- key={`jsx-${exportName}`}
1346
- id={`jsx-${exportName}`}
1347
- data-tc-x={sourcePosition.x}
1348
- data-tc-y={sourcePosition.y}
1349
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1350
- {...canvasPrimerAttrs}
1351
- style={canvasThemeVars}
1352
- onClick={isLocalDev ? (e) => {
1353
- e.stopPropagation()
1354
- if (!e.target.closest('.tc-drag-handle')) {
1355
- handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1356
- }
1357
- } : undefined}
1628
+ // 1. Component widgets (from jsxExports or sources fallback)
1629
+ const componentFeatures = getFeatures('component', { isLocalDev })
1630
+ for (const entry of componentEntries) {
1631
+ const { exportName, Component, sourceData } = entry
1632
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
1633
+ allChildren.push(
1634
+ <div
1635
+ key={`jsx-${exportName}`}
1636
+ id={`jsx-${exportName}`}
1637
+ data-tc-x={sourcePosition.x}
1638
+ data-tc-y={sourcePosition.y}
1639
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1640
+ {...canvasPrimerAttrs}
1641
+ style={canvasThemeVars}
1642
+ onClick={isLocalDev ? (e) => {
1643
+ e.stopPropagation()
1644
+ if (!e.target.closest('.tc-drag-handle')) {
1645
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1646
+ }
1647
+ } : undefined}
1648
+ >
1649
+ <WidgetChrome
1650
+ widgetId={`jsx-${exportName}`}
1651
+ features={componentFeatures}
1652
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1653
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1654
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1655
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1656
+ readOnly={!isLocalDev}
1358
1657
  >
1359
- <WidgetChrome
1360
- widgetId={`jsx-${exportName}`}
1361
- features={componentFeatures}
1362
- selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1363
- multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1364
- onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1365
- onDeselect={() => setSelectedWidgetIds(new Set())}
1366
- readOnly={!isLocalDev}
1367
- >
1368
- <ComponentWidget
1369
- component={Component}
1370
- width={sourceData.width}
1371
- height={sourceData.height}
1372
- onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1373
- resizable={isResizable('component') && isLocalDev}
1374
- />
1375
- </WidgetChrome>
1376
- </div>
1377
- )
1378
- }
1658
+ <ComponentWidget
1659
+ component={Component}
1660
+ jsxModule={canvas?._jsxModule}
1661
+ exportName={exportName}
1662
+ canvasTheme={canvasTheme}
1663
+ isLocalDev={isLocalDev}
1664
+ width={sourceData.width}
1665
+ height={sourceData.height}
1666
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1667
+ resizable={isResizable('component') && isLocalDev}
1668
+ />
1669
+ </WidgetChrome>
1670
+ </div>
1671
+ )
1379
1672
  }
1380
1673
 
1381
1674
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
@@ -1419,24 +1712,8 @@ export default function CanvasPage({ name }) {
1419
1712
  return (
1420
1713
  <>
1421
1714
  <div className={styles.canvasTitle}>
1422
- <div className={styles.canvasTitleWrap}>
1423
- <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1424
- {isLocalDev ? (
1425
- <input
1426
- ref={titleInputRef}
1427
- className={styles.canvasTitleInput}
1428
- value={canvasTitle}
1429
- size={1}
1430
- onChange={handleTitleChange}
1431
- onKeyDown={handleTitleKeyDown}
1432
- onMouseDown={(e) => e.stopPropagation()}
1433
- spellCheck={false}
1434
- aria-label="Canvas title"
1435
- />
1436
- ) : (
1437
- <h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
1438
- )}
1439
- </div>
1715
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || name.split('/').pop()}</h1>
1716
+ <PageSelector currentName={name} pages={siblingPages} />
1440
1717
  {isLocalDev && (
1441
1718
  <span className={styles.localEditingLabel}>Local editing</span>
1442
1719
  )}