@dfosco/storyboard-react 4.2.0-beta.1 → 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
|
@@ -112,35 +112,35 @@ function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
|
|
|
112
112
|
* @param {Object} endPt — position of the connector's end endpoint
|
|
113
113
|
*/
|
|
114
114
|
function EndpointShape({ x, y, startPt, endPt, style, onPointerDown }) {
|
|
115
|
+
const passThrough = !onPointerDown ? { pointerEvents: 'none' } : {}
|
|
115
116
|
if (style === 'none') {
|
|
116
117
|
return (
|
|
117
118
|
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
118
|
-
style={{ fill: 'transparent', stroke: 'none', pointerEvents: 'auto', cursor: 'crosshair' }}
|
|
119
|
+
style={{ fill: 'transparent', stroke: 'none', ...(onPointerDown ? { pointerEvents: 'auto', cursor: 'crosshair' } : { pointerEvents: 'none' }) }}
|
|
119
120
|
onPointerDown={onPointerDown}
|
|
120
121
|
/>
|
|
121
122
|
)
|
|
122
123
|
}
|
|
123
124
|
if (style === 'arrow-start' || style === 'arrow-end') {
|
|
124
125
|
const size = connectorConfig.endpointRadius * 2.2
|
|
125
|
-
// Determine which point the arrow should aim toward
|
|
126
126
|
const target = style === 'arrow-start' ? startPt : endPt
|
|
127
127
|
const dx = target.x - x
|
|
128
128
|
const dy = target.y - y
|
|
129
|
-
// atan2 gives angle from positive X axis; polygon tip points up (-Y), so offset by 90°
|
|
130
129
|
const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
|
|
131
130
|
return (
|
|
132
131
|
<polygon
|
|
133
132
|
points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
|
|
134
133
|
transform={`translate(${x},${y}) rotate(${rotation})`}
|
|
135
134
|
className={styles.connectorEndpoint}
|
|
135
|
+
style={passThrough}
|
|
136
136
|
onPointerDown={onPointerDown}
|
|
137
137
|
/>
|
|
138
138
|
)
|
|
139
139
|
}
|
|
140
|
-
// Default: circle
|
|
141
140
|
return (
|
|
142
141
|
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
143
142
|
className={styles.connectorEndpoint}
|
|
143
|
+
style={passThrough}
|
|
144
144
|
onPointerDown={onPointerDown}
|
|
145
145
|
/>
|
|
146
146
|
)
|
|
@@ -216,7 +216,7 @@ export default function ConnectorLayer({
|
|
|
216
216
|
className={styles.connectorPath}
|
|
217
217
|
onClick={(e) => handleClick(e, conn.id)}
|
|
218
218
|
/>
|
|
219
|
-
{/* Endpoint shapes —
|
|
219
|
+
{/* Endpoint shapes — visual only, pointer events pass through to anchor dots */}
|
|
220
220
|
<EndpointShape x={startPt.x} y={startPt.y} startPt={startPt} endPt={endPt} style={startStyle}
|
|
221
221
|
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'start', e) } : undefined}
|
|
222
222
|
/>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
3
3
|
import PageSelector from './PageSelector.jsx'
|
|
4
4
|
|
|
5
5
|
const PAGES = [
|
|
@@ -10,9 +10,15 @@ const PAGES = [
|
|
|
10
10
|
|
|
11
11
|
describe('PageSelector', () => {
|
|
12
12
|
beforeEach(() => {
|
|
13
|
-
// Reset location mock
|
|
13
|
+
// Reset location mock — use a setter spy so we can track assignments
|
|
14
14
|
delete window.location
|
|
15
|
-
|
|
15
|
+
const loc = { _href: '' }
|
|
16
|
+
Object.defineProperty(loc, 'href', {
|
|
17
|
+
get() { return loc._href },
|
|
18
|
+
set(v) { loc._href = v },
|
|
19
|
+
configurable: true,
|
|
20
|
+
})
|
|
21
|
+
window.location = loc
|
|
16
22
|
})
|
|
17
23
|
|
|
18
24
|
it('renders nothing when fewer than 2 pages', () => {
|
|
@@ -58,14 +64,17 @@ describe('PageSelector', () => {
|
|
|
58
64
|
expect(options[0].getAttribute('aria-selected')).toBe('false')
|
|
59
65
|
})
|
|
60
66
|
|
|
61
|
-
it('navigates to selected page', () => {
|
|
67
|
+
it('navigates to selected page', async () => {
|
|
62
68
|
render(<PageSelector currentName="research/interviews" pages={PAGES} />)
|
|
63
69
|
fireEvent.click(screen.getByTitle('Switch canvas page'))
|
|
64
70
|
// Click the option in the menu (not the trigger label)
|
|
65
71
|
const options = screen.getAllByRole('option')
|
|
66
72
|
fireEvent.click(options[1]) // Surveys
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
// Navigation uses a 300ms setTimeout for mouse clicks
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(window.location.href).toContain('/canvas/research/surveys')
|
|
77
|
+
})
|
|
69
78
|
})
|
|
70
79
|
|
|
71
80
|
it('closes dropdown on Escape', () => {
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -100,7 +100,7 @@ export function useCanvas(canvasId) {
|
|
|
100
100
|
useEffect(() => {
|
|
101
101
|
if (!import.meta.hot || !buildTimeCanvas) return
|
|
102
102
|
|
|
103
|
-
const handleCanvasFileChanged = (
|
|
103
|
+
const handleCanvasFileChanged = (data) => {
|
|
104
104
|
const eventId = data?.canvasId || data?.name
|
|
105
105
|
if (!data || eventId !== canvasId) return
|
|
106
106
|
// Use metadata from the HMR event directly if available (faster)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
+
import { readProp } from './widgetProps.js'
|
|
3
|
+
import { schemas } from './widgetProps.js'
|
|
4
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
5
|
+
import styles from './ActionWidget.module.css'
|
|
6
|
+
|
|
7
|
+
const actionSchema = schemas['action']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ActionWidget — a canvas widget that runs a background agent.
|
|
11
|
+
*
|
|
12
|
+
* Displays a "Run" button. When clicked, spawns a headless tmux+copilot
|
|
13
|
+
* session via the /agent/spawn endpoint. Shows status indicators
|
|
14
|
+
* (running/done/error) and allows peeking into errored sessions.
|
|
15
|
+
*/
|
|
16
|
+
export default forwardRef(function ActionWidget({ id, props, onUpdate, resizable }, ref) {
|
|
17
|
+
const width = readProp(props, 'width', actionSchema)
|
|
18
|
+
const height = readProp(props, 'height', actionSchema)
|
|
19
|
+
const prompt = readProp(props, 'prompt', actionSchema) || ''
|
|
20
|
+
const label = readProp(props, 'label', actionSchema) || 'Run Agent'
|
|
21
|
+
|
|
22
|
+
const [status, setStatus] = useState('idle') // idle | running | done | error
|
|
23
|
+
const [message, setMessage] = useState(null)
|
|
24
|
+
|
|
25
|
+
useImperativeHandle(ref, () => ({
|
|
26
|
+
handleAction(actionId) {
|
|
27
|
+
// ActionWidget doesn't handle expand/split-screen itself
|
|
28
|
+
return false
|
|
29
|
+
},
|
|
30
|
+
}), [])
|
|
31
|
+
|
|
32
|
+
// Listen for agent status updates via Vite HMR custom events
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!import.meta.hot) return
|
|
35
|
+
|
|
36
|
+
const handler = (data) => {
|
|
37
|
+
if (data.widgetId === id) {
|
|
38
|
+
setStatus(data.status)
|
|
39
|
+
setMessage(data.message || null)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
import.meta.hot.on('storyboard:agent-status', handler)
|
|
44
|
+
return () => {
|
|
45
|
+
// Vite HMR doesn't support removeListener, but cleanup on unmount
|
|
46
|
+
}
|
|
47
|
+
}, [id])
|
|
48
|
+
|
|
49
|
+
// Poll for status on mount (in case we missed a WS event)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
52
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
53
|
+
|
|
54
|
+
fetch(`${baseClean}_storyboard/canvas/agent/status?widgetId=${id}`)
|
|
55
|
+
.then((r) => r.json())
|
|
56
|
+
.then((data) => {
|
|
57
|
+
if (data.agentStatus?.status) {
|
|
58
|
+
setStatus(data.agentStatus.status)
|
|
59
|
+
setMessage(data.agentStatus.message || null)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {})
|
|
63
|
+
}, [id])
|
|
64
|
+
|
|
65
|
+
const handleRun = useCallback(async () => {
|
|
66
|
+
if (status === 'running') return
|
|
67
|
+
|
|
68
|
+
setStatus('running')
|
|
69
|
+
setMessage('Spawning agent...')
|
|
70
|
+
|
|
71
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
72
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${baseClean}_storyboard/canvas/agent/spawn`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
|
|
80
|
+
widgetId: id,
|
|
81
|
+
prompt,
|
|
82
|
+
autopilot: true,
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const data = await res.json().catch(() => ({}))
|
|
88
|
+
setStatus('error')
|
|
89
|
+
setMessage(data.error || 'Spawn failed')
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setStatus('error')
|
|
93
|
+
setMessage(err.message || 'Connection failed')
|
|
94
|
+
}
|
|
95
|
+
}, [id, prompt, status])
|
|
96
|
+
|
|
97
|
+
const handlePeek = useCallback(async () => {
|
|
98
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
99
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`${baseClean}_storyboard/canvas/agent/peek`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
widgetId: id,
|
|
107
|
+
canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
|
|
108
|
+
}),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (res.ok) {
|
|
112
|
+
setMessage('Session opened — check the new terminal widget')
|
|
113
|
+
} else {
|
|
114
|
+
const data = await res.json().catch(() => ({}))
|
|
115
|
+
setMessage(data.error || 'Peek failed')
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setMessage(err.message || 'Connection failed')
|
|
119
|
+
}
|
|
120
|
+
}, [id])
|
|
121
|
+
|
|
122
|
+
const handleDismiss = useCallback(() => {
|
|
123
|
+
setStatus('idle')
|
|
124
|
+
setMessage(null)
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
const handleResize = useCallback((w, h) => {
|
|
128
|
+
onUpdate?.({ width: w, height: h })
|
|
129
|
+
}, [onUpdate])
|
|
130
|
+
|
|
131
|
+
const statusIcon = {
|
|
132
|
+
idle: '⚡',
|
|
133
|
+
running: '⏳',
|
|
134
|
+
done: '✓',
|
|
135
|
+
error: '!',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const statusClass = {
|
|
139
|
+
idle: styles.idle,
|
|
140
|
+
running: styles.running,
|
|
141
|
+
done: styles.done,
|
|
142
|
+
error: styles.error,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={`${styles.container} ${statusClass[status] || ''}`}
|
|
148
|
+
style={{
|
|
149
|
+
...(typeof width === 'number' ? { width: `${width}px` } : undefined),
|
|
150
|
+
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<div className={styles.header}>
|
|
154
|
+
<span className={styles.icon}>{statusIcon[status]}</span>
|
|
155
|
+
<span className={styles.label}>{label}</span>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{prompt && (
|
|
159
|
+
<div className={styles.prompt}>
|
|
160
|
+
{prompt.length > 100 ? prompt.slice(0, 100) + '…' : prompt}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<div className={styles.actions}>
|
|
165
|
+
{(status === 'idle' || status === 'done') && (
|
|
166
|
+
<button className={styles.runButton} onClick={handleRun}>
|
|
167
|
+
{status === 'done' ? 'Run Again' : 'Run'}
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{status === 'running' && (
|
|
172
|
+
<div className={styles.spinner}>Running…</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{status === 'error' && (
|
|
176
|
+
<div className={styles.errorActions}>
|
|
177
|
+
<button className={styles.peekButton} onClick={handlePeek}>
|
|
178
|
+
Peek Session
|
|
179
|
+
</button>
|
|
180
|
+
<button className={styles.dismissButton} onClick={handleDismiss}>
|
|
181
|
+
Dismiss
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{message && (
|
|
188
|
+
<div className={styles.message}>{message}</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{resizable && (
|
|
192
|
+
<ResizeHandle
|
|
193
|
+
onResize={handleResize}
|
|
194
|
+
minWidth={200}
|
|
195
|
+
minHeight={120}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 8px;
|
|
5
|
+
padding: 16px;
|
|
6
|
+
border-radius: 8px;
|
|
7
|
+
background: var(--bgColor-default, #0d1117);
|
|
8
|
+
border: 1px solid var(--borderColor-default, #30363d);
|
|
9
|
+
color: var(--fgColor-default, #e6edf3);
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
11
|
+
font-size: 13px;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.container.running {
|
|
16
|
+
border-color: var(--borderColor-accent-emphasis, #58a6ff);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.container.done {
|
|
20
|
+
border-color: var(--borderColor-success-emphasis, #3fb950);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.container.error {
|
|
24
|
+
border-color: var(--borderColor-danger-emphasis, #f85149);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.header {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 8px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.icon {
|
|
36
|
+
font-size: 16px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.label {
|
|
40
|
+
flex: 1;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
text-overflow: ellipsis;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.prompt {
|
|
47
|
+
color: var(--fgColor-muted, #8b949e);
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
line-height: 1.4;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
display: -webkit-box;
|
|
52
|
+
-webkit-line-clamp: 3;
|
|
53
|
+
-webkit-box-orient: vertical;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.actions {
|
|
57
|
+
display: flex;
|
|
58
|
+
gap: 8px;
|
|
59
|
+
margin-top: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.runButton {
|
|
63
|
+
padding: 6px 16px;
|
|
64
|
+
border-radius: 6px;
|
|
65
|
+
border: none;
|
|
66
|
+
background: var(--bgColor-accent-emphasis, #1f6feb);
|
|
67
|
+
color: #fff;
|
|
68
|
+
font-size: 13px;
|
|
69
|
+
font-weight: 500;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
transition: background 0.15s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.runButton:hover {
|
|
75
|
+
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.spinner {
|
|
79
|
+
color: var(--fgColor-accent, #58a6ff);
|
|
80
|
+
font-size: 12px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.errorActions {
|
|
84
|
+
display: flex;
|
|
85
|
+
gap: 8px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.peekButton {
|
|
89
|
+
padding: 4px 12px;
|
|
90
|
+
border-radius: 6px;
|
|
91
|
+
border: 1px solid var(--borderColor-danger-emphasis, #f85149);
|
|
92
|
+
background: transparent;
|
|
93
|
+
color: var(--fgColor-danger, #f85149);
|
|
94
|
+
font-size: 12px;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.peekButton:hover {
|
|
99
|
+
background: var(--bgColor-danger-muted, rgba(248, 81, 73, 0.1));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.dismissButton {
|
|
103
|
+
padding: 4px 12px;
|
|
104
|
+
border-radius: 6px;
|
|
105
|
+
border: 1px solid var(--borderColor-default, #30363d);
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: var(--fgColor-muted, #8b949e);
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.dismissButton:hover {
|
|
113
|
+
background: var(--bgColor-muted, #161b22);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.message {
|
|
117
|
+
color: var(--fgColor-muted, #8b949e);
|
|
118
|
+
font-size: 11px;
|
|
119
|
+
overflow: hidden;
|
|
120
|
+
text-overflow: ellipsis;
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
}
|
|
@@ -5,6 +5,8 @@ import { readProp } from './widgetProps.js'
|
|
|
5
5
|
import { schemas } from './widgetConfig.js'
|
|
6
6
|
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
7
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
|
+
import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel } from './expandUtils.js'
|
|
9
|
+
import SplitScreenTopBar from './SplitScreenTopBar.jsx'
|
|
8
10
|
import styles from './FigmaEmbed.module.css'
|
|
9
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
10
12
|
|
|
@@ -49,7 +51,7 @@ function FigmaLogo() {
|
|
|
49
51
|
|
|
50
52
|
const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
|
|
51
53
|
|
|
52
|
-
export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
|
|
54
|
+
export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
53
55
|
const url = readProp(props, 'url', figmaEmbedSchema)
|
|
54
56
|
const width = readProp(props, 'width', figmaEmbedSchema)
|
|
55
57
|
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
@@ -156,7 +158,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
156
158
|
handleAction(actionId) {
|
|
157
159
|
if (actionId === 'open-external') {
|
|
158
160
|
if (url) window.open(url, '_blank', 'noopener')
|
|
159
|
-
} else if (actionId === 'expand') {
|
|
161
|
+
} else if (actionId === 'expand' || actionId === 'split-screen') {
|
|
160
162
|
setShowIframe(true)
|
|
161
163
|
setExpanded(true)
|
|
162
164
|
}
|
|
@@ -247,35 +249,101 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
247
249
|
)}
|
|
248
250
|
</WidgetWrapper>
|
|
249
251
|
{createPortal(
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
e.stopPropagation()
|
|
257
|
-
if (e.key === 'Escape') setExpanded(false)
|
|
258
|
-
}}
|
|
259
|
-
onWheel={(e) => e.stopPropagation()}
|
|
260
|
-
tabIndex={-1}
|
|
261
|
-
ref={(el) => { if (el && expanded) el.focus() }}
|
|
262
|
-
>
|
|
263
|
-
<div
|
|
264
|
-
ref={modalContainerRef}
|
|
265
|
-
className={styles.expandContainer}
|
|
266
|
-
onClick={(e) => e.stopPropagation()}
|
|
267
|
-
>
|
|
268
|
-
{/* iframe is reparented here via useEffect */}
|
|
269
|
-
<button
|
|
270
|
-
className={styles.expandClose}
|
|
271
|
-
onClick={() => setExpanded(false)}
|
|
272
|
-
aria-label="Close expanded view"
|
|
273
|
-
autoFocus
|
|
274
|
-
>✕</button>
|
|
275
|
-
</div>
|
|
276
|
-
</div>,
|
|
252
|
+
<FigmaExpandModal
|
|
253
|
+
expanded={expanded && !!embedUrl}
|
|
254
|
+
onClose={() => setExpanded(false)}
|
|
255
|
+
modalContainerRef={modalContainerRef}
|
|
256
|
+
widgetId={widgetId}
|
|
257
|
+
/>,
|
|
277
258
|
document.body
|
|
278
259
|
)}
|
|
279
260
|
</>
|
|
280
261
|
)
|
|
281
262
|
})
|
|
263
|
+
|
|
264
|
+
function FigmaExpandModal({ expanded, onClose, modalContainerRef, widgetId }) {
|
|
265
|
+
const connectedWidget = useMemo(
|
|
266
|
+
() => (expanded ? findConnectedSplitTarget(widgetId) : null),
|
|
267
|
+
[expanded, widgetId],
|
|
268
|
+
)
|
|
269
|
+
const hasSplit = Boolean(connectedWidget)
|
|
270
|
+
const paneOrder = useMemo(
|
|
271
|
+
() => (hasSplit ? getPaneOrder(widgetId, connectedWidget) : { primaryIsLeft: true }),
|
|
272
|
+
[hasSplit, widgetId, connectedWidget],
|
|
273
|
+
)
|
|
274
|
+
const secondaryUrl = useMemo(() => buildSecondaryIframeUrl(connectedWidget), [connectedWidget])
|
|
275
|
+
const isTerminalSecondary = connectedWidget?.type === 'terminal' || connectedWidget?.type === 'terminal-read' || connectedWidget?.type === 'agent'
|
|
276
|
+
const terminalRef = useRef(null)
|
|
277
|
+
const cleanupRef = useRef(null)
|
|
278
|
+
const [activePane, setActivePane] = useState('left')
|
|
279
|
+
|
|
280
|
+
const primaryWidget = useMemo(() => {
|
|
281
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
282
|
+
return bridge?.widgets?.find((w) => w.id === widgetId) || { type: 'figma-embed', props: {} }
|
|
283
|
+
}, [widgetId, expanded])
|
|
284
|
+
|
|
285
|
+
const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
|
|
286
|
+
const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedWidget), [connectedWidget])
|
|
287
|
+
const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
|
|
288
|
+
const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
|
|
289
|
+
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!isTerminalSecondary || !expanded || !terminalRef.current) return
|
|
292
|
+
cleanupRef.current = reparentTerminalInto(connectedWidget.id, terminalRef.current)
|
|
293
|
+
return () => { cleanupRef.current?.(); cleanupRef.current = null }
|
|
294
|
+
}, [isTerminalSecondary, expanded, connectedWidget?.id])
|
|
295
|
+
|
|
296
|
+
const primaryPane = (
|
|
297
|
+
<div
|
|
298
|
+
ref={modalContainerRef}
|
|
299
|
+
className={hasSplit ? styles.expandContainerSplit : styles.expandContainer}
|
|
300
|
+
onClick={(e) => e.stopPropagation()}
|
|
301
|
+
onPointerDown={() => setActivePane(paneOrder.primaryIsLeft ? 'left' : 'right')}
|
|
302
|
+
>
|
|
303
|
+
{!hasSplit && <button className={styles.expandClose} onClick={onClose} aria-label="Close expanded view" autoFocus>✕</button>}
|
|
304
|
+
</div>
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
let secondaryPane = null
|
|
308
|
+
const secondarySide = paneOrder.primaryIsLeft ? 'right' : 'left'
|
|
309
|
+
if (hasSplit) {
|
|
310
|
+
if (secondaryUrl) {
|
|
311
|
+
secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><iframe src={secondaryUrl} className={styles.expandSecondaryIframe} title="Connected widget" /></div>
|
|
312
|
+
} else if (isTerminalSecondary) {
|
|
313
|
+
secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><div ref={terminalRef} className={styles.expandTerminal} /></div>
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
|
|
318
|
+
const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
className={styles.expandBackdrop}
|
|
323
|
+
style={expanded ? undefined : { display: 'none' }}
|
|
324
|
+
onClick={onClose}
|
|
325
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
326
|
+
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose() }}
|
|
327
|
+
onWheel={(e) => e.stopPropagation()}
|
|
328
|
+
tabIndex={-1}
|
|
329
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
330
|
+
>
|
|
331
|
+
{hasSplit ? (
|
|
332
|
+
<div className={styles.expandSplitBody}>
|
|
333
|
+
<SplitScreenTopBar
|
|
334
|
+
leftLabel={leftLabel}
|
|
335
|
+
rightLabel={rightLabel}
|
|
336
|
+
activePane={activePane}
|
|
337
|
+
onClose={onClose}
|
|
338
|
+
/>
|
|
339
|
+
<div className={styles.expandSplitPanes}>
|
|
340
|
+
<div className={styles.expandSplitLeft}>{leftPane}</div>
|
|
341
|
+
<div className={styles.expandSplitRight}>{rightPane}</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
) : (
|
|
345
|
+
primaryPane
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
@@ -139,6 +139,67 @@
|
|
|
139
139
|
background: rgba(0, 0, 0, 0.7);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/* ── Split-screen layout ──────────────────────────────────────────── */
|
|
143
|
+
|
|
144
|
+
.expandContainerSplit {
|
|
145
|
+
flex: 1;
|
|
146
|
+
min-width: 0;
|
|
147
|
+
height: 100%;
|
|
148
|
+
position: relative;
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
background: var(--bgColor-default, #ffffff);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.expandSplitBody {
|
|
154
|
+
position: fixed;
|
|
155
|
+
inset: 0;
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
animation: expandScaleIn 0.2s ease;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.expandSplitPanes {
|
|
163
|
+
flex: 1;
|
|
164
|
+
min-height: 0;
|
|
165
|
+
display: flex;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.expandSplitLeft {
|
|
169
|
+
flex: 1;
|
|
170
|
+
min-width: 0;
|
|
171
|
+
height: 100%;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
border-right: 1px solid var(--borderColor-muted, #d8dee4);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.expandSplitRight {
|
|
177
|
+
flex: 1;
|
|
178
|
+
min-width: 0;
|
|
179
|
+
height: 100%;
|
|
180
|
+
overflow: hidden;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.expandSecondary {
|
|
184
|
+
width: 100%;
|
|
185
|
+
height: 100%;
|
|
186
|
+
overflow: auto;
|
|
187
|
+
background: var(--bgColor-default, #ffffff);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.expandSecondaryIframe {
|
|
191
|
+
border: none;
|
|
192
|
+
width: 100%;
|
|
193
|
+
height: 100%;
|
|
194
|
+
display: block;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.expandTerminal {
|
|
198
|
+
width: 100%;
|
|
199
|
+
height: 100%;
|
|
200
|
+
background: #0d1117;
|
|
201
|
+
}
|
|
202
|
+
|
|
142
203
|
.emptyState {
|
|
143
204
|
width: 100%;
|
|
144
205
|
height: calc(100% - 10px);
|
|
@@ -59,7 +59,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
|
|
|
59
59
|
const url = getImageUrl(src)
|
|
60
60
|
const a = document.createElement('a')
|
|
61
61
|
a.href = url
|
|
62
|
-
a.download = src.replace(
|
|
62
|
+
a.download = src.replace(/^~/, '')
|
|
63
63
|
document.body.appendChild(a)
|
|
64
64
|
a.click()
|
|
65
65
|
document.body.removeChild(a)
|