@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.26

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.
@@ -12,17 +12,25 @@
12
12
  * Props: { storyId, exportName, width, height }
13
13
  */
14
14
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
15
- import { getStoryData, getFlag } from '@dfosco/storyboard-core'
15
+ import { getStoryData } from '@dfosco/storyboard-core'
16
16
  import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
17
17
  import WidgetWrapper from './WidgetWrapper.jsx'
18
18
  import ResizeHandle from './ResizeHandle.jsx'
19
- import { uploadImage } from '../canvasApi.js'
20
- import { useIframeQueue } from './useViewportEntry.js'
19
+ import { useIframeDevLogs } from './iframeDevLogs.js'
20
+ import { useSnapshotCapture } from './useSnapshotCapture.js'
21
+ import { resolveCanvasTheme } from './embedTheme.js'
21
22
  import styles from './StoryWidget.module.css'
22
23
  import overlayStyles from './embedOverlay.module.css'
23
24
 
24
- function devLog(...args) {
25
- try { if (getFlag('dev-logs')) console.log('[canvas:story-widget]', ...args) } catch { /* */ }
25
+ function ComponentIcon({ size = 36 }) {
26
+ return (
27
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
28
+ <path d="M5.21173 15.1113L2.52473 12.4243C2.29041 12.1899 2.29041 11.8101 2.52473 11.5757L5.21173 8.88873C5.44605 8.65442 5.82595 8.65442 6.06026 8.88873L8.74727 11.5757C8.98158 11.8101 8.98158 12.1899 8.74727 12.4243L6.06026 15.1113C5.82595 15.3456 5.44605 15.3456 5.21173 15.1113Z" />
29
+ <path d="M11.5757 21.475L8.88874 18.788C8.65443 18.5537 8.65443 18.1738 8.88874 17.9395L11.5757 15.2525C11.8101 15.0182 12.19 15.0182 12.4243 15.2525L15.1113 17.9395C15.3456 18.1738 15.3456 18.5537 15.1113 18.788L12.4243 21.475C12.19 21.7094 11.8101 21.7094 11.5757 21.475Z" />
30
+ <path d="M11.5757 8.7475L8.88874 6.06049C8.65443 5.82618 8.65443 5.44628 8.88874 5.21197L11.5757 2.52496C11.8101 2.29065 12.19 2.29065 12.4243 2.52496L15.1113 5.21197C15.3456 5.44628 15.3456 5.82618 15.1113 6.06049L12.4243 8.7475C12.19 8.98181 11.8101 8.98181 11.5757 8.7475Z" />
31
+ <path d="M17.9396 15.1113L15.2526 12.4243C15.0183 12.1899 15.0183 11.8101 15.2526 11.5757L17.9396 8.88873C18.174 8.65442 18.5539 8.65442 18.7882 8.88873L21.4752 11.5757C21.7095 11.8101 21.7095 12.1899 21.4752 12.4243L18.7882 15.1113C18.5539 15.3456 18.174 15.3456 17.9396 15.1113Z" />
32
+ </svg>
33
+ )
26
34
  }
27
35
 
28
36
  function resolveStoryUrl(storyId, exportName) {
@@ -78,89 +86,46 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
78
86
  const exportName = props?.exportName || ''
79
87
  const width = props?.width
80
88
  const height = props?.height
81
-
82
- // Snapshot props for lazy loading
83
- const snapshotLight = props?.snapshotLight || null
84
- const snapshotDark = props?.snapshotDark || null
89
+ const snapshotLight = props?.snapshotLight || ''
90
+ const snapshotDark = props?.snapshotDark || ''
85
91
 
86
92
  const containerRef = useRef(null)
87
93
  const iframeRef = useRef(null)
94
+ const resizeTimerRef = useRef(null)
95
+ const captureOnReadyRef = useRef(false)
96
+ const exitSessionRef = useRef(0)
88
97
  const [interactive, setInteractive] = useState(false)
98
+ const [showIframe, setShowIframe] = useState(false)
99
+ const [iframeLoaded, setIframeLoaded] = useState(false)
89
100
  const [showCode, setShowCode] = useState(!!props?.showCode)
90
101
  const [sourceCode, setSourceCode] = useState(null)
91
102
  const [highlightedHtml, setHighlightedHtml] = useState(null)
92
103
  const [sourceLoading, setSourceLoading] = useState(false)
104
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
105
+ const [brokenSnaps, setBrokenSnaps] = useState({})
93
106
 
94
- // Theme tracking for snapshot selection
95
- const [canvasTheme, setCanvasTheme] = useState(() => {
96
- if (typeof localStorage === 'undefined') return 'light'
97
- const stored = localStorage.getItem('sb-color-scheme') || 'system'
98
- if (stored !== 'system') return stored
99
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
100
- })
107
+ // Resolve canvas theme reactive to theme changes
108
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasTheme())
101
109
 
102
110
  useEffect(() => {
103
- function onThemeChanged() {
104
- const stored = localStorage.getItem('sb-color-scheme') || 'system'
105
- if (stored !== 'system') { setCanvasTheme(stored); return }
106
- setCanvasTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
107
- }
108
- document.addEventListener('storyboard:theme:changed', onThemeChanged)
109
- return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
111
+ const readTheme = () => setCanvasTheme(resolveCanvasTheme())
112
+ document.addEventListener('storyboard:theme:changed', readTheme)
113
+ return () => document.removeEventListener('storyboard:theme:changed', readTheme)
110
114
  }, [])
111
115
 
112
- // Lazy loading state — only use snapshots that match this widget's ID
113
- const isDark = canvasTheme?.startsWith('dark')
114
- const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
115
- const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
116
- const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
117
- const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
118
- const hasSnapshot = !!currentSnapshot
119
-
120
- // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
121
- const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot, widgetId)
122
- const [preloadIframe, setPreloadIframe] = useState(hasSnapshot)
123
- const [iframeLoaded, setIframeLoaded] = useState(false)
124
- const [showIframe, setShowIframe] = useState(hasSnapshot)
125
- const [showSpinner, setShowSpinner] = useState(false)
126
- const capturingRef = useRef(false)
127
-
128
- devLog(widgetId, { hasSnapshot, queueReady, preloadIframe, showIframe, iframeLoaded, storyId })
129
-
130
- // Start loading when the queue grants this widget a slot
131
- useEffect(() => {
132
- if (queueReady && !preloadIframe) {
133
- devLog(widgetId, 'queue ready → loading iframe')
134
- setPreloadIframe(true)
135
- setShowIframe(true)
136
- }
137
- }, [queueReady, preloadIframe])
138
-
139
- // Release the queue slot once the iframe has loaded or user clicked to interact
140
- useEffect(() => {
141
- if (iframeLoaded) {
142
- devLog(widgetId, 'iframe loaded')
143
- releaseSlot()
144
- }
145
- }, [iframeLoaded, releaseSlot])
146
-
147
- // Click-to-interact: immediately start iframe and release queue slot for others
148
- const activateIframe = useCallback(() => {
149
- devLog(widgetId, 'user activated → jumping queue')
150
- setShowIframe(true)
151
- setPreloadIframe(true)
152
- releaseSlot()
153
- }, [releaseSlot, widgetId])
116
+ // Snapshot capture hook
117
+ const { iframeReady, requestCapture } = useSnapshotCapture({
118
+ iframeRef,
119
+ widgetId,
120
+ onUpdate,
121
+ canvasTheme,
122
+ })
154
123
 
155
- // Show spinner only after 500ms of loading
156
- useEffect(() => {
157
- if (showIframe && !iframeLoaded && hasSnapshot) {
158
- const timer = setTimeout(() => setShowSpinner(true), 500)
159
- return () => clearTimeout(timer)
160
- }
161
- setShowSpinner(false)
162
- }, [showIframe, iframeLoaded, hasSnapshot])
163
- const [storyIndexKey, setStoryIndexKey] = useState(0)
124
+ // Determine available snapshots for layered rendering
125
+ const isDark = canvasTheme?.startsWith('dark')
126
+ const hasLightSnap = !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
127
+ const hasDarkSnap = !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
128
+ const hasAnySnap = hasLightSnap || hasDarkSnap
164
129
 
165
130
  // Re-resolve story URL when the story index is live-patched (new story added)
166
131
  useEffect(() => {
@@ -180,90 +145,69 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
180
145
  })
181
146
  }, [onUpdate])
182
147
 
183
- const enterInteractive = useCallback(() => setInteractive(true), [])
148
+ const enterInteractive = useCallback(() => {
149
+ exitSessionRef.current++
150
+ setShowIframe(true)
151
+ setInteractive(true)
152
+ }, [])
153
+
154
+ useEffect(() => {
155
+ if (!showIframe) setIframeLoaded(false)
156
+ }, [showIframe])
184
157
 
158
+ // Exit interactive mode when clicking outside.
159
+ // Hides iframe immediately for a responsive feel, then captures
160
+ // snapshots in the background with the iframe hidden but still mounted.
185
161
  useEffect(() => {
186
162
  if (!interactive) return
187
163
  function handlePointerDown(e) {
188
164
  if (containerRef.current && !containerRef.current.contains(e.target)) {
165
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
166
+ if (chromeEl) return
167
+
189
168
  setInteractive(false)
169
+ if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
170
+ // Keep iframe mounted but hidden for background capture
171
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
172
+ const session = ++exitSessionRef.current
173
+ requestCapture({ force: true }).then(() => {
174
+ if (exitSessionRef.current !== session) return
175
+ setShowIframe(false)
176
+ })
177
+ } else {
178
+ setShowIframe(false)
179
+ }
190
180
  }
191
181
  }
192
182
  document.addEventListener('pointerdown', handlePointerDown)
193
183
  return () => document.removeEventListener('pointerdown', handlePointerDown)
194
- }, [interactive])
195
-
196
- // Listen for snapshot messages from the iframe
197
- useEffect(() => {
198
- function handleMessage(e) {
199
- if (!iframeRef.current?.contentWindow) return
200
- if (e.source !== iframeRef.current.contentWindow) return
201
-
202
- if (e.data?.type === 'storyboard:embed:snapshot') {
203
- if (e.data.error) {
204
- console.warn('[canvas] Story snapshot capture failed:', e.data.error)
205
- return
206
- }
207
- handleSnapshotResult(e.data.dataUrl)
208
- return
209
- }
210
-
211
- // snapshot-ready means the iframe content has fully rendered
212
- if (e.data?.type === 'storyboard:embed:snapshot-ready') {
213
- setIframeLoaded(true)
214
- if (onUpdate) requestSnapshotCapture()
215
- }
216
- }
217
- window.addEventListener('message', handleMessage)
218
- return () => window.removeEventListener('message', handleMessage)
219
- }, [onUpdate, canvasTheme])
220
-
221
- const requestSnapshotCapture = useCallback(() => {
222
- if (!iframeRef.current?.contentWindow || capturingRef.current) return
223
- capturingRef.current = true
224
- iframeRef.current.contentWindow.postMessage({
225
- type: 'storyboard:embed:capture',
226
- requestId: `story-snap-${Date.now()}`,
227
- }, '*')
228
- }, [])
229
-
230
- const handleSnapshotResult = useCallback(async (dataUrl) => {
231
- if (!dataUrl || !onUpdate || !widgetId) return
232
- capturingRef.current = false
233
- try {
234
- const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
235
- if (!result?.success || !result?.filename) return
236
- const imageUrl = `/_storyboard/canvas/images/${result.filename}`
237
- const themeKey = isDark ? 'snapshotDark' : 'snapshotLight'
238
- onUpdate?.({ [themeKey]: imageUrl })
239
- } catch (err) {
240
- console.warn('[canvas] Failed to upload story snapshot:', err)
241
- }
242
- }, [onUpdate, isDark, widgetId])
243
-
244
- // Re-capture after resize
245
- const resizeCaptureTimer = useRef(null)
246
- const triggerResizeCapture = useCallback(() => {
247
- if (!onUpdate) return
248
- clearTimeout(resizeCaptureTimer.current)
249
- resizeCaptureTimer.current = setTimeout(() => requestSnapshotCapture(), 2000)
250
- }, [requestSnapshotCapture, onUpdate])
184
+ }, [interactive, onUpdate, iframeLoaded, requestCapture])
251
185
 
252
186
  const handleResize = useCallback((w, h) => {
253
187
  onUpdate?.({ width: w, height: h })
254
- triggerResizeCapture()
255
- }, [onUpdate, triggerResizeCapture])
188
+ // Recapture snapshot after resize (debounced)
189
+ clearTimeout(resizeTimerRef.current)
190
+ resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
191
+ }, [onUpdate, requestCapture])
256
192
 
257
- // Re-capture for alternate theme variant when theme changes
258
- const prevThemeRef = useRef(canvasTheme)
193
+ // Capture snapshot on first iframe ready (when no existing snapshot)
259
194
  useEffect(() => {
260
- if (canvasTheme !== prevThemeRef.current && onUpdate && showIframe) {
261
- prevThemeRef.current = canvasTheme
262
- const timer = setTimeout(() => requestSnapshotCapture(), 3000)
263
- return () => clearTimeout(timer)
195
+ if (!iframeReady || !onUpdate) return
196
+ if (!hasAnySnap) {
197
+ requestCapture()
264
198
  }
265
- prevThemeRef.current = canvasTheme
266
- }, [canvasTheme, onUpdate, showIframe, requestSnapshotCapture])
199
+ }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
200
+
201
+ // Capture when iframe becomes ready after refresh-thumbnail requested it
202
+ useEffect(() => {
203
+ if (iframeReady && captureOnReadyRef.current) {
204
+ captureOnReadyRef.current = false
205
+ requestCapture()
206
+ }
207
+ }, [iframeReady, requestCapture])
208
+
209
+ // Cleanup resize timer on unmount
210
+ useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
267
211
 
268
212
  // Load source code when show-code is toggled on
269
213
  useEffect(() => {
@@ -350,15 +294,28 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
350
294
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
351
295
  window.open(`${base}${story._route}`, '_blank', 'noopener')
352
296
  }
297
+ } else if (actionId === 'refresh-thumbnail') {
298
+ if (iframeReady && iframeRef.current?.contentWindow) {
299
+ requestCapture()
300
+ } else {
301
+ captureOnReadyRef.current = true
302
+ setShowIframe(true)
303
+ }
353
304
  }
354
305
  },
355
- }), [storyId, showCode, toggleShowCode, copyCode])
306
+ }), [storyId, showCode, toggleShowCode, copyCode, iframeReady, requestCapture])
356
307
 
357
308
  const iframeSrc = useMemo(
358
309
  () => resolveStoryUrl(storyId, exportName),
359
310
  [storyId, exportName, storyIndexKey],
360
311
  )
361
312
 
313
+ useIframeDevLogs({
314
+ widget: 'StoryWidget',
315
+ loaded: showIframe && !showCode && Boolean(iframeSrc),
316
+ src: iframeSrc,
317
+ })
318
+
362
319
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
363
320
 
364
321
  // Error state — missing story or no route
@@ -367,7 +324,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
367
324
  <WidgetWrapper>
368
325
  <div className={styles.container} ref={containerRef}>
369
326
  <div className={styles.error}>
370
- <span className={styles.errorIcon}>📖</span>
327
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
371
328
  <span className={styles.errorText}>Missing story ID</span>
372
329
  </div>
373
330
  </div>
@@ -380,7 +337,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
380
337
  <WidgetWrapper>
381
338
  <div className={styles.container} ref={containerRef}>
382
339
  <div className={styles.error}>
383
- <span className={styles.errorIcon}>📖</span>
340
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
384
341
  <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
385
342
  </div>
386
343
  </div>
@@ -396,7 +353,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
396
353
  <WidgetWrapper>
397
354
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
398
355
  <div className={styles.header}>
399
- <span className={styles.headerIcon}>📖</span>
356
+ <span className={styles.headerIcon}><ComponentIcon size={16} /></span>
400
357
  <span className={styles.headerTitle}>{displayName}</span>
401
358
  </div>
402
359
  {showCode ? (
@@ -430,47 +387,55 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
430
387
  </div>
431
388
  ) : (
432
389
  <>
433
- {/* Snapshot image — shown until iframe is fully loaded */}
434
- {hasSnapshot && !(showIframe && iframeLoaded) && (
435
- <div className={styles.content}>
390
+ <div className={styles.content}>
391
+ {/* Snapshot layer both themes always in DOM for instant swap */}
392
+ {hasLightSnap && (
436
393
  <img
437
- src={(import.meta.env.BASE_URL || '/').replace(/\/$/, '') + currentSnapshot}
438
- alt={displayName}
394
+ src={snapshotLight}
439
395
  className={styles.snapshotImage}
396
+ style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
397
+ alt={`${displayName} snapshot`}
440
398
  draggable={false}
399
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
441
400
  />
442
- {showIframe && !iframeLoaded && showSpinner && (
443
- <div className={styles.snapshotSpinner}>
444
- <div className={styles.spinner} />
445
- </div>
446
- )}
447
- </div>
448
- )}
401
+ )}
402
+ {hasDarkSnap && (
403
+ <img
404
+ src={snapshotDark}
405
+ className={styles.snapshotImage}
406
+ style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
407
+ alt={`${displayName} snapshot`}
408
+ draggable={false}
409
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotDark]: true }))}
410
+ />
411
+ )}
449
412
 
450
- {/* Iframe — preloaded on hover, revealed after load */}
451
- {(preloadIframe || showIframe) && (
452
- <div
453
- className={styles.content}
454
- style={hasSnapshot && !(showIframe && iframeLoaded) ? { position: 'absolute', top: 31, left: 0, right: 0, bottom: 0, opacity: 0, pointerEvents: 'none' } : undefined}
455
- >
413
+ {/* Iframe layer — on top, transparent until loaded */}
414
+ {showIframe && (
456
415
  <iframe
457
416
  ref={iframeRef}
458
417
  src={iframeSrc}
459
418
  className={styles.iframe}
419
+ style={iframeLoaded ? undefined : { opacity: 0 }}
420
+ onLoad={() => setIframeLoaded(true)}
460
421
  title={displayName}
461
422
  />
462
- </div>
463
- )}
423
+ )}
424
+
425
+ {/* Placeholder — only when no snapshots and no iframe */}
426
+ {!hasAnySnap && !showIframe && (
427
+ <div className={styles.placeholder}>
428
+ <ComponentIcon size={36} />
429
+ <span className={styles.placeholderLabel}>{displayName}</span>
430
+ </div>
431
+ )}
432
+ </div>
464
433
 
465
434
  {!interactive && (
466
435
  <div
467
436
  className={overlayStyles.interactOverlay}
468
- onPointerEnter={() => {
469
- if (!preloadIframe) setPreloadIframe(true)
470
- }}
471
437
  onClick={(e) => {
472
438
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
473
- activateIframe()
474
439
  enterInteractive()
475
440
  }}
476
441
  role="button"
@@ -479,7 +444,6 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
479
444
  if (e.key === 'Enter' || e.key === ' ') {
480
445
  e.preventDefault()
481
446
  e.stopPropagation()
482
- activateIframe()
483
447
  enterInteractive()
484
448
  }
485
449
  }}
@@ -15,7 +15,7 @@
15
15
  display: flex;
16
16
  align-items: center;
17
17
  gap: 6px;
18
- padding: 6px 10px;
18
+ padding: 10px 10px;
19
19
  font-size: 12px;
20
20
  font-weight: 500;
21
21
  color: var(--fgColor-muted, #656d76);
@@ -28,7 +28,7 @@
28
28
  }
29
29
 
30
30
  .headerIcon {
31
- font-size: 13px;
31
+ display: inline-flex;
32
32
  flex-shrink: 0;
33
33
  }
34
34
 
@@ -38,57 +38,67 @@
38
38
  }
39
39
 
40
40
  .content {
41
+ position: relative;
41
42
  width: 100%;
42
- height: calc(100% - 31px);
43
- }
44
-
45
- .iframe {
46
- display: block;
47
- width: 100%;
48
- height: 100%;
49
- border: none;
50
- }
51
-
52
- .snapshotImage {
53
- display: block;
54
- width: 100%;
55
- height: 100%;
56
- object-fit: cover;
57
- object-position: top left;
43
+ height: calc(100% - 37px);
58
44
  }
59
45
 
60
- .snapshotSpinner {
46
+ .placeholder {
61
47
  position: absolute;
62
48
  inset: 0;
63
49
  display: flex;
50
+ flex-direction: column;
64
51
  align-items: center;
65
52
  justify-content: center;
66
- background: rgba(0, 0, 0, 0.08);
67
- animation: fadeIn 150ms ease;
53
+ gap: 8px;
54
+ color: var(--fgColor-muted, #656d76);
55
+ text-align: center;
56
+ }
57
+
58
+ .placeholderLabel {
59
+ font-size: 13px;
60
+ font-weight: 500;
68
61
  }
69
62
 
70
63
  .spinner {
71
64
  width: 24px;
72
65
  height: 24px;
73
- border: 2.5px solid var(--borderColor-default, #d0d7de);
74
- border-top-color: var(--fgColor-accent, #0969da);
66
+ border: 3px solid var(--borderColor-muted, #d0d7de);
67
+ border-top-color: var(--fgColor-accent, #2f81f7);
75
68
  border-radius: 50%;
76
- animation: spin 0.6s linear infinite;
69
+ animation: spin 0.8s linear infinite;
77
70
  }
78
71
 
79
72
  @keyframes spin {
73
+ from { transform: rotate(0deg); }
80
74
  to { transform: rotate(360deg); }
81
75
  }
82
76
 
83
- @keyframes fadeIn {
84
- from { opacity: 0; }
85
- to { opacity: 1; }
77
+ .iframe {
78
+ position: absolute;
79
+ inset: 0;
80
+ display: block;
81
+ width: 100%;
82
+ height: 100%;
83
+ border: none;
84
+ z-index: 1;
85
+ }
86
+
87
+ .snapshotImage {
88
+ position: absolute;
89
+ inset: 0;
90
+ width: 100%;
91
+ height: 100%;
92
+ object-fit: cover;
93
+ object-position: top left;
94
+ display: block;
95
+ pointer-events: none;
86
96
  }
87
97
 
88
98
  .codeView {
89
99
  display: flex;
90
100
  flex-direction: column;
91
- height: calc(100% - 31px);
101
+ height: calc(100% - 37px);
92
102
  overflow: hidden;
93
103
  }
94
104
 
@@ -204,8 +204,8 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
204
204
  url.searchParams.set('widget', widgetId)
205
205
  navigator.clipboard.writeText(url.toString()).catch(() => {})
206
206
  } else if (action === 'copy-widget-id') {
207
- const canvasName = window.location.pathname.split('/').filter(Boolean).pop() || ''
208
- navigator.clipboard.writeText(`${canvasName}/${widgetId}`).catch(() => {})
207
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
208
+ navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
209
209
  } else {
210
210
  onAction?.(action)
211
211
  }
@@ -436,6 +436,7 @@ export default function WidgetChrome({
436
436
  return (
437
437
  <div
438
438
  className={styles.chromeContainer}
439
+ data-widget-id={widgetId}
439
440
  data-tc-elevated={(hovered || selected) || undefined}
440
441
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
441
442
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}