@dfosco/storyboard-react 4.0.0-beta.0 → 4.0.0-beta.10

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.
@@ -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'
@@ -47,6 +47,35 @@ function resolveCanvasThemeFromStorage() {
47
47
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
48
48
  }
49
49
 
50
+ /**
51
+ * Get the copyable URL for a widget based on its type.
52
+ * Returns the most relevant URL/path for the widget content.
53
+ */
54
+ function getWidgetCopyableUrl(widget) {
55
+ const { type, props = {} } = widget
56
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
57
+ switch (type) {
58
+ case 'prototype':
59
+ // Prototype src is a path like "/MyPrototype" - make it a full URL
60
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
61
+ case 'figma-embed':
62
+ return props.url || ''
63
+ case 'link-preview':
64
+ return props.url || ''
65
+ case 'image':
66
+ // Return the served image URL
67
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
68
+ case 'sticky-note':
69
+ // Sticky notes have text content, not a URL
70
+ return props.text || ''
71
+ case 'markdown':
72
+ // Markdown has content, not a URL
73
+ return props.content || ''
74
+ default:
75
+ return ''
76
+ }
77
+ }
78
+
50
79
  /**
51
80
  * Debounce helper — returns a function that delays invocation.
52
81
  * Exposes `.cancel()` to abort pending calls (used by undo/redo).
@@ -158,7 +187,7 @@ const FIT_PADDING = 48
158
187
  * Compute the axis-aligned bounding box that contains every widget and source.
159
188
  * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
160
189
  */
161
- function computeCanvasBounds(widgets, sources, jsxExports) {
190
+ function computeCanvasBounds(widgets, componentEntries) {
162
191
  let minX = Infinity
163
192
  let minY = Infinity
164
193
  let maxX = -Infinity
@@ -179,24 +208,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
179
208
  hasItems = true
180
209
  }
181
210
 
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
- }
211
+ // Component widgets (from jsxExports or sources fallback)
212
+ for (const entry of componentEntries) {
213
+ const x = entry.sourceData?.position?.x ?? 0
214
+ const y = entry.sourceData?.position?.y ?? 0
215
+ const fallback = WIDGET_FALLBACK_SIZES['component']
216
+ const width = entry.sourceData?.width ?? fallback.width
217
+ const height = entry.sourceData?.height ?? fallback.height
218
+ minX = Math.min(minX, x)
219
+ minY = Math.min(minY, y)
220
+ maxX = Math.max(maxX, x + width)
221
+ maxY = Math.max(maxY, y + height)
222
+ hasItems = true
200
223
  }
201
224
 
202
225
  return hasItems ? { minX, minY, maxX, maxY } : null
@@ -275,7 +298,7 @@ function ChromeWrappedWidget({
275
298
  * @param {{ name: string }} props - Canvas name as indexed by the data plugin
276
299
  */
277
300
  export default function CanvasPage({ name }) {
278
- const { canvas, jsxExports, loading } = useCanvas(name)
301
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
279
302
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
280
303
 
281
304
  // Local mutable copy of widgets for instant UI updates
@@ -293,6 +316,37 @@ export default function CanvasPage({ name }) {
293
316
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
294
317
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
295
318
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
319
+ // Refs for snap settings (used by drop handler inside effect closure)
320
+ const snapEnabledRef = useRef(snapEnabled)
321
+ const snapGridSizeRef = useRef(snapGridSize)
322
+
323
+ // Centralized list of component export names.
324
+ // When jsxExports is available, use it (discovers new exports not yet in sources).
325
+ // When jsxExports is null (module import failed), fall back to sources so iframes
326
+ // still render — the error is contained inside each iframe.
327
+ const componentEntries = useMemo(() => {
328
+ const sourceMap = Object.fromEntries(
329
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
330
+ )
331
+ if (jsxExports) {
332
+ return Object.keys(jsxExports).map((exportName) => ({
333
+ exportName,
334
+ Component: jsxExports[exportName],
335
+ sourceData: sourceMap[exportName] || {},
336
+ }))
337
+ }
338
+ // Fallback: use sources when module import failed (iframe isolation still works)
339
+ if (jsxError && canvas?._jsxModule) {
340
+ return (localSources || [])
341
+ .filter((s) => s?.export)
342
+ .map((s) => ({
343
+ exportName: s.export,
344
+ Component: null,
345
+ sourceData: s,
346
+ }))
347
+ }
348
+ return []
349
+ }, [jsxExports, jsxError, localSources, canvas?._jsxModule])
296
350
 
297
351
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
298
352
  const undoRedo = useUndoRedo()
@@ -673,16 +727,13 @@ export default function CanvasPage({ name }) {
673
727
  // Check JSX sources (jsx-ExportName)
674
728
  if (!widget && targetId.startsWith('jsx-')) {
675
729
  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)) {
730
+ const entry = componentEntries.find((e) => e.exportName === exportName)
731
+ if (entry) {
681
732
  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
733
+ x = entry.sourceData?.position?.x ?? 0
734
+ y = entry.sourceData?.position?.y ?? 0
735
+ w = entry.sourceData?.width ?? fallback.width
736
+ h = entry.sourceData?.height ?? fallback.height
686
737
  }
687
738
  }
688
739
 
@@ -696,7 +747,7 @@ export default function CanvasPage({ name }) {
696
747
  const url = new URL(window.location.href)
697
748
  url.searchParams.delete('widget')
698
749
  window.history.replaceState({}, '', url.toString())
699
- }, [loading, localWidgets, localSources, jsxExports])
750
+ }, [loading, localWidgets, componentEntries])
700
751
 
701
752
  // Persist viewport state (zoom + scroll) to localStorage on changes
702
753
  useEffect(() => {
@@ -875,6 +926,7 @@ export default function CanvasPage({ name }) {
875
926
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
876
927
  detail: { snapEnabled }
877
928
  }))
929
+ snapEnabledRef.current = snapEnabled
878
930
  }, [snapEnabled])
879
931
 
880
932
  // Listen for gridSize from Svelte toolbar config
@@ -887,13 +939,18 @@ export default function CanvasPage({ name }) {
887
939
  return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
888
940
  }, [])
889
941
 
942
+ // Keep snapGridSize ref in sync for drop handler
943
+ useEffect(() => {
944
+ snapGridSizeRef.current = snapGridSize
945
+ }, [snapGridSize])
946
+
890
947
  // Listen for zoom-to-fit from CoreUIBar
891
948
  useEffect(() => {
892
949
  function handleZoomToFit() {
893
950
  const el = scrollRef.current
894
951
  if (!el) return
895
952
 
896
- const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
953
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
897
954
  if (!bounds) return
898
955
 
899
956
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
@@ -917,7 +974,7 @@ export default function CanvasPage({ name }) {
917
974
  }
918
975
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
919
976
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
920
- }, [localWidgets, localSources, jsxExports])
977
+ }, [localWidgets, componentEntries])
921
978
 
922
979
  // Canvas background should follow toolbar theme target.
923
980
  useEffect(() => {
@@ -958,6 +1015,34 @@ export default function CanvasPage({ name }) {
958
1015
  e.preventDefault()
959
1016
  setSelectedWidgetIds(new Set())
960
1017
  }
1018
+ // Copy shortcuts (single widget selected):
1019
+ // - cmd+c → copy URL/content
1020
+ // - Shift+C (no cmd) → copy widget ID (or file path for images)
1021
+ const mod = e.metaKey || e.ctrlKey
1022
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1023
+ const widgetId = [...selectedWidgetIds][0]
1024
+ const widget = localWidgets?.find(w => w.id === widgetId)
1025
+ if (widget) {
1026
+ e.preventDefault()
1027
+ const url = getWidgetCopyableUrl(widget)
1028
+ if (url) {
1029
+ navigator.clipboard.writeText(url).catch(() => {})
1030
+ }
1031
+ }
1032
+ }
1033
+ // Shift+C (uppercase C, no cmd) → copy ID or file path
1034
+ if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
1035
+ const widgetId = [...selectedWidgetIds][0]
1036
+ const widget = localWidgets?.find(w => w.id === widgetId)
1037
+ if (widget) {
1038
+ e.preventDefault()
1039
+ if (widget.type === 'image' && widget.props?.src) {
1040
+ navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
1041
+ } else {
1042
+ navigator.clipboard.writeText(widgetId).catch(() => {})
1043
+ }
1044
+ }
1045
+ }
961
1046
  if (e.key === 'Delete' || e.key === 'Backspace') {
962
1047
  e.preventDefault()
963
1048
  if (selectedWidgetIds.size > 1) {
@@ -983,9 +1068,12 @@ export default function CanvasPage({ name }) {
983
1068
  }
984
1069
  document.addEventListener('keydown', handleKeyDown)
985
1070
  return () => document.removeEventListener('keydown', handleKeyDown)
986
- }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
1071
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
987
1072
 
988
- // Paste handler images become image widgets, same-origin URLs become prototypes,
1073
+ // Ref to store processImageFile for use by drop effect
1074
+ const processImageFileRef = useRef(null)
1075
+
1076
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
989
1077
  // other URLs become link previews, text becomes markdown
990
1078
  useEffect(() => {
991
1079
  const origin = window.location.origin
@@ -1017,6 +1105,17 @@ export default function CanvasPage({ name }) {
1017
1105
  return pathname
1018
1106
  }
1019
1107
 
1108
+ /** Parse text as a web URL (http/https only). Returns URL object or null. */
1109
+ function looksLikeWebUrl(text) {
1110
+ try {
1111
+ const url = new URL(text)
1112
+ if (url.protocol === 'http:' || url.protocol === 'https:') return url
1113
+ return null
1114
+ } catch {
1115
+ return null
1116
+ }
1117
+ }
1118
+
1020
1119
  function blobToDataUrl(blob) {
1021
1120
  return new Promise((resolve, reject) => {
1022
1121
  const reader = new FileReader()
@@ -1035,6 +1134,59 @@ export default function CanvasPage({ name }) {
1035
1134
  })
1036
1135
  }
1037
1136
 
1137
+ /**
1138
+ * Process an image file (from paste or drop) and add it as a widget.
1139
+ * @param {File|Blob} file - Image file to process
1140
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
1141
+ */
1142
+ async function processImageFile(file, position = null) {
1143
+ try {
1144
+ const dataUrl = await blobToDataUrl(file)
1145
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1146
+
1147
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1148
+ const maxWidth = 600
1149
+ let displayW = Math.round(natW / 2)
1150
+ let displayH = Math.round(natH / 2)
1151
+ if (displayW > maxWidth) {
1152
+ displayH = Math.round(displayH * (maxWidth / displayW))
1153
+ displayW = maxWidth
1154
+ }
1155
+
1156
+ const uploadResult = await uploadImage(dataUrl, name)
1157
+ if (!uploadResult.success) {
1158
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1159
+ return false
1160
+ }
1161
+
1162
+ // Use provided position or fall back to viewport center
1163
+ let pos
1164
+ if (position) {
1165
+ pos = { x: position.x, y: position.y }
1166
+ } else {
1167
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1168
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1169
+ }
1170
+
1171
+ const result = await addWidgetApi(name, {
1172
+ type: 'image',
1173
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1174
+ position: pos,
1175
+ })
1176
+ if (result.success && result.widget) {
1177
+ undoRedo.snapshot(stateRef.current, 'add')
1178
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1179
+ }
1180
+ return true
1181
+ } catch (err) {
1182
+ console.error('[canvas] Failed to process image:', err)
1183
+ return false
1184
+ }
1185
+ }
1186
+
1187
+ // Store in ref for use by drag/drop effect
1188
+ processImageFileRef.current = processImageFile
1189
+
1038
1190
  async function handleImagePaste(e) {
1039
1191
  const items = e.clipboardData?.items
1040
1192
  if (!items) return false
@@ -1046,40 +1198,7 @@ export default function CanvasPage({ name }) {
1046
1198
  if (!blob) continue
1047
1199
 
1048
1200
  e.preventDefault()
1049
-
1050
- try {
1051
- const dataUrl = await blobToDataUrl(blob)
1052
- const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1053
-
1054
- // Display at 2x retina: halve natural dimensions, then cap at 600px
1055
- const maxWidth = 600
1056
- let displayW = Math.round(natW / 2)
1057
- let displayH = Math.round(natH / 2)
1058
- if (displayW > maxWidth) {
1059
- displayH = Math.round(displayH * (maxWidth / displayW))
1060
- displayW = maxWidth
1061
- }
1062
-
1063
- const uploadResult = await uploadImage(dataUrl, name)
1064
- if (!uploadResult.success) {
1065
- console.error('[canvas] Image upload failed:', uploadResult.error)
1066
- return true
1067
- }
1068
-
1069
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1070
- const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1071
- const result = await addWidgetApi(name, {
1072
- type: 'image',
1073
- props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1074
- position: pos,
1075
- })
1076
- if (result.success && result.widget) {
1077
- undoRedo.snapshot(stateRef.current, 'add')
1078
- setLocalWidgets((prev) => [...(prev || []), result.widget])
1079
- }
1080
- } catch (err) {
1081
- console.error('[canvas] Failed to paste image:', err)
1082
- }
1201
+ await processImageFile(blob, null)
1083
1202
  return true
1084
1203
  }
1085
1204
  return false
@@ -1099,13 +1218,13 @@ export default function CanvasPage({ name }) {
1099
1218
  e.preventDefault()
1100
1219
 
1101
1220
  let type, props
1102
- try {
1103
- const parsed = new URL(text)
1221
+ const url = looksLikeWebUrl(text)
1222
+ if (url) {
1104
1223
  if (isFigmaUrl(text)) {
1105
1224
  type = 'figma-embed'
1106
1225
  props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1107
1226
  } else if (isSameOriginPrototype(text)) {
1108
- const pathPortion = parsed.pathname + parsed.search + parsed.hash
1227
+ const pathPortion = url.pathname + url.search + url.hash
1109
1228
  const src = extractPrototypeSrc(pathPortion)
1110
1229
  type = 'prototype'
1111
1230
  props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
@@ -1113,7 +1232,7 @@ export default function CanvasPage({ name }) {
1113
1232
  type = 'link-preview'
1114
1233
  props = { url: text, title: '' }
1115
1234
  }
1116
- } catch {
1235
+ } else {
1117
1236
  type = 'markdown'
1118
1237
  props = { content: text }
1119
1238
  }
@@ -1134,10 +1253,75 @@ export default function CanvasPage({ name }) {
1134
1253
  console.error('[canvas] Failed to add widget from paste:', err)
1135
1254
  }
1136
1255
  }
1256
+
1137
1257
  document.addEventListener('paste', handlePaste)
1138
1258
  return () => document.removeEventListener('paste', handlePaste)
1139
1259
  }, [name, undoRedo])
1140
1260
 
1261
+ // --- Drag and drop handlers for images from Finder/file manager ---
1262
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
1263
+ useEffect(() => {
1264
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
1265
+
1266
+ const scrollEl = scrollRef.current
1267
+ if (!scrollEl) return
1268
+
1269
+ function handleDragOver(e) {
1270
+ // Only handle if dragging files (not internal widget drag)
1271
+ if (!e.dataTransfer?.types?.includes('Files')) return
1272
+ e.preventDefault()
1273
+ e.dataTransfer.dropEffect = 'copy'
1274
+ }
1275
+
1276
+ async function handleDrop(e) {
1277
+ // Only handle file drops, not internal widget drags
1278
+ if (!e.dataTransfer?.types?.includes('Files')) return
1279
+
1280
+ // Prevent browser default (opening file) immediately for any file drop
1281
+ e.preventDefault()
1282
+ e.stopPropagation()
1283
+
1284
+ const files = e.dataTransfer.files
1285
+ if (!files || files.length === 0) return
1286
+
1287
+ // Filter to image files only — non-images are silently ignored (default already prevented)
1288
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
1289
+ if (imageFiles.length === 0) return
1290
+
1291
+ // Convert drop coordinates to canvas coordinates
1292
+ const rect = scrollEl.getBoundingClientRect()
1293
+ const scale = zoomRef.current / 100
1294
+
1295
+ // Mouse position relative to scroll container
1296
+ const mouseX = e.clientX - rect.left
1297
+ const mouseY = e.clientY - rect.top
1298
+
1299
+ // Convert to canvas coordinates (account for scroll and zoom)
1300
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
1301
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
1302
+
1303
+ // Snap to grid if enabled, using current grid size
1304
+ const gridSize = snapGridSizeRef.current
1305
+ const shouldSnap = snapEnabledRef.current
1306
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
1307
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
1308
+
1309
+ // Process each image file, offsetting subsequent images
1310
+ for (let i = 0; i < imageFiles.length; i++) {
1311
+ const offset = shouldSnap ? i * gridSize : i * 24
1312
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
1313
+ }
1314
+ }
1315
+
1316
+ scrollEl.addEventListener('dragover', handleDragOver)
1317
+ scrollEl.addEventListener('drop', handleDrop)
1318
+
1319
+ return () => {
1320
+ scrollEl.removeEventListener('dragover', handleDragOver)
1321
+ scrollEl.removeEventListener('drop', handleDrop)
1322
+ }
1323
+ }, [loading])
1324
+
1141
1325
  // --- Undo / Redo ---
1142
1326
  const handleUndo = useCallback(() => {
1143
1327
  const previous = undoRedo.undo(stateRef.current)
@@ -1317,54 +1501,50 @@ export default function CanvasPage({ name }) {
1317
1501
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1318
1502
  const allChildren = []
1319
1503
 
1320
- const sourceDataByExport = Object.fromEntries(
1321
- (localSources || [])
1322
- .filter((source) => source?.export)
1323
- .map((source) => [source.export, source])
1324
- )
1325
-
1326
- // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
1504
+ // 1. Component widgets (from jsxExports or sources fallback)
1327
1505
  const componentFeatures = getFeatures('component')
1328
- if (jsxExports) {
1329
- for (const [exportName, Component] of Object.entries(jsxExports)) {
1330
- const sourceData = sourceDataByExport[exportName] || {}
1331
- const sourcePosition = sourceData.position || { x: 0, y: 0 }
1332
- allChildren.push(
1333
- <div
1334
- key={`jsx-${exportName}`}
1335
- id={`jsx-${exportName}`}
1336
- data-tc-x={sourcePosition.x}
1337
- data-tc-y={sourcePosition.y}
1338
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1339
- {...canvasPrimerAttrs}
1340
- style={canvasThemeVars}
1341
- onClick={isLocalDev ? (e) => {
1342
- e.stopPropagation()
1343
- if (!e.target.closest('.tc-drag-handle')) {
1344
- handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1345
- }
1346
- } : undefined}
1506
+ for (const entry of componentEntries) {
1507
+ const { exportName, Component, sourceData } = entry
1508
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
1509
+ allChildren.push(
1510
+ <div
1511
+ key={`jsx-${exportName}`}
1512
+ id={`jsx-${exportName}`}
1513
+ data-tc-x={sourcePosition.x}
1514
+ data-tc-y={sourcePosition.y}
1515
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1516
+ {...canvasPrimerAttrs}
1517
+ style={canvasThemeVars}
1518
+ onClick={isLocalDev ? (e) => {
1519
+ e.stopPropagation()
1520
+ if (!e.target.closest('.tc-drag-handle')) {
1521
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1522
+ }
1523
+ } : undefined}
1524
+ >
1525
+ <WidgetChrome
1526
+ widgetId={`jsx-${exportName}`}
1527
+ features={componentFeatures}
1528
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1529
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1530
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1531
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1532
+ readOnly={!isLocalDev}
1347
1533
  >
1348
- <WidgetChrome
1349
- widgetId={`jsx-${exportName}`}
1350
- features={componentFeatures}
1351
- selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1352
- multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1353
- onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1354
- onDeselect={() => setSelectedWidgetIds(new Set())}
1355
- readOnly={!isLocalDev}
1356
- >
1357
- <ComponentWidget
1358
- component={Component}
1359
- width={sourceData.width}
1360
- height={sourceData.height}
1361
- onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1362
- resizable={isResizable('component') && isLocalDev}
1363
- />
1364
- </WidgetChrome>
1365
- </div>
1366
- )
1367
- }
1534
+ <ComponentWidget
1535
+ component={Component}
1536
+ jsxModule={canvas?._jsxModule}
1537
+ exportName={exportName}
1538
+ canvasTheme={canvasTheme}
1539
+ isLocalDev={isLocalDev}
1540
+ width={sourceData.width}
1541
+ height={sourceData.height}
1542
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1543
+ resizable={isResizable('component') && isLocalDev}
1544
+ />
1545
+ </WidgetChrome>
1546
+ </div>
1547
+ )
1368
1548
  }
1369
1549
 
1370
1550
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
@@ -0,0 +1,50 @@
1
+ import { Component } from 'react'
2
+
3
+ /**
4
+ * Error boundary for canvas component widgets.
5
+ * Catches render-time errors so a single broken component
6
+ * doesn't crash the entire canvas page.
7
+ *
8
+ * Used as a production fallback when iframe isolation is not available.
9
+ */
10
+ export default class ComponentErrorBoundary extends Component {
11
+ constructor(props) {
12
+ super(props)
13
+ this.state = { error: null }
14
+ }
15
+
16
+ static getDerivedStateFromError(error) {
17
+ return { error }
18
+ }
19
+
20
+ componentDidCatch(error, info) {
21
+ console.error(
22
+ `[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
23
+ error,
24
+ info?.componentStack,
25
+ )
26
+ }
27
+
28
+ render() {
29
+ if (this.state.error) {
30
+ return (
31
+ <div style={{
32
+ padding: '16px',
33
+ color: '#cf222e',
34
+ fontFamily: 'system-ui, -apple-system, sans-serif',
35
+ fontSize: '13px',
36
+ lineHeight: 1.5,
37
+ whiteSpace: 'pre-wrap',
38
+ wordBreak: 'break-word',
39
+ minWidth: 200,
40
+ minHeight: 60,
41
+ }}>
42
+ <strong>{this.props.name || 'Component'}</strong>
43
+ <br />
44
+ {String(this.state.error.message || this.state.error)}
45
+ </div>
46
+ )
47
+ }
48
+ return this.props.children
49
+ }
50
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Canvas reload guard — client-side state for preventing HMR full reloads.
3
+ *
4
+ * This module tracks whether a canvas is currently active. When active,
5
+ * the Vite plugin suppresses full-page reloads to preserve canvas state.
6
+ *
7
+ * The actual guard logic is implemented in:
8
+ * - Server: vite.config.js (ws.send monkey-patch + heartbeat)
9
+ * - Client: CanvasPage.jsx (vite:beforeFullReload + vite:ws:disconnect)
10
+ *
11
+ * This module provides the state that those systems check.
12
+ */
13
+
14
+ let active = false
15
+
16
+ /**
17
+ * Enable the canvas reload guard.
18
+ * Call when a canvas page mounts.
19
+ */
20
+ export function enableCanvasGuard() {
21
+ active = true
22
+ }
23
+
24
+ /**
25
+ * Disable the canvas reload guard.
26
+ * Call when a canvas page unmounts.
27
+ */
28
+ export function disableCanvasGuard() {
29
+ active = false
30
+ }
31
+
32
+ /**
33
+ * Check if the canvas reload guard is currently active.
34
+ */
35
+ export function isCanvasGuardActive() {
36
+ return active
37
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
3
+
4
+ describe('canvasReloadGuard', () => {
5
+ beforeEach(() => {
6
+ disableCanvasGuard()
7
+ })
8
+
9
+ it('starts inactive', () => {
10
+ expect(isCanvasGuardActive()).toBe(false)
11
+ })
12
+
13
+ it('can be enabled and disabled', () => {
14
+ enableCanvasGuard()
15
+ expect(isCanvasGuardActive()).toBe(true)
16
+ disableCanvasGuard()
17
+ expect(isCanvasGuardActive()).toBe(false)
18
+ })
19
+
20
+ it('enable is idempotent', () => {
21
+ enableCanvasGuard()
22
+ enableCanvasGuard()
23
+ expect(isCanvasGuardActive()).toBe(true)
24
+ disableCanvasGuard()
25
+ expect(isCanvasGuardActive()).toBe(false)
26
+ })
27
+ })