@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.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 +9 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +478 -186
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +561 -191
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +738 -216
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -153
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +472 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +112 -4
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +72 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +496 -69
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +73 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +557 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +55 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +47 -19
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +8 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +324 -30
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for expandable widget modals and split-screen.
|
|
3
|
+
*
|
|
4
|
+
* Reads the canvas bridge state to find connected widgets eligible
|
|
5
|
+
* for split-screen, and builds iframe URLs for secondary panes.
|
|
6
|
+
*/
|
|
7
|
+
import { createElement, useCallback, useState } from 'react'
|
|
8
|
+
import { getStoryData } from '@dfosco/storyboard-core'
|
|
9
|
+
import { isSplitScreenCapable, getWidgetMeta, getFeaturesForSurface } from './widgetConfig.js'
|
|
10
|
+
import { ExpandedMarkdownEditor } from './MarkdownBlock.jsx'
|
|
11
|
+
import { getImageUrl } from './ImageWidget.jsx'
|
|
12
|
+
import linkStyles from './LinkPreview.module.css'
|
|
13
|
+
import imageStyles from './ImageWidget.module.css'
|
|
14
|
+
|
|
15
|
+
// Re-export for convenience
|
|
16
|
+
export { isSplitScreenCapable }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Stateful wrapper for markdown in secondary split-screen panes.
|
|
20
|
+
* Manages editing state and syncs it to the shared editingRef so
|
|
21
|
+
* the title bar features' getState stays in sync.
|
|
22
|
+
*/
|
|
23
|
+
function MarkdownSecondaryPane({ widget, editingRef, onUpdate }) {
|
|
24
|
+
const [editing, setEditing] = useState(false)
|
|
25
|
+
editingRef.current = editing
|
|
26
|
+
editingRef.setter = (v) => {
|
|
27
|
+
setEditing(v)
|
|
28
|
+
// Notify ExpandedPane to re-render so titlebar features resolve updated toggle state
|
|
29
|
+
document.dispatchEvent(new CustomEvent('storyboard:expanded-pane:refresh'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = widget.props?.content || ''
|
|
33
|
+
|
|
34
|
+
return createElement(ExpandedMarkdownEditor, {
|
|
35
|
+
content,
|
|
36
|
+
onUpdate,
|
|
37
|
+
editing,
|
|
38
|
+
onToggleEdit: () => editingRef.setter(!editing),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a pane config for a connected widget to use with ExpandedPane.
|
|
44
|
+
* Returns a ReactPane or ExternalPane config depending on the widget type.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ id: string, type: string, props: Object }} widget
|
|
47
|
+
* @param {'fullbar' | 'splitbar'} [surface='splitbar'] — surface for titlebar features
|
|
48
|
+
* @returns {import('./ExpandedPane.jsx').PaneConfig | null}
|
|
49
|
+
*/
|
|
50
|
+
export function buildPaneForWidget(widget, surface = 'splitbar') {
|
|
51
|
+
if (!widget) return null
|
|
52
|
+
|
|
53
|
+
const label = getSplitPaneLabel(widget)
|
|
54
|
+
|
|
55
|
+
// Terminal/agent: external pane with DOM reparenting
|
|
56
|
+
if (widget.type === 'terminal' || widget.type === 'terminal-read' || widget.type === 'agent') {
|
|
57
|
+
return {
|
|
58
|
+
id: widget.id,
|
|
59
|
+
label,
|
|
60
|
+
widgetType: widget.type,
|
|
61
|
+
kind: 'external',
|
|
62
|
+
attach: (container) => reparentTerminalInto(widget.id, container),
|
|
63
|
+
onResize: (rect) => {
|
|
64
|
+
// fitTerminalToElement is in TerminalWidget.jsx (module-level).
|
|
65
|
+
// We call it via the global registry if available.
|
|
66
|
+
if (typeof window !== 'undefined' && window.__storyboardTerminalRegistry) {
|
|
67
|
+
const entry = window.__storyboardTerminalRegistry.get(widget.id)
|
|
68
|
+
if (entry) {
|
|
69
|
+
const { term, ws } = entry
|
|
70
|
+
const cw = term.renderer?.charWidth
|
|
71
|
+
const ch = term.renderer?.charHeight
|
|
72
|
+
if (cw && ch && rect.width > 50 && rect.height > 50) {
|
|
73
|
+
const cols = Math.max(10, Math.floor(rect.width / cw))
|
|
74
|
+
const rows = Math.max(4, Math.floor(rect.height / ch))
|
|
75
|
+
term.resize?.(cols, rows)
|
|
76
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
77
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// iframe-embeddable types: build iframe URL
|
|
87
|
+
const iframeUrl = buildSecondaryIframeUrl(widget)
|
|
88
|
+
if (iframeUrl) {
|
|
89
|
+
return {
|
|
90
|
+
id: widget.id,
|
|
91
|
+
label,
|
|
92
|
+
widgetType: widget.type,
|
|
93
|
+
kind: 'react',
|
|
94
|
+
render: () => createElement('iframe', {
|
|
95
|
+
src: iframeUrl,
|
|
96
|
+
style: { border: 'none', width: '100%', height: '100%', display: 'block' },
|
|
97
|
+
title: label,
|
|
98
|
+
}),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Markdown: same editable pane as primary, updates via custom event
|
|
103
|
+
if (widget.type === 'markdown') {
|
|
104
|
+
// Shared mutable ref for editing state — lets getState and render stay in sync
|
|
105
|
+
const editingRef = { current: false, setter: null }
|
|
106
|
+
const toggleEdit = () => { editingRef.setter?.(!editingRef.current) }
|
|
107
|
+
const onUpdate = (updates) => {
|
|
108
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:update-widget', {
|
|
109
|
+
detail: { widgetId: widget.id, updates },
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
const surfaceFeatures = getFeaturesForSurface('markdown', surface)
|
|
113
|
+
return {
|
|
114
|
+
id: widget.id,
|
|
115
|
+
label,
|
|
116
|
+
widgetType: widget.type,
|
|
117
|
+
kind: 'react',
|
|
118
|
+
features: surfaceFeatures,
|
|
119
|
+
getState: (key) => {
|
|
120
|
+
if (key === 'editing') return editingRef.current
|
|
121
|
+
return undefined
|
|
122
|
+
},
|
|
123
|
+
onAction: (actionId) => {
|
|
124
|
+
if (actionId === 'toggle-edit') toggleEdit()
|
|
125
|
+
},
|
|
126
|
+
render: () => createElement(MarkdownSecondaryPane, { widget, editingRef, onUpdate }),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Link-preview
|
|
131
|
+
if (widget.type === 'link-preview') {
|
|
132
|
+
return {
|
|
133
|
+
id: widget.id,
|
|
134
|
+
label,
|
|
135
|
+
widgetType: widget.type,
|
|
136
|
+
kind: 'react',
|
|
137
|
+
render: () => createElement(LazyLinkPreviewPane, { widget }),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Image: display at full size within the pane
|
|
142
|
+
if (widget.type === 'image') {
|
|
143
|
+
return {
|
|
144
|
+
id: widget.id,
|
|
145
|
+
label,
|
|
146
|
+
widgetType: widget.type,
|
|
147
|
+
kind: 'react',
|
|
148
|
+
render: () => createElement(LazyImagePane, { widget }),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Link-preview renderer for expanded panes — matches the primary expanded
|
|
157
|
+
* rendering used when link-preview triggers its own expand/split-screen.
|
|
158
|
+
*/
|
|
159
|
+
function LazyLinkPreviewPane({ widget }) {
|
|
160
|
+
const { url, title, github, ogImage, description } = widget.props || {}
|
|
161
|
+
|
|
162
|
+
let hostname = ''
|
|
163
|
+
try { hostname = new URL(url).hostname } catch { /* */ }
|
|
164
|
+
|
|
165
|
+
if (github) {
|
|
166
|
+
const titleText = title || github.title || ''
|
|
167
|
+
const issueNumber = github.number ? `#${github.number}` : ''
|
|
168
|
+
const primaryAuthor = github.author || ''
|
|
169
|
+
const createdAgo = github.createdAgo || ''
|
|
170
|
+
const bodyHtml = github.bodyHtml || ''
|
|
171
|
+
|
|
172
|
+
return createElement('div', { className: linkStyles.expandedIssue },
|
|
173
|
+
createElement('header', { className: linkStyles.expandedIssueHeader },
|
|
174
|
+
createElement('h2', { className: linkStyles.expandedIssueTitle },
|
|
175
|
+
createElement('a', { href: url || '#', target: '_blank', rel: 'noopener noreferrer' },
|
|
176
|
+
titleText || url,
|
|
177
|
+
issueNumber && createElement('span', { className: linkStyles.expandedIssueNumber }, ` ${issueNumber}`),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
createElement('div', { className: linkStyles.expandedByline },
|
|
181
|
+
primaryAuthor && createElement('a', {
|
|
182
|
+
href: `https://github.com/${primaryAuthor}`,
|
|
183
|
+
target: '_blank',
|
|
184
|
+
rel: 'noopener noreferrer',
|
|
185
|
+
className: linkStyles.expandedAuthor,
|
|
186
|
+
},
|
|
187
|
+
createElement('img', {
|
|
188
|
+
src: `https://github.com/${primaryAuthor}.png?size=40`,
|
|
189
|
+
alt: '', width: '20', height: '20',
|
|
190
|
+
className: linkStyles.avatar, loading: 'lazy',
|
|
191
|
+
}),
|
|
192
|
+
primaryAuthor,
|
|
193
|
+
),
|
|
194
|
+
createdAgo && createElement('span', { className: linkStyles.expandedBylineText },
|
|
195
|
+
primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`,
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
bodyHtml && createElement(DangerousHtmlDiv, { html: bodyHtml, className: linkStyles.expandedIssueBody }),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return createElement('div', { className: linkStyles.expandedLink },
|
|
204
|
+
ogImage && createElement('img', { className: linkStyles.expandedOgImage, src: ogImage, alt: '', loading: 'lazy' }),
|
|
205
|
+
createElement('h2', { className: linkStyles.expandedTitle }, title || hostname || url || 'Untitled'),
|
|
206
|
+
description && createElement('p', { className: linkStyles.expandedDescription }, description),
|
|
207
|
+
url && createElement('a', {
|
|
208
|
+
href: url, target: '_blank', rel: 'noopener noreferrer',
|
|
209
|
+
className: linkStyles.expandedUrl,
|
|
210
|
+
}, url),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Renders raw HTML into a div via a callback ref (avoids ref-during-render lint). */
|
|
215
|
+
function DangerousHtmlDiv({ html, className }) {
|
|
216
|
+
const setRef = useCallback((el) => { if (el) el.innerHTML = html }, [html])
|
|
217
|
+
return createElement('div', { ref: setRef, className })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Image renderer for secondary expanded/split-screen panes. */
|
|
221
|
+
function LazyImagePane({ widget }) {
|
|
222
|
+
const src = widget.props?.src
|
|
223
|
+
if (!src) return null
|
|
224
|
+
return createElement('div', { className: imageStyles.expandedImageContainer },
|
|
225
|
+
createElement('img', {
|
|
226
|
+
src: getImageUrl(src),
|
|
227
|
+
alt: '',
|
|
228
|
+
className: imageStyles.expandedImage,
|
|
229
|
+
draggable: false,
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Find a connected widget that is split-screen capable.
|
|
236
|
+
* Returns the first match, or null.
|
|
237
|
+
* @param {string} widgetId — the primary (expanded) widget's ID
|
|
238
|
+
* @returns {{ id: string, type: string, position: { x: number, y: number }, props: Object } | null}
|
|
239
|
+
*/
|
|
240
|
+
export function findConnectedSplitTarget(widgetId) {
|
|
241
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
242
|
+
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
243
|
+
|
|
244
|
+
// Find all widgets connected to this one
|
|
245
|
+
const connectedIds = new Set()
|
|
246
|
+
for (const c of bridge.connectors) {
|
|
247
|
+
if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
|
|
248
|
+
if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
|
|
249
|
+
}
|
|
250
|
+
if (connectedIds.size === 0) return null
|
|
251
|
+
|
|
252
|
+
// Return the first connected widget that is split-screen capable
|
|
253
|
+
for (const w of bridge.widgets) {
|
|
254
|
+
if (connectedIds.has(w.id) && isSplitScreenCapable(w.type)) return w
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find ALL connected widgets that are split-screen capable.
|
|
261
|
+
* If more than maxCount, picks the nearest by Euclidean distance from primary.
|
|
262
|
+
* @param {string} widgetId — the primary (expanded) widget's ID
|
|
263
|
+
* @param {number} [maxCount=3] — max connected widgets to return
|
|
264
|
+
* @returns {Array<{ id: string, type: string, position: { x: number, y: number }, props: Object }>}
|
|
265
|
+
*/
|
|
266
|
+
export function findAllConnectedSplitTargets(widgetId, maxCount = 3) {
|
|
267
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
268
|
+
if (!bridge?.connectors || !bridge?.widgets) return []
|
|
269
|
+
|
|
270
|
+
const connectedIds = new Set()
|
|
271
|
+
for (const c of bridge.connectors) {
|
|
272
|
+
if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
|
|
273
|
+
if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
|
|
274
|
+
}
|
|
275
|
+
if (connectedIds.size === 0) return []
|
|
276
|
+
|
|
277
|
+
const candidates = bridge.widgets.filter(
|
|
278
|
+
(w) => connectedIds.has(w.id) && isSplitScreenCapable(w.type),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if (candidates.length <= maxCount) return candidates
|
|
282
|
+
|
|
283
|
+
// Too many — pick the nearest by Euclidean distance from primary
|
|
284
|
+
const primary = bridge.widgets.find((w) => w.id === widgetId)
|
|
285
|
+
const px = primary?.position?.x ?? 0
|
|
286
|
+
const py = primary?.position?.y ?? 0
|
|
287
|
+
return candidates
|
|
288
|
+
.map((w) => {
|
|
289
|
+
const dx = (w.position?.x ?? 0) - px
|
|
290
|
+
const dy = (w.position?.y ?? 0) - py
|
|
291
|
+
return { widget: w, dist: dx * dx + dy * dy }
|
|
292
|
+
})
|
|
293
|
+
.sort((a, b) => a.dist - b.dist)
|
|
294
|
+
.slice(0, maxCount)
|
|
295
|
+
.map((e) => e.widget)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Build a 2D split layout (PaneConfig[][]) from a primary widget and connected widgets.
|
|
300
|
+
* Uses quadrant-based spatial assignment: compute centroid, assign each widget to
|
|
301
|
+
* TL/TR/BL/BR, then build columns (left = TL+BL, right = TR+BR).
|
|
302
|
+
*
|
|
303
|
+
* @param {{ id: string, type: string, position?: { x: number, y: number }, props: Object }} primaryWidget
|
|
304
|
+
* @param {Array<{ id: string, type: string, position?: { x: number, y: number }, props: Object }>} connectedWidgets
|
|
305
|
+
* @param {(widget: Object) => import('./ExpandedPane.jsx').PaneConfig | null} buildPaneFn — builds a PaneConfig for a widget
|
|
306
|
+
* @returns {import('./ExpandedPane.jsx').PaneConfig[][]} — 2D layout: outer = columns, inner = rows
|
|
307
|
+
*/
|
|
308
|
+
export function buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn) {
|
|
309
|
+
const allWidgets = [primaryWidget, ...connectedWidgets]
|
|
310
|
+
|
|
311
|
+
// Build panes, filter nulls, keep widget reference for positioning
|
|
312
|
+
const entries = allWidgets
|
|
313
|
+
.map((w) => ({ widget: w, pane: buildPaneFn(w) }))
|
|
314
|
+
.filter((e) => e.pane !== null)
|
|
315
|
+
|
|
316
|
+
if (entries.length === 0) return []
|
|
317
|
+
if (entries.length === 1) return [[entries[0].pane]]
|
|
318
|
+
|
|
319
|
+
// Assign to quadrants
|
|
320
|
+
const assigned = assignToQuadrants(entries.map((e) => ({
|
|
321
|
+
x: e.widget.position?.x ?? 0,
|
|
322
|
+
y: e.widget.position?.y ?? 0,
|
|
323
|
+
data: e.pane,
|
|
324
|
+
})))
|
|
325
|
+
|
|
326
|
+
// Build columns: left = TL + BL (top first), right = TR + BR (top first)
|
|
327
|
+
const leftCol = [assigned.tl, assigned.bl].filter(Boolean)
|
|
328
|
+
const rightCol = [assigned.tr, assigned.br].filter(Boolean)
|
|
329
|
+
|
|
330
|
+
const layout = []
|
|
331
|
+
if (leftCol.length > 0) layout.push(leftCol)
|
|
332
|
+
if (rightCol.length > 0) layout.push(rightCol)
|
|
333
|
+
return layout
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Assign items to a 2×2 quadrant grid using centroid splitting.
|
|
338
|
+
* Falls back to TL→TR→BL→BR cycling when positions are degenerate (all same x or y).
|
|
339
|
+
*
|
|
340
|
+
* @template T
|
|
341
|
+
* @param {Array<{ x: number, y: number, data: T }>} items — 2-4 positioned items
|
|
342
|
+
* @returns {{ tl: T|null, tr: T|null, bl: T|null, br: T|null }}
|
|
343
|
+
*/
|
|
344
|
+
export function assignToQuadrants(items) {
|
|
345
|
+
const result = { tl: null, tr: null, bl: null, br: null }
|
|
346
|
+
if (items.length === 0) return result
|
|
347
|
+
|
|
348
|
+
// Centroid
|
|
349
|
+
const cx = items.reduce((s, i) => s + i.x, 0) / items.length
|
|
350
|
+
const cy = items.reduce((s, i) => s + i.y, 0) / items.length
|
|
351
|
+
|
|
352
|
+
// Check if all x or all y are identical (degenerate)
|
|
353
|
+
const allSameX = items.every((i) => i.x === items[0].x)
|
|
354
|
+
const allSameY = items.every((i) => i.y === items[0].y)
|
|
355
|
+
|
|
356
|
+
if (allSameX && allSameY) {
|
|
357
|
+
// All positions identical — cycle TL→TR→BL→BR
|
|
358
|
+
const slots = ['tl', 'tr', 'bl', 'br']
|
|
359
|
+
for (let i = 0; i < Math.min(items.length, 4); i++) {
|
|
360
|
+
result[slots[i]] = items[i].data
|
|
361
|
+
}
|
|
362
|
+
return result
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Assign to quadrants based on centroid
|
|
366
|
+
// Use buckets to handle collisions (multiple items in same quadrant)
|
|
367
|
+
const buckets = { tl: [], tr: [], bl: [], br: [] }
|
|
368
|
+
for (const item of items) {
|
|
369
|
+
const col = item.x < cx ? 'l' : 'r'
|
|
370
|
+
const row = item.y < cy ? 't' : 'b'
|
|
371
|
+
buckets[`${row}${col}`].push(item)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// If centroid splits are degenerate (e.g. 2 items with same x = centroid),
|
|
375
|
+
// we may have empty quadrants and overflow. Redistribute overflow.
|
|
376
|
+
const filled = []
|
|
377
|
+
const overflow = []
|
|
378
|
+
for (const [slot, bucket] of Object.entries(buckets)) {
|
|
379
|
+
if (bucket.length > 0) {
|
|
380
|
+
// Sort by position for deterministic order: top-left first
|
|
381
|
+
bucket.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
382
|
+
result[slot] = bucket[0].data
|
|
383
|
+
filled.push(slot)
|
|
384
|
+
for (let i = 1; i < bucket.length; i++) overflow.push(bucket[i])
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Place overflow into empty quadrant slots
|
|
389
|
+
if (overflow.length > 0) {
|
|
390
|
+
const allSlots = ['tl', 'tr', 'bl', 'br']
|
|
391
|
+
const emptySlots = allSlots.filter((s) => result[s] === null)
|
|
392
|
+
for (let i = 0; i < Math.min(overflow.length, emptySlots.length); i++) {
|
|
393
|
+
result[emptySlots[i]] = overflow[i].data
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Get the x-coordinate position of a widget from bridge state.
|
|
402
|
+
* @param {string} widgetId
|
|
403
|
+
* @returns {number}
|
|
404
|
+
*/
|
|
405
|
+
export function getWidgetX(widgetId) {
|
|
406
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
407
|
+
if (!bridge?.widgets) return 0
|
|
408
|
+
const w = bridge.widgets.find((w) => w.id === widgetId)
|
|
409
|
+
return w?.position?.x ?? 0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Determine pane order (left/right) based on x-coordinates.
|
|
414
|
+
* Returns { left, right } where each is 'primary' or 'secondary'.
|
|
415
|
+
* @param {string} primaryId — the widget being expanded
|
|
416
|
+
* @param {{ id: string, position?: { x: number } }} secondaryWidget
|
|
417
|
+
* @returns {{ primaryIsLeft: boolean }}
|
|
418
|
+
*/
|
|
419
|
+
export function getPaneOrder(primaryId, secondaryWidget) {
|
|
420
|
+
const primaryX = getWidgetX(primaryId)
|
|
421
|
+
const secondaryX = secondaryWidget?.position?.x ?? 0
|
|
422
|
+
return { primaryIsLeft: primaryX <= secondaryX }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Build an iframe URL for a widget to render in a secondary pane.
|
|
427
|
+
* Returns null if the widget type isn't iframe-embeddable.
|
|
428
|
+
* @param {{ type: string, props: Object }} widget
|
|
429
|
+
* @returns {string | null}
|
|
430
|
+
*/
|
|
431
|
+
export function buildSecondaryIframeUrl(widget) {
|
|
432
|
+
if (!widget) return null
|
|
433
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
434
|
+
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
435
|
+
|
|
436
|
+
if (widget.type === 'prototype') {
|
|
437
|
+
const src = widget.props?.src
|
|
438
|
+
if (!src) return null
|
|
439
|
+
if (/^https?:\/\//.test(src)) return src
|
|
440
|
+
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (widget.type === 'figma-embed') {
|
|
444
|
+
const url = widget.props?.url
|
|
445
|
+
if (!url) return null
|
|
446
|
+
// Inline a minimal figma embed URL builder to avoid circular deps
|
|
447
|
+
try {
|
|
448
|
+
const u = new URL(url)
|
|
449
|
+
if (!u.hostname.endsWith('figma.com')) return null
|
|
450
|
+
return `https://www.figma.com/embed?embed_host=storyboard&url=${encodeURIComponent(url)}`
|
|
451
|
+
} catch { return null }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (widget.type === 'codepen-embed') {
|
|
455
|
+
const url = widget.props?.url
|
|
456
|
+
if (!url) return null
|
|
457
|
+
try {
|
|
458
|
+
const u = new URL(url)
|
|
459
|
+
if (!u.hostname.endsWith('codepen.io')) return null
|
|
460
|
+
const path = u.pathname.replace(/\/(pen|full|details)\//, '/embed/')
|
|
461
|
+
return `https://codepen.io${path}?default-tab=result`
|
|
462
|
+
} catch { return null }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (widget.type === 'story') {
|
|
466
|
+
const storyId = widget.props?.storyId
|
|
467
|
+
const exportName = widget.props?.exportName
|
|
468
|
+
if (!storyId) return null
|
|
469
|
+
const storyData = getStoryData(storyId)
|
|
470
|
+
if (storyData?._route) {
|
|
471
|
+
const params = new URLSearchParams()
|
|
472
|
+
if (exportName) params.set('export', exportName)
|
|
473
|
+
params.set('_sb_embed', '')
|
|
474
|
+
params.set('_sb_hide_branch_bar', '')
|
|
475
|
+
return `${baseClean}${storyData._route}?${params}`
|
|
476
|
+
}
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Reparent a terminal widget's xterm container into a target element.
|
|
485
|
+
* Returns a cleanup function to restore the original position.
|
|
486
|
+
* @param {string} widgetId
|
|
487
|
+
* @param {HTMLElement} targetEl
|
|
488
|
+
* @returns {(() => void) | null}
|
|
489
|
+
*/
|
|
490
|
+
export function reparentTerminalInto(widgetId, targetEl) {
|
|
491
|
+
const widgetEl = document.querySelector(`[data-widget-id="${widgetId}"]`)
|
|
492
|
+
if (!widgetEl) return null
|
|
493
|
+
|
|
494
|
+
const xtermEl = widgetEl.querySelector('[class*="xtermContainer"]')
|
|
495
|
+
if (!xtermEl) return null
|
|
496
|
+
|
|
497
|
+
const originalParent = xtermEl.parentElement
|
|
498
|
+
const originalNextSibling = xtermEl.nextSibling
|
|
499
|
+
|
|
500
|
+
targetEl.appendChild(xtermEl)
|
|
501
|
+
|
|
502
|
+
return () => {
|
|
503
|
+
if (originalNextSibling) {
|
|
504
|
+
originalParent.insertBefore(xtermEl, originalNextSibling)
|
|
505
|
+
} else {
|
|
506
|
+
originalParent.appendChild(xtermEl)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Build a "Type · Metadata" label for a widget in split-screen top bar.
|
|
513
|
+
* @param {{ type: string, props: Object }} widget
|
|
514
|
+
* @returns {string}
|
|
515
|
+
*/
|
|
516
|
+
export function getSplitPaneLabel(widget) {
|
|
517
|
+
if (!widget) return ''
|
|
518
|
+
const meta = getWidgetMeta(widget.type)
|
|
519
|
+
const typeName = meta?.label || widget.type
|
|
520
|
+
|
|
521
|
+
if (widget.type === 'terminal' || widget.type === 'terminal-read') {
|
|
522
|
+
return `Terminal · ${widget.props?.prettyName || '…'}`
|
|
523
|
+
}
|
|
524
|
+
if (widget.type === 'agent') {
|
|
525
|
+
return `Agent · ${widget.props?.prettyName || '…'}`
|
|
526
|
+
}
|
|
527
|
+
if (widget.type === 'prototype') {
|
|
528
|
+
return `Prototype · ${widget.props?.src || '…'}`
|
|
529
|
+
}
|
|
530
|
+
if (widget.type === 'figma-embed') {
|
|
531
|
+
const url = widget.props?.url || ''
|
|
532
|
+
let name = 'Figma'
|
|
533
|
+
try { name = new URL(url).pathname.split('/').pop() || 'Figma' } catch { /* */ }
|
|
534
|
+
return `Figma · ${name}`
|
|
535
|
+
}
|
|
536
|
+
if (widget.type === 'codepen-embed') {
|
|
537
|
+
return `CodePen · ${widget.props?.url || '…'}`
|
|
538
|
+
}
|
|
539
|
+
if (widget.type === 'story') {
|
|
540
|
+
return `Story · ${widget.props?.storyId || '…'}`
|
|
541
|
+
}
|
|
542
|
+
if (widget.type === 'markdown') {
|
|
543
|
+
const content = widget.props?.content || ''
|
|
544
|
+
const firstLine = content.split('\n').find((l) => l.trim()) || ''
|
|
545
|
+
const preview = firstLine.replace(/^#+\s*/, '').slice(0, 40)
|
|
546
|
+
return `Markdown · ${preview || '…'}`
|
|
547
|
+
}
|
|
548
|
+
if (widget.type === 'link-preview') {
|
|
549
|
+
return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
|
|
550
|
+
}
|
|
551
|
+
if (widget.type === 'image') {
|
|
552
|
+
const filename = widget.props?.src || ''
|
|
553
|
+
const name = filename.replace(/^~/, '').replace(/\.[^.]+$/, '') || '…'
|
|
554
|
+
return `Image · ${name}`
|
|
555
|
+
}
|
|
556
|
+
return typeName
|
|
557
|
+
}
|