@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.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  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 +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  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 +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  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 -165
  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/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. 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
- 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,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
- <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
118
- style={{ fill: 'transparent', stroke: 'none', pointerEvents: 'auto', cursor: 'crosshair' }}
119
- onPointerDown={onPointerDown}
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
- <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
- />
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
- <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
143
- className={styles.connectorEndpoint}
144
- onPointerDown={onPointerDown}
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 that renders connector lines between widgets.
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
- 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')
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
- return (
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
- {/* 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
- />
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
- {/* Drag preview — dashed when free, solid when snapped to anchor */}
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
- {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>
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, vi, beforeEach } from 'vitest'
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
- 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
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
3
3
 
4
4
  describe('canvasReloadGuard', () => {