@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15

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.
@@ -4,6 +4,7 @@ import { buildPrototypeIndex } from '@dfosco/storyboard-core'
4
4
  import WidgetWrapper from './WidgetWrapper.jsx'
5
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
6
6
  import { getEmbedChromeVars } from './embedTheme.js'
7
+ import { uploadImage } from '../canvasApi.js'
7
8
  import styles from './PrototypeEmbed.module.css'
8
9
  import overlayStyles from './embedOverlay.module.css'
9
10
 
@@ -30,25 +31,29 @@ function resolveCanvasThemeFromStorage() {
30
31
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
31
32
  }
32
33
 
33
- export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
34
+ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
34
35
  const src = readProp(props, 'src', prototypeEmbedSchema)
35
36
  const width = readProp(props, 'width', prototypeEmbedSchema)
36
37
  const height = readProp(props, 'height', prototypeEmbedSchema)
37
38
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
38
39
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
39
40
 
41
+ // Snapshot props for lazy loading
42
+ const snapshotLight = props?.snapshotLight || null
43
+ const snapshotDark = props?.snapshotDark || null
44
+
40
45
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
41
46
  const baseSegment = basePath.replace(/^\//, '')
42
47
  const rawSrc = useMemo(() => {
43
48
  if (!src) return ''
44
49
  if (/^https?:\/\//.test(src)) return src
45
- // Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
46
50
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
47
51
  if (baseSegment && cleaned.startsWith(basePath)) return cleaned
48
52
  if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
49
53
  return `${basePath}${cleaned}`
50
54
  }, [src, basePath, baseSegment])
51
55
 
56
+ const isExternal = /^https?:\/\//.test(rawSrc)
52
57
  const scale = zoom / 100
53
58
 
54
59
  const [editing, setEditing] = useState(false)
@@ -56,6 +61,28 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
56
61
  const [expanded, setExpanded] = useState(false)
57
62
  const [filter, setFilter] = useState('')
58
63
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
64
+
65
+ // Lazy loading state — only use snapshots that match this widget's ID
66
+ const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
67
+ const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
68
+ const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
69
+ const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
70
+ const hasSnapshot = !!currentSnapshot
71
+ const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot || isExternal)
72
+ const [iframeLoaded, setIframeLoaded] = useState(false)
73
+ const [showIframe, setShowIframe] = useState(!hasSnapshot || isExternal)
74
+ const [showSpinner, setShowSpinner] = useState(false)
75
+ const capturingRef = useRef(false)
76
+
77
+ // Show spinner only after 500ms of loading
78
+ useEffect(() => {
79
+ if (showIframe && !iframeLoaded && hasSnapshot) {
80
+ const timer = setTimeout(() => setShowSpinner(true), 500)
81
+ return () => clearTimeout(timer)
82
+ }
83
+ setShowSpinner(false)
84
+ }, [showIframe, iframeLoaded, hasSnapshot])
85
+
59
86
  const inputRef = useRef(null)
60
87
  const filterRef = useRef(null)
61
88
  const embedRef = useRef(null)
@@ -233,20 +260,100 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
233
260
  }
234
261
  }, [expanded])
235
262
 
236
- // Listen for navigation events from the embedded prototype iframe
263
+ // Listen for messages from the embedded prototype iframe
237
264
  useEffect(() => {
238
265
  function handleMessage(e) {
239
- if (e.source !== iframeRef.current?.contentWindow) return
240
- if (e.data?.type !== 'storyboard:embed:navigate') return
241
- const newSrc = e.data.src
242
- if (newSrc && newSrc !== src) {
243
- const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
244
- onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
266
+ if (!iframeRef.current?.contentWindow) return
267
+ if (e.source !== iframeRef.current.contentWindow) return
268
+
269
+ // Navigation events
270
+ if (e.data?.type === 'storyboard:embed:navigate') {
271
+ const newSrc = e.data.src
272
+ if (newSrc && newSrc !== src) {
273
+ const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
274
+ onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
275
+ }
276
+ return
277
+ }
278
+
279
+ // Snapshot capture responses
280
+ if (e.data?.type === 'storyboard:embed:snapshot') {
281
+ if (e.data.error) {
282
+ console.warn('[canvas] Snapshot capture failed:', e.data.error)
283
+ return
284
+ }
285
+ handleSnapshotResult(e.data.requestId, e.data.dataUrl)
286
+ return
287
+ }
288
+
289
+ // Snapshot-ready signal — iframe content has fully rendered
290
+ if (e.data?.type === 'storyboard:embed:snapshot-ready') {
291
+ setIframeLoaded(true)
292
+ if (onUpdate && !isExternal) requestSnapshotCapture()
245
293
  }
246
294
  }
247
295
  window.addEventListener('message', handleMessage)
248
296
  return () => window.removeEventListener('message', handleMessage)
249
- }, [src, props, onUpdate])
297
+ }, [src, props, onUpdate, isExternal])
298
+
299
+ // Request a snapshot capture from the iframe
300
+ const requestSnapshotCapture = useCallback(() => {
301
+ if (!iframeRef.current?.contentWindow || capturingRef.current || isExternal) return
302
+ capturingRef.current = true
303
+ const requestId = `snap-${Date.now()}`
304
+ iframeRef.current.contentWindow.postMessage({
305
+ type: 'storyboard:embed:capture',
306
+ requestId,
307
+ }, '*')
308
+ }, [isExternal])
309
+
310
+ // Handle a completed snapshot — upload and persist as widget prop
311
+ const handleSnapshotResult = useCallback(async (requestId, dataUrl) => {
312
+ if (!dataUrl || !onUpdate || !widgetId) return
313
+ capturingRef.current = false
314
+ try {
315
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
316
+ if (!result?.success || !result?.filename) return
317
+ const imageUrl = `/_storyboard/canvas/images/${result.filename}`
318
+ const themeKey = canvasTheme?.startsWith('dark') ? 'snapshotDark' : 'snapshotLight'
319
+ onUpdate?.({ [themeKey]: imageUrl })
320
+ } catch (err) {
321
+ console.warn('[canvas] Failed to upload snapshot:', err)
322
+ }
323
+ }, [onUpdate, canvasTheme, widgetId])
324
+
325
+ // Re-capture snapshots after resize (debounced)
326
+ const resizeCaptureTimer = useRef(null)
327
+ const triggerResizeCapture = useCallback(() => {
328
+ if (!onUpdate || isExternal) return
329
+ clearTimeout(resizeCaptureTimer.current)
330
+ resizeCaptureTimer.current = setTimeout(() => {
331
+ requestSnapshotCapture()
332
+ }, 2000)
333
+ }, [requestSnapshotCapture, isExternal, onUpdate])
334
+
335
+ // Re-capture when src changes (new prototype selected)
336
+ const prevSrcRef = useRef(src)
337
+ useEffect(() => {
338
+ if (src && src !== prevSrcRef.current && onUpdate && !isExternal && showIframe) {
339
+ prevSrcRef.current = src
340
+ // Wait for the new page to render
341
+ const timer = setTimeout(() => requestSnapshotCapture(), 4000)
342
+ return () => clearTimeout(timer)
343
+ }
344
+ prevSrcRef.current = src
345
+ }, [src, onUpdate, isExternal, showIframe, requestSnapshotCapture])
346
+
347
+ // Re-capture for the alternate theme variant when theme changes
348
+ const prevThemeRef = useRef(canvasTheme)
349
+ useEffect(() => {
350
+ if (canvasTheme !== prevThemeRef.current && onUpdate && !isExternal && showIframe) {
351
+ prevThemeRef.current = canvasTheme
352
+ const timer = setTimeout(() => requestSnapshotCapture(), 3000)
353
+ return () => clearTimeout(timer)
354
+ }
355
+ prevThemeRef.current = canvasTheme
356
+ }, [canvasTheme, onUpdate, isExternal, showIframe, requestSnapshotCapture])
250
357
 
251
358
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
252
359
 
@@ -261,15 +368,9 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
261
368
  setExpanded(true)
262
369
  } else if (actionId === 'open-external') {
263
370
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
264
- } else if (actionId === 'zoom-in') {
265
- const step = zoom < 75 ? 5 : 25
266
- onUpdate?.({ zoom: Math.min(200, zoom + step) })
267
- } else if (actionId === 'zoom-out') {
268
- const step = zoom <= 75 ? 5 : 25
269
- onUpdate?.({ zoom: Math.max(25, zoom - step) })
270
371
  }
271
372
  },
272
- }), [rawSrc, zoom, onUpdate])
373
+ }), [rawSrc])
273
374
 
274
375
  function handlePickRoute(route) {
275
376
  onUpdate?.({ src: route })
@@ -381,31 +482,61 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
381
482
  </div>
382
483
  ) : iframeSrc ? (
383
484
  <>
384
- <div
385
- ref={inlineContainerRef}
386
- className={styles.iframeContainer}
387
- style={expanded ? { visibility: 'hidden' } : undefined}
388
- >
389
- <iframe
390
- ref={iframeRef}
391
- src={iframeSrc}
392
- className={styles.iframe}
393
- style={{
394
- width: width / scale,
395
- height: height / scale,
396
- transform: `scale(${scale})`,
397
- transformOrigin: '0 0',
398
- }}
399
- title={label || 'Prototype embed'}
400
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
401
- />
402
- </div>
485
+ {/* Snapshot image — shown until iframe is fully loaded */}
486
+ {hasSnapshot && !(showIframe && iframeLoaded) && (
487
+ <div className={styles.iframeContainer}>
488
+ <img
489
+ src={basePath + currentSnapshot}
490
+ alt={label || 'Prototype preview'}
491
+ className={styles.snapshotImage}
492
+ style={{ width, height }}
493
+ draggable={false}
494
+ />
495
+ {showIframe && !iframeLoaded && showSpinner && (
496
+ <div className={styles.snapshotSpinner}>
497
+ <div className={styles.spinner} />
498
+ </div>
499
+ )}
500
+ </div>
501
+ )}
502
+
503
+ {/* Iframe — preloaded on hover, revealed after load */}
504
+ {(preloadIframe || showIframe) && (
505
+ <div
506
+ ref={inlineContainerRef}
507
+ className={styles.iframeContainer}
508
+ style={
509
+ expanded ? { visibility: 'hidden' }
510
+ : (hasSnapshot && !(showIframe && iframeLoaded)) ? { position: 'absolute', top: 0, left: 0, opacity: 0, pointerEvents: 'none' }
511
+ : undefined
512
+ }
513
+ >
514
+ <iframe
515
+ ref={iframeRef}
516
+ src={iframeSrc}
517
+ className={styles.iframe}
518
+ style={{
519
+ width: width / scale,
520
+ height: height / scale,
521
+ transform: `scale(${scale})`,
522
+ transformOrigin: '0 0',
523
+ }}
524
+ title={label || 'Prototype embed'}
525
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
526
+ />
527
+ </div>
528
+ )}
529
+
403
530
  {!interactive && !expanded && (
404
531
  <div
405
532
  className={overlayStyles.interactOverlay}
533
+ onPointerEnter={() => {
534
+ if (!preloadIframe) setPreloadIframe(true)
535
+ }}
406
536
  onClick={(e) => {
407
- // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
408
537
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
538
+ setShowIframe(true)
539
+ setPreloadIframe(true)
409
540
  enterInteractive()
410
541
  }}
411
542
  role="button"
@@ -414,6 +545,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
414
545
  if (e.key === 'Enter' || e.key === ' ') {
415
546
  e.preventDefault()
416
547
  e.stopPropagation()
548
+ setShowIframe(true)
549
+ setPreloadIframe(true)
417
550
  enterInteractive()
418
551
  }
419
552
  }}
@@ -453,6 +586,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
453
586
  function onUp() {
454
587
  document.removeEventListener('mousemove', onMove)
455
588
  document.removeEventListener('mouseup', onUp)
589
+ triggerResizeCapture()
456
590
  }
457
591
  document.addEventListener('mousemove', onMove)
458
592
  document.addEventListener('mouseup', onUp)
@@ -467,8 +601,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
467
601
  style={expanded ? undefined : { display: 'none' }}
468
602
  onClick={() => setExpanded(false)}
469
603
  onPointerDown={(e) => e.stopPropagation()}
470
- onKeyDown={(e) => e.stopPropagation()}
604
+ onKeyDown={(e) => {
605
+ e.stopPropagation()
606
+ if (e.key === 'Escape') setExpanded(false)
607
+ }}
471
608
  onWheel={(e) => e.stopPropagation()}
609
+ tabIndex={-1}
610
+ ref={(el) => { if (el && expanded) el.focus() }}
472
611
  >
473
612
  <div
474
613
  ref={modalContainerRef}
@@ -18,6 +18,40 @@
18
18
  display: block;
19
19
  }
20
20
 
21
+ .snapshotImage {
22
+ display: block;
23
+ object-fit: cover;
24
+ object-position: top left;
25
+ }
26
+
27
+ .snapshotSpinner {
28
+ position: absolute;
29
+ inset: 0;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ background: rgba(0, 0, 0, 0.08);
34
+ animation: fadeIn 150ms ease;
35
+ }
36
+
37
+ .spinner {
38
+ width: 24px;
39
+ height: 24px;
40
+ border: 2.5px solid var(--borderColor-default, #d0d7de);
41
+ border-top-color: var(--fgColor-accent, #0969da);
42
+ border-radius: 50%;
43
+ animation: spin 0.6s linear infinite;
44
+ }
45
+
46
+ @keyframes spin {
47
+ to { transform: rotate(360deg); }
48
+ }
49
+
50
+ @keyframes fadeIn {
51
+ from { opacity: 0; }
52
+ to { opacity: 1; }
53
+ }
54
+
21
55
  .empty {
22
56
  display: flex;
23
57
  align-items: center;
@@ -20,6 +20,11 @@
20
20
  box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
21
21
  }
22
22
 
23
+ /* Hide own border when parent widget slot is selected (avoid double focus ring) */
24
+ :global([data-widget-selected]) .sticky {
25
+ border-color: transparent;
26
+ }
27
+
23
28
  .text {
24
29
  padding: 16px 20px;
25
30
  margin: 0;