@dfosco/storyboard-react 4.0.0-beta.35 → 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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.35",
3
+ "version": "4.0.0-beta.36",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.35",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.35",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.36",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.36",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -133,19 +133,22 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
133
133
  .filter(Boolean)
134
134
  }, [pickerGroups, filter])
135
135
 
136
- const prototypeName = useMemo(() => {
137
- if (!src) return ''
136
+ const prototypeTitle = useMemo(() => {
137
+ if (!src) return label || 'Prototype'
138
+ const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
138
139
  for (const group of pickerGroups) {
139
140
  for (const item of group.items) {
140
141
  const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
141
- const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
142
- if (cleanRoute === cleanSrc) return item.name
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}`
146
+ }
143
147
  }
144
148
  }
145
- return ''
146
- }, [src, pickerGroups])
149
+ return label || 'Prototype'
150
+ }, [src, label, pickerGroups])
147
151
 
148
- const prototypeTitle = prototypeName || label || 'Prototype'
149
152
  const hasPicker = pickerGroups.length > 0
150
153
 
151
154
  useIframeDevLogs({
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * Renders a story at its route URL inside an iframe on canvas.
3
3
  *
4
- * Works like PrototypeEmbed: the story has its own route (e.g. /components/button-patterns)
5
- * and this widget iframes that URL with ?export=ExportName&_sb_embed for single-export mode.
6
- *
7
4
  * Features:
8
- * - Title bar showing story name + export (like Figma embed)
5
+ * - Title bar showing story name + export
9
6
  * - "Show code" action toggles between iframe and source view
10
7
  * - "Copy code" action copies the story source to clipboard
11
8
  *
@@ -17,9 +14,6 @@ import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/hi
17
14
  import WidgetWrapper from './WidgetWrapper.jsx'
18
15
  import ResizeHandle from './ResizeHandle.jsx'
19
16
  import { useIframeDevLogs } from './iframeDevLogs.js'
20
- import { useSnapshotCapture } from './useSnapshotCapture.js'
21
- import { subscribeCanvasTheme } from './embedTheme.js'
22
- import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
23
17
  import styles from './StoryWidget.module.css'
24
18
  import overlayStyles from './embedOverlay.module.css'
25
19
 
@@ -28,58 +22,32 @@ function ComponentIcon({ size = 36 }) {
28
22
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
29
23
  <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" />
30
24
  <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" />
31
- <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" />
32
- <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" />
25
+ <path d="M17.9395 15.1113L15.2525 12.4243C15.0182 12.1899 15.0182 11.8101 15.2525 11.5757L17.9395 8.88873C18.1738 8.65442 18.5537 8.65442 18.788 8.88873L21.475 11.5757C21.7094 11.8101 21.7094 12.1899 21.475 12.4243L18.788 15.1113C18.5537 15.3456 18.1738 15.3456 17.9395 15.1113Z" />
26
+ <path d="M11.5757 8.74727L8.88874 6.06026C8.65443 5.82595 8.65443 5.44605 8.88874 5.21173L11.5757 2.52473C11.8101 2.29041 12.19 2.29041 12.4243 2.52473L15.1113 5.21173C15.3456 5.44605 15.3456 5.82595 15.1113 6.06026L12.4243 8.74727C12.19 8.98158 11.8101 8.98158 11.5757 8.74727Z" />
33
27
  </svg>
34
28
  )
35
29
  }
36
30
 
37
31
  function resolveStoryUrl(storyId, exportName) {
38
32
  const story = getStoryData(storyId)
39
- if (!story?._route) return null
40
-
33
+ if (!story?._route) return ''
41
34
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
42
- const route = story._route
43
- const params = new URLSearchParams({ _sb_embed: '1', _sb_theme_target: 'prototype' })
35
+ const params = new URLSearchParams()
44
36
  if (exportName) params.set('export', exportName)
45
-
46
- return `${base}${route}?${params}`
47
- }
48
-
49
- /** Resolve a module path with the app base URL for dynamic imports. */
50
- function resolveModulePath(modulePath) {
51
- if (!modulePath || !modulePath.startsWith('/')) return modulePath
52
- const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
53
- return base ? `${base}${modulePath}` : modulePath
37
+ params.set('_sb_embed', '')
38
+ return `${base}${story._route}?${params}`
54
39
  }
55
40
 
56
- /** Cache for the static story sources JSON fetched in prod builds. */
57
- let _storySourcesCache = null
41
+ const _storySourcesCache = {}
58
42
 
59
- /**
60
- * Fetch story source code. In dev, uses Vite's ?raw dynamic import.
61
- * In prod, fetches from the build-time _storyboard/stories/sources.json.
62
- */
63
43
  async function fetchStorySource(modulePath) {
64
- // Dev: use Vite's ?raw import for live source
65
- if (import.meta.env.DEV) {
66
- const mod = await import(/* @vite-ignore */ `${resolveModulePath(modulePath)}?raw`)
67
- return mod.default || ''
68
- }
69
-
70
- // Prod: load from static JSON endpoint (same pattern as inspector.json)
71
- if (!_storySourcesCache) {
72
- const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
73
- const res = await fetch(`${base}/_storyboard/stories/sources.json`)
74
- if (!res.ok) throw new Error(`Story sources not available (${res.status})`)
75
- _storySourcesCache = await res.json()
76
- }
77
-
78
- // _storyModule is like "/src/canvas/stories/foo.story.jsx" — strip leading /
79
- const key = modulePath.startsWith('/') ? modulePath.slice(1) : modulePath
80
- const source = _storySourcesCache[key]
81
- if (source == null) throw new Error(`Source not found for ${key}`)
82
- return source
44
+ if (modulePath in _storySourcesCache) return _storySourcesCache[modulePath]
45
+ const url = modulePath.startsWith('/') ? modulePath : `/${modulePath}`
46
+ const res = await fetch(`${url}?raw`)
47
+ if (!res.ok) throw new Error(`Failed to fetch ${url}`)
48
+ const code = await res.text()
49
+ _storySourcesCache[modulePath] = code
50
+ return code
83
51
  }
84
52
 
85
53
  export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
@@ -87,60 +55,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
87
55
  const exportName = props?.exportName || ''
88
56
  const width = props?.width
89
57
  const height = props?.height
90
- const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
91
58
 
92
59
  const containerRef = useRef(null)
93
60
  const iframeRef = useRef(null)
94
- const resizeTimerRef = useRef(null)
95
- const captureOnReadyRef = useRef(false)
96
- const exitSessionRef = useRef(0)
97
- const refreshMetaRef = useRef(null)
98
61
  const [interactive, setInteractive] = useState(false)
99
- const [showIframe, setShowIframe] = useState(false)
100
- const [iframeLoaded, setIframeLoaded] = useState(false)
101
62
  const [showCode, setShowCode] = useState(!!props?.showCode)
102
63
  const [sourceCode, setSourceCode] = useState(null)
103
64
  const [highlightedHtml, setHighlightedHtml] = useState(null)
104
65
  const [sourceLoading, setSourceLoading] = useState(false)
105
66
  const [storyIndexKey, setStoryIndexKey] = useState(0)
106
- const [brokenSnaps, setBrokenSnaps] = useState({})
107
67
 
108
- // Resolve canvas theme reactive to theme changes
109
- const [canvasTheme, setCanvasTheme] = useState('light')
110
-
111
- useEffect(() => subscribeCanvasTheme({
112
- anchorRef: containerRef,
113
- onTheme: setCanvasTheme,
114
- }), [])
115
-
116
- // On canvas theme change, enqueue a background snapshot refresh
117
- const canvasThemeInitRef = useRef(true)
118
- useEffect(() => {
119
- if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
120
- if (!onUpdate || interactive) return
121
- const rect = containerRef.current?.getBoundingClientRect()
122
- enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
123
- return new Promise((resolve) => {
124
- refreshMetaRef.current = { revealOrder, batchStart, resolve }
125
- captureOnReadyRef.current = true
126
- setShowIframe(true)
127
- setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
128
- })
129
- }, rect ? { x: rect.left, y: rect.top } : undefined)
130
- }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
131
-
132
- // Snapshot capture hook
133
- const { iframeReady, requestCapture } = useSnapshotCapture({
134
- iframeRef,
135
- widgetId,
136
- onUpdate,
137
- showIframe,
138
- })
139
-
140
- // Single snapshot
141
- const hasSnap = !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
142
-
143
- // Re-resolve story URL when the story index is live-patched (new story added)
68
+ // Re-resolve story URL when the story index is live-patched
144
69
  useEffect(() => {
145
70
  const handler = () => setStoryIndexKey((k) => k + 1)
146
71
  document.addEventListener('storyboard:story-index-changed', handler)
@@ -150,116 +75,30 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
150
75
  const toggleShowCode = useCallback(() => {
151
76
  setShowCode((v) => {
152
77
  const next = !v
153
- // Persist to canvas JSONL in dev
154
- if (onUpdate) {
155
- onUpdate({ showCode: next })
156
- }
78
+ if (onUpdate) onUpdate({ showCode: next })
157
79
  return next
158
80
  })
159
81
  }, [onUpdate])
160
82
 
161
- const enterInteractive = useCallback(() => {
162
- exitSessionRef.current++
163
- cancelRefresh(widgetId)
164
- setShowIframe(true)
165
- setInteractive(true)
166
- }, [widgetId])
167
-
168
- useEffect(() => {
169
- if (!showIframe) setIframeLoaded(false)
170
- }, [showIframe])
83
+ const enterInteractive = useCallback(() => setInteractive(true), [])
171
84
 
172
- // Exit interactive mode when clicking outside.
173
- // Hides iframe immediately for a responsive feel, then captures
174
- // snapshots in the background with the iframe hidden but still mounted.
85
+ // Exit interactive mode when clicking outside
175
86
  useEffect(() => {
176
87
  if (!interactive) return
177
88
  function handlePointerDown(e) {
178
89
  if (containerRef.current && !containerRef.current.contains(e.target)) {
179
90
  const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
180
91
  if (chromeEl) return
181
-
182
92
  setInteractive(false)
183
- if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
184
- if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
185
- const session = ++exitSessionRef.current
186
- setTimeout(() => {
187
- if (exitSessionRef.current !== session) return
188
- requestCapture({ force: true }).then((updates) => {
189
- if (exitSessionRef.current !== session) return
190
- const snap = updates?.snapshot
191
- if (snap) {
192
- const img = new Image()
193
- const done = () => {
194
- if (exitSessionRef.current === session) setShowIframe(false)
195
- }
196
- img.onload = done
197
- img.onerror = done
198
- img.src = snap
199
- setTimeout(done, 2000)
200
- } else {
201
- setShowIframe(false)
202
- }
203
- })
204
- }, 0)
205
- } else {
206
- setShowIframe(false)
207
- }
208
93
  }
209
94
  }
210
95
  document.addEventListener('pointerdown', handlePointerDown)
211
96
  return () => document.removeEventListener('pointerdown', handlePointerDown)
212
- }, [interactive, onUpdate, iframeLoaded, requestCapture])
97
+ }, [interactive, widgetId])
213
98
 
214
99
  const handleResize = useCallback((w, h) => {
215
100
  onUpdate?.({ width: w, height: h })
216
- // Recapture snapshot after resize (debounced)
217
- clearTimeout(resizeTimerRef.current)
218
- resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
219
- }, [onUpdate, requestCapture])
220
-
221
- // Capture snapshot on first iframe ready (when no existing snapshot)
222
- useEffect(() => {
223
- if (!iframeReady || !onUpdate) return
224
- if (!hasSnap) {
225
- requestCapture()
226
- }
227
- }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
228
-
229
- // Capture when iframe becomes ready after refresh-thumbnail requested it
230
- useEffect(() => {
231
- if (iframeReady && captureOnReadyRef.current) {
232
- captureOnReadyRef.current = false
233
- requestCapture().then((updates) => {
234
- const meta = refreshMetaRef.current
235
- if (meta) {
236
- refreshMetaRef.current = null
237
- const snap = updates?.snapshot
238
- const reveal = () => {
239
- if (snap) {
240
- const img = new Image()
241
- const done = () => setShowIframe(false)
242
- img.onload = done
243
- img.onerror = done
244
- img.src = snap
245
- setTimeout(done, 2000)
246
- } else {
247
- setShowIframe(false)
248
- }
249
- meta.resolve(!!snap)
250
- }
251
- // Wait for our reveal slot in the wave
252
- const elapsed = Date.now() - meta.batchStart
253
- const targetTime = meta.revealOrder * REVEAL_INTERVAL
254
- const wait = Math.max(0, targetTime - elapsed)
255
- setTimeout(reveal, wait)
256
- }
257
- })
258
- }
259
- }, [iframeReady, requestCapture])
260
-
261
- // Cleanup resize timer on unmount
262
- useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
101
+ }, [onUpdate])
263
102
 
264
103
  // Load source code when show-code is toggled on
265
104
  useEffect(() => {
@@ -269,65 +108,43 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
269
108
  setSourceCode('// Source not available')
270
109
  return
271
110
  }
272
-
273
111
  let cancelled = false
274
112
  setSourceLoading(true)
275
-
276
113
  fetchStorySource(story._storyModule)
277
- .then((code) => {
278
- if (cancelled) return
279
- setSourceCode(code || '// Empty file')
280
- setSourceLoading(false)
281
- })
282
- .catch(() => {
283
- if (cancelled) return
284
- setSourceCode('// Failed to load source')
285
- setSourceLoading(false)
286
- })
287
-
288
- return () => {
289
- cancelled = true
290
- setSourceLoading(false)
291
- }
114
+ .then((code) => { if (!cancelled) { setSourceCode(code || '// Empty file'); setSourceLoading(false) } })
115
+ .catch(() => { if (!cancelled) { setSourceCode('// Failed to load source'); setSourceLoading(false) } })
116
+ return () => { cancelled = true; setSourceLoading(false) }
292
117
  }, [showCode, sourceCode, storyId])
293
118
 
294
- // Re-highlight when the code-box theme changes (storyboard:theme:changed event).
119
+ // Re-highlight when theme changes
295
120
  const [codeThemeKey, setCodeThemeKey] = useState(0)
296
121
  useEffect(() => {
297
- function onThemeChanged() {
298
- setCodeThemeKey((k) => k + 1)
299
- }
122
+ function onThemeChanged() { setCodeThemeKey((k) => k + 1) }
300
123
  document.addEventListener('storyboard:theme:changed', onThemeChanged)
301
124
  return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
302
125
  }, [])
303
126
 
304
- // Syntax-highlight source code using the inspector highlighter.
305
- // Uses the current code-box theme (data-sb-code-theme) set by the theme store.
127
+ // Syntax-highlight source code
306
128
  useEffect(() => {
307
129
  if (!sourceCode) return
308
130
  let cancelled = false
309
131
  createInspectorHighlighter().then((hl) => {
310
132
  if (cancelled) return
311
133
  const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
312
- const html = hl.codeToHtml(sourceCode, { lang })
313
- setHighlightedHtml(html)
134
+ setHighlightedHtml(hl.codeToHtml(sourceCode, { lang }))
314
135
  })
315
136
  return () => { cancelled = true }
316
137
  }, [sourceCode, storyId, codeThemeKey])
317
138
 
318
139
  const copyCode = useCallback(async () => {
319
- if (sourceCode) {
320
- await navigator.clipboard?.writeText(sourceCode)
321
- return
322
- }
323
- // Load source on demand if not already loaded
140
+ if (sourceCode) { await navigator.clipboard?.writeText(sourceCode); return }
324
141
  const story = getStoryData(storyId)
325
142
  if (!story?._storyModule) return
326
143
  try {
327
144
  const code = await fetchStorySource(story._storyModule)
328
145
  setSourceCode(code)
329
146
  await navigator.clipboard?.writeText(code)
330
- } catch { /* ignore */ }
147
+ } catch { /* */ }
331
148
  }, [sourceCode, storyId])
332
149
 
333
150
  useImperativeHandle(ref, () => ({
@@ -336,26 +153,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
336
153
  return undefined
337
154
  },
338
155
  handleAction(actionId) {
339
- if (actionId === 'show-code') {
340
- toggleShowCode()
341
- } else if (actionId === 'copy-code') {
342
- copyCode()
343
- } else if (actionId === 'open-external') {
156
+ if (actionId === 'show-code') toggleShowCode()
157
+ else if (actionId === 'copy-code') copyCode()
158
+ else if (actionId === 'open-external') {
344
159
  const story = getStoryData(storyId)
345
160
  if (story?._route) {
346
161
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
347
162
  window.open(`${base}${story._route}`, '_blank', 'noopener')
348
163
  }
349
- } else if (actionId === 'refresh-thumbnail') {
350
- if (iframeReady && iframeRef.current?.contentWindow) {
351
- requestCapture()
352
- } else {
353
- captureOnReadyRef.current = true
354
- setShowIframe(true)
355
- }
356
164
  }
357
165
  },
358
- }), [storyId, showCode, toggleShowCode, copyCode, iframeReady, requestCapture])
166
+ }), [storyId, showCode, toggleShowCode, copyCode])
359
167
 
360
168
  const iframeSrc = useMemo(
361
169
  () => resolveStoryUrl(storyId, exportName),
@@ -364,13 +172,12 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
364
172
 
365
173
  useIframeDevLogs({
366
174
  widget: 'StoryWidget',
367
- loaded: showIframe && !showCode && Boolean(iframeSrc),
175
+ loaded: interactive && !showCode && Boolean(iframeSrc),
368
176
  src: iframeSrc,
369
177
  })
370
178
 
371
179
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
372
180
 
373
- // Error state — missing story or no route
374
181
  if (!storyId) {
375
182
  return (
376
183
  <WidgetWrapper>
@@ -418,61 +225,25 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
418
225
  >
419
226
  <div className={styles.codeHeader}>
420
227
  <span className={styles.codeLabel}>{storyId}.story.jsx</span>
421
- <button
422
- className={styles.codeCloseBtn}
423
- onClick={() => setShowCode(false)}
424
- aria-label="Close code view"
425
- >×</button>
228
+ <button className={styles.codeCloseBtn} onClick={() => setShowCode(false)} aria-label="Close code view">×</button>
426
229
  </div>
427
230
  {sourceLoading ? (
428
231
  <div className={styles.codeLoading}>Loading…</div>
429
232
  ) : highlightedHtml ? (
430
- <div
431
- className={styles.codeBlock}
432
- dangerouslySetInnerHTML={{ __html: highlightedHtml }}
433
- />
233
+ <div className={styles.codeBlock} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
434
234
  ) : (
435
- <pre className={styles.codeBlock}>
436
- <code>{sourceCode || ''}</code>
437
- </pre>
235
+ <pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
438
236
  )}
439
237
  </div>
440
238
  ) : (
441
239
  <>
442
240
  <div className={styles.content}>
443
- {/* Snapshot layer — single image */}
444
- {hasSnap && (
445
- <img
446
- src={snapshot}
447
- className={styles.snapshotImage}
448
- alt={`${displayName} snapshot`}
449
- draggable={false}
450
- onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
451
- />
452
- )}
453
-
454
- {/* Iframe layer — on top, transparent until loaded */}
455
- {showIframe && (
456
- <iframe
457
- ref={iframeRef}
458
- src={iframeSrc}
459
- className={styles.iframe}
460
- style={{
461
- ...(iframeLoaded ? undefined : { opacity: 0 }),
462
- transition: 'opacity 150ms ease',
463
- }}
464
- onLoad={() => setIframeLoaded(true)}
465
- title={displayName}
466
- />
467
- )}
468
-
469
- {/* Placeholder — only when no snapshot and no iframe */}
470
- {!hasSnap && !showIframe && (
471
- <div className={styles.placeholder}>
472
- <ComponentIcon size={36} />
473
- <span className={styles.placeholderLabel}>{displayName}</span>
474
- </div>
475
- )}
241
+ <iframe
242
+ ref={iframeRef}
243
+ src={iframeSrc}
244
+ className={styles.iframe}
245
+ title={displayName}
246
+ />
476
247
  </div>
477
248
 
478
249
  {!interactive && (
@@ -491,22 +262,15 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
491
262
  enterInteractive()
492
263
  }
493
264
  }}
494
- aria-label={hasSnap ? 'Click to interact with story component' : 'Click to open story component'}
265
+ aria-label="Click to interact"
495
266
  >
496
- <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
267
+ <span className={overlayStyles.interactHint}>Click to interact</span>
497
268
  </div>
498
269
  )}
499
270
  </>
500
271
  )}
501
- {resizable && (
502
- <ResizeHandle
503
- targetRef={containerRef}
504
- minWidth={100}
505
- minHeight={60}
506
- onResize={handleResize}
507
- />
508
- )}
509
272
  </div>
273
+ {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
510
274
  </WidgetWrapper>
511
275
  )
512
276
  })
@@ -1,111 +0,0 @@
1
- /**
2
- * Concurrent refresh queue for bulk snapshot recapture (e.g. on theme change).
3
- *
4
- * Captures run in parallel (up to MAX_CONCURRENT) for speed, but REVEALS are
5
- * staggered on a fixed timeline — widget 0 reveals at 0ms, widget 1 at
6
- * REVEAL_INTERVAL ms, widget 2 at 2×REVEAL_INTERVAL ms, etc., all relative to
7
- * batch start. This creates a clean, predictable wave sweep regardless of how
8
- * fast each capture completes.
9
- *
10
- * After a batch completes, any widgets that failed are re-enqueued for a
11
- * single retry pass.
12
- *
13
- * Sorted spatially (top-to-bottom, left-to-right) before assigning reveal slots.
14
- * Supports cancellation by widget ID.
15
- */
16
- const queue = []
17
- let running = 0
18
- let drainScheduled = false
19
- let batchTotal = 0
20
- let batchDone = 0
21
- const batchFailed = []
22
-
23
- const MAX_CONCURRENT = 4
24
- export const REVEAL_INTERVAL = 200
25
-
26
- /**
27
- * Enqueue a snapshot refresh task for a widget.
28
- * @param {string} widgetId — unique widget identifier (for cancellation)
29
- * @param {(meta: { revealOrder: number, batchStart: number }) => Promise<boolean>} fn
30
- * Must resolve to `true` on success, `false` on failure.
31
- * @param {{ x: number, y: number }} [pos] — spatial position for wave ordering
32
- */
33
- export function enqueueRefresh(widgetId, fn, pos) {
34
- console.log(`[refreshQueue] enqueue: ${widgetId}, queueLen=${queue.length}`)
35
- const existing = queue.findIndex(item => item.widgetId === widgetId)
36
- if (existing !== -1) queue.splice(existing, 1)
37
-
38
- queue.push({ widgetId, fn, x: pos?.x ?? 0, y: pos?.y ?? 0 })
39
- scheduleDrain()
40
- }
41
-
42
- /**
43
- * Cancel a pending refresh for a widget (e.g. user activated it manually).
44
- */
45
- export function cancelRefresh(widgetId) {
46
- const idx = queue.findIndex(item => item.widgetId === widgetId)
47
- if (idx !== -1) queue.splice(idx, 1)
48
- }
49
-
50
- function scheduleDrain() {
51
- if (drainScheduled) return
52
- drainScheduled = true
53
- // Batch all enqueueRefresh calls from the same React commit, then sort
54
- // spatially and assign reveal slots before starting captures.
55
- setTimeout(() => {
56
- drainScheduled = false
57
- queue.sort((a, b) => a.y - b.y || a.x - b.x)
58
- const batchStart = Date.now()
59
- batchTotal = queue.length
60
- batchDone = 0
61
- batchFailed.length = 0
62
- queue.forEach((item, i) => {
63
- item.revealOrder = i
64
- item.batchStart = batchStart
65
- item.isRetry = item.isRetry || false
66
- })
67
- drain()
68
- }, 0)
69
- }
70
-
71
- function onTaskDone(success, item) {
72
- batchDone++
73
- console.log(`[refreshQueue] taskDone: ${item.widgetId}, success=${success}, done=${batchDone}/${batchTotal}, retry=${item.isRetry}`)
74
- if (!success && !item.isRetry) {
75
- batchFailed.push(item)
76
- }
77
- // When batch is complete, re-enqueue failures for one retry
78
- if (batchDone >= batchTotal && batchFailed.length > 0) {
79
- console.log(`[refreshQueue] batch complete, retrying ${batchFailed.length} failed`)
80
- const retries = batchFailed.splice(0)
81
- for (const failed of retries) {
82
- failed.isRetry = true
83
- queue.push(failed)
84
- }
85
- batchTotal = queue.length
86
- batchDone = 0
87
- const batchStart = Date.now()
88
- queue.forEach((item, i) => {
89
- item.revealOrder = i
90
- item.batchStart = batchStart
91
- })
92
- }
93
- drain()
94
- }
95
-
96
- function drain() {
97
- if (running >= MAX_CONCURRENT || queue.length === 0) return
98
-
99
- running++
100
- const item = queue.shift()
101
- const { fn, revealOrder, batchStart } = item
102
- Promise.resolve()
103
- .then(() => fn({ revealOrder, batchStart }))
104
- .then((success) => { running--; onTaskDone(success !== false, item) })
105
- .catch(() => { running--; onTaskDone(false, item) })
106
-
107
- // Start next capture immediately (no stagger on capture start — only reveals are staggered)
108
- if (queue.length > 0 && running < MAX_CONCURRENT) {
109
- drain()
110
- }
111
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * useSnapshotCapture — parent-side capture orchestration hook.
3
- *
4
- * Listens for snapshot-ready signals from an embedded iframe and
5
- * provides a requestCapture() function that triggers a single capture
6
- * of whatever the iframe is currently showing.
7
- *
8
- * Saves a single `snapshot` prop — overwritten every time.
9
- * Only active in dev mode (when onUpdate is provided).
10
- */
11
- import { useState, useEffect, useCallback, useRef } from 'react'
12
- import { uploadImage } from '../canvasApi.js'
13
-
14
- const CAPTURE_TIMEOUT = 3000
15
-
16
- /**
17
- * Run a single capture request against the iframe.
18
- * Returns the dataUrl or null on failure.
19
- */
20
- function captureOnce(iframeContentWindow, requestId, listeners) {
21
- return new Promise((resolve) => {
22
- const timer = setTimeout(() => {
23
- cleanup()
24
- resolve(null)
25
- }, CAPTURE_TIMEOUT)
26
-
27
- function cleanup() {
28
- clearTimeout(timer)
29
- const idx = listeners.indexOf(handler)
30
- if (idx !== -1) listeners.splice(idx, 1)
31
- }
32
-
33
- function handler(data) {
34
- if (data.requestId !== requestId) return
35
- cleanup()
36
- if (data.error || !data.dataUrl) {
37
- if (data.error) console.warn('[snapshot] Capture failed:', data.error)
38
- resolve(null)
39
- } else {
40
- resolve(data.dataUrl)
41
- }
42
- }
43
-
44
- listeners.push(handler)
45
- iframeContentWindow.postMessage({
46
- type: 'storyboard:embed:capture',
47
- requestId,
48
- }, '*')
49
- })
50
- }
51
-
52
- export function useSnapshotCapture({
53
- iframeRef,
54
- widgetId,
55
- onUpdate,
56
- showIframe,
57
- }) {
58
- const [iframeReady, setIframeReady] = useState(false)
59
- const iframeReadyRef = useRef(false)
60
- const capturingRef = useRef(false)
61
- const requestIdCounter = useRef(0)
62
- const captureGeneration = useRef(0)
63
- const responseHandlers = useRef([])
64
- // Track the iframe contentWindow to reset readiness on remount
65
- const lastContentWindowRef = useRef(null)
66
-
67
- // Reset ready state when iframe is unmounted/remounted
68
- useEffect(() => {
69
- setIframeReady(false)
70
- iframeReadyRef.current = false
71
- }, [widgetId])
72
-
73
- // Reset readiness when iframe is torn down so remount waits for new snapshot-ready
74
- useEffect(() => {
75
- if (!showIframe) {
76
- setIframeReady(false)
77
- iframeReadyRef.current = false
78
- lastContentWindowRef.current = null
79
- }
80
- }, [showIframe])
81
-
82
- // Listen for postMessage events from the embedded iframe
83
- useEffect(() => {
84
- if (!onUpdate) return
85
-
86
- function handler(e) {
87
- if (!iframeRef.current) return
88
- if (e.source !== iframeRef.current.contentWindow) return
89
-
90
- // Detect new iframe instance → reset readiness
91
- if (e.source !== lastContentWindowRef.current) {
92
- lastContentWindowRef.current = e.source
93
- setIframeReady(false)
94
- iframeReadyRef.current = false
95
- }
96
-
97
- if (e.data?.type === 'storyboard:embed:snapshot-ready') {
98
- console.log(`[snapshot:${widgetId}] iframe ready`)
99
- setIframeReady(true)
100
- iframeReadyRef.current = true
101
- }
102
-
103
- if (e.data?.type === 'storyboard:embed:snapshot') {
104
- for (const fn of responseHandlers.current) {
105
- fn(e.data)
106
- }
107
- }
108
- }
109
-
110
- window.addEventListener('message', handler)
111
- return () => window.removeEventListener('message', handler)
112
- }, [iframeRef, onUpdate])
113
-
114
- /**
115
- * Capture a single snapshot of the current iframe state.
116
- * Uploads and saves as `snapshot` prop, overwriting any previous value.
117
- */
118
- const requestCapture = useCallback(async ({ force = false } = {}) => {
119
- console.log(`[snapshot:${widgetId}] requestCapture: force=${force}, hasContentWindow=${!!iframeRef.current?.contentWindow}, capturing=${capturingRef.current}, ready=${iframeReadyRef.current}`)
120
- if (!onUpdate) return {}
121
- if (!iframeRef.current?.contentWindow) { console.log(`[snapshot:${widgetId}] requestCapture: no contentWindow`); return {} }
122
- if (capturingRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: already capturing`); return {} }
123
- if (!force && !iframeReadyRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: not ready`); return {} }
124
-
125
- capturingRef.current = true
126
- const gen = ++captureGeneration.current
127
- const cw = iframeRef.current.contentWindow
128
- const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
129
-
130
- try {
131
- const reqId = ++requestIdCounter.current
132
- const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
133
-
134
- if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after capture`); return {} }
135
- if (!dataUrl) { console.log(`[snapshot:${widgetId}] captureOnce returned null`); return {} }
136
-
137
- const filename = `snapshot-${widgetId}.webp`
138
- console.log(`[snapshot:${widgetId}] uploading ${filename}`)
139
- const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
140
-
141
- if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after upload`); return {} }
142
-
143
- if (result?.filename) {
144
- const cacheBust = `?v=${Date.now()}`
145
- const url = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
146
- const updates = { snapshot: url }
147
- console.log(`[snapshot:${widgetId}] saved: ${url.slice(0, 60)}`)
148
- onUpdate?.(updates)
149
- return updates
150
- }
151
- return {}
152
- } catch (err) {
153
- console.warn('[snapshot] Capture failed:', err)
154
- return {}
155
- } finally {
156
- capturingRef.current = false
157
- }
158
- }, [onUpdate, iframeRef, widgetId])
159
-
160
- return { iframeReady, requestCapture }
161
- }