@dfosco/storyboard-react 4.2.0-beta.0 → 4.2.0-beta.17
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 +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +17 -5
- package/src/BranchBar/BranchBar.module.css +11 -2
- package/src/CommandPalette/CommandPalette.jsx +267 -164
- package/src/CommandPalette/command-palette.css +130 -78
- package/src/Icon.jsx +112 -48
- package/src/Viewfinder.jsx +511 -61
- package/src/Viewfinder.module.css +414 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +157 -174
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/ConnectorLayer.jsx +5 -5
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/widgets/ActionWidget.jsx +200 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/LinkPreview.jsx +64 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
- package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
- package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
- package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
- package/src/canvas/widgets/StoryWidget.jsx +7 -4
- package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
- package/src/canvas/widgets/TerminalWidget.jsx +299 -49
- package/src/canvas/widgets/TerminalWidget.module.css +155 -1
- package/src/canvas/widgets/WidgetChrome.jsx +19 -14
- package/src/canvas/widgets/WidgetChrome.module.css +10 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +188 -0
- package/src/canvas/widgets/index.js +5 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/widgetConfig.js +19 -1
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +4 -0
- package/src/vite/data-plugin.js +264 -14
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
|
-
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold, ScreenFullIcon as OcticonScreenFull } from '@primer/octicons-react'
|
|
4
4
|
import { getConnectorConfig, getInteractGate } from './widgetConfig.js'
|
|
5
5
|
import styles from './WidgetChrome.module.css'
|
|
6
6
|
import overlayStyles from './embedOverlay.module.css'
|
|
@@ -117,11 +117,7 @@ function DownloadIcon() {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function ExpandIcon() {
|
|
120
|
-
return
|
|
121
|
-
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
122
|
-
<path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1Zm10.5 0C14.216 1 15 1.784 15 2.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1 0-1.5Z" />
|
|
123
|
-
</svg>
|
|
124
|
-
)
|
|
120
|
+
return <OcticonScreenFull size={12} />
|
|
125
121
|
}
|
|
126
122
|
|
|
127
123
|
function SyncIcon() {
|
|
@@ -140,6 +136,14 @@ function FoldIcon() {
|
|
|
140
136
|
return <OcticonFold size={12} />
|
|
141
137
|
}
|
|
142
138
|
|
|
139
|
+
function ColumnsIcon() {
|
|
140
|
+
return (
|
|
141
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
142
|
+
<path d="M12 3v18" /><rect x="3" y="3" width="18" height="18" rx="2" />
|
|
143
|
+
</svg>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
143
147
|
/** Icon registry — maps icon name strings from config to React components. */
|
|
144
148
|
const ICON_REGISTRY = {
|
|
145
149
|
'trash': DeleteIcon,
|
|
@@ -161,6 +165,7 @@ const ICON_REGISTRY = {
|
|
|
161
165
|
'sync': SyncIcon,
|
|
162
166
|
'unfold': UnfoldIcon,
|
|
163
167
|
'fold': FoldIcon,
|
|
168
|
+
'columns': ColumnsIcon,
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
/** Danger-styled actions in the overflow menu. */
|
|
@@ -438,8 +443,8 @@ export default function WidgetChrome({
|
|
|
438
443
|
}
|
|
439
444
|
// Widget-specific actions go through the widget's imperative ref
|
|
440
445
|
if (widgetRef?.current?.handleAction) {
|
|
441
|
-
widgetRef.current.handleAction(actionId)
|
|
442
|
-
return
|
|
446
|
+
const handled = widgetRef.current.handleAction(actionId)
|
|
447
|
+
if (handled !== false) return
|
|
443
448
|
}
|
|
444
449
|
// Fallback to generic handler
|
|
445
450
|
onAction?.(actionId)
|
|
@@ -614,10 +619,10 @@ export default function WidgetChrome({
|
|
|
614
619
|
feature={feature}
|
|
615
620
|
onAction={(actionId) => {
|
|
616
621
|
if (widgetRef?.current?.handleAction) {
|
|
617
|
-
widgetRef.current.handleAction(actionId)
|
|
618
|
-
|
|
619
|
-
onAction?.(actionId)
|
|
622
|
+
const handled = widgetRef.current.handleAction(actionId)
|
|
623
|
+
if (handled !== false) return
|
|
620
624
|
}
|
|
625
|
+
onAction?.(actionId)
|
|
621
626
|
}}
|
|
622
627
|
/>
|
|
623
628
|
)
|
|
@@ -632,10 +637,10 @@ export default function WidgetChrome({
|
|
|
632
637
|
onAction={(actionId) => {
|
|
633
638
|
// Route overflow menu actions through the widget ref first
|
|
634
639
|
if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
|
|
635
|
-
widgetRef.current.handleAction(actionId)
|
|
636
|
-
|
|
637
|
-
onAction?.(actionId)
|
|
640
|
+
const handled = widgetRef.current.handleAction(actionId)
|
|
641
|
+
if (handled !== false) return
|
|
638
642
|
}
|
|
643
|
+
onAction?.(actionId)
|
|
639
644
|
}}
|
|
640
645
|
/>
|
|
641
646
|
)}
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
pointer-events: auto;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/* Invisible expanded hit area — 15px padding around the visible dot */
|
|
23
|
+
.anchorPort::before {
|
|
24
|
+
content: '';
|
|
25
|
+
position: absolute;
|
|
26
|
+
top: -15px;
|
|
27
|
+
left: -15px;
|
|
28
|
+
right: -15px;
|
|
29
|
+
bottom: -15px;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
.chromeContainer:hover .anchorPort {
|
|
23
33
|
opacity: 0.6;
|
|
24
34
|
}
|
|
@@ -56,72 +56,70 @@ describe('Embed interaction overlay', () => {
|
|
|
56
56
|
resizable: false,
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
it('renders "Click to
|
|
59
|
+
it('renders "Click to interact" hint when no snapshot exists', () => {
|
|
60
60
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
61
61
|
|
|
62
|
-
const hint = screen.getByText('Click to
|
|
62
|
+
const hint = screen.getByText('Click to interact')
|
|
63
63
|
expect(hint).toBeInTheDocument()
|
|
64
|
-
// CSS modules mangle class names, just check the element exists
|
|
65
64
|
})
|
|
66
65
|
|
|
67
66
|
it('enters interactive mode on single click (not double-click)', async () => {
|
|
68
67
|
const { container } = render(<PrototypeEmbed {...defaultProps} />)
|
|
69
68
|
|
|
70
|
-
// Overlay should exist before interaction
|
|
71
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
69
|
+
// Overlay should exist before interaction; iframe is always rendered
|
|
70
|
+
const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
|
|
72
71
|
expect(overlay).toBeInTheDocument()
|
|
73
|
-
expect(container.querySelector('iframe')).
|
|
74
|
-
expect(screen.getByText('Design Overview')).toBeInTheDocument()
|
|
72
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
75
73
|
|
|
76
74
|
// Single click should remove the overlay (enter interactive mode)
|
|
77
75
|
fireEvent.click(overlay)
|
|
78
76
|
|
|
79
77
|
// Overlay should no longer exist
|
|
80
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
78
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
81
79
|
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
82
80
|
|
|
83
81
|
fireEvent.pointerDown(document.body)
|
|
84
|
-
expect(screen.getByRole('button', { name: /click to
|
|
85
|
-
expect(container.querySelector('iframe')).
|
|
82
|
+
expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
|
|
83
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
86
84
|
})
|
|
87
85
|
|
|
88
86
|
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
89
87
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
90
88
|
|
|
91
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
89
|
+
const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
|
|
92
90
|
fireEvent.click(overlay, { shiftKey: true })
|
|
93
91
|
|
|
94
92
|
// Overlay should still exist (did not enter interactive mode)
|
|
95
|
-
expect(screen.getByRole('button', { name: /click to
|
|
93
|
+
expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
|
|
96
94
|
})
|
|
97
95
|
|
|
98
96
|
it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
|
|
99
97
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
100
98
|
|
|
101
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
99
|
+
const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
|
|
102
100
|
fireEvent.click(overlay, { metaKey: true })
|
|
103
101
|
|
|
104
|
-
expect(screen.getByRole('button', { name: /click to
|
|
102
|
+
expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
|
|
105
103
|
})
|
|
106
104
|
|
|
107
105
|
it('supports keyboard interaction (Enter key) with event prevention', () => {
|
|
108
106
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
109
107
|
|
|
110
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
108
|
+
const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
|
|
111
109
|
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
112
110
|
fireEvent.keyDown(overlay, event)
|
|
113
111
|
|
|
114
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
112
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
115
113
|
})
|
|
116
114
|
|
|
117
115
|
it('supports keyboard interaction (Space key) with event prevention', () => {
|
|
118
116
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
119
117
|
|
|
120
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
118
|
+
const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
|
|
121
119
|
const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
122
120
|
fireEvent.keyDown(overlay, event)
|
|
123
121
|
|
|
124
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
122
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
125
123
|
})
|
|
126
124
|
})
|
|
127
125
|
|
|
@@ -143,7 +141,7 @@ describe('Embed interaction overlay', () => {
|
|
|
143
141
|
const { container } = render(<FigmaEmbed {...defaultProps} />)
|
|
144
142
|
|
|
145
143
|
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
146
|
-
expect(container.querySelector('iframe')).
|
|
144
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
147
145
|
fireEvent.click(overlay)
|
|
148
146
|
|
|
149
147
|
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
@@ -151,7 +149,7 @@ describe('Embed interaction overlay', () => {
|
|
|
151
149
|
|
|
152
150
|
fireEvent.pointerDown(document.body)
|
|
153
151
|
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
154
|
-
expect(container.querySelector('iframe')).
|
|
152
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
155
153
|
})
|
|
156
154
|
})
|
|
157
155
|
|
|
@@ -162,20 +160,20 @@ describe('Embed interaction overlay', () => {
|
|
|
162
160
|
resizable: false,
|
|
163
161
|
}
|
|
164
162
|
|
|
165
|
-
it('mounts iframe
|
|
163
|
+
it('mounts iframe and shows overlay initially, removes overlay on click', () => {
|
|
166
164
|
const { container } = render(<StoryWidget {...defaultProps} />)
|
|
167
165
|
|
|
168
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
169
|
-
expect(container.querySelector('iframe')).
|
|
166
|
+
const overlay = screen.getByRole('button', { name: /click to interact$/i })
|
|
167
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
170
168
|
|
|
171
169
|
fireEvent.click(overlay)
|
|
172
170
|
|
|
173
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
171
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
174
172
|
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
175
173
|
|
|
176
174
|
fireEvent.pointerDown(document.body)
|
|
177
|
-
expect(screen.getByRole('button', { name: /click to
|
|
178
|
-
expect(container.querySelector('iframe')).
|
|
175
|
+
expect(screen.getByRole('button', { name: /click to interact$/i })).toBeInTheDocument()
|
|
176
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
179
177
|
})
|
|
180
178
|
})
|
|
181
179
|
|
|
@@ -0,0 +1,188 @@
|
|
|
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 { isSplitScreenCapable, getWidgetMeta } from './widgetConfig.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find a connected widget that is split-screen capable.
|
|
11
|
+
* Returns the first match, or null.
|
|
12
|
+
* @param {string} widgetId — the primary (expanded) widget's ID
|
|
13
|
+
* @returns {{ id: string, type: string, position: { x: number, y: number }, props: Object } | null}
|
|
14
|
+
*/
|
|
15
|
+
export function findConnectedSplitTarget(widgetId) {
|
|
16
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
17
|
+
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
18
|
+
|
|
19
|
+
// Only allow split-screen when this widget has exactly one connection
|
|
20
|
+
const myConnections = bridge.connectors.filter(
|
|
21
|
+
(c) => c.start?.widgetId === widgetId || c.end?.widgetId === widgetId,
|
|
22
|
+
)
|
|
23
|
+
if (myConnections.length !== 1) return null
|
|
24
|
+
|
|
25
|
+
const conn = myConnections[0]
|
|
26
|
+
const otherId = conn.start?.widgetId === widgetId ? conn.end?.widgetId : conn.start?.widgetId
|
|
27
|
+
|
|
28
|
+
// The other widget must also have exactly one connection
|
|
29
|
+
const otherConnections = bridge.connectors.filter(
|
|
30
|
+
(c) => c.start?.widgetId === otherId || c.end?.widgetId === otherId,
|
|
31
|
+
)
|
|
32
|
+
if (otherConnections.length !== 1) return null
|
|
33
|
+
|
|
34
|
+
const other = bridge.widgets.find((w) => w.id === otherId)
|
|
35
|
+
if (other && isSplitScreenCapable(other.type)) return other
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the x-coordinate position of a widget from bridge state.
|
|
41
|
+
* @param {string} widgetId
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
export function getWidgetX(widgetId) {
|
|
45
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
46
|
+
if (!bridge?.widgets) return 0
|
|
47
|
+
const w = bridge.widgets.find((w) => w.id === widgetId)
|
|
48
|
+
return w?.position?.x ?? 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Determine pane order (left/right) based on x-coordinates.
|
|
53
|
+
* Returns { left, right } where each is 'primary' or 'secondary'.
|
|
54
|
+
* @param {string} primaryId — the widget being expanded
|
|
55
|
+
* @param {{ id: string, position?: { x: number } }} secondaryWidget
|
|
56
|
+
* @returns {{ primaryIsLeft: boolean }}
|
|
57
|
+
*/
|
|
58
|
+
export function getPaneOrder(primaryId, secondaryWidget) {
|
|
59
|
+
const primaryX = getWidgetX(primaryId)
|
|
60
|
+
const secondaryX = secondaryWidget?.position?.x ?? 0
|
|
61
|
+
return { primaryIsLeft: primaryX <= secondaryX }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build an iframe URL for a widget to render in a secondary pane.
|
|
66
|
+
* Returns null if the widget type isn't iframe-embeddable.
|
|
67
|
+
* @param {{ type: string, props: Object }} widget
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function buildSecondaryIframeUrl(widget) {
|
|
71
|
+
if (!widget) return null
|
|
72
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
73
|
+
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
74
|
+
|
|
75
|
+
if (widget.type === 'prototype') {
|
|
76
|
+
const src = widget.props?.src
|
|
77
|
+
if (!src) return null
|
|
78
|
+
if (/^https?:\/\//.test(src)) return src
|
|
79
|
+
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (widget.type === 'figma-embed') {
|
|
83
|
+
const url = widget.props?.url
|
|
84
|
+
if (!url) return null
|
|
85
|
+
// Inline a minimal figma embed URL builder to avoid circular deps
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(url)
|
|
88
|
+
if (!u.hostname.endsWith('figma.com')) return null
|
|
89
|
+
return `https://www.figma.com/embed?embed_host=storyboard&url=${encodeURIComponent(url)}`
|
|
90
|
+
} catch { return null }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (widget.type === 'codepen-embed') {
|
|
94
|
+
const url = widget.props?.url
|
|
95
|
+
if (!url) return null
|
|
96
|
+
try {
|
|
97
|
+
const u = new URL(url)
|
|
98
|
+
if (!u.hostname.endsWith('codepen.io')) return null
|
|
99
|
+
const path = u.pathname.replace(/\/(pen|full|details)\//, '/embed/')
|
|
100
|
+
return `https://codepen.io${path}?default-tab=result`
|
|
101
|
+
} catch { return null }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (widget.type === 'story') {
|
|
105
|
+
const storyId = widget.props?.storyId
|
|
106
|
+
const exportName = widget.props?.exportName
|
|
107
|
+
if (!storyId) return null
|
|
108
|
+
const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
|
|
109
|
+
if (storyData?._route) {
|
|
110
|
+
const params = new URLSearchParams()
|
|
111
|
+
if (exportName) params.set('export', exportName)
|
|
112
|
+
params.set('_sb_embed', '')
|
|
113
|
+
params.set('_sb_hide_branch_bar', '')
|
|
114
|
+
return `${baseClean}${storyData._route}?${params}`
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reparent a terminal widget's xterm container into a target element.
|
|
124
|
+
* Returns a cleanup function to restore the original position.
|
|
125
|
+
* @param {string} widgetId
|
|
126
|
+
* @param {HTMLElement} targetEl
|
|
127
|
+
* @returns {(() => void) | null}
|
|
128
|
+
*/
|
|
129
|
+
export function reparentTerminalInto(widgetId, targetEl) {
|
|
130
|
+
const widgetEl = document.querySelector(`[data-widget-id="${widgetId}"]`)
|
|
131
|
+
if (!widgetEl) return null
|
|
132
|
+
|
|
133
|
+
const xtermEl = widgetEl.querySelector('[class*="xtermContainer"]')
|
|
134
|
+
if (!xtermEl) return null
|
|
135
|
+
|
|
136
|
+
const originalParent = xtermEl.parentElement
|
|
137
|
+
const originalNextSibling = xtermEl.nextSibling
|
|
138
|
+
|
|
139
|
+
targetEl.appendChild(xtermEl)
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
if (originalNextSibling) {
|
|
143
|
+
originalParent.insertBefore(xtermEl, originalNextSibling)
|
|
144
|
+
} else {
|
|
145
|
+
originalParent.appendChild(xtermEl)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build a "Type · Metadata" label for a widget in split-screen top bar.
|
|
152
|
+
* @param {{ type: string, props: Object }} widget
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
export function getSplitPaneLabel(widget) {
|
|
156
|
+
if (!widget) return ''
|
|
157
|
+
const meta = getWidgetMeta(widget.type)
|
|
158
|
+
const typeName = meta?.label || widget.type
|
|
159
|
+
|
|
160
|
+
if (widget.type === 'terminal' || widget.type === 'terminal-read') {
|
|
161
|
+
return `Terminal · ${widget.props?.prettyName || '…'}`
|
|
162
|
+
}
|
|
163
|
+
if (widget.type === 'prototype') {
|
|
164
|
+
return `Prototype · ${widget.props?.src || '…'}`
|
|
165
|
+
}
|
|
166
|
+
if (widget.type === 'figma-embed') {
|
|
167
|
+
const url = widget.props?.url || ''
|
|
168
|
+
let name = 'Figma'
|
|
169
|
+
try { name = new URL(url).pathname.split('/').pop() || 'Figma' } catch { /* */ }
|
|
170
|
+
return `Figma · ${name}`
|
|
171
|
+
}
|
|
172
|
+
if (widget.type === 'codepen-embed') {
|
|
173
|
+
return `CodePen · ${widget.props?.url || '…'}`
|
|
174
|
+
}
|
|
175
|
+
if (widget.type === 'story') {
|
|
176
|
+
return `Story · ${widget.props?.storyId || '…'}`
|
|
177
|
+
}
|
|
178
|
+
if (widget.type === 'markdown') {
|
|
179
|
+
const content = widget.props?.content || ''
|
|
180
|
+
const firstLine = content.split('\n').find((l) => l.trim()) || ''
|
|
181
|
+
const preview = firstLine.replace(/^#+\s*/, '').slice(0, 40)
|
|
182
|
+
return `Markdown · ${preview || '…'}`
|
|
183
|
+
}
|
|
184
|
+
if (widget.type === 'link-preview') {
|
|
185
|
+
return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
|
|
186
|
+
}
|
|
187
|
+
return typeName
|
|
188
|
+
}
|
|
@@ -7,6 +7,8 @@ import FigmaEmbed from './FigmaEmbed.jsx'
|
|
|
7
7
|
import CodePenEmbed from './CodePenEmbed.jsx'
|
|
8
8
|
import StoryWidget from './StoryWidget.jsx'
|
|
9
9
|
import TerminalWidget from './TerminalWidget.jsx'
|
|
10
|
+
import TerminalReadWidget from './TerminalReadWidget.jsx'
|
|
11
|
+
import ActionWidget from './ActionWidget.jsx'
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Maps widget type strings to their React components.
|
|
@@ -22,6 +24,9 @@ export const widgetRegistry = {
|
|
|
22
24
|
'codepen-embed': CodePenEmbed,
|
|
23
25
|
'story': StoryWidget,
|
|
24
26
|
'terminal': TerminalWidget,
|
|
27
|
+
'terminal-read': TerminalReadWidget,
|
|
28
|
+
'action': ActionWidget,
|
|
29
|
+
'agent': TerminalWidget,
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for iframe snapshot display — single snapshot prop.
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi,
|
|
5
|
-
import { render
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
5
|
+
import { render } from '@testing-library/react'
|
|
6
6
|
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
7
|
import StoryWidget from './StoryWidget.jsx'
|
|
8
8
|
|
|
@@ -54,9 +54,9 @@ afterEach(() => {
|
|
|
54
54
|
document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
describe('Snapshot display', () => {
|
|
57
|
+
describe('Snapshot display (snapshots removed — iframes always render)', () => {
|
|
58
58
|
describe('PrototypeEmbed', () => {
|
|
59
|
-
it('
|
|
59
|
+
it('renders iframe even when snapshot prop is provided', () => {
|
|
60
60
|
const { wrapper } = renderInCanvas(
|
|
61
61
|
<PrototypeEmbed
|
|
62
62
|
id="proto-abc123"
|
|
@@ -72,13 +72,11 @@ describe('Snapshot display', () => {
|
|
|
72
72
|
/>
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
expect(img.src).toContain('snapshot-proto-abc123.webp')
|
|
78
|
-
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
75
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
76
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
79
77
|
})
|
|
80
78
|
|
|
81
|
-
it('
|
|
79
|
+
it('renders iframe when snapshotLight prop is provided', () => {
|
|
82
80
|
const { wrapper } = renderInCanvas(
|
|
83
81
|
<PrototypeEmbed
|
|
84
82
|
id="proto-abc123"
|
|
@@ -94,12 +92,11 @@ describe('Snapshot display', () => {
|
|
|
94
92
|
/>
|
|
95
93
|
)
|
|
96
94
|
|
|
97
|
-
|
|
98
|
-
expect(
|
|
99
|
-
expect(img.src).toContain('snapshot-proto-abc123--light.webp')
|
|
95
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
96
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
100
97
|
})
|
|
101
98
|
|
|
102
|
-
it('
|
|
99
|
+
it('renders iframe when no snapshot exists', () => {
|
|
103
100
|
const { wrapper } = renderInCanvas(
|
|
104
101
|
<PrototypeEmbed
|
|
105
102
|
id="proto-xyz"
|
|
@@ -110,32 +107,10 @@ describe('Snapshot display', () => {
|
|
|
110
107
|
)
|
|
111
108
|
|
|
112
109
|
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
113
|
-
expect(wrapper.querySelector('iframe')).
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
117
|
-
const { wrapper } = renderInCanvas(
|
|
118
|
-
<PrototypeEmbed
|
|
119
|
-
id="proto-abc123"
|
|
120
|
-
props={{
|
|
121
|
-
src: '/test',
|
|
122
|
-
width: 400,
|
|
123
|
-
height: 300,
|
|
124
|
-
zoom: 100,
|
|
125
|
-
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
126
|
-
}}
|
|
127
|
-
onUpdate={vi.fn()}
|
|
128
|
-
resizable={false}
|
|
129
|
-
/>
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
const img = wrapper.querySelector('img')
|
|
133
|
-
expect(img).toBeInTheDocument()
|
|
134
|
-
fireEvent.error(img)
|
|
135
|
-
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
110
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
136
111
|
})
|
|
137
112
|
|
|
138
|
-
it('ignores snapshot that does not match widget ID', () => {
|
|
113
|
+
it('ignores snapshot prop that does not match widget ID', () => {
|
|
139
114
|
const { wrapper } = renderInCanvas(
|
|
140
115
|
<PrototypeEmbed
|
|
141
116
|
id="proto-abc123"
|
|
@@ -152,9 +127,10 @@ describe('Snapshot display', () => {
|
|
|
152
127
|
)
|
|
153
128
|
|
|
154
129
|
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
130
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
155
131
|
})
|
|
156
132
|
|
|
157
|
-
it('
|
|
133
|
+
it('renders iframe for external URLs regardless of snapshot', () => {
|
|
158
134
|
const { wrapper } = renderInCanvas(
|
|
159
135
|
<PrototypeEmbed
|
|
160
136
|
id="proto-ext"
|
|
@@ -171,11 +147,12 @@ describe('Snapshot display', () => {
|
|
|
171
147
|
)
|
|
172
148
|
|
|
173
149
|
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
150
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
174
151
|
})
|
|
175
152
|
})
|
|
176
153
|
|
|
177
154
|
describe('StoryWidget', () => {
|
|
178
|
-
it('
|
|
155
|
+
it('renders iframe even when snapshot prop is provided', () => {
|
|
179
156
|
const { wrapper } = renderInCanvas(
|
|
180
157
|
<StoryWidget
|
|
181
158
|
id="story-abc123"
|
|
@@ -191,13 +168,11 @@ describe('Snapshot display', () => {
|
|
|
191
168
|
/>
|
|
192
169
|
)
|
|
193
170
|
|
|
194
|
-
|
|
195
|
-
expect(
|
|
196
|
-
expect(img.src).toContain('snapshot-story-abc123.webp')
|
|
197
|
-
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
171
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
172
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
198
173
|
})
|
|
199
174
|
|
|
200
|
-
it('
|
|
175
|
+
it('renders iframe when snapshotDark prop is provided', () => {
|
|
201
176
|
const { wrapper } = renderInCanvas(
|
|
202
177
|
<StoryWidget
|
|
203
178
|
id="story-abc123"
|
|
@@ -210,50 +185,27 @@ describe('Snapshot display', () => {
|
|
|
210
185
|
/>
|
|
211
186
|
)
|
|
212
187
|
|
|
213
|
-
const img = wrapper.querySelector('img')
|
|
214
|
-
expect(img).toBeInTheDocument()
|
|
215
|
-
expect(img.src).toContain('snapshot-story-abc123--dark.webp')
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
it('shows placeholder when no snapshot exists', () => {
|
|
219
|
-
const { wrapper } = renderInCanvas(
|
|
220
|
-
<StoryWidget
|
|
221
|
-
id="story-xyz"
|
|
222
|
-
props={{
|
|
223
|
-
storyId: 'button-patterns',
|
|
224
|
-
exportName: 'Primary',
|
|
225
|
-
width: 400,
|
|
226
|
-
height: 300,
|
|
227
|
-
}}
|
|
228
|
-
onUpdate={vi.fn()}
|
|
229
|
-
resizable={false}
|
|
230
|
-
/>
|
|
231
|
-
)
|
|
232
|
-
|
|
233
188
|
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
234
|
-
expect(wrapper.querySelector('iframe')).
|
|
189
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
235
190
|
})
|
|
236
191
|
|
|
237
|
-
it('
|
|
192
|
+
it('renders iframe when no snapshot exists', () => {
|
|
238
193
|
const { wrapper } = renderInCanvas(
|
|
239
194
|
<StoryWidget
|
|
240
|
-
id="story-
|
|
195
|
+
id="story-xyz"
|
|
241
196
|
props={{
|
|
242
197
|
storyId: 'button-patterns',
|
|
243
198
|
exportName: 'Primary',
|
|
244
199
|
width: 400,
|
|
245
200
|
height: 300,
|
|
246
|
-
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
247
201
|
}}
|
|
248
202
|
onUpdate={vi.fn()}
|
|
249
203
|
resizable={false}
|
|
250
204
|
/>
|
|
251
205
|
)
|
|
252
206
|
|
|
253
|
-
const img = wrapper.querySelector('img')
|
|
254
|
-
expect(img).toBeInTheDocument()
|
|
255
|
-
fireEvent.error(img)
|
|
256
207
|
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
208
|
+
expect(wrapper.querySelector('iframe')).toBeInTheDocument()
|
|
257
209
|
})
|
|
258
210
|
})
|
|
259
211
|
})
|
|
@@ -151,6 +151,24 @@ export function getWidgetMeta(type) {
|
|
|
151
151
|
return { label: def.label, icon: def.icon }
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Check if a widget type supports expanding to a full-screen modal.
|
|
156
|
+
* @param {string} type — widget type string
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
export function isExpandable(type) {
|
|
160
|
+
return widgetTypes[type]?.expandable === true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a widget type can appear in a split-screen pane.
|
|
165
|
+
* @param {string} type — widget type string
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function isSplitScreenCapable(type) {
|
|
169
|
+
return widgetTypes[type]?.splitScreen === true
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
/**
|
|
155
173
|
* Get the interact gate config for a widget type.
|
|
156
174
|
* @returns {{ enabled: boolean, label: string }}
|
|
@@ -170,7 +188,7 @@ export function getInteractGate(type) {
|
|
|
170
188
|
*/
|
|
171
189
|
export function getMenuWidgetTypes() {
|
|
172
190
|
return Object.entries(widgetTypes)
|
|
173
|
-
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
|
|
191
|
+
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read')
|
|
174
192
|
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
175
193
|
}
|
|
176
194
|
|