@dfosco/storyboard-react 4.2.4 → 4.2.5
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/CommandPalette/CommandPalette.jsx +4 -6
- package/src/Viewfinder.jsx +7 -4
- package/src/canvas/CanvasPage.jsx +60 -3
- package/src/canvas/WebGLContextPool.jsx +292 -0
- package/src/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/canvas/componentIsolate.jsx +45 -15
- package/src/canvas/componentSetIsolate.jsx +257 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +2 -208
- package/src/canvas/widgets/ComponentWidget.jsx +0 -139
- package/src/canvas/widgets/ComponentWidget.module.css +0 -26
- package/src/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/canvas/widgets/PromptWidget.jsx +16 -2
- package/src/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/canvas/widgets/StoryWidget.jsx +3 -4
- package/src/canvas/widgets/TerminalWidget.jsx +146 -100
- package/src/canvas/widgets/TerminalWidget.module.css +23 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -61
- package/src/canvas/widgets/expandUtils.js +3 -4
- package/src/canvas/widgets/index.js +2 -2
- package/src/canvas/widgets/snapshotDisplay.test.jsx +1 -1
- package/src/context.jsx +70 -7
- package/src/vite/data-plugin.js +8 -2
|
@@ -89,11 +89,20 @@ document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark'
|
|
|
89
89
|
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
90
90
|
document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
|
|
91
91
|
|
|
92
|
+
// Suppress HMR full-reloads — this iframe is embedded inside a canvas page
|
|
93
|
+
// that manages its own reload lifecycle. Without this guard, every file change
|
|
94
|
+
// causes the iframe to flash/reload.
|
|
95
|
+
if (import.meta.hot) {
|
|
96
|
+
const msg = { active: true }
|
|
97
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
98
|
+
setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
const root = createRoot(document.getElementById('root'))
|
|
93
102
|
|
|
94
103
|
async function mount() {
|
|
95
|
-
if (!modulePath
|
|
96
|
-
root.render(createElement('div', { style: errorStyle }, 'Missing module
|
|
104
|
+
if (!modulePath) {
|
|
105
|
+
root.render(createElement('div', { style: errorStyle }, 'Missing module param'))
|
|
97
106
|
return
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -106,25 +115,46 @@ async function mount() {
|
|
|
106
115
|
try {
|
|
107
116
|
const resolved = resolveModulePath(modulePath)
|
|
108
117
|
const mod = await import(/* @vite-ignore */ resolved)
|
|
109
|
-
const Component = mod[exportName]
|
|
110
|
-
|
|
111
|
-
if (!Component || typeof Component !== 'function') {
|
|
112
|
-
throw new Error(`Export "${exportName}" not found or is not a component`)
|
|
113
|
-
}
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
if (exportName) {
|
|
120
|
+
// Single export mode
|
|
121
|
+
const Component = mod[exportName]
|
|
122
|
+
if (!Component || typeof Component !== 'function') {
|
|
123
|
+
throw new Error(`Export "${exportName}" not found or is not a component`)
|
|
124
|
+
}
|
|
125
|
+
root.render(
|
|
126
|
+
createElement(ThemeProvider, { colorMode },
|
|
127
|
+
createElement(BaseStyles, null,
|
|
128
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
129
|
+
createElement(Component),
|
|
130
|
+
),
|
|
120
131
|
),
|
|
121
132
|
),
|
|
122
|
-
)
|
|
123
|
-
|
|
133
|
+
)
|
|
134
|
+
} else {
|
|
135
|
+
// All exports mode — render every named function export stacked
|
|
136
|
+
const entries = Object.entries(mod).filter(
|
|
137
|
+
([key, value]) => key !== 'default' && typeof value === 'function',
|
|
138
|
+
)
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
throw new Error('No named exports found in story module')
|
|
141
|
+
}
|
|
142
|
+
root.render(
|
|
143
|
+
createElement(ThemeProvider, { colorMode },
|
|
144
|
+
createElement(BaseStyles, null,
|
|
145
|
+
...entries.map(([name, Component]) =>
|
|
146
|
+
createElement(IsolateErrorBoundary, { key: name, name },
|
|
147
|
+
createElement(Component),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
124
154
|
} catch (err) {
|
|
125
155
|
root.render(
|
|
126
156
|
createElement('div', { style: errorStyle },
|
|
127
|
-
createElement('strong', null, exportName),
|
|
157
|
+
createElement('strong', null, exportName || 'Component'),
|
|
128
158
|
createElement('br'),
|
|
129
159
|
String(err.message || err),
|
|
130
160
|
),
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Component-Set Isolate — lightweight iframe entry point.
|
|
3
|
+
*
|
|
4
|
+
* Renders ALL named exports from a .story.jsx module in a grid layout,
|
|
5
|
+
* bypassing the full SPA bootstrap (router, StoryboardProvider, data index).
|
|
6
|
+
*
|
|
7
|
+
* This is the component-set equivalent of componentIsolate.jsx, which renders
|
|
8
|
+
* a single export. By avoiding the full app bootstrap, component-set widgets
|
|
9
|
+
* load significantly faster — especially important since each widget is an
|
|
10
|
+
* iframe that would otherwise need to initialize the entire app.
|
|
11
|
+
*
|
|
12
|
+
* Query params:
|
|
13
|
+
* module — absolute or base-relative path to the .story.jsx file
|
|
14
|
+
* layout — "horizontal" (default) | "vertical"
|
|
15
|
+
* selected — export name of the currently selected cell
|
|
16
|
+
* theme — canvas theme (light / dark / dark_dimmed)
|
|
17
|
+
*/
|
|
18
|
+
import { createElement, Component as ReactComponent, useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'
|
|
19
|
+
import { createRoot } from 'react-dom/client'
|
|
20
|
+
import { ThemeProvider, BaseStyles } from '@primer/react'
|
|
21
|
+
|
|
22
|
+
// ── Primer Primitives CSS (required for CSS variables) ──────────────
|
|
23
|
+
import '@primer/primitives/dist/css/base/size/size.css'
|
|
24
|
+
import '@primer/primitives/dist/css/base/typography/typography.css'
|
|
25
|
+
import '@primer/primitives/dist/css/base/motion/motion.css'
|
|
26
|
+
import '@primer/primitives/dist/css/functional/size/border.css'
|
|
27
|
+
import '@primer/primitives/dist/css/functional/size/breakpoints.css'
|
|
28
|
+
import '@primer/primitives/dist/css/functional/size/size-coarse.css'
|
|
29
|
+
import '@primer/primitives/dist/css/functional/size/size-fine.css'
|
|
30
|
+
import '@primer/primitives/dist/css/functional/size/size.css'
|
|
31
|
+
import '@primer/primitives/dist/css/functional/size/viewport.css'
|
|
32
|
+
import '@primer/primitives/dist/css/functional/typography/typography.css'
|
|
33
|
+
import '@primer/primitives/dist/css/functional/themes/light.css'
|
|
34
|
+
import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'
|
|
35
|
+
import '@primer/primitives/dist/css/functional/themes/dark.css'
|
|
36
|
+
import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'
|
|
37
|
+
import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'
|
|
38
|
+
import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'
|
|
39
|
+
|
|
40
|
+
import styles from '../story/ComponentSetPage.module.css'
|
|
41
|
+
|
|
42
|
+
// ── Error Boundary ──────────────────────────────────────────────────
|
|
43
|
+
class IsolateErrorBoundary extends ReactComponent {
|
|
44
|
+
constructor(props) {
|
|
45
|
+
super(props)
|
|
46
|
+
this.state = { error: null }
|
|
47
|
+
}
|
|
48
|
+
static getDerivedStateFromError(error) {
|
|
49
|
+
return { error }
|
|
50
|
+
}
|
|
51
|
+
render() {
|
|
52
|
+
if (this.state.error) {
|
|
53
|
+
return createElement('div', { style: errorStyle },
|
|
54
|
+
createElement('strong', null, this.props.name || 'Component'),
|
|
55
|
+
createElement('br'),
|
|
56
|
+
String(this.state.error.message || this.state.error),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return this.props.children
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const errorStyle = {
|
|
64
|
+
padding: '16px',
|
|
65
|
+
color: '#cf222e',
|
|
66
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
67
|
+
fontSize: '13px',
|
|
68
|
+
lineHeight: 1.5,
|
|
69
|
+
whiteSpace: 'pre-wrap',
|
|
70
|
+
wordBreak: 'break-word',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Resolve module path ─────────────────────────────────────────────
|
|
74
|
+
function resolveModulePath(raw) {
|
|
75
|
+
if (!raw) return raw
|
|
76
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
|
|
77
|
+
if (!raw.startsWith('/')) return raw
|
|
78
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
79
|
+
if (!base) return raw
|
|
80
|
+
if (raw.startsWith(base)) return raw
|
|
81
|
+
return `${base}${raw}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Component-Set Grid (mirrors ComponentSetPage UI) ────────────────
|
|
85
|
+
function ComponentSetGrid({ exports, layout, initialSelected }) {
|
|
86
|
+
const [selected, setSelected] = useState(initialSelected)
|
|
87
|
+
const gridRef = useRef(null)
|
|
88
|
+
|
|
89
|
+
const handleSelect = useCallback((exportName) => {
|
|
90
|
+
const next = exportName === selected ? '' : exportName
|
|
91
|
+
setSelected(next)
|
|
92
|
+
|
|
93
|
+
// Update URL without navigation
|
|
94
|
+
const params = new URLSearchParams(window.location.search)
|
|
95
|
+
if (next) params.set('selected', next)
|
|
96
|
+
else params.delete('selected')
|
|
97
|
+
window.history.replaceState(null, '', `${window.location.pathname}?${params}`)
|
|
98
|
+
|
|
99
|
+
// Notify parent widget
|
|
100
|
+
if (window.parent !== window) {
|
|
101
|
+
window.parent.postMessage({
|
|
102
|
+
type: 'storyboard:component-set:select',
|
|
103
|
+
exportName: next || null,
|
|
104
|
+
}, '*')
|
|
105
|
+
}
|
|
106
|
+
}, [selected])
|
|
107
|
+
|
|
108
|
+
// Measure cells and post grid size to parent
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const grid = gridRef.current
|
|
111
|
+
if (!grid || !exports) return
|
|
112
|
+
|
|
113
|
+
const cells = grid.querySelectorAll('[data-cell-content]')
|
|
114
|
+
if (cells.length === 0) return
|
|
115
|
+
|
|
116
|
+
function measure() {
|
|
117
|
+
let maxW = 0
|
|
118
|
+
let maxH = 0
|
|
119
|
+
for (const el of cells) {
|
|
120
|
+
maxW = Math.max(maxW, el.scrollWidth)
|
|
121
|
+
maxH = Math.max(maxH, el.scrollHeight)
|
|
122
|
+
}
|
|
123
|
+
grid.style.setProperty('--cell-snap-w', `${maxW}px`)
|
|
124
|
+
grid.style.setProperty('--cell-snap-h', `${maxH}px`)
|
|
125
|
+
|
|
126
|
+
if (window.parent !== window) {
|
|
127
|
+
requestAnimationFrame(() => {
|
|
128
|
+
window.parent.postMessage({
|
|
129
|
+
type: 'storyboard:component-set:resize',
|
|
130
|
+
width: grid.scrollWidth,
|
|
131
|
+
height: grid.scrollHeight,
|
|
132
|
+
}, '*')
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
measure()
|
|
138
|
+
document.fonts.ready.then(() => requestAnimationFrame(measure))
|
|
139
|
+
|
|
140
|
+
const ro = new ResizeObserver(measure)
|
|
141
|
+
for (const el of cells) ro.observe(el)
|
|
142
|
+
return () => ro.disconnect()
|
|
143
|
+
}, [exports, layout])
|
|
144
|
+
|
|
145
|
+
// Signal snapshot-ready
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
document.fonts.ready.then(() => {
|
|
148
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
149
|
+
window.__sbSnapshotReady?.()
|
|
150
|
+
}))
|
|
151
|
+
})
|
|
152
|
+
}, [exports])
|
|
153
|
+
|
|
154
|
+
const exportNames = Object.keys(exports)
|
|
155
|
+
|
|
156
|
+
// eslint-disable-next-line react-hooks/refs -- ref assigned to DOM element, not read during render
|
|
157
|
+
return createElement('div', {
|
|
158
|
+
ref: gridRef,
|
|
159
|
+
className: styles.grid,
|
|
160
|
+
'data-layout': layout,
|
|
161
|
+
},
|
|
162
|
+
exportNames.map((exportName) => {
|
|
163
|
+
const Component = exports[exportName]
|
|
164
|
+
const isSelected = exportName === selected
|
|
165
|
+
return createElement('div', {
|
|
166
|
+
key: exportName,
|
|
167
|
+
className: styles.cell,
|
|
168
|
+
'data-selected': isSelected || undefined,
|
|
169
|
+
},
|
|
170
|
+
createElement('button', {
|
|
171
|
+
className: styles.cellLabel,
|
|
172
|
+
onClick: () => handleSelect(exportName),
|
|
173
|
+
'data-selected': isSelected || undefined,
|
|
174
|
+
'aria-pressed': isSelected,
|
|
175
|
+
},
|
|
176
|
+
createElement('span', { className: styles.cellRadio, 'data-selected': isSelected || undefined }),
|
|
177
|
+
createElement('span', { className: styles.cellName }, exportName),
|
|
178
|
+
),
|
|
179
|
+
createElement('div', { className: styles.cellContent, 'data-cell-content': '' },
|
|
180
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
181
|
+
createElement(Component),
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
}),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
190
|
+
const params = new URLSearchParams(window.location.search)
|
|
191
|
+
const modulePath = params.get('module')
|
|
192
|
+
const layout = params.get('layout') || 'horizontal'
|
|
193
|
+
const selected = params.get('selected') || ''
|
|
194
|
+
const theme = params.get('theme') || 'light'
|
|
195
|
+
|
|
196
|
+
const colorMode = theme.startsWith('dark') ? 'night' : 'day'
|
|
197
|
+
|
|
198
|
+
document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
|
|
199
|
+
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
200
|
+
document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
|
|
201
|
+
|
|
202
|
+
// Suppress HMR full-reloads — this iframe is embedded inside a canvas page
|
|
203
|
+
// that manages its own reload lifecycle. Without this guard, every file change
|
|
204
|
+
// causes the iframe to flash/reload.
|
|
205
|
+
if (import.meta.hot) {
|
|
206
|
+
const msg = { active: true }
|
|
207
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
208
|
+
setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const root = createRoot(document.getElementById('root'))
|
|
212
|
+
|
|
213
|
+
async function mount() {
|
|
214
|
+
if (!modulePath) {
|
|
215
|
+
root.render(createElement('div', { style: errorStyle }, 'Missing module param'))
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!modulePath.match(/\.story\.(jsx|tsx)$/)) {
|
|
220
|
+
root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .story.jsx/.tsx files are allowed'))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const resolved = resolveModulePath(modulePath)
|
|
226
|
+
const mod = await import(/* @vite-ignore */ resolved)
|
|
227
|
+
|
|
228
|
+
const namedExports = {}
|
|
229
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
230
|
+
if (key !== 'default' && typeof value === 'function') {
|
|
231
|
+
namedExports[key] = value
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (Object.keys(namedExports).length === 0) {
|
|
236
|
+
throw new Error('No named exports found in story module')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
root.render(
|
|
240
|
+
createElement(ThemeProvider, { colorMode },
|
|
241
|
+
createElement(BaseStyles, null,
|
|
242
|
+
createElement(ComponentSetGrid, { exports: namedExports, layout, initialSelected: selected }),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
root.render(
|
|
248
|
+
createElement('div', { style: errorStyle },
|
|
249
|
+
createElement('strong', null, 'Component Set'),
|
|
250
|
+
createElement('br'),
|
|
251
|
+
String(err.message || err),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
mount()
|
|
@@ -1,208 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Instead of N iframes (one per export), this widget loads one iframe pointing
|
|
5
|
-
* to the story's ComponentSetPage. Each export renders in a grid cell inside
|
|
6
|
-
* that single page. The user can select a cell (via label click) which updates
|
|
7
|
-
* `props.selected` — visible to connected agents.
|
|
8
|
-
*
|
|
9
|
-
* Props: { storyId, layout, selected, width, height }
|
|
10
|
-
*/
|
|
11
|
-
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
|
-
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
-
import Icon from '../../Icon.jsx'
|
|
14
|
-
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
15
|
-
import ResizeHandle from './ResizeHandle.jsx'
|
|
16
|
-
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
17
|
-
import styles from './ComponentSetWidget.module.css'
|
|
18
|
-
import overlayStyles from './embedOverlay.module.css'
|
|
19
|
-
|
|
20
|
-
function GridIcon({ size = 16 }) {
|
|
21
|
-
return <Icon name="iconoir/view-grid" size={size} />
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function resolveComponentSetUrl(storyId, layout, selected) {
|
|
25
|
-
const story = getStoryData(storyId)
|
|
26
|
-
if (!story?._route) return ''
|
|
27
|
-
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
28
|
-
const params = new URLSearchParams()
|
|
29
|
-
params.set('_sb_embed', '')
|
|
30
|
-
params.set('_sb_hide_branch_bar', '')
|
|
31
|
-
params.set('_sb_component_set', '')
|
|
32
|
-
if (layout) params.set('layout', layout)
|
|
33
|
-
if (selected) params.set('selected', selected)
|
|
34
|
-
return `${base}${story._route}?${params}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
38
|
-
const storyId = props?.storyId || ''
|
|
39
|
-
const layout = props?.layout || 'horizontal'
|
|
40
|
-
const selected = props?.selected || ''
|
|
41
|
-
const width = props?.width
|
|
42
|
-
const height = props?.height
|
|
43
|
-
|
|
44
|
-
const containerRef = useRef(null)
|
|
45
|
-
const iframeRef = useRef(null)
|
|
46
|
-
const [interactive, setInteractive] = useState(false)
|
|
47
|
-
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
48
|
-
|
|
49
|
-
// Re-resolve when story index is live-patched
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
52
|
-
document.addEventListener('storyboard:story-index-changed', handler)
|
|
53
|
-
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
54
|
-
}, [])
|
|
55
|
-
|
|
56
|
-
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
57
|
-
|
|
58
|
-
// Exit interactive mode when clicking outside
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (!interactive) return
|
|
61
|
-
function handlePointerDown(e) {
|
|
62
|
-
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
63
|
-
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
64
|
-
if (chromeEl) return
|
|
65
|
-
setInteractive(false)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
document.addEventListener('pointerdown', handlePointerDown)
|
|
69
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
70
|
-
}, [interactive, widgetId])
|
|
71
|
-
|
|
72
|
-
// Listen for selection messages from the embedded ComponentSetPage
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
function handleMessage(e) {
|
|
75
|
-
if (e.source !== iframeRef.current?.contentWindow) return
|
|
76
|
-
if (e.data?.type === 'storyboard:component-set:select') {
|
|
77
|
-
const newSelected = e.data.exportName || ''
|
|
78
|
-
if (newSelected !== selected) {
|
|
79
|
-
onUpdate?.({ selected: newSelected })
|
|
80
|
-
}
|
|
81
|
-
} else if (e.data?.type === 'storyboard:component-set:resize') {
|
|
82
|
-
// Auto-size widget to fit the grid content (+ header height)
|
|
83
|
-
const headerH = 32
|
|
84
|
-
const newW = Math.max(200, Math.ceil(e.data.width))
|
|
85
|
-
const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
|
|
86
|
-
if (newW !== width || newH !== height) {
|
|
87
|
-
onUpdate?.({ width: newW, height: newH })
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
window.addEventListener('message', handleMessage)
|
|
92
|
-
return () => window.removeEventListener('message', handleMessage)
|
|
93
|
-
}, [selected, width, height, onUpdate])
|
|
94
|
-
|
|
95
|
-
const handleResize = useCallback((w, h) => {
|
|
96
|
-
onUpdate?.({ width: w, height: h })
|
|
97
|
-
}, [onUpdate])
|
|
98
|
-
|
|
99
|
-
useImperativeHandle(ref, () => ({
|
|
100
|
-
handleAction(actionId) {
|
|
101
|
-
if (actionId === 'flip-layout') {
|
|
102
|
-
const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
|
|
103
|
-
onUpdate?.({ layout: next })
|
|
104
|
-
return true
|
|
105
|
-
} else if (actionId === 'open-external') {
|
|
106
|
-
const story = getStoryData(storyId)
|
|
107
|
-
if (story?._route) {
|
|
108
|
-
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
109
|
-
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
110
|
-
}
|
|
111
|
-
return true
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
}), [storyId, layout, onUpdate])
|
|
115
|
-
|
|
116
|
-
const iframeSrc = useMemo(
|
|
117
|
-
() => resolveComponentSetUrl(storyId, layout, selected),
|
|
118
|
-
// storyIndexKey forces re-evaluation when HMR mutates the story index
|
|
119
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
-
[storyId, layout, selected, storyIndexKey],
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
useIframeDevLogs({
|
|
124
|
-
widget: 'ComponentSetWidget',
|
|
125
|
-
loaded: interactive && Boolean(iframeSrc),
|
|
126
|
-
src: iframeSrc,
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
const displayName = storyId || 'Component Set'
|
|
130
|
-
|
|
131
|
-
if (!storyId) {
|
|
132
|
-
return (
|
|
133
|
-
<WidgetWrapper>
|
|
134
|
-
<div className={styles.container} ref={containerRef}>
|
|
135
|
-
<div className={styles.error}>
|
|
136
|
-
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
137
|
-
<span className={styles.errorText}>Missing story ID</span>
|
|
138
|
-
</div>
|
|
139
|
-
</div>
|
|
140
|
-
</WidgetWrapper>
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (!iframeSrc) {
|
|
145
|
-
return (
|
|
146
|
-
<WidgetWrapper>
|
|
147
|
-
<div className={styles.container} ref={containerRef}>
|
|
148
|
-
<div className={styles.error}>
|
|
149
|
-
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
150
|
-
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
151
|
-
</div>
|
|
152
|
-
</div>
|
|
153
|
-
</WidgetWrapper>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const sizeStyle = {}
|
|
158
|
-
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
159
|
-
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<WidgetWrapper>
|
|
163
|
-
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
164
|
-
<div className={styles.header}>
|
|
165
|
-
<span className={styles.headerIcon}><GridIcon size={16} /></span>
|
|
166
|
-
<span className={styles.headerTitle}>{displayName}</span>
|
|
167
|
-
{selected && (
|
|
168
|
-
<span className={styles.headerSelected}>· {selected}</span>
|
|
169
|
-
)}
|
|
170
|
-
<span className={styles.headerLayout} title={`Layout: ${layout}`}>
|
|
171
|
-
{layout === 'horizontal' ? '⇔' : '⇕'}
|
|
172
|
-
</span>
|
|
173
|
-
</div>
|
|
174
|
-
<div className={styles.content}>
|
|
175
|
-
<iframe
|
|
176
|
-
ref={iframeRef}
|
|
177
|
-
src={iframeSrc}
|
|
178
|
-
className={styles.iframe}
|
|
179
|
-
title={`${displayName} component set`}
|
|
180
|
-
onLoad={(e) => e.target.blur()}
|
|
181
|
-
/>
|
|
182
|
-
</div>
|
|
183
|
-
{!interactive && (
|
|
184
|
-
<div
|
|
185
|
-
className={overlayStyles.interactOverlay}
|
|
186
|
-
onClick={(e) => {
|
|
187
|
-
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
188
|
-
enterInteractive()
|
|
189
|
-
}}
|
|
190
|
-
role="button"
|
|
191
|
-
tabIndex={0}
|
|
192
|
-
onKeyDown={(e) => {
|
|
193
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
194
|
-
e.preventDefault()
|
|
195
|
-
e.stopPropagation()
|
|
196
|
-
enterInteractive()
|
|
197
|
-
}
|
|
198
|
-
}}
|
|
199
|
-
aria-label="Click to interact"
|
|
200
|
-
>
|
|
201
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
{resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
206
|
-
</WidgetWrapper>
|
|
207
|
-
)
|
|
208
|
-
})
|
|
1
|
+
// Deprecated — renamed to StorySetWidget. This re-export exists for backward compatibility.
|
|
2
|
+
export { default } from './StorySetWidget.jsx'
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
2
|
-
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
-
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
-
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
5
|
-
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
6
|
-
import styles from './ComponentWidget.module.css'
|
|
7
|
-
import overlayStyles from './embedOverlay.module.css'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Renders a live JSX export from a .story.jsx file.
|
|
11
|
-
*
|
|
12
|
-
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
13
|
-
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
14
|
-
* components so they cannot crash the entire canvas page.
|
|
15
|
-
*
|
|
16
|
-
* In production, the component is rendered directly with an ErrorBoundary
|
|
17
|
-
* as a fallback safety net.
|
|
18
|
-
*
|
|
19
|
-
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
20
|
-
* Click outside to exit interactive mode.
|
|
21
|
-
*/
|
|
22
|
-
export default function ComponentWidget({
|
|
23
|
-
component: Component,
|
|
24
|
-
jsxModule,
|
|
25
|
-
exportName,
|
|
26
|
-
canvasTheme,
|
|
27
|
-
isLocalDev,
|
|
28
|
-
width,
|
|
29
|
-
height,
|
|
30
|
-
onUpdate,
|
|
31
|
-
resizable,
|
|
32
|
-
}) {
|
|
33
|
-
const containerRef = useRef(null)
|
|
34
|
-
const [interactive, setInteractive] = useState(false)
|
|
35
|
-
const [showIframe, setShowIframe] = useState(false)
|
|
36
|
-
|
|
37
|
-
const handleResize = useCallback((w, h) => {
|
|
38
|
-
onUpdate?.({ width: w, height: h })
|
|
39
|
-
}, [onUpdate])
|
|
40
|
-
|
|
41
|
-
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
42
|
-
|
|
43
|
-
// Exit interactive mode when clicking outside the component
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (!interactive) return
|
|
46
|
-
function handlePointerDown(e) {
|
|
47
|
-
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
48
|
-
setInteractive(false)
|
|
49
|
-
setShowIframe(false)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
document.addEventListener('pointerdown', handlePointerDown)
|
|
53
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
54
|
-
}, [interactive])
|
|
55
|
-
|
|
56
|
-
// Build iframe src for dev isolation
|
|
57
|
-
const iframeSrc = useMemo(() => {
|
|
58
|
-
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
59
|
-
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
60
|
-
const params = new URLSearchParams({
|
|
61
|
-
module: jsxModule,
|
|
62
|
-
export: exportName,
|
|
63
|
-
theme: canvasTheme || 'light',
|
|
64
|
-
})
|
|
65
|
-
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
66
|
-
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
67
|
-
|
|
68
|
-
const useIframe = isLocalDev && iframeSrc
|
|
69
|
-
|
|
70
|
-
useIframeDevLogs({
|
|
71
|
-
widget: 'ComponentWidget',
|
|
72
|
-
loaded: Boolean(useIframe && showIframe),
|
|
73
|
-
src: iframeSrc,
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
if (!useIframe && !Component) return null
|
|
77
|
-
|
|
78
|
-
const sizeStyle = {}
|
|
79
|
-
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
80
|
-
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
81
|
-
|
|
82
|
-
return (
|
|
83
|
-
<WidgetWrapper>
|
|
84
|
-
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
85
|
-
<div className={styles.content}>
|
|
86
|
-
{useIframe ? (
|
|
87
|
-
showIframe ? (
|
|
88
|
-
<iframe
|
|
89
|
-
src={iframeSrc}
|
|
90
|
-
className={styles.iframe}
|
|
91
|
-
title={exportName || 'Component widget'}
|
|
92
|
-
sandbox="allow-same-origin allow-scripts"
|
|
93
|
-
onLoad={(e) => e.target.blur()}
|
|
94
|
-
/>
|
|
95
|
-
) : (
|
|
96
|
-
<div className={styles.placeholder} />
|
|
97
|
-
)
|
|
98
|
-
) : Component ? (
|
|
99
|
-
<ComponentErrorBoundary name={exportName}>
|
|
100
|
-
<Component />
|
|
101
|
-
</ComponentErrorBoundary>
|
|
102
|
-
) : null}
|
|
103
|
-
</div>
|
|
104
|
-
{!interactive && (
|
|
105
|
-
<div
|
|
106
|
-
className={overlayStyles.interactOverlay}
|
|
107
|
-
onClick={(e) => {
|
|
108
|
-
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
109
|
-
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
110
|
-
if (useIframe) setShowIframe(true)
|
|
111
|
-
enterInteractive()
|
|
112
|
-
}}
|
|
113
|
-
role="button"
|
|
114
|
-
tabIndex={0}
|
|
115
|
-
onKeyDown={(e) => {
|
|
116
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
117
|
-
e.preventDefault()
|
|
118
|
-
e.stopPropagation()
|
|
119
|
-
if (useIframe) setShowIframe(true)
|
|
120
|
-
enterInteractive()
|
|
121
|
-
}
|
|
122
|
-
}}
|
|
123
|
-
aria-label="Click to interact with component"
|
|
124
|
-
>
|
|
125
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
126
|
-
</div>
|
|
127
|
-
)}
|
|
128
|
-
{resizable && (
|
|
129
|
-
<ResizeHandle
|
|
130
|
-
targetRef={containerRef}
|
|
131
|
-
minWidth={100}
|
|
132
|
-
minHeight={60}
|
|
133
|
-
onResize={handleResize}
|
|
134
|
-
/>
|
|
135
|
-
)}
|
|
136
|
-
</div>
|
|
137
|
-
</WidgetWrapper>
|
|
138
|
-
)
|
|
139
|
-
}
|