@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.
Files changed (85) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -153
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. 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
- Visual stacking between widgets is controlled by DOM order — selected
107
- widgets are rendered last so they appear on top. */
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
- .localEditingLabel {
113
- display: inline-flex;
114
- align-items: center;
115
- padding: 4px 12px;
116
- background: hsl(212, 92%, 45%);
117
- color: #fff;
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
- getFeatures: () => [],
94
- isResizable: () => false,
95
- schemas: {},
96
- getMenuWidgetTypes: () => [],
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
- <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
118
- style={{ fill: 'transparent', stroke: 'none', pointerEvents: 'auto', cursor: 'crosshair' }}
119
- onPointerDown={onPointerDown}
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
- <polygon
133
- points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
134
- transform={`translate(${x},${y}) rotate(${rotation})`}
135
- className={styles.connectorEndpoint}
136
- onPointerDown={onPointerDown}
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
- <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
143
- className={styles.connectorEndpoint}
144
- onPointerDown={onPointerDown}
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 that renders connector lines between widgets.
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
- <svg
176
- className={`${styles.connectorLayer} ${hidden ? styles.connectorLayerHidden : ''}`}
177
- style={{
178
- width: '100000px',
179
- height: '100000px',
180
- '--connector-stroke': connectorConfig.stroke,
181
- '--connector-stroke-width': `${connectorConfig.strokeWidth}px`,
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
- {/* Invisible wider hit area for easier clicking */}
208
- <path
209
- d={d}
210
- className={styles.connectorPathHitArea}
211
- onClick={(e) => handleClick(e, conn.id)}
212
- />
213
- {/* Visible connector line */}
214
- <path
215
- d={d}
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
- {/* Drag preview — dashed when free, solid when snapped to anchor */}
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
- {dragPreview.snapTarget && (
244
- <EndpointShape x={dragPreview.endPt.x} y={dragPreview.endPt.y} startPt={dragPreview.startPt} endPt={dragPreview.endPt} style={connectorConfig.endEndpoint} />
245
- )}
246
- </>
247
- )}
248
- </svg>
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
- window.location = { href: '' }
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
- expect(window.location.href).toContain('/canvas/research/surveys')
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', () => {
@@ -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
+ }