@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.26
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 +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +8 -8
- package/src/canvas/CanvasPage.dragdrop.test.jsx +8 -8
- package/src/canvas/CanvasPage.jsx +170 -103
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +11 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/canvasApi.js +12 -10
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/widgets/ComponentWidget.jsx +21 -6
- package/src/canvas/widgets/ComponentWidget.module.css +5 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +44 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +210 -208
- package/src/canvas/widgets/PrototypeEmbed.module.css +61 -19
- package/src/canvas/widgets/StoryWidget.jsx +135 -171
- package/src/canvas/widgets/StoryWidget.module.css +38 -28
- package/src/canvas/widgets/WidgetChrome.jsx +3 -2
- package/src/canvas/widgets/embedInteraction.test.jsx +86 -4
- package/src/canvas/widgets/embedTheme.js +20 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +289 -0
- package/src/canvas/widgets/useSnapshotCapture.js +258 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +225 -0
- package/src/context.jsx +14 -14
- package/src/story/StoryPage.jsx +7 -7
- package/src/vite/data-plugin.js +25 -20
- package/src/vite/data-plugin.test.js +4 -4
- package/src/canvas/widgets/useViewportEntry.js +0 -93
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { buildPrototypeIndex
|
|
3
|
+
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
|
-
import { getEmbedChromeVars } from './embedTheme.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { getEmbedChromeVars, resolveCanvasTheme } from './embedTheme.js'
|
|
7
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
|
+
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
9
9
|
import styles from './PrototypeEmbed.module.css'
|
|
10
10
|
import overlayStyles from './embedOverlay.module.css'
|
|
11
11
|
|
|
12
|
-
function
|
|
13
|
-
|
|
12
|
+
function CollageFrameIcon({ size = 36 }) {
|
|
13
|
+
return (
|
|
14
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
15
|
+
<path d="M19.4 20H4.6C4.26863 20 4 19.7314 4 19.4V4.6C4 4.26863 4.26863 4 4.6 4H19.4C19.7314 4 20 4.26863 20 4.6V19.4C20 19.7314 19.7314 20 19.4 20Z" />
|
|
16
|
+
<path d="M11 12V4" />
|
|
17
|
+
<path d="M4 12H20" />
|
|
18
|
+
</svg>
|
|
19
|
+
)
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
function formatName(name) {
|
|
@@ -19,21 +25,39 @@ function formatName(name) {
|
|
|
19
25
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
function listInternalPrototypes(index) {
|
|
29
|
+
const allProtos = []
|
|
30
|
+
const sortedFolders = index.sorted?.title?.folders
|
|
31
|
+
const sortedPrototypes = index.sorted?.title?.prototypes
|
|
32
|
+
const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
|
|
33
|
+
? sortedFolders
|
|
34
|
+
: (index.folders || [])
|
|
35
|
+
const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
|
|
36
|
+
? sortedPrototypes
|
|
37
|
+
: (index.prototypes || [])
|
|
38
|
+
|
|
39
|
+
for (const folder of folderList) {
|
|
40
|
+
for (const proto of folder.prototypes || []) {
|
|
41
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const proto of standaloneList) {
|
|
45
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
46
|
+
}
|
|
47
|
+
return allProtos
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeRoutePath(value, basePath = '') {
|
|
51
|
+
if (!value || /^https?:\/\//.test(value)) return ''
|
|
52
|
+
const noHash = value.split('#')[0]
|
|
53
|
+
let route = noHash.split('?')[0]
|
|
54
|
+
route = route.replace(/^\/branch--[^/]+/, '')
|
|
55
|
+
if (basePath && route.startsWith(basePath)) {
|
|
56
|
+
route = route.slice(basePath.length) || '/'
|
|
30
57
|
}
|
|
31
|
-
if (!
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
35
|
-
if (stored !== 'system') return stored
|
|
36
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
58
|
+
if (!route.startsWith('/')) route = `/${route}`
|
|
59
|
+
route = route.replace(/\/+$/, '')
|
|
60
|
+
return route || '/'
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
@@ -42,10 +66,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
42
66
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
43
67
|
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
44
68
|
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const snapshotLight = props?.snapshotLight || null
|
|
48
|
-
const snapshotDark = props?.snapshotDark || null
|
|
69
|
+
const snapshotLight = props?.snapshotLight || ''
|
|
70
|
+
const snapshotDark = props?.snapshotDark || ''
|
|
49
71
|
|
|
50
72
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
51
73
|
const baseSegment = basePath.replace(/^\//, '')
|
|
@@ -58,73 +80,42 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
58
80
|
return `${basePath}${cleaned}`
|
|
59
81
|
}, [src, basePath, baseSegment])
|
|
60
82
|
|
|
61
|
-
const isExternal = /^https?:\/\//.test(rawSrc)
|
|
62
83
|
const scale = zoom / 100
|
|
63
84
|
|
|
64
85
|
const [editing, setEditing] = useState(false)
|
|
65
86
|
const [interactive, setInteractive] = useState(false)
|
|
87
|
+
const [showIframe, setShowIframe] = useState(false)
|
|
88
|
+
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
66
89
|
const [expanded, setExpanded] = useState(false)
|
|
67
90
|
const [filter, setFilter] = useState('')
|
|
68
|
-
const [canvasTheme, setCanvasTheme] = useState(() =>
|
|
69
|
-
|
|
70
|
-
// Lazy loading state — only use snapshots that match this widget's ID
|
|
71
|
-
const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
|
|
72
|
-
const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
|
|
73
|
-
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
74
|
-
const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
|
|
75
|
-
const hasSnapshot = !!currentSnapshot
|
|
76
|
-
|
|
77
|
-
// Sequential iframe queue — prevents stampede when many embeds lack snapshots.
|
|
78
|
-
// Widgets with snapshots skip the queue entirely; others load one at a time.
|
|
79
|
-
const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot || isExternal, widgetId)
|
|
80
|
-
const [preloadIframe, setPreloadIframe] = useState(hasSnapshot || isExternal)
|
|
81
|
-
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
82
|
-
const [showIframe, setShowIframe] = useState(hasSnapshot || isExternal)
|
|
83
|
-
const [showSpinner, setShowSpinner] = useState(false)
|
|
84
|
-
const capturingRef = useRef(false)
|
|
85
|
-
|
|
86
|
-
devLog(widgetId, { hasSnapshot, isExternal, queueReady, preloadIframe, showIframe, iframeLoaded, src })
|
|
87
|
-
|
|
88
|
-
// Start loading when the queue grants this widget a slot
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
if (queueReady && !preloadIframe) {
|
|
91
|
-
devLog(widgetId, 'queue ready → loading iframe')
|
|
92
|
-
setPreloadIframe(true)
|
|
93
|
-
setShowIframe(true)
|
|
94
|
-
}
|
|
95
|
-
}, [queueReady, preloadIframe])
|
|
96
|
-
|
|
97
|
-
// Release the queue slot once the iframe has loaded or user clicked to interact
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
if (iframeLoaded) {
|
|
100
|
-
devLog(widgetId, 'iframe loaded')
|
|
101
|
-
releaseSlot()
|
|
102
|
-
}
|
|
103
|
-
}, [iframeLoaded, releaseSlot])
|
|
104
|
-
|
|
105
|
-
// Click-to-interact: immediately start iframe and release queue slot for others
|
|
106
|
-
const activateIframe = useCallback(() => {
|
|
107
|
-
devLog(widgetId, 'user activated → jumping queue')
|
|
108
|
-
setShowIframe(true)
|
|
109
|
-
setPreloadIframe(true)
|
|
110
|
-
releaseSlot()
|
|
111
|
-
}, [releaseSlot])
|
|
112
|
-
|
|
113
|
-
// Show spinner only after 500ms of loading
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
116
|
-
const timer = setTimeout(() => setShowSpinner(true), 500)
|
|
117
|
-
return () => clearTimeout(timer)
|
|
118
|
-
}
|
|
119
|
-
setShowSpinner(false)
|
|
120
|
-
}, [showIframe, iframeLoaded, hasSnapshot])
|
|
91
|
+
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasTheme())
|
|
92
|
+
const [brokenSnaps, setBrokenSnaps] = useState({})
|
|
121
93
|
|
|
122
94
|
const inputRef = useRef(null)
|
|
123
95
|
const filterRef = useRef(null)
|
|
124
96
|
const embedRef = useRef(null)
|
|
125
97
|
const iframeRef = useRef(null)
|
|
98
|
+
const captureOnReadyRef = useRef(false)
|
|
99
|
+
const exitSessionRef = useRef(0)
|
|
126
100
|
const inlineContainerRef = useRef(null)
|
|
127
101
|
const modalContainerRef = useRef(null)
|
|
102
|
+
const resizeTimerRef = useRef(null)
|
|
103
|
+
const prevInteractiveRef = useRef(false)
|
|
104
|
+
|
|
105
|
+
// Snapshot capture hook — only active in dev mode (onUpdate present)
|
|
106
|
+
const isExternal = /^https?:\/\//.test(src || '')
|
|
107
|
+
const { iframeReady, requestCapture } = useSnapshotCapture({
|
|
108
|
+
iframeRef,
|
|
109
|
+
widgetId,
|
|
110
|
+
onUpdate: isExternal ? null : onUpdate,
|
|
111
|
+
canvasTheme,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Determine available snapshots for layered rendering
|
|
115
|
+
const isDark = canvasTheme?.startsWith('dark')
|
|
116
|
+
const hasLightSnap = !isExternal && !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
|
|
117
|
+
const hasDarkSnap = !isExternal && !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
|
|
118
|
+
const hasAnySnap = hasLightSnap || hasDarkSnap
|
|
128
119
|
|
|
129
120
|
const iframeSrc = useMemo(() => {
|
|
130
121
|
if (!rawSrc) return ''
|
|
@@ -137,6 +128,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
137
128
|
return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
138
129
|
}, [rawSrc, canvasTheme])
|
|
139
130
|
|
|
131
|
+
useIframeDevLogs({
|
|
132
|
+
widget: 'PrototypeEmbed',
|
|
133
|
+
loaded: showIframe && Boolean(iframeSrc),
|
|
134
|
+
src: iframeSrc,
|
|
135
|
+
})
|
|
136
|
+
|
|
140
137
|
// Build prototype index for the picker
|
|
141
138
|
const prototypeIndex = useMemo(() => {
|
|
142
139
|
try {
|
|
@@ -151,16 +148,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
151
148
|
const groups = []
|
|
152
149
|
const idx = prototypeIndex
|
|
153
150
|
|
|
154
|
-
|
|
155
|
-
const allProtos = []
|
|
156
|
-
for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
|
|
157
|
-
for (const proto of folder.prototypes || []) {
|
|
158
|
-
if (!proto.isExternal) allProtos.push(proto)
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
|
|
162
|
-
if (!proto.isExternal) allProtos.push(proto)
|
|
163
|
-
}
|
|
151
|
+
const allProtos = listInternalPrototypes(idx)
|
|
164
152
|
|
|
165
153
|
for (const proto of allProtos) {
|
|
166
154
|
if (proto.hideFlows && proto.flows.length === 1) {
|
|
@@ -216,6 +204,35 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
216
204
|
.filter(Boolean)
|
|
217
205
|
}, [pickerGroups, filter])
|
|
218
206
|
|
|
207
|
+
const prototypeName = useMemo(() => {
|
|
208
|
+
const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
|
|
209
|
+
if (!currentRoute) return ''
|
|
210
|
+
|
|
211
|
+
let bestMatchName = ''
|
|
212
|
+
let bestMatchLength = -1
|
|
213
|
+
|
|
214
|
+
for (const proto of listInternalPrototypes(prototypeIndex)) {
|
|
215
|
+
const candidateRoutes = [
|
|
216
|
+
`/${proto.dirName}`,
|
|
217
|
+
...(proto.flows || []).map((flow) => flow.route),
|
|
218
|
+
]
|
|
219
|
+
for (const candidate of candidateRoutes) {
|
|
220
|
+
const candidateRoute = normalizeRoutePath(candidate, basePath)
|
|
221
|
+
if (!candidateRoute || candidateRoute === '/') continue
|
|
222
|
+
if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
|
|
223
|
+
if (candidateRoute.length > bestMatchLength) {
|
|
224
|
+
bestMatchLength = candidateRoute.length
|
|
225
|
+
bestMatchName = proto.name || ''
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return bestMatchName
|
|
232
|
+
}, [prototypeIndex, src, rawSrc, basePath])
|
|
233
|
+
|
|
234
|
+
const prototypeTitle = prototypeName || label || 'Prototype'
|
|
235
|
+
|
|
219
236
|
const hasPicker = pickerGroups.length > 0
|
|
220
237
|
|
|
221
238
|
useEffect(() => {
|
|
@@ -227,27 +244,64 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
227
244
|
}
|
|
228
245
|
}, [editing, hasPicker])
|
|
229
246
|
|
|
230
|
-
// Exit interactive mode when clicking outside the embed
|
|
231
247
|
useEffect(() => {
|
|
232
|
-
if (!
|
|
248
|
+
if (!showIframe) setIframeLoaded(false)
|
|
249
|
+
}, [showIframe])
|
|
250
|
+
|
|
251
|
+
// Exit interactive mode when clicking outside the embed.
|
|
252
|
+
// Hides iframe immediately for a responsive feel, then captures
|
|
253
|
+
// snapshots in the background with the iframe hidden but still mounted.
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!interactive || expanded) return
|
|
233
256
|
function handlePointerDown(e) {
|
|
234
257
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
258
|
+
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
259
|
+
if (chromeEl) return
|
|
260
|
+
|
|
235
261
|
setInteractive(false)
|
|
262
|
+
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
263
|
+
// Keep iframe mounted but hidden for background capture
|
|
264
|
+
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
265
|
+
const session = ++exitSessionRef.current
|
|
266
|
+
requestCapture({ force: true }).then(() => {
|
|
267
|
+
if (exitSessionRef.current !== session) return
|
|
268
|
+
setShowIframe(false)
|
|
269
|
+
})
|
|
270
|
+
} else {
|
|
271
|
+
setShowIframe(false)
|
|
272
|
+
}
|
|
236
273
|
}
|
|
237
274
|
}
|
|
238
275
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
239
276
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
240
|
-
}, [interactive])
|
|
277
|
+
}, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
|
|
241
278
|
|
|
242
279
|
useEffect(() => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
document.addEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
248
|
-
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
280
|
+
const readTheme = () => setCanvasTheme(resolveCanvasTheme())
|
|
281
|
+
readTheme()
|
|
282
|
+
document.addEventListener('storyboard:theme:changed', readTheme)
|
|
283
|
+
return () => document.removeEventListener('storyboard:theme:changed', readTheme)
|
|
249
284
|
}, [])
|
|
250
285
|
|
|
286
|
+
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!iframeReady || !onUpdate || isExternal) return
|
|
289
|
+
if (!hasAnySnap) {
|
|
290
|
+
requestCapture()
|
|
291
|
+
}
|
|
292
|
+
}, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
293
|
+
|
|
294
|
+
// Capture when iframe becomes ready after refresh-thumbnail requested it
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (iframeReady && captureOnReadyRef.current) {
|
|
297
|
+
captureOnReadyRef.current = false
|
|
298
|
+
requestCapture()
|
|
299
|
+
}
|
|
300
|
+
}, [iframeReady, requestCapture])
|
|
301
|
+
|
|
302
|
+
// Cleanup resize timer on unmount
|
|
303
|
+
useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
|
|
304
|
+
|
|
251
305
|
// Close expanded modal on Escape
|
|
252
306
|
useEffect(() => {
|
|
253
307
|
if (!expanded) return
|
|
@@ -311,89 +365,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
311
365
|
}
|
|
312
366
|
return
|
|
313
367
|
}
|
|
314
|
-
|
|
315
|
-
// Snapshot capture responses
|
|
316
|
-
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
317
|
-
if (e.data.error) {
|
|
318
|
-
console.warn('[canvas] Snapshot capture failed:', e.data.error)
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
handleSnapshotResult(e.data.requestId, e.data.dataUrl)
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Snapshot-ready signal — iframe content has fully rendered
|
|
326
|
-
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
327
|
-
setIframeLoaded(true)
|
|
328
|
-
if (onUpdate && !isExternal) requestSnapshotCapture()
|
|
329
|
-
}
|
|
330
368
|
}
|
|
331
369
|
window.addEventListener('message', handleMessage)
|
|
332
370
|
return () => window.removeEventListener('message', handleMessage)
|
|
333
|
-
}, [src, props, onUpdate
|
|
334
|
-
|
|
335
|
-
// Request a snapshot capture from the iframe
|
|
336
|
-
const requestSnapshotCapture = useCallback(() => {
|
|
337
|
-
if (!iframeRef.current?.contentWindow || capturingRef.current || isExternal) return
|
|
338
|
-
capturingRef.current = true
|
|
339
|
-
const requestId = `snap-${Date.now()}`
|
|
340
|
-
iframeRef.current.contentWindow.postMessage({
|
|
341
|
-
type: 'storyboard:embed:capture',
|
|
342
|
-
requestId,
|
|
343
|
-
}, '*')
|
|
344
|
-
}, [isExternal])
|
|
345
|
-
|
|
346
|
-
// Handle a completed snapshot — upload and persist as widget prop
|
|
347
|
-
const handleSnapshotResult = useCallback(async (requestId, dataUrl) => {
|
|
348
|
-
if (!dataUrl || !onUpdate || !widgetId) return
|
|
349
|
-
capturingRef.current = false
|
|
350
|
-
try {
|
|
351
|
-
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
|
|
352
|
-
if (!result?.success || !result?.filename) return
|
|
353
|
-
const imageUrl = `/_storyboard/canvas/images/${result.filename}`
|
|
354
|
-
const themeKey = canvasTheme?.startsWith('dark') ? 'snapshotDark' : 'snapshotLight'
|
|
355
|
-
onUpdate?.({ [themeKey]: imageUrl })
|
|
356
|
-
} catch (err) {
|
|
357
|
-
console.warn('[canvas] Failed to upload snapshot:', err)
|
|
358
|
-
}
|
|
359
|
-
}, [onUpdate, canvasTheme, widgetId])
|
|
360
|
-
|
|
361
|
-
// Re-capture snapshots after resize (debounced)
|
|
362
|
-
const resizeCaptureTimer = useRef(null)
|
|
363
|
-
const triggerResizeCapture = useCallback(() => {
|
|
364
|
-
if (!onUpdate || isExternal) return
|
|
365
|
-
clearTimeout(resizeCaptureTimer.current)
|
|
366
|
-
resizeCaptureTimer.current = setTimeout(() => {
|
|
367
|
-
requestSnapshotCapture()
|
|
368
|
-
}, 2000)
|
|
369
|
-
}, [requestSnapshotCapture, isExternal, onUpdate])
|
|
370
|
-
|
|
371
|
-
// Re-capture when src changes (new prototype selected)
|
|
372
|
-
const prevSrcRef = useRef(src)
|
|
373
|
-
useEffect(() => {
|
|
374
|
-
if (src && src !== prevSrcRef.current && onUpdate && !isExternal && showIframe) {
|
|
375
|
-
prevSrcRef.current = src
|
|
376
|
-
// Wait for the new page to render
|
|
377
|
-
const timer = setTimeout(() => requestSnapshotCapture(), 4000)
|
|
378
|
-
return () => clearTimeout(timer)
|
|
379
|
-
}
|
|
380
|
-
prevSrcRef.current = src
|
|
381
|
-
}, [src, onUpdate, isExternal, showIframe, requestSnapshotCapture])
|
|
382
|
-
|
|
383
|
-
// Re-capture for the alternate theme variant when theme changes
|
|
384
|
-
const prevThemeRef = useRef(canvasTheme)
|
|
385
|
-
useEffect(() => {
|
|
386
|
-
if (canvasTheme !== prevThemeRef.current && onUpdate && !isExternal && showIframe) {
|
|
387
|
-
prevThemeRef.current = canvasTheme
|
|
388
|
-
const timer = setTimeout(() => requestSnapshotCapture(), 3000)
|
|
389
|
-
return () => clearTimeout(timer)
|
|
390
|
-
}
|
|
391
|
-
prevThemeRef.current = canvasTheme
|
|
392
|
-
}, [canvasTheme, onUpdate, isExternal, showIframe, requestSnapshotCapture])
|
|
371
|
+
}, [src, props, onUpdate])
|
|
393
372
|
|
|
394
373
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
395
374
|
|
|
396
|
-
const enterInteractive = useCallback(() =>
|
|
375
|
+
const enterInteractive = useCallback(() => {
|
|
376
|
+
exitSessionRef.current++
|
|
377
|
+
setShowIframe(true)
|
|
378
|
+
setInteractive(true)
|
|
379
|
+
}, [])
|
|
397
380
|
|
|
398
381
|
// Expose imperative action handlers for WidgetChrome
|
|
399
382
|
useImperativeHandle(ref, () => ({
|
|
@@ -401,12 +384,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
401
384
|
if (actionId === 'edit') {
|
|
402
385
|
setEditing(true)
|
|
403
386
|
} else if (actionId === 'expand') {
|
|
387
|
+
setShowIframe(true)
|
|
404
388
|
setExpanded(true)
|
|
405
389
|
} else if (actionId === 'open-external') {
|
|
406
390
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
391
|
+
} else if (actionId === 'refresh-thumbnail') {
|
|
392
|
+
if (iframeReady && iframeRef.current?.contentWindow) {
|
|
393
|
+
requestCapture()
|
|
394
|
+
} else {
|
|
395
|
+
captureOnReadyRef.current = true
|
|
396
|
+
setShowIframe(true)
|
|
397
|
+
}
|
|
407
398
|
}
|
|
408
399
|
},
|
|
409
|
-
}), [rawSrc])
|
|
400
|
+
}), [rawSrc, iframeReady, requestCapture])
|
|
410
401
|
|
|
411
402
|
function handlePickRoute(route) {
|
|
412
403
|
onUpdate?.({ src: route })
|
|
@@ -435,6 +426,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
435
426
|
className={styles.embed}
|
|
436
427
|
style={{ width, height, ...chromeVars }}
|
|
437
428
|
>
|
|
429
|
+
<div className={styles.header}>
|
|
430
|
+
<span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
|
|
431
|
+
<span className={styles.headerTitle}>{prototypeTitle}</span>
|
|
432
|
+
</div>
|
|
438
433
|
{editing ? (
|
|
439
434
|
<div
|
|
440
435
|
className={styles.pickerPanel}
|
|
@@ -518,35 +513,35 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
518
513
|
</div>
|
|
519
514
|
) : iframeSrc ? (
|
|
520
515
|
<>
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
516
|
+
<div
|
|
517
|
+
ref={inlineContainerRef}
|
|
518
|
+
className={styles.iframeContainer}
|
|
519
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
520
|
+
>
|
|
521
|
+
{/* Snapshot layer — both themes always in DOM for instant swap */}
|
|
522
|
+
{hasLightSnap && (
|
|
524
523
|
<img
|
|
525
|
-
src={
|
|
526
|
-
alt={label || 'Prototype preview'}
|
|
524
|
+
src={snapshotLight}
|
|
527
525
|
className={styles.snapshotImage}
|
|
528
|
-
style={{
|
|
526
|
+
style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
|
|
527
|
+
alt={`${prototypeTitle} snapshot`}
|
|
529
528
|
draggable={false}
|
|
529
|
+
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
|
|
530
530
|
/>
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
531
|
+
)}
|
|
532
|
+
{hasDarkSnap && (
|
|
533
|
+
<img
|
|
534
|
+
src={snapshotDark}
|
|
535
|
+
className={styles.snapshotImage}
|
|
536
|
+
style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
|
|
537
|
+
alt={`${prototypeTitle} snapshot`}
|
|
538
|
+
draggable={false}
|
|
539
|
+
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotDark]: true }))}
|
|
540
|
+
/>
|
|
541
|
+
)}
|
|
538
542
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
<div
|
|
542
|
-
ref={inlineContainerRef}
|
|
543
|
-
className={styles.iframeContainer}
|
|
544
|
-
style={
|
|
545
|
-
expanded ? { visibility: 'hidden' }
|
|
546
|
-
: (hasSnapshot && !(showIframe && iframeLoaded)) ? { position: 'absolute', top: 0, left: 0, opacity: 0, pointerEvents: 'none' }
|
|
547
|
-
: undefined
|
|
548
|
-
}
|
|
549
|
-
>
|
|
543
|
+
{/* Iframe layer — on top, transparent until loaded */}
|
|
544
|
+
{showIframe && (
|
|
550
545
|
<iframe
|
|
551
546
|
ref={iframeRef}
|
|
552
547
|
src={iframeSrc}
|
|
@@ -556,22 +551,28 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
556
551
|
height: height / scale,
|
|
557
552
|
transform: `scale(${scale})`,
|
|
558
553
|
transformOrigin: '0 0',
|
|
554
|
+
...(iframeLoaded ? {} : { opacity: 0 }),
|
|
559
555
|
}}
|
|
560
|
-
|
|
556
|
+
onLoad={() => setIframeLoaded(true)}
|
|
557
|
+
title={`${prototypeTitle} prototype`}
|
|
561
558
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
562
559
|
/>
|
|
563
|
-
|
|
564
|
-
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{/* Placeholder — only when no snapshots and no iframe */}
|
|
563
|
+
{!hasAnySnap && !showIframe && (
|
|
564
|
+
<div className={styles.placeholder}>
|
|
565
|
+
<CollageFrameIcon size={36} />
|
|
566
|
+
<span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
</div>
|
|
565
570
|
|
|
566
571
|
{!interactive && !expanded && (
|
|
567
572
|
<div
|
|
568
573
|
className={overlayStyles.interactOverlay}
|
|
569
|
-
onPointerEnter={() => {
|
|
570
|
-
if (!preloadIframe) setPreloadIframe(true)
|
|
571
|
-
}}
|
|
572
574
|
onClick={(e) => {
|
|
573
575
|
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
574
|
-
activateIframe()
|
|
575
576
|
enterInteractive()
|
|
576
577
|
}}
|
|
577
578
|
role="button"
|
|
@@ -580,7 +581,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
580
581
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
581
582
|
e.preventDefault()
|
|
582
583
|
e.stopPropagation()
|
|
583
|
-
activateIframe()
|
|
584
584
|
enterInteractive()
|
|
585
585
|
}
|
|
586
586
|
}}
|
|
@@ -620,7 +620,9 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
620
620
|
function onUp() {
|
|
621
621
|
document.removeEventListener('mousemove', onMove)
|
|
622
622
|
document.removeEventListener('mouseup', onUp)
|
|
623
|
-
|
|
623
|
+
// Recapture snapshot after resize (debounced)
|
|
624
|
+
clearTimeout(resizeTimerRef.current)
|
|
625
|
+
resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
|
|
624
626
|
}
|
|
625
627
|
document.addEventListener('mousemove', onMove)
|
|
626
628
|
document.addEventListener('mouseup', onUp)
|
|
@@ -7,49 +7,91 @@
|
|
|
7
7
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
.
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 10px 10px;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
color: var(--fgColor-muted, #656d76);
|
|
18
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
19
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
20
|
+
white-space: nowrap;
|
|
13
21
|
overflow: hidden;
|
|
22
|
+
text-overflow: ellipsis;
|
|
23
|
+
user-select: none;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
.headerIcon {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
flex-shrink: 0;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
.headerTitle {
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
text-overflow: ellipsis;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.iframeContainer {
|
|
37
|
+
position: relative;
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: calc(100% - 37px);
|
|
40
|
+
overflow: hidden;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
|
-
.
|
|
43
|
+
.placeholder {
|
|
28
44
|
position: absolute;
|
|
29
45
|
inset: 0;
|
|
30
46
|
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
31
48
|
align-items: center;
|
|
32
49
|
justify-content: center;
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
gap: 8px;
|
|
51
|
+
color: var(--fgColor-muted, #656d76);
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.placeholderIcon {
|
|
56
|
+
width: 36px;
|
|
57
|
+
height: 36px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.placeholderLabel {
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
font-weight: 500;
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
.spinner {
|
|
38
66
|
width: 24px;
|
|
39
67
|
height: 24px;
|
|
40
|
-
border:
|
|
41
|
-
border-top-color: var(--fgColor-accent, #
|
|
68
|
+
border: 3px solid var(--borderColor-muted, #d0d7de);
|
|
69
|
+
border-top-color: var(--fgColor-accent, #2f81f7);
|
|
42
70
|
border-radius: 50%;
|
|
43
|
-
animation: spin 0.
|
|
71
|
+
animation: spin 0.8s linear infinite;
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
@keyframes spin {
|
|
75
|
+
from { transform: rotate(0deg); }
|
|
47
76
|
to { transform: rotate(360deg); }
|
|
48
77
|
}
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
.iframe {
|
|
80
|
+
border: none;
|
|
81
|
+
display: block;
|
|
82
|
+
position: relative;
|
|
83
|
+
z-index: 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.snapshotImage {
|
|
87
|
+
position: absolute;
|
|
88
|
+
inset: 0;
|
|
89
|
+
width: 100%;
|
|
90
|
+
height: 100%;
|
|
91
|
+
object-fit: cover;
|
|
92
|
+
object-position: top left;
|
|
93
|
+
display: block;
|
|
94
|
+
pointer-events: none;
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
.empty {
|