@dfosco/storyboard-react 4.1.0-beta.3 → 4.2.0-alpha.5

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.
@@ -0,0 +1,252 @@
1
+ import { useMemo, useCallback } from 'react'
2
+ import styles from './ConnectorLayer.module.css'
3
+ import { getConnectorDefaults, getConnectorConfig } from './widgets/widgetConfig.js'
4
+
5
+ const connectorConfig = getConnectorDefaults()
6
+ const CONTROL_OFFSET = connectorConfig.controlOffset
7
+
8
+ /**
9
+ * Get the effective endpoint style for a widget, merging per-widget-type
10
+ * connector overrides with global defaults.
11
+ * @param {string} widgetType — widget type string
12
+ * @param {'start'|'end'} side — which end of the connector
13
+ * @returns {string} endpoint style ('circle', 'arrow-in', 'arrow-out', 'none')
14
+ */
15
+ function getEndpointStyle(widgetType, side) {
16
+ const key = side === 'start' ? 'startEndpoint' : 'endEndpoint'
17
+ const widgetConnectors = getConnectorConfig(widgetType)
18
+ if (widgetConnectors.defaults?.[key]) return widgetConnectors.defaults[key]
19
+ return connectorConfig[key]
20
+ }
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
+ /**
104
+ * Render an endpoint shape (circle, arrow-start, arrow-end, or none) at the given point.
105
+ * - "circle" (default): filled dot
106
+ * - "arrow-start": arrowhead pointing toward the start widget
107
+ * - "arrow-end": arrowhead pointing toward the end widget
108
+ * - "none": invisible drag target only
109
+ *
110
+ * @param {number} x,y — position of this endpoint
111
+ * @param {Object} startPt — position of the connector's start endpoint
112
+ * @param {Object} endPt — position of the connector's end endpoint
113
+ */
114
+ function EndpointShape({ x, y, startPt, endPt, style, onPointerDown }) {
115
+ const passThrough = !onPointerDown ? { pointerEvents: 'none' } : {}
116
+ if (style === 'none') {
117
+ return (
118
+ <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
119
+ style={{ fill: 'transparent', stroke: 'none', ...(onPointerDown ? { pointerEvents: 'auto', cursor: 'crosshair' } : { pointerEvents: 'none' }) }}
120
+ onPointerDown={onPointerDown}
121
+ />
122
+ )
123
+ }
124
+ if (style === 'arrow-start' || style === 'arrow-end') {
125
+ const size = connectorConfig.endpointRadius * 2.2
126
+ const target = style === 'arrow-start' ? startPt : endPt
127
+ const dx = target.x - x
128
+ const dy = target.y - y
129
+ const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
130
+ return (
131
+ <polygon
132
+ points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
133
+ transform={`translate(${x},${y}) rotate(${rotation})`}
134
+ className={styles.connectorEndpoint}
135
+ style={passThrough}
136
+ onPointerDown={onPointerDown}
137
+ />
138
+ )
139
+ }
140
+ return (
141
+ <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
142
+ className={styles.connectorEndpoint}
143
+ style={passThrough}
144
+ onPointerDown={onPointerDown}
145
+ />
146
+ )
147
+ }
148
+
149
+ /**
150
+ * SVG overlay that renders connector lines between widgets.
151
+ * Must be placed inside the same zoom-transformed container as widgets.
152
+ */
153
+ export default function ConnectorLayer({
154
+ connectors = [],
155
+ widgets = [],
156
+ onRemove,
157
+ onEndpointDrag,
158
+ dragPreview,
159
+ hidden = false,
160
+ }) {
161
+ const widgetMap = useMemo(() => {
162
+ const map = new Map()
163
+ for (const w of widgets) {
164
+ map.set(w.id, w)
165
+ }
166
+ return map
167
+ }, [widgets])
168
+
169
+ const handleClick = useCallback((e, connectorId) => {
170
+ e.stopPropagation()
171
+ onRemove?.(connectorId)
172
+ }, [onRemove])
173
+
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')
204
+
205
+ return (
206
+ <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 — visual only, pointer events pass through to anchor dots */}
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
+ />
226
+ </g>
227
+ )
228
+ })}
229
+
230
+ {/* Drag preview — dashed when free, solid when snapped to anchor */}
231
+ {dragPreview && (
232
+ <>
233
+ <path
234
+ d={buildPath(
235
+ dragPreview.startPt,
236
+ dragPreview.startAnchor,
237
+ dragPreview.endPt,
238
+ dragPreview.endAnchor || dragPreview.startAnchor,
239
+ !dragPreview.snapTarget,
240
+ )}
241
+ className={dragPreview.snapTarget ? styles.connectorPath : styles.dragPreviewPath}
242
+ />
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>
249
+ )
250
+ }
251
+
252
+ export { getAnchorPoint, buildPath }
@@ -0,0 +1,60 @@
1
+ .connectorLayer {
2
+ position: absolute;
3
+ top: 0;
4
+ left: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ pointer-events: none;
8
+ overflow: visible;
9
+ z-index: 1;
10
+ transition: opacity 0.2s ease;
11
+ }
12
+
13
+ .connectorLayerHidden {
14
+ opacity: 0;
15
+ pointer-events: none;
16
+ }
17
+
18
+ .connectorPath {
19
+ fill: none;
20
+ stroke: var(--connector-stroke, var(--fgColor-accent, #0969da));
21
+ stroke-width: var(--connector-stroke-width, 4px);
22
+ pointer-events: stroke;
23
+ cursor: pointer;
24
+ transition: stroke 0.15s ease, stroke-width 0.15s ease;
25
+ }
26
+
27
+ .connectorPath:hover {
28
+ stroke: var(--connector-hover-stroke, var(--fgColor-danger, #cf222e));
29
+ stroke-width: var(--connector-hover-stroke-width, 5px);
30
+ }
31
+
32
+ .connectorEndpoint {
33
+ fill: var(--connector-endpoint-fill, var(--fgColor-accent, #0969da));
34
+ stroke: var(--connector-endpoint-stroke, var(--bgColor-default, #ffffff));
35
+ stroke-width: var(--connector-endpoint-stroke-width, 3px);
36
+ pointer-events: auto;
37
+ cursor: crosshair;
38
+ transition: filter 0.1s ease;
39
+ }
40
+
41
+ .connectorEndpoint:hover {
42
+ filter: drop-shadow(0 0 4px var(--connector-endpoint-fill, var(--fgColor-accent, #0969da)));
43
+ }
44
+
45
+ .connectorPathHitArea {
46
+ fill: none;
47
+ stroke: transparent;
48
+ stroke-width: var(--connector-hit-area-width, 16px);
49
+ pointer-events: stroke;
50
+ cursor: pointer;
51
+ }
52
+
53
+ .dragPreviewPath {
54
+ fill: none;
55
+ stroke: var(--connector-drag-stroke, var(--fgColor-accent, #0969da));
56
+ stroke-width: var(--connector-drag-stroke-width, 2px);
57
+ stroke-dasharray: var(--connector-drag-dasharray, 6 4);
58
+ pointer-events: none;
59
+ opacity: var(--connector-drag-opacity, 0.7);
60
+ }