@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0
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 +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +277 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a story at its route URL inside an iframe on canvas.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Title bar showing story name + export
|
|
6
|
+
* - "Show code" action toggles between iframe and source view
|
|
7
|
+
* - "Copy code" action copies the story source to clipboard
|
|
8
|
+
*
|
|
9
|
+
* Props: { storyId, exportName, width, height }
|
|
10
|
+
*/
|
|
11
|
+
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
|
+
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
+
import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
|
|
14
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
15
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
16
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
17
|
+
import styles from './StoryWidget.module.css'
|
|
18
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
19
|
+
|
|
20
|
+
function ComponentIcon({ size = 36 }) {
|
|
21
|
+
return (
|
|
22
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
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" />
|
|
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" />
|
|
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" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveStoryUrl(storyId, exportName) {
|
|
32
|
+
const story = getStoryData(storyId)
|
|
33
|
+
if (!story?._route) return ''
|
|
34
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
35
|
+
const params = new URLSearchParams()
|
|
36
|
+
if (exportName) params.set('export', exportName)
|
|
37
|
+
params.set('_sb_embed', '')
|
|
38
|
+
params.set('_sb_hide_branch_bar', '')
|
|
39
|
+
return `${base}${story._route}?${params}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const _storySourcesCache = {}
|
|
43
|
+
|
|
44
|
+
async function fetchStorySource(modulePath) {
|
|
45
|
+
if (modulePath in _storySourcesCache) return _storySourcesCache[modulePath]
|
|
46
|
+
const url = modulePath.startsWith('/') ? modulePath : `/${modulePath}`
|
|
47
|
+
const res = await fetch(`${url}?raw`)
|
|
48
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url}`)
|
|
49
|
+
const code = await res.text()
|
|
50
|
+
_storySourcesCache[modulePath] = code
|
|
51
|
+
return code
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
55
|
+
const storyId = props?.storyId || ''
|
|
56
|
+
const exportName = props?.exportName || ''
|
|
57
|
+
const width = props?.width
|
|
58
|
+
const height = props?.height
|
|
59
|
+
|
|
60
|
+
const containerRef = useRef(null)
|
|
61
|
+
const iframeRef = useRef(null)
|
|
62
|
+
const [interactive, setInteractive] = useState(false)
|
|
63
|
+
const [showCode, setShowCode] = useState(!!props?.showCode)
|
|
64
|
+
const [sourceCode, setSourceCode] = useState(null)
|
|
65
|
+
const [highlightedHtml, setHighlightedHtml] = useState(null)
|
|
66
|
+
const [sourceLoading, setSourceLoading] = useState(false)
|
|
67
|
+
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
68
|
+
|
|
69
|
+
// Re-resolve story URL when the story index is live-patched
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
72
|
+
document.addEventListener('storyboard:story-index-changed', handler)
|
|
73
|
+
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const toggleShowCode = useCallback(() => {
|
|
77
|
+
setShowCode((v) => {
|
|
78
|
+
const next = !v
|
|
79
|
+
if (onUpdate) onUpdate({ showCode: next })
|
|
80
|
+
return next
|
|
81
|
+
})
|
|
82
|
+
}, [onUpdate])
|
|
83
|
+
|
|
84
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
85
|
+
|
|
86
|
+
// Exit interactive mode when clicking outside
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!interactive) return
|
|
89
|
+
function handlePointerDown(e) {
|
|
90
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
91
|
+
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
92
|
+
if (chromeEl) return
|
|
93
|
+
setInteractive(false)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
97
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
98
|
+
}, [interactive, widgetId])
|
|
99
|
+
|
|
100
|
+
const handleResize = useCallback((w, h) => {
|
|
101
|
+
onUpdate?.({ width: w, height: h })
|
|
102
|
+
}, [onUpdate])
|
|
103
|
+
|
|
104
|
+
// Load source code when show-code is toggled on
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!showCode || sourceCode !== null) return
|
|
107
|
+
const story = getStoryData(storyId)
|
|
108
|
+
if (!story?._storyModule) {
|
|
109
|
+
setSourceCode('// Source not available')
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
let cancelled = false
|
|
113
|
+
setSourceLoading(true)
|
|
114
|
+
fetchStorySource(story._storyModule)
|
|
115
|
+
.then((code) => { if (!cancelled) { setSourceCode(code || '// Empty file'); setSourceLoading(false) } })
|
|
116
|
+
.catch(() => { if (!cancelled) { setSourceCode('// Failed to load source'); setSourceLoading(false) } })
|
|
117
|
+
return () => { cancelled = true; setSourceLoading(false) }
|
|
118
|
+
}, [showCode, sourceCode, storyId])
|
|
119
|
+
|
|
120
|
+
// Re-highlight when theme changes
|
|
121
|
+
const [codeThemeKey, setCodeThemeKey] = useState(0)
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
function onThemeChanged() { setCodeThemeKey((k) => k + 1) }
|
|
124
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
125
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
// Syntax-highlight source code
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!sourceCode) return
|
|
131
|
+
let cancelled = false
|
|
132
|
+
createInspectorHighlighter().then((hl) => {
|
|
133
|
+
if (cancelled) return
|
|
134
|
+
const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
|
|
135
|
+
setHighlightedHtml(hl.codeToHtml(sourceCode, { lang }))
|
|
136
|
+
})
|
|
137
|
+
return () => { cancelled = true }
|
|
138
|
+
}, [sourceCode, storyId, codeThemeKey])
|
|
139
|
+
|
|
140
|
+
const copyCode = useCallback(async () => {
|
|
141
|
+
if (sourceCode) { await navigator.clipboard?.writeText(sourceCode); return }
|
|
142
|
+
const story = getStoryData(storyId)
|
|
143
|
+
if (!story?._storyModule) return
|
|
144
|
+
try {
|
|
145
|
+
const code = await fetchStorySource(story._storyModule)
|
|
146
|
+
setSourceCode(code)
|
|
147
|
+
await navigator.clipboard?.writeText(code)
|
|
148
|
+
} catch { /* */ }
|
|
149
|
+
}, [sourceCode, storyId])
|
|
150
|
+
|
|
151
|
+
useImperativeHandle(ref, () => ({
|
|
152
|
+
getState(key) {
|
|
153
|
+
if (key === 'showCode') return showCode
|
|
154
|
+
return undefined
|
|
155
|
+
},
|
|
156
|
+
handleAction(actionId) {
|
|
157
|
+
if (actionId === 'show-code') toggleShowCode()
|
|
158
|
+
else if (actionId === 'copy-code') copyCode()
|
|
159
|
+
else if (actionId === 'open-external') {
|
|
160
|
+
const story = getStoryData(storyId)
|
|
161
|
+
if (story?._route) {
|
|
162
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
163
|
+
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}), [storyId, showCode, toggleShowCode, copyCode])
|
|
168
|
+
|
|
169
|
+
const iframeSrc = useMemo(
|
|
170
|
+
() => resolveStoryUrl(storyId, exportName),
|
|
171
|
+
[storyId, exportName, storyIndexKey],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
useIframeDevLogs({
|
|
175
|
+
widget: 'StoryWidget',
|
|
176
|
+
loaded: interactive && !showCode && Boolean(iframeSrc),
|
|
177
|
+
src: iframeSrc,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const displayName = exportName ? `${storyId} / ${exportName}` : storyId
|
|
181
|
+
|
|
182
|
+
if (!storyId) {
|
|
183
|
+
return (
|
|
184
|
+
<WidgetWrapper>
|
|
185
|
+
<div className={styles.container} ref={containerRef}>
|
|
186
|
+
<div className={styles.error}>
|
|
187
|
+
<span className={styles.errorIcon}><ComponentIcon size={20} /></span>
|
|
188
|
+
<span className={styles.errorText}>Missing story ID</span>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</WidgetWrapper>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!iframeSrc) {
|
|
196
|
+
return (
|
|
197
|
+
<WidgetWrapper>
|
|
198
|
+
<div className={styles.container} ref={containerRef}>
|
|
199
|
+
<div className={styles.error}>
|
|
200
|
+
<span className={styles.errorIcon}><ComponentIcon size={20} /></span>
|
|
201
|
+
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</WidgetWrapper>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sizeStyle = {}
|
|
209
|
+
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
210
|
+
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<WidgetWrapper>
|
|
214
|
+
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
215
|
+
<div className={styles.header}>
|
|
216
|
+
<span className={styles.headerIcon}><ComponentIcon size={16} /></span>
|
|
217
|
+
<span className={styles.headerTitle}>{displayName}</span>
|
|
218
|
+
</div>
|
|
219
|
+
{showCode ? (
|
|
220
|
+
<div
|
|
221
|
+
className={styles.codeView}
|
|
222
|
+
data-canvas-allow-text-selection
|
|
223
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
224
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
225
|
+
onClick={(e) => e.stopPropagation()}
|
|
226
|
+
>
|
|
227
|
+
<div className={styles.codeHeader}>
|
|
228
|
+
<span className={styles.codeLabel}>{storyId}.story.jsx</span>
|
|
229
|
+
<button className={styles.codeCloseBtn} onClick={() => setShowCode(false)} aria-label="Close code view">×</button>
|
|
230
|
+
</div>
|
|
231
|
+
{sourceLoading ? (
|
|
232
|
+
<div className={styles.codeLoading}>Loading…</div>
|
|
233
|
+
) : highlightedHtml ? (
|
|
234
|
+
<div className={styles.codeBlock} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
|
235
|
+
) : (
|
|
236
|
+
<pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
) : (
|
|
240
|
+
<>
|
|
241
|
+
<div className={styles.content}>
|
|
242
|
+
<iframe
|
|
243
|
+
ref={iframeRef}
|
|
244
|
+
src={iframeSrc}
|
|
245
|
+
className={styles.iframe}
|
|
246
|
+
title={displayName}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{!interactive && (
|
|
251
|
+
<div
|
|
252
|
+
className={overlayStyles.interactOverlay}
|
|
253
|
+
onClick={(e) => {
|
|
254
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
255
|
+
enterInteractive()
|
|
256
|
+
}}
|
|
257
|
+
role="button"
|
|
258
|
+
tabIndex={0}
|
|
259
|
+
onKeyDown={(e) => {
|
|
260
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
261
|
+
e.preventDefault()
|
|
262
|
+
e.stopPropagation()
|
|
263
|
+
enterInteractive()
|
|
264
|
+
}
|
|
265
|
+
}}
|
|
266
|
+
aria-label="Click to interact"
|
|
267
|
+
>
|
|
268
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
{resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
275
|
+
</WidgetWrapper>
|
|
276
|
+
)
|
|
277
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
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: 10px 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
|
+
display: inline-flex;
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.headerTitle {
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
text-overflow: ellipsis;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.content {
|
|
41
|
+
position: relative;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: calc(100% - 37px);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.placeholder {
|
|
47
|
+
position: absolute;
|
|
48
|
+
inset: 0;
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
gap: 8px;
|
|
54
|
+
color: var(--fgColor-muted, #656d76);
|
|
55
|
+
text-align: center;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.placeholderLabel {
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
font-weight: 500;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.spinner {
|
|
64
|
+
width: 24px;
|
|
65
|
+
height: 24px;
|
|
66
|
+
border: 3px solid var(--borderColor-muted, #d0d7de);
|
|
67
|
+
border-top-color: var(--fgColor-accent, #2f81f7);
|
|
68
|
+
border-radius: 50%;
|
|
69
|
+
animation: spin 0.8s linear infinite;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@keyframes spin {
|
|
73
|
+
from { transform: rotate(0deg); }
|
|
74
|
+
to { transform: rotate(360deg); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.iframe {
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset: 0;
|
|
80
|
+
display: block;
|
|
81
|
+
width: 100%;
|
|
82
|
+
height: 100%;
|
|
83
|
+
border: none;
|
|
84
|
+
z-index: 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.snapshotImage {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
width: 100%;
|
|
91
|
+
height: 100%;
|
|
92
|
+
object-fit: cover;
|
|
93
|
+
object-position: top left;
|
|
94
|
+
display: block;
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
transition: opacity 150ms ease;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.codeView {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
height: calc(100% - 37px);
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.codeHeader {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: space-between;
|
|
110
|
+
padding: 4px 10px;
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
font-weight: 500;
|
|
113
|
+
color: var(--fgColor-muted, #656d76);
|
|
114
|
+
background: var(--bgColor-inset, #eff2f5);
|
|
115
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
116
|
+
user-select: none;
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.codeLabel {
|
|
121
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.codeCloseBtn {
|
|
125
|
+
all: unset;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
justify-content: center;
|
|
130
|
+
width: 20px;
|
|
131
|
+
height: 20px;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
font-size: 14px;
|
|
134
|
+
color: var(--fgColor-muted, #656d76);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.codeCloseBtn:hover {
|
|
138
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
139
|
+
color: var(--fgColor-default, #1f2328);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.codeBlock {
|
|
143
|
+
flex: 1;
|
|
144
|
+
margin: 0;
|
|
145
|
+
overflow: auto;
|
|
146
|
+
user-select: text;
|
|
147
|
+
cursor: text;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Style the highlighted pre from the inspector highlighter.
|
|
151
|
+
padding uses !important to override the inline padding:0 from codeToHtml. */
|
|
152
|
+
.codeBlock pre {
|
|
153
|
+
margin: 0;
|
|
154
|
+
padding: var(--base-size-8, 8px) !important;
|
|
155
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
156
|
+
font-size: 12px;
|
|
157
|
+
font-weight: 400;
|
|
158
|
+
line-height: 1.6;
|
|
159
|
+
tab-size: 2;
|
|
160
|
+
min-height: 100%;
|
|
161
|
+
box-sizing: border-box;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Fallback when no highlighted HTML (plain pre/code) */
|
|
165
|
+
.codeBlock > code {
|
|
166
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
font-weight: 400;
|
|
169
|
+
line-height: 1.6;
|
|
170
|
+
white-space: pre;
|
|
171
|
+
tab-size: 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.codeLoading {
|
|
175
|
+
flex: 1;
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: center;
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: var(--fgColor-muted, #656d76);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.error {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
gap: 8px;
|
|
187
|
+
padding: 16px;
|
|
188
|
+
color: var(--fgColor-danger, #cf222e);
|
|
189
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
190
|
+
font-size: 13px;
|
|
191
|
+
line-height: 1.5;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.errorIcon {
|
|
195
|
+
font-size: 20px;
|
|
196
|
+
flex-shrink: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.errorText {
|
|
200
|
+
word-break: break-word;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.loading {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: center;
|
|
207
|
+
padding: 16px;
|
|
208
|
+
color: var(--fgColor-muted, #656d76);
|
|
209
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
210
|
+
font-size: 13px;
|
|
211
|
+
}
|
|
@@ -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, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } 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">
|
|
@@ -110,6 +122,22 @@ function ExpandIcon() {
|
|
|
110
122
|
)
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
function SyncIcon() {
|
|
126
|
+
return (
|
|
127
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
128
|
+
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
|
|
129
|
+
</svg>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function UnfoldIcon() {
|
|
134
|
+
return <OcticonUnfold size={12} />
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function FoldIcon() {
|
|
138
|
+
return <OcticonFold size={12} />
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
/** Icon registry — maps icon name strings from config to React components. */
|
|
114
142
|
const ICON_REGISTRY = {
|
|
115
143
|
'trash': DeleteIcon,
|
|
@@ -119,12 +147,18 @@ const ICON_REGISTRY = {
|
|
|
119
147
|
'open-external': OpenExternalIcon,
|
|
120
148
|
'eye': EyeIcon,
|
|
121
149
|
'eye-closed': EyeClosedIcon,
|
|
150
|
+
'code': CodeIcon,
|
|
151
|
+
'unwrap': UnwrapIcon,
|
|
152
|
+
'image': ImageIcon,
|
|
122
153
|
'copy': CopyIcon,
|
|
123
154
|
'link': LinkIcon,
|
|
124
155
|
'more': MoreIcon,
|
|
125
156
|
'chevron-down': ChevronDownIcon,
|
|
126
157
|
'download': DownloadIcon,
|
|
127
158
|
'expand': ExpandIcon,
|
|
159
|
+
'sync': SyncIcon,
|
|
160
|
+
'unfold': UnfoldIcon,
|
|
161
|
+
'fold': FoldIcon,
|
|
128
162
|
}
|
|
129
163
|
|
|
130
164
|
/** Danger-styled actions in the overflow menu. */
|
|
@@ -189,8 +223,8 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
|
189
223
|
url.searchParams.set('widget', widgetId)
|
|
190
224
|
navigator.clipboard.writeText(url.toString()).catch(() => {})
|
|
191
225
|
} else if (action === 'copy-widget-id') {
|
|
192
|
-
const
|
|
193
|
-
navigator.clipboard.writeText(`${
|
|
226
|
+
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
|
|
227
|
+
navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
|
|
194
228
|
} else {
|
|
195
229
|
onAction?.(action)
|
|
196
230
|
}
|
|
@@ -411,16 +445,22 @@ export default function WidgetChrome({
|
|
|
411
445
|
onUpdate?.({ color })
|
|
412
446
|
}, [onUpdate])
|
|
413
447
|
|
|
414
|
-
|
|
448
|
+
// In readOnly mode, features are already filtered to prod-only by getFeatures.
|
|
449
|
+
// Show toolbar if there are prod features even when readOnly.
|
|
450
|
+
const hasFeatures = features.length > 0
|
|
451
|
+
const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
|
|
415
452
|
const showFeatures = showToolbar && !multiSelected
|
|
453
|
+
const menuFeatures = features.filter((f) => f.menu)
|
|
416
454
|
|
|
417
455
|
return (
|
|
418
456
|
<div
|
|
419
457
|
className={styles.chromeContainer}
|
|
420
|
-
|
|
421
|
-
|
|
458
|
+
data-widget-id={widgetId}
|
|
459
|
+
data-tc-elevated={(hovered || selected) || undefined}
|
|
460
|
+
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
461
|
+
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
422
462
|
>
|
|
423
|
-
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
463
|
+
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
|
|
424
464
|
{children}
|
|
425
465
|
</div>
|
|
426
466
|
<div
|
|
@@ -464,6 +504,11 @@ export default function WidgetChrome({
|
|
|
464
504
|
}
|
|
465
505
|
}
|
|
466
506
|
|
|
507
|
+
// Show-code toggle: swap label based on widget state
|
|
508
|
+
if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
|
|
509
|
+
label = 'Show component'
|
|
510
|
+
}
|
|
511
|
+
|
|
467
512
|
return (
|
|
468
513
|
<Tooltip key={feature.id} text={label} direction="n">
|
|
469
514
|
<button
|
|
@@ -495,22 +540,33 @@ export default function WidgetChrome({
|
|
|
495
540
|
|
|
496
541
|
return null
|
|
497
542
|
})}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
543
|
+
{menuFeatures.length > 0 && (
|
|
544
|
+
<WidgetOverflowMenu
|
|
545
|
+
widgetId={widgetId}
|
|
546
|
+
menuFeatures={menuFeatures}
|
|
547
|
+
onAction={(actionId) => {
|
|
548
|
+
// Route overflow menu actions through the widget ref first
|
|
549
|
+
if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
|
|
550
|
+
widgetRef.current.handleAction(actionId)
|
|
551
|
+
} else {
|
|
552
|
+
onAction?.(actionId)
|
|
553
|
+
}
|
|
554
|
+
}}
|
|
555
|
+
/>
|
|
556
|
+
)}
|
|
503
557
|
</div>
|
|
504
558
|
)}
|
|
505
559
|
|
|
506
|
-
|
|
507
|
-
<
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
560
|
+
{!readOnly && (
|
|
561
|
+
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
562
|
+
<button
|
|
563
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
564
|
+
onClick={handleHandleClick}
|
|
565
|
+
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
566
|
+
aria-pressed={selected}
|
|
567
|
+
/>
|
|
568
|
+
</Tooltip>
|
|
569
|
+
)}
|
|
514
570
|
</div>
|
|
515
571
|
</div>
|
|
516
572
|
</div>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
top: calc(100% + 10px);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* Trigger dot —
|
|
36
|
+
/* Trigger dot — positioned in the toolbar, visible at rest */
|
|
37
37
|
.triggerDot {
|
|
38
38
|
width: 6px;
|
|
39
39
|
height: 6px;
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
background: var(--borderColor-muted, #d0d7de);
|
|
42
42
|
opacity: 0.5;
|
|
43
43
|
transition: opacity 120ms;
|
|
44
|
-
position: absolute;
|
|
45
|
-
left: 50%;
|
|
46
|
-
top: 50%;
|
|
47
|
-
transform: translate(-50%, -50%);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
@@ -235,7 +231,7 @@
|
|
|
235
231
|
.overflowMenu {
|
|
236
232
|
position: absolute;
|
|
237
233
|
top: calc(100% + 10px);
|
|
238
|
-
|
|
234
|
+
left: 0;
|
|
239
235
|
min-width: max-content;
|
|
240
236
|
padding: 4px;
|
|
241
237
|
background: var(--bgColor-default, #ffffff);
|