@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1
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 +10 -11
- package/src/AuthModal/AuthModal.jsx +6 -8
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +480 -187
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +562 -207
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
- package/src/canvas/CanvasPage.jsx +739 -219
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -165
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/canvasReloadGuard.test.js +1 -1
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +474 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +113 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +73 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +445 -67
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +300 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +74 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +560 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +55 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +48 -20
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/hooks/useSceneData.js +1 -0
- package/src/hooks/useThemeState.test.js +1 -1
- package/src/index.js +8 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +407 -67
- package/src/vite/data-plugin.test.js +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getConnectorDefaults } from './widgets/widgetConfig.js'
|
|
2
|
+
|
|
3
|
+
const connectorConfig = getConnectorDefaults()
|
|
4
|
+
const CONTROL_OFFSET = connectorConfig.controlOffset
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compute the anchor point on a widget's edge.
|
|
8
|
+
* Reads actual DOM dimensions for accuracy (widgets like markdown auto-size).
|
|
9
|
+
* Falls back to props/bounds/defaults if DOM element isn't found.
|
|
10
|
+
*/
|
|
11
|
+
export function getAnchorPoint(widget, anchor) {
|
|
12
|
+
const x = widget.position?.x ?? 0
|
|
13
|
+
const y = widget.position?.y ?? 0
|
|
14
|
+
|
|
15
|
+
let w, h
|
|
16
|
+
const el = typeof document !== 'undefined' ? document.getElementById(widget.id) : null
|
|
17
|
+
if (el) {
|
|
18
|
+
const firstChild = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
19
|
+
if (firstChild) {
|
|
20
|
+
w = firstChild.offsetWidth
|
|
21
|
+
h = firstChild.offsetHeight
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!w) w = widget.props?.width ?? widget.bounds?.width ?? 270
|
|
25
|
+
if (!h) h = widget.props?.height ?? widget.bounds?.height ?? 170
|
|
26
|
+
|
|
27
|
+
switch (anchor) {
|
|
28
|
+
case 'top': return { x: x + w / 2, y }
|
|
29
|
+
case 'bottom': return { x: x + w / 2, y: y + h }
|
|
30
|
+
case 'left': return { x, y: y + h / 2 }
|
|
31
|
+
case 'right': return { x: x + w, y: y + h / 2 }
|
|
32
|
+
default: return { x: x + w / 2, y: y + h / 2 }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute the control point offset direction for an anchor.
|
|
38
|
+
*/
|
|
39
|
+
export function getControlOffset(anchor) {
|
|
40
|
+
switch (anchor) {
|
|
41
|
+
case 'top': return { dx: 0, dy: -CONTROL_OFFSET }
|
|
42
|
+
case 'bottom': return { dx: 0, dy: CONTROL_OFFSET }
|
|
43
|
+
case 'left': return { dx: -CONTROL_OFFSET, dy: 0 }
|
|
44
|
+
case 'right': return { dx: CONTROL_OFFSET, dy: 0 }
|
|
45
|
+
default: return { dx: 0, dy: 0 }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a cubic Bézier path string between two anchor points.
|
|
51
|
+
* When `freeEnd` is true, the end control point is computed from
|
|
52
|
+
* the direction vector (end→start) so the curve never bends in
|
|
53
|
+
* front of the cursor during drag.
|
|
54
|
+
*/
|
|
55
|
+
export function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
|
|
56
|
+
const c1 = getControlOffset(startAnchor)
|
|
57
|
+
let c2
|
|
58
|
+
if (freeEnd) {
|
|
59
|
+
const dx = startPt.x - endPt.x
|
|
60
|
+
const dy = startPt.y - endPt.y
|
|
61
|
+
const dist = Math.hypot(dx, dy) || 1
|
|
62
|
+
const scale = Math.min(CONTROL_OFFSET, dist * 0.4)
|
|
63
|
+
c2 = { dx: (dx / dist) * scale, dy: (dy / dist) * scale }
|
|
64
|
+
} else {
|
|
65
|
+
c2 = getControlOffset(endAnchor)
|
|
66
|
+
}
|
|
67
|
+
return `M ${startPt.x} ${startPt.y} C ${startPt.x + c1.dx} ${startPt.y + c1.dy}, ${endPt.x + c2.dx} ${endPt.y + c2.dy}, ${endPt.x} ${endPt.y}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Evaluate a cubic Bézier curve at parameter t.
|
|
72
|
+
* B(t) = (1-t)³·P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3
|
|
73
|
+
*/
|
|
74
|
+
function evalCubicBezier(p0, p1, p2, p3, t) {
|
|
75
|
+
const mt = 1 - t
|
|
76
|
+
const mt2 = mt * mt
|
|
77
|
+
const mt3 = mt2 * mt
|
|
78
|
+
const t2 = t * t
|
|
79
|
+
const t3 = t2 * t
|
|
80
|
+
return {
|
|
81
|
+
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
|
|
82
|
+
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a connector's Bézier control points from widget positions.
|
|
88
|
+
* Returns { p0, cp1, cp2, p3 } or null if either widget is missing.
|
|
89
|
+
*/
|
|
90
|
+
export function getConnectorControlPoints(connector, widgetMap) {
|
|
91
|
+
const startWidget = widgetMap.get(connector.start?.widgetId)
|
|
92
|
+
const endWidget = widgetMap.get(connector.end?.widgetId)
|
|
93
|
+
if (!startWidget || !endWidget) return null
|
|
94
|
+
|
|
95
|
+
const p0 = getAnchorPoint(startWidget, connector.start.anchor)
|
|
96
|
+
const p3 = getAnchorPoint(endWidget, connector.end.anchor)
|
|
97
|
+
const c1 = getControlOffset(connector.start.anchor)
|
|
98
|
+
const c2 = getControlOffset(connector.end.anchor)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
p0,
|
|
102
|
+
cp1: { x: p0.x + c1.dx, y: p0.y + c1.dy },
|
|
103
|
+
cp2: { x: p3.x + c2.dx, y: p3.y + c2.dy },
|
|
104
|
+
p3,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Test whether any point along a connector's Bézier path falls inside a rect.
|
|
110
|
+
* @param {Object} connector — { start: { widgetId, anchor }, end: { widgetId, anchor } }
|
|
111
|
+
* @param {Map} widgetMap — Map<widgetId, widget>
|
|
112
|
+
* @param {{ x, y, width, height }} rect — axis-aligned selection rectangle
|
|
113
|
+
* @param {number} numSamples — number of evenly-spaced points to test (default 24)
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function connectorIntersectsRect(connector, widgetMap, rect, numSamples = 24) {
|
|
117
|
+
const pts = getConnectorControlPoints(connector, widgetMap)
|
|
118
|
+
if (!pts) return false
|
|
119
|
+
|
|
120
|
+
const { p0, cp1, cp2, p3 } = pts
|
|
121
|
+
const rRight = rect.x + rect.width
|
|
122
|
+
const rBottom = rect.y + rect.height
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i <= numSamples; i++) {
|
|
125
|
+
const t = i / numSamples
|
|
126
|
+
const pt = evalCubicBezier(p0, cp1, cp2, p3, t)
|
|
127
|
+
if (pt.x >= rect.x && pt.x <= rRight && pt.y >= rect.y && pt.y <= rBottom) {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot Pool browser devlogs — listens for server HMR events and
|
|
3
|
+
* logs them to the browser console when DevTools "Dev logs" is on.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getFlag } from '@dfosco/storyboard-core'
|
|
7
|
+
|
|
8
|
+
let registered = false
|
|
9
|
+
|
|
10
|
+
function isDevLogsEnabled() {
|
|
11
|
+
return getFlag('dev-logs')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerHotPoolDevLogs() {
|
|
15
|
+
if (registered) return
|
|
16
|
+
registered = true
|
|
17
|
+
|
|
18
|
+
if (!import.meta.hot) return
|
|
19
|
+
|
|
20
|
+
import.meta.hot.on('storyboard:hot-pool-log', (data) => {
|
|
21
|
+
if (!isDevLogsEnabled()) return
|
|
22
|
+
const poolTag = data.poolId ? `:${data.poolId}` : ''
|
|
23
|
+
console.log(`%c[hot-pool${poolTag}]%c ${data.message}`, 'color: #8b5cf6; font-weight: bold', 'color: inherit')
|
|
24
|
+
})
|
|
25
|
+
}
|
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)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { connectorIntersectsRect } from './connectorGeometry.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Returns the bounding-box list for all widgets on the canvas.
|
|
@@ -45,6 +46,7 @@ function rectsIntersect(a, b) {
|
|
|
45
46
|
* @param {React.RefObject} opts.zoomRef — ref holding current zoom (number 25-200)
|
|
46
47
|
* @param {Function} opts.setSelectedWidgetIds — state setter for selected IDs (Set)
|
|
47
48
|
* @param {Array} opts.widgets — current localWidgets array
|
|
49
|
+
* @param {Array} opts.connectors — current localConnectors array
|
|
48
50
|
* @param {Array} opts.componentEntries — current componentEntries array
|
|
49
51
|
* @param {Object} opts.fallbackSizes — WIDGET_FALLBACK_SIZES map
|
|
50
52
|
* @param {boolean} opts.spaceHeld — whether the space key is pressed (panning)
|
|
@@ -57,6 +59,7 @@ export default function useMarqueeSelect({
|
|
|
57
59
|
zoomRef,
|
|
58
60
|
setSelectedWidgetIds,
|
|
59
61
|
widgets,
|
|
62
|
+
connectors,
|
|
60
63
|
componentEntries,
|
|
61
64
|
fallbackSizes,
|
|
62
65
|
spaceHeld,
|
|
@@ -93,6 +96,8 @@ export default function useMarqueeSelect({
|
|
|
93
96
|
startCanvasY: canvasStart.y,
|
|
94
97
|
startClientX: e.clientX,
|
|
95
98
|
startClientY: e.clientY,
|
|
99
|
+
shiftKey: e.shiftKey,
|
|
100
|
+
altKey: e.altKey,
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
// Minimum drag distance before showing the marquee (avoids flicker on clicks)
|
|
@@ -141,11 +146,11 @@ export default function useMarqueeSelect({
|
|
|
141
146
|
|
|
142
147
|
if (!ms) return
|
|
143
148
|
|
|
144
|
-
// If the user barely moved, treat as a deselect click
|
|
149
|
+
// If the user barely moved, treat as a deselect click (unless shift held)
|
|
145
150
|
const dx = ev.clientX - ms.startClientX
|
|
146
151
|
const dy = ev.clientY - ms.startClientY
|
|
147
152
|
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) {
|
|
148
|
-
setSelectedWidgetIds(new Set())
|
|
153
|
+
if (!ms.shiftKey) setSelectedWidgetIds(new Set())
|
|
149
154
|
return
|
|
150
155
|
}
|
|
151
156
|
|
|
@@ -167,14 +172,35 @@ export default function useMarqueeSelect({
|
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
|
|
175
|
+
// Option+marquee: also select widgets connected by intersected connectors
|
|
176
|
+
if (ms.altKey && connectors?.length) {
|
|
177
|
+
const widgetMap = new Map()
|
|
178
|
+
for (const w of (widgets ?? [])) widgetMap.set(w.id, w)
|
|
179
|
+
for (const conn of connectors) {
|
|
180
|
+
if (connectorIntersectsRect(conn, widgetMap, selRect)) {
|
|
181
|
+
if (conn.start?.widgetId) selected.add(conn.start.widgetId)
|
|
182
|
+
if (conn.end?.widgetId) selected.add(conn.end.widgetId)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Shift+marquee merges with existing selection; plain marquee replaces it
|
|
188
|
+
if (ms.shiftKey) {
|
|
189
|
+
setSelectedWidgetIds((prev) => {
|
|
190
|
+
const merged = new Set(prev)
|
|
191
|
+
for (const id of selected) merged.add(id)
|
|
192
|
+
return merged
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
setSelectedWidgetIds(selected)
|
|
196
|
+
}
|
|
171
197
|
}
|
|
172
198
|
|
|
173
199
|
document.addEventListener('mousemove', handleMove)
|
|
174
200
|
document.addEventListener('mouseup', handleUp)
|
|
175
201
|
window.addEventListener('blur', handleCancel)
|
|
176
202
|
cleanupRef.current = removeListeners
|
|
177
|
-
}, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, componentEntries, fallbackSizes])
|
|
203
|
+
}, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, connectors, componentEntries, fallbackSizes])
|
|
178
204
|
|
|
179
205
|
// Clean up listeners if component unmounts mid-drag
|
|
180
206
|
useEffect(() => {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentSetWidget — renders all exports from a story in a single iframe grid.
|
|
3
|
+
*
|
|
4
|
+
* Instead of N iframes (one per export), this widget loads one iframe pointing
|
|
5
|
+
* to the story's ComponentSetPage. Each export renders in a grid cell inside
|
|
6
|
+
* that single page. The user can select a cell (via label click) which updates
|
|
7
|
+
* `props.selected` — visible to connected agents.
|
|
8
|
+
*
|
|
9
|
+
* Props: { storyId, layout, selected, width, height }
|
|
10
|
+
*/
|
|
11
|
+
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
|
+
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
+
import Icon from '../../Icon.jsx'
|
|
14
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
15
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
16
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
17
|
+
import styles from './ComponentSetWidget.module.css'
|
|
18
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
19
|
+
|
|
20
|
+
function GridIcon({ size = 16 }) {
|
|
21
|
+
return <Icon name="iconoir/view-grid" size={size} />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveComponentSetUrl(storyId, layout, selected) {
|
|
25
|
+
const story = getStoryData(storyId)
|
|
26
|
+
if (!story?._route) return ''
|
|
27
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
28
|
+
const params = new URLSearchParams()
|
|
29
|
+
params.set('_sb_embed', '')
|
|
30
|
+
params.set('_sb_hide_branch_bar', '')
|
|
31
|
+
params.set('_sb_component_set', '')
|
|
32
|
+
if (layout) params.set('layout', layout)
|
|
33
|
+
if (selected) params.set('selected', selected)
|
|
34
|
+
return `${base}${story._route}?${params}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
38
|
+
const storyId = props?.storyId || ''
|
|
39
|
+
const layout = props?.layout || 'horizontal'
|
|
40
|
+
const selected = props?.selected || ''
|
|
41
|
+
const width = props?.width
|
|
42
|
+
const height = props?.height
|
|
43
|
+
|
|
44
|
+
const containerRef = useRef(null)
|
|
45
|
+
const iframeRef = useRef(null)
|
|
46
|
+
const [interactive, setInteractive] = useState(false)
|
|
47
|
+
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
48
|
+
|
|
49
|
+
// Re-resolve when story index is live-patched
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
52
|
+
document.addEventListener('storyboard:story-index-changed', handler)
|
|
53
|
+
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
57
|
+
|
|
58
|
+
// Exit interactive mode when clicking outside
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!interactive) return
|
|
61
|
+
function handlePointerDown(e) {
|
|
62
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
63
|
+
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
64
|
+
if (chromeEl) return
|
|
65
|
+
setInteractive(false)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
69
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
70
|
+
}, [interactive, widgetId])
|
|
71
|
+
|
|
72
|
+
// Listen for selection messages from the embedded ComponentSetPage
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
function handleMessage(e) {
|
|
75
|
+
if (e.source !== iframeRef.current?.contentWindow) return
|
|
76
|
+
if (e.data?.type !== 'storyboard:component-set:select') return
|
|
77
|
+
const newSelected = e.data.exportName || ''
|
|
78
|
+
if (newSelected !== selected) {
|
|
79
|
+
onUpdate?.({ selected: newSelected })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
window.addEventListener('message', handleMessage)
|
|
83
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
84
|
+
}, [selected, onUpdate])
|
|
85
|
+
|
|
86
|
+
const handleResize = useCallback((w, h) => {
|
|
87
|
+
onUpdate?.({ width: w, height: h })
|
|
88
|
+
}, [onUpdate])
|
|
89
|
+
|
|
90
|
+
useImperativeHandle(ref, () => ({
|
|
91
|
+
handleAction(actionId) {
|
|
92
|
+
if (actionId === 'flip-layout') {
|
|
93
|
+
const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
|
|
94
|
+
onUpdate?.({ layout: next })
|
|
95
|
+
return true
|
|
96
|
+
} else if (actionId === 'open-external') {
|
|
97
|
+
const story = getStoryData(storyId)
|
|
98
|
+
if (story?._route) {
|
|
99
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
100
|
+
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
101
|
+
}
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}), [storyId, layout, onUpdate])
|
|
106
|
+
|
|
107
|
+
const iframeSrc = useMemo(
|
|
108
|
+
() => resolveComponentSetUrl(storyId, layout, selected),
|
|
109
|
+
// storyIndexKey forces re-evaluation when HMR mutates the story index
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
[storyId, layout, selected, storyIndexKey],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
useIframeDevLogs({
|
|
115
|
+
widget: 'ComponentSetWidget',
|
|
116
|
+
loaded: interactive && Boolean(iframeSrc),
|
|
117
|
+
src: iframeSrc,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const displayName = storyId || 'Component Set'
|
|
121
|
+
|
|
122
|
+
if (!storyId) {
|
|
123
|
+
return (
|
|
124
|
+
<WidgetWrapper>
|
|
125
|
+
<div className={styles.container} ref={containerRef}>
|
|
126
|
+
<div className={styles.error}>
|
|
127
|
+
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
128
|
+
<span className={styles.errorText}>Missing story ID</span>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</WidgetWrapper>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!iframeSrc) {
|
|
136
|
+
return (
|
|
137
|
+
<WidgetWrapper>
|
|
138
|
+
<div className={styles.container} ref={containerRef}>
|
|
139
|
+
<div className={styles.error}>
|
|
140
|
+
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
141
|
+
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</WidgetWrapper>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const sizeStyle = {}
|
|
149
|
+
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
150
|
+
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<WidgetWrapper>
|
|
154
|
+
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
155
|
+
<div className={styles.header}>
|
|
156
|
+
<span className={styles.headerIcon}><GridIcon size={16} /></span>
|
|
157
|
+
<span className={styles.headerTitle}>{displayName}</span>
|
|
158
|
+
{selected && (
|
|
159
|
+
<span className={styles.headerSelected}>· {selected}</span>
|
|
160
|
+
)}
|
|
161
|
+
<span className={styles.headerLayout} title={`Layout: ${layout}`}>
|
|
162
|
+
{layout === 'horizontal' ? '⇔' : '⇕'}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div className={styles.content}>
|
|
166
|
+
<iframe
|
|
167
|
+
ref={iframeRef}
|
|
168
|
+
src={iframeSrc}
|
|
169
|
+
className={styles.iframe}
|
|
170
|
+
title={`${displayName} component set`}
|
|
171
|
+
onLoad={(e) => e.target.blur()}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
{!interactive && (
|
|
175
|
+
<div
|
|
176
|
+
className={overlayStyles.interactOverlay}
|
|
177
|
+
onClick={(e) => {
|
|
178
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
179
|
+
enterInteractive()
|
|
180
|
+
}}
|
|
181
|
+
role="button"
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
onKeyDown={(e) => {
|
|
184
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
e.stopPropagation()
|
|
187
|
+
enterInteractive()
|
|
188
|
+
}
|
|
189
|
+
}}
|
|
190
|
+
aria-label="Click to interact"
|
|
191
|
+
>
|
|
192
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
{resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
197
|
+
</WidgetWrapper>
|
|
198
|
+
)
|
|
199
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* ComponentSetWidget — canvas widget chrome */
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
position: relative;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
min-width: 200px;
|
|
7
|
+
min-height: 120px;
|
|
8
|
+
background: var(--bgColor-default, #ffffff);
|
|
9
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
10
|
+
border-radius: 12px;
|
|
11
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.header {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 6px;
|
|
20
|
+
padding: 10px 10px;
|
|
21
|
+
font-size: 12px;
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
color: var(--fgColor-muted, #656d76);
|
|
24
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
25
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
text-overflow: ellipsis;
|
|
29
|
+
user-select: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.headerIcon {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.headerTitle {
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
text-overflow: ellipsis;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.headerSelected {
|
|
43
|
+
color: var(--fgColor-accent, #0969da);
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
flex-shrink: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.headerLayout {
|
|
49
|
+
margin-left: auto;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
opacity: 0.5;
|
|
52
|
+
flex-shrink: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.content {
|
|
56
|
+
position: relative;
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: calc(100% - 37px);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.iframe {
|
|
62
|
+
position: absolute;
|
|
63
|
+
inset: 0;
|
|
64
|
+
display: block;
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 100%;
|
|
67
|
+
border: none;
|
|
68
|
+
z-index: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.error {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 8px;
|
|
75
|
+
padding: 16px;
|
|
76
|
+
color: var(--fgColor-danger, #cf222e);
|
|
77
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
line-height: 1.5;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.errorIcon {
|
|
83
|
+
font-size: 20px;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.errorText {
|
|
88
|
+
word-break: break-word;
|
|
89
|
+
}
|