@pauloricca/spagbol 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spagbol
2
2
 
3
- An extensible graph editor extracted from the interaction patterns in Teia and visual-visual. This repository is independent of both applications. Teia now consumes the complete shared canvas/controller through a patch codec and domain bindings; visual-visual remains unchanged.
3
+ An extensible graph editor extracted from the interaction patterns in Teia and visual-visual. This repository is independent of both applications. Teia now consumes the complete shared canvas/controller through a patch codec and domain bindings; Visual Visual consumes the shared canvas/controller through its shader patch codec and domain bindings.
4
4
 
5
5
  Spagbol owns graph editing and presentation. Consumers own their node catalogues, node bodies, execution, persistence services, assets and runtime observations. There is no audio engine, shader compiler, MIDI access or server client in the package.
6
6
 
@@ -8,7 +8,7 @@ Spagbol owns graph editing and presentation. Consumers own their node catalogues
8
8
 
9
9
  `@pauloricca/spagbol` contains the complete graph model, registries, atomic edits, undo/redo, graph transformations, nesting, areas, observations and codecs together with the extensible React canvas, node frames/ports and controls. All public model and UI symbols come from the same entry point. Import `@pauloricca/spagbol/styles.css` explicitly.
10
10
 
11
- The package is version `0.1.0`, licensed under [MIT](LICENSE). React/React DOM 19 and React Flow 12.11 are peer dependencies. Source remains organised into internal `src/core` and `src/react` modules, with a single build, version and tarball. This is an initial implementation, not a claim of complete Teia interaction parity.
11
+ The package is version `0.1.1`, licensed under [MIT](LICENSE). React/React DOM 19 and React Flow 12.11 are peer dependencies. Source remains organised into internal `src/core` and `src/react` modules, with a single build, version and tarball. This is an initial implementation, not a claim of complete Teia interaction parity.
12
12
 
13
13
  ## Docker development
14
14
 
@@ -329,3 +329,11 @@ npm install @pauloricca/spagbol @xyflow/react react react-dom
329
329
  Import `@pauloricca/spagbol/styles.css` once and mount `SpagbolCanvas` in a sized container, as above. Applications should declare the released npm version in their manifest and commit the registry-backed lockfile. The bundled example intentionally uses a local tarball so edits can be tested before a release.
330
330
 
331
331
  For updates, change the version first (for example `npm version patch --no-git-tag-version`), rerun the checks and dry run, then publish again. Commit the manifest/lockfile through your normal release workflow. Published name/version pairs cannot be reused. Local example preparation reads the package version to select its tarball. See [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/).
332
+
333
+ ## Node drag previews
334
+
335
+ Ordinary node movement is presented as transient canvas geometry. The immutable editor document keeps its previous positions until drop, when all moved nodes are committed as one layout edit and one undo entry. Escape, pointer cancellation, or controller cancellation discards the preview. Numeric controls still publish live value previews through the controller's gesture API. Hosts observing document changes should expect a layout change at drop rather than on each pointer movement.
336
+
337
+ ## 0.1.1
338
+
339
+ Node dragging now previews native canvas positions and commits one document edit on drop. This removes per-pointer graph cloning, validation and redundant controlled-position updates. Selection subscriptions are scoped to the affected node, port or cable. Sliders and numeric controls retain live value updates and grouped undo. Escape, pointer cancellation, multi-selection and Alt duplication are covered by regression checks. See [the performance investigation](docs/node-drag-performance.md) for measurements and behavior details.
@@ -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":"AAIA,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;AAOD,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,+BAetD"}
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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";
@@ -13,6 +14,7 @@ import { rememberMeasurements, reuseFlowPresentations, stationaryReconnectEdge,
13
14
  import { duplicateDragPresentation, } from "./duplicateDrag.js";
14
15
  import { translateExtentForVisibleContent, clampViewportToTranslateExtent, infiniteCanvasExtent, } from "./viewport.js";
15
16
  import { anchorFlowId, areaFlowId, linkPath, nodeFlowId, parseHandle, sourceHandle, targetHandle, } from "./geometry.js";
17
+ const emptyFlowNodes = [];
16
18
  const emptyViews = Object.freeze({});
17
19
  const emptyPresentations = Object.freeze({});
18
20
  const defaultFeatures = { compact: true, nestedGraphs: true, panels: true, areas: true, linkProperties: true };
@@ -163,8 +165,8 @@ const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props) {
163
165
  const flow = useReactFlow();
164
166
  const viewport = useViewport();
165
167
  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;
168
+ const selectedLinkId = useEditorSelector(editor, snapshot => snapshot.selection.linkIds.length === 1 ? snapshot.selection.linkIds[0] : undefined);
169
+ const showControls = props.selected && selectedLinkId === props.id && editor.editing && !connecting && !props.data?.virtual;
168
170
  useEffect(() => {
169
171
  if (!showControls) {
170
172
  setControlsVisible(false);
@@ -206,7 +208,9 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
206
208
  const flow = useReactFlow();
207
209
  const flowStore = useStoreApi();
208
210
  const nodesInitialized = useNodesInitialized();
209
- const flowNodes = useStore((state) => state.nodes);
211
+ // Only cable-property anchors need live native geometry. Subscribing to every
212
+ // position change otherwise causes redundant full-canvas renders during drags.
213
+ const flowNodes = useStore((state) => features.linkProperties ? state.nodes : emptyFlowNodes);
210
214
  // Measurements are canvas state, not saved graph data. Retaining them keeps
211
215
  // React Flow from hiding/reinitializing every node on each drag preview frame.
212
216
  const measuredSizes = useRef(new Map());
@@ -244,10 +248,24 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
244
248
  const [reconnectOriginal, setReconnectOriginal] = useState(null);
245
249
  const duplicate = useRef(null);
246
250
  const [duplicatePreview, setDuplicatePreview] = useState(null);
247
- const cancelledDuplicate = useRef(false);
251
+ const cancelledNodeDrag = useRef(false);
252
+ // Position previews belong to the canvas, not the persistent document. A drag
253
+ // commits once, avoiding whole-document edits/validation on every pointer move.
254
+ const nodeDrag = useRef(null);
255
+ const [positionRevision, setPositionRevision] = useState(0);
256
+ const clearNodeDrag = () => {
257
+ nodeDrag.current = null;
258
+ // Force a controlled refresh on cancellation, even though the saved position
259
+ // never changed: React Flow currently holds the transient pointer position.
260
+ setPositionRevision(value => value + 1);
261
+ };
248
262
  useEffect(() => {
263
+ if (!snapshot.gesture && nodeDrag.current) {
264
+ cancelledNodeDrag.current = true;
265
+ clearNodeDrag();
266
+ }
249
267
  if (!snapshot.gesture && duplicate.current) {
250
- cancelledDuplicate.current = true;
268
+ cancelledNodeDrag.current = true;
251
269
  duplicate.current = null;
252
270
  setDuplicatePreview(null);
253
271
  }
@@ -283,7 +301,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
283
301
  const area = editor.graph.areas.find(area => area.id === id);
284
302
  if (!area)
285
303
  return;
286
- cancelledDuplicate.current = false;
304
+ cancelledNodeDrag.current = false;
287
305
  const fragment = copyFragment(editor.graph, [], [id]);
288
306
  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
307
  setDuplicatePreview(duplicate.current);
@@ -391,7 +409,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
391
409
  ...renderGraph.nodes.map((node) => ({
392
410
  id: nodeFlowId(node.id),
393
411
  type: "spagbolNode",
394
- position: node.position,
412
+ position: nodeDrag.current?.get(node.id) ?? node.position,
395
413
  selected: snapshot.selection.nodeIds.includes(node.id),
396
414
  hidden: collapsed.nodes.has(node.id),
397
415
  draggable: editor.editing && views[node.kind]?.draggable !== false &&
@@ -407,6 +425,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
407
425
  deletable: false,
408
426
  dragHandle: ".spagbol-canvas-drag-handle",
409
427
  data: {
428
+ positionRevision,
410
429
  node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
411
430
  projected: collapsed.projected.has(node.id),
412
431
  stationaryOriginal: duplicatePresentation?.originalNodes.has(node.id),
@@ -426,6 +445,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
426
445
  })),
427
446
  ], [
428
447
  renderGraph,
448
+ positionRevision,
429
449
  duplicatePresentation,
430
450
  snapshot.selection,
431
451
  snapshot.mode,
@@ -483,6 +503,7 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
483
503
  collapsed,
484
504
  flow,
485
505
  measurementKey,
506
+ measurementRevision,
486
507
  settledViewport.zoom,
487
508
  editorSize,
488
509
  props.panBounds,
@@ -758,8 +779,14 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
758
779
  setDuplicatePreview(next);
759
780
  return;
760
781
  }
761
- if (cancelledDuplicate.current)
782
+ if (cancelledNodeDrag.current)
762
783
  return;
784
+ if (positions.length && nodeDrag.current) {
785
+ const next = previewNodePositions(nodeDrag.current, positions);
786
+ if (next !== nodeDrag.current)
787
+ nodeDrag.current = next;
788
+ return;
789
+ }
763
790
  if (positions.length)
764
791
  editor.edit("Move nodes", "layout", (graph) => {
765
792
  for (const change of positions)
@@ -892,8 +919,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
892
919
  return;
893
920
  const command = event.metaKey || event.ctrlKey, key = event.key.toLowerCase();
894
921
  if (key === "escape") {
895
- if (duplicate.current)
896
- cancelledDuplicate.current = true;
922
+ if (duplicate.current || nodeDrag.current)
923
+ cancelledNodeDrag.current = true;
924
+ if (nodeDrag.current)
925
+ clearNodeDrag();
897
926
  duplicate.current = null;
898
927
  setDuplicatePreview(null);
899
928
  editor.cancelGesture();
@@ -1045,8 +1074,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1045
1074
  if (event.currentTarget.hasPointerCapture(event.pointerId))
1046
1075
  event.currentTarget.releasePointerCapture(event.pointerId);
1047
1076
  }, onPointerCancel: () => {
1048
- if (duplicate.current)
1049
- cancelledDuplicate.current = true;
1077
+ if (duplicate.current || nodeDrag.current)
1078
+ cancelledNodeDrag.current = true;
1079
+ if (nodeDrag.current)
1080
+ clearNodeDrag();
1050
1081
  duplicate.current = null;
1051
1082
  setDuplicatePreview(null);
1052
1083
  setDrawing(null);
@@ -1064,8 +1095,10 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1064
1095
  }, onNodeDragStart: (event, node) => {
1065
1096
  if (!node.data.node)
1066
1097
  return;
1067
- cancelledDuplicate.current = false;
1098
+ cancelledNodeDrag.current = false;
1068
1099
  editor.beginGesture(event.altKey ? "Duplicate nodes" : "Move nodes");
1100
+ if (!event.altKey)
1101
+ nodeDrag.current = new Map();
1069
1102
  if (event.altKey) {
1070
1103
  const selection = editor.getSnapshot().selection;
1071
1104
  const ids = selection.nodeIds.includes(node.data.node.id)
@@ -1084,11 +1117,14 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1084
1117
  setDuplicatePreview(duplicate.current);
1085
1118
  }
1086
1119
  }, onNodeDragStop: (_event, node) => {
1120
+ const positions = nodeDrag.current;
1121
+ if (positions)
1122
+ clearNodeDrag();
1087
1123
  const current = duplicate.current;
1088
1124
  duplicate.current = null;
1089
1125
  setDuplicatePreview(null);
1090
- if (cancelledDuplicate.current) {
1091
- cancelledDuplicate.current = false;
1126
+ if (cancelledNodeDrag.current) {
1127
+ cancelledNodeDrag.current = false;
1092
1128
  editor.cancelGesture();
1093
1129
  editor.select(editor.getSnapshot().selection);
1094
1130
  return;
@@ -1108,6 +1144,8 @@ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeat
1108
1144
  return;
1109
1145
  }
1110
1146
  }
1147
+ if (positions?.size)
1148
+ editor.edit("Move nodes", "layout", graph => applyNodePositions(graph, positions));
1111
1149
  editor.commitGesture();
1112
1150
  }, onConnectStart: (_event, params) => {
1113
1151
  setRevealedNodeId(params.nodeId?.startsWith("node:") ? params.nodeId.slice(5) : null);