@pauloricca/spagbol 0.1.1 → 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 +33 -3
- 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 +53 -14
- 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 +5 -5
- 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/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 +74 -18
- 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 +18 -13
- 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/styles.css +112 -41
- package/src/react/zoom.ts +18 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { SPAGBOL_ZOOM_BASELINE, spagbolHeaderTitleScale } from "./zoom.js";
|
|
1
2
|
import { previewNodePositions, applyNodePositions } from "./nodeDrag.js";
|
|
2
3
|
import { createPortal } from "react-dom";
|
|
3
4
|
import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
|
|
4
5
|
import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
|
|
5
6
|
import {
|
|
7
|
+
applyNodeChanges,
|
|
6
8
|
BaseEdge,
|
|
7
9
|
ConnectionLineType,
|
|
8
10
|
EdgeLabelRenderer,
|
|
@@ -147,6 +149,7 @@ export interface SpagbolCanvasProps extends SpagbolCanvasSlots {
|
|
|
147
149
|
controls?: SpagbolCanvasControlsOptions | false;
|
|
148
150
|
ariaLabel?: string;
|
|
149
151
|
}
|
|
152
|
+
const defaultGraphViewport = { x: 0, y: 0, zoom: SPAGBOL_ZOOM_BASELINE };
|
|
150
153
|
const emptyFlowNodes: FlowNode[] = [];
|
|
151
154
|
const emptyViews: Readonly<Record<string, SpagbolNodeView>> = Object.freeze({});
|
|
152
155
|
const emptyPresentations: Readonly<Record<string, SpagbolLinkPresentation>> =
|
|
@@ -342,20 +345,31 @@ function SpagbolCanvasArea({ data, selected }: NodeProps<FlowNode>) {
|
|
|
342
345
|
onPointerDown={(event) => start(event)}
|
|
343
346
|
{...pointerHandlers}
|
|
344
347
|
>
|
|
345
|
-
{titleEditing ? <input autoFocus className="spagbol-canvas-area-title-input nodrag nopan" aria-label="Area title" value={titleDraft} onFocus={event => event.currentTarget.select()} onChange={event => setTitleDraft(event.currentTarget.value)} onPointerDown={event => event.stopPropagation()} onBlur={() => { editor.updateArea(area.id, { title: titleDraft.trim() || '
|
|
348
|
+
{titleEditing ? <input autoFocus className="spagbol-canvas-area-title-input nodrag nopan" aria-label="Area title" value={titleDraft} onFocus={event => event.currentTarget.select()} onChange={event => setTitleDraft(event.currentTarget.value)} onPointerDown={event => event.stopPropagation()} onBlur={() => { editor.updateArea(area.id, { title: titleDraft.trim() || 'Container' }); setTitleEditing(false); }} onKeyDown={event => { event.stopPropagation(); if (event.key === 'Enter') event.currentTarget.blur(); if (event.key === 'Escape') { setTitleDraft(area.title); setTitleEditing(false); } }} /> : <span className="spagbol-canvas-area-title" onDoubleClick={event => { event.stopPropagation(); if (editor.editing) { setTitleDraft(area.title); setTitleEditing(true); } }}>{area.title}</span>}
|
|
346
349
|
<div
|
|
347
350
|
className="spagbol-canvas-area-actions"
|
|
348
351
|
onPointerDown={(event) => event.stopPropagation()}
|
|
349
352
|
>
|
|
350
353
|
<button
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
type="button"
|
|
355
|
+
className="spagbol-canvas-area-lock"
|
|
356
|
+
title={area.locked ? "Unlock area membership" : "Lock area membership"}
|
|
357
|
+
aria-label={area.locked ? "Unlock area membership" : "Lock area membership"}
|
|
358
|
+
aria-pressed={area.locked === true}
|
|
353
359
|
disabled={!editor.editing}
|
|
354
|
-
|
|
360
|
+
onDoubleClick={event => event.stopPropagation()}
|
|
361
|
+
onClick={event => {
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
event.stopPropagation();
|
|
364
|
+
editor.lockArea(area.id, !area.locked);
|
|
365
|
+
}}
|
|
355
366
|
>
|
|
356
|
-
|
|
367
|
+
<span className="spagbol-canvas-area-lock-icon" aria-hidden="true" />
|
|
357
368
|
</button>
|
|
358
369
|
<button
|
|
370
|
+
type="button"
|
|
371
|
+
className="spagbol-canvas-area-compact"
|
|
372
|
+
aria-pressed={area.collapsed === true}
|
|
359
373
|
title={area.collapsed ? "Expand area" : "Collapse area"}
|
|
360
374
|
aria-label={area.collapsed ? "Expand area" : "Collapse area"}
|
|
361
375
|
disabled={!editor.editing}
|
|
@@ -518,9 +532,9 @@ function SpagbolCanvasInner({
|
|
|
518
532
|
new Map<string, { width: number; height: number }>(),
|
|
519
533
|
);
|
|
520
534
|
const [measurementRevision, setMeasurementRevision] = useState(0);
|
|
521
|
-
const [zoom, setZoom] = useState(props.defaultViewport?.zoom ??
|
|
535
|
+
const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? SPAGBOL_ZOOM_BASELINE);
|
|
522
536
|
const [settledViewport, setSettledViewport] = useState<Viewport>(
|
|
523
|
-
props.defaultViewport ??
|
|
537
|
+
props.defaultViewport ?? defaultGraphViewport,
|
|
524
538
|
);
|
|
525
539
|
const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
|
|
526
540
|
const viewportMoving = useRef(false);
|
|
@@ -547,6 +561,9 @@ function SpagbolCanvasInner({
|
|
|
547
561
|
);
|
|
548
562
|
const [picker, setPicker] = useState<Picker | null>(null);
|
|
549
563
|
const [search, setSearch] = useState("");
|
|
564
|
+
const [pickerIndex, setPickerIndex] = useState(0);
|
|
565
|
+
const pickerListId = useId();
|
|
566
|
+
const pickerList = useRef<HTMLDivElement>(null);
|
|
550
567
|
const [error, setError] = useState<string | null>(null);
|
|
551
568
|
const [drawing, setDrawing] = useState<{
|
|
552
569
|
start: Point;
|
|
@@ -1207,6 +1224,11 @@ function SpagbolCanvasInner({
|
|
|
1207
1224
|
if (positions.length && nodeDrag.current) {
|
|
1208
1225
|
const next = previewNodePositions(nodeDrag.current, positions);
|
|
1209
1226
|
if (next !== nodeDrag.current) nodeDrag.current = next;
|
|
1227
|
+
// This is a controlled React Flow: recording positions in a ref does not
|
|
1228
|
+
// update native geometry. Publish only the transient node presentation;
|
|
1229
|
+
// the graph, validation and history still change once, on drop.
|
|
1230
|
+
const state = flowStore.getState();
|
|
1231
|
+
state.setNodes(applyNodeChanges(positions, state.nodes));
|
|
1210
1232
|
return;
|
|
1211
1233
|
}
|
|
1212
1234
|
if (positions.length)
|
|
@@ -1239,6 +1261,7 @@ function SpagbolCanvasInner({
|
|
|
1239
1261
|
if (!editor.editing) return;
|
|
1240
1262
|
const bounds = root.current?.getBoundingClientRect();
|
|
1241
1263
|
setSearch("");
|
|
1264
|
+
setPickerIndex(0);
|
|
1242
1265
|
setPicker({
|
|
1243
1266
|
point: flow.screenToFlowPosition(client),
|
|
1244
1267
|
screen: {
|
|
@@ -1439,7 +1462,7 @@ function SpagbolCanvasInner({
|
|
|
1439
1462
|
}
|
|
1440
1463
|
if (command && key === "0") {
|
|
1441
1464
|
event.preventDefault();
|
|
1442
|
-
void flow.zoomTo(
|
|
1465
|
+
void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
|
|
1443
1466
|
return;
|
|
1444
1467
|
}
|
|
1445
1468
|
if (key === "delete" || key === "backspace") {
|
|
@@ -1487,13 +1510,18 @@ function SpagbolCanvasInner({
|
|
|
1487
1510
|
const touch = event.changedTouches[0];
|
|
1488
1511
|
return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
|
|
1489
1512
|
}
|
|
1490
|
-
const availableNodes = [...editor.nodes.list().filter(definition => !props.nodeKinds || props.nodeKinds.includes(definition.kind)), ...(features.areas && !picker?.origin && !picker?.linkId ? [{ kind: 'spagbol.area', label: '
|
|
1513
|
+
const availableNodes = [...editor.nodes.list().filter(definition => !props.nodeKinds || props.nodeKinds.includes(definition.kind)), ...(features.areas && !picker?.origin && !picker?.linkId ? [{ kind: 'spagbol.area', label: 'Container', createData: () => ({}), ports: () => [] } as import('../core/index.js').NodeDefinition] : [])]
|
|
1491
1514
|
.filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind as typeof spagbolGroupKinds.inputs))
|
|
1492
1515
|
.filter((definition) =>
|
|
1493
1516
|
`${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
|
|
1494
1517
|
.toLowerCase()
|
|
1495
1518
|
.includes(search.toLowerCase()),
|
|
1496
1519
|
);
|
|
1520
|
+
const selectedPickerIndex = Math.min(pickerIndex, Math.max(0, availableNodes.length - 1));
|
|
1521
|
+
const selectedPickerNode = availableNodes[selectedPickerIndex];
|
|
1522
|
+
useEffect(() => {
|
|
1523
|
+
if (picker) pickerList.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1524
|
+
}, [picker, selectedPickerIndex, search]);
|
|
1497
1525
|
|
|
1498
1526
|
return (
|
|
1499
1527
|
<GraphContext.Provider value={context}>
|
|
@@ -1503,7 +1531,7 @@ function SpagbolCanvasInner({
|
|
|
1503
1531
|
style={
|
|
1504
1532
|
{
|
|
1505
1533
|
...props.style,
|
|
1506
|
-
"--spagbol-title-scale": zoom
|
|
1534
|
+
"--spagbol-title-scale": spagbolHeaderTitleScale(zoom),
|
|
1507
1535
|
"--spagbol-canvas-height": `${editorSize.height}px`,
|
|
1508
1536
|
} as CSSProperties
|
|
1509
1537
|
}
|
|
@@ -1779,7 +1807,7 @@ function SpagbolCanvasInner({
|
|
|
1779
1807
|
stroke: editor.modes.get(activeMode)?.color,
|
|
1780
1808
|
strokeWidth: 2,
|
|
1781
1809
|
}}
|
|
1782
|
-
defaultViewport={props.defaultViewport}
|
|
1810
|
+
defaultViewport={props.defaultViewport ?? defaultGraphViewport}
|
|
1783
1811
|
onInit={instance => props.onInit?.(instance as unknown as ReactFlowInstance)}
|
|
1784
1812
|
onMoveStart={() => {
|
|
1785
1813
|
viewportMoving.current = true;
|
|
@@ -1841,7 +1869,9 @@ function SpagbolCanvasInner({
|
|
|
1841
1869
|
onPointerDown={(event) => event.stopPropagation()}
|
|
1842
1870
|
>
|
|
1843
1871
|
<div className="spagbol-canvas-picker-heading">
|
|
1844
|
-
|
|
1872
|
+
<span className="spagbol-canvas-picker-title">
|
|
1873
|
+
{picker.linkId ? "Insert a node" : "Add a node"}
|
|
1874
|
+
</span>
|
|
1845
1875
|
<button
|
|
1846
1876
|
aria-label="Cancel node creation"
|
|
1847
1877
|
onClick={() => setPicker(null)}
|
|
@@ -1852,20 +1882,46 @@ function SpagbolCanvasInner({
|
|
|
1852
1882
|
<input
|
|
1853
1883
|
autoFocus
|
|
1854
1884
|
aria-label="Search node types"
|
|
1885
|
+
role="combobox"
|
|
1886
|
+
aria-expanded="true"
|
|
1887
|
+
aria-autocomplete="list"
|
|
1888
|
+
aria-controls={pickerListId}
|
|
1889
|
+
aria-activedescendant={selectedPickerNode ? `${pickerListId}-${selectedPickerIndex}` : undefined}
|
|
1855
1890
|
placeholder="Search nodes…"
|
|
1856
1891
|
value={search}
|
|
1857
|
-
onChange={(event) =>
|
|
1892
|
+
onChange={(event) => {
|
|
1893
|
+
setSearch(event.target.value);
|
|
1894
|
+
setPickerIndex(0);
|
|
1895
|
+
}}
|
|
1858
1896
|
onKeyDown={(event) => {
|
|
1859
1897
|
event.stopPropagation();
|
|
1860
|
-
if (event.
|
|
1861
|
-
if (event.key === "
|
|
1862
|
-
|
|
1898
|
+
if (event.nativeEvent.isComposing) return;
|
|
1899
|
+
if (event.key === "ArrowDown") {
|
|
1900
|
+
event.preventDefault();
|
|
1901
|
+
setPickerIndex(Math.min(selectedPickerIndex + 1, Math.max(0, availableNodes.length - 1)));
|
|
1902
|
+
} else if (event.key === "ArrowUp") {
|
|
1903
|
+
event.preventDefault();
|
|
1904
|
+
setPickerIndex(Math.max(0, selectedPickerIndex - 1));
|
|
1905
|
+
} else if (event.key === "Escape") {
|
|
1906
|
+
event.preventDefault();
|
|
1907
|
+
setPicker(null);
|
|
1908
|
+
} else if (event.key === "Enter") {
|
|
1909
|
+
event.preventDefault();
|
|
1910
|
+
if (selectedPickerNode) chooseNode(selectedPickerNode.kind);
|
|
1911
|
+
}
|
|
1863
1912
|
}}
|
|
1864
1913
|
/>
|
|
1865
|
-
<div className="spagbol-canvas-picker-list">
|
|
1866
|
-
{availableNodes.map((definition) => (
|
|
1914
|
+
<div ref={pickerList} id={pickerListId} className="spagbol-canvas-picker-list nowheel" role="listbox" aria-label="Node types">
|
|
1915
|
+
{availableNodes.map((definition, index) => (
|
|
1867
1916
|
<button
|
|
1868
1917
|
key={definition.kind}
|
|
1918
|
+
id={`${pickerListId}-${index}`}
|
|
1919
|
+
type="button"
|
|
1920
|
+
role="option"
|
|
1921
|
+
aria-selected={index === selectedPickerIndex}
|
|
1922
|
+
tabIndex={-1}
|
|
1923
|
+
onPointerMove={() => setPickerIndex(index)}
|
|
1924
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1869
1925
|
onClick={() => chooseNode(definition.kind)}
|
|
1870
1926
|
>
|
|
1871
1927
|
<span>{definition.label}</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useReactFlow } from "@xyflow/react";
|
|
2
|
-
import { isGroupBoundary } from "../core/index.js";
|
|
2
|
+
import { graphAt, isGroupBoundary } from "../core/index.js";
|
|
3
3
|
import { useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { SpagbolControlZoom } from "./SpagbolControlZoom.js";
|
|
5
5
|
import { SpagbolCanvasMenu } from "./SpagbolCanvasMenu.js";
|
|
@@ -154,11 +154,6 @@ export function SpagbolCanvasActions(options: SpagbolCanvasControlsOptions) {
|
|
|
154
154
|
void invoke(action, fromMenu);
|
|
155
155
|
}}
|
|
156
156
|
>
|
|
157
|
-
{action.checked !== undefined && fromMenu ? (
|
|
158
|
-
<span className="spagbol-canvas-menu-check" aria-hidden="true">
|
|
159
|
-
{action.checked ? "✓" : ""}
|
|
160
|
-
</span>
|
|
161
|
-
) : null}
|
|
162
157
|
{action.label}
|
|
163
158
|
</button>
|
|
164
159
|
);
|
|
@@ -237,7 +232,7 @@ export function SpagbolCanvasControls(options: SpagbolCanvasControlsOptions) {
|
|
|
237
232
|
}
|
|
238
233
|
onClick={() => editor.navigate(snapshot.path.slice(0, index + 1))}
|
|
239
234
|
>
|
|
240
|
-
{id}
|
|
235
|
+
{graphAt(snapshot.document, snapshot.path.slice(0, index)).nodes.find(node => node.id === id)?.label?.trim() || 'Group'}
|
|
241
236
|
</button>
|
|
242
237
|
))}
|
|
243
238
|
</nav>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useEffect, useRef, type PointerEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SpagbolControlJoystickProps {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
elasticity?: number;
|
|
7
|
+
className?: string;
|
|
8
|
+
onChange: (x: number, y: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SpagbolControlJoystick({ x, y, elasticity = 0, onChange, className = '' }: SpagbolControlJoystickProps) {
|
|
12
|
+
const changeRef = useRef(onChange); changeRef.current = onChange;
|
|
13
|
+
const dragPointerRef = useRef<number | null>(null);
|
|
14
|
+
const returnFrameRef = useRef<number | null>(null);
|
|
15
|
+
const returnTimeRef = useRef(0);
|
|
16
|
+
const elasticityRef = useRef(elasticity);
|
|
17
|
+
const normalizedX = clamp(x, 0, 1);
|
|
18
|
+
const normalizedY = clamp(y, 0, 1);
|
|
19
|
+
const positionRef = useRef({ x: normalizedX, y: normalizedY });
|
|
20
|
+
|
|
21
|
+
elasticityRef.current = elasticity;
|
|
22
|
+
if (dragPointerRef.current === null && returnFrameRef.current === null) {
|
|
23
|
+
positionRef.current = { x: normalizedX, y: normalizedY };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
useEffect(() => () => {
|
|
27
|
+
if (returnFrameRef.current !== null) {
|
|
28
|
+
window.cancelAnimationFrame(returnFrameRef.current);
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
function stopReturn() {
|
|
33
|
+
if (returnFrameRef.current !== null) {
|
|
34
|
+
window.cancelAnimationFrame(returnFrameRef.current);
|
|
35
|
+
returnFrameRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function startReturn() {
|
|
40
|
+
stopReturn();
|
|
41
|
+
if (!(elasticityRef.current > 0)) return;
|
|
42
|
+
returnTimeRef.current = performance.now();
|
|
43
|
+
|
|
44
|
+
function stepReturn(timestamp: number) {
|
|
45
|
+
if (dragPointerRef.current !== null || !(elasticityRef.current > 0)) {
|
|
46
|
+
returnFrameRef.current = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const elapsedSeconds = Math.min(0.05, Math.max(0, (timestamp - returnTimeRef.current) / 1000));
|
|
51
|
+
returnTimeRef.current = timestamp;
|
|
52
|
+
const current = positionRef.current;
|
|
53
|
+
const deltaX = 0.5 - current.x;
|
|
54
|
+
const deltaY = 0.5 - current.y;
|
|
55
|
+
const distance = Math.hypot(deltaX, deltaY);
|
|
56
|
+
const travel = elasticityRef.current * elapsedSeconds;
|
|
57
|
+
|
|
58
|
+
if (distance <= Math.max(0.0001, travel)) {
|
|
59
|
+
positionRef.current = { x: 0.5, y: 0.5 };
|
|
60
|
+
returnFrameRef.current = null;
|
|
61
|
+
changeRef.current(0.5, 0.5);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const scale = travel / distance;
|
|
66
|
+
const next = {
|
|
67
|
+
x: current.x + deltaX * scale,
|
|
68
|
+
y: current.y + deltaY * scale,
|
|
69
|
+
};
|
|
70
|
+
positionRef.current = next;
|
|
71
|
+
changeRef.current(next.x, next.y);
|
|
72
|
+
returnFrameRef.current = window.requestAnimationFrame(stepReturn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
returnFrameRef.current = window.requestAnimationFrame(stepReturn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateFromPointer(event: PointerEvent<HTMLDivElement>) {
|
|
79
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
80
|
+
const nextX = (event.clientX - bounds.left) / Math.max(1, bounds.width);
|
|
81
|
+
const nextY = 1 - ((event.clientY - bounds.top) / Math.max(1, bounds.height));
|
|
82
|
+
const next = { x: clamp(nextX, 0, 1), y: clamp(nextY, 0, 1) };
|
|
83
|
+
positionRef.current = next;
|
|
84
|
+
onChange(next.x, next.y);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={`spagbol-control-joystick ${className} nodrag nopan`}
|
|
90
|
+
role="group"
|
|
91
|
+
aria-label="Joystick"
|
|
92
|
+
aria-valuetext={`X ${normalizedX.toFixed(3)}, Y ${normalizedY.toFixed(3)}`}
|
|
93
|
+
onPointerDown={(event) => {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
if (!event.isPrimary || event.button !== 0) return;
|
|
97
|
+
stopReturn();
|
|
98
|
+
dragPointerRef.current = event.pointerId;
|
|
99
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
100
|
+
updateFromPointer(event);
|
|
101
|
+
}}
|
|
102
|
+
onPointerMove={(event) => {
|
|
103
|
+
if (dragPointerRef.current !== event.pointerId) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
updateFromPointer(event);
|
|
107
|
+
}}
|
|
108
|
+
onPointerUp={(event) => {
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
111
|
+
dragPointerRef.current = null;
|
|
112
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
113
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
114
|
+
}
|
|
115
|
+
startReturn();
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
onPointerCancel={(event) => {
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
121
|
+
dragPointerRef.current = null;
|
|
122
|
+
startReturn();
|
|
123
|
+
}
|
|
124
|
+
}}
|
|
125
|
+
onLostPointerCapture={(event) => {
|
|
126
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
127
|
+
dragPointerRef.current = null;
|
|
128
|
+
startReturn();
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
onClick={(event) => event.stopPropagation()}
|
|
132
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
className="spagbol-control-joystick-handle"
|
|
136
|
+
style={{
|
|
137
|
+
left: `calc(${normalizedX} * (100% - var(--joystick-handle-size)))`,
|
|
138
|
+
top: `calc(${1 - normalizedY} * (100% - var(--joystick-handle-size)))`,
|
|
139
|
+
}}
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, Number.isFinite(value) ? value : 0.5)); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SPAGBOL_ZOOM_BASELINE, spagbolZoomPercentage } from "./zoom.js";
|
|
1
2
|
import { ControlButton, Controls, useReactFlow } from "@xyflow/react";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
|
|
@@ -17,10 +18,10 @@ export function SpagbolControlZoom({
|
|
|
17
18
|
layout = "flow",
|
|
18
19
|
}: SpagbolControlZoomProps) {
|
|
19
20
|
const flow = useReactFlow();
|
|
20
|
-
const percentage =
|
|
21
|
+
const percentage = spagbolZoomPercentage(zoom);
|
|
21
22
|
const reset = () => {
|
|
22
23
|
if (onReset) onReset();
|
|
23
|
-
else void flow.zoomTo(
|
|
24
|
+
else void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
|
|
24
25
|
};
|
|
25
26
|
const label = `Reset zoom to 100% (currently ${percentage})`;
|
|
26
27
|
if (layout === "flow")
|
|
@@ -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
|
+
}
|
|
@@ -205,32 +205,37 @@ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
|
|
|
205
205
|
<span className="spagbol-node-frame-title">
|
|
206
206
|
{props.title ?? <SpagbolNodeFrameTypePicker node={node} compact={!!compact} selected={selected} disabled={projected || props.readOnly || !editor.editing} />}
|
|
207
207
|
</span>
|
|
208
|
-
{!projected &&
|
|
209
|
-
{!projected && features.compact && !props.forceCompact && view?.compact !== false && (
|
|
208
|
+
{!projected && node.graph && features.nestedGraphs && (
|
|
210
209
|
<button
|
|
211
|
-
className="spagbol-node-frame-header-button nodrag nopan"
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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"
|
|
215
216
|
onClick={(event) => {
|
|
216
217
|
event.stopPropagation();
|
|
217
|
-
editor.
|
|
218
|
+
editor.enter(node.id);
|
|
218
219
|
}}
|
|
219
220
|
>
|
|
220
|
-
|
|
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>
|
|
221
224
|
</button>
|
|
222
225
|
)}
|
|
223
|
-
{!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 && (
|
|
224
228
|
<button
|
|
225
229
|
className="spagbol-node-frame-header-button nodrag nopan"
|
|
226
|
-
aria-label="
|
|
227
|
-
title="
|
|
230
|
+
aria-label={node.compact ? "Expand ports" : "Compact ports"}
|
|
231
|
+
title={node.compact ? "Expand ports" : "Compact ports"}
|
|
232
|
+
disabled={!editor.editing}
|
|
228
233
|
onClick={(event) => {
|
|
229
234
|
event.stopPropagation();
|
|
230
|
-
editor.
|
|
235
|
+
editor.updateNode(node.id, { compact: !node.compact }, "layout");
|
|
231
236
|
}}
|
|
232
237
|
>
|
|
233
|
-
|
|
238
|
+
{node.compact ? "+" : "−"}
|
|
234
239
|
</button>
|
|
235
240
|
)}
|
|
236
241
|
</div>
|
|
@@ -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]);
|