@dfosco/storyboard-react 4.0.0-beta.33 → 4.0.0-beta.35
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/widgets/PrototypeEmbed.jsx +110 -435
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.35",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.35",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -2,11 +2,10 @@ import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImper
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
5
6
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
|
-
import { getEmbedChromeVars
|
|
7
|
+
import { getEmbedChromeVars } from './embedTheme.js'
|
|
7
8
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
|
-
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
9
|
-
import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
|
|
10
9
|
import styles from './PrototypeEmbed.module.css'
|
|
11
10
|
import overlayStyles from './embedOverlay.module.css'
|
|
12
11
|
|
|
@@ -26,39 +25,20 @@ function formatName(name) {
|
|
|
26
25
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
for (const proto of standaloneList) {
|
|
46
|
-
if (!proto.isExternal) allProtos.push(proto)
|
|
47
|
-
}
|
|
48
|
-
return allProtos
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeRoutePath(value, basePath = '') {
|
|
52
|
-
if (!value || /^https?:\/\//.test(value)) return ''
|
|
53
|
-
const noHash = value.split('#')[0]
|
|
54
|
-
let route = noHash.split('?')[0]
|
|
55
|
-
route = route.replace(/^\/branch--[^/]+/, '')
|
|
56
|
-
if (basePath && route.startsWith(basePath)) {
|
|
57
|
-
route = route.slice(basePath.length) || '/'
|
|
58
|
-
}
|
|
59
|
-
if (!route.startsWith('/')) route = `/${route}`
|
|
60
|
-
route = route.replace(/\/+$/, '')
|
|
61
|
-
return route || '/'
|
|
28
|
+
function resolveCanvasThemeFromStorage() {
|
|
29
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
30
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
|
|
31
|
+
try {
|
|
32
|
+
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
33
|
+
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
34
|
+
} catch { /* */ }
|
|
35
|
+
if (!sync.canvas) return 'light'
|
|
36
|
+
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
37
|
+
if (attrTheme) return attrTheme
|
|
38
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
39
|
+
if (stored !== 'system') return stored
|
|
40
|
+
return typeof window.matchMedia === 'function' &&
|
|
41
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
62
42
|
}
|
|
63
43
|
|
|
64
44
|
export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
@@ -67,7 +47,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
67
47
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
68
48
|
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
69
49
|
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
70
|
-
const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
|
|
71
50
|
|
|
72
51
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
73
52
|
const baseSegment = basePath.replace(/^\//, '')
|
|
@@ -81,119 +60,63 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
81
60
|
}, [src, basePath, baseSegment])
|
|
82
61
|
|
|
83
62
|
const scale = zoom / 100
|
|
63
|
+
const isExternal = /^https?:\/\//.test(src || '')
|
|
84
64
|
|
|
85
65
|
const [editing, setEditing] = useState(false)
|
|
86
|
-
const [interactive,
|
|
87
|
-
const [showIframe, _setShowIframe] = useState(false)
|
|
88
|
-
const [iframeLoaded, _setIframeLoaded] = useState(false)
|
|
66
|
+
const [interactive, setInteractive] = useState(false)
|
|
89
67
|
const [expanded, setExpanded] = useState(false)
|
|
90
68
|
const [filter, setFilter] = useState('')
|
|
91
|
-
const [canvasTheme,
|
|
92
|
-
const [brokenSnaps, _setBrokenSnaps] = useState({})
|
|
93
|
-
|
|
94
|
-
// ── Debug logging wrappers ──
|
|
95
|
-
const setInteractive = (v) => { console.log(`[embed:${widgetId}] setInteractive →`, v); _setInteractive(v) }
|
|
96
|
-
const setShowIframe = (v) => { if (v) console.trace(`[embed:${widgetId}] setShowIframe → TRUE`); else console.log(`[embed:${widgetId}] setShowIframe → false`); _setShowIframe(v) }
|
|
97
|
-
const setIframeLoaded = (v) => { console.log(`[embed:${widgetId}] iframeLoaded →`, v); _setIframeLoaded(v) }
|
|
98
|
-
const setCanvasTheme = (v) => { console.log(`[embed:${widgetId}] canvasTheme →`, v); _setCanvasTheme(v) }
|
|
99
|
-
const setBrokenSnaps = (fn) => { console.log(`[embed:${widgetId}] setBrokenSnaps`); _setBrokenSnaps(fn) }
|
|
100
|
-
|
|
69
|
+
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
101
70
|
const inputRef = useRef(null)
|
|
102
71
|
const filterRef = useRef(null)
|
|
103
72
|
const embedRef = useRef(null)
|
|
104
73
|
const iframeRef = useRef(null)
|
|
105
|
-
const captureOnReadyRef = useRef(false)
|
|
106
|
-
const exitSessionRef = useRef(0)
|
|
107
|
-
const teardownTimerRef = useRef(null)
|
|
108
74
|
const inlineContainerRef = useRef(null)
|
|
109
75
|
const modalContainerRef = useRef(null)
|
|
110
|
-
const resizeTimerRef = useRef(null)
|
|
111
|
-
const prevInteractiveRef = useRef(false)
|
|
112
|
-
|
|
113
|
-
// Snapshot capture hook — only active in dev mode (onUpdate present)
|
|
114
|
-
const isExternal = /^https?:\/\//.test(src || '')
|
|
115
|
-
const { iframeReady, requestCapture } = useSnapshotCapture({
|
|
116
|
-
iframeRef,
|
|
117
|
-
widgetId,
|
|
118
|
-
onUpdate: isExternal ? null : onUpdate,
|
|
119
|
-
showIframe,
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
// Single snapshot — backward compat reads snapshotLight/snapshotDark if snapshot is missing
|
|
123
|
-
const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
|
|
124
|
-
console.log(`[embed:${widgetId}] render: hasSnap=${hasSnap}, showIframe=${showIframe}, interactive=${interactive}, canvasTheme=${canvasTheme}, snapshot=${snapshot ? snapshot.slice(0, 50) : 'null'}, broken=${!!brokenSnaps[snapshot]}`)
|
|
125
76
|
|
|
126
77
|
const iframeSrc = useMemo(() => {
|
|
127
78
|
if (!rawSrc) return ''
|
|
128
|
-
// External URLs are embedded as-is — storyboard query params only apply to local prototypes
|
|
129
79
|
if (/^https?:\/\//.test(rawSrc)) return rawSrc
|
|
130
80
|
const hashIdx = rawSrc.indexOf('#')
|
|
131
81
|
const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
|
|
132
82
|
const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
|
|
133
83
|
const sep = base.includes('?') ? '&' : '?'
|
|
134
|
-
return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
|
|
135
|
-
}, [rawSrc])
|
|
84
|
+
return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
85
|
+
}, [rawSrc, canvasTheme])
|
|
136
86
|
|
|
137
|
-
useIframeDevLogs({
|
|
138
|
-
widget: 'PrototypeEmbed',
|
|
139
|
-
loaded: showIframe && Boolean(iframeSrc),
|
|
140
|
-
src: iframeSrc,
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
// Build prototype index for the picker
|
|
144
87
|
const prototypeIndex = useMemo(() => {
|
|
145
|
-
try {
|
|
146
|
-
|
|
147
|
-
} catch {
|
|
148
|
-
return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }
|
|
149
|
-
}
|
|
88
|
+
try { return buildPrototypeIndex() }
|
|
89
|
+
catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
|
|
150
90
|
}, [])
|
|
151
91
|
|
|
152
|
-
// Build grouped picker entries from the prototype index
|
|
153
92
|
const pickerGroups = useMemo(() => {
|
|
154
93
|
const groups = []
|
|
155
94
|
const idx = prototypeIndex
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
95
|
+
const allProtos = []
|
|
96
|
+
for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
|
|
97
|
+
for (const proto of folder.prototypes || []) {
|
|
98
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
|
|
102
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
103
|
+
}
|
|
159
104
|
for (const proto of allProtos) {
|
|
160
105
|
if (proto.hideFlows && proto.flows.length === 1) {
|
|
161
|
-
groups.push({
|
|
162
|
-
label: proto.name,
|
|
163
|
-
items: [{ name: proto.name, route: proto.flows[0].route }],
|
|
164
|
-
})
|
|
106
|
+
groups.push({ label: proto.name, items: [{ name: proto.name, route: proto.flows[0].route }] })
|
|
165
107
|
} else if (proto.flows.length > 0) {
|
|
166
|
-
groups.push({
|
|
167
|
-
label: proto.name,
|
|
168
|
-
items: proto.flows.map((f) => ({
|
|
169
|
-
name: f.meta?.title || formatName(f.name),
|
|
170
|
-
route: f.route,
|
|
171
|
-
})),
|
|
172
|
-
})
|
|
108
|
+
groups.push({ label: proto.name, items: proto.flows.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
|
|
173
109
|
} else {
|
|
174
|
-
groups.push({
|
|
175
|
-
label: proto.name,
|
|
176
|
-
items: [{ name: proto.name, route: `/${proto.dirName}` }],
|
|
177
|
-
})
|
|
110
|
+
groups.push({ label: proto.name, items: [{ name: proto.name, route: `/${proto.dirName}` }] })
|
|
178
111
|
}
|
|
179
112
|
}
|
|
180
|
-
|
|
181
|
-
// Global flows
|
|
182
113
|
const gf = idx.globalFlows || []
|
|
183
114
|
if (gf.length > 0) {
|
|
184
|
-
groups.push({
|
|
185
|
-
label: 'Other flows',
|
|
186
|
-
items: gf.map((f) => ({
|
|
187
|
-
name: f.meta?.title || formatName(f.name),
|
|
188
|
-
route: f.route,
|
|
189
|
-
})),
|
|
190
|
-
})
|
|
115
|
+
groups.push({ label: 'Other flows', items: gf.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
|
|
191
116
|
}
|
|
192
|
-
|
|
193
117
|
return groups
|
|
194
118
|
}, [prototypeIndex])
|
|
195
119
|
|
|
196
|
-
// Filter groups by search text
|
|
197
120
|
const filteredGroups = useMemo(() => {
|
|
198
121
|
if (!filter) return pickerGroups
|
|
199
122
|
const q = filter.toLowerCase()
|
|
@@ -211,36 +134,26 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
211
134
|
}, [pickerGroups, filter])
|
|
212
135
|
|
|
213
136
|
const prototypeName = useMemo(() => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
for (const proto of listInternalPrototypes(prototypeIndex)) {
|
|
221
|
-
const candidateRoutes = [
|
|
222
|
-
`/${proto.dirName}`,
|
|
223
|
-
...(proto.flows || []).map((flow) => flow.route),
|
|
224
|
-
]
|
|
225
|
-
for (const candidate of candidateRoutes) {
|
|
226
|
-
const candidateRoute = normalizeRoutePath(candidate, basePath)
|
|
227
|
-
if (!candidateRoute || candidateRoute === '/') continue
|
|
228
|
-
if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
|
|
229
|
-
if (candidateRoute.length > bestMatchLength) {
|
|
230
|
-
bestMatchLength = candidateRoute.length
|
|
231
|
-
bestMatchName = proto.name || ''
|
|
232
|
-
}
|
|
233
|
-
}
|
|
137
|
+
if (!src) return ''
|
|
138
|
+
for (const group of pickerGroups) {
|
|
139
|
+
for (const item of group.items) {
|
|
140
|
+
const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
|
|
141
|
+
const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
|
|
142
|
+
if (cleanRoute === cleanSrc) return item.name
|
|
234
143
|
}
|
|
235
144
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}, [prototypeIndex, src, rawSrc, basePath])
|
|
145
|
+
return ''
|
|
146
|
+
}, [src, pickerGroups])
|
|
239
147
|
|
|
240
148
|
const prototypeTitle = prototypeName || label || 'Prototype'
|
|
241
|
-
|
|
242
149
|
const hasPicker = pickerGroups.length > 0
|
|
243
150
|
|
|
151
|
+
useIframeDevLogs({
|
|
152
|
+
widget: 'PrototypeEmbed',
|
|
153
|
+
loaded: Boolean(iframeSrc && interactive),
|
|
154
|
+
src: iframeSrc,
|
|
155
|
+
})
|
|
156
|
+
|
|
244
157
|
useEffect(() => {
|
|
245
158
|
if (editing && hasPicker && filterRef.current) {
|
|
246
159
|
filterRef.current.focus()
|
|
@@ -250,144 +163,27 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
250
163
|
}
|
|
251
164
|
}, [editing, hasPicker])
|
|
252
165
|
|
|
253
|
-
|
|
254
|
-
console.log(`[embed:${widgetId}] effect:showIframe →`, showIframe)
|
|
255
|
-
if (!showIframe) setIframeLoaded(false)
|
|
256
|
-
}, [showIframe])
|
|
257
|
-
|
|
258
|
-
// Exit interactive mode when clicking outside the embed.
|
|
259
|
-
// Hides iframe immediately for a responsive feel, then captures
|
|
260
|
-
// snapshots in the background with the iframe hidden but still mounted.
|
|
166
|
+
// Exit interactive mode when clicking outside the embed
|
|
261
167
|
useEffect(() => {
|
|
262
168
|
if (!interactive || expanded) return
|
|
263
|
-
console.log(`[embed:${widgetId}] effect:exit-interactive listener attached`)
|
|
264
169
|
function handlePointerDown(e) {
|
|
265
170
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
266
171
|
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
267
172
|
if (chromeEl) return
|
|
268
|
-
|
|
269
|
-
console.log(`[embed:${widgetId}] exit-interactive: pointerdown outside, iframeLoaded=${iframeLoaded}, hasContentWindow=${!!iframeRef.current?.contentWindow}`)
|
|
270
173
|
setInteractive(false)
|
|
271
|
-
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
272
|
-
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
273
|
-
const session = ++exitSessionRef.current
|
|
274
|
-
console.log(`[embed:${widgetId}] exit-interactive: starting capture, session=${session}`)
|
|
275
|
-
setTimeout(() => {
|
|
276
|
-
if (exitSessionRef.current !== session) { console.log(`[embed:${widgetId}] exit-interactive: stale session ${session}, current=${exitSessionRef.current}`); return }
|
|
277
|
-
requestCapture({ force: true }).then((updates) => {
|
|
278
|
-
if (exitSessionRef.current !== session) { console.log(`[embed:${widgetId}] exit-interactive: stale session after capture`); return }
|
|
279
|
-
const snap = updates?.snapshot
|
|
280
|
-
console.log(`[embed:${widgetId}] exit-interactive: capture done, snap=${snap ? 'yes(' + snap.length + ')' : 'null'}`)
|
|
281
|
-
if (snap) {
|
|
282
|
-
const img = new Image()
|
|
283
|
-
const done = () => {
|
|
284
|
-
if (exitSessionRef.current === session) setShowIframe(false)
|
|
285
|
-
}
|
|
286
|
-
img.onload = done
|
|
287
|
-
img.onerror = done
|
|
288
|
-
img.src = snap
|
|
289
|
-
setTimeout(done, 2000)
|
|
290
|
-
} else {
|
|
291
|
-
setShowIframe(false)
|
|
292
|
-
}
|
|
293
|
-
})
|
|
294
|
-
}, 0)
|
|
295
|
-
} else if (isExternal && showIframe) {
|
|
296
|
-
// External embeds (e.g. Figma) are slow to reload — keep the
|
|
297
|
-
// iframe mounted for 2 min so re-entering is instant.
|
|
298
|
-
const session = ++exitSessionRef.current
|
|
299
|
-
clearTimeout(teardownTimerRef.current)
|
|
300
|
-
teardownTimerRef.current = setTimeout(() => {
|
|
301
|
-
if (exitSessionRef.current !== session) return
|
|
302
|
-
setShowIframe(false)
|
|
303
|
-
}, 2 * 60 * 1000)
|
|
304
|
-
} else {
|
|
305
|
-
setShowIframe(false)
|
|
306
|
-
}
|
|
307
174
|
}
|
|
308
175
|
}
|
|
309
176
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
310
177
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
311
|
-
}, [interactive, expanded,
|
|
312
|
-
|
|
313
|
-
useEffect(() => subscribeCanvasTheme({
|
|
314
|
-
anchorRef: embedRef,
|
|
315
|
-
onTheme: setCanvasTheme,
|
|
316
|
-
}), [])
|
|
178
|
+
}, [interactive, expanded, widgetId])
|
|
317
179
|
|
|
318
|
-
// On canvas theme change, enqueue a background snapshot refresh.
|
|
319
|
-
// Skips the initial render (canvasThemeInitRef tracks first value).
|
|
320
|
-
// Uses a ref to check hasSnap at callback time (not closure time).
|
|
321
|
-
const canvasThemeInitRef = useRef(true)
|
|
322
|
-
const refreshMetaRef = useRef(null)
|
|
323
|
-
const hasSnapRef = useRef(hasSnap)
|
|
324
|
-
hasSnapRef.current = hasSnap
|
|
325
180
|
useEffect(() => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
console.log(`[embed:${widgetId}] theme-effect: enqueue refresh, hasSnap=${hasSnap}, hasSnapRef=${hasSnapRef.current}`)
|
|
329
|
-
const rect = embedRef.current?.getBoundingClientRect()
|
|
330
|
-
enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
|
|
331
|
-
console.log(`[embed:${widgetId}] refresh-callback: hasSnapRef=${hasSnapRef.current}`)
|
|
332
|
-
if (!hasSnapRef.current) { console.log(`[embed:${widgetId}] refresh-callback: ABORT, no snap`); return Promise.resolve(false) }
|
|
333
|
-
return new Promise((resolve) => {
|
|
334
|
-
refreshMetaRef.current = { revealOrder, batchStart, resolve }
|
|
335
|
-
captureOnReadyRef.current = true
|
|
336
|
-
setShowIframe(true)
|
|
337
|
-
// Safety timeout — report failure so retry pass picks it up
|
|
338
|
-
setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
|
|
339
|
-
})
|
|
340
|
-
}, rect ? { x: rect.left, y: rect.top } : undefined)
|
|
341
|
-
}, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
342
|
-
|
|
343
|
-
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
344
|
-
useEffect(() => {
|
|
345
|
-
console.log(`[embed:${widgetId}] effect:iframeReady → ${iframeReady}, onUpdate=${!!onUpdate}, isExternal=${isExternal}, hasSnap=${hasSnap}`)
|
|
346
|
-
if (!iframeReady || !onUpdate || isExternal) return
|
|
347
|
-
if (!hasSnap) {
|
|
348
|
-
console.log(`[embed:${widgetId}] first-ready: requestCapture (no snap)`)
|
|
349
|
-
requestCapture()
|
|
181
|
+
function readToolbarTheme() {
|
|
182
|
+
setCanvasTheme(resolveCanvasThemeFromStorage())
|
|
350
183
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
useEffect(() => {
|
|
355
|
-
if (iframeReady && captureOnReadyRef.current) {
|
|
356
|
-
captureOnReadyRef.current = false
|
|
357
|
-
console.log(`[embed:${widgetId}] captureOnReady: requestCapture`)
|
|
358
|
-
requestCapture().then((updates) => {
|
|
359
|
-
const meta = refreshMetaRef.current
|
|
360
|
-
console.log(`[embed:${widgetId}] captureOnReady: done, snap=${updates?.snapshot ? 'yes' : 'null'}, hasMeta=${!!meta}`)
|
|
361
|
-
if (meta) {
|
|
362
|
-
refreshMetaRef.current = null
|
|
363
|
-
const snap = updates?.snapshot
|
|
364
|
-
const reveal = () => {
|
|
365
|
-
if (snap) {
|
|
366
|
-
const img = new Image()
|
|
367
|
-
const done = () => setShowIframe(false)
|
|
368
|
-
img.onload = done
|
|
369
|
-
img.onerror = done
|
|
370
|
-
img.src = snap
|
|
371
|
-
setTimeout(done, 2000)
|
|
372
|
-
} else {
|
|
373
|
-
setShowIframe(false)
|
|
374
|
-
}
|
|
375
|
-
meta.resolve(!!snap)
|
|
376
|
-
}
|
|
377
|
-
// Wait for our reveal slot in the wave
|
|
378
|
-
const elapsed = Date.now() - meta.batchStart
|
|
379
|
-
const targetTime = meta.revealOrder * REVEAL_INTERVAL
|
|
380
|
-
const wait = Math.max(0, targetTime - elapsed)
|
|
381
|
-
setTimeout(reveal, wait)
|
|
382
|
-
}
|
|
383
|
-
})
|
|
384
|
-
}
|
|
385
|
-
}, [iframeReady, requestCapture])
|
|
386
|
-
|
|
387
|
-
// Cleanup timers on unmount
|
|
388
|
-
useEffect(() => () => {
|
|
389
|
-
clearTimeout(resizeTimerRef.current)
|
|
390
|
-
clearTimeout(teardownTimerRef.current)
|
|
184
|
+
readToolbarTheme()
|
|
185
|
+
document.addEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
186
|
+
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
391
187
|
}, [])
|
|
392
188
|
|
|
393
189
|
// Close expanded modal on Escape
|
|
@@ -403,25 +199,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
403
199
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
404
200
|
}, [expanded])
|
|
405
201
|
|
|
406
|
-
// Reparent iframe
|
|
407
|
-
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
408
|
-
// browsing context — no reload. Falls back to appendChild which
|
|
409
|
-
// will reload but still works functionally.
|
|
202
|
+
// Reparent iframe between inline and modal
|
|
410
203
|
useEffect(() => {
|
|
411
204
|
const iframe = iframeRef.current
|
|
412
205
|
if (!iframe) return
|
|
413
|
-
|
|
414
206
|
if (expanded && modalContainerRef.current) {
|
|
415
207
|
iframe._savedClassName = iframe.className
|
|
416
208
|
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
417
209
|
iframe.className = styles.expandIframe
|
|
418
210
|
iframe.removeAttribute('style')
|
|
419
211
|
const target = modalContainerRef.current
|
|
420
|
-
if (target.moveBefore)
|
|
421
|
-
|
|
422
|
-
} else {
|
|
423
|
-
target.prepend(iframe)
|
|
424
|
-
}
|
|
212
|
+
if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
|
|
213
|
+
else target.prepend(iframe)
|
|
425
214
|
} else if (!expanded && inlineContainerRef.current) {
|
|
426
215
|
if (iframe._savedClassName !== undefined) {
|
|
427
216
|
iframe.className = iframe._savedClassName
|
|
@@ -430,28 +219,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
430
219
|
delete iframe._savedStyle
|
|
431
220
|
}
|
|
432
221
|
const target = inlineContainerRef.current
|
|
433
|
-
if (target.moveBefore)
|
|
434
|
-
|
|
435
|
-
} else {
|
|
436
|
-
target.appendChild(iframe)
|
|
437
|
-
}
|
|
222
|
+
if (target.moveBefore) target.moveBefore(iframe, null)
|
|
223
|
+
else target.appendChild(iframe)
|
|
438
224
|
}
|
|
439
225
|
}, [expanded])
|
|
440
226
|
|
|
441
|
-
// Listen for
|
|
227
|
+
// Listen for navigation events from the embedded prototype iframe
|
|
442
228
|
useEffect(() => {
|
|
443
229
|
function handleMessage(e) {
|
|
444
|
-
if (
|
|
445
|
-
if (e.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (newSrc && newSrc !== src) {
|
|
451
|
-
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
452
|
-
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
453
|
-
}
|
|
454
|
-
return
|
|
230
|
+
if (e.source !== iframeRef.current?.contentWindow) return
|
|
231
|
+
if (e.data?.type !== 'storyboard:embed:navigate') return
|
|
232
|
+
const newSrc = e.data.src
|
|
233
|
+
if (newSrc && newSrc !== src) {
|
|
234
|
+
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
235
|
+
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
455
236
|
}
|
|
456
237
|
}
|
|
457
238
|
window.addEventListener('message', handleMessage)
|
|
@@ -460,35 +241,25 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
460
241
|
|
|
461
242
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
462
243
|
|
|
463
|
-
const enterInteractive = useCallback(() =>
|
|
464
|
-
console.log(`[embed:${widgetId}] enterInteractive`)
|
|
465
|
-
exitSessionRef.current++
|
|
466
|
-
clearTimeout(teardownTimerRef.current)
|
|
467
|
-
cancelRefresh(widgetId)
|
|
468
|
-
setShowIframe(true)
|
|
469
|
-
setInteractive(true)
|
|
470
|
-
}, [widgetId])
|
|
244
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
471
245
|
|
|
472
|
-
// Expose imperative action handlers for WidgetChrome
|
|
473
246
|
useImperativeHandle(ref, () => ({
|
|
474
247
|
handleAction(actionId) {
|
|
475
248
|
if (actionId === 'edit') {
|
|
476
249
|
setEditing(true)
|
|
477
250
|
} else if (actionId === 'expand') {
|
|
478
|
-
setShowIframe(true)
|
|
479
251
|
setExpanded(true)
|
|
480
252
|
} else if (actionId === 'open-external') {
|
|
481
253
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
482
|
-
} else if (actionId === '
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
254
|
+
} else if (actionId === 'zoom-in') {
|
|
255
|
+
const step = zoom < 75 ? 5 : 25
|
|
256
|
+
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
257
|
+
} else if (actionId === 'zoom-out') {
|
|
258
|
+
const step = zoom <= 75 ? 5 : 25
|
|
259
|
+
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
489
260
|
}
|
|
490
261
|
},
|
|
491
|
-
}), [rawSrc,
|
|
262
|
+
}), [rawSrc, zoom, onUpdate])
|
|
492
263
|
|
|
493
264
|
function handlePickRoute(route) {
|
|
494
265
|
onUpdate?.({ src: route })
|
|
@@ -509,6 +280,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
509
280
|
setFilter('')
|
|
510
281
|
}
|
|
511
282
|
|
|
283
|
+
const handleResize = useCallback((w, h) => {
|
|
284
|
+
onUpdate?.({ width: w, height: h })
|
|
285
|
+
}, [onUpdate])
|
|
286
|
+
|
|
512
287
|
return (
|
|
513
288
|
<>
|
|
514
289
|
<WidgetWrapper>
|
|
@@ -531,12 +306,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
531
306
|
<>
|
|
532
307
|
<div className={styles.pickerHeader}>
|
|
533
308
|
<span className={styles.urlLabel}>Pick a prototype</span>
|
|
534
|
-
<button
|
|
535
|
-
type="button"
|
|
536
|
-
className={styles.urlCancel}
|
|
537
|
-
onClick={handleCancelEdit}
|
|
538
|
-
aria-label="Cancel"
|
|
539
|
-
>✕</button>
|
|
309
|
+
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
|
|
540
310
|
</div>
|
|
541
311
|
<input
|
|
542
312
|
ref={filterRef}
|
|
@@ -551,23 +321,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
551
321
|
{filteredGroups.map((group) => (
|
|
552
322
|
<div key={group.label} className={styles.pickerGroup}>
|
|
553
323
|
{group.items.length === 1 && group.items[0].name === group.label ? (
|
|
554
|
-
<button
|
|
555
|
-
className={styles.pickerItem}
|
|
556
|
-
role="option"
|
|
557
|
-
onClick={() => handlePickRoute(group.items[0].route)}
|
|
558
|
-
>
|
|
324
|
+
<button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
|
|
559
325
|
{group.label}
|
|
560
326
|
</button>
|
|
561
327
|
) : (
|
|
562
328
|
<>
|
|
563
329
|
<div className={styles.pickerGroupLabel}>{group.label}</div>
|
|
564
330
|
{group.items.map((item) => (
|
|
565
|
-
<button
|
|
566
|
-
key={item.route}
|
|
567
|
-
className={styles.pickerItem}
|
|
568
|
-
role="option"
|
|
569
|
-
onClick={() => handlePickRoute(item.route)}
|
|
570
|
-
>
|
|
331
|
+
<button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
|
|
571
332
|
{item.name}
|
|
572
333
|
</button>
|
|
573
334
|
))}
|
|
@@ -575,30 +336,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
575
336
|
)}
|
|
576
337
|
</div>
|
|
577
338
|
))}
|
|
578
|
-
{filteredGroups.length === 0 &&
|
|
579
|
-
<div className={styles.pickerEmpty}>No matches</div>
|
|
580
|
-
)}
|
|
339
|
+
{filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
|
|
581
340
|
</div>
|
|
582
341
|
<div className={styles.pickerDivider} />
|
|
583
342
|
</>
|
|
584
343
|
)}
|
|
585
344
|
<form className={styles.customUrlSection} onSubmit={handleSubmit}>
|
|
586
|
-
<label className={styles.urlLabel}>
|
|
587
|
-
|
|
588
|
-
</label>
|
|
589
|
-
<input
|
|
590
|
-
ref={inputRef}
|
|
591
|
-
className={styles.urlInput}
|
|
592
|
-
type="text"
|
|
593
|
-
defaultValue={src}
|
|
594
|
-
placeholder="/MyPrototype/page"
|
|
595
|
-
onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
|
|
596
|
-
/>
|
|
345
|
+
<label className={styles.urlLabel}>{hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}</label>
|
|
346
|
+
<input ref={inputRef} className={styles.urlInput} type="text" defaultValue={src} placeholder="/MyPrototype/page" onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }} />
|
|
597
347
|
<div className={styles.urlActions}>
|
|
598
348
|
<button type="submit" className={styles.urlSave}>Save</button>
|
|
599
|
-
{!hasPicker &&
|
|
600
|
-
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
|
|
601
|
-
)}
|
|
349
|
+
{!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
|
|
602
350
|
</div>
|
|
603
351
|
</form>
|
|
604
352
|
</div>
|
|
@@ -609,46 +357,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
609
357
|
className={styles.iframeContainer}
|
|
610
358
|
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
611
359
|
>
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
<iframe
|
|
626
|
-
ref={iframeRef}
|
|
627
|
-
src={iframeSrc}
|
|
628
|
-
className={styles.iframe}
|
|
629
|
-
style={{
|
|
630
|
-
width: width / scale,
|
|
631
|
-
height: height / scale,
|
|
632
|
-
transform: `scale(${scale})`,
|
|
633
|
-
transformOrigin: '0 0',
|
|
634
|
-
transition: 'opacity 150ms ease',
|
|
635
|
-
...(iframeLoaded ? {} : { opacity: 0 }),
|
|
636
|
-
}}
|
|
637
|
-
onLoad={() => { console.log(`[embed:${widgetId}] iframe onLoad`); setIframeLoaded(true) }}
|
|
638
|
-
title={`${prototypeTitle} prototype`}
|
|
639
|
-
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
640
|
-
/>
|
|
641
|
-
)}
|
|
642
|
-
|
|
643
|
-
{/* Placeholder — only when no snapshot and no iframe */}
|
|
644
|
-
{!hasSnap && !showIframe && (
|
|
645
|
-
<div className={styles.placeholder}>
|
|
646
|
-
<CollageFrameIcon size={36} />
|
|
647
|
-
<span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
|
|
648
|
-
</div>
|
|
649
|
-
)}
|
|
360
|
+
<iframe
|
|
361
|
+
ref={iframeRef}
|
|
362
|
+
src={iframeSrc}
|
|
363
|
+
className={styles.iframe}
|
|
364
|
+
style={{
|
|
365
|
+
width: width / scale,
|
|
366
|
+
height: height / scale,
|
|
367
|
+
transform: `scale(${scale})`,
|
|
368
|
+
transformOrigin: '0 0',
|
|
369
|
+
}}
|
|
370
|
+
title={`${prototypeTitle} prototype`}
|
|
371
|
+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
372
|
+
/>
|
|
650
373
|
</div>
|
|
651
|
-
|
|
652
374
|
{!interactive && !expanded && (
|
|
653
375
|
<div
|
|
654
376
|
className={overlayStyles.interactOverlay}
|
|
@@ -665,52 +387,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
665
387
|
enterInteractive()
|
|
666
388
|
}
|
|
667
389
|
}}
|
|
668
|
-
aria-label=
|
|
390
|
+
aria-label="Click to interact with prototype"
|
|
669
391
|
>
|
|
670
|
-
<span className={overlayStyles.interactHint}>
|
|
392
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
671
393
|
</div>
|
|
672
394
|
)}
|
|
673
395
|
</>
|
|
674
396
|
) : (
|
|
675
|
-
<div
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
role="button"
|
|
679
|
-
tabIndex={0}
|
|
680
|
-
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
681
|
-
>
|
|
682
|
-
<p>Double-click to set prototype URL</p>
|
|
397
|
+
<div className={styles.empty} onClick={() => onUpdate && setEditing(true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}>
|
|
398
|
+
<CollageFrameIcon size={36} />
|
|
399
|
+
<p>Click to set prototype URL</p>
|
|
683
400
|
</div>
|
|
684
401
|
)}
|
|
685
402
|
</div>
|
|
686
|
-
{resizable &&
|
|
687
|
-
<div
|
|
688
|
-
className={styles.resizeHandle}
|
|
689
|
-
onMouseDown={(e) => {
|
|
690
|
-
e.stopPropagation()
|
|
691
|
-
e.preventDefault()
|
|
692
|
-
const startX = e.clientX
|
|
693
|
-
const startY = e.clientY
|
|
694
|
-
const startW = width
|
|
695
|
-
const startH = height
|
|
696
|
-
function onMove(ev) {
|
|
697
|
-
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
698
|
-
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
699
|
-
onUpdate?.({ width: newW, height: newH })
|
|
700
|
-
}
|
|
701
|
-
function onUp() {
|
|
702
|
-
document.removeEventListener('mousemove', onMove)
|
|
703
|
-
document.removeEventListener('mouseup', onUp)
|
|
704
|
-
// Recapture snapshot after resize (debounced)
|
|
705
|
-
clearTimeout(resizeTimerRef.current)
|
|
706
|
-
resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
|
|
707
|
-
}
|
|
708
|
-
document.addEventListener('mousemove', onMove)
|
|
709
|
-
document.addEventListener('mouseup', onUp)
|
|
710
|
-
}}
|
|
711
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
712
|
-
/>
|
|
713
|
-
)}
|
|
403
|
+
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
714
404
|
</WidgetWrapper>
|
|
715
405
|
{createPortal(
|
|
716
406
|
<div
|
|
@@ -718,26 +408,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
718
408
|
style={expanded ? undefined : { display: 'none' }}
|
|
719
409
|
onClick={() => setExpanded(false)}
|
|
720
410
|
onPointerDown={(e) => e.stopPropagation()}
|
|
721
|
-
onKeyDown={(e) =>
|
|
722
|
-
e.stopPropagation()
|
|
723
|
-
if (e.key === 'Escape') setExpanded(false)
|
|
724
|
-
}}
|
|
411
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
725
412
|
onWheel={(e) => e.stopPropagation()}
|
|
726
|
-
tabIndex={-1}
|
|
727
|
-
ref={(el) => { if (el && expanded) el.focus() }}
|
|
728
413
|
>
|
|
729
|
-
<div
|
|
730
|
-
|
|
731
|
-
className={styles.expandContainer}
|
|
732
|
-
onClick={(e) => e.stopPropagation()}
|
|
733
|
-
>
|
|
734
|
-
{/* iframe is reparented here via useEffect */}
|
|
735
|
-
<button
|
|
736
|
-
className={styles.expandClose}
|
|
737
|
-
onClick={() => setExpanded(false)}
|
|
738
|
-
aria-label="Close expanded view"
|
|
739
|
-
autoFocus
|
|
740
|
-
>✕</button>
|
|
414
|
+
<div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
|
|
415
|
+
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
|
|
741
416
|
</div>
|
|
742
417
|
</div>,
|
|
743
418
|
document.body
|