@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +512 -235
- package/src/canvas/CanvasPage.module.css +9 -47
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +4 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +67 -9
- package/src/canvas/widgets/ComponentWidget.module.css +9 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
- package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +471 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +54 -18
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +31 -9
- package/src/context.jsx +138 -13
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +441 -58
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a story at its route URL inside an iframe on canvas.
|
|
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
|
+
* Features:
|
|
8
|
+
* - Title bar showing story name + export (like Figma embed)
|
|
9
|
+
* - "Show code" action toggles between iframe and source view
|
|
10
|
+
* - "Copy code" action copies the story source to clipboard
|
|
11
|
+
*
|
|
12
|
+
* Props: { storyId, exportName, width, height }
|
|
13
|
+
*/
|
|
14
|
+
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
15
|
+
import { getStoryData } from '@dfosco/storyboard-core'
|
|
16
|
+
import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
|
|
17
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
18
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
19
|
+
import { uploadImage } from '../canvasApi.js'
|
|
20
|
+
import styles from './StoryWidget.module.css'
|
|
21
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
22
|
+
|
|
23
|
+
function resolveStoryUrl(storyId, exportName) {
|
|
24
|
+
const story = getStoryData(storyId)
|
|
25
|
+
if (!story?._route) return null
|
|
26
|
+
|
|
27
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
28
|
+
const route = story._route
|
|
29
|
+
const params = new URLSearchParams({ _sb_embed: '1' })
|
|
30
|
+
if (exportName) params.set('export', exportName)
|
|
31
|
+
|
|
32
|
+
return `${base}${route}?${params}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve a module path with the app base URL for dynamic imports. */
|
|
36
|
+
function resolveModulePath(modulePath) {
|
|
37
|
+
if (!modulePath || !modulePath.startsWith('/')) return modulePath
|
|
38
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
39
|
+
return base ? `${base}${modulePath}` : modulePath
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Cache for the static story sources JSON fetched in prod builds. */
|
|
43
|
+
let _storySourcesCache = null
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch story source code. In dev, uses Vite's ?raw dynamic import.
|
|
47
|
+
* In prod, fetches from the build-time _storyboard/stories/sources.json.
|
|
48
|
+
*/
|
|
49
|
+
async function fetchStorySource(modulePath) {
|
|
50
|
+
// Dev: use Vite's ?raw import for live source
|
|
51
|
+
if (import.meta.env.DEV) {
|
|
52
|
+
const mod = await import(/* @vite-ignore */ `${resolveModulePath(modulePath)}?raw`)
|
|
53
|
+
return mod.default || ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Prod: load from static JSON endpoint (same pattern as inspector.json)
|
|
57
|
+
if (!_storySourcesCache) {
|
|
58
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
59
|
+
const res = await fetch(`${base}/_storyboard/stories/sources.json`)
|
|
60
|
+
if (!res.ok) throw new Error(`Story sources not available (${res.status})`)
|
|
61
|
+
_storySourcesCache = await res.json()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// _storyModule is like "/src/canvas/stories/foo.story.jsx" — strip leading /
|
|
65
|
+
const key = modulePath.startsWith('/') ? modulePath.slice(1) : modulePath
|
|
66
|
+
const source = _storySourcesCache[key]
|
|
67
|
+
if (source == null) throw new Error(`Source not found for ${key}`)
|
|
68
|
+
return source
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
72
|
+
const storyId = props?.storyId || ''
|
|
73
|
+
const exportName = props?.exportName || ''
|
|
74
|
+
const width = props?.width
|
|
75
|
+
const height = props?.height
|
|
76
|
+
|
|
77
|
+
// Snapshot props for lazy loading
|
|
78
|
+
const snapshotLight = props?.snapshotLight || null
|
|
79
|
+
const snapshotDark = props?.snapshotDark || null
|
|
80
|
+
|
|
81
|
+
const containerRef = useRef(null)
|
|
82
|
+
const iframeRef = useRef(null)
|
|
83
|
+
const [interactive, setInteractive] = useState(false)
|
|
84
|
+
const [showCode, setShowCode] = useState(!!props?.showCode)
|
|
85
|
+
const [sourceCode, setSourceCode] = useState(null)
|
|
86
|
+
const [highlightedHtml, setHighlightedHtml] = useState(null)
|
|
87
|
+
const [sourceLoading, setSourceLoading] = useState(false)
|
|
88
|
+
|
|
89
|
+
// Theme tracking for snapshot selection
|
|
90
|
+
const [canvasTheme, setCanvasTheme] = useState(() => {
|
|
91
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
92
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
93
|
+
if (stored !== 'system') return stored
|
|
94
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
function onThemeChanged() {
|
|
99
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
100
|
+
if (stored !== 'system') { setCanvasTheme(stored); return }
|
|
101
|
+
setCanvasTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
102
|
+
}
|
|
103
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
104
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
// Lazy loading state — only use snapshots that match this widget's ID
|
|
108
|
+
const isDark = canvasTheme?.startsWith('dark')
|
|
109
|
+
const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
|
|
110
|
+
const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
|
|
111
|
+
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
112
|
+
const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
|
|
113
|
+
const hasSnapshot = !!currentSnapshot
|
|
114
|
+
const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
|
|
115
|
+
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
116
|
+
const [showIframe, setShowIframe] = useState(!hasSnapshot)
|
|
117
|
+
const [showSpinner, setShowSpinner] = useState(false)
|
|
118
|
+
const capturingRef = useRef(false)
|
|
119
|
+
|
|
120
|
+
// Show spinner only after 500ms of loading
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
123
|
+
const timer = setTimeout(() => setShowSpinner(true), 500)
|
|
124
|
+
return () => clearTimeout(timer)
|
|
125
|
+
}
|
|
126
|
+
setShowSpinner(false)
|
|
127
|
+
}, [showIframe, iframeLoaded, hasSnapshot])
|
|
128
|
+
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
129
|
+
|
|
130
|
+
// Re-resolve story URL when the story index is live-patched (new story added)
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
133
|
+
document.addEventListener('storyboard:story-index-changed', handler)
|
|
134
|
+
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
const toggleShowCode = useCallback(() => {
|
|
138
|
+
setShowCode((v) => {
|
|
139
|
+
const next = !v
|
|
140
|
+
// Persist to canvas JSONL in dev
|
|
141
|
+
if (onUpdate) {
|
|
142
|
+
onUpdate({ showCode: next })
|
|
143
|
+
}
|
|
144
|
+
return next
|
|
145
|
+
})
|
|
146
|
+
}, [onUpdate])
|
|
147
|
+
|
|
148
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!interactive) return
|
|
152
|
+
function handlePointerDown(e) {
|
|
153
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
154
|
+
setInteractive(false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
158
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
159
|
+
}, [interactive])
|
|
160
|
+
|
|
161
|
+
// Listen for snapshot messages from the iframe
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
function handleMessage(e) {
|
|
164
|
+
if (!iframeRef.current?.contentWindow) return
|
|
165
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
166
|
+
|
|
167
|
+
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
168
|
+
if (e.data.error) {
|
|
169
|
+
console.warn('[canvas] Story snapshot capture failed:', e.data.error)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
handleSnapshotResult(e.data.dataUrl)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// snapshot-ready means the iframe content has fully rendered
|
|
177
|
+
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
178
|
+
setIframeLoaded(true)
|
|
179
|
+
if (onUpdate) requestSnapshotCapture()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
window.addEventListener('message', handleMessage)
|
|
183
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
184
|
+
}, [onUpdate, canvasTheme])
|
|
185
|
+
|
|
186
|
+
const requestSnapshotCapture = useCallback(() => {
|
|
187
|
+
if (!iframeRef.current?.contentWindow || capturingRef.current) return
|
|
188
|
+
capturingRef.current = true
|
|
189
|
+
iframeRef.current.contentWindow.postMessage({
|
|
190
|
+
type: 'storyboard:embed:capture',
|
|
191
|
+
requestId: `story-snap-${Date.now()}`,
|
|
192
|
+
}, '*')
|
|
193
|
+
}, [])
|
|
194
|
+
|
|
195
|
+
const handleSnapshotResult = useCallback(async (dataUrl) => {
|
|
196
|
+
if (!dataUrl || !onUpdate || !widgetId) return
|
|
197
|
+
capturingRef.current = false
|
|
198
|
+
try {
|
|
199
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
|
|
200
|
+
if (!result?.success || !result?.filename) return
|
|
201
|
+
const imageUrl = `/_storyboard/canvas/images/${result.filename}`
|
|
202
|
+
const themeKey = isDark ? 'snapshotDark' : 'snapshotLight'
|
|
203
|
+
onUpdate?.({ [themeKey]: imageUrl })
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.warn('[canvas] Failed to upload story snapshot:', err)
|
|
206
|
+
}
|
|
207
|
+
}, [onUpdate, isDark, widgetId])
|
|
208
|
+
|
|
209
|
+
// Re-capture after resize
|
|
210
|
+
const resizeCaptureTimer = useRef(null)
|
|
211
|
+
const triggerResizeCapture = useCallback(() => {
|
|
212
|
+
if (!onUpdate) return
|
|
213
|
+
clearTimeout(resizeCaptureTimer.current)
|
|
214
|
+
resizeCaptureTimer.current = setTimeout(() => requestSnapshotCapture(), 2000)
|
|
215
|
+
}, [requestSnapshotCapture, onUpdate])
|
|
216
|
+
|
|
217
|
+
const handleResize = useCallback((w, h) => {
|
|
218
|
+
onUpdate?.({ width: w, height: h })
|
|
219
|
+
triggerResizeCapture()
|
|
220
|
+
}, [onUpdate, triggerResizeCapture])
|
|
221
|
+
|
|
222
|
+
// Re-capture for alternate theme variant when theme changes
|
|
223
|
+
const prevThemeRef = useRef(canvasTheme)
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (canvasTheme !== prevThemeRef.current && onUpdate && showIframe) {
|
|
226
|
+
prevThemeRef.current = canvasTheme
|
|
227
|
+
const timer = setTimeout(() => requestSnapshotCapture(), 3000)
|
|
228
|
+
return () => clearTimeout(timer)
|
|
229
|
+
}
|
|
230
|
+
prevThemeRef.current = canvasTheme
|
|
231
|
+
}, [canvasTheme, onUpdate, showIframe, requestSnapshotCapture])
|
|
232
|
+
|
|
233
|
+
// Load source code when show-code is toggled on
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!showCode || sourceCode !== null) return
|
|
236
|
+
const story = getStoryData(storyId)
|
|
237
|
+
if (!story?._storyModule) {
|
|
238
|
+
setSourceCode('// Source not available')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let cancelled = false
|
|
243
|
+
setSourceLoading(true)
|
|
244
|
+
|
|
245
|
+
fetchStorySource(story._storyModule)
|
|
246
|
+
.then((code) => {
|
|
247
|
+
if (cancelled) return
|
|
248
|
+
setSourceCode(code || '// Empty file')
|
|
249
|
+
setSourceLoading(false)
|
|
250
|
+
})
|
|
251
|
+
.catch(() => {
|
|
252
|
+
if (cancelled) return
|
|
253
|
+
setSourceCode('// Failed to load source')
|
|
254
|
+
setSourceLoading(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return () => {
|
|
258
|
+
cancelled = true
|
|
259
|
+
setSourceLoading(false)
|
|
260
|
+
}
|
|
261
|
+
}, [showCode, sourceCode, storyId])
|
|
262
|
+
|
|
263
|
+
// Re-highlight when the code-box theme changes (storyboard:theme:changed event).
|
|
264
|
+
const [codeThemeKey, setCodeThemeKey] = useState(0)
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
function onThemeChanged() {
|
|
267
|
+
setCodeThemeKey((k) => k + 1)
|
|
268
|
+
}
|
|
269
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
270
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
271
|
+
}, [])
|
|
272
|
+
|
|
273
|
+
// Syntax-highlight source code using the inspector highlighter.
|
|
274
|
+
// Uses the current code-box theme (data-sb-code-theme) set by the theme store.
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!sourceCode) return
|
|
277
|
+
let cancelled = false
|
|
278
|
+
createInspectorHighlighter().then((hl) => {
|
|
279
|
+
if (cancelled) return
|
|
280
|
+
const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
|
|
281
|
+
const html = hl.codeToHtml(sourceCode, { lang })
|
|
282
|
+
setHighlightedHtml(html)
|
|
283
|
+
})
|
|
284
|
+
return () => { cancelled = true }
|
|
285
|
+
}, [sourceCode, storyId, codeThemeKey])
|
|
286
|
+
|
|
287
|
+
const copyCode = useCallback(async () => {
|
|
288
|
+
if (sourceCode) {
|
|
289
|
+
await navigator.clipboard?.writeText(sourceCode)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
// Load source on demand if not already loaded
|
|
293
|
+
const story = getStoryData(storyId)
|
|
294
|
+
if (!story?._storyModule) return
|
|
295
|
+
try {
|
|
296
|
+
const code = await fetchStorySource(story._storyModule)
|
|
297
|
+
setSourceCode(code)
|
|
298
|
+
await navigator.clipboard?.writeText(code)
|
|
299
|
+
} catch { /* ignore */ }
|
|
300
|
+
}, [sourceCode, storyId])
|
|
301
|
+
|
|
302
|
+
useImperativeHandle(ref, () => ({
|
|
303
|
+
getState(key) {
|
|
304
|
+
if (key === 'showCode') return showCode
|
|
305
|
+
return undefined
|
|
306
|
+
},
|
|
307
|
+
handleAction(actionId) {
|
|
308
|
+
if (actionId === 'show-code') {
|
|
309
|
+
toggleShowCode()
|
|
310
|
+
} else if (actionId === 'copy-code') {
|
|
311
|
+
copyCode()
|
|
312
|
+
} else if (actionId === 'open-external') {
|
|
313
|
+
const story = getStoryData(storyId)
|
|
314
|
+
if (story?._route) {
|
|
315
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
316
|
+
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
}), [storyId, showCode, toggleShowCode, copyCode])
|
|
321
|
+
|
|
322
|
+
const iframeSrc = useMemo(
|
|
323
|
+
() => resolveStoryUrl(storyId, exportName),
|
|
324
|
+
[storyId, exportName, storyIndexKey],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const displayName = exportName ? `${storyId} / ${exportName}` : storyId
|
|
328
|
+
|
|
329
|
+
// Error state — missing story or no route
|
|
330
|
+
if (!storyId) {
|
|
331
|
+
return (
|
|
332
|
+
<WidgetWrapper>
|
|
333
|
+
<div className={styles.container} ref={containerRef}>
|
|
334
|
+
<div className={styles.error}>
|
|
335
|
+
<span className={styles.errorIcon}>📖</span>
|
|
336
|
+
<span className={styles.errorText}>Missing story ID</span>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</WidgetWrapper>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!iframeSrc) {
|
|
344
|
+
return (
|
|
345
|
+
<WidgetWrapper>
|
|
346
|
+
<div className={styles.container} ref={containerRef}>
|
|
347
|
+
<div className={styles.error}>
|
|
348
|
+
<span className={styles.errorIcon}>📖</span>
|
|
349
|
+
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</WidgetWrapper>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const sizeStyle = {}
|
|
357
|
+
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
358
|
+
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<WidgetWrapper>
|
|
362
|
+
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
363
|
+
<div className={styles.header}>
|
|
364
|
+
<span className={styles.headerIcon}>📖</span>
|
|
365
|
+
<span className={styles.headerTitle}>{displayName}</span>
|
|
366
|
+
</div>
|
|
367
|
+
{showCode ? (
|
|
368
|
+
<div
|
|
369
|
+
className={styles.codeView}
|
|
370
|
+
data-canvas-allow-text-selection
|
|
371
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
372
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
373
|
+
onClick={(e) => e.stopPropagation()}
|
|
374
|
+
>
|
|
375
|
+
<div className={styles.codeHeader}>
|
|
376
|
+
<span className={styles.codeLabel}>{storyId}.story.jsx</span>
|
|
377
|
+
<button
|
|
378
|
+
className={styles.codeCloseBtn}
|
|
379
|
+
onClick={() => setShowCode(false)}
|
|
380
|
+
aria-label="Close code view"
|
|
381
|
+
>×</button>
|
|
382
|
+
</div>
|
|
383
|
+
{sourceLoading ? (
|
|
384
|
+
<div className={styles.codeLoading}>Loading…</div>
|
|
385
|
+
) : highlightedHtml ? (
|
|
386
|
+
<div
|
|
387
|
+
className={styles.codeBlock}
|
|
388
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
389
|
+
/>
|
|
390
|
+
) : (
|
|
391
|
+
<pre className={styles.codeBlock}>
|
|
392
|
+
<code>{sourceCode || ''}</code>
|
|
393
|
+
</pre>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
) : (
|
|
397
|
+
<>
|
|
398
|
+
{/* Snapshot image — shown until iframe is fully loaded */}
|
|
399
|
+
{hasSnapshot && !(showIframe && iframeLoaded) && (
|
|
400
|
+
<div className={styles.content}>
|
|
401
|
+
<img
|
|
402
|
+
src={(import.meta.env.BASE_URL || '/').replace(/\/$/, '') + currentSnapshot}
|
|
403
|
+
alt={displayName}
|
|
404
|
+
className={styles.snapshotImage}
|
|
405
|
+
draggable={false}
|
|
406
|
+
/>
|
|
407
|
+
{showIframe && !iframeLoaded && showSpinner && (
|
|
408
|
+
<div className={styles.snapshotSpinner}>
|
|
409
|
+
<div className={styles.spinner} />
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
{/* Iframe — preloaded on hover, revealed after load */}
|
|
416
|
+
{(preloadIframe || showIframe) && (
|
|
417
|
+
<div
|
|
418
|
+
className={styles.content}
|
|
419
|
+
style={hasSnapshot && !(showIframe && iframeLoaded) ? { position: 'absolute', top: 31, left: 0, right: 0, bottom: 0, opacity: 0, pointerEvents: 'none' } : undefined}
|
|
420
|
+
>
|
|
421
|
+
<iframe
|
|
422
|
+
ref={iframeRef}
|
|
423
|
+
src={iframeSrc}
|
|
424
|
+
className={styles.iframe}
|
|
425
|
+
title={displayName}
|
|
426
|
+
/>
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
{!interactive && (
|
|
431
|
+
<div
|
|
432
|
+
className={overlayStyles.interactOverlay}
|
|
433
|
+
onPointerEnter={() => {
|
|
434
|
+
if (!preloadIframe) setPreloadIframe(true)
|
|
435
|
+
}}
|
|
436
|
+
onClick={(e) => {
|
|
437
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
438
|
+
setShowIframe(true)
|
|
439
|
+
setPreloadIframe(true)
|
|
440
|
+
enterInteractive()
|
|
441
|
+
}}
|
|
442
|
+
role="button"
|
|
443
|
+
tabIndex={0}
|
|
444
|
+
onKeyDown={(e) => {
|
|
445
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
446
|
+
e.preventDefault()
|
|
447
|
+
e.stopPropagation()
|
|
448
|
+
setShowIframe(true)
|
|
449
|
+
setPreloadIframe(true)
|
|
450
|
+
enterInteractive()
|
|
451
|
+
}
|
|
452
|
+
}}
|
|
453
|
+
aria-label="Click to interact with story component"
|
|
454
|
+
>
|
|
455
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
</>
|
|
459
|
+
)}
|
|
460
|
+
{resizable && (
|
|
461
|
+
<ResizeHandle
|
|
462
|
+
targetRef={containerRef}
|
|
463
|
+
minWidth={100}
|
|
464
|
+
minHeight={60}
|
|
465
|
+
onResize={handleResize}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
</WidgetWrapper>
|
|
470
|
+
)
|
|
471
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
min-width: 100px;
|
|
5
|
+
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
8
|
+
border-radius: 12px;
|
|
9
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
10
|
+
width: 100%;
|
|
11
|
+
height: 100%;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.header {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 6px;
|
|
18
|
+
padding: 6px 10px;
|
|
19
|
+
font-size: 12px;
|
|
20
|
+
font-weight: 500;
|
|
21
|
+
color: var(--fgColor-muted, #656d76);
|
|
22
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
23
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
text-overflow: ellipsis;
|
|
27
|
+
user-select: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.headerIcon {
|
|
31
|
+
font-size: 13px;
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.headerTitle {
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
text-overflow: ellipsis;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.content {
|
|
41
|
+
width: 100%;
|
|
42
|
+
height: calc(100% - 31px);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.iframe {
|
|
46
|
+
display: block;
|
|
47
|
+
width: 100%;
|
|
48
|
+
height: 100%;
|
|
49
|
+
border: none;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.snapshotImage {
|
|
53
|
+
display: block;
|
|
54
|
+
width: 100%;
|
|
55
|
+
height: 100%;
|
|
56
|
+
object-fit: cover;
|
|
57
|
+
object-position: top left;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.snapshotSpinner {
|
|
61
|
+
position: absolute;
|
|
62
|
+
inset: 0;
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
background: rgba(0, 0, 0, 0.08);
|
|
67
|
+
animation: fadeIn 150ms ease;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.spinner {
|
|
71
|
+
width: 24px;
|
|
72
|
+
height: 24px;
|
|
73
|
+
border: 2.5px solid var(--borderColor-default, #d0d7de);
|
|
74
|
+
border-top-color: var(--fgColor-accent, #0969da);
|
|
75
|
+
border-radius: 50%;
|
|
76
|
+
animation: spin 0.6s linear infinite;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@keyframes spin {
|
|
80
|
+
to { transform: rotate(360deg); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@keyframes fadeIn {
|
|
84
|
+
from { opacity: 0; }
|
|
85
|
+
to { opacity: 1; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.codeView {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
height: calc(100% - 31px);
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.codeHeader {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
padding: 4px 10px;
|
|
100
|
+
font-size: 11px;
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
color: var(--fgColor-muted, #656d76);
|
|
103
|
+
background: var(--bgColor-inset, #eff2f5);
|
|
104
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
105
|
+
user-select: none;
|
|
106
|
+
flex-shrink: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.codeLabel {
|
|
110
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.codeCloseBtn {
|
|
114
|
+
all: unset;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
width: 20px;
|
|
120
|
+
height: 20px;
|
|
121
|
+
border-radius: 4px;
|
|
122
|
+
font-size: 14px;
|
|
123
|
+
color: var(--fgColor-muted, #656d76);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.codeCloseBtn:hover {
|
|
127
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
128
|
+
color: var(--fgColor-default, #1f2328);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.codeBlock {
|
|
132
|
+
flex: 1;
|
|
133
|
+
margin: 0;
|
|
134
|
+
overflow: auto;
|
|
135
|
+
user-select: text;
|
|
136
|
+
cursor: text;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Style the highlighted pre from the inspector highlighter.
|
|
140
|
+
padding uses !important to override the inline padding:0 from codeToHtml. */
|
|
141
|
+
.codeBlock pre {
|
|
142
|
+
margin: 0;
|
|
143
|
+
padding: var(--base-size-8, 8px) !important;
|
|
144
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
font-weight: 400;
|
|
147
|
+
line-height: 1.6;
|
|
148
|
+
tab-size: 2;
|
|
149
|
+
min-height: 100%;
|
|
150
|
+
box-sizing: border-box;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Fallback when no highlighted HTML (plain pre/code) */
|
|
154
|
+
.codeBlock > code {
|
|
155
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
156
|
+
font-size: 12px;
|
|
157
|
+
font-weight: 400;
|
|
158
|
+
line-height: 1.6;
|
|
159
|
+
white-space: pre;
|
|
160
|
+
tab-size: 2;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.codeLoading {
|
|
164
|
+
flex: 1;
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
color: var(--fgColor-muted, #656d76);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.error {
|
|
173
|
+
display: flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
gap: 8px;
|
|
176
|
+
padding: 16px;
|
|
177
|
+
color: var(--fgColor-danger, #cf222e);
|
|
178
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
179
|
+
font-size: 13px;
|
|
180
|
+
line-height: 1.5;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.errorIcon {
|
|
184
|
+
font-size: 20px;
|
|
185
|
+
flex-shrink: 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.errorText {
|
|
189
|
+
word-break: break-word;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.loading {
|
|
193
|
+
display: flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
justify-content: center;
|
|
196
|
+
padding: 16px;
|
|
197
|
+
color: var(--fgColor-muted, #656d76);
|
|
198
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
199
|
+
font-size: 13px;
|
|
200
|
+
}
|