@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15
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 +3 -3
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.jsx +77 -109
- package/src/canvas/CanvasPage.module.css +3 -47
- 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/componentIsolate.jsx +3 -3
- package/src/canvas/widgets/FigmaEmbed.jsx +6 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +84 -4
- package/src/canvas/widgets/MarkdownBlock.module.css +30 -4
- package/src/canvas/widgets/PrototypeEmbed.jsx +177 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +34 -0
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StoryWidget.jsx +438 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +30 -3
- 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 +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +4 -1
- package/src/context.jsx +138 -13
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +234 -27
- package/src/vite/data-plugin.test.js +179 -4
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
43
|
+
const storyId = props?.storyId || ''
|
|
44
|
+
const exportName = props?.exportName || ''
|
|
45
|
+
const width = props?.width
|
|
46
|
+
const height = props?.height
|
|
47
|
+
|
|
48
|
+
// Snapshot props for lazy loading
|
|
49
|
+
const snapshotLight = props?.snapshotLight || null
|
|
50
|
+
const snapshotDark = props?.snapshotDark || null
|
|
51
|
+
|
|
52
|
+
const containerRef = useRef(null)
|
|
53
|
+
const iframeRef = useRef(null)
|
|
54
|
+
const [interactive, setInteractive] = useState(false)
|
|
55
|
+
const [showCode, setShowCode] = useState(!!props?.showCode)
|
|
56
|
+
const [sourceCode, setSourceCode] = useState(null)
|
|
57
|
+
const [highlightedHtml, setHighlightedHtml] = useState(null)
|
|
58
|
+
const [sourceLoading, setSourceLoading] = useState(false)
|
|
59
|
+
|
|
60
|
+
// Theme tracking for snapshot selection
|
|
61
|
+
const [canvasTheme, setCanvasTheme] = useState(() => {
|
|
62
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
63
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
64
|
+
if (stored !== 'system') return stored
|
|
65
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
function onThemeChanged() {
|
|
70
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
71
|
+
if (stored !== 'system') { setCanvasTheme(stored); return }
|
|
72
|
+
setCanvasTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
75
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
// Lazy loading state — only use snapshots that match this widget's ID
|
|
79
|
+
const isDark = canvasTheme?.startsWith('dark')
|
|
80
|
+
const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
|
|
81
|
+
const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
|
|
82
|
+
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
83
|
+
const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
|
|
84
|
+
const hasSnapshot = !!currentSnapshot
|
|
85
|
+
const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
|
|
86
|
+
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
87
|
+
const [showIframe, setShowIframe] = useState(!hasSnapshot)
|
|
88
|
+
const [showSpinner, setShowSpinner] = useState(false)
|
|
89
|
+
const capturingRef = useRef(false)
|
|
90
|
+
|
|
91
|
+
// Show spinner only after 500ms of loading
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
94
|
+
const timer = setTimeout(() => setShowSpinner(true), 500)
|
|
95
|
+
return () => clearTimeout(timer)
|
|
96
|
+
}
|
|
97
|
+
setShowSpinner(false)
|
|
98
|
+
}, [showIframe, iframeLoaded, hasSnapshot])
|
|
99
|
+
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
100
|
+
|
|
101
|
+
// Re-resolve story URL when the story index is live-patched (new story added)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
104
|
+
document.addEventListener('storyboard:story-index-changed', handler)
|
|
105
|
+
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
const toggleShowCode = useCallback(() => {
|
|
109
|
+
setShowCode((v) => {
|
|
110
|
+
const next = !v
|
|
111
|
+
// Persist to canvas JSONL in dev
|
|
112
|
+
if (onUpdate) {
|
|
113
|
+
onUpdate({ showCode: next })
|
|
114
|
+
}
|
|
115
|
+
return next
|
|
116
|
+
})
|
|
117
|
+
}, [onUpdate])
|
|
118
|
+
|
|
119
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!interactive) return
|
|
123
|
+
function handlePointerDown(e) {
|
|
124
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
125
|
+
setInteractive(false)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
129
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
130
|
+
}, [interactive])
|
|
131
|
+
|
|
132
|
+
// Listen for snapshot messages from the iframe
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
function handleMessage(e) {
|
|
135
|
+
if (!iframeRef.current?.contentWindow) return
|
|
136
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
137
|
+
|
|
138
|
+
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
139
|
+
if (e.data.error) {
|
|
140
|
+
console.warn('[canvas] Story snapshot capture failed:', e.data.error)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
handleSnapshotResult(e.data.dataUrl)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// snapshot-ready means the iframe content has fully rendered
|
|
148
|
+
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
149
|
+
setIframeLoaded(true)
|
|
150
|
+
if (onUpdate) requestSnapshotCapture()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
window.addEventListener('message', handleMessage)
|
|
154
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
155
|
+
}, [onUpdate, canvasTheme])
|
|
156
|
+
|
|
157
|
+
const requestSnapshotCapture = useCallback(() => {
|
|
158
|
+
if (!iframeRef.current?.contentWindow || capturingRef.current) return
|
|
159
|
+
capturingRef.current = true
|
|
160
|
+
iframeRef.current.contentWindow.postMessage({
|
|
161
|
+
type: 'storyboard:embed:capture',
|
|
162
|
+
requestId: `story-snap-${Date.now()}`,
|
|
163
|
+
}, '*')
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
const handleSnapshotResult = useCallback(async (dataUrl) => {
|
|
167
|
+
if (!dataUrl || !onUpdate || !widgetId) return
|
|
168
|
+
capturingRef.current = false
|
|
169
|
+
try {
|
|
170
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
|
|
171
|
+
if (!result?.success || !result?.filename) return
|
|
172
|
+
const imageUrl = `/_storyboard/canvas/images/${result.filename}`
|
|
173
|
+
const themeKey = isDark ? 'snapshotDark' : 'snapshotLight'
|
|
174
|
+
onUpdate?.({ [themeKey]: imageUrl })
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn('[canvas] Failed to upload story snapshot:', err)
|
|
177
|
+
}
|
|
178
|
+
}, [onUpdate, isDark, widgetId])
|
|
179
|
+
|
|
180
|
+
// Re-capture after resize
|
|
181
|
+
const resizeCaptureTimer = useRef(null)
|
|
182
|
+
const triggerResizeCapture = useCallback(() => {
|
|
183
|
+
if (!onUpdate) return
|
|
184
|
+
clearTimeout(resizeCaptureTimer.current)
|
|
185
|
+
resizeCaptureTimer.current = setTimeout(() => requestSnapshotCapture(), 2000)
|
|
186
|
+
}, [requestSnapshotCapture, onUpdate])
|
|
187
|
+
|
|
188
|
+
const handleResize = useCallback((w, h) => {
|
|
189
|
+
onUpdate?.({ width: w, height: h })
|
|
190
|
+
triggerResizeCapture()
|
|
191
|
+
}, [onUpdate, triggerResizeCapture])
|
|
192
|
+
|
|
193
|
+
// Re-capture for alternate theme variant when theme changes
|
|
194
|
+
const prevThemeRef = useRef(canvasTheme)
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (canvasTheme !== prevThemeRef.current && onUpdate && showIframe) {
|
|
197
|
+
prevThemeRef.current = canvasTheme
|
|
198
|
+
const timer = setTimeout(() => requestSnapshotCapture(), 3000)
|
|
199
|
+
return () => clearTimeout(timer)
|
|
200
|
+
}
|
|
201
|
+
prevThemeRef.current = canvasTheme
|
|
202
|
+
}, [canvasTheme, onUpdate, showIframe, requestSnapshotCapture])
|
|
203
|
+
|
|
204
|
+
// Load source code when show-code is toggled on
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!showCode || sourceCode !== null) return
|
|
207
|
+
const story = getStoryData(storyId)
|
|
208
|
+
if (!story?._storyModule) {
|
|
209
|
+
Promise.resolve().then(() => setSourceCode('// Source not available'))
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let cancelled = false
|
|
214
|
+
Promise.resolve().then(() => { if (!cancelled) setSourceLoading(true) })
|
|
215
|
+
|
|
216
|
+
// Use dynamic import with ?raw to get the file contents as a string.
|
|
217
|
+
// Vite's ?raw suffix returns a module whose default export is the raw text.
|
|
218
|
+
import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
|
|
219
|
+
.then((mod) => {
|
|
220
|
+
if (cancelled) return
|
|
221
|
+
setSourceCode(mod.default || '// Empty file')
|
|
222
|
+
})
|
|
223
|
+
.catch(() => { if (!cancelled) setSourceCode('// Failed to load source') })
|
|
224
|
+
.finally(() => { if (!cancelled) setSourceLoading(false) })
|
|
225
|
+
|
|
226
|
+
return () => { cancelled = true }
|
|
227
|
+
}, [showCode, sourceCode, storyId])
|
|
228
|
+
|
|
229
|
+
// Re-highlight when the code-box theme changes (storyboard:theme:changed event).
|
|
230
|
+
const [codeThemeKey, setCodeThemeKey] = useState(0)
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
function onThemeChanged() {
|
|
233
|
+
setCodeThemeKey((k) => k + 1)
|
|
234
|
+
}
|
|
235
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
236
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
237
|
+
}, [])
|
|
238
|
+
|
|
239
|
+
// Syntax-highlight source code using the inspector highlighter.
|
|
240
|
+
// Uses the current code-box theme (data-sb-code-theme) set by the theme store.
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!sourceCode) return
|
|
243
|
+
let cancelled = false
|
|
244
|
+
createInspectorHighlighter().then((hl) => {
|
|
245
|
+
if (cancelled) return
|
|
246
|
+
const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
|
|
247
|
+
const html = hl.codeToHtml(sourceCode, { lang })
|
|
248
|
+
setHighlightedHtml(html)
|
|
249
|
+
})
|
|
250
|
+
return () => { cancelled = true }
|
|
251
|
+
}, [sourceCode, storyId, codeThemeKey])
|
|
252
|
+
|
|
253
|
+
const copyCode = useCallback(async () => {
|
|
254
|
+
if (sourceCode) {
|
|
255
|
+
await navigator.clipboard?.writeText(sourceCode)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
// Load source on demand if not already loaded
|
|
259
|
+
const story = getStoryData(storyId)
|
|
260
|
+
if (!story?._storyModule) return
|
|
261
|
+
try {
|
|
262
|
+
const mod = await import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
|
|
263
|
+
const code = mod.default || ''
|
|
264
|
+
setSourceCode(code)
|
|
265
|
+
await navigator.clipboard?.writeText(code)
|
|
266
|
+
} catch { /* ignore */ }
|
|
267
|
+
}, [sourceCode, storyId])
|
|
268
|
+
|
|
269
|
+
useImperativeHandle(ref, () => ({
|
|
270
|
+
getState(key) {
|
|
271
|
+
if (key === 'showCode') return showCode
|
|
272
|
+
return undefined
|
|
273
|
+
},
|
|
274
|
+
handleAction(actionId) {
|
|
275
|
+
if (actionId === 'show-code') {
|
|
276
|
+
toggleShowCode()
|
|
277
|
+
} else if (actionId === 'copy-code') {
|
|
278
|
+
copyCode()
|
|
279
|
+
} else if (actionId === 'open-external') {
|
|
280
|
+
const story = getStoryData(storyId)
|
|
281
|
+
if (story?._route) {
|
|
282
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
283
|
+
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}), [storyId, showCode, toggleShowCode, copyCode])
|
|
288
|
+
|
|
289
|
+
const iframeSrc = useMemo(
|
|
290
|
+
() => resolveStoryUrl(storyId, exportName),
|
|
291
|
+
[storyId, exportName, storyIndexKey],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const displayName = exportName ? `${storyId} / ${exportName}` : storyId
|
|
295
|
+
|
|
296
|
+
// Error state — missing story or no route
|
|
297
|
+
if (!storyId) {
|
|
298
|
+
return (
|
|
299
|
+
<WidgetWrapper>
|
|
300
|
+
<div className={styles.container} ref={containerRef}>
|
|
301
|
+
<div className={styles.error}>
|
|
302
|
+
<span className={styles.errorIcon}>📖</span>
|
|
303
|
+
<span className={styles.errorText}>Missing story ID</span>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</WidgetWrapper>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!iframeSrc) {
|
|
311
|
+
return (
|
|
312
|
+
<WidgetWrapper>
|
|
313
|
+
<div className={styles.container} ref={containerRef}>
|
|
314
|
+
<div className={styles.error}>
|
|
315
|
+
<span className={styles.errorIcon}>📖</span>
|
|
316
|
+
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</WidgetWrapper>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sizeStyle = {}
|
|
324
|
+
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
325
|
+
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<WidgetWrapper>
|
|
329
|
+
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
330
|
+
<div className={styles.header}>
|
|
331
|
+
<span className={styles.headerIcon}>📖</span>
|
|
332
|
+
<span className={styles.headerTitle}>{displayName}</span>
|
|
333
|
+
</div>
|
|
334
|
+
{showCode ? (
|
|
335
|
+
<div
|
|
336
|
+
className={styles.codeView}
|
|
337
|
+
data-canvas-allow-text-selection
|
|
338
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
339
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
340
|
+
onClick={(e) => e.stopPropagation()}
|
|
341
|
+
>
|
|
342
|
+
<div className={styles.codeHeader}>
|
|
343
|
+
<span className={styles.codeLabel}>{storyId}.story.jsx</span>
|
|
344
|
+
<button
|
|
345
|
+
className={styles.codeCloseBtn}
|
|
346
|
+
onClick={() => setShowCode(false)}
|
|
347
|
+
aria-label="Close code view"
|
|
348
|
+
>×</button>
|
|
349
|
+
</div>
|
|
350
|
+
{sourceLoading ? (
|
|
351
|
+
<div className={styles.codeLoading}>Loading…</div>
|
|
352
|
+
) : highlightedHtml ? (
|
|
353
|
+
<div
|
|
354
|
+
className={styles.codeBlock}
|
|
355
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
356
|
+
/>
|
|
357
|
+
) : (
|
|
358
|
+
<pre className={styles.codeBlock}>
|
|
359
|
+
<code>{sourceCode || ''}</code>
|
|
360
|
+
</pre>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
) : (
|
|
364
|
+
<>
|
|
365
|
+
{/* Snapshot image — shown until iframe is fully loaded */}
|
|
366
|
+
{hasSnapshot && !(showIframe && iframeLoaded) && (
|
|
367
|
+
<div className={styles.content}>
|
|
368
|
+
<img
|
|
369
|
+
src={(import.meta.env.BASE_URL || '/').replace(/\/$/, '') + currentSnapshot}
|
|
370
|
+
alt={displayName}
|
|
371
|
+
className={styles.snapshotImage}
|
|
372
|
+
draggable={false}
|
|
373
|
+
/>
|
|
374
|
+
{showIframe && !iframeLoaded && showSpinner && (
|
|
375
|
+
<div className={styles.snapshotSpinner}>
|
|
376
|
+
<div className={styles.spinner} />
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Iframe — preloaded on hover, revealed after load */}
|
|
383
|
+
{(preloadIframe || showIframe) && (
|
|
384
|
+
<div
|
|
385
|
+
className={styles.content}
|
|
386
|
+
style={hasSnapshot && !(showIframe && iframeLoaded) ? { position: 'absolute', top: 31, left: 0, right: 0, bottom: 0, opacity: 0, pointerEvents: 'none' } : undefined}
|
|
387
|
+
>
|
|
388
|
+
<iframe
|
|
389
|
+
ref={iframeRef}
|
|
390
|
+
src={iframeSrc}
|
|
391
|
+
className={styles.iframe}
|
|
392
|
+
title={displayName}
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{!interactive && (
|
|
398
|
+
<div
|
|
399
|
+
className={overlayStyles.interactOverlay}
|
|
400
|
+
onPointerEnter={() => {
|
|
401
|
+
if (!preloadIframe) setPreloadIframe(true)
|
|
402
|
+
}}
|
|
403
|
+
onClick={(e) => {
|
|
404
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
405
|
+
setShowIframe(true)
|
|
406
|
+
setPreloadIframe(true)
|
|
407
|
+
enterInteractive()
|
|
408
|
+
}}
|
|
409
|
+
role="button"
|
|
410
|
+
tabIndex={0}
|
|
411
|
+
onKeyDown={(e) => {
|
|
412
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
413
|
+
e.preventDefault()
|
|
414
|
+
e.stopPropagation()
|
|
415
|
+
setShowIframe(true)
|
|
416
|
+
setPreloadIframe(true)
|
|
417
|
+
enterInteractive()
|
|
418
|
+
}
|
|
419
|
+
}}
|
|
420
|
+
aria-label="Click to interact with story component"
|
|
421
|
+
>
|
|
422
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
</>
|
|
426
|
+
)}
|
|
427
|
+
{resizable && (
|
|
428
|
+
<ResizeHandle
|
|
429
|
+
targetRef={containerRef}
|
|
430
|
+
minWidth={100}
|
|
431
|
+
minHeight={60}
|
|
432
|
+
onResize={handleResize}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
</WidgetWrapper>
|
|
437
|
+
)
|
|
438
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
|
-
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
5
5
|
|
|
6
6
|
const STICKY_NOTE_COLORS = {
|
|
@@ -60,6 +60,18 @@ function EyeClosedIcon() {
|
|
|
60
60
|
return <OcticonEyeClosed size={12} />
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function CodeIcon() {
|
|
64
|
+
return <OcticonCode size={12} />
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function UnwrapIcon() {
|
|
68
|
+
return <OcticonUnwrap size={12} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ImageIcon() {
|
|
72
|
+
return <OcticonImage size={12} />
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function CopyIcon() {
|
|
64
76
|
return (
|
|
65
77
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
@@ -119,6 +131,9 @@ const ICON_REGISTRY = {
|
|
|
119
131
|
'open-external': OpenExternalIcon,
|
|
120
132
|
'eye': EyeIcon,
|
|
121
133
|
'eye-closed': EyeClosedIcon,
|
|
134
|
+
'code': CodeIcon,
|
|
135
|
+
'unwrap': UnwrapIcon,
|
|
136
|
+
'image': ImageIcon,
|
|
122
137
|
'copy': CopyIcon,
|
|
123
138
|
'link': LinkIcon,
|
|
124
139
|
'more': MoreIcon,
|
|
@@ -425,7 +440,7 @@ export default function WidgetChrome({
|
|
|
425
440
|
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
426
441
|
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
427
442
|
>
|
|
428
|
-
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
443
|
+
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
|
|
429
444
|
{children}
|
|
430
445
|
</div>
|
|
431
446
|
<div
|
|
@@ -469,6 +484,11 @@ export default function WidgetChrome({
|
|
|
469
484
|
}
|
|
470
485
|
}
|
|
471
486
|
|
|
487
|
+
// Show-code toggle: swap label based on widget state
|
|
488
|
+
if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
|
|
489
|
+
label = 'Show component'
|
|
490
|
+
}
|
|
491
|
+
|
|
472
492
|
return (
|
|
473
493
|
<Tooltip key={feature.id} text={label} direction="n">
|
|
474
494
|
<button
|
|
@@ -504,7 +524,14 @@ export default function WidgetChrome({
|
|
|
504
524
|
<WidgetOverflowMenu
|
|
505
525
|
widgetId={widgetId}
|
|
506
526
|
menuFeatures={menuFeatures}
|
|
507
|
-
onAction={
|
|
527
|
+
onAction={(actionId) => {
|
|
528
|
+
// Route overflow menu actions through the widget ref first
|
|
529
|
+
if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
|
|
530
|
+
widgetRef.current.handleAction(actionId)
|
|
531
|
+
} else {
|
|
532
|
+
onAction?.(actionId)
|
|
533
|
+
}
|
|
534
|
+
}}
|
|
508
535
|
/>
|
|
509
536
|
)}
|
|
510
537
|
</div>
|