@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 +10 -2
- package/dist/react/SpagbolCanvas.d.ts.map +1 -1
- package/dist/react/SpagbolCanvas.js +53 -15
- package/dist/react/SpagbolCanvas.js.map +1 -1
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -1
- package/dist/react/SpagbolNodeFrame.js +13 -11
- package/dist/react/SpagbolNodeFrame.js.map +1 -1
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/package.json +1 -1
- package/src/react/SpagbolCanvas.tsx +47 -13
- package/src/react/SpagbolNodeFrame.tsx +12 -11
- package/src/react/nodeDrag.ts +33 -0
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;
|
|
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.
|
|
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":"
|
|
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
|
|
167
|
-
const showControls = props.selected &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1091
|
-
|
|
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);
|