@dfosco/storyboard-react 4.2.0-beta.1 → 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 +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- 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 +478 -186
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +561 -191
- 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 +10 -6
- package/src/canvas/CanvasPage.jsx +738 -216
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -153
- 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/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 +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 +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 +112 -4
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +164 -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 -38
- 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 +72 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +496 -69
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +73 -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 +557 -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 +47 -19
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- 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 +324 -30
|
@@ -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,53 +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
22
|
const DOT_OUTSET = 8
|
|
70
23
|
|
|
71
24
|
function getDotOffset(anchor) {
|
|
@@ -78,28 +31,6 @@ function getDotOffset(anchor) {
|
|
|
78
31
|
}
|
|
79
32
|
}
|
|
80
33
|
|
|
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
34
|
/**
|
|
104
35
|
* Render an endpoint shape (circle, arrow-start, arrow-end, or none) at the given point.
|
|
105
36
|
* - "circle" (default): filled dot
|
|
@@ -111,48 +42,88 @@ function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
|
|
|
111
42
|
* @param {Object} startPt — position of the connector's start endpoint
|
|
112
43
|
* @param {Object} endPt — position of the connector's end endpoint
|
|
113
44
|
*/
|
|
45
|
+
const ENDPOINT_HIT_PADDING = 8
|
|
46
|
+
|
|
114
47
|
function EndpointShape({ x, y, startPt, endPt, style, onPointerDown }) {
|
|
48
|
+
const passThrough = !onPointerDown ? { pointerEvents: 'none' } : {}
|
|
49
|
+
const hitRadius = connectorConfig.endpointRadius + ENDPOINT_HIT_PADDING
|
|
50
|
+
const hitTarget = onPointerDown ? (
|
|
51
|
+
<circle cx={x} cy={y} r={hitRadius}
|
|
52
|
+
className={styles.endpointHitArea}
|
|
53
|
+
onPointerDown={onPointerDown}
|
|
54
|
+
/>
|
|
55
|
+
) : null
|
|
115
56
|
if (style === 'none') {
|
|
116
57
|
return (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
58
|
+
<>
|
|
59
|
+
{hitTarget}
|
|
60
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
61
|
+
style={{ fill: 'transparent', stroke: 'none', ...(onPointerDown ? { pointerEvents: 'none' } : { pointerEvents: 'none' }) }}
|
|
62
|
+
/>
|
|
63
|
+
</>
|
|
121
64
|
)
|
|
122
65
|
}
|
|
123
66
|
if (style === 'arrow-start' || style === 'arrow-end') {
|
|
124
67
|
const size = connectorConfig.endpointRadius * 2.2
|
|
125
|
-
// Determine which point the arrow should aim toward
|
|
126
68
|
const target = style === 'arrow-start' ? startPt : endPt
|
|
127
69
|
const dx = target.x - x
|
|
128
70
|
const dy = target.y - y
|
|
129
|
-
// atan2 gives angle from positive X axis; polygon tip points up (-Y), so offset by 90°
|
|
130
71
|
const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
|
|
131
72
|
return (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
73
|
+
<>
|
|
74
|
+
{hitTarget}
|
|
75
|
+
<polygon
|
|
76
|
+
points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
|
|
77
|
+
transform={`translate(${x},${y}) rotate(${rotation})`}
|
|
78
|
+
className={styles.connectorEndpoint}
|
|
79
|
+
style={passThrough}
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
138
82
|
)
|
|
139
83
|
}
|
|
140
|
-
// Default: circle
|
|
141
84
|
return (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
85
|
+
<>
|
|
86
|
+
{hitTarget}
|
|
87
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
88
|
+
className={styles.connectorEndpoint}
|
|
89
|
+
style={passThrough}
|
|
90
|
+
/>
|
|
91
|
+
</>
|
|
146
92
|
)
|
|
147
93
|
}
|
|
148
94
|
|
|
95
|
+
/** Shared inline style for both SVG layers (CSS vars for connector theming). */
|
|
96
|
+
const svgLayerStyle = {
|
|
97
|
+
width: '100000px',
|
|
98
|
+
height: '100000px',
|
|
99
|
+
'--connector-stroke': connectorConfig.stroke,
|
|
100
|
+
'--connector-stroke-width': `${connectorConfig.strokeWidth}px`,
|
|
101
|
+
'--connector-hover-stroke': connectorConfig.hoverStroke,
|
|
102
|
+
'--connector-hover-stroke-width': `${connectorConfig.hoverStrokeWidth}px`,
|
|
103
|
+
'--connector-endpoint-fill': connectorConfig.endpointFill,
|
|
104
|
+
'--connector-endpoint-stroke': connectorConfig.endpointStroke,
|
|
105
|
+
'--connector-endpoint-stroke-width': `${connectorConfig.endpointStrokeWidth}px`,
|
|
106
|
+
'--connector-hit-area-width': `${connectorConfig.hitAreaStrokeWidth}px`,
|
|
107
|
+
'--connector-drag-stroke': connectorConfig.dragStroke,
|
|
108
|
+
'--connector-drag-stroke-width': `${connectorConfig.dragStrokeWidth}px`,
|
|
109
|
+
'--connector-drag-dasharray': connectorConfig.dragDasharray,
|
|
110
|
+
'--connector-drag-opacity': connectorConfig.dragOpacity,
|
|
111
|
+
}
|
|
112
|
+
|
|
149
113
|
/**
|
|
150
|
-
* SVG overlay
|
|
114
|
+
* Two-layer SVG overlay for connector lines and endpoints.
|
|
115
|
+
*
|
|
116
|
+
* Stacking (bottom → top):
|
|
117
|
+
* connectorLayer (z-index 1) — paths, hit areas, broadcast animations
|
|
118
|
+
* widgets (z-index 2/3) — normal / selected
|
|
119
|
+
* endpointLayer (z-index 4) — anchor dots & drag targets
|
|
120
|
+
*
|
|
151
121
|
* Must be placed inside the same zoom-transformed container as widgets.
|
|
152
122
|
*/
|
|
153
123
|
export default function ConnectorLayer({
|
|
154
124
|
connectors = [],
|
|
155
125
|
widgets = [],
|
|
126
|
+
selectedWidgetIds,
|
|
156
127
|
onRemove,
|
|
157
128
|
onEndpointDrag,
|
|
158
129
|
dragPreview,
|
|
@@ -171,81 +142,78 @@ export default function ConnectorLayer({
|
|
|
171
142
|
onRemove?.(connectorId)
|
|
172
143
|
}, [onRemove])
|
|
173
144
|
|
|
145
|
+
// Pre-compute connector geometry once, shared by both SVG layers
|
|
146
|
+
const resolved = useMemo(() => connectors.map((conn) => {
|
|
147
|
+
const startWidget = widgetMap.get(conn.start?.widgetId)
|
|
148
|
+
const endWidget = widgetMap.get(conn.end?.widgetId)
|
|
149
|
+
if (!startWidget || !endWidget) return null
|
|
150
|
+
const startPt = getAnchorPoint(startWidget, conn.start.anchor)
|
|
151
|
+
const endPt = getAnchorPoint(endWidget, conn.end.anchor)
|
|
152
|
+
const d = buildPath(startPt, conn.start.anchor, endPt, conn.end.anchor)
|
|
153
|
+
const startStyle = getEndpointStyle(startWidget.type, 'start')
|
|
154
|
+
const endStyle = getEndpointStyle(endWidget.type, 'end')
|
|
155
|
+
const isBroadcast = conn.meta?.messagingMode === 'two-way'
|
|
156
|
+
const startSelected = selectedWidgetIds?.has(conn.start?.widgetId)
|
|
157
|
+
const endSelected = selectedWidgetIds?.has(conn.end?.widgetId)
|
|
158
|
+
const reverseAnim = endSelected && !startSelected
|
|
159
|
+
return { conn, startPt, endPt, d, startStyle, endStyle, isBroadcast, reverseAnim }
|
|
160
|
+
}).filter(Boolean), [connectors, widgetMap, selectedWidgetIds])
|
|
161
|
+
|
|
162
|
+
const hiddenClass = hidden ? styles.connectorLayerHidden : ''
|
|
163
|
+
|
|
174
164
|
return (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
'--connector-hover-stroke': connectorConfig.hoverStroke,
|
|
183
|
-
'--connector-hover-stroke-width': `${connectorConfig.hoverStrokeWidth}px`,
|
|
184
|
-
'--connector-endpoint-fill': connectorConfig.endpointFill,
|
|
185
|
-
'--connector-endpoint-stroke': connectorConfig.endpointStroke,
|
|
186
|
-
'--connector-endpoint-stroke-width': `${connectorConfig.endpointStrokeWidth}px`,
|
|
187
|
-
'--connector-hit-area-width': `${connectorConfig.hitAreaStrokeWidth}px`,
|
|
188
|
-
'--connector-drag-stroke': connectorConfig.dragStroke,
|
|
189
|
-
'--connector-drag-stroke-width': `${connectorConfig.dragStrokeWidth}px`,
|
|
190
|
-
'--connector-drag-dasharray': connectorConfig.dragDasharray,
|
|
191
|
-
'--connector-drag-opacity': connectorConfig.dragOpacity,
|
|
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')
|
|
204
|
-
|
|
205
|
-
return (
|
|
165
|
+
<>
|
|
166
|
+
{/* Back layer: connector paths (behind widgets) */}
|
|
167
|
+
<svg
|
|
168
|
+
className={`${styles.connectorLayer} ${hiddenClass}`}
|
|
169
|
+
style={svgLayerStyle}
|
|
170
|
+
>
|
|
171
|
+
{resolved.map(({ conn, d, isBroadcast, reverseAnim }) => (
|
|
206
172
|
<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
|
-
/>
|
|
173
|
+
<path d={d} className={styles.connectorPathHitArea}
|
|
174
|
+
onClick={(e) => handleClick(e, conn.id)} />
|
|
175
|
+
<path d={d}
|
|
176
|
+
className={`${styles.connectorPath}${isBroadcast ? ` ${styles.connectorBroadcast}` : ''}`}
|
|
177
|
+
onClick={(e) => handleClick(e, conn.id)} />
|
|
178
|
+
{isBroadcast && (
|
|
179
|
+
<path d={d}
|
|
180
|
+
className={`${styles.broadcastFlow}${reverseAnim ? ` ${styles.broadcastFlowReverse}` : ''}`} />
|
|
181
|
+
)}
|
|
226
182
|
</g>
|
|
227
|
-
)
|
|
228
|
-
})}
|
|
183
|
+
))}
|
|
229
184
|
|
|
230
|
-
|
|
231
|
-
{dragPreview && (
|
|
232
|
-
<>
|
|
185
|
+
{dragPreview && (
|
|
233
186
|
<path
|
|
234
187
|
d={buildPath(
|
|
235
|
-
dragPreview.startPt,
|
|
236
|
-
dragPreview.startAnchor,
|
|
237
|
-
dragPreview.endPt,
|
|
238
|
-
dragPreview.endAnchor || dragPreview.startAnchor,
|
|
188
|
+
dragPreview.startPt, dragPreview.startAnchor,
|
|
189
|
+
dragPreview.endPt, dragPreview.endAnchor || dragPreview.startAnchor,
|
|
239
190
|
!dragPreview.snapTarget,
|
|
240
191
|
)}
|
|
241
192
|
className={dragPreview.snapTarget ? styles.connectorPath : styles.dragPreviewPath}
|
|
242
193
|
/>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
194
|
+
)}
|
|
195
|
+
</svg>
|
|
196
|
+
|
|
197
|
+
{/* Front layer: endpoint anchors (above widgets) */}
|
|
198
|
+
<svg
|
|
199
|
+
className={`${styles.endpointLayer} ${hiddenClass}`}
|
|
200
|
+
style={svgLayerStyle}
|
|
201
|
+
>
|
|
202
|
+
{resolved.map(({ conn, startPt, endPt, startStyle, endStyle }) => (
|
|
203
|
+
<g key={conn.id}>
|
|
204
|
+
<EndpointShape x={startPt.x} y={startPt.y} startPt={startPt} endPt={endPt} style={startStyle}
|
|
205
|
+
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'start', e) } : undefined} />
|
|
206
|
+
<EndpointShape x={endPt.x} y={endPt.y} startPt={startPt} endPt={endPt} style={endStyle}
|
|
207
|
+
onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'end', e) } : undefined} />
|
|
208
|
+
</g>
|
|
209
|
+
))}
|
|
210
|
+
|
|
211
|
+
{dragPreview?.snapTarget && (
|
|
212
|
+
<EndpointShape x={dragPreview.endPt.x} y={dragPreview.endPt.y}
|
|
213
|
+
startPt={dragPreview.startPt} endPt={dragPreview.endPt} style={connectorConfig.endEndpoint} />
|
|
214
|
+
)}
|
|
215
|
+
</svg>
|
|
216
|
+
</>
|
|
249
217
|
)
|
|
250
218
|
}
|
|
251
219
|
|
|
@@ -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, 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/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
|
+
}
|