@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.
Files changed (66) hide show
  1. package/README.md +42 -4
  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 +106 -29
  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 +18 -16
  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/nodeDrag.d.ts +7 -0
  39. package/dist/react/nodeDrag.d.ts.map +1 -0
  40. package/dist/react/nodeDrag.js +27 -0
  41. package/dist/react/nodeDrag.js.map +1 -0
  42. package/dist/react/zoom.d.ts +5 -0
  43. package/dist/react/zoom.d.ts.map +1 -0
  44. package/dist/react/zoom.js +19 -0
  45. package/dist/react/zoom.js.map +1 -0
  46. package/dist/styles.css +112 -41
  47. package/docs/language/standard-node-comparison.md +92 -0
  48. package/docs/language/standard-node-contracts.md +237 -0
  49. package/docs/language/standard-nodes.md +198 -0
  50. package/docs/language/ui-components.md +79 -0
  51. package/package.json +5 -3
  52. package/src/core/controller.ts +4 -4
  53. package/src/core/model.ts +2 -1
  54. package/src/react/SpagbolCanvas.tsx +121 -31
  55. package/src/react/SpagbolCanvasControls.tsx +2 -7
  56. package/src/react/SpagbolControlJoystick.tsx +147 -0
  57. package/src/react/SpagbolControlZoom.tsx +3 -2
  58. package/src/react/SpagbolCurveEditor.tsx +166 -0
  59. package/src/react/SpagbolNodeFrame.tsx +30 -24
  60. package/src/react/SpagbolNodeFrameTypePicker.tsx +1 -1
  61. package/src/react/SpagbolSequencer.tsx +265 -0
  62. package/src/react/editors.css +157 -0
  63. package/src/react/index.ts +4 -0
  64. package/src/react/nodeDrag.ts +33 -0
  65. package/src/react/styles.css +112 -41
  66. package/src/react/zoom.ts +18 -0
@@ -1,7 +1,10 @@
1
+ import { SPAGBOL_ZOOM_BASELINE, spagbolHeaderTitleScale } from "./zoom.js";
2
+ import { previewNodePositions, applyNodePositions } from "./nodeDrag.js";
1
3
  import { createPortal } from "react-dom";
2
4
  import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
3
5
  import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
4
6
  import {
7
+ applyNodeChanges,
5
8
  BaseEdge,
6
9
  ConnectionLineType,
7
10
  EdgeLabelRenderer,
@@ -146,6 +149,8 @@ export interface SpagbolCanvasProps extends SpagbolCanvasSlots {
146
149
  controls?: SpagbolCanvasControlsOptions | false;
147
150
  ariaLabel?: string;
148
151
  }
152
+ const defaultGraphViewport = { x: 0, y: 0, zoom: SPAGBOL_ZOOM_BASELINE };
153
+ const emptyFlowNodes: FlowNode[] = [];
149
154
  const emptyViews: Readonly<Record<string, SpagbolNodeView>> = Object.freeze({});
150
155
  const emptyPresentations: Readonly<Record<string, SpagbolLinkPresentation>> =
151
156
  Object.freeze({});
@@ -340,20 +345,31 @@ function SpagbolCanvasArea({ data, selected }: NodeProps<FlowNode>) {
340
345
  onPointerDown={(event) => start(event)}
341
346
  {...pointerHandlers}
342
347
  >
343
- {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>}
344
349
  <div
345
350
  className="spagbol-canvas-area-actions"
346
351
  onPointerDown={(event) => event.stopPropagation()}
347
352
  >
348
353
  <button
349
- title={area.locked ? "Unlock membership" : "Lock membership"}
350
- 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}
351
359
  disabled={!editor.editing}
352
- 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
+ }}
353
366
  >
354
- {area.locked ? "●" : "○"}
367
+ <span className="spagbol-canvas-area-lock-icon" aria-hidden="true" />
355
368
  </button>
356
369
  <button
370
+ type="button"
371
+ className="spagbol-canvas-area-compact"
372
+ aria-pressed={area.collapsed === true}
357
373
  title={area.collapsed ? "Expand area" : "Collapse area"}
358
374
  aria-label={area.collapsed ? "Expand area" : "Collapse area"}
359
375
  disabled={!editor.editing}
@@ -404,8 +420,8 @@ const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props: EdgeProps<FlowL
404
420
  const flow = useReactFlow();
405
421
  const viewport = useViewport();
406
422
  const [controlsVisible, setControlsVisible] = useState(false);
407
- const selection = useEditorSelector(editor, snapshot => snapshot.selection);
408
- const showControls = props.selected && selection.linkIds.length === 1 && editor.editing && !connecting && !props.data?.virtual;
423
+ const selectedLinkId = useEditorSelector(editor, snapshot => snapshot.selection.linkIds.length === 1 ? snapshot.selection.linkIds[0] : undefined);
424
+ const showControls = props.selected && selectedLinkId === props.id && editor.editing && !connecting && !props.data?.virtual;
409
425
  useEffect(() => {
410
426
  if (!showControls) { setControlsVisible(false); return; }
411
427
  const timer = window.setTimeout(() => setControlsVisible(true), 260);
@@ -507,16 +523,18 @@ function SpagbolCanvasInner({
507
523
  const flow = useReactFlow<FlowNode, FlowLink>();
508
524
  const flowStore = useStoreApi();
509
525
  const nodesInitialized = useNodesInitialized();
510
- const flowNodes = useStore((state) => state.nodes);
526
+ // Only cable-property anchors need live native geometry. Subscribing to every
527
+ // position change otherwise causes redundant full-canvas renders during drags.
528
+ const flowNodes = useStore((state) => features.linkProperties ? state.nodes : emptyFlowNodes);
511
529
  // Measurements are canvas state, not saved graph data. Retaining them keeps
512
530
  // React Flow from hiding/reinitializing every node on each drag preview frame.
513
531
  const measuredSizes = useRef(
514
532
  new Map<string, { width: number; height: number }>(),
515
533
  );
516
534
  const [measurementRevision, setMeasurementRevision] = useState(0);
517
- const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? 1);
535
+ const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? SPAGBOL_ZOOM_BASELINE);
518
536
  const [settledViewport, setSettledViewport] = useState<Viewport>(
519
- props.defaultViewport ?? { x: 0, y: 0, zoom: 1 },
537
+ props.defaultViewport ?? defaultGraphViewport,
520
538
  );
521
539
  const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
522
540
  const viewportMoving = useRef(false);
@@ -543,6 +561,9 @@ function SpagbolCanvasInner({
543
561
  );
544
562
  const [picker, setPicker] = useState<Picker | null>(null);
545
563
  const [search, setSearch] = useState("");
564
+ const [pickerIndex, setPickerIndex] = useState(0);
565
+ const pickerListId = useId();
566
+ const pickerList = useRef<HTMLDivElement>(null);
546
567
  const [error, setError] = useState<string | null>(null);
547
568
  const [drawing, setDrawing] = useState<{
548
569
  start: Point;
@@ -559,10 +580,25 @@ function SpagbolCanvasInner({
559
580
  const duplicate = useRef<DuplicateDrag | null>(null);
560
581
  const [duplicatePreview, setDuplicatePreview] =
561
582
  useState<DuplicateDrag | null>(null);
562
- const cancelledDuplicate = useRef(false);
583
+ const cancelledNodeDrag = useRef(false);
584
+ // Position previews belong to the canvas, not the persistent document. A drag
585
+ // commits once, avoiding whole-document edits/validation on every pointer move.
586
+ const nodeDrag = useRef<ReadonlyMap<string, Point> | null>(null);
587
+ const [positionRevision, setPositionRevision] = useState(0);
588
+ const clearNodeDrag = () => {
589
+ nodeDrag.current = null;
590
+ // Force a controlled refresh on cancellation, even though the saved position
591
+ // never changed: React Flow currently holds the transient pointer position.
592
+ setPositionRevision(value => value + 1);
593
+ };
594
+
563
595
  useEffect(() => {
596
+ if (!snapshot.gesture && nodeDrag.current) {
597
+ cancelledNodeDrag.current = true;
598
+ clearNodeDrag();
599
+ }
564
600
  if (!snapshot.gesture && duplicate.current) {
565
- cancelledDuplicate.current = true;
601
+ cancelledNodeDrag.current = true;
566
602
  duplicate.current = null;
567
603
  setDuplicatePreview(null);
568
604
  }
@@ -612,7 +648,7 @@ function SpagbolCanvasInner({
612
648
  if (phase === 'start') {
613
649
  const area = editor.graph.areas.find(area => area.id === id);
614
650
  if (!area) return;
615
- cancelledDuplicate.current = false;
651
+ cancelledNodeDrag.current = false;
616
652
  const fragment = copyFragment(editor.graph, [], [id]);
617
653
  duplicate.current = { namespace: createId('preview'), fragment, nodeId: '', origin: area.position, positions: new Map(fragment.nodes.map(node => [node.id, node.position])), areaPositions: new Map(fragment.areas.map(area => [area.id, area.position])), external };
618
654
  setDuplicatePreview(duplicate.current);
@@ -723,7 +759,7 @@ function SpagbolCanvasInner({
723
759
  (node): FlowNode => ({
724
760
  id: nodeFlowId(node.id),
725
761
  type: "spagbolNode",
726
- position: node.position,
762
+ position: nodeDrag.current?.get(node.id) ?? node.position,
727
763
  selected: snapshot.selection.nodeIds.includes(node.id),
728
764
  hidden: collapsed.nodes.has(node.id),
729
765
  draggable:
@@ -742,6 +778,7 @@ function SpagbolCanvasInner({
742
778
  deletable: false,
743
779
  dragHandle: ".spagbol-canvas-drag-handle",
744
780
  data: {
781
+ positionRevision,
745
782
  node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
746
783
  projected: collapsed.projected.has(node.id),
747
784
  stationaryOriginal: duplicatePresentation?.originalNodes.has(
@@ -770,6 +807,7 @@ function SpagbolCanvasInner({
770
807
  ],
771
808
  [
772
809
  renderGraph,
810
+ positionRevision,
773
811
  duplicatePresentation,
774
812
  snapshot.selection,
775
813
  snapshot.mode,
@@ -836,6 +874,7 @@ function SpagbolCanvasInner({
836
874
  collapsed,
837
875
  flow,
838
876
  measurementKey,
877
+ measurementRevision,
839
878
  settledViewport.zoom,
840
879
  editorSize,
841
880
  props.panBounds,
@@ -1181,7 +1220,17 @@ function SpagbolCanvasInner({
1181
1220
  setDuplicatePreview(next);
1182
1221
  return;
1183
1222
  }
1184
- if (cancelledDuplicate.current) return;
1223
+ if (cancelledNodeDrag.current) return;
1224
+ if (positions.length && nodeDrag.current) {
1225
+ const next = previewNodePositions(nodeDrag.current, positions);
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));
1232
+ return;
1233
+ }
1185
1234
  if (positions.length)
1186
1235
  editor.edit("Move nodes", "layout", (graph) => {
1187
1236
  for (const change of positions)
@@ -1212,6 +1261,7 @@ function SpagbolCanvasInner({
1212
1261
  if (!editor.editing) return;
1213
1262
  const bounds = root.current?.getBoundingClientRect();
1214
1263
  setSearch("");
1264
+ setPickerIndex(0);
1215
1265
  setPicker({
1216
1266
  point: flow.screenToFlowPosition(client),
1217
1267
  screen: {
@@ -1362,7 +1412,8 @@ function SpagbolCanvasInner({
1362
1412
  const command = event.metaKey || event.ctrlKey,
1363
1413
  key = event.key.toLowerCase();
1364
1414
  if (key === "escape") {
1365
- if (duplicate.current) cancelledDuplicate.current = true;
1415
+ if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
1416
+ if (nodeDrag.current) clearNodeDrag();
1366
1417
  duplicate.current = null;
1367
1418
  setDuplicatePreview(null);
1368
1419
  editor.cancelGesture();
@@ -1411,7 +1462,7 @@ function SpagbolCanvasInner({
1411
1462
  }
1412
1463
  if (command && key === "0") {
1413
1464
  event.preventDefault();
1414
- void flow.zoomTo(1);
1465
+ void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
1415
1466
  return;
1416
1467
  }
1417
1468
  if (key === "delete" || key === "backspace") {
@@ -1459,13 +1510,18 @@ function SpagbolCanvasInner({
1459
1510
  const touch = event.changedTouches[0];
1460
1511
  return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
1461
1512
  }
1462
- 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] : [])]
1463
1514
  .filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind as typeof spagbolGroupKinds.inputs))
1464
1515
  .filter((definition) =>
1465
1516
  `${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
1466
1517
  .toLowerCase()
1467
1518
  .includes(search.toLowerCase()),
1468
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]);
1469
1525
 
1470
1526
  return (
1471
1527
  <GraphContext.Provider value={context}>
@@ -1475,7 +1531,7 @@ function SpagbolCanvasInner({
1475
1531
  style={
1476
1532
  {
1477
1533
  ...props.style,
1478
- "--spagbol-title-scale": zoom < 0.3 ? 1.75 : zoom < 0.5 ? 1.3 : 1,
1534
+ "--spagbol-title-scale": spagbolHeaderTitleScale(zoom),
1479
1535
  "--spagbol-canvas-height": `${editorSize.height}px`,
1480
1536
  } as CSSProperties
1481
1537
  }
@@ -1539,7 +1595,8 @@ function SpagbolCanvasInner({
1539
1595
  event.currentTarget.releasePointerCapture(event.pointerId);
1540
1596
  }}
1541
1597
  onPointerCancel={() => {
1542
- if (duplicate.current) cancelledDuplicate.current = true;
1598
+ if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
1599
+ if (nodeDrag.current) clearNodeDrag();
1543
1600
  duplicate.current = null;
1544
1601
  setDuplicatePreview(null);
1545
1602
  setDrawing(null);
@@ -1569,10 +1626,11 @@ function SpagbolCanvasInner({
1569
1626
  }}
1570
1627
  onNodeDragStart={(event, node) => {
1571
1628
  if (!node.data.node) return;
1572
- cancelledDuplicate.current = false;
1629
+ cancelledNodeDrag.current = false;
1573
1630
  editor.beginGesture(
1574
1631
  event.altKey ? "Duplicate nodes" : "Move nodes",
1575
1632
  );
1633
+ if (!event.altKey) nodeDrag.current = new Map();
1576
1634
  if (event.altKey) {
1577
1635
  const selection = editor.getSnapshot().selection;
1578
1636
  const ids = selection.nodeIds.includes(node.data.node.id)
@@ -1594,11 +1652,13 @@ function SpagbolCanvasInner({
1594
1652
  }
1595
1653
  }}
1596
1654
  onNodeDragStop={(_event, node) => {
1655
+ const positions = nodeDrag.current;
1656
+ if (positions) clearNodeDrag();
1597
1657
  const current = duplicate.current;
1598
1658
  duplicate.current = null;
1599
1659
  setDuplicatePreview(null);
1600
- if (cancelledDuplicate.current) {
1601
- cancelledDuplicate.current = false;
1660
+ if (cancelledNodeDrag.current) {
1661
+ cancelledNodeDrag.current = false;
1602
1662
  editor.cancelGesture();
1603
1663
  editor.select(editor.getSnapshot().selection);
1604
1664
  return;
@@ -1620,6 +1680,8 @@ function SpagbolCanvasInner({
1620
1680
  return;
1621
1681
  }
1622
1682
  }
1683
+ if (positions?.size)
1684
+ editor.edit("Move nodes", "layout", graph => applyNodePositions(graph, positions));
1623
1685
  editor.commitGesture();
1624
1686
  }}
1625
1687
  onConnectStart={(_event, params) => {
@@ -1745,7 +1807,7 @@ function SpagbolCanvasInner({
1745
1807
  stroke: editor.modes.get(activeMode)?.color,
1746
1808
  strokeWidth: 2,
1747
1809
  }}
1748
- defaultViewport={props.defaultViewport}
1810
+ defaultViewport={props.defaultViewport ?? defaultGraphViewport}
1749
1811
  onInit={instance => props.onInit?.(instance as unknown as ReactFlowInstance)}
1750
1812
  onMoveStart={() => {
1751
1813
  viewportMoving.current = true;
@@ -1807,7 +1869,9 @@ function SpagbolCanvasInner({
1807
1869
  onPointerDown={(event) => event.stopPropagation()}
1808
1870
  >
1809
1871
  <div className="spagbol-canvas-picker-heading">
1810
- {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>
1811
1875
  <button
1812
1876
  aria-label="Cancel node creation"
1813
1877
  onClick={() => setPicker(null)}
@@ -1818,20 +1882,46 @@ function SpagbolCanvasInner({
1818
1882
  <input
1819
1883
  autoFocus
1820
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}
1821
1890
  placeholder="Search nodes…"
1822
1891
  value={search}
1823
- onChange={(event) => setSearch(event.target.value)}
1892
+ onChange={(event) => {
1893
+ setSearch(event.target.value);
1894
+ setPickerIndex(0);
1895
+ }}
1824
1896
  onKeyDown={(event) => {
1825
1897
  event.stopPropagation();
1826
- if (event.key === "Escape") setPicker(null);
1827
- if (event.key === "Enter" && availableNodes[0])
1828
- 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
+ }
1829
1912
  }}
1830
1913
  />
1831
- <div className="spagbol-canvas-picker-list">
1832
- {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) => (
1833
1916
  <button
1834
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()}
1835
1925
  onClick={() => chooseNode(definition.kind)}
1836
1926
  >
1837
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")