@pauloricca/spagbol 0.1.0 → 0.1.3
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/README.md +42 -4
- package/dist/core/controller.js +4 -4
- package/dist/core/controller.js.map +1 -1
- package/dist/core/model.d.ts +2 -1
- package/dist/core/model.d.ts.map +1 -1
- package/dist/core/model.js.map +1 -1
- package/dist/editors.css +157 -0
- package/dist/react/SpagbolCanvas.d.ts.map +1 -1
- package/dist/react/SpagbolCanvas.js +106 -29
- package/dist/react/SpagbolCanvas.js.map +1 -1
- package/dist/react/SpagbolCanvasControls.d.ts.map +1 -1
- package/dist/react/SpagbolCanvasControls.js +5 -5
- package/dist/react/SpagbolCanvasControls.js.map +1 -1
- package/dist/react/SpagbolControlJoystick.d.ts +9 -0
- package/dist/react/SpagbolControlJoystick.d.ts.map +1 -0
- package/dist/react/SpagbolControlJoystick.js +111 -0
- package/dist/react/SpagbolControlJoystick.js.map +1 -0
- package/dist/react/SpagbolControlZoom.d.ts.map +1 -1
- package/dist/react/SpagbolControlZoom.js +3 -2
- package/dist/react/SpagbolControlZoom.js.map +1 -1
- package/dist/react/SpagbolCurveEditor.d.ts +45 -0
- package/dist/react/SpagbolCurveEditor.d.ts.map +1 -0
- package/dist/react/SpagbolCurveEditor.js +142 -0
- package/dist/react/SpagbolCurveEditor.js.map +1 -0
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -1
- package/dist/react/SpagbolNodeFrame.js +18 -16
- package/dist/react/SpagbolNodeFrame.js.map +1 -1
- package/dist/react/SpagbolNodeFrameTypePicker.js +1 -1
- package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -1
- package/dist/react/SpagbolSequencer.d.ts +35 -0
- package/dist/react/SpagbolSequencer.d.ts.map +1 -0
- package/dist/react/SpagbolSequencer.js +166 -0
- package/dist/react/SpagbolSequencer.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/dist/react/zoom.d.ts +5 -0
- package/dist/react/zoom.d.ts.map +1 -0
- package/dist/react/zoom.js +19 -0
- package/dist/react/zoom.js.map +1 -0
- package/dist/styles.css +112 -41
- package/docs/language/standard-node-comparison.md +92 -0
- package/docs/language/standard-node-contracts.md +237 -0
- package/docs/language/standard-nodes.md +198 -0
- package/docs/language/ui-components.md +79 -0
- package/package.json +5 -3
- package/src/core/controller.ts +4 -4
- package/src/core/model.ts +2 -1
- package/src/react/SpagbolCanvas.tsx +121 -31
- package/src/react/SpagbolCanvasControls.tsx +2 -7
- package/src/react/SpagbolControlJoystick.tsx +147 -0
- package/src/react/SpagbolControlZoom.tsx +3 -2
- package/src/react/SpagbolCurveEditor.tsx +166 -0
- package/src/react/SpagbolNodeFrame.tsx +30 -24
- package/src/react/SpagbolNodeFrameTypePicker.tsx +1 -1
- package/src/react/SpagbolSequencer.tsx +265 -0
- package/src/react/editors.css +157 -0
- package/src/react/index.ts +4 -0
- package/src/react/nodeDrag.ts +33 -0
- package/src/react/styles.css +112 -41
- package/src/react/zoom.ts +18 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode, type PointerEvent, type MouseEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Normalized view coordinates, not a serialized curve or runtime data model. */
|
|
4
|
+
export interface SpagbolCurveEditorPoint { x: number; y: number; locked?: boolean }
|
|
5
|
+
export interface SpagbolCurveEditorProps {
|
|
6
|
+
/** x: 0..1, y: -1..1. The host projects its data into these view coordinates. */
|
|
7
|
+
points: readonly SpagbolCurveEditorPoint[];
|
|
8
|
+
backgroundCurves?: readonly { points: readonly SpagbolCurveEditorPoint[]; className?: string }[];
|
|
9
|
+
className?: string;
|
|
10
|
+
pathClassName?: string;
|
|
11
|
+
ariaLabel?: string;
|
|
12
|
+
/** Actual on-screen size, including canvas zoom, for constant-sized hit targets. */
|
|
13
|
+
displaySize: { width: number; height: number };
|
|
14
|
+
screenEmphasis?: number;
|
|
15
|
+
pointScale?: number;
|
|
16
|
+
minPointSpacing?: number;
|
|
17
|
+
formatValue?: (normalizedValue: number) => string;
|
|
18
|
+
toolbar?: ReactNode;
|
|
19
|
+
underlay?: ReactNode;
|
|
20
|
+
/** SVG children in the fixed 300 × 128 viewBox; decorative, without pointer events. */
|
|
21
|
+
overlay?: ReactNode;
|
|
22
|
+
footer?: ReactNode;
|
|
23
|
+
/** Apply an insertion in host data and return the new point's index, or null to reject. */
|
|
24
|
+
onInsertPoint: (point: { x: number; y: number }) => number | null;
|
|
25
|
+
onMovePoint: (index: number, point: { x: number; y: number }) => void;
|
|
26
|
+
onRemovePoint: (index: number) => void;
|
|
27
|
+
onGestureEnd?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const width = 300, height = 128, padding = 12;
|
|
31
|
+
const innerWidth = width - padding * 2, innerHeight = height - padding * 2;
|
|
32
|
+
const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n));
|
|
33
|
+
const screenPoint = (point: { x: number; y: number }) => ({ x: padding + point.x * innerWidth, y: padding + (1 - (point.y + 1) / 2) * innerHeight });
|
|
34
|
+
function path(points: readonly SpagbolCurveEditorPoint[]) {
|
|
35
|
+
return points.map((point, index) => {
|
|
36
|
+
const p = screenPoint(point);
|
|
37
|
+
// Preserve Teia's two-decimal SVG path precision.
|
|
38
|
+
return `${index ? 'L' : 'M'}${Math.round(p.x * 100) / 100} ${Math.round(p.y * 100) / 100}`;
|
|
39
|
+
}).join(' ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SpagbolCurveEditor({
|
|
43
|
+
points, backgroundCurves = [], className = '', pathClassName = '', ariaLabel = 'Curve editor',
|
|
44
|
+
displaySize, screenEmphasis = 1, pointScale = 1, minPointSpacing = 0.001,
|
|
45
|
+
formatValue = (value) => String(Math.round(value * 1000) / 1000),
|
|
46
|
+
toolbar, underlay, overlay, footer, onInsertPoint, onMovePoint, onRemovePoint, onGestureEnd,
|
|
47
|
+
}: SpagbolCurveEditorProps) {
|
|
48
|
+
const editorRef = useRef<SVGSVGElement | null>(null);
|
|
49
|
+
const dragRef = useRef<{ pointerId: number; index: number } | null>(null);
|
|
50
|
+
const removedByPress = useRef(false);
|
|
51
|
+
const clickRef = useRef<{ index: number | null; time: number }>({ index: null, time: 0 });
|
|
52
|
+
const [activePoint, setActivePoint] = useState<number | null>(null);
|
|
53
|
+
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
|
|
54
|
+
const latest = useRef({ points, onMovePoint, onGestureEnd });
|
|
55
|
+
latest.current = { points, onMovePoint, onGestureEnd };
|
|
56
|
+
|
|
57
|
+
function fromPointer(event: { clientX: number; clientY: number }) {
|
|
58
|
+
const rect = editorRef.current?.getBoundingClientRect();
|
|
59
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
|
|
60
|
+
const x = clamp((event.clientX - rect.left) / rect.width * width, padding, width - padding);
|
|
61
|
+
const y = clamp((event.clientY - rect.top) / rect.height * height, padding, height - padding);
|
|
62
|
+
return { x: (x - padding) / innerWidth, y: (1 - (y - padding) / innerHeight) * 2 - 1 };
|
|
63
|
+
}
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const move = (event: globalThis.PointerEvent) => {
|
|
66
|
+
const drag = dragRef.current;
|
|
67
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
68
|
+
const current = latest.current.points;
|
|
69
|
+
const selected = current[drag.index];
|
|
70
|
+
if (!selected || selected.locked) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
const point = fromPointer(event);
|
|
73
|
+
if (!point) return;
|
|
74
|
+
const low = drag.index === 0 ? 0 : (current[drag.index - 1]?.x ?? 0) + minPointSpacing;
|
|
75
|
+
const high = drag.index === current.length - 1 ? 1 : (current[drag.index + 1]?.x ?? 1) - minPointSpacing;
|
|
76
|
+
// A host may reject a move; never cross adjacent point positions.
|
|
77
|
+
if (low > high) return;
|
|
78
|
+
latest.current.onMovePoint(drag.index, { x: clamp(point.x, low, high), y: point.y });
|
|
79
|
+
};
|
|
80
|
+
const stop = (event: globalThis.PointerEvent) => {
|
|
81
|
+
if (dragRef.current?.pointerId !== event.pointerId) return;
|
|
82
|
+
dragRef.current = null;
|
|
83
|
+
setActivePoint(null);
|
|
84
|
+
latest.current.onGestureEnd?.();
|
|
85
|
+
};
|
|
86
|
+
window.addEventListener('pointermove', move, { passive: false });
|
|
87
|
+
window.addEventListener('pointerup', stop);
|
|
88
|
+
window.addEventListener('pointercancel', stop);
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener('pointermove', move);
|
|
91
|
+
window.removeEventListener('pointerup', stop);
|
|
92
|
+
window.removeEventListener('pointercancel', stop);
|
|
93
|
+
};
|
|
94
|
+
}, [minPointSpacing]);
|
|
95
|
+
|
|
96
|
+
const hitIndex = (target: EventTarget) => {
|
|
97
|
+
const element = target instanceof Element ? target.closest<SVGElement>('[data-spagbol-curve-point]') : null;
|
|
98
|
+
return element ? Number(element.dataset.spagbolCurvePoint) : null;
|
|
99
|
+
};
|
|
100
|
+
const remove = (index: number) => {
|
|
101
|
+
if (!points[index] || points[index].locked) return;
|
|
102
|
+
if (dragRef.current) onGestureEnd?.();
|
|
103
|
+
dragRef.current = null;
|
|
104
|
+
setActivePoint(null);
|
|
105
|
+
onRemovePoint(index);
|
|
106
|
+
};
|
|
107
|
+
const pointerDown = (event: PointerEvent<SVGSVGElement>) => {
|
|
108
|
+
if (event.button !== 0 || dragRef.current) return;
|
|
109
|
+
event.preventDefault(); event.stopPropagation();
|
|
110
|
+
removedByPress.current = false;
|
|
111
|
+
const index = hitIndex(event.target);
|
|
112
|
+
if (index !== null && Number.isInteger(index)) {
|
|
113
|
+
const now = performance.now();
|
|
114
|
+
if (clickRef.current.index === index && now - clickRef.current.time < 360 && !points[index]?.locked) {
|
|
115
|
+
clickRef.current = { index: null, time: 0 }; removedByPress.current = true; remove(index); return;
|
|
116
|
+
}
|
|
117
|
+
clickRef.current = { index, time: now };
|
|
118
|
+
if (!points[index] || points[index].locked) return;
|
|
119
|
+
dragRef.current = { pointerId: event.pointerId, index };
|
|
120
|
+
setActivePoint(index); event.currentTarget.setPointerCapture(event.pointerId); return;
|
|
121
|
+
}
|
|
122
|
+
const point = fromPointer(event);
|
|
123
|
+
if (!point) return;
|
|
124
|
+
clickRef.current = { index: null, time: 0 };
|
|
125
|
+
const insertedIndex = onInsertPoint(point);
|
|
126
|
+
if (insertedIndex === null) return;
|
|
127
|
+
dragRef.current = { pointerId: event.pointerId, index: insertedIndex };
|
|
128
|
+
setActivePoint(insertedIndex); event.currentTarget.setPointerCapture(event.pointerId);
|
|
129
|
+
};
|
|
130
|
+
const doubleClick = (event: MouseEvent<SVGSVGElement>) => {
|
|
131
|
+
event.preventDefault(); event.stopPropagation();
|
|
132
|
+
if (removedByPress.current) { removedByPress.current = false; return; }
|
|
133
|
+
const index = hitIndex(event.target);
|
|
134
|
+
if (index !== null) remove(index);
|
|
135
|
+
};
|
|
136
|
+
const radius = (pixels: number) => ({ rx: pixels / Math.max(0.001, displaySize.width / width), ry: pixels / Math.max(0.001, displaySize.height / height) });
|
|
137
|
+
const hitRadius = radius(15 * screenEmphasis);
|
|
138
|
+
const endpointRadius = radius(5 * screenEmphasis * pointScale);
|
|
139
|
+
const handleRadius = radius(6 * screenEmphasis * pointScale);
|
|
140
|
+
const valuePoint = points[activePoint ?? hoveredPoint ?? -1];
|
|
141
|
+
const valueScreen = valuePoint && screenPoint(valuePoint);
|
|
142
|
+
return <div className={`spagbol-curve-editor ${className} nodrag nopan`}>
|
|
143
|
+
<div className="spagbol-curve-editor-chart">
|
|
144
|
+
{toolbar}{underlay}
|
|
145
|
+
<svg ref={editorRef} className="spagbol-curve-editor-canvas" viewBox="0 0 300 128" preserveAspectRatio="none" aria-label={ariaLabel} role="img" onPointerDown={pointerDown} onDoubleClick={doubleClick}
|
|
146
|
+
onLostPointerCapture={(event) => {
|
|
147
|
+
if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = null; setActivePoint(null); onGestureEnd?.(); }
|
|
148
|
+
}}>
|
|
149
|
+
<g className="spagbol-curve-editor-chrome">
|
|
150
|
+
{backgroundCurves.map((curve, index) => <path key={index} className={`spagbol-curve-editor-path is-background ${curve.className ?? ''}`} d={path(curve.points)} />)}
|
|
151
|
+
<path className={`spagbol-curve-editor-path ${pathClassName}`} d={path(points)} />
|
|
152
|
+
{overlay}
|
|
153
|
+
</g>
|
|
154
|
+
{points.map((point, index) => {
|
|
155
|
+
const screen = screenPoint(point);
|
|
156
|
+
return <g key={index} onPointerEnter={() => setHoveredPoint(index)} onPointerLeave={() => setHoveredPoint((current) => current === index ? null : current)}>
|
|
157
|
+
<ellipse className={`spagbol-curve-editor-hit-target ${point.locked ? 'is-locked' : ''}`} data-spagbol-curve-point={index} cx={screen.x} cy={screen.y} {...hitRadius} />
|
|
158
|
+
<ellipse className={`spagbol-curve-editor-handle ${point.locked ? 'spagbol-curve-editor-endpoint is-locked' : ''}`} data-spagbol-curve-point={index} cx={screen.x} cy={screen.y} {...(point.locked ? endpointRadius : handleRadius)} />
|
|
159
|
+
</g>;
|
|
160
|
+
})}
|
|
161
|
+
</svg>
|
|
162
|
+
{valuePoint && valueScreen ? <span className={`spagbol-curve-editor-value-label ${valuePoint.x > 0.72 ? 'is-left' : ''} ${valuePoint.y > 0.72 ? 'is-below' : ''}`} style={{ left: `${valueScreen.x / width * 100}%`, top: `${valueScreen.y / height * 100}%` }} aria-hidden="true">{formatValue(valuePoint.y)}</span> : null}
|
|
163
|
+
</div>
|
|
164
|
+
{footer}
|
|
165
|
+
</div>;
|
|
166
|
+
}
|
|
@@ -39,15 +39,16 @@ export interface SpagbolNodePortProps {
|
|
|
39
39
|
/** Also usable in a completely custom node renderer. */
|
|
40
40
|
export function SpagbolNodePort({ port, connectable = true, style, position, className }: SpagbolNodePortProps) {
|
|
41
41
|
const { editor } = useSpagbol();
|
|
42
|
-
const selection = useEditorSelector(editor, snapshot => snapshot.selection);
|
|
43
42
|
const mode = useEditorSelector(editor, snapshot => snapshot.mode);
|
|
44
43
|
const flowId = useNodeId();
|
|
45
|
-
const selectedLink = mode === 'edit' && selection.linkIds.length === 1
|
|
46
|
-
? editor.graph.links.find(link => link.id === selection.linkIds[0]) : undefined;
|
|
47
44
|
const nodeId = flowId?.startsWith('node:') ? flowId.slice(5) : undefined;
|
|
48
|
-
const reconnecting =
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
const reconnecting = useEditorSelector(editor, snapshot => {
|
|
46
|
+
if (snapshot.mode !== 'edit' || snapshot.selection.linkIds.length !== 1) return false;
|
|
47
|
+
const link = graphAt(snapshot.document, snapshot.path).links.find(link => link.id === snapshot.selection.linkIds[0]);
|
|
48
|
+
return !!link && (port.direction === 'output'
|
|
49
|
+
? link.source.nodeId === nodeId && link.source.portId === port.id
|
|
50
|
+
: link.target.type === 'port' && link.target.nodeId === nodeId && link.target.portId === port.id);
|
|
51
|
+
});
|
|
51
52
|
const direction = port.direction;
|
|
52
53
|
return (
|
|
53
54
|
<Handle
|
|
@@ -56,7 +57,7 @@ export function SpagbolNodePort({ port, connectable = true, style, position, cla
|
|
|
56
57
|
position={position ?? (direction === "input" ? Position.Left : Position.Right)}
|
|
57
58
|
className={`spagbol-node-port${reconnecting ? ' spagbol-node-port-selected-link' : ''} ${className ?? ""}`}
|
|
58
59
|
isConnectable={
|
|
59
|
-
|
|
60
|
+
mode === "edit" && port.connectable !== false && connectable
|
|
60
61
|
}
|
|
61
62
|
style={style}
|
|
62
63
|
aria-label={`${port.label} ${direction}`}
|
|
@@ -85,7 +86,8 @@ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
|
|
|
85
86
|
const [pointerOver, setPointerOver] = useState(false);
|
|
86
87
|
const updateInternals = useUpdateNodeInternals();
|
|
87
88
|
const flowId = useNodeId();
|
|
88
|
-
const
|
|
89
|
+
const singleSelected = useEditorSelector(editor, snapshot => snapshot.selection.nodeIds.length === 1 && snapshot.selection.nodeIds[0] === node.id);
|
|
90
|
+
const selectedPortId = useEditorSelector(editor, snapshot => snapshot.selection.port?.nodeId === node.id ? snapshot.selection.port.portId : undefined);
|
|
89
91
|
const mode = useEditorSelector(editor, snapshot => snapshot.mode);
|
|
90
92
|
const path = useEditorSelector(editor, snapshot => snapshot.path);
|
|
91
93
|
const boundary = isGroupBoundary(node);
|
|
@@ -93,7 +95,7 @@ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
|
|
|
93
95
|
const ports = portsFor(node, editor.nodes);
|
|
94
96
|
const inputPorts = ports.filter((port) => port.direction === "input" && props.portVisible?.(port) !== false);
|
|
95
97
|
const outputPorts = ports.filter((port) => port.direction === "output" && props.portVisible?.(port) !== false);
|
|
96
|
-
const compact = !projected && views[node.kind]?.compact !== false && features.compact && (props.forceCompact || node.compact) && !(selected &&
|
|
98
|
+
const compact = !projected && views[node.kind]?.compact !== false && features.compact && (props.forceCompact || node.compact) && !(selected && singleSelected) && !(connecting && pointerOver);
|
|
97
99
|
const headerInput = !boundary ? inputPorts.find(port => port.label === 'signal') : undefined;
|
|
98
100
|
const headerOutput = !boundary && props.headerOutput !== false ? outputPorts.find(port => port.label === 'signal') ?? (outputPorts.length === 1 ? outputPorts[0] : undefined) : undefined;
|
|
99
101
|
const portState = useEditorSelector(editor, snapshot => graphPortStates(graphAt(snapshot.document, snapshot.path)).get(node.id) ?? emptyPortState);
|
|
@@ -140,7 +142,7 @@ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
|
|
|
140
142
|
style={{ gridColumn: boundary || input || inputPorts.length === 0 ? 1 : 2, gridRow: index + 1 + (props.portsHeader ? 1 : 0) }}
|
|
141
143
|
>
|
|
142
144
|
{!projected && !props.hidePorts && <SpagbolNodePort port={port} />}
|
|
143
|
-
{props.renderPortContent ? props.renderPortContent(port) : <>{boundary && !projected ? <SpagbolNodeGroupPort name={port.label} editable={editor.editing && !props.readOnly} draggable={editor.editing && !props.readOnly} selected={
|
|
145
|
+
{props.renderPortContent ? props.renderPortContent(port) : <>{boundary && !projected ? <SpagbolNodeGroupPort name={port.label} editable={editor.editing && !props.readOnly} draggable={editor.editing && !props.readOnly} selected={selectedPortId === port.id} onChange={label => { try { editor.renameBoundaryPort(node.id, port.id, label); } catch { /* Keep the valid label. */ } }} onPointerDown={event => {
|
|
144
146
|
if (!editor.editing || props.readOnly || event.button !== 0) return;
|
|
145
147
|
event.preventDefault(); event.stopPropagation();
|
|
146
148
|
editor.select({ nodeIds: [node.id], port: { nodeId: node.id, portId: port.id, direction: port.direction } });
|
|
@@ -203,32 +205,37 @@ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
|
|
|
203
205
|
<span className="spagbol-node-frame-title">
|
|
204
206
|
{props.title ?? <SpagbolNodeFrameTypePicker node={node} compact={!!compact} selected={selected} disabled={projected || props.readOnly || !editor.editing} />}
|
|
205
207
|
</span>
|
|
206
|
-
{!projected &&
|
|
207
|
-
{!projected && features.compact && !props.forceCompact && view?.compact !== false && (
|
|
208
|
+
{!projected && node.graph && features.nestedGraphs && (
|
|
208
209
|
<button
|
|
209
|
-
className="spagbol-node-frame-header-button nodrag nopan"
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
className="spagbol-node-frame-header-button spagbol-node-frame-enter nodrag nopan"
|
|
211
|
+
type="button"
|
|
212
|
+
onPointerDown={event => event.stopPropagation()}
|
|
213
|
+
onDoubleClick={event => event.stopPropagation()}
|
|
214
|
+
aria-label="Enter nested graph"
|
|
215
|
+
title="Enter nested graph"
|
|
213
216
|
onClick={(event) => {
|
|
214
217
|
event.stopPropagation();
|
|
215
|
-
editor.
|
|
218
|
+
editor.enter(node.id);
|
|
216
219
|
}}
|
|
217
220
|
>
|
|
218
|
-
|
|
221
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
222
|
+
<path d="m9 6 6 6-6 6" />
|
|
223
|
+
</svg>
|
|
219
224
|
</button>
|
|
220
225
|
)}
|
|
221
|
-
{!projected && node.
|
|
226
|
+
{!projected && <button className="spagbol-node-frame-enabled" type="button" aria-label={node.enabled === false ? 'Enable node' : 'Disable node'} title={node.enabled === false ? 'Enable node' : 'Disable node'} aria-pressed={node.enabled !== false} disabled={!editor.editing || props.readOnly} onPointerDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onClick={event => { event.preventDefault(); event.stopPropagation(); editor.updateNode(node.id, { enabled: node.enabled === false }, 'structure'); }}><span className="spagbol-node-frame-enabled-icon" aria-hidden="true" /></button>}
|
|
227
|
+
{!projected && features.compact && !props.forceCompact && view?.compact !== false && (
|
|
222
228
|
<button
|
|
223
229
|
className="spagbol-node-frame-header-button nodrag nopan"
|
|
224
|
-
aria-label="
|
|
225
|
-
title="
|
|
230
|
+
aria-label={node.compact ? "Expand ports" : "Compact ports"}
|
|
231
|
+
title={node.compact ? "Expand ports" : "Compact ports"}
|
|
232
|
+
disabled={!editor.editing}
|
|
226
233
|
onClick={(event) => {
|
|
227
234
|
event.stopPropagation();
|
|
228
|
-
editor.
|
|
235
|
+
editor.updateNode(node.id, { compact: !node.compact }, "layout");
|
|
229
236
|
}}
|
|
230
237
|
>
|
|
231
|
-
|
|
238
|
+
{node.compact ? "+" : "−"}
|
|
232
239
|
</button>
|
|
233
240
|
)}
|
|
234
241
|
</div>
|
|
@@ -274,7 +281,6 @@ export function useNodeViewProps(
|
|
|
274
281
|
const { editor, projection } = useSpagbol();
|
|
275
282
|
const owner = projection?.groupId ?? groupId;
|
|
276
283
|
const controlPath = projection?.path ?? [node.id];
|
|
277
|
-
const selection = useEditorSelector(editor, snapshot => snapshot.selection);
|
|
278
284
|
const mode = useEditorSelector(editor, snapshot => snapshot.mode);
|
|
279
285
|
const path = useEditorSelector(editor, snapshot => snapshot.path);
|
|
280
286
|
return useMemo(
|
|
@@ -14,7 +14,7 @@ export function SpagbolNodeFrameTypePicker({ node, compact, selected, disabled }
|
|
|
14
14
|
const [highlight, setHighlight] = useState(0);
|
|
15
15
|
const input = useRef<HTMLInputElement>(null);
|
|
16
16
|
const menu = useRef<HTMLDivElement>(null);
|
|
17
|
-
const options = useMemo(() => [...editor.nodes.list().filter(item => !nodeKinds || nodeKinds.includes(item.kind)), ...(features.areas ? [{ kind: 'spagbol.area', label: '
|
|
17
|
+
const options = useMemo(() => [...editor.nodes.list().filter(item => !nodeKinds || nodeKinds.includes(item.kind)), ...(features.areas ? [{ kind: 'spagbol.area', label: 'Container' }] : [])].filter(item =>
|
|
18
18
|
`${item.kind} ${item.label}`.toLowerCase().includes(query.trim().toLowerCase())
|
|
19
19
|
).sort((a, b) => Number(b.label.toLowerCase().startsWith(query.toLowerCase())) - Number(a.label.toLowerCase().startsWith(query.toLowerCase()))), [editor, query, nodeKinds, features.areas]);
|
|
20
20
|
useEffect(() => { if (!selected || disabled) setOpen(false); }, [selected, disabled]);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Controlled view projections and edit intents; no persisted sequence model or evaluator.
|
|
2
|
+
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface SpagbolSequencerItem {
|
|
5
|
+
id: number;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
level: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SpagbolSequencerProps {
|
|
12
|
+
ariaLabel: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
minLevel?: number;
|
|
15
|
+
rows: number;
|
|
16
|
+
steps: number;
|
|
17
|
+
groupSize?: number;
|
|
18
|
+
currentStep?: number;
|
|
19
|
+
rowLabel: (row: number) => string;
|
|
20
|
+
renderRowLabel?: (row: number) => ReactNode;
|
|
21
|
+
labelPosition?: 'left' | 'right';
|
|
22
|
+
onRowRef?: (row: number, element: HTMLDivElement | null) => void;
|
|
23
|
+
itemsForRow: (row: number) => SpagbolSequencerItem[];
|
|
24
|
+
mode?: 'gate' | 'trigger';
|
|
25
|
+
defaultItemLength?: number;
|
|
26
|
+
allowMove?: boolean;
|
|
27
|
+
allowResize?: boolean;
|
|
28
|
+
allowLevel?: boolean;
|
|
29
|
+
onCreateItem: (row: number, start: number, end: number) => void;
|
|
30
|
+
onCreateItems?: (row: number, gates: Array<{ start: number; end: number }>) => void;
|
|
31
|
+
onUpdateItem: (row: number, id: number, update: Partial<Pick<SpagbolSequencerItem, 'start' | 'end' | 'level'>>) => void;
|
|
32
|
+
onDeleteItem: (row: number, id: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SpagbolSequencer({
|
|
36
|
+
ariaLabel,
|
|
37
|
+
className = '',
|
|
38
|
+
minLevel = 0.1,
|
|
39
|
+
rows,
|
|
40
|
+
steps,
|
|
41
|
+
groupSize = 4,
|
|
42
|
+
currentStep,
|
|
43
|
+
rowLabel,
|
|
44
|
+
renderRowLabel,
|
|
45
|
+
labelPosition = 'left',
|
|
46
|
+
onRowRef,
|
|
47
|
+
itemsForRow,
|
|
48
|
+
mode = 'gate',
|
|
49
|
+
defaultItemLength = 1,
|
|
50
|
+
allowMove = true,
|
|
51
|
+
allowResize = true,
|
|
52
|
+
allowLevel = true,
|
|
53
|
+
onCreateItem,
|
|
54
|
+
onCreateItems,
|
|
55
|
+
onUpdateItem,
|
|
56
|
+
onDeleteItem,
|
|
57
|
+
}: SpagbolSequencerProps) {
|
|
58
|
+
const activeStep = Number.isFinite(currentStep) ? Math.floor(currentStep ?? -1) : -1;
|
|
59
|
+
const dragRef = useRef<{ pointerId: number; row: number; gate: SpagbolSequencerItem; x: number; y: number; action: 'move' | 'left' | 'right' | 'level'; moved: boolean } | null>(null);
|
|
60
|
+
const createRef = useRef<{
|
|
61
|
+
pointerId: number;
|
|
62
|
+
row: number;
|
|
63
|
+
anchorX: number;
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
lastStep: number;
|
|
67
|
+
positions: Set<number>;
|
|
68
|
+
moved: boolean;
|
|
69
|
+
} | null>(null);
|
|
70
|
+
const [createPreview, setCreatePreview] = useState<{ row: number; gates: Array<{ start: number; end: number }> } | null>(null);
|
|
71
|
+
|
|
72
|
+
const positionInRow = (event: PointerEvent<HTMLElement>) => {
|
|
73
|
+
const bounds = event.currentTarget.closest('.spagbol-sequencer-cells')?.getBoundingClientRect();
|
|
74
|
+
if (!bounds) return 0;
|
|
75
|
+
return Math.max(0, Math.min(steps, ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * steps));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const beginCreate = (event: PointerEvent<HTMLButtonElement>, row: number, step: number) => {
|
|
79
|
+
if (event.button !== 0) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
event.stopPropagation();
|
|
82
|
+
const existing = itemsForRow(row);
|
|
83
|
+
if (existing.some((gate) => step >= gate.start && step < gate.end)) return;
|
|
84
|
+
const nextStart = mode === 'gate'
|
|
85
|
+
? existing.filter((gate) => gate.start > step).reduce((boundary, gate) => Math.min(boundary, gate.start), steps)
|
|
86
|
+
: steps;
|
|
87
|
+
const end = mode === 'trigger' ? Math.min(steps, step + 1) : Math.min(nextStart, step + defaultItemLength);
|
|
88
|
+
if (end - step < 0.05) return;
|
|
89
|
+
createRef.current = {
|
|
90
|
+
pointerId: event.pointerId,
|
|
91
|
+
row,
|
|
92
|
+
anchorX: event.clientX,
|
|
93
|
+
start: step,
|
|
94
|
+
end,
|
|
95
|
+
lastStep: step,
|
|
96
|
+
positions: new Set([step]),
|
|
97
|
+
moved: false,
|
|
98
|
+
};
|
|
99
|
+
setCreatePreview({ row, gates: [{ start: step, end }] });
|
|
100
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const moveCreate = (event: PointerEvent<HTMLButtonElement>) => {
|
|
104
|
+
const create = createRef.current;
|
|
105
|
+
if (!create || create.pointerId !== event.pointerId) return;
|
|
106
|
+
if (Math.abs(event.clientX - create.anchorX) >= 3) create.moved = true;
|
|
107
|
+
if (mode === 'trigger') {
|
|
108
|
+
const nextStep = Math.max(0, Math.min(steps - 1, Math.floor(positionInRow(event))));
|
|
109
|
+
const direction = nextStep >= create.lastStep ? 1 : -1;
|
|
110
|
+
for (let step = create.lastStep; step !== nextStep + direction; step += direction) create.positions.add(step);
|
|
111
|
+
create.lastStep = nextStep;
|
|
112
|
+
const occupied = itemsForRow(create.row);
|
|
113
|
+
const gates = [...create.positions]
|
|
114
|
+
.filter((step) => !occupied.some((gate) => Math.abs(gate.start - step) < 0.000001))
|
|
115
|
+
.sort((left, right) => left - right)
|
|
116
|
+
.map((step) => ({ start: step, end: Math.min(steps, step + 1) }));
|
|
117
|
+
setCreatePreview({ row: create.row, gates });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!create.moved) return;
|
|
121
|
+
const nextStart = itemsForRow(create.row)
|
|
122
|
+
.filter((gate) => gate.start > create.start)
|
|
123
|
+
.reduce((boundary, gate) => Math.min(boundary, gate.start), steps);
|
|
124
|
+
create.end = Math.max(create.start + 0.05, Math.min(nextStart, positionInRow(event)));
|
|
125
|
+
setCreatePreview({ row: create.row, gates: [{ start: create.start, end: create.end }] });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const finishCreate = (pointerId: number) => {
|
|
129
|
+
const create = createRef.current;
|
|
130
|
+
if (!create || create.pointerId !== pointerId) return;
|
|
131
|
+
const occupied = itemsForRow(create.row);
|
|
132
|
+
const gates = mode === 'trigger'
|
|
133
|
+
? [...create.positions]
|
|
134
|
+
.filter((step) => !occupied.some((gate) => Math.abs(gate.start - step) < 0.000001))
|
|
135
|
+
.sort((left, right) => left - right)
|
|
136
|
+
.map((step) => ({ start: step, end: Math.min(steps, step + 1) }))
|
|
137
|
+
: [{ start: create.start, end: create.end }];
|
|
138
|
+
if (onCreateItems) onCreateItems(create.row, gates);
|
|
139
|
+
else for (const gate of gates) onCreateItem(create.row, gate.start, gate.end);
|
|
140
|
+
createRef.current = null;
|
|
141
|
+
setCreatePreview(null);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const beginDrag = (event: PointerEvent<HTMLElement>, row: number, gate: SpagbolSequencerItem, action: 'move' | 'left' | 'right' | 'level') => {
|
|
145
|
+
if (event.button !== 0) return;
|
|
146
|
+
event.preventDefault(); event.stopPropagation();
|
|
147
|
+
dragRef.current = { pointerId: event.pointerId, row, gate, x: event.clientX, y: event.clientY, action, moved: false };
|
|
148
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
149
|
+
};
|
|
150
|
+
const moveDrag = (event: PointerEvent<HTMLElement>) => {
|
|
151
|
+
const drag = dragRef.current;
|
|
152
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
153
|
+
const bounds = event.currentTarget.closest('.spagbol-sequencer-cells')?.getBoundingClientRect();
|
|
154
|
+
const delta = ((event.clientX - drag.x) / Math.max(1, bounds?.width ?? steps * 26)) * steps;
|
|
155
|
+
const velocityDelta = (event.clientY - drag.y) / Math.max(1, bounds?.height ?? 26);
|
|
156
|
+
drag.moved ||= Math.hypot(event.clientX - drag.x, event.clientY - drag.y) >= 3;
|
|
157
|
+
if (!drag.moved) return;
|
|
158
|
+
if (drag.action === 'level') return onUpdateItem(drag.row, drag.gate.id, { level: Math.max(minLevel, Math.min(1, drag.gate.level - velocityDelta)) });
|
|
159
|
+
const length = drag.gate.end - drag.gate.start;
|
|
160
|
+
if (mode === 'trigger') {
|
|
161
|
+
const start = Math.max(0, Math.min(steps - 1, drag.gate.start + delta));
|
|
162
|
+
return onUpdateItem(drag.row, drag.gate.id, { start, end: Math.min(steps, start + 1) });
|
|
163
|
+
}
|
|
164
|
+
const otherGates = itemsForRow(drag.row).filter((gate) => gate.id !== drag.gate.id);
|
|
165
|
+
const previousEnd = otherGates
|
|
166
|
+
.filter((gate) => gate.start < drag.gate.start)
|
|
167
|
+
.reduce((boundary, gate) => Math.max(boundary, gate.end), 0);
|
|
168
|
+
const nextStart = otherGates
|
|
169
|
+
.filter((gate) => gate.start >= drag.gate.end)
|
|
170
|
+
.reduce((boundary, gate) => Math.min(boundary, gate.start), steps);
|
|
171
|
+
if (drag.action === 'move') {
|
|
172
|
+
const start = Math.max(previousEnd, Math.min(nextStart - length, drag.gate.start + delta));
|
|
173
|
+
return onUpdateItem(drag.row, drag.gate.id, { start, end: start + length });
|
|
174
|
+
}
|
|
175
|
+
if (drag.action === 'left') return onUpdateItem(drag.row, drag.gate.id, { start: Math.max(previousEnd, Math.min(drag.gate.end - 0.05, drag.gate.start + delta)) });
|
|
176
|
+
onUpdateItem(drag.row, drag.gate.id, { end: Math.max(drag.gate.start + 0.05, Math.min(nextStart, drag.gate.end + delta)) });
|
|
177
|
+
};
|
|
178
|
+
const finishDrag = (pointerId: number) => {
|
|
179
|
+
const drag = dragRef.current;
|
|
180
|
+
if (!drag || drag.pointerId !== pointerId) return;
|
|
181
|
+
if (!drag.moved && drag.action === 'move') onDeleteItem(drag.row, drag.gate.id);
|
|
182
|
+
dragRef.current = null;
|
|
183
|
+
};
|
|
184
|
+
const endDrag = (event: PointerEvent<HTMLElement>) => finishDrag(event.pointerId);
|
|
185
|
+
const cancelDrag = (pointerId: number) => {
|
|
186
|
+
if (dragRef.current?.pointerId === pointerId) dragRef.current = null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const finishPointer = (event: globalThis.PointerEvent) => {
|
|
191
|
+
finishCreate(event.pointerId);
|
|
192
|
+
finishDrag(event.pointerId);
|
|
193
|
+
};
|
|
194
|
+
const cancelPointer = (event: globalThis.PointerEvent) => {
|
|
195
|
+
if (createRef.current?.pointerId === event.pointerId) {
|
|
196
|
+
createRef.current = null;
|
|
197
|
+
setCreatePreview(null);
|
|
198
|
+
}
|
|
199
|
+
cancelDrag(event.pointerId);
|
|
200
|
+
};
|
|
201
|
+
window.addEventListener('pointerup', finishPointer);
|
|
202
|
+
window.addEventListener('pointercancel', cancelPointer);
|
|
203
|
+
return () => {
|
|
204
|
+
window.removeEventListener('pointerup', finishPointer);
|
|
205
|
+
window.removeEventListener('pointercancel', cancelPointer);
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className={`spagbol-sequencer spagbol-sequencer-label-${labelPosition} ${className} nodrag nopan`} style={{ '--spagbol-sequencer-steps': steps } as CSSProperties} aria-label={ariaLabel} onDoubleClick={(event) => event.stopPropagation()}>
|
|
211
|
+
{Array.from({ length: rows }, (_, row) => (
|
|
212
|
+
<div className="spagbol-sequencer-row" key={row} ref={(element) => onRowRef?.(row, element)}>
|
|
213
|
+
{labelPosition === 'left' ? <div className="spagbol-sequencer-label">{renderRowLabel?.(row) ?? rowLabel(row)}</div> : null}
|
|
214
|
+
<div className="spagbol-sequencer-cells" role="row">
|
|
215
|
+
{Array.from({ length: steps }, (_, step) => (
|
|
216
|
+
<button
|
|
217
|
+
className={['spagbol-sequencer-cell', Math.floor(step / groupSize) % 2 === 1 ? 'spagbol-sequencer-cell-beat-highlight' : '', activeStep === step ? 'spagbol-sequencer-cell-current' : ''].filter(Boolean).join(' ')}
|
|
218
|
+
key={step}
|
|
219
|
+
type="button"
|
|
220
|
+
aria-label={`${rowLabel(row)}, step ${step + 1}`}
|
|
221
|
+
data-spagbol-sequencer-cell="true"
|
|
222
|
+
data-row-index={row}
|
|
223
|
+
data-step-index={step}
|
|
224
|
+
onPointerDown={(event) => beginCreate(event, row, step)}
|
|
225
|
+
onPointerMove={moveCreate}
|
|
226
|
+
onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); finishCreate(event.pointerId); }}
|
|
227
|
+
onPointerCancel={(event) => { createRef.current = null; setCreatePreview(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
|
228
|
+
onLostPointerCapture={(event) => finishCreate(event.pointerId)}
|
|
229
|
+
onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
|
230
|
+
/>
|
|
231
|
+
))}
|
|
232
|
+
{createPreview?.row === row ? createPreview.gates.map((gate, index) => (
|
|
233
|
+
<div
|
|
234
|
+
className="spagbol-sequencer-gate spagbol-sequencer-create-preview"
|
|
235
|
+
key={`${gate.start}:${index}`}
|
|
236
|
+
style={{ left: `${gate.start / steps * 100}%`, width: `${(gate.end - gate.start) / steps * 100}%` }}
|
|
237
|
+
/>
|
|
238
|
+
)) : null}
|
|
239
|
+
{itemsForRow(row).map((gate) => (
|
|
240
|
+
<div
|
|
241
|
+
className="spagbol-sequencer-gate"
|
|
242
|
+
key={gate.id}
|
|
243
|
+
style={{ top: `${(1 - gate.level) * 100}%`, left: `${gate.start / steps * 100}%`, width: `${(gate.end - gate.start) / steps * 100}%` }}
|
|
244
|
+
role="button"
|
|
245
|
+
tabIndex={0}
|
|
246
|
+
aria-label={`${rowLabel(row)} ${mode} from ${gate.start.toFixed(2)} to ${gate.end.toFixed(2)}, level ${gate.level.toFixed(2)}`}
|
|
247
|
+
onPointerDown={(event) => { if (allowMove && event.target === event.currentTarget) beginDrag(event, row, gate, 'move'); }}
|
|
248
|
+
onPointerMove={moveDrag}
|
|
249
|
+
onPointerUp={endDrag}
|
|
250
|
+
onPointerCancel={(event) => cancelDrag(event.pointerId)}
|
|
251
|
+
onLostPointerCapture={(event) => cancelDrag(event.pointerId)}
|
|
252
|
+
onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
|
253
|
+
>
|
|
254
|
+
{allowLevel ? <span className="spagbol-sequencer-level-handle" onPointerDown={(event) => beginDrag(event, row, gate, 'level')} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={(event) => cancelDrag(event.pointerId)} /> : null}
|
|
255
|
+
{allowResize ? <><span className="spagbol-sequencer-gate-handle spagbol-sequencer-gate-handle-left" onPointerDown={(event) => beginDrag(event, row, gate, 'left')} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={(event) => cancelDrag(event.pointerId)} /><span className="spagbol-sequencer-gate-handle spagbol-sequencer-gate-handle-right" onPointerDown={(event) => beginDrag(event, row, gate, 'right')} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={(event) => cancelDrag(event.pointerId)} /></> : null}
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
{labelPosition === 'right' ? <div className="spagbol-sequencer-label">{renderRowLabel?.(row) ?? rowLabel(row)}</div> : null}
|
|
260
|
+
</div>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|