@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, subscribeCanvasTheme } from './embedTheme.js'
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 listInternalPrototypes(index) {
30
- const allProtos = []
31
- const sortedFolders = index.sorted?.title?.folders
32
- const sortedPrototypes = index.sorted?.title?.prototypes
33
- const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
34
- ? sortedFolders
35
- : (index.folders || [])
36
- const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
37
- ? sortedPrototypes
38
- : (index.prototypes || [])
39
-
40
- for (const folder of folderList) {
41
- for (const proto of folder.prototypes || []) {
42
- if (!proto.isExternal) allProtos.push(proto)
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, _setInteractive] = useState(false)
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, _setCanvasTheme] = useState('light')
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
- return buildPrototypeIndex()
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 allProtos = listInternalPrototypes(idx)
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 prototypeName = useMemo(() => {
214
- const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
215
- if (!currentRoute) return ''
216
-
217
- let bestMatchName = ''
218
- let bestMatchLength = -1
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
- }
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
- return bestMatchName
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
- useEffect(() => {
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, onUpdate, isExternal, iframeLoaded, requestCapture])
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
- if (!iframeReady || !onUpdate || isExternal) return
340
- if (!hasSnap) {
341
- requestCapture()
184
+ function readToolbarTheme() {
185
+ setCanvasTheme(resolveCanvasThemeFromStorage())
342
186
  }
343
- }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
344
-
345
- // Capture when iframe becomes ready after refresh-thumbnail requested it
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 DOM node between inline container and modal.
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
- target.moveBefore(iframe, target.firstChild)
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
- target.moveBefore(iframe, null)
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 messages from the embedded prototype iframe
230
+ // Listen for navigation events from the embedded prototype iframe
431
231
  useEffect(() => {
432
232
  function handleMessage(e) {
433
- if (!iframeRef.current?.contentWindow) return
434
- if (e.source !== iframeRef.current.contentWindow) return
435
-
436
- // Navigation events
437
- if (e.data?.type === 'storyboard:embed:navigate') {
438
- const newSrc = e.data.src
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 === 'refresh-thumbnail') {
472
- if (iframeReady && iframeRef.current?.contentWindow) {
473
- requestCapture()
474
- } else {
475
- captureOnReadyRef.current = true
476
- setShowIframe(true)
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, iframeReady, requestCapture])
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
- {hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}
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
- {/* Snapshot layer — single image */}
602
- {hasSnap && (
603
- <img
604
- src={snapshot}
605
- className={styles.snapshotImage}
606
- alt={`${prototypeTitle} snapshot`}
607
- draggable={false}
608
- onError={() => {
609
- console.log(`[embed:${widgetId}] snapshot img onError: ${snapshot?.slice(0, 60)}`)
610
- setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))
611
- // Clear the broken snapshot from widget data so we stop trying
612
- onUpdate?.({ snapshot: '' })
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={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
393
+ aria-label="Click to interact with prototype"
663
394
  >
664
- <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
395
+ <span className={overlayStyles.interactHint}>Click to interact</span>
665
396
  </div>
666
397
  )}
667
398
  </>
668
399
  ) : (
669
- <div
670
- className={styles.empty}
671
- onDoubleClick={() => setEditing(true)}
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
- ref={modalContainerRef}
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