@dfosco/storyboard-react 4.0.0-beta.34 → 4.0.0-beta.36
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.
|
@@ -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()
|
|
@@ -210,37 +133,30 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
210
133
|
.filter(Boolean)
|
|
211
134
|
}, [pickerGroups, filter])
|
|
212
135
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
}
|
|
136
|
+
const prototypeTitle = useMemo(() => {
|
|
137
|
+
if (!src) return label || 'Prototype'
|
|
138
|
+
const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
|
|
139
|
+
for (const group of pickerGroups) {
|
|
140
|
+
for (const item of group.items) {
|
|
141
|
+
const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
|
|
142
|
+
if (cleanRoute === cleanSrc) {
|
|
143
|
+
// If the flow name matches the group name, just show the name
|
|
144
|
+
if (item.name === group.label) return group.label
|
|
145
|
+
return `${group.label} · ${item.name}`
|
|
233
146
|
}
|
|
234
147
|
}
|
|
235
148
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}, [prototypeIndex, src, rawSrc, basePath])
|
|
239
|
-
|
|
240
|
-
const prototypeTitle = prototypeName || label || 'Prototype'
|
|
149
|
+
return label || 'Prototype'
|
|
150
|
+
}, [src, label, pickerGroups])
|
|
241
151
|
|
|
242
152
|
const hasPicker = pickerGroups.length > 0
|
|
243
153
|
|
|
154
|
+
useIframeDevLogs({
|
|
155
|
+
widget: 'PrototypeEmbed',
|
|
156
|
+
loaded: Boolean(iframeSrc && interactive),
|
|
157
|
+
src: iframeSrc,
|
|
158
|
+
})
|
|
159
|
+
|
|
244
160
|
useEffect(() => {
|
|
245
161
|
if (editing && hasPicker && filterRef.current) {
|
|
246
162
|
filterRef.current.focus()
|
|
@@ -250,133 +166,27 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
250
166
|
}
|
|
251
167
|
}, [editing, hasPicker])
|
|
252
168
|
|
|
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.
|
|
169
|
+
// Exit interactive mode when clicking outside the embed
|
|
259
170
|
useEffect(() => {
|
|
260
171
|
if (!interactive || expanded) return
|
|
261
172
|
function handlePointerDown(e) {
|
|
262
173
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
263
174
|
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
264
175
|
if (chromeEl) return
|
|
265
|
-
|
|
266
176
|
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
177
|
}
|
|
300
178
|
}
|
|
301
179
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
302
180
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
303
|
-
}, [interactive, expanded,
|
|
304
|
-
|
|
305
|
-
useEffect(() => subscribeCanvasTheme({
|
|
306
|
-
anchorRef: embedRef,
|
|
307
|
-
onTheme: setCanvasTheme,
|
|
308
|
-
}), [])
|
|
309
|
-
|
|
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
|
-
useEffect(() => {
|
|
319
|
-
// Ignore theme changes during initial page load (first 3 seconds)
|
|
320
|
-
const elapsed = Date.now() - mountTimeRef.current
|
|
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
|
|
181
|
+
}, [interactive, expanded, widgetId])
|
|
336
182
|
|
|
337
|
-
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
338
183
|
useEffect(() => {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
requestCapture()
|
|
184
|
+
function readToolbarTheme() {
|
|
185
|
+
setCanvasTheme(resolveCanvasThemeFromStorage())
|
|
342
186
|
}
|
|
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)
|
|
187
|
+
readToolbarTheme()
|
|
188
|
+
document.addEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
189
|
+
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
380
190
|
}, [])
|
|
381
191
|
|
|
382
192
|
// Close expanded modal on Escape
|
|
@@ -392,25 +202,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
392
202
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
393
203
|
}, [expanded])
|
|
394
204
|
|
|
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.
|
|
205
|
+
// Reparent iframe between inline and modal
|
|
399
206
|
useEffect(() => {
|
|
400
207
|
const iframe = iframeRef.current
|
|
401
208
|
if (!iframe) return
|
|
402
|
-
|
|
403
209
|
if (expanded && modalContainerRef.current) {
|
|
404
210
|
iframe._savedClassName = iframe.className
|
|
405
211
|
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
406
212
|
iframe.className = styles.expandIframe
|
|
407
213
|
iframe.removeAttribute('style')
|
|
408
214
|
const target = modalContainerRef.current
|
|
409
|
-
if (target.moveBefore)
|
|
410
|
-
|
|
411
|
-
} else {
|
|
412
|
-
target.prepend(iframe)
|
|
413
|
-
}
|
|
215
|
+
if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
|
|
216
|
+
else target.prepend(iframe)
|
|
414
217
|
} else if (!expanded && inlineContainerRef.current) {
|
|
415
218
|
if (iframe._savedClassName !== undefined) {
|
|
416
219
|
iframe.className = iframe._savedClassName
|
|
@@ -419,28 +222,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
419
222
|
delete iframe._savedStyle
|
|
420
223
|
}
|
|
421
224
|
const target = inlineContainerRef.current
|
|
422
|
-
if (target.moveBefore)
|
|
423
|
-
|
|
424
|
-
} else {
|
|
425
|
-
target.appendChild(iframe)
|
|
426
|
-
}
|
|
225
|
+
if (target.moveBefore) target.moveBefore(iframe, null)
|
|
226
|
+
else target.appendChild(iframe)
|
|
427
227
|
}
|
|
428
228
|
}, [expanded])
|
|
429
229
|
|
|
430
|
-
// Listen for
|
|
230
|
+
// Listen for navigation events from the embedded prototype iframe
|
|
431
231
|
useEffect(() => {
|
|
432
232
|
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
|
|
233
|
+
if (e.source !== iframeRef.current?.contentWindow) return
|
|
234
|
+
if (e.data?.type !== 'storyboard:embed:navigate') return
|
|
235
|
+
const newSrc = e.data.src
|
|
236
|
+
if (newSrc && newSrc !== src) {
|
|
237
|
+
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
238
|
+
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
444
239
|
}
|
|
445
240
|
}
|
|
446
241
|
window.addEventListener('message', handleMessage)
|
|
@@ -449,35 +244,25 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
449
244
|
|
|
450
245
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
451
246
|
|
|
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])
|
|
247
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
460
248
|
|
|
461
|
-
// Expose imperative action handlers for WidgetChrome
|
|
462
249
|
useImperativeHandle(ref, () => ({
|
|
463
250
|
handleAction(actionId) {
|
|
464
251
|
if (actionId === 'edit') {
|
|
465
252
|
setEditing(true)
|
|
466
253
|
} else if (actionId === 'expand') {
|
|
467
|
-
setShowIframe(true)
|
|
468
254
|
setExpanded(true)
|
|
469
255
|
} else if (actionId === 'open-external') {
|
|
470
256
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
471
|
-
} else if (actionId === '
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
257
|
+
} else if (actionId === 'zoom-in') {
|
|
258
|
+
const step = zoom < 75 ? 5 : 25
|
|
259
|
+
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
260
|
+
} else if (actionId === 'zoom-out') {
|
|
261
|
+
const step = zoom <= 75 ? 5 : 25
|
|
262
|
+
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
478
263
|
}
|
|
479
264
|
},
|
|
480
|
-
}), [rawSrc,
|
|
265
|
+
}), [rawSrc, zoom, onUpdate])
|
|
481
266
|
|
|
482
267
|
function handlePickRoute(route) {
|
|
483
268
|
onUpdate?.({ src: route })
|
|
@@ -498,6 +283,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
498
283
|
setFilter('')
|
|
499
284
|
}
|
|
500
285
|
|
|
286
|
+
const handleResize = useCallback((w, h) => {
|
|
287
|
+
onUpdate?.({ width: w, height: h })
|
|
288
|
+
}, [onUpdate])
|
|
289
|
+
|
|
501
290
|
return (
|
|
502
291
|
<>
|
|
503
292
|
<WidgetWrapper>
|
|
@@ -520,12 +309,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
520
309
|
<>
|
|
521
310
|
<div className={styles.pickerHeader}>
|
|
522
311
|
<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>
|
|
312
|
+
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
|
|
529
313
|
</div>
|
|
530
314
|
<input
|
|
531
315
|
ref={filterRef}
|
|
@@ -540,23 +324,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
540
324
|
{filteredGroups.map((group) => (
|
|
541
325
|
<div key={group.label} className={styles.pickerGroup}>
|
|
542
326
|
{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
|
-
>
|
|
327
|
+
<button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
|
|
548
328
|
{group.label}
|
|
549
329
|
</button>
|
|
550
330
|
) : (
|
|
551
331
|
<>
|
|
552
332
|
<div className={styles.pickerGroupLabel}>{group.label}</div>
|
|
553
333
|
{group.items.map((item) => (
|
|
554
|
-
<button
|
|
555
|
-
key={item.route}
|
|
556
|
-
className={styles.pickerItem}
|
|
557
|
-
role="option"
|
|
558
|
-
onClick={() => handlePickRoute(item.route)}
|
|
559
|
-
>
|
|
334
|
+
<button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
|
|
560
335
|
{item.name}
|
|
561
336
|
</button>
|
|
562
337
|
))}
|
|
@@ -564,30 +339,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
564
339
|
)}
|
|
565
340
|
</div>
|
|
566
341
|
))}
|
|
567
|
-
{filteredGroups.length === 0 &&
|
|
568
|
-
<div className={styles.pickerEmpty}>No matches</div>
|
|
569
|
-
)}
|
|
342
|
+
{filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
|
|
570
343
|
</div>
|
|
571
344
|
<div className={styles.pickerDivider} />
|
|
572
345
|
</>
|
|
573
346
|
)}
|
|
574
347
|
<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
|
-
/>
|
|
348
|
+
<label className={styles.urlLabel}>{hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}</label>
|
|
349
|
+
<input ref={inputRef} className={styles.urlInput} type="text" defaultValue={src} placeholder="/MyPrototype/page" onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }} />
|
|
586
350
|
<div className={styles.urlActions}>
|
|
587
351
|
<button type="submit" className={styles.urlSave}>Save</button>
|
|
588
|
-
{!hasPicker &&
|
|
589
|
-
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
|
|
590
|
-
)}
|
|
352
|
+
{!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
|
|
591
353
|
</div>
|
|
592
354
|
</form>
|
|
593
355
|
</div>
|
|
@@ -598,51 +360,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
598
360
|
className={styles.iframeContainer}
|
|
599
361
|
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
600
362
|
>
|
|
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
|
-
)}
|
|
363
|
+
<iframe
|
|
364
|
+
ref={iframeRef}
|
|
365
|
+
src={iframeSrc}
|
|
366
|
+
className={styles.iframe}
|
|
367
|
+
style={{
|
|
368
|
+
width: width / scale,
|
|
369
|
+
height: height / scale,
|
|
370
|
+
transform: `scale(${scale})`,
|
|
371
|
+
transformOrigin: '0 0',
|
|
372
|
+
}}
|
|
373
|
+
title={`${prototypeTitle} prototype`}
|
|
374
|
+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
375
|
+
/>
|
|
644
376
|
</div>
|
|
645
|
-
|
|
646
377
|
{!interactive && !expanded && (
|
|
647
378
|
<div
|
|
648
379
|
className={overlayStyles.interactOverlay}
|
|
@@ -659,52 +390,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
659
390
|
enterInteractive()
|
|
660
391
|
}
|
|
661
392
|
}}
|
|
662
|
-
aria-label=
|
|
393
|
+
aria-label="Click to interact with prototype"
|
|
663
394
|
>
|
|
664
|
-
<span className={overlayStyles.interactHint}>
|
|
395
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
665
396
|
</div>
|
|
666
397
|
)}
|
|
667
398
|
</>
|
|
668
399
|
) : (
|
|
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>
|
|
400
|
+
<div className={styles.empty} onClick={() => onUpdate && setEditing(true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}>
|
|
401
|
+
<CollageFrameIcon size={36} />
|
|
402
|
+
<p>Click to set prototype URL</p>
|
|
677
403
|
</div>
|
|
678
404
|
)}
|
|
679
405
|
</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
|
-
)}
|
|
406
|
+
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
708
407
|
</WidgetWrapper>
|
|
709
408
|
{createPortal(
|
|
710
409
|
<div
|
|
@@ -712,26 +411,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
712
411
|
style={expanded ? undefined : { display: 'none' }}
|
|
713
412
|
onClick={() => setExpanded(false)}
|
|
714
413
|
onPointerDown={(e) => e.stopPropagation()}
|
|
715
|
-
onKeyDown={(e) =>
|
|
716
|
-
e.stopPropagation()
|
|
717
|
-
if (e.key === 'Escape') setExpanded(false)
|
|
718
|
-
}}
|
|
414
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
719
415
|
onWheel={(e) => e.stopPropagation()}
|
|
720
|
-
tabIndex={-1}
|
|
721
|
-
ref={(el) => { if (el && expanded) el.focus() }}
|
|
722
416
|
>
|
|
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>
|
|
417
|
+
<div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
|
|
418
|
+
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
|
|
735
419
|
</div>
|
|
736
420
|
</div>,
|
|
737
421
|
document.body
|