@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.
|
|
3
|
+
"version": "4.0.0-beta.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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="
|
|
32
|
-
<path d="
|
|
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
|
|
40
|
-
|
|
33
|
+
if (!story?._route) return ''
|
|
41
34
|
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
42
|
-
const
|
|
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}${
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
97
|
+
}, [interactive, widgetId])
|
|
213
98
|
|
|
214
99
|
const handleResize = useCallback((w, h) => {
|
|
215
100
|
onUpdate?.({ width: w, height: h })
|
|
216
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 { /*
|
|
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
|
-
|
|
341
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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=
|
|
265
|
+
aria-label="Click to interact"
|
|
495
266
|
>
|
|
496
|
-
<span className={overlayStyles.interactHint}>
|
|
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
|
-
}
|