@dfosco/storyboard-react 4.0.0-beta.35 → 4.0.0-beta.37
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 +4 -3
- package/src/Icon.jsx +179 -0
- package/src/ViewfinderNew.jsx +1172 -0
- package/src/ViewfinderNew.module.css +1773 -0
- package/src/canvas/CanvasPage.jsx +14 -0
- package/src/canvas/widgets/LinkPreview.jsx +74 -10
- package/src/canvas/widgets/MarkdownBlock.module.css +2 -2
- package/src/canvas/widgets/PrototypeEmbed.jsx +11 -8
- package/src/canvas/widgets/StoryWidget.jsx +47 -283
- package/src/canvas/widgets/StoryWidget.module.css +3 -3
- package/src/index.js +1 -1
- package/src/vite/data-plugin.js +24 -0
- package/src/Viewfinder.jsx +0 -72
- package/src/Viewfinder.module.css +0 -235
- package/src/canvas/widgets/refreshQueue.js +0 -111
- package/src/canvas/widgets/useSnapshotCapture.js +0 -161
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +0 -164
|
@@ -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 targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
510
274
|
</WidgetWrapper>
|
|
511
275
|
)
|
|
512
276
|
})
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
.codeLabel {
|
|
121
|
-
font-family:
|
|
121
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
.codeCloseBtn {
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
.codeBlock pre {
|
|
153
153
|
margin: 0;
|
|
154
154
|
padding: var(--base-size-8, 8px) !important;
|
|
155
|
-
font-family:
|
|
155
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
156
156
|
font-size: 12px;
|
|
157
157
|
font-weight: 400;
|
|
158
158
|
line-height: 1.6;
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
|
|
164
164
|
/* Fallback when no highlighted HTML (plain pre/code) */
|
|
165
165
|
.codeBlock > code {
|
|
166
|
-
font-family:
|
|
166
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
167
167
|
font-size: 12px;
|
|
168
168
|
font-weight: 400;
|
|
169
169
|
line-height: 1.6;
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { FormContext } from './context/FormContext.js'
|
|
|
35
35
|
// ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
|
|
36
36
|
|
|
37
37
|
// Viewfinder dashboard
|
|
38
|
-
export { default as Viewfinder } from './
|
|
38
|
+
export { default as Viewfinder } from './ViewfinderNew.jsx'
|
|
39
39
|
|
|
40
40
|
// Canvas
|
|
41
41
|
export { default as CanvasPage } from './canvas/CanvasPage.jsx'
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -1128,6 +1128,30 @@ export default function storyboardDataPlugin() {
|
|
|
1128
1128
|
return []
|
|
1129
1129
|
},
|
|
1130
1130
|
|
|
1131
|
+
// Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
|
|
1132
|
+
// Reads .worktrees/ports.json to enumerate active worktree dev servers.
|
|
1133
|
+
transformIndexHtml(html, ctx) {
|
|
1134
|
+
// Only inject in dev mode
|
|
1135
|
+
if (!ctx.server) return html
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
|
|
1139
|
+
if (!fs.existsSync(portsJsonPath)) return html
|
|
1140
|
+
|
|
1141
|
+
const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
|
|
1142
|
+
const branches = Object.entries(ports)
|
|
1143
|
+
.filter(([name]) => name !== 'main')
|
|
1144
|
+
.map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
|
|
1145
|
+
|
|
1146
|
+
if (branches.length === 0) return html
|
|
1147
|
+
|
|
1148
|
+
const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
|
|
1149
|
+
return html.replace('</head>', `${script}\n</head>`)
|
|
1150
|
+
} catch {
|
|
1151
|
+
return html
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1131
1155
|
// Rebuild index on each build start
|
|
1132
1156
|
buildStart() {
|
|
1133
1157
|
buildResult = null
|
package/src/Viewfinder.jsx
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { useRef, useEffect, useMemo } from 'react'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Viewfinder — thin React wrapper around the Svelte Viewfinder component.
|
|
6
|
-
*
|
|
7
|
-
* Mounts the core Svelte Viewfinder into a container div and manages
|
|
8
|
-
* its lifecycle via React's useEffect.
|
|
9
|
-
*
|
|
10
|
-
* @param {Object} props
|
|
11
|
-
* @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored — data comes from core)
|
|
12
|
-
* @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
|
|
13
|
-
* @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
|
|
14
|
-
* @param {string} [props.basePath] - Base URL path
|
|
15
|
-
* @param {string} [props.title] - Header title
|
|
16
|
-
* @param {string} [props.subtitle] - Optional subtitle
|
|
17
|
-
* @param {boolean} [props.showThumbnails] - Show thumbnail previews
|
|
18
|
-
* @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
|
|
19
|
-
*/
|
|
20
|
-
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
|
|
21
|
-
const containerRef = useRef(null)
|
|
22
|
-
const handleRef = useRef(null)
|
|
23
|
-
|
|
24
|
-
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
25
|
-
|
|
26
|
-
const knownRoutes = useMemo(() => Object.keys(pageModules)
|
|
27
|
-
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
28
|
-
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
|
|
29
|
-
[pageModules])
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
if (!containerRef.current) return
|
|
33
|
-
|
|
34
|
-
let cancelled = false
|
|
35
|
-
|
|
36
|
-
import('@dfosco/storyboard-core/ui-runtime').then(({ mountViewfinder, unmountViewfinder }) => {
|
|
37
|
-
if (cancelled) return
|
|
38
|
-
// Ensure clean state for re-mounts
|
|
39
|
-
unmountViewfinder()
|
|
40
|
-
handleRef.current = mountViewfinder(containerRef.current, {
|
|
41
|
-
title,
|
|
42
|
-
subtitle,
|
|
43
|
-
basePath,
|
|
44
|
-
knownRoutes,
|
|
45
|
-
showThumbnails,
|
|
46
|
-
hideDefaultFlow: shouldHideDefault,
|
|
47
|
-
})
|
|
48
|
-
// Wait for styles to be fully loaded before revealing
|
|
49
|
-
handleRef.current.ready.then(() => {
|
|
50
|
-
requestAnimationFrame(() => {
|
|
51
|
-
if (containerRef.current) containerRef.current.style.opacity = '1'
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
return () => {
|
|
57
|
-
cancelled = true
|
|
58
|
-
if (handleRef.current) {
|
|
59
|
-
handleRef.current.destroy()
|
|
60
|
-
handleRef.current = null
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
|
|
64
|
-
|
|
65
|
-
return <div ref={containerRef} style={{
|
|
66
|
-
minHeight: '100vh',
|
|
67
|
-
background: 'var(--bgColor-default, #0d1117)',
|
|
68
|
-
opacity: 0,
|
|
69
|
-
transition: 'opacity 0.15s ease',
|
|
70
|
-
}} />
|
|
71
|
-
}
|
|
72
|
-
|