@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.
- package/package.json +7 -4
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +299 -119
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +109 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +47 -6
- package/src/canvas/widgets/ComponentWidget.module.css +8 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +10 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +82 -0
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/WidgetChrome.module.css +2 -1
- package/src/canvas/widgets/widgetConfig.test.js +8 -8
- package/src/vite/data-plugin.js +144 -11
- package/src/vite/data-plugin.test.js +6 -0
|
@@ -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,
|
|
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
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
677
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
1103
|
-
|
|
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 =
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
<
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
+
})
|