@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.
Files changed (61) hide show
  1. package/README.md +33 -3
  2. package/dist/core/controller.js +4 -4
  3. package/dist/core/controller.js.map +1 -1
  4. package/dist/core/model.d.ts +2 -1
  5. package/dist/core/model.d.ts.map +1 -1
  6. package/dist/core/model.js.map +1 -1
  7. package/dist/editors.css +157 -0
  8. package/dist/react/SpagbolCanvas.d.ts.map +1 -1
  9. package/dist/react/SpagbolCanvas.js +53 -14
  10. package/dist/react/SpagbolCanvas.js.map +1 -1
  11. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -1
  12. package/dist/react/SpagbolCanvasControls.js +5 -5
  13. package/dist/react/SpagbolCanvasControls.js.map +1 -1
  14. package/dist/react/SpagbolControlJoystick.d.ts +9 -0
  15. package/dist/react/SpagbolControlJoystick.d.ts.map +1 -0
  16. package/dist/react/SpagbolControlJoystick.js +111 -0
  17. package/dist/react/SpagbolControlJoystick.js.map +1 -0
  18. package/dist/react/SpagbolControlZoom.d.ts.map +1 -1
  19. package/dist/react/SpagbolControlZoom.js +3 -2
  20. package/dist/react/SpagbolControlZoom.js.map +1 -1
  21. package/dist/react/SpagbolCurveEditor.d.ts +45 -0
  22. package/dist/react/SpagbolCurveEditor.d.ts.map +1 -0
  23. package/dist/react/SpagbolCurveEditor.js +142 -0
  24. package/dist/react/SpagbolCurveEditor.js.map +1 -0
  25. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -1
  26. package/dist/react/SpagbolNodeFrame.js +5 -5
  27. package/dist/react/SpagbolNodeFrame.js.map +1 -1
  28. package/dist/react/SpagbolNodeFrameTypePicker.js +1 -1
  29. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -1
  30. package/dist/react/SpagbolSequencer.d.ts +35 -0
  31. package/dist/react/SpagbolSequencer.d.ts.map +1 -0
  32. package/dist/react/SpagbolSequencer.js +166 -0
  33. package/dist/react/SpagbolSequencer.js.map +1 -0
  34. package/dist/react/index.d.ts +3 -0
  35. package/dist/react/index.d.ts.map +1 -1
  36. package/dist/react/index.js +3 -0
  37. package/dist/react/index.js.map +1 -1
  38. package/dist/react/zoom.d.ts +5 -0
  39. package/dist/react/zoom.d.ts.map +1 -0
  40. package/dist/react/zoom.js +19 -0
  41. package/dist/react/zoom.js.map +1 -0
  42. package/dist/styles.css +112 -41
  43. package/docs/language/standard-node-comparison.md +92 -0
  44. package/docs/language/standard-node-contracts.md +237 -0
  45. package/docs/language/standard-nodes.md +198 -0
  46. package/docs/language/ui-components.md +79 -0
  47. package/package.json +5 -3
  48. package/src/core/controller.ts +4 -4
  49. package/src/core/model.ts +2 -1
  50. package/src/react/SpagbolCanvas.tsx +74 -18
  51. package/src/react/SpagbolCanvasControls.tsx +2 -7
  52. package/src/react/SpagbolControlJoystick.tsx +147 -0
  53. package/src/react/SpagbolControlZoom.tsx +3 -2
  54. package/src/react/SpagbolCurveEditor.tsx +166 -0
  55. package/src/react/SpagbolNodeFrame.tsx +18 -13
  56. package/src/react/SpagbolNodeFrameTypePicker.tsx +1 -1
  57. package/src/react/SpagbolSequencer.tsx +265 -0
  58. package/src/react/editors.css +157 -0
  59. package/src/react/index.ts +4 -0
  60. package/src/react/styles.css +112 -41
  61. 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() || 'Area' }); 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>}
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
- title={area.locked ? "Unlock membership" : "Lock membership"}
352
- aria-label={area.locked ? "Unlock membership" : "Lock membership"}
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
- onClick={() => editor.lockArea(area.id, !area.locked)}
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
- {area.locked ? "●" : "○"}
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 ?? 1);
535
+ const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? SPAGBOL_ZOOM_BASELINE);
522
536
  const [settledViewport, setSettledViewport] = useState<Viewport>(
523
- props.defaultViewport ?? { x: 0, y: 0, zoom: 1 },
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(1);
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: 'Area', createData: () => ({}), ports: () => [] } as import('../core/index.js').NodeDefinition] : [])]
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 < 0.3 ? 1.75 : zoom < 0.5 ? 1.3 : 1,
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
- {picker.linkId ? "insert a node" : "add a node"}
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) => setSearch(event.target.value)}
1892
+ onChange={(event) => {
1893
+ setSearch(event.target.value);
1894
+ setPickerIndex(0);
1895
+ }}
1858
1896
  onKeyDown={(event) => {
1859
1897
  event.stopPropagation();
1860
- if (event.key === "Escape") setPicker(null);
1861
- if (event.key === "Enter" && availableNodes[0])
1862
- chooseNode(availableNodes[0].kind);
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 = `${Math.round(zoom * 100)}%`;
21
+ const percentage = spagbolZoomPercentage(zoom);
21
22
  const reset = () => {
22
23
  if (onReset) onReset();
23
- else void flow.zoomTo(1);
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 && <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>}
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
- aria-label={node.compact ? "Expand ports" : "Compact ports"}
213
- title={node.compact ? "Expand ports" : "Compact ports"}
214
- disabled={!editor.editing}
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.updateNode(node.id, { compact: !node.compact }, "layout");
218
+ editor.enter(node.id);
218
219
  }}
219
220
  >
220
- {node.compact ? "+" : "−"}
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.graph && features.nestedGraphs && (
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="Enter nested graph"
227
- title="Enter nested graph"
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.enter(node.id);
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: 'Area' }] : [])].filter(item =>
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]);