@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
@@ -0,0 +1,157 @@
1
+ /* Controlled editors extracted from Teia; hosts own storage and execution. */
2
+ .spagbol-curve-editor {
3
+ display: grid;
4
+ grid-column: 1 / -1;
5
+ gap: 0;
6
+ width: 100%;
7
+ min-width: 0;
8
+ }
9
+
10
+ .spagbol-curve-editor-chart {
11
+ position: relative;
12
+ height: var(--node-display-height, 128px);
13
+ overflow: hidden;
14
+ }
15
+
16
+ .spagbol-curve-editor-canvas {
17
+ position: relative;
18
+ z-index: 1;
19
+ display: block;
20
+ width: 100%;
21
+ height: 100%;
22
+ border: 0;
23
+ background: transparent;
24
+ cursor: crosshair;
25
+ touch-action: none;
26
+ user-select: none;
27
+ -webkit-user-select: none;
28
+ }
29
+
30
+ .spagbol-curve-editor-value-label {
31
+ --spagbol-curve-editor-value-offset: calc(7px * var(--graph-screen-scale, 1));
32
+ position: absolute;
33
+ z-index: 3;
34
+ padding: calc(2px * var(--graph-screen-scale, 1)) calc(4px * var(--graph-screen-scale, 1));
35
+ transform: translate(var(--spagbol-curve-editor-value-offset), calc(-100% - var(--spagbol-curve-editor-value-offset)));
36
+ border: calc(1px * var(--graph-screen-scale, 1)) solid var(--color-foreground-62, rgba(255,255,255,.62));
37
+ background: var(--color-background, var(--spagbol-bg, #000));
38
+ color: var(--color-foreground, var(--spagbol-foreground, #fff));
39
+ font-size: calc(1.1em * var(--graph-screen-scale, 1));
40
+ font-variant-numeric: tabular-nums;
41
+ line-height: 1;
42
+ white-space: nowrap;
43
+ pointer-events: none;
44
+ }
45
+
46
+ .spagbol-curve-editor-value-label.is-left {
47
+ transform: translate(calc(-100% - var(--spagbol-curve-editor-value-offset)), calc(-100% - var(--spagbol-curve-editor-value-offset)));
48
+ }
49
+
50
+ .spagbol-curve-editor-value-label.is-below {
51
+ transform: translate(var(--spagbol-curve-editor-value-offset), var(--spagbol-curve-editor-value-offset));
52
+ }
53
+
54
+ .spagbol-curve-editor-value-label.is-left.is-below {
55
+ transform: translate(calc(-100% - var(--spagbol-curve-editor-value-offset)), var(--spagbol-curve-editor-value-offset));
56
+ }
57
+
58
+ .spagbol-curve-editor-chrome {
59
+ pointer-events: none;
60
+ }
61
+
62
+ .spagbol-curve-editor-path {
63
+ fill: none;
64
+ stroke: var(--color-accent, var(--spagbol-accent, #2fd061));
65
+ stroke-linecap: square;
66
+ stroke-linejoin: round;
67
+ stroke-width: calc(2px * var(--graph-stroke-scale, 1));
68
+ vector-effect: non-scaling-stroke;
69
+ }
70
+ .spagbol-curve-editor-path.is-background { opacity: .38; }
71
+ .spagbol-curve-editor-hit-target {
72
+ fill: transparent;
73
+ cursor: grab;
74
+ pointer-events: all;
75
+ }
76
+
77
+ .spagbol-curve-editor-hit-target.is-locked {
78
+ cursor: default;
79
+ }
80
+
81
+ .spagbol-curve-editor-handle {
82
+ fill: var(--color-background, var(--spagbol-bg, #000));
83
+ stroke: var(--color-accent, var(--spagbol-accent, #2fd061));
84
+ stroke-width: calc(2px * var(--graph-stroke-scale, 1) * var(--spagbol-curve-editor-point-scale, 1));
85
+ cursor: grab;
86
+ pointer-events: none;
87
+ vector-effect: non-scaling-stroke;
88
+ }
89
+
90
+ .spagbol-curve-editor-endpoint {
91
+ stroke: var(--color-foreground, var(--spagbol-foreground, #fff));
92
+ }
93
+
94
+ .spagbol-curve-editor-handle.is-locked {
95
+ cursor: default;
96
+ }
97
+
98
+
99
+ .spagbol-sequencer { display: grid; grid-auto-rows: minmax(0, 1fr); width: 100%; min-width: 0; }
100
+ .spagbol-sequencer-row { display: grid; grid-template-columns: 32px minmax(0, 1fr); min-width: 0; }
101
+ .spagbol-sequencer-label-right .spagbol-sequencer-row { grid-template-columns: minmax(0, 1fr) var(--spagbol-sequencer-label-width, 44px); }
102
+ .spagbol-sequencer-label { display: flex; align-items: center; justify-content: flex-end; padding-right: 5px; }
103
+ .spagbol-sequencer-label-right .spagbol-sequencer-label { justify-content: flex-start; padding-right: 0; }
104
+ .spagbol-sequencer-cells { position: relative; display: grid; grid-template-columns: repeat(var(--spagbol-sequencer-steps), minmax(0, 1fr)); min-width: 0; overflow: hidden; }
105
+ .spagbol-sequencer-cell { min-width: 0; padding: 0; border: 0; background: transparent; }
106
+ .spagbol-sequencer-gate { position: absolute; z-index: 2; bottom: 0; min-width: 2px; background: var(--color-accent, var(--spagbol-accent, #2fd061)); cursor: grab; touch-action: none; }
107
+ .spagbol-sequencer-create-preview { pointer-events: none; opacity: 0.7; }
108
+ .spagbol-sequencer-gate-handle { position: absolute; top: 0; bottom: 0; width: 7px; cursor: ew-resize; }
109
+ .spagbol-sequencer-gate-handle-left { left: 0; }.spagbol-sequencer-gate-handle-right { right: 0; }
110
+ .spagbol-sequencer-level-handle { position: absolute; top: 0; left: 0; right: 0; height: 7px; cursor: ns-resize; }
111
+
112
+
113
+ .spagbol-sequencer .spagbol-sequencer-cells { height: 100%; }
114
+ .spagbol-sequencer .spagbol-sequencer-cell {
115
+ width: 100%;
116
+ height: 100%;
117
+ border-right: 1px solid var(--color-foreground-44, rgba(255,255,255,.44));
118
+ border-bottom: 1px solid var(--color-foreground-44, rgba(255,255,255,.44));
119
+ box-sizing: border-box;
120
+ cursor: pointer;
121
+ }
122
+ .spagbol-sequencer .spagbol-sequencer-row:first-child .spagbol-sequencer-cell { border-top: 1px solid var(--color-foreground-44, rgba(255,255,255,.44)); }
123
+ .spagbol-sequencer .spagbol-sequencer-cell:first-child { border-left: 1px solid var(--color-foreground-44, rgba(255,255,255,.44)); }
124
+ .spagbol-sequencer .spagbol-sequencer-cell-beat-highlight { background: var(--color-foreground-15, rgba(255,255,255,.15)); }
125
+ .spagbol-sequencer .spagbol-sequencer-cell:hover,
126
+ .spagbol-sequencer .spagbol-sequencer-cell:focus-visible { background: var(--color-foreground-20, rgba(255,255,255,.2)); outline: 0; }
127
+ .spagbol-sequencer .spagbol-sequencer-cell-current {
128
+ background: var(--color-foreground-12, rgba(255,255,255,.12));
129
+ box-shadow: inset 0 0 0 2px var(--color-foreground-28, rgba(255,255,255,.28));
130
+ }
131
+ .spagbol-sequencer .spagbol-sequencer-gate { border: 0; border-radius: 0; box-shadow: none; }
132
+
133
+
134
+ .spagbol-control-joystick {
135
+ --joystick-handle-size: 12px;
136
+ position: relative;
137
+ height: var(--node-display-height, 120px);
138
+ min-width: 0;
139
+ box-sizing: border-box;
140
+ border: calc(1px * var(--graph-stroke-scale, 1)) solid var(--color-foreground, var(--spagbol-foreground, #fff));
141
+ background: transparent;
142
+ cursor: crosshair;
143
+ overflow: hidden;
144
+ touch-action: none;
145
+ user-select: none;
146
+ }
147
+
148
+ .spagbol-control-joystick-handle {
149
+ position: absolute;
150
+ width: var(--joystick-handle-size);
151
+ height: var(--joystick-handle-size);
152
+ box-sizing: border-box;
153
+ border: calc(1px * var(--graph-stroke-scale, 1)) solid var(--color-foreground, var(--spagbol-foreground, #fff));
154
+ background: var(--color-accent, var(--spagbol-accent, #2fd061));
155
+ pointer-events: none;
156
+ }
157
+
@@ -1 +1 @@
1
- {"version":3,"file":"SpagbolCanvas.d.ts","sourceRoot":"","sources":["../../src/react/SpagbolCanvas.tsx"],"names":[],"mappings":"AAGA,OAAO,EAyBL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAWL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAQL,KAAK,UAAU,EAEf,KAAK,WAAW,EAChB,KAAK,SAAS,EAIf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAKL,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,4BAA4B,CAAC;AA4CpC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AACD,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC;IAClD,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,8EAA8E;IAC9E,gBAAgB,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IACpC,gBAAgB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAAC;IACrE,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,eAAe,CAAC,EAAE,QAAQ,CAAC;IAC3B,gBAAgB,CAAC,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5C,MAAM,CAAC,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC3C,mFAAmF;IACnF,SAAS,CAAC,EAAE,KAAK,GAAG;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAClD,cAAc,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAAC;IAChE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,QAAQ,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAC;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,+BAetD"}
1
+ {"version":3,"file":"SpagbolCanvas.d.ts","sourceRoot":"","sources":["../../src/react/SpagbolCanvas.tsx"],"names":[],"mappings":"AAKA,OAAO,EA0BL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAWL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAQL,KAAK,UAAU,EAEf,KAAK,WAAW,EAChB,KAAK,SAAS,EAIf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAKL,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,4BAA4B,CAAC;AA4CpC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AACD,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC;IAClD,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,8EAA8E;IAC9E,gBAAgB,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IACpC,gBAAgB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAAC;IACrE,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,eAAe,CAAC,EAAE,QAAQ,CAAC;IAC3B,gBAAgB,CAAC,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5C,MAAM,CAAC,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC3C,mFAAmF;IACnF,SAAS,CAAC,EAAE,KAAK,GAAG;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAClD,cAAc,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,IAAI,CAAC;IAChE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,QAAQ,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAC;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,+BAetD"}
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { SPAGBOL_ZOOM_BASELINE, spagbolHeaderTitleScale } from "./zoom.js";
3
+ import { previewNodePositions, applyNodePositions } from "./nodeDrag.js";
2
4
  import { createPortal } from "react-dom";
3
5
  import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
4
6
  import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
5
- import { BaseEdge, ConnectionLineType, EdgeLabelRenderer, Handle, Position, ReactFlow, ReactFlowProvider, SelectionMode, ViewportPortal, useReactFlow, useStore, useStoreApi, useUpdateNodeInternals, useNodeId, useNodesInitialized, useViewport, } from "@xyflow/react";
7
+ import { applyNodeChanges, BaseEdge, ConnectionLineType, EdgeLabelRenderer, Handle, Position, ReactFlow, ReactFlowProvider, SelectionMode, ViewportPortal, useReactFlow, useStore, useStoreApi, useUpdateNodeInternals, useNodeId, useNodesInitialized, useViewport, } from "@xyflow/react";
6
8
  import { memo, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react";
7
9
  import { areaMembers, groupPanelLayout, spagbolGroupKinds, copyFragment, createId, portsFor, readFragment, } from "../core/index.js";
8
10
  import { GraphContext, useEditorSnapshot, useEditorSelector, useSpagbol, } from "./context.js";
@@ -13,6 +15,8 @@ import { rememberMeasurements, reuseFlowPresentations, stationaryReconnectEdge,
13
15
  import { duplicateDragPresentation, } from "./duplicateDrag.js";
14
16
  import { translateExtentForVisibleContent, clampViewportToTranslateExtent, infiniteCanvasExtent, } from "./viewport.js";
15
17
  import { anchorFlowId, areaFlowId, linkPath, nodeFlowId, parseHandle, sourceHandle, targetHandle, } from "./geometry.js";
18
+ const defaultGraphViewport = { x: 0, y: 0, zoom: SPAGBOL_ZOOM_BASELINE };
19
+ const emptyFlowNodes = [];
16
20
  const emptyViews = Object.freeze({});
17
21
  const emptyPresentations = Object.freeze({});
18
22
  const defaultFeatures = { compact: true, nestedGraphs: true, panels: true, areas: true, linkProperties: true };
@@ -149,22 +153,26 @@ function SpagbolCanvasArea({ data, selected }) {
149
153
  height: area.collapsed
150
154
  ? 34 + (area.controlHeight ?? 0)
151
155
  : area.size.height,
152
- }, children: [_jsxs("div", { className: "spagbol-canvas-area-header nodrag nopan", onPointerDown: (event) => start(event), ...pointerHandlers, children: [titleEditing ? _jsx("input", { autoFocus: true, 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')
156
+ }, children: [_jsxs("div", { className: "spagbol-canvas-area-header nodrag nopan", onPointerDown: (event) => start(event), ...pointerHandlers, children: [titleEditing ? _jsx("input", { autoFocus: true, 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')
153
157
  event.currentTarget.blur(); if (event.key === 'Escape') {
154
158
  setTitleDraft(area.title);
155
159
  setTitleEditing(false);
156
160
  } } }) : _jsx("span", { className: "spagbol-canvas-area-title", onDoubleClick: event => { event.stopPropagation(); if (editor.editing) {
157
161
  setTitleDraft(area.title);
158
162
  setTitleEditing(true);
159
- } }, children: area.title }), _jsxs("div", { className: "spagbol-canvas-area-actions", onPointerDown: (event) => event.stopPropagation(), children: [_jsx("button", { title: area.locked ? "Unlock membership" : "Lock membership", "aria-label": area.locked ? "Unlock membership" : "Lock membership", disabled: !editor.editing, onClick: () => editor.lockArea(area.id, !area.locked), children: area.locked ? "●" : "○" }), _jsx("button", { title: area.collapsed ? "Expand area" : "Collapse area", "aria-label": area.collapsed ? "Expand area" : "Collapse area", disabled: !editor.editing, onClick: () => editor.updateArea(area.id, { collapsed: !area.collapsed }), children: area.collapsed ? "+" : "−" })] })] }), !area.collapsed && area.controlHeight ? (_jsx("div", { className: "spagbol-canvas-area-divider", style: { top: 34 + area.controlHeight } })) : null, !area.collapsed && editor.editing && (_jsx("button", { className: "spagbol-canvas-area-resize nodrag nopan", "aria-label": "Resize area", onPointerDown: (event) => start(event, true), ...pointerHandlers })), _jsx(Handle, { id: "collapsed:in", type: "target", position: Position.Left, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } }), _jsx(Handle, { id: "collapsed:out", type: "source", position: Position.Right, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } })] }));
163
+ } }, children: area.title }), _jsxs("div", { className: "spagbol-canvas-area-actions", onPointerDown: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", className: "spagbol-canvas-area-lock", title: area.locked ? "Unlock area membership" : "Lock area membership", "aria-label": area.locked ? "Unlock area membership" : "Lock area membership", "aria-pressed": area.locked === true, disabled: !editor.editing, onDoubleClick: event => event.stopPropagation(), onClick: event => {
164
+ event.preventDefault();
165
+ event.stopPropagation();
166
+ editor.lockArea(area.id, !area.locked);
167
+ }, children: _jsx("span", { className: "spagbol-canvas-area-lock-icon", "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: "spagbol-canvas-area-compact", "aria-pressed": area.collapsed === true, title: area.collapsed ? "Expand area" : "Collapse area", "aria-label": area.collapsed ? "Expand area" : "Collapse area", disabled: !editor.editing, onClick: () => editor.updateArea(area.id, { collapsed: !area.collapsed }), children: area.collapsed ? "+" : "−" })] })] }), !area.collapsed && area.controlHeight ? (_jsx("div", { className: "spagbol-canvas-area-divider", style: { top: 34 + area.controlHeight } })) : null, !area.collapsed && editor.editing && (_jsx("button", { className: "spagbol-canvas-area-resize nodrag nopan", "aria-label": "Resize area", onPointerDown: (event) => start(event, true), ...pointerHandlers })), _jsx(Handle, { id: "collapsed:in", type: "target", position: Position.Left, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } }), _jsx(Handle, { id: "collapsed:out", type: "source", position: Position.Right, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } })] }));
160
168
  }
161
169
  const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props) {
162
170
  const { editor, diagnostics, linkOverlay, connecting } = useSpagbol();
163
171
  const flow = useReactFlow();
164
172
  const viewport = useViewport();
165
173
  const [controlsVisible, setControlsVisible] = useState(false);
166
- const selection = useEditorSelector(editor, snapshot => snapshot.selection);
167
- const showControls = props.selected && selection.linkIds.length === 1 && editor.editing && !connecting && !props.data?.virtual;
174
+ const selectedLinkId = useEditorSelector(editor, snapshot => snapshot.selection.linkIds.length === 1 ? snapshot.selection.linkIds[0] : undefined);
175
+ const showControls = props.selected && selectedLinkId === props.id && editor.editing && !connecting && !props.data?.virtual;
168
176
  useEffect(() => {
169
177
  if (!showControls) {
170
178
  setControlsVisible(false);
@@ -206,13 +214,15 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
206
214
  const flow = useReactFlow();
207
215
  const flowStore = useStoreApi();
208
216
  const nodesInitialized = useNodesInitialized();
209
- const flowNodes = useStore((state) => state.nodes);
217
+ // Only cable-property anchors need live native geometry. Subscribing to every
218
+ // position change otherwise causes redundant full-canvas renders during drags.
219
+ const flowNodes = useStore((state) => features.linkProperties ? state.nodes : emptyFlowNodes);
210
220
  // Measurements are canvas state, not saved graph data. Retaining them keeps
211
221
  // React Flow from hiding/reinitializing every node on each drag preview frame.
212
222
  const measuredSizes = useRef(new Map());
213
223
  const [measurementRevision, setMeasurementRevision] = useState(0);
214
- const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? 1);
215
- const [settledViewport, setSettledViewport] = useState(props.defaultViewport ?? { x: 0, y: 0, zoom: 1 });
224
+ const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? SPAGBOL_ZOOM_BASELINE);
225
+ const [settledViewport, setSettledViewport] = useState(props.defaultViewport ?? defaultGraphViewport);
216
226
  const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
217
227
  const viewportMoving = useRef(false);
218
228
  useEffect(() => {
@@ -235,6 +245,9 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
235
245
  const [activeMode, setActiveMode] = useState(editor.policy.modes?.[0] ?? editor.modes.list()[0]?.id ?? "set");
236
246
  const [picker, setPicker] = useState(null);
237
247
  const [search, setSearch] = useState("");
248
+ const [pickerIndex, setPickerIndex] = useState(0);
249
+ const pickerListId = useId();
250
+ const pickerList = useRef(null);
238
251
  const [error, setError] = useState(null);
239
252
  const [drawing, setDrawing] = useState(null);
240
253
  const connection = useRef(null);
@@ -244,10 +257,24 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
244
257
  const [reconnectOriginal, setReconnectOriginal] = useState(null);
245
258
  const duplicate = useRef(null);
246
259
  const [duplicatePreview, setDuplicatePreview] = useState(null);
247
- const cancelledDuplicate = useRef(false);
260
+ const cancelledNodeDrag = useRef(false);
261
+ // Position previews belong to the canvas, not the persistent document. A drag
262
+ // commits once, avoiding whole-document edits/validation on every pointer move.
263
+ const nodeDrag = useRef(null);
264
+ const [positionRevision, setPositionRevision] = useState(0);
265
+ const clearNodeDrag = () => {
266
+ nodeDrag.current = null;
267
+ // Force a controlled refresh on cancellation, even though the saved position
268
+ // never changed: React Flow currently holds the transient pointer position.
269
+ setPositionRevision(value => value + 1);
270
+ };
248
271
  useEffect(() => {
272
+ if (!snapshot.gesture && nodeDrag.current) {
273
+ cancelledNodeDrag.current = true;
274
+ clearNodeDrag();
275
+ }
249
276
  if (!snapshot.gesture && duplicate.current) {
250
- cancelledDuplicate.current = true;
277
+ cancelledNodeDrag.current = true;
251
278
  duplicate.current = null;
252
279
  setDuplicatePreview(null);
253
280
  }
@@ -283,7 +310,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
283
310
  const area = editor.graph.areas.find(area => area.id === id);
284
311
  if (!area)
285
312
  return;
286
- cancelledDuplicate.current = false;
313
+ cancelledNodeDrag.current = false;
287
314
  const fragment = copyFragment(editor.graph, [], [id]);
288
315
  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 };
289
316
  setDuplicatePreview(duplicate.current);
@@ -391,7 +418,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
391
418
  ...renderGraph.nodes.map((node) => ({
392
419
  id: nodeFlowId(node.id),
393
420
  type: "spagbolNode",
394
- position: node.position,
421
+ position: nodeDrag.current?.get(node.id) ?? node.position,
395
422
  selected: snapshot.selection.nodeIds.includes(node.id),
396
423
  hidden: collapsed.nodes.has(node.id),
397
424
  draggable: editor.editing && views[node.kind]?.draggable !== false &&
@@ -407,6 +434,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
407
434
  deletable: false,
408
435
  dragHandle: ".spagbol-canvas-drag-handle",
409
436
  data: {
437
+ positionRevision,
410
438
  node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
411
439
  projected: collapsed.projected.has(node.id),
412
440
  stationaryOriginal: duplicatePresentation?.originalNodes.has(node.id),
@@ -426,6 +454,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
426
454
  })),
427
455
  ], [
428
456
  renderGraph,
457
+ positionRevision,
429
458
  duplicatePresentation,
430
459
  snapshot.selection,
431
460
  snapshot.mode,
@@ -483,6 +512,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
483
512
  collapsed,
484
513
  flow,
485
514
  measurementKey,
515
+ measurementRevision,
486
516
  settledViewport.zoom,
487
517
  editorSize,
488
518
  props.panBounds,
@@ -758,8 +788,19 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
758
788
  setDuplicatePreview(next);
759
789
  return;
760
790
  }
761
- if (cancelledDuplicate.current)
791
+ if (cancelledNodeDrag.current)
762
792
  return;
793
+ if (positions.length && nodeDrag.current) {
794
+ const next = previewNodePositions(nodeDrag.current, positions);
795
+ if (next !== nodeDrag.current)
796
+ nodeDrag.current = next;
797
+ // This is a controlled React Flow: recording positions in a ref does not
798
+ // update native geometry. Publish only the transient node presentation;
799
+ // the graph, validation and history still change once, on drop.
800
+ const state = flowStore.getState();
801
+ state.setNodes(applyNodeChanges(positions, state.nodes));
802
+ return;
803
+ }
763
804
  if (positions.length)
764
805
  editor.edit("Move nodes", "layout", (graph) => {
765
806
  for (const change of positions)
@@ -792,6 +833,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
792
833
  return;
793
834
  const bounds = root.current?.getBoundingClientRect();
794
835
  setSearch("");
836
+ setPickerIndex(0);
795
837
  setPicker({
796
838
  point: flow.screenToFlowPosition(client),
797
839
  screen: {
@@ -892,8 +934,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
892
934
  return;
893
935
  const command = event.metaKey || event.ctrlKey, key = event.key.toLowerCase();
894
936
  if (key === "escape") {
895
- if (duplicate.current)
896
- cancelledDuplicate.current = true;
937
+ if (duplicate.current || nodeDrag.current)
938
+ cancelledNodeDrag.current = true;
939
+ if (nodeDrag.current)
940
+ clearNodeDrag();
897
941
  duplicate.current = null;
898
942
  setDuplicatePreview(null);
899
943
  editor.cancelGesture();
@@ -943,7 +987,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
943
987
  }
944
988
  if (command && key === "0") {
945
989
  event.preventDefault();
946
- void flow.zoomTo(1);
990
+ void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
947
991
  return;
948
992
  }
949
993
  if (key === "delete" || key === "backspace") {
@@ -988,14 +1032,20 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
988
1032
  const touch = event.changedTouches[0];
989
1033
  return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
990
1034
  }
991
- 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: () => [] }] : [])]
1035
+ 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: () => [] }] : [])]
992
1036
  .filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind))
993
1037
  .filter((definition) => `${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
994
1038
  .toLowerCase()
995
1039
  .includes(search.toLowerCase()));
1040
+ const selectedPickerIndex = Math.min(pickerIndex, Math.max(0, availableNodes.length - 1));
1041
+ const selectedPickerNode = availableNodes[selectedPickerIndex];
1042
+ useEffect(() => {
1043
+ if (picker)
1044
+ pickerList.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
1045
+ }, [picker, selectedPickerIndex, search]);
996
1046
  return (_jsx(GraphContext.Provider, { value: context, children: _jsxs("div", { ref: root, className: `spagbol-canvas ${props.className ?? ""}`, style: {
997
1047
  ...props.style,
998
- "--spagbol-title-scale": zoom < 0.3 ? 1.75 : zoom < 0.5 ? 1.3 : 1,
1048
+ "--spagbol-title-scale": spagbolHeaderTitleScale(zoom),
999
1049
  "--spagbol-canvas-height": `${editorSize.height}px`,
1000
1050
  }, tabIndex: 0, "aria-label": props.ariaLabel ?? "Graph editor", onKeyDown: keyDown, onDoubleClick: (event) => {
1001
1051
  if (event.target.classList.contains("react-flow__pane")) {
@@ -1045,8 +1095,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1045
1095
  if (event.currentTarget.hasPointerCapture(event.pointerId))
1046
1096
  event.currentTarget.releasePointerCapture(event.pointerId);
1047
1097
  }, onPointerCancel: () => {
1048
- if (duplicate.current)
1049
- cancelledDuplicate.current = true;
1098
+ if (duplicate.current || nodeDrag.current)
1099
+ cancelledNodeDrag.current = true;
1100
+ if (nodeDrag.current)
1101
+ clearNodeDrag();
1050
1102
  duplicate.current = null;
1051
1103
  setDuplicatePreview(null);
1052
1104
  setDrawing(null);
@@ -1064,8 +1116,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1064
1116
  }, onNodeDragStart: (event, node) => {
1065
1117
  if (!node.data.node)
1066
1118
  return;
1067
- cancelledDuplicate.current = false;
1119
+ cancelledNodeDrag.current = false;
1068
1120
  editor.beginGesture(event.altKey ? "Duplicate nodes" : "Move nodes");
1121
+ if (!event.altKey)
1122
+ nodeDrag.current = new Map();
1069
1123
  if (event.altKey) {
1070
1124
  const selection = editor.getSnapshot().selection;
1071
1125
  const ids = selection.nodeIds.includes(node.data.node.id)
@@ -1084,11 +1138,14 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1084
1138
  setDuplicatePreview(duplicate.current);
1085
1139
  }
1086
1140
  }, onNodeDragStop: (_event, node) => {
1141
+ const positions = nodeDrag.current;
1142
+ if (positions)
1143
+ clearNodeDrag();
1087
1144
  const current = duplicate.current;
1088
1145
  duplicate.current = null;
1089
1146
  setDuplicatePreview(null);
1090
- if (cancelledDuplicate.current) {
1091
- cancelledDuplicate.current = false;
1147
+ if (cancelledNodeDrag.current) {
1148
+ cancelledNodeDrag.current = false;
1092
1149
  editor.cancelGesture();
1093
1150
  editor.select(editor.getSnapshot().selection);
1094
1151
  return;
@@ -1108,6 +1165,8 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1108
1165
  return;
1109
1166
  }
1110
1167
  }
1168
+ if (positions?.size)
1169
+ editor.edit("Move nodes", "layout", graph => applyNodePositions(graph, positions));
1111
1170
  editor.commitGesture();
1112
1171
  }, onConnectStart: (_event, params) => {
1113
1172
  setRevealedNodeId(params.nodeId?.startsWith("node:") ? params.nodeId.slice(5) : null);
@@ -1189,7 +1248,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1189
1248
  }, connectionLineType: ConnectionLineType.Bezier, connectionLineStyle: {
1190
1249
  stroke: editor.modes.get(activeMode)?.color,
1191
1250
  strokeWidth: 2,
1192
- }, defaultViewport: props.defaultViewport, onInit: instance => props.onInit?.(instance), onMoveStart: () => {
1251
+ }, defaultViewport: props.defaultViewport ?? defaultGraphViewport, onInit: instance => props.onInit?.(instance), onMoveStart: () => {
1193
1252
  viewportMoving.current = true;
1194
1253
  }, onMove: (_, viewport) => setZoom(viewport.zoom), onMoveEnd: (_, viewport) => {
1195
1254
  viewportMoving.current = false;
@@ -1199,12 +1258,30 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1199
1258
  transform: `translate(${Math.min(drawing.start.x, drawing.end.x)}px, ${Math.min(drawing.start.y, drawing.end.y)}px)`,
1200
1259
  width: Math.abs(drawing.start.x - drawing.end.x),
1201
1260
  height: Math.abs(drawing.start.y - drawing.end.y),
1202
- } }) })) }), _jsx("div", { ref: setLinkOverlay, className: "spagbol-canvas-link-overlay" }), (props.toolbar ?? true) === true ? (props.controls === false ? null : (_jsx(SpagbolCanvasControls, { ...props.controls }))) : (props.toolbar), picker && (_jsxs("div", { className: "spagbol-canvas-picker nodrag nopan", role: "dialog", "aria-label": "Add node", style: { left: picker.screen.x, top: picker.screen.y }, onPointerDown: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "spagbol-canvas-picker-heading", children: [picker.linkId ? "insert a node" : "add a node", _jsx("button", { "aria-label": "Cancel node creation", onClick: () => setPicker(null), children: "\u00D7" })] }), _jsx("input", { autoFocus: true, "aria-label": "Search node types", placeholder: "Search nodes\u2026", value: search, onChange: (event) => setSearch(event.target.value), onKeyDown: (event) => {
1261
+ } }) })) }), _jsx("div", { ref: setLinkOverlay, className: "spagbol-canvas-link-overlay" }), (props.toolbar ?? true) === true ? (props.controls === false ? null : (_jsx(SpagbolCanvasControls, { ...props.controls }))) : (props.toolbar), picker && (_jsxs("div", { className: "spagbol-canvas-picker nodrag nopan", role: "dialog", "aria-label": "Add node", style: { left: picker.screen.x, top: picker.screen.y }, onPointerDown: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "spagbol-canvas-picker-heading", children: [_jsx("span", { className: "spagbol-canvas-picker-title", children: picker.linkId ? "Insert a node" : "Add a node" }), _jsx("button", { "aria-label": "Cancel node creation", onClick: () => setPicker(null), children: "\u00D7" })] }), _jsx("input", { autoFocus: true, "aria-label": "Search node types", role: "combobox", "aria-expanded": "true", "aria-autocomplete": "list", "aria-controls": pickerListId, "aria-activedescendant": selectedPickerNode ? `${pickerListId}-${selectedPickerIndex}` : undefined, placeholder: "Search nodes\u2026", value: search, onChange: (event) => {
1262
+ setSearch(event.target.value);
1263
+ setPickerIndex(0);
1264
+ }, onKeyDown: (event) => {
1203
1265
  event.stopPropagation();
1204
- if (event.key === "Escape")
1266
+ if (event.nativeEvent.isComposing)
1267
+ return;
1268
+ if (event.key === "ArrowDown") {
1269
+ event.preventDefault();
1270
+ setPickerIndex(Math.min(selectedPickerIndex + 1, Math.max(0, availableNodes.length - 1)));
1271
+ }
1272
+ else if (event.key === "ArrowUp") {
1273
+ event.preventDefault();
1274
+ setPickerIndex(Math.max(0, selectedPickerIndex - 1));
1275
+ }
1276
+ else if (event.key === "Escape") {
1277
+ event.preventDefault();
1205
1278
  setPicker(null);
1206
- if (event.key === "Enter" && availableNodes[0])
1207
- chooseNode(availableNodes[0].kind);
1208
- } }), _jsxs("div", { className: "spagbol-canvas-picker-list", children: [availableNodes.map((definition) => (_jsxs("button", { onClick: () => chooseNode(definition.kind), children: [_jsx("span", { children: definition.label }), _jsx("small", { children: definition.category })] }, definition.kind))), !availableNodes.length && _jsx("p", { children: "No matching nodes." })] })] })), error && (_jsxs("div", { className: "spagbol-canvas-error", role: "alert", children: [error, _jsx("button", { "aria-label": "Dismiss error", onClick: () => setError(null), children: "\u00D7" })] })), props.overlay && (_jsx("div", { className: "spagbol-canvas-overlay", children: props.overlay }))] }) }));
1279
+ }
1280
+ else if (event.key === "Enter") {
1281
+ event.preventDefault();
1282
+ if (selectedPickerNode)
1283
+ chooseNode(selectedPickerNode.kind);
1284
+ }
1285
+ } }), _jsxs("div", { ref: pickerList, id: pickerListId, className: "spagbol-canvas-picker-list nowheel", role: "listbox", "aria-label": "Node types", children: [availableNodes.map((definition, index) => (_jsxs("button", { id: `${pickerListId}-${index}`, type: "button", role: "option", "aria-selected": index === selectedPickerIndex, tabIndex: -1, onPointerMove: () => setPickerIndex(index), onMouseDown: (event) => event.preventDefault(), onClick: () => chooseNode(definition.kind), children: [_jsx("span", { children: definition.label }), _jsx("small", { children: definition.category })] }, definition.kind))), !availableNodes.length && _jsx("p", { children: "No matching nodes." })] })] })), error && (_jsxs("div", { className: "spagbol-canvas-error", role: "alert", children: [error, _jsx("button", { "aria-label": "Dismiss error", onClick: () => setError(null), children: "\u00D7" })] })), props.overlay && (_jsx("div", { className: "spagbol-canvas-overlay", children: props.overlay }))] }) }));
1209
1286
  }
1210
1287
  //# sourceMappingURL=SpagbolCanvas.js.map