@dfosco/storyboard-react 4.0.0-beta.34 → 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 -429
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 (temporary) ──
|
|
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,133 +163,27 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
250
163
|
}
|
|
251
164
|
}, [editing, hasPicker])
|
|
252
165
|
|
|
253
|
-
|
|
254
|
-
if (!showIframe) setIframeLoaded(false)
|
|
255
|
-
}, [showIframe])
|
|
256
|
-
|
|
257
|
-
// Exit interactive mode when clicking outside the embed.
|
|
258
|
-
// Captures a snapshot in the background, then unmounts the iframe.
|
|
166
|
+
// Exit interactive mode when clicking outside the embed
|
|
259
167
|
useEffect(() => {
|
|
260
168
|
if (!interactive || expanded) return
|
|
261
169
|
function handlePointerDown(e) {
|
|
262
170
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
263
171
|
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
264
172
|
if (chromeEl) return
|
|
265
|
-
|
|
266
173
|
setInteractive(false)
|
|
267
|
-
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
268
|
-
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
269
|
-
const session = ++exitSessionRef.current
|
|
270
|
-
setTimeout(() => {
|
|
271
|
-
if (exitSessionRef.current !== session) return
|
|
272
|
-
requestCapture({ force: true }).then((updates) => {
|
|
273
|
-
if (exitSessionRef.current !== session) return
|
|
274
|
-
const snap = updates?.snapshot
|
|
275
|
-
if (snap) {
|
|
276
|
-
const img = new Image()
|
|
277
|
-
const done = () => {
|
|
278
|
-
if (exitSessionRef.current === session) setShowIframe(false)
|
|
279
|
-
}
|
|
280
|
-
img.onload = done
|
|
281
|
-
img.onerror = done
|
|
282
|
-
img.src = snap
|
|
283
|
-
setTimeout(done, 2000)
|
|
284
|
-
} else {
|
|
285
|
-
setShowIframe(false)
|
|
286
|
-
}
|
|
287
|
-
})
|
|
288
|
-
}, 0)
|
|
289
|
-
} else if (isExternal && showIframe) {
|
|
290
|
-
const session = ++exitSessionRef.current
|
|
291
|
-
clearTimeout(teardownTimerRef.current)
|
|
292
|
-
teardownTimerRef.current = setTimeout(() => {
|
|
293
|
-
if (exitSessionRef.current !== session) return
|
|
294
|
-
setShowIframe(false)
|
|
295
|
-
}, 2 * 60 * 1000)
|
|
296
|
-
} else {
|
|
297
|
-
setShowIframe(false)
|
|
298
|
-
}
|
|
299
174
|
}
|
|
300
175
|
}
|
|
301
176
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
302
177
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
303
|
-
}, [interactive, expanded,
|
|
304
|
-
|
|
305
|
-
useEffect(() => subscribeCanvasTheme({
|
|
306
|
-
anchorRef: embedRef,
|
|
307
|
-
onTheme: setCanvasTheme,
|
|
308
|
-
}), [])
|
|
178
|
+
}, [interactive, expanded, widgetId])
|
|
309
179
|
|
|
310
|
-
// On canvas theme change, enqueue a background snapshot refresh.
|
|
311
|
-
// Only fires for true user-initiated theme changes (not page load).
|
|
312
|
-
// Uses mountTime to ignore the initial theme resolution that happens
|
|
313
|
-
// within the first 3 seconds of mount.
|
|
314
|
-
const mountTimeRef = useRef(Date.now())
|
|
315
|
-
const refreshMetaRef = useRef(null)
|
|
316
|
-
const hasSnapRef = useRef(hasSnap)
|
|
317
|
-
hasSnapRef.current = hasSnap
|
|
318
180
|
useEffect(() => {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (elapsed < 3000) { console.log(`[embed:${widgetId}] theme-effect: skip page-load (${elapsed}ms)`); return }
|
|
322
|
-
if (isExternal || !onUpdate || interactive) { console.log(`[embed:${widgetId}] theme-effect: skip (ext=${isExternal}, noUpdate=${!onUpdate}, interactive=${interactive})`); return }
|
|
323
|
-
if (!hasSnapRef.current) { console.log(`[embed:${widgetId}] theme-effect: skip (no snap)`); return }
|
|
324
|
-
console.log(`[embed:${widgetId}] theme-effect: enqueue refresh, hasSnapRef=${hasSnapRef.current}`)
|
|
325
|
-
const rect = embedRef.current?.getBoundingClientRect()
|
|
326
|
-
enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
|
|
327
|
-
if (!hasSnapRef.current) return Promise.resolve(false)
|
|
328
|
-
return new Promise((resolve) => {
|
|
329
|
-
refreshMetaRef.current = { revealOrder, batchStart, resolve }
|
|
330
|
-
captureOnReadyRef.current = true
|
|
331
|
-
setShowIframe(true)
|
|
332
|
-
setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
|
|
333
|
-
})
|
|
334
|
-
}, rect ? { x: rect.left, y: rect.top } : undefined)
|
|
335
|
-
}, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
336
|
-
|
|
337
|
-
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
338
|
-
useEffect(() => {
|
|
339
|
-
if (!iframeReady || !onUpdate || isExternal) return
|
|
340
|
-
if (!hasSnap) {
|
|
341
|
-
requestCapture()
|
|
181
|
+
function readToolbarTheme() {
|
|
182
|
+
setCanvasTheme(resolveCanvasThemeFromStorage())
|
|
342
183
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
useEffect(() => {
|
|
347
|
-
if (iframeReady && captureOnReadyRef.current) {
|
|
348
|
-
captureOnReadyRef.current = false
|
|
349
|
-
requestCapture().then((updates) => {
|
|
350
|
-
const meta = refreshMetaRef.current
|
|
351
|
-
if (meta) {
|
|
352
|
-
refreshMetaRef.current = null
|
|
353
|
-
const snap = updates?.snapshot
|
|
354
|
-
const reveal = () => {
|
|
355
|
-
if (snap) {
|
|
356
|
-
const img = new Image()
|
|
357
|
-
const done = () => setShowIframe(false)
|
|
358
|
-
img.onload = done
|
|
359
|
-
img.onerror = done
|
|
360
|
-
img.src = snap
|
|
361
|
-
setTimeout(done, 2000)
|
|
362
|
-
} else {
|
|
363
|
-
setShowIframe(false)
|
|
364
|
-
}
|
|
365
|
-
meta.resolve(!!snap)
|
|
366
|
-
}
|
|
367
|
-
const elapsed = Date.now() - meta.batchStart
|
|
368
|
-
const targetTime = meta.revealOrder * REVEAL_INTERVAL
|
|
369
|
-
const wait = Math.max(0, targetTime - elapsed)
|
|
370
|
-
setTimeout(reveal, wait)
|
|
371
|
-
}
|
|
372
|
-
})
|
|
373
|
-
}
|
|
374
|
-
}, [iframeReady, requestCapture])
|
|
375
|
-
|
|
376
|
-
// Cleanup timers on unmount
|
|
377
|
-
useEffect(() => () => {
|
|
378
|
-
clearTimeout(resizeTimerRef.current)
|
|
379
|
-
clearTimeout(teardownTimerRef.current)
|
|
184
|
+
readToolbarTheme()
|
|
185
|
+
document.addEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
186
|
+
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
380
187
|
}, [])
|
|
381
188
|
|
|
382
189
|
// Close expanded modal on Escape
|
|
@@ -392,25 +199,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
392
199
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
393
200
|
}, [expanded])
|
|
394
201
|
|
|
395
|
-
// Reparent iframe
|
|
396
|
-
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
397
|
-
// browsing context — no reload. Falls back to appendChild which
|
|
398
|
-
// will reload but still works functionally.
|
|
202
|
+
// Reparent iframe between inline and modal
|
|
399
203
|
useEffect(() => {
|
|
400
204
|
const iframe = iframeRef.current
|
|
401
205
|
if (!iframe) return
|
|
402
|
-
|
|
403
206
|
if (expanded && modalContainerRef.current) {
|
|
404
207
|
iframe._savedClassName = iframe.className
|
|
405
208
|
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
406
209
|
iframe.className = styles.expandIframe
|
|
407
210
|
iframe.removeAttribute('style')
|
|
408
211
|
const target = modalContainerRef.current
|
|
409
|
-
if (target.moveBefore)
|
|
410
|
-
|
|
411
|
-
} else {
|
|
412
|
-
target.prepend(iframe)
|
|
413
|
-
}
|
|
212
|
+
if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
|
|
213
|
+
else target.prepend(iframe)
|
|
414
214
|
} else if (!expanded && inlineContainerRef.current) {
|
|
415
215
|
if (iframe._savedClassName !== undefined) {
|
|
416
216
|
iframe.className = iframe._savedClassName
|
|
@@ -419,28 +219,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
419
219
|
delete iframe._savedStyle
|
|
420
220
|
}
|
|
421
221
|
const target = inlineContainerRef.current
|
|
422
|
-
if (target.moveBefore)
|
|
423
|
-
|
|
424
|
-
} else {
|
|
425
|
-
target.appendChild(iframe)
|
|
426
|
-
}
|
|
222
|
+
if (target.moveBefore) target.moveBefore(iframe, null)
|
|
223
|
+
else target.appendChild(iframe)
|
|
427
224
|
}
|
|
428
225
|
}, [expanded])
|
|
429
226
|
|
|
430
|
-
// Listen for
|
|
227
|
+
// Listen for navigation events from the embedded prototype iframe
|
|
431
228
|
useEffect(() => {
|
|
432
229
|
function handleMessage(e) {
|
|
433
|
-
if (
|
|
434
|
-
if (e.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (newSrc && newSrc !== src) {
|
|
440
|
-
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
441
|
-
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
442
|
-
}
|
|
443
|
-
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 })
|
|
444
236
|
}
|
|
445
237
|
}
|
|
446
238
|
window.addEventListener('message', handleMessage)
|
|
@@ -449,35 +241,25 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
449
241
|
|
|
450
242
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
451
243
|
|
|
452
|
-
const enterInteractive = useCallback(() =>
|
|
453
|
-
console.log(`[embed:${widgetId}] enterInteractive`)
|
|
454
|
-
exitSessionRef.current++
|
|
455
|
-
clearTimeout(teardownTimerRef.current)
|
|
456
|
-
cancelRefresh(widgetId)
|
|
457
|
-
setShowIframe(true)
|
|
458
|
-
setInteractive(true)
|
|
459
|
-
}, [widgetId])
|
|
244
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
460
245
|
|
|
461
|
-
// Expose imperative action handlers for WidgetChrome
|
|
462
246
|
useImperativeHandle(ref, () => ({
|
|
463
247
|
handleAction(actionId) {
|
|
464
248
|
if (actionId === 'edit') {
|
|
465
249
|
setEditing(true)
|
|
466
250
|
} else if (actionId === 'expand') {
|
|
467
|
-
setShowIframe(true)
|
|
468
251
|
setExpanded(true)
|
|
469
252
|
} else if (actionId === 'open-external') {
|
|
470
253
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
471
|
-
} else if (actionId === '
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
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) })
|
|
478
260
|
}
|
|
479
261
|
},
|
|
480
|
-
}), [rawSrc,
|
|
262
|
+
}), [rawSrc, zoom, onUpdate])
|
|
481
263
|
|
|
482
264
|
function handlePickRoute(route) {
|
|
483
265
|
onUpdate?.({ src: route })
|
|
@@ -498,6 +280,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
498
280
|
setFilter('')
|
|
499
281
|
}
|
|
500
282
|
|
|
283
|
+
const handleResize = useCallback((w, h) => {
|
|
284
|
+
onUpdate?.({ width: w, height: h })
|
|
285
|
+
}, [onUpdate])
|
|
286
|
+
|
|
501
287
|
return (
|
|
502
288
|
<>
|
|
503
289
|
<WidgetWrapper>
|
|
@@ -520,12 +306,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
520
306
|
<>
|
|
521
307
|
<div className={styles.pickerHeader}>
|
|
522
308
|
<span className={styles.urlLabel}>Pick a prototype</span>
|
|
523
|
-
<button
|
|
524
|
-
type="button"
|
|
525
|
-
className={styles.urlCancel}
|
|
526
|
-
onClick={handleCancelEdit}
|
|
527
|
-
aria-label="Cancel"
|
|
528
|
-
>✕</button>
|
|
309
|
+
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
|
|
529
310
|
</div>
|
|
530
311
|
<input
|
|
531
312
|
ref={filterRef}
|
|
@@ -540,23 +321,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
540
321
|
{filteredGroups.map((group) => (
|
|
541
322
|
<div key={group.label} className={styles.pickerGroup}>
|
|
542
323
|
{group.items.length === 1 && group.items[0].name === group.label ? (
|
|
543
|
-
<button
|
|
544
|
-
className={styles.pickerItem}
|
|
545
|
-
role="option"
|
|
546
|
-
onClick={() => handlePickRoute(group.items[0].route)}
|
|
547
|
-
>
|
|
324
|
+
<button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
|
|
548
325
|
{group.label}
|
|
549
326
|
</button>
|
|
550
327
|
) : (
|
|
551
328
|
<>
|
|
552
329
|
<div className={styles.pickerGroupLabel}>{group.label}</div>
|
|
553
330
|
{group.items.map((item) => (
|
|
554
|
-
<button
|
|
555
|
-
key={item.route}
|
|
556
|
-
className={styles.pickerItem}
|
|
557
|
-
role="option"
|
|
558
|
-
onClick={() => handlePickRoute(item.route)}
|
|
559
|
-
>
|
|
331
|
+
<button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
|
|
560
332
|
{item.name}
|
|
561
333
|
</button>
|
|
562
334
|
))}
|
|
@@ -564,30 +336,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
564
336
|
)}
|
|
565
337
|
</div>
|
|
566
338
|
))}
|
|
567
|
-
{filteredGroups.length === 0 &&
|
|
568
|
-
<div className={styles.pickerEmpty}>No matches</div>
|
|
569
|
-
)}
|
|
339
|
+
{filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
|
|
570
340
|
</div>
|
|
571
341
|
<div className={styles.pickerDivider} />
|
|
572
342
|
</>
|
|
573
343
|
)}
|
|
574
344
|
<form className={styles.customUrlSection} onSubmit={handleSubmit}>
|
|
575
|
-
<label className={styles.urlLabel}>
|
|
576
|
-
|
|
577
|
-
</label>
|
|
578
|
-
<input
|
|
579
|
-
ref={inputRef}
|
|
580
|
-
className={styles.urlInput}
|
|
581
|
-
type="text"
|
|
582
|
-
defaultValue={src}
|
|
583
|
-
placeholder="/MyPrototype/page"
|
|
584
|
-
onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
|
|
585
|
-
/>
|
|
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() }} />
|
|
586
347
|
<div className={styles.urlActions}>
|
|
587
348
|
<button type="submit" className={styles.urlSave}>Save</button>
|
|
588
|
-
{!hasPicker &&
|
|
589
|
-
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
|
|
590
|
-
)}
|
|
349
|
+
{!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
|
|
591
350
|
</div>
|
|
592
351
|
</form>
|
|
593
352
|
</div>
|
|
@@ -598,51 +357,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
598
357
|
className={styles.iframeContainer}
|
|
599
358
|
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
600
359
|
>
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
/>
|
|
615
|
-
)}
|
|
616
|
-
|
|
617
|
-
{/* Iframe layer — on top, transparent until loaded */}
|
|
618
|
-
{showIframe && (
|
|
619
|
-
<iframe
|
|
620
|
-
ref={iframeRef}
|
|
621
|
-
src={iframeSrc}
|
|
622
|
-
className={styles.iframe}
|
|
623
|
-
style={{
|
|
624
|
-
width: width / scale,
|
|
625
|
-
height: height / scale,
|
|
626
|
-
transform: `scale(${scale})`,
|
|
627
|
-
transformOrigin: '0 0',
|
|
628
|
-
transition: 'opacity 150ms ease',
|
|
629
|
-
...(iframeLoaded ? {} : { opacity: 0 }),
|
|
630
|
-
}}
|
|
631
|
-
onLoad={() => { console.log(`[embed:${widgetId}] iframe onLoad`); setIframeLoaded(true) }}
|
|
632
|
-
title={`${prototypeTitle} prototype`}
|
|
633
|
-
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
634
|
-
/>
|
|
635
|
-
)}
|
|
636
|
-
|
|
637
|
-
{/* Placeholder — only when no snapshot and no iframe */}
|
|
638
|
-
{!hasSnap && !showIframe && (
|
|
639
|
-
<div className={styles.placeholder}>
|
|
640
|
-
<CollageFrameIcon size={36} />
|
|
641
|
-
<span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
|
|
642
|
-
</div>
|
|
643
|
-
)}
|
|
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
|
+
/>
|
|
644
373
|
</div>
|
|
645
|
-
|
|
646
374
|
{!interactive && !expanded && (
|
|
647
375
|
<div
|
|
648
376
|
className={overlayStyles.interactOverlay}
|
|
@@ -659,52 +387,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
659
387
|
enterInteractive()
|
|
660
388
|
}
|
|
661
389
|
}}
|
|
662
|
-
aria-label=
|
|
390
|
+
aria-label="Click to interact with prototype"
|
|
663
391
|
>
|
|
664
|
-
<span className={overlayStyles.interactHint}>
|
|
392
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
665
393
|
</div>
|
|
666
394
|
)}
|
|
667
395
|
</>
|
|
668
396
|
) : (
|
|
669
|
-
<div
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
role="button"
|
|
673
|
-
tabIndex={0}
|
|
674
|
-
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
675
|
-
>
|
|
676
|
-
<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>
|
|
677
400
|
</div>
|
|
678
401
|
)}
|
|
679
402
|
</div>
|
|
680
|
-
{resizable &&
|
|
681
|
-
<div
|
|
682
|
-
className={styles.resizeHandle}
|
|
683
|
-
onMouseDown={(e) => {
|
|
684
|
-
e.stopPropagation()
|
|
685
|
-
e.preventDefault()
|
|
686
|
-
const startX = e.clientX
|
|
687
|
-
const startY = e.clientY
|
|
688
|
-
const startW = width
|
|
689
|
-
const startH = height
|
|
690
|
-
function onMove(ev) {
|
|
691
|
-
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
692
|
-
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
693
|
-
onUpdate?.({ width: newW, height: newH })
|
|
694
|
-
}
|
|
695
|
-
function onUp() {
|
|
696
|
-
document.removeEventListener('mousemove', onMove)
|
|
697
|
-
document.removeEventListener('mouseup', onUp)
|
|
698
|
-
// Recapture snapshot after resize (debounced)
|
|
699
|
-
clearTimeout(resizeTimerRef.current)
|
|
700
|
-
resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
|
|
701
|
-
}
|
|
702
|
-
document.addEventListener('mousemove', onMove)
|
|
703
|
-
document.addEventListener('mouseup', onUp)
|
|
704
|
-
}}
|
|
705
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
706
|
-
/>
|
|
707
|
-
)}
|
|
403
|
+
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
708
404
|
</WidgetWrapper>
|
|
709
405
|
{createPortal(
|
|
710
406
|
<div
|
|
@@ -712,26 +408,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
712
408
|
style={expanded ? undefined : { display: 'none' }}
|
|
713
409
|
onClick={() => setExpanded(false)}
|
|
714
410
|
onPointerDown={(e) => e.stopPropagation()}
|
|
715
|
-
onKeyDown={(e) =>
|
|
716
|
-
e.stopPropagation()
|
|
717
|
-
if (e.key === 'Escape') setExpanded(false)
|
|
718
|
-
}}
|
|
411
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
719
412
|
onWheel={(e) => e.stopPropagation()}
|
|
720
|
-
tabIndex={-1}
|
|
721
|
-
ref={(el) => { if (el && expanded) el.focus() }}
|
|
722
413
|
>
|
|
723
|
-
<div
|
|
724
|
-
|
|
725
|
-
className={styles.expandContainer}
|
|
726
|
-
onClick={(e) => e.stopPropagation()}
|
|
727
|
-
>
|
|
728
|
-
{/* iframe is reparented here via useEffect */}
|
|
729
|
-
<button
|
|
730
|
-
className={styles.expandClose}
|
|
731
|
-
onClick={() => setExpanded(false)}
|
|
732
|
-
aria-label="Close expanded view"
|
|
733
|
-
autoFocus
|
|
734
|
-
>✕</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>
|
|
735
416
|
</div>
|
|
736
417
|
</div>,
|
|
737
418
|
document.body
|