@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import ExpandedPane from './ExpandedPane.jsx'
|
|
4
|
+
|
|
5
|
+
// Mock createPortal to render inline for testing
|
|
6
|
+
vi.mock('react-dom', async () => {
|
|
7
|
+
const actual = await vi.importActual('react-dom')
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
createPortal: (children) => children,
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// Polyfill ResizeObserver for jsdom
|
|
15
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
16
|
+
globalThis.ResizeObserver = class {
|
|
17
|
+
constructor(cb) { this._cb = cb }
|
|
18
|
+
observe() {}
|
|
19
|
+
unobserve() {}
|
|
20
|
+
disconnect() {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeReactPane(id, label = `Pane ${id}`) {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
label,
|
|
28
|
+
kind: 'react',
|
|
29
|
+
render: () => <div data-testid={`content-${id}`}>{label} content</div>,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeExternalPane(id, label = `External ${id}`) {
|
|
34
|
+
const detach = vi.fn()
|
|
35
|
+
const attach = vi.fn(() => detach)
|
|
36
|
+
const onResize = vi.fn()
|
|
37
|
+
return {
|
|
38
|
+
pane: { id, label, kind: 'external', attach, onResize },
|
|
39
|
+
attach,
|
|
40
|
+
detach,
|
|
41
|
+
onResize,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('ExpandedPane', () => {
|
|
46
|
+
describe('single-pane modal variant', () => {
|
|
47
|
+
it('renders modal container with title and content', () => {
|
|
48
|
+
const pane = makeReactPane('md-1', 'Markdown · Notes')
|
|
49
|
+
render(<ExpandedPane initialPanes={[pane]} variant="modal" onClose={vi.fn()} />)
|
|
50
|
+
expect(screen.getByText('Markdown · Notes')).toBeTruthy()
|
|
51
|
+
expect(screen.getByText('Markdown · Notes content')).toBeTruthy()
|
|
52
|
+
expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('calls onClose when close button is clicked', () => {
|
|
56
|
+
const onClose = vi.fn()
|
|
57
|
+
render(<ExpandedPane initialPanes={[makeReactPane('md-1')]} variant="modal" onClose={onClose} />)
|
|
58
|
+
fireEvent.click(screen.getByLabelText('Close expanded view'))
|
|
59
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('calls onClose on Escape key', () => {
|
|
63
|
+
const onClose = vi.fn()
|
|
64
|
+
render(<ExpandedPane initialPanes={[makeReactPane('md-1')]} variant="modal" onClose={onClose} />)
|
|
65
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
66
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('single-pane full variant', () => {
|
|
71
|
+
it('renders full-screen container with top bar', () => {
|
|
72
|
+
const pane = makeReactPane('term-1', 'Terminal · wren')
|
|
73
|
+
render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
|
|
74
|
+
expect(screen.getByText('Terminal · wren')).toBeTruthy()
|
|
75
|
+
expect(screen.getByText('Terminal · wren content')).toBeTruthy()
|
|
76
|
+
expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('multi-pane (split) layout', () => {
|
|
81
|
+
it('renders both panes with grid layout', () => {
|
|
82
|
+
const paneA = makeReactPane('term-1', 'Terminal · wren')
|
|
83
|
+
const paneB = makeReactPane('proto-1', 'Prototype · /Signup')
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<ExpandedPane initialPanes={[paneA, paneB]} variant="full" onClose={vi.fn()} />
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByText('Terminal · wren content')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText('Prototype · /Signup content')).toBeTruthy()
|
|
89
|
+
// Check grid exists
|
|
90
|
+
const grid = container.querySelector('[class*="grid"]')
|
|
91
|
+
expect(grid).toBeTruthy()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders 3 panes', () => {
|
|
95
|
+
const panes = [
|
|
96
|
+
makeReactPane('a', 'Pane A'),
|
|
97
|
+
makeReactPane('b', 'Pane B'),
|
|
98
|
+
makeReactPane('c', 'Pane C'),
|
|
99
|
+
]
|
|
100
|
+
render(<ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />)
|
|
101
|
+
expect(screen.getByText('Pane A content')).toBeTruthy()
|
|
102
|
+
expect(screen.getByText('Pane B content')).toBeTruthy()
|
|
103
|
+
expect(screen.getByText('Pane C content')).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('renders both pane labels in their respective panes', () => {
|
|
107
|
+
const paneA = makeReactPane('term-1', 'Terminal · wren')
|
|
108
|
+
const paneB = makeReactPane('proto-1', 'Prototype · /Signup')
|
|
109
|
+
render(
|
|
110
|
+
<ExpandedPane initialPanes={[paneA, paneB]} variant="full" onClose={vi.fn()} />
|
|
111
|
+
)
|
|
112
|
+
// Each pane has its own title bar with left-aligned label
|
|
113
|
+
expect(screen.getByText('Terminal · wren')).toBeTruthy()
|
|
114
|
+
expect(screen.getByText('Prototype · /Signup')).toBeTruthy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders divider between panes', () => {
|
|
118
|
+
const panes = [makeReactPane('a'), makeReactPane('b')]
|
|
119
|
+
const { container } = render(
|
|
120
|
+
<ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />
|
|
121
|
+
)
|
|
122
|
+
const dividers = container.querySelectorAll('[role="separator"]')
|
|
123
|
+
expect(dividers.length).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders N-1 dividers for N panes', () => {
|
|
127
|
+
const panes = [makeReactPane('a'), makeReactPane('b'), makeReactPane('c')]
|
|
128
|
+
const { container } = render(
|
|
129
|
+
<ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />
|
|
130
|
+
)
|
|
131
|
+
const dividers = container.querySelectorAll('[role="separator"]')
|
|
132
|
+
expect(dividers.length).toBe(2)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('external pane attach/detach', () => {
|
|
137
|
+
it('calls attach with container element on mount', async () => {
|
|
138
|
+
const { pane, attach, detach } = makeExternalPane('term-1')
|
|
139
|
+
render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
|
|
140
|
+
// useLayoutEffect runs synchronously in test
|
|
141
|
+
expect(attach).toHaveBeenCalledOnce()
|
|
142
|
+
expect(attach.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('calls detach on unmount', () => {
|
|
146
|
+
const { pane, detach } = makeExternalPane('term-1')
|
|
147
|
+
const { unmount } = render(
|
|
148
|
+
<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />
|
|
149
|
+
)
|
|
150
|
+
unmount()
|
|
151
|
+
expect(detach).toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('returns null for empty panes', () => {
|
|
156
|
+
it('returns null when no panes provided', () => {
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<ExpandedPane initialPanes={[]} variant="modal" onClose={vi.fn()} />
|
|
159
|
+
)
|
|
160
|
+
// Modal variant with no panes returns null
|
|
161
|
+
expect(container.innerHTML).toBe('')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('initialLayout (2D layout)', () => {
|
|
166
|
+
it('renders 2 columns from initialLayout', () => {
|
|
167
|
+
const layout = [
|
|
168
|
+
[makeReactPane('a', 'Left')],
|
|
169
|
+
[makeReactPane('b', 'Right')],
|
|
170
|
+
]
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
|
|
173
|
+
)
|
|
174
|
+
expect(screen.getByText('Left content')).toBeTruthy()
|
|
175
|
+
expect(screen.getByText('Right content')).toBeTruthy()
|
|
176
|
+
const grid = container.querySelector('[class*="grid"]')
|
|
177
|
+
expect(grid).toBeTruthy()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('renders 3 panes: 1 column + 2-row column', () => {
|
|
181
|
+
const layout = [
|
|
182
|
+
[makeReactPane('a', 'Left')],
|
|
183
|
+
[makeReactPane('b', 'Top Right'), makeReactPane('c', 'Bottom Right')],
|
|
184
|
+
]
|
|
185
|
+
render(
|
|
186
|
+
<ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
|
|
187
|
+
)
|
|
188
|
+
expect(screen.getByText('Left content')).toBeTruthy()
|
|
189
|
+
expect(screen.getByText('Top Right content')).toBeTruthy()
|
|
190
|
+
expect(screen.getByText('Bottom Right content')).toBeTruthy()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('renders 4 panes in 2×2 grid', () => {
|
|
194
|
+
const layout = [
|
|
195
|
+
[makeReactPane('a', 'TL'), makeReactPane('b', 'BL')],
|
|
196
|
+
[makeReactPane('c', 'TR'), makeReactPane('d', 'BR')],
|
|
197
|
+
]
|
|
198
|
+
render(
|
|
199
|
+
<ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
|
|
200
|
+
)
|
|
201
|
+
expect(screen.getByText('TL content')).toBeTruthy()
|
|
202
|
+
expect(screen.getByText('BL content')).toBeTruthy()
|
|
203
|
+
expect(screen.getByText('TR content')).toBeTruthy()
|
|
204
|
+
expect(screen.getByText('BR content')).toBeTruthy()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('renders column divider between columns', () => {
|
|
208
|
+
const layout = [
|
|
209
|
+
[makeReactPane('a')],
|
|
210
|
+
[makeReactPane('b')],
|
|
211
|
+
]
|
|
212
|
+
const { container } = render(
|
|
213
|
+
<ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
|
|
214
|
+
)
|
|
215
|
+
const vSeparators = container.querySelectorAll('[role="separator"][aria-orientation="vertical"]')
|
|
216
|
+
expect(vSeparators.length).toBe(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('renders row dividers within 2-row columns', () => {
|
|
220
|
+
const layout = [
|
|
221
|
+
[makeReactPane('a'), makeReactPane('b')],
|
|
222
|
+
[makeReactPane('c'), makeReactPane('d')],
|
|
223
|
+
]
|
|
224
|
+
const { container } = render(
|
|
225
|
+
<ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
|
|
226
|
+
)
|
|
227
|
+
const hSeparators = container.querySelectorAll('[role="separator"][aria-orientation="horizontal"]')
|
|
228
|
+
expect(hSeparators.length).toBe(2) // one per 2-row column
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('renders single-pane modal from 1-element layout', () => {
|
|
232
|
+
const layout = [[makeReactPane('a', 'Only Pane')]]
|
|
233
|
+
render(
|
|
234
|
+
<ExpandedPane initialLayout={layout} variant="modal" onClose={vi.fn()} />
|
|
235
|
+
)
|
|
236
|
+
expect(screen.getByText('Only Pane')).toBeTruthy()
|
|
237
|
+
expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExpandedPaneTopBar — dark per-pane title bar for expanded/split-screen views.
|
|
3
|
+
*
|
|
4
|
+
* Each pane gets its own bar with a left-aligned label.
|
|
5
|
+
* The rightmost (or only) pane shows the close button.
|
|
6
|
+
*
|
|
7
|
+
* Actions render as toolbar-style buttons (same as WidgetChrome) forced to dark mode.
|
|
8
|
+
* Tooltips show labels on hover (Primer <Tooltip>, not title attr).
|
|
9
|
+
*
|
|
10
|
+
* Supports two action sources:
|
|
11
|
+
* 1. `actions` array/function — legacy inline actions ({ icon, label, ariaLabel, onClick })
|
|
12
|
+
* 2. `features` array — config-driven features resolved via ICON_REGISTRY
|
|
13
|
+
* Combined with `getState` for toggle resolution and `onAction` for dispatch.
|
|
14
|
+
*/
|
|
15
|
+
import { ScreenNormalIcon } from '@primer/octicons-react'
|
|
16
|
+
import { Tooltip } from '@primer/react'
|
|
17
|
+
import { ICON_REGISTRY } from './widgetIcons.jsx'
|
|
18
|
+
import { getWidgetMeta } from './widgetConfig.js'
|
|
19
|
+
import Icon from '../../Icon.jsx'
|
|
20
|
+
import styles from './ExpandedPaneTopBar.module.css'
|
|
21
|
+
|
|
22
|
+
/** Named icons use only lowercase alphanumeric, hyphens, and slashes. */
|
|
23
|
+
function isNamedIcon(str) {
|
|
24
|
+
return str && /^[a-z0-9/-]+$/i.test(str)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a feature's icon and label, applying toggle state if configured.
|
|
29
|
+
* @param {Object} feature — config feature object
|
|
30
|
+
* @param {((key: string) => any) | undefined} getState — state accessor
|
|
31
|
+
* @returns {{ Icon: Function|null, label: string }}
|
|
32
|
+
*/
|
|
33
|
+
function resolveFeatureDisplay(feature, getState) {
|
|
34
|
+
let Icon = ICON_REGISTRY[feature.icon]
|
|
35
|
+
let label = feature.label || feature.action
|
|
36
|
+
|
|
37
|
+
if (feature.toggle && getState) {
|
|
38
|
+
const isActive = getState(feature.toggle.stateKey)
|
|
39
|
+
if (isActive) {
|
|
40
|
+
if (feature.toggle.activeIcon) Icon = ICON_REGISTRY[feature.toggle.activeIcon] || Icon
|
|
41
|
+
if (feature.toggle.activeLabel) label = feature.toggle.activeLabel
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { Icon, label }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {Object} props
|
|
50
|
+
* @param {string} props.label — pane display label
|
|
51
|
+
* @param {string} [props.widgetType] — widget type string for icon resolution
|
|
52
|
+
* @param {boolean} [props.showClose] — show close button (rightmost pane)
|
|
53
|
+
* @param {() => void} [props.onClose] — close entire ExpandedPane
|
|
54
|
+
* @param {Array<{ label: string, onClick: () => void }>} [props.actions] — legacy action buttons
|
|
55
|
+
* @param {Array} [props.features] — config-driven features for this surface
|
|
56
|
+
* @param {(key: string) => any} [props.getState] — state accessor for toggle resolution
|
|
57
|
+
* @param {(actionId: string) => void} [props.onAction] — action dispatch callback
|
|
58
|
+
*/
|
|
59
|
+
export default function ExpandedPaneTopBar({ label, widgetType, showClose, onClose, actions, features, getState, onAction }) {
|
|
60
|
+
const resolvedActions = typeof actions === 'function' ? actions() : actions
|
|
61
|
+
const meta = widgetType ? getWidgetMeta(widgetType) : null
|
|
62
|
+
const iconName = meta?.icon || null
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={styles.bar}>
|
|
66
|
+
{iconName && (
|
|
67
|
+
<span className={styles.widgetIcon} aria-hidden="true">
|
|
68
|
+
{isNamedIcon(iconName) ? <Icon name={iconName} size={12} /> : iconName}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
<span className={styles.label}>{label}</span>
|
|
72
|
+
|
|
73
|
+
{/* Config-driven feature actions */}
|
|
74
|
+
{features?.map((feature) => {
|
|
75
|
+
const { Icon, label: featureLabel } = resolveFeatureDisplay(feature, getState)
|
|
76
|
+
return (
|
|
77
|
+
<Tooltip key={feature.id} text={featureLabel} direction="s">
|
|
78
|
+
<button
|
|
79
|
+
className={styles.actionBtn}
|
|
80
|
+
onClick={() => onAction?.(feature.action)}
|
|
81
|
+
aria-label={featureLabel}
|
|
82
|
+
>
|
|
83
|
+
{Icon ? <Icon /> : featureLabel}
|
|
84
|
+
</button>
|
|
85
|
+
</Tooltip>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
|
|
89
|
+
{/* Legacy inline actions (backward compat) */}
|
|
90
|
+
{resolvedActions?.map((action, i) => (
|
|
91
|
+
<Tooltip key={i} text={action.label || action.ariaLabel || 'Action'} direction="s">
|
|
92
|
+
<button
|
|
93
|
+
className={styles.actionBtn}
|
|
94
|
+
onClick={action.onClick}
|
|
95
|
+
aria-label={action.ariaLabel || action.label}
|
|
96
|
+
>
|
|
97
|
+
{action.icon || action.label}
|
|
98
|
+
</button>
|
|
99
|
+
</Tooltip>
|
|
100
|
+
))}
|
|
101
|
+
|
|
102
|
+
{showClose && (
|
|
103
|
+
<Tooltip text="Close fullscreen" direction="s">
|
|
104
|
+
<button className={styles.actionBtn} onClick={onClose} aria-label="Close expanded view" autoFocus>
|
|
105
|
+
<ScreenNormalIcon size={12} />
|
|
106
|
+
</button>
|
|
107
|
+
</Tooltip>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* ── ExpandedPaneTopBar — dark per-pane title bar ─────────────────── */
|
|
2
|
+
|
|
3
|
+
.bar {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
height: 40px;
|
|
7
|
+
padding: 0 12px;
|
|
8
|
+
gap: 6px;
|
|
9
|
+
background: #21262d;
|
|
10
|
+
border-bottom: 1px solid #30363d;
|
|
11
|
+
flex-shrink: 0;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
13
|
+
font-size: 12px;
|
|
14
|
+
font-weight: 500;
|
|
15
|
+
user-select: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.widgetIcon {
|
|
19
|
+
flex-shrink: 0;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
color: #8b949e;
|
|
24
|
+
font-size: 12px;
|
|
25
|
+
line-height: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.label {
|
|
29
|
+
white-space: nowrap;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
text-overflow: ellipsis;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
color: #8b949e;
|
|
34
|
+
margin-right: auto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Action buttons — same shape/size as widget toolbar, forced dark mode */
|
|
38
|
+
.actionBtn {
|
|
39
|
+
all: unset;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
flex-shrink: 0;
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
width: 24px;
|
|
46
|
+
height: 24px;
|
|
47
|
+
border-radius: 12px;
|
|
48
|
+
border: 1.6px solid #373e47;
|
|
49
|
+
background: #161b22;
|
|
50
|
+
color: #8b949e;
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
transition: background 100ms, color 100ms, border-color 100ms;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.actionBtn:hover {
|
|
56
|
+
background: #272c33;
|
|
57
|
+
color: #e6edf3;
|
|
58
|
+
border-color: #484f58;
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import ExpandedPaneTopBar from './ExpandedPaneTopBar.jsx'
|
|
4
|
+
|
|
5
|
+
describe('ExpandedPaneTopBar', () => {
|
|
6
|
+
it('renders pane label left-aligned', () => {
|
|
7
|
+
render(
|
|
8
|
+
<ExpandedPaneTopBar label="Terminal · pearl-wren" onClose={vi.fn()} />
|
|
9
|
+
)
|
|
10
|
+
expect(screen.getByText('Terminal · pearl-wren')).toBeTruthy()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('renders close button when showClose is true', () => {
|
|
14
|
+
render(
|
|
15
|
+
<ExpandedPaneTopBar label="Terminal · pearl-wren" showClose onClose={vi.fn()} />
|
|
16
|
+
)
|
|
17
|
+
expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does not render close button when showClose is false', () => {
|
|
21
|
+
render(
|
|
22
|
+
<ExpandedPaneTopBar label="Terminal · pearl-wren" onClose={vi.fn()} />
|
|
23
|
+
)
|
|
24
|
+
expect(screen.queryByLabelText('Close expanded view')).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('calls onClose when close button is clicked', () => {
|
|
28
|
+
const onClose = vi.fn()
|
|
29
|
+
render(
|
|
30
|
+
<ExpandedPaneTopBar label="Terminal · pearl-wren" showClose onClose={onClose} />
|
|
31
|
+
)
|
|
32
|
+
fireEvent.click(screen.getByLabelText('Close expanded view'))
|
|
33
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('has dark background styling', () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<ExpandedPaneTopBar label="Agent · wren" showClose onClose={vi.fn()} />
|
|
39
|
+
)
|
|
40
|
+
const bar = container.firstChild
|
|
41
|
+
expect(bar).toBeTruthy()
|
|
42
|
+
// Bar should have the dark background class
|
|
43
|
+
expect(bar.className).toMatch(/bar/)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
4
3
|
import { readProp } from './widgetProps.js'
|
|
5
4
|
import { schemas } from './widgetConfig.js'
|
|
6
5
|
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
6
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
7
|
+
import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
8
|
+
import ExpandedPane from './ExpandedPane.jsx'
|
|
10
9
|
import styles from './FigmaEmbed.module.css'
|
|
11
10
|
import overlayStyles from './embedOverlay.module.css'
|
|
12
11
|
|
|
@@ -58,7 +57,8 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
|
|
|
58
57
|
|
|
59
58
|
const [interactive, setInteractive] = useState(false)
|
|
60
59
|
const [showIframe, setShowIframe] = useState(true)
|
|
61
|
-
const [
|
|
60
|
+
const [expandMode, setExpandMode] = useState(null)
|
|
61
|
+
const expanded = expandMode !== null
|
|
62
62
|
|
|
63
63
|
const iframeRef = useRef(null)
|
|
64
64
|
const embedRef = useRef(null)
|
|
@@ -107,19 +107,6 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
|
|
|
107
107
|
|
|
108
108
|
useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
|
|
109
109
|
|
|
110
|
-
// Close expanded modal on Escape
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
if (!expanded) return
|
|
113
|
-
function handleKeyDown(e) {
|
|
114
|
-
if (e.key === 'Escape') {
|
|
115
|
-
e.stopPropagation()
|
|
116
|
-
setExpanded(false)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
document.addEventListener('keydown', handleKeyDown, true)
|
|
120
|
-
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
121
|
-
}, [expanded])
|
|
122
|
-
|
|
123
110
|
// Reparent iframe DOM node between inline container and modal.
|
|
124
111
|
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
125
112
|
// browsing context — no reload. Falls back to appendChild.
|
|
@@ -158,9 +145,12 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
|
|
|
158
145
|
handleAction(actionId) {
|
|
159
146
|
if (actionId === 'open-external') {
|
|
160
147
|
if (url) window.open(url, '_blank', 'noopener')
|
|
161
|
-
} else if (actionId === 'expand' || actionId === '
|
|
148
|
+
} else if (actionId === 'expand' || actionId === 'expand-single') {
|
|
149
|
+
setShowIframe(true)
|
|
150
|
+
setExpandMode('single')
|
|
151
|
+
} else if (actionId === 'split-screen') {
|
|
162
152
|
setShowIframe(true)
|
|
163
|
-
|
|
153
|
+
setExpandMode('split')
|
|
164
154
|
}
|
|
165
155
|
},
|
|
166
156
|
}), [url])
|
|
@@ -187,6 +177,7 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
|
|
|
187
177
|
className={styles.iframe}
|
|
188
178
|
title={`Figma ${typeLabel}: ${title}`}
|
|
189
179
|
allowFullScreen
|
|
180
|
+
onLoad={(e) => e.target.blur()}
|
|
190
181
|
/>
|
|
191
182
|
</div>
|
|
192
183
|
) : (
|
|
@@ -248,102 +239,58 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
|
|
|
248
239
|
/>
|
|
249
240
|
)}
|
|
250
241
|
</WidgetWrapper>
|
|
251
|
-
{
|
|
252
|
-
<
|
|
253
|
-
expanded={expanded && !!embedUrl}
|
|
254
|
-
onClose={() => setExpanded(false)}
|
|
255
|
-
modalContainerRef={modalContainerRef}
|
|
242
|
+
{expanded && !!embedUrl && (
|
|
243
|
+
<FigmaExpandPane
|
|
256
244
|
widgetId={widgetId}
|
|
257
|
-
|
|
258
|
-
|
|
245
|
+
modalContainerRef={modalContainerRef}
|
|
246
|
+
splitMode={expandMode === 'split'}
|
|
247
|
+
onClose={() => setExpandMode(null)}
|
|
248
|
+
/>
|
|
259
249
|
)}
|
|
260
250
|
</>
|
|
261
251
|
)
|
|
262
252
|
})
|
|
263
253
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
[hasSplit, widgetId, connectedWidget],
|
|
254
|
+
/**
|
|
255
|
+
* Builds pane configs and renders ExpandedPane for an expanded Figma widget.
|
|
256
|
+
* The primary pane is an external pane that receives the iframe via reparenting.
|
|
257
|
+
*/
|
|
258
|
+
function FigmaExpandPane({ widgetId, modalContainerRef, splitMode, onClose }) {
|
|
259
|
+
const connectedWidgets = useMemo(
|
|
260
|
+
() => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
|
|
261
|
+
[widgetId, splitMode],
|
|
273
262
|
)
|
|
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
263
|
const primaryWidget = useMemo(() => {
|
|
281
264
|
const bridge = window.__storyboardCanvasBridgeState
|
|
282
|
-
return bridge?.widgets?.find((w) => w.id === widgetId) || { type: 'figma-embed', props: {} }
|
|
283
|
-
}, [widgetId
|
|
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
|
|
265
|
+
return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'figma-embed', position: { x: 0, y: 0 }, props: {} }
|
|
266
|
+
}, [widgetId])
|
|
289
267
|
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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>
|
|
268
|
+
const buildPaneFn = useCallback((widget) => {
|
|
269
|
+
if (widget.id === widgetId) {
|
|
270
|
+
return {
|
|
271
|
+
id: widgetId,
|
|
272
|
+
label: getSplitPaneLabel(primaryWidget),
|
|
273
|
+
widgetType: 'figma-embed',
|
|
274
|
+
kind: 'external',
|
|
275
|
+
attach: (container) => {
|
|
276
|
+
modalContainerRef.current = container
|
|
277
|
+
return () => { modalContainerRef.current = null }
|
|
278
|
+
},
|
|
279
|
+
}
|
|
314
280
|
}
|
|
315
|
-
|
|
281
|
+
return buildPaneForWidget(widget)
|
|
282
|
+
}, [widgetId, primaryWidget, modalContainerRef])
|
|
316
283
|
|
|
317
|
-
const
|
|
318
|
-
|
|
284
|
+
const layout = useMemo(
|
|
285
|
+
() => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
|
|
286
|
+
[primaryWidget, connectedWidgets, buildPaneFn],
|
|
287
|
+
)
|
|
319
288
|
|
|
320
289
|
return (
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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>
|
|
290
|
+
<ExpandedPane
|
|
291
|
+
initialLayout={layout}
|
|
292
|
+
variant={layout.flat().length <= 1 ? 'modal' : 'full'}
|
|
293
|
+
onClose={onClose}
|
|
294
|
+
/>
|
|
348
295
|
)
|
|
349
296
|
}
|