@dfosco/storyboard-react 4.1.0 → 4.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/CommandPalette/CommandPalette.jsx +69 -5
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +412 -10
- package/src/canvas/CanvasPage.module.css +26 -4
- package/src/canvas/ConnectorLayer.jsx +252 -0
- package/src/canvas/ConnectorLayer.module.css +60 -0
- package/src/canvas/PageSelector.jsx +376 -37
- package/src/canvas/PageSelector.module.css +93 -6
- package/src/canvas/canvasApi.js +35 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +27 -6
- package/src/canvas/widgets/MarkdownBlock.module.css +11 -1
- package/src/canvas/widgets/StickyNote.module.css +3 -0
- package/src/canvas/widgets/TerminalWidget.jsx +274 -0
- package/src/canvas/widgets/TerminalWidget.module.css +158 -0
- package/src/canvas/widgets/WidgetChrome.jsx +86 -1
- package/src/canvas/widgets/WidgetChrome.module.css +72 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
|
@@ -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
|
+
if (style === 'none') {
|
|
116
|
+
return (
|
|
117
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
118
|
+
style={{ fill: 'transparent', stroke: 'none', pointerEvents: 'auto', cursor: 'crosshair' }}
|
|
119
|
+
onPointerDown={onPointerDown}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
if (style === 'arrow-start' || style === 'arrow-end') {
|
|
124
|
+
const size = connectorConfig.endpointRadius * 2.2
|
|
125
|
+
// Determine which point the arrow should aim toward
|
|
126
|
+
const target = style === 'arrow-start' ? startPt : endPt
|
|
127
|
+
const dx = target.x - x
|
|
128
|
+
const dy = target.y - y
|
|
129
|
+
// atan2 gives angle from positive X axis; polygon tip points up (-Y), so offset by 90°
|
|
130
|
+
const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
|
|
131
|
+
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
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
// Default: circle
|
|
141
|
+
return (
|
|
142
|
+
<circle cx={x} cy={y} r={connectorConfig.endpointRadius}
|
|
143
|
+
className={styles.connectorEndpoint}
|
|
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 — 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
|
+
/>
|
|
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
|
+
}
|