@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -3
- package/src/BranchBar/BranchBar.jsx +3 -1
- package/src/BranchBar/BranchBar.module.css +2 -2
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +250 -61
- package/src/CommandPalette/command-palette.css +12 -0
- package/src/Icon.jsx +46 -11
- package/src/Viewfinder.jsx +53 -133
- package/src/Viewfinder.module.css +20 -91
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.jsx +601 -62
- package/src/canvas/CanvasPage.module.css +15 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
- package/src/canvas/ConnectorLayer.jsx +120 -152
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- 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 +49 -102
- package/src/canvas/widgets/ImageWidget.jsx +129 -8
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +93 -44
- package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
- package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +65 -11
- package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
- package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
- package/src/canvas/widgets/TerminalWidget.jsx +301 -124
- package/src/canvas/widgets/TerminalWidget.module.css +121 -12
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +67 -152
- package/src/canvas/widgets/WidgetChrome.module.css +20 -1
- package/src/canvas/widgets/expandUtils.js +385 -16
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +6 -2
- 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 +37 -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/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +4 -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 +79 -35
- package/src/canvas/widgets/ActionWidget.jsx +0 -200
- package/src/canvas/widgets/ActionWidget.module.css +0 -122
- package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
- package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
- package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
- package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
|
@@ -4,7 +4,232 @@
|
|
|
4
4
|
* Reads the canvas bridge state to find connected widgets eligible
|
|
5
5
|
* for split-screen, and builds iframe URLs for secondary panes.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
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
|
+
}
|
|
8
233
|
|
|
9
234
|
/**
|
|
10
235
|
* Find a connected widget that is split-screen capable.
|
|
@@ -16,24 +241,160 @@ export function findConnectedSplitTarget(widgetId) {
|
|
|
16
241
|
const bridge = window.__storyboardCanvasBridgeState
|
|
17
242
|
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
18
243
|
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 []
|
|
24
269
|
|
|
25
|
-
const
|
|
26
|
-
const
|
|
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 []
|
|
27
276
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(c) => c.start?.widgetId === otherId || c.end?.widgetId === otherId,
|
|
277
|
+
const candidates = bridge.widgets.filter(
|
|
278
|
+
(w) => connectedIds.has(w.id) && isSplitScreenCapable(w.type),
|
|
31
279
|
)
|
|
32
|
-
if (otherConnections.length !== 1) return null
|
|
33
280
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
398
|
}
|
|
38
399
|
|
|
39
400
|
/**
|
|
@@ -105,7 +466,7 @@ export function buildSecondaryIframeUrl(widget) {
|
|
|
105
466
|
const storyId = widget.props?.storyId
|
|
106
467
|
const exportName = widget.props?.exportName
|
|
107
468
|
if (!storyId) return null
|
|
108
|
-
const storyData =
|
|
469
|
+
const storyData = getStoryData(storyId)
|
|
109
470
|
if (storyData?._route) {
|
|
110
471
|
const params = new URLSearchParams()
|
|
111
472
|
if (exportName) params.set('export', exportName)
|
|
@@ -160,6 +521,9 @@ export function getSplitPaneLabel(widget) {
|
|
|
160
521
|
if (widget.type === 'terminal' || widget.type === 'terminal-read') {
|
|
161
522
|
return `Terminal · ${widget.props?.prettyName || '…'}`
|
|
162
523
|
}
|
|
524
|
+
if (widget.type === 'agent') {
|
|
525
|
+
return `Agent · ${widget.props?.prettyName || '…'}`
|
|
526
|
+
}
|
|
163
527
|
if (widget.type === 'prototype') {
|
|
164
528
|
return `Prototype · ${widget.props?.src || '…'}`
|
|
165
529
|
}
|
|
@@ -184,5 +548,10 @@ export function getSplitPaneLabel(widget) {
|
|
|
184
548
|
if (widget.type === 'link-preview') {
|
|
185
549
|
return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
|
|
186
550
|
}
|
|
551
|
+
if (widget.type === 'image') {
|
|
552
|
+
const filename = widget.props?.src || ''
|
|
553
|
+
const name = filename.replace(/^~/, '').replace(/\.[^.]+$/, '') || '…'
|
|
554
|
+
return `Image · ${name}`
|
|
555
|
+
}
|
|
187
556
|
return typeName
|
|
188
557
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { assignToQuadrants, buildSplitLayout } from './expandUtils.js'
|
|
3
|
+
|
|
4
|
+
describe('assignToQuadrants', () => {
|
|
5
|
+
it('assigns 2 items to left/right columns', () => {
|
|
6
|
+
const result = assignToQuadrants([
|
|
7
|
+
{ x: 100, y: 200, data: 'A' },
|
|
8
|
+
{ x: 500, y: 200, data: 'B' },
|
|
9
|
+
])
|
|
10
|
+
// centroid.y = 200 = both y values, so both get 'b' row (>= centroid)
|
|
11
|
+
expect(result.bl).toBe('A')
|
|
12
|
+
expect(result.br).toBe('B')
|
|
13
|
+
expect(result.tl).toBeNull()
|
|
14
|
+
expect(result.tr).toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('assigns 2 items stacked vertically to top/bottom in same column', () => {
|
|
18
|
+
const result = assignToQuadrants([
|
|
19
|
+
{ x: 100, y: 100, data: 'A' },
|
|
20
|
+
{ x: 100, y: 500, data: 'B' },
|
|
21
|
+
])
|
|
22
|
+
// Both same x → degenerate x, but different y
|
|
23
|
+
// centroid = (100, 300). A.y=100 < 300 → top, B.y=500 >= 300 → bottom
|
|
24
|
+
// A.x=100 is NOT < centroid.x=100, so both go to 'r'
|
|
25
|
+
// → tr='A', br='B'
|
|
26
|
+
expect(result.tr).toBe('A')
|
|
27
|
+
expect(result.br).toBe('B')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('assigns 4 items to all quadrants', () => {
|
|
31
|
+
const result = assignToQuadrants([
|
|
32
|
+
{ x: 100, y: 100, data: 'TL' },
|
|
33
|
+
{ x: 500, y: 100, data: 'TR' },
|
|
34
|
+
{ x: 100, y: 500, data: 'BL' },
|
|
35
|
+
{ x: 500, y: 500, data: 'BR' },
|
|
36
|
+
])
|
|
37
|
+
expect(result.tl).toBe('TL')
|
|
38
|
+
expect(result.tr).toBe('TR')
|
|
39
|
+
expect(result.bl).toBe('BL')
|
|
40
|
+
expect(result.br).toBe('BR')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('assigns 3 items: 2 in left column, 1 in right', () => {
|
|
44
|
+
const result = assignToQuadrants([
|
|
45
|
+
{ x: 100, y: 100, data: 'TL' },
|
|
46
|
+
{ x: 100, y: 500, data: 'BL' },
|
|
47
|
+
{ x: 500, y: 300, data: 'R' },
|
|
48
|
+
])
|
|
49
|
+
// centroid x = (100+100+500)/3 ≈ 233, y = (100+500+300)/3 = 300
|
|
50
|
+
// TL: x=100 < 233 → l, y=100 < 300 → t → tl
|
|
51
|
+
// BL: x=100 < 233 → l, y=500 >= 300 → b → bl
|
|
52
|
+
// R: x=500 >= 233 → r, y=300 >= 300 → b → br
|
|
53
|
+
expect(result.tl).toBe('TL')
|
|
54
|
+
expect(result.bl).toBe('BL')
|
|
55
|
+
expect(result.br).toBe('R')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('cycles TL→TR→BL→BR when all positions are identical', () => {
|
|
59
|
+
const result = assignToQuadrants([
|
|
60
|
+
{ x: 0, y: 0, data: 'A' },
|
|
61
|
+
{ x: 0, y: 0, data: 'B' },
|
|
62
|
+
{ x: 0, y: 0, data: 'C' },
|
|
63
|
+
])
|
|
64
|
+
expect(result.tl).toBe('A')
|
|
65
|
+
expect(result.tr).toBe('B')
|
|
66
|
+
expect(result.bl).toBe('C')
|
|
67
|
+
expect(result.br).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('handles overflow: 2 items in same quadrant', () => {
|
|
71
|
+
// Two items very close, both land in same quadrant → overflow redistributes
|
|
72
|
+
const result = assignToQuadrants([
|
|
73
|
+
{ x: 100, y: 100, data: 'A' },
|
|
74
|
+
{ x: 110, y: 110, data: 'B' },
|
|
75
|
+
{ x: 500, y: 500, data: 'C' },
|
|
76
|
+
])
|
|
77
|
+
// centroid = (236, 236). A: x<236 y<236 → tl. B: x<236 y<236 → tl (overflow!)
|
|
78
|
+
// C: x>=236 y>=236 → br
|
|
79
|
+
// A wins tl, B overflows to first empty slot (tr)
|
|
80
|
+
expect(result.tl).toBe('A')
|
|
81
|
+
expect(result.br).toBe('C')
|
|
82
|
+
// B goes to overflow → first empty slot (tr)
|
|
83
|
+
expect(result.tr).toBe('B')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns all nulls for empty input', () => {
|
|
87
|
+
const result = assignToQuadrants([])
|
|
88
|
+
expect(result.tl).toBeNull()
|
|
89
|
+
expect(result.tr).toBeNull()
|
|
90
|
+
expect(result.bl).toBeNull()
|
|
91
|
+
expect(result.br).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('buildSplitLayout', () => {
|
|
96
|
+
function mockPaneFn(widget) {
|
|
97
|
+
return { id: widget.id, label: widget.id, kind: 'react', render: () => null }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('returns single column for 1 widget (no connected)', () => {
|
|
101
|
+
const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
|
|
102
|
+
const layout = buildSplitLayout(primary, [], mockPaneFn)
|
|
103
|
+
expect(layout.length).toBe(1)
|
|
104
|
+
expect(layout[0].length).toBe(1)
|
|
105
|
+
expect(layout[0][0].id).toBe('a')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns 2 columns for 2 horizontally-spaced widgets', () => {
|
|
109
|
+
const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 100 }, props: {} }
|
|
110
|
+
const connected = [
|
|
111
|
+
{ id: 'b', type: 'prototype', position: { x: 500, y: 100 }, props: {} },
|
|
112
|
+
]
|
|
113
|
+
const layout = buildSplitLayout(primary, connected, mockPaneFn)
|
|
114
|
+
expect(layout.length).toBe(2)
|
|
115
|
+
expect(layout[0].length).toBe(1) // left column
|
|
116
|
+
expect(layout[1].length).toBe(1) // right column
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns 2 columns with row split for 3 widgets', () => {
|
|
120
|
+
const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
|
|
121
|
+
const connected = [
|
|
122
|
+
{ id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
|
|
123
|
+
{ id: 'c', type: 'markdown', position: { x: 500, y: 500 }, props: {} },
|
|
124
|
+
]
|
|
125
|
+
const layout = buildSplitLayout(primary, connected, mockPaneFn)
|
|
126
|
+
const totalPanes = layout.flat().length
|
|
127
|
+
expect(totalPanes).toBe(3)
|
|
128
|
+
// One column should have 2 panes (row split)
|
|
129
|
+
const hasRowSplit = layout.some((col) => col.length === 2)
|
|
130
|
+
expect(hasRowSplit).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns 2×2 grid for 4 widgets', () => {
|
|
134
|
+
const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
|
|
135
|
+
const connected = [
|
|
136
|
+
{ id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
|
|
137
|
+
{ id: 'c', type: 'markdown', position: { x: 0, y: 500 }, props: {} },
|
|
138
|
+
{ id: 'd', type: 'agent', position: { x: 500, y: 500 }, props: {} },
|
|
139
|
+
]
|
|
140
|
+
const layout = buildSplitLayout(primary, connected, mockPaneFn)
|
|
141
|
+
expect(layout.length).toBe(2)
|
|
142
|
+
expect(layout[0].length).toBe(2)
|
|
143
|
+
expect(layout[1].length).toBe(2)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('skips widgets where buildPaneFn returns null', () => {
|
|
147
|
+
const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
|
|
148
|
+
const connected = [
|
|
149
|
+
{ id: 'b', type: 'unknown', position: { x: 500, y: 0 }, props: {} },
|
|
150
|
+
]
|
|
151
|
+
const paneFn = (w) => w.id === 'a' ? mockPaneFn(w) : null
|
|
152
|
+
const layout = buildSplitLayout(primary, connected, paneFn)
|
|
153
|
+
expect(layout.flat().length).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -6,9 +6,11 @@ import ImageWidget from './ImageWidget.jsx'
|
|
|
6
6
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
7
7
|
import CodePenEmbed from './CodePenEmbed.jsx'
|
|
8
8
|
import StoryWidget from './StoryWidget.jsx'
|
|
9
|
+
import ComponentSetWidget from './ComponentSetWidget.jsx'
|
|
9
10
|
import TerminalWidget from './TerminalWidget.jsx'
|
|
10
11
|
import TerminalReadWidget from './TerminalReadWidget.jsx'
|
|
11
|
-
import
|
|
12
|
+
import PromptWidget from './PromptWidget.jsx'
|
|
13
|
+
import TilesWidget from './TilesWidget.jsx'
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Maps widget type strings to their React components.
|
|
@@ -23,10 +25,12 @@ export const widgetRegistry = {
|
|
|
23
25
|
'figma-embed': FigmaEmbed,
|
|
24
26
|
'codepen-embed': CodePenEmbed,
|
|
25
27
|
'story': StoryWidget,
|
|
28
|
+
'component-set': ComponentSetWidget,
|
|
26
29
|
'terminal': TerminalWidget,
|
|
27
30
|
'terminal-read': TerminalReadWidget,
|
|
28
|
-
'action': ActionWidget,
|
|
29
31
|
'agent': TerminalWidget,
|
|
32
|
+
'prompt': PromptWidget,
|
|
33
|
+
'tiles': TilesWidget,
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static tile image pool.
|
|
3
|
+
* Each import resolves to a Vite asset URL at build time.
|
|
4
|
+
*/
|
|
5
|
+
import solidA from './tiles/solid-a.png'
|
|
6
|
+
import solidB from './tiles/solid-b.png'
|
|
7
|
+
import quarterTL from './tiles/quarter-tl.png'
|
|
8
|
+
import quarterTR from './tiles/quarter-tr.png'
|
|
9
|
+
import diagonalBR from './tiles/diagonal-br.png'
|
|
10
|
+
import diagonalBL from './tiles/diagonal-bl.png'
|
|
11
|
+
import diagonalTL from './tiles/diagonal-tl.png'
|
|
12
|
+
import leaf from './tiles/leaf.png'
|
|
13
|
+
|
|
14
|
+
export const TILE_POOL = [
|
|
15
|
+
solidA,
|
|
16
|
+
solidB,
|
|
17
|
+
quarterTL,
|
|
18
|
+
quarterTR,
|
|
19
|
+
diagonalBR,
|
|
20
|
+
diagonalBL,
|
|
21
|
+
diagonalTL,
|
|
22
|
+
leaf,
|
|
23
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|