@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
|
@@ -103,25 +103,23 @@
|
|
|
103
103
|
|
|
104
104
|
/* Each widget gets its own stacking context so internal z-index
|
|
105
105
|
(chrome toolbars, resize handles, overlays) never leaks to siblings.
|
|
106
|
-
|
|
107
|
-
widgets are
|
|
106
|
+
z-index: 2 keeps widgets above the ConnectorLayer (z-index: 1).
|
|
107
|
+
Selected widgets are raised via z-index on the .tc-drag wrapper
|
|
108
|
+
(data-widget-raised) instead of DOM reordering — this prevents
|
|
109
|
+
iframe widgets from remounting when selection changes.
|
|
110
|
+
The actively dragged widget (.tc-on from neodrag) floats above
|
|
111
|
+
everything so it always passes over siblings during movement. */
|
|
108
112
|
:global(.tc-drag) {
|
|
109
113
|
isolation: isolate;
|
|
114
|
+
z-index: 2;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
font-size: 13px;
|
|
119
|
-
font-weight: 600;
|
|
120
|
-
border-radius: 6px;
|
|
121
|
-
letter-spacing: 0.01em;
|
|
122
|
-
white-space: nowrap;
|
|
123
|
-
pointer-events: none;
|
|
124
|
-
user-select: none;
|
|
117
|
+
:global(.tc-drag:has([data-widget-raised])) {
|
|
118
|
+
z-index: 3;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
:global(.tc-drag.tc-on) {
|
|
122
|
+
z-index: 4;
|
|
125
123
|
}
|
|
126
124
|
|
|
127
125
|
.ghInstallBanner {
|
|
@@ -89,12 +89,23 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
89
89
|
getDefaults: () => ({}),
|
|
90
90
|
}))
|
|
91
91
|
|
|
92
|
-
vi.mock('./widgets/widgetConfig.js', () =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
vi.mock('./widgets/widgetConfig.js', async () => {
|
|
93
|
+
const actual = await vi.importActual('./widgets/widgetConfig.js')
|
|
94
|
+
return {
|
|
95
|
+
getFeatures: () => [],
|
|
96
|
+
isResizable: () => false,
|
|
97
|
+
isExpandable: () => false,
|
|
98
|
+
isSplitScreenCapable: () => false,
|
|
99
|
+
getInteractGate: () => ({ enabled: false, label: 'Click to interact' }),
|
|
100
|
+
getWidgetMeta: () => null,
|
|
101
|
+
getConnectorConfig: actual.getConnectorConfig,
|
|
102
|
+
getAnchorState: actual.getAnchorState,
|
|
103
|
+
canAcceptConnection: () => true,
|
|
104
|
+
schemas: {},
|
|
105
|
+
getMenuWidgetTypes: () => [],
|
|
106
|
+
getConnectorDefaults: actual.getConnectorDefaults,
|
|
107
|
+
}
|
|
108
|
+
})
|
|
98
109
|
|
|
99
110
|
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
100
111
|
isFigmaUrl: () => false,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useMemo, useCallback } from 'react'
|
|
2
2
|
import styles from './ConnectorLayer.module.css'
|
|
3
3
|
import { getConnectorDefaults, getConnectorConfig } from './widgets/widgetConfig.js'
|
|
4
|
+
import { getAnchorPoint, buildPath } from './connectorGeometry.js'
|
|
4
5
|
|
|
5
6
|
const connectorConfig = getConnectorDefaults()
|
|
6
|
-
const CONTROL_OFFSET = connectorConfig.controlOffset
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Get the effective endpoint style for a widget, merging per-widget-type
|
|
@@ -19,87 +19,6 @@ function getEndpointStyle(widgetType, side) {
|
|
|
19
19
|
return connectorConfig[key]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Compute the anchor point on a widget's edge.
|
|
24
|
-
* Reads actual DOM dimensions for accuracy (widgets like markdown auto-size).
|
|
25
|
-
* Falls back to props/bounds/defaults if DOM element isn't found.
|
|
26
|
-
*/
|
|
27
|
-
function getAnchorPoint(widget, anchor) {
|
|
28
|
-
const x = widget.position?.x ?? 0
|
|
29
|
-
const y = widget.position?.y ?? 0
|
|
30
|
-
|
|
31
|
-
// Try to read actual rendered dimensions from DOM
|
|
32
|
-
let w, h
|
|
33
|
-
const el = document.getElementById(widget.id)
|
|
34
|
-
if (el) {
|
|
35
|
-
// The widget element uses CSS translate for positioning;
|
|
36
|
-
// its offsetWidth/Height give the actual rendered size
|
|
37
|
-
const firstChild = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
38
|
-
if (firstChild) {
|
|
39
|
-
w = firstChild.offsetWidth
|
|
40
|
-
h = firstChild.offsetHeight
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// Fallback to data
|
|
44
|
-
if (!w) w = widget.props?.width ?? widget.bounds?.width ?? 270
|
|
45
|
-
if (!h) h = widget.props?.height ?? widget.bounds?.height ?? 170
|
|
46
|
-
|
|
47
|
-
switch (anchor) {
|
|
48
|
-
case 'top': return { x: x + w / 2, y }
|
|
49
|
-
case 'bottom': return { x: x + w / 2, y: y + h }
|
|
50
|
-
case 'left': return { x, y: y + h / 2 }
|
|
51
|
-
case 'right': return { x: x + w, y: y + h / 2 }
|
|
52
|
-
default: return { x: x + w / 2, y: y + h / 2 }
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Compute the control point offset direction for an anchor.
|
|
58
|
-
*/
|
|
59
|
-
function getControlOffset(anchor) {
|
|
60
|
-
switch (anchor) {
|
|
61
|
-
case 'top': return { dx: 0, dy: -CONTROL_OFFSET }
|
|
62
|
-
case 'bottom': return { dx: 0, dy: CONTROL_OFFSET }
|
|
63
|
-
case 'left': return { dx: -CONTROL_OFFSET, dy: 0 }
|
|
64
|
-
case 'right': return { dx: CONTROL_OFFSET, dy: 0 }
|
|
65
|
-
default: return { dx: 0, dy: 0 }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const DOT_OUTSET = 8
|
|
70
|
-
|
|
71
|
-
function getDotOffset(anchor) {
|
|
72
|
-
switch (anchor) {
|
|
73
|
-
case 'top': return { dx: 0, dy: -DOT_OUTSET }
|
|
74
|
-
case 'bottom': return { dx: 0, dy: DOT_OUTSET }
|
|
75
|
-
case 'left': return { dx: -DOT_OUTSET, dy: 0 }
|
|
76
|
-
case 'right': return { dx: DOT_OUTSET, dy: 0 }
|
|
77
|
-
default: return { dx: 0, dy: 0 }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Build a cubic Bézier path string between two anchor points.
|
|
83
|
-
* When `freeEnd` is true, the end control point is computed from
|
|
84
|
-
* the direction vector (end→start) so the curve never bends in
|
|
85
|
-
* front of the cursor during drag.
|
|
86
|
-
*/
|
|
87
|
-
function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
|
|
88
|
-
const c1 = getControlOffset(startAnchor)
|
|
89
|
-
let c2
|
|
90
|
-
if (freeEnd) {
|
|
91
|
-
// Point the end control toward the start so the curve approaches naturally
|
|
92
|
-
const dx = startPt.x - endPt.x
|
|
93
|
-
const dy = startPt.y - endPt.y
|
|
94
|
-
const dist = Math.hypot(dx, dy) || 1
|
|
95
|
-
const scale = Math.min(CONTROL_OFFSET, dist * 0.4)
|
|
96
|
-
c2 = { dx: (dx / dist) * scale, dy: (dy / dist) * scale }
|
|
97
|
-
} else {
|
|
98
|
-
c2 = getControlOffset(endAnchor)
|
|
99
|
-
}
|
|
100
|
-
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}`
|
|
101
|
-
}
|
|
102
|
-
|
|
103
22
|
/**
|
|
104
23
|
* Render an endpoint shape (circle, arrow-start, arrow-end, or none) at the given point.
|
|
105
24
|
* - "circle" (default): filled dot
|
|
@@ -111,48 +30,88 @@ function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
|
|
|
111
30
|
* @param {Object} startPt — position of the connector's start endpoint
|
|
112
31
|
* @param {Object} endPt — position of the connector's end endpoint
|
|
113
32
|
*/
|
|
33
|
+
const ENDPOINT_HIT_PADDING = 8
|
|
34
|
+
|
|
114
35
|
function EndpointShape({ x, y, startPt, endPt, style, onPointerDown }) {
|
|
36
|
+
const passThrough = !onPointerDown ? { pointerEvents: 'none' } : {}
|
|
37
|
+
const hitRadius = connectorConfig.endpointRadius + ENDPOINT_HIT_PADDING
|
|
38
|
+
const hitTarget = onPointerDown ? (
|
|
39
|
+
<circle cx={x} cy={y} r={hitRadius}
|
|
40
|
+
className={styles.endpointHitArea}
|
|
41
|
+
onPointerDown={onPointerDown}
|
|
42
|
+
/>
|
|
43
|
+
) : null
|
|
115
44
|
if (style === 'none') {
|
|
116
45
|
return (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
46
|
+
<>
|
|
47
|
+
{hitTarget}
|
|
48
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
49
|
+
style={{ fill: 'transparent', stroke: 'none', ...(onPointerDown ? { pointerEvents: 'none' } : { pointerEvents: 'none' }) }}
|
|
50
|
+
/>
|
|
51
|
+
</>
|
|
121
52
|
)
|
|
122
53
|
}
|
|
123
54
|
if (style === 'arrow-start' || style === 'arrow-end') {
|
|
124
55
|
const size = connectorConfig.endpointRadius * 2.2
|
|
125
|
-
// Determine which point the arrow should aim toward
|
|
126
56
|
const target = style === 'arrow-start' ? startPt : endPt
|
|
127
57
|
const dx = target.x - x
|
|
128
58
|
const dy = target.y - y
|
|
129
|
-
// atan2 gives angle from positive X axis; polygon tip points up (-Y), so offset by 90°
|
|
130
59
|
const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
|
|
131
60
|
return (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
61
|
+
<>
|
|
62
|
+
{hitTarget}
|
|
63
|
+
<polygon
|
|
64
|
+
points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
|
|
65
|
+
transform={`translate(${x},${y}) rotate(${rotation})`}
|
|
66
|
+
className={styles.connectorEndpoint}
|
|
67
|
+
style={passThrough}
|
|
68
|
+
/>
|
|
69
|
+
</>
|
|
138
70
|
)
|
|
139
71
|
}
|
|
140
|
-
// Default: circle
|
|
141
72
|
return (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
73
|
+
<>
|
|
74
|
+
{hitTarget}
|
|
75
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
76
|
+
className={styles.connectorEndpoint}
|
|
77
|
+
style={passThrough}
|
|
78
|
+
/>
|
|
79
|
+
</>
|
|
146
80
|
)
|
|
147
81
|
}
|
|
148
82
|
|
|
83
|
+
/** Shared inline style for both SVG layers (CSS vars for connector theming). */
|
|
84
|
+
const svgLayerStyle = {
|
|
85
|
+
width: '100000px',
|
|
86
|
+
height: '100000px',
|
|
87
|
+
'--connector-stroke': connectorConfig.stroke,
|
|
88
|
+
'--connector-stroke-width': `${connectorConfig.strokeWidth}px`,
|
|
89
|
+
'--connector-hover-stroke': connectorConfig.hoverStroke,
|
|
90
|
+
'--connector-hover-stroke-width': `${connectorConfig.hoverStrokeWidth}px`,
|
|
91
|
+
'--connector-endpoint-fill': connectorConfig.endpointFill,
|
|
92
|
+
'--connector-endpoint-stroke': connectorConfig.endpointStroke,
|
|
93
|
+
'--connector-endpoint-stroke-width': `${connectorConfig.endpointStrokeWidth}px`,
|
|
94
|
+
'--connector-hit-area-width': `${connectorConfig.hitAreaStrokeWidth}px`,
|
|
95
|
+
'--connector-drag-stroke': connectorConfig.dragStroke,
|
|
96
|
+
'--connector-drag-stroke-width': `${connectorConfig.dragStrokeWidth}px`,
|
|
97
|
+
'--connector-drag-dasharray': connectorConfig.dragDasharray,
|
|
98
|
+
'--connector-drag-opacity': connectorConfig.dragOpacity,
|
|
99
|
+
}
|
|
100
|
+
|
|
149
101
|
/**
|
|
150
|
-
* SVG overlay
|
|
102
|
+
* Two-layer SVG overlay for connector lines and endpoints.
|
|
103
|
+
*
|
|
104
|
+
* Stacking (bottom → top):
|
|
105
|
+
* connectorLayer (z-index 1) — paths, hit areas, broadcast animations
|
|
106
|
+
* widgets (z-index 2/3) — normal / selected
|
|
107
|
+
* endpointLayer (z-index 4) — anchor dots & drag targets
|
|
108
|
+
*
|
|
151
109
|
* Must be placed inside the same zoom-transformed container as widgets.
|
|
152
110
|
*/
|
|
153
111
|
export default function ConnectorLayer({
|
|
154
112
|
connectors = [],
|
|
155
113
|
widgets = [],
|
|
114
|
+
selectedWidgetIds,
|
|
156
115
|
onRemove,
|
|
157
116
|
onEndpointDrag,
|
|
158
117
|
dragPreview,
|
|
@@ -171,81 +130,78 @@ export default function ConnectorLayer({
|
|
|
171
130
|
onRemove?.(connectorId)
|
|
172
131
|
}, [onRemove])
|
|
173
132
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}}
|
|
193
|
-
>
|
|
194
|
-
{connectors.map((conn) => {
|
|
195
|
-
const startWidget = widgetMap.get(conn.start?.widgetId)
|
|
196
|
-
const endWidget = widgetMap.get(conn.end?.widgetId)
|
|
197
|
-
if (!startWidget || !endWidget) return null
|
|
198
|
-
|
|
199
|
-
const startPt = getAnchorPoint(startWidget, conn.start.anchor)
|
|
200
|
-
const endPt = getAnchorPoint(endWidget, conn.end.anchor)
|
|
201
|
-
const d = buildPath(startPt, conn.start.anchor, endPt, conn.end.anchor)
|
|
202
|
-
const startStyle = getEndpointStyle(startWidget.type, 'start')
|
|
203
|
-
const endStyle = getEndpointStyle(endWidget.type, 'end')
|
|
133
|
+
// Pre-compute connector geometry once, shared by both SVG layers
|
|
134
|
+
const resolved = useMemo(() => connectors.map((conn) => {
|
|
135
|
+
const startWidget = widgetMap.get(conn.start?.widgetId)
|
|
136
|
+
const endWidget = widgetMap.get(conn.end?.widgetId)
|
|
137
|
+
if (!startWidget || !endWidget) return null
|
|
138
|
+
const startPt = getAnchorPoint(startWidget, conn.start.anchor)
|
|
139
|
+
const endPt = getAnchorPoint(endWidget, conn.end.anchor)
|
|
140
|
+
const d = buildPath(startPt, conn.start.anchor, endPt, conn.end.anchor)
|
|
141
|
+
const startStyle = getEndpointStyle(startWidget.type, 'start')
|
|
142
|
+
const endStyle = getEndpointStyle(endWidget.type, 'end')
|
|
143
|
+
const isBroadcast = conn.meta?.messagingMode === 'two-way'
|
|
144
|
+
const startSelected = selectedWidgetIds?.has(conn.start?.widgetId)
|
|
145
|
+
const endSelected = selectedWidgetIds?.has(conn.end?.widgetId)
|
|
146
|
+
const reverseAnim = endSelected && !startSelected
|
|
147
|
+
return { conn, startPt, endPt, d, startStyle, endStyle, isBroadcast, reverseAnim }
|
|
148
|
+
}).filter(Boolean), [connectors, widgetMap, selectedWidgetIds])
|
|
149
|
+
|
|
150
|
+
const hiddenClass = hidden ? styles.connectorLayerHidden : ''
|
|
204
151
|
|
|
205
|
-
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
{/* Back layer: connector paths (behind widgets) */}
|
|
155
|
+
<svg
|
|
156
|
+
className={`${styles.connectorLayer} ${hiddenClass}`}
|
|
157
|
+
style={svgLayerStyle}
|
|
158
|
+
>
|
|
159
|
+
{resolved.map(({ conn, d, isBroadcast, reverseAnim }) => (
|
|
206
160
|
<g key={conn.id}>
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
className={styles.
|
|
211
|
-
onClick={(e) => handleClick(e, conn.id)}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
className={styles.connectorPath}
|
|
217
|
-
onClick={(e) => handleClick(e, conn.id)}
|
|
218
|
-
/>
|
|
219
|
-
{/* Endpoint shapes — draggable to reconnect or remove */}
|
|
220
|
-
<EndpointShape x={startPt.x} y={startPt.y} startPt={startPt} endPt={endPt} style={startStyle}
|
|
221
|
-
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'start', e) } : undefined}
|
|
222
|
-
/>
|
|
223
|
-
<EndpointShape x={endPt.x} y={endPt.y} startPt={startPt} endPt={endPt} style={endStyle}
|
|
224
|
-
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'end', e) } : undefined}
|
|
225
|
-
/>
|
|
161
|
+
<path d={d} className={styles.connectorPathHitArea}
|
|
162
|
+
onClick={(e) => handleClick(e, conn.id)} />
|
|
163
|
+
<path d={d}
|
|
164
|
+
className={`${styles.connectorPath}${isBroadcast ? ` ${styles.connectorBroadcast}` : ''}`}
|
|
165
|
+
onClick={(e) => handleClick(e, conn.id)} />
|
|
166
|
+
{isBroadcast && (
|
|
167
|
+
<path d={d}
|
|
168
|
+
className={`${styles.broadcastFlow}${reverseAnim ? ` ${styles.broadcastFlowReverse}` : ''}`} />
|
|
169
|
+
)}
|
|
226
170
|
</g>
|
|
227
|
-
)
|
|
228
|
-
})}
|
|
171
|
+
))}
|
|
229
172
|
|
|
230
|
-
|
|
231
|
-
{dragPreview && (
|
|
232
|
-
<>
|
|
173
|
+
{dragPreview && (
|
|
233
174
|
<path
|
|
234
175
|
d={buildPath(
|
|
235
|
-
dragPreview.startPt,
|
|
236
|
-
dragPreview.startAnchor,
|
|
237
|
-
dragPreview.endPt,
|
|
238
|
-
dragPreview.endAnchor || dragPreview.startAnchor,
|
|
176
|
+
dragPreview.startPt, dragPreview.startAnchor,
|
|
177
|
+
dragPreview.endPt, dragPreview.endAnchor || dragPreview.startAnchor,
|
|
239
178
|
!dragPreview.snapTarget,
|
|
240
179
|
)}
|
|
241
180
|
className={dragPreview.snapTarget ? styles.connectorPath : styles.dragPreviewPath}
|
|
242
181
|
/>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
182
|
+
)}
|
|
183
|
+
</svg>
|
|
184
|
+
|
|
185
|
+
{/* Front layer: endpoint anchors (above widgets) */}
|
|
186
|
+
<svg
|
|
187
|
+
className={`${styles.endpointLayer} ${hiddenClass}`}
|
|
188
|
+
style={svgLayerStyle}
|
|
189
|
+
>
|
|
190
|
+
{resolved.map(({ conn, startPt, endPt, startStyle, endStyle }) => (
|
|
191
|
+
<g key={conn.id}>
|
|
192
|
+
<EndpointShape x={startPt.x} y={startPt.y} startPt={startPt} endPt={endPt} style={startStyle}
|
|
193
|
+
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'start', e) } : undefined} />
|
|
194
|
+
<EndpointShape x={endPt.x} y={endPt.y} startPt={startPt} endPt={endPt} style={endStyle}
|
|
195
|
+
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'end', e) } : undefined} />
|
|
196
|
+
</g>
|
|
197
|
+
))}
|
|
198
|
+
|
|
199
|
+
{dragPreview?.snapTarget && (
|
|
200
|
+
<EndpointShape x={dragPreview.endPt.x} y={dragPreview.endPt.y}
|
|
201
|
+
startPt={dragPreview.startPt} endPt={dragPreview.endPt} style={connectorConfig.endEndpoint} />
|
|
202
|
+
)}
|
|
203
|
+
</svg>
|
|
204
|
+
</>
|
|
249
205
|
)
|
|
250
206
|
}
|
|
251
207
|
|
|
@@ -15,6 +15,19 @@
|
|
|
15
15
|
pointer-events: none;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* Endpoint anchors sit above widgets so they're always visible and interactive */
|
|
19
|
+
.endpointLayer {
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 0;
|
|
22
|
+
left: 0;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
overflow: visible;
|
|
27
|
+
z-index: 4;
|
|
28
|
+
transition: opacity 0.2s ease;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
.connectorPath {
|
|
19
32
|
fill: none;
|
|
20
33
|
stroke: var(--connector-stroke, var(--fgColor-accent, #0969da));
|
|
@@ -42,6 +55,19 @@
|
|
|
42
55
|
filter: drop-shadow(0 0 4px var(--connector-endpoint-fill, var(--fgColor-accent, #0969da)));
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
/* Invisible expanded hit area around connector endpoints */
|
|
59
|
+
.endpointHitArea {
|
|
60
|
+
fill: transparent;
|
|
61
|
+
stroke: none;
|
|
62
|
+
pointer-events: auto;
|
|
63
|
+
cursor: crosshair;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Glow effect when hovering endpoint hit area */
|
|
67
|
+
.endpointHitArea:hover ~ .connectorEndpoint {
|
|
68
|
+
filter: drop-shadow(0 0 4px var(--connector-endpoint-fill, var(--fgColor-accent, #0969da)));
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
.connectorPathHitArea {
|
|
46
72
|
fill: none;
|
|
47
73
|
stroke: transparent;
|
|
@@ -50,6 +76,12 @@
|
|
|
50
76
|
cursor: pointer;
|
|
51
77
|
}
|
|
52
78
|
|
|
79
|
+
/* Trigger the visible connector's hover style from the wider hit area */
|
|
80
|
+
.connectorPathHitArea:hover ~ .connectorPath {
|
|
81
|
+
stroke: var(--connector-hover-stroke, var(--fgColor-danger, #cf222e));
|
|
82
|
+
stroke-width: var(--connector-hover-stroke-width, 5px);
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
.dragPreviewPath {
|
|
54
86
|
fill: none;
|
|
55
87
|
stroke: var(--connector-drag-stroke, var(--fgColor-accent, #0969da));
|
|
@@ -58,3 +90,40 @@
|
|
|
58
90
|
pointer-events: none;
|
|
59
91
|
opacity: var(--connector-drag-opacity, 0.7);
|
|
60
92
|
}
|
|
93
|
+
|
|
94
|
+
/* Broadcast-active connector — transparent base, 50% wider */
|
|
95
|
+
.connectorBroadcast {
|
|
96
|
+
stroke: transparent;
|
|
97
|
+
stroke-width: 6px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.connectorPathHitArea:hover ~ .connectorBroadcast {
|
|
101
|
+
stroke: var(--connector-hover-stroke, var(--fgColor-danger, #cf222e));
|
|
102
|
+
stroke-width: var(--connector-hover-stroke-width, 5px);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Flowing dots animation along broadcast connectors */
|
|
106
|
+
.broadcastFlow {
|
|
107
|
+
fill: none;
|
|
108
|
+
stroke: var(--bgColor-accent-emphasis, #0969da);
|
|
109
|
+
stroke-width: 6px;
|
|
110
|
+
stroke-dasharray: 3 17;
|
|
111
|
+
stroke-linecap: round;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
animation: broadcastFlowAnim 0.8s linear infinite;
|
|
114
|
+
transition: opacity 0.15s ease;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Hide dots on hover so the red delete indicator shows cleanly */
|
|
118
|
+
.connectorPathHitArea:hover ~ .broadcastFlow {
|
|
119
|
+
opacity: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.broadcastFlowReverse {
|
|
123
|
+
animation-direction: reverse;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@keyframes broadcastFlowAnim {
|
|
127
|
+
from { stroke-dashoffset: 0; }
|
|
128
|
+
to { stroke-dashoffset: -20; }
|
|
129
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { describe, it, expect, beforeEach } 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/canvasApi.js
CHANGED
|
@@ -28,8 +28,8 @@ export function createCanvas(data) {
|
|
|
28
28
|
return request('/create', 'POST', data)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function updateCanvas(canvasId, { widgets, sources, settings }) {
|
|
32
|
-
return request('/update', 'PUT', { name: canvasId, widgets, sources, settings })
|
|
31
|
+
export function updateCanvas(canvasId, { widgets, sources, settings, connectors }) {
|
|
32
|
+
return request('/update', 'PUT', { name: canvasId, widgets, sources, settings, connectors })
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function addWidget(canvasId, { type, props, position }) {
|
|
@@ -50,6 +50,68 @@ export function toggleImagePrivacy(filename) {
|
|
|
50
50
|
return request('/image/toggle-private', 'POST', { filename })
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function duplicateImage(filename) {
|
|
54
|
+
return request('/image/duplicate', 'POST', { filename })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Crop an image client-side and upload the result.
|
|
59
|
+
* @param {string} imageSrc — current image filename (e.g. "canvas--2026-01-01--12-00-00.png")
|
|
60
|
+
* @param {{ x: number, y: number, width: number, height: number }} cropRect — crop region in natural image pixels
|
|
61
|
+
* @param {string} canvasId — canvas name for directory resolution
|
|
62
|
+
* @returns {Promise<{ success: boolean, filename: string }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function cropAndUpload(imageSrc, cropRect, canvasId) {
|
|
65
|
+
const imageUrl = (() => {
|
|
66
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
67
|
+
return `${base}/_storyboard/canvas/images/${imageSrc}`
|
|
68
|
+
})()
|
|
69
|
+
|
|
70
|
+
// Load the image into an offscreen canvas
|
|
71
|
+
const img = new Image()
|
|
72
|
+
img.crossOrigin = 'anonymous'
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
img.onload = resolve
|
|
75
|
+
img.onerror = reject
|
|
76
|
+
img.src = imageUrl
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Draw the cropped region
|
|
80
|
+
const canvas = document.createElement('canvas')
|
|
81
|
+
canvas.width = Math.round(cropRect.width)
|
|
82
|
+
canvas.height = Math.round(cropRect.height)
|
|
83
|
+
const ctx = canvas.getContext('2d')
|
|
84
|
+
ctx.drawImage(
|
|
85
|
+
img,
|
|
86
|
+
Math.round(cropRect.x), Math.round(cropRect.y),
|
|
87
|
+
Math.round(cropRect.width), Math.round(cropRect.height),
|
|
88
|
+
0, 0,
|
|
89
|
+
canvas.width, canvas.height,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Determine output format from original filename
|
|
93
|
+
const ext = imageSrc.split('.').pop()?.toLowerCase() || 'png'
|
|
94
|
+
const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
95
|
+
const mime = mimeMap[ext] || 'image/png'
|
|
96
|
+
const dataUrl = canvas.toDataURL(mime, 0.92)
|
|
97
|
+
|
|
98
|
+
// Build cropped filename: strip any previous --cropped-- suffix, append new one
|
|
99
|
+
const privacyPrefix = imageSrc.startsWith('~') ? '~' : ''
|
|
100
|
+
const baseName = imageSrc.replace(/^~/, '')
|
|
101
|
+
const withoutCrop = baseName.replace(/--cropped--\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2}/, '')
|
|
102
|
+
const nameWithoutExt = withoutCrop.replace(/\.\w+$/, '')
|
|
103
|
+
const now = new Date()
|
|
104
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
105
|
+
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
106
|
+
const croppedFilename = `${privacyPrefix}${nameWithoutExt}--cropped--${ts}.${ext}`
|
|
107
|
+
|
|
108
|
+
return uploadImage(dataUrl, canvasId, croppedFilename)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function batchOperations(canvasId, operations) {
|
|
112
|
+
return request('/batch', 'POST', { name: canvasId, operations })
|
|
113
|
+
}
|
|
114
|
+
|
|
53
115
|
export function getCanvas(canvasId) {
|
|
54
116
|
return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
|
|
55
117
|
}
|
|
@@ -96,3 +158,7 @@ export function addConnector(canvasId, { startWidgetId, startAnchor, endWidgetId
|
|
|
96
158
|
export function removeConnector(canvasId, connectorId) {
|
|
97
159
|
return request('/connector', 'DELETE', { name: canvasId, connectorId })
|
|
98
160
|
}
|
|
161
|
+
|
|
162
|
+
export function updateConnector(canvasId, connectorId, meta) {
|
|
163
|
+
return request('/connector', 'PATCH', { name: canvasId, connectorId, meta })
|
|
164
|
+
}
|