@kolosal-ai/rivet 0.1.1 → 0.3.0

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/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { parseAnchorHandleId, ANCHOR_SIDES, anchorDotHandleId, strayPlacementPct, anchorHandleId, anchorAutoHandleId, parseAnchorAutoHandleId, normalizeAnchorConnection, computeAnchorGeometry, anchorGeometryEqual, DEFAULT_ANCHOR_OPTIONS } from './chunk-AXT35ZJV.js';
2
+ import { createPresenceRegistry, createLockRegistry, DEFAULT_PRESENCE_OPTIONS, peerColor } from './chunk-A52HZWLH.js';
3
+ export { LOCK_DEFAULT_REFUSED, useNodeLock, useNodeLockAllows } from './chunk-A52HZWLH.js';
2
4
  import { useRivetContext, RivetNodeContext, useRivetNodeContext, useRivetControls, RivetContext, useRivetViewport } from './chunk-VQYN27OK.js';
3
5
  export { useRivetContext, useRivetControls, useRivetFocusedNode, useRivetHistory, useRivetViewport } from './chunk-VQYN27OK.js';
4
- import { clampNodeToLanes, clamp, swimlaneChromeAt, SWIMLANE_LABEL_WIDTH, resolveSwimlanes, flattenLanes, resolveMargin, requiredLaneHeight, withAlpha } from './chunk-6XRQSAQT.js';
6
+ import { clamp, clampNodeToLanes, swimlaneChromeAt, SWIMLANE_LABEL_WIDTH, resolveSwimlanes, flattenLanes, resolveMargin, requiredLaneHeight, withAlpha } from './chunk-6XRQSAQT.js';
5
7
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
8
  import { memo, useRef, useState, useCallback, useSyncExternalStore, useMemo, useEffect, Fragment as Fragment$1, useId, useLayoutEffect } from 'react';
7
9
 
@@ -356,6 +358,35 @@ function parseEdgeEndpoint(nodeId, handleId, handles) {
356
358
  return side ? { nodeId, side } : { nodeId };
357
359
  }
358
360
 
361
+ // src/gesture-guard.ts
362
+ function keepLocalGeometry(incoming, local) {
363
+ return {
364
+ ...incoming,
365
+ position: local.position,
366
+ width: local.width,
367
+ height: local.height,
368
+ size: local.size
369
+ };
370
+ }
371
+ function guardNodeChanges(changes, isHeld, getLocal) {
372
+ if (!changes.some((change) => change.type !== "add" && isHeld(change.id))) return changes;
373
+ const guarded = [];
374
+ for (const change of changes) {
375
+ if (change.type === "add" || !isHeld(change.id)) {
376
+ guarded.push(change);
377
+ continue;
378
+ }
379
+ if (change.type === "position" || change.type === "dimensions") continue;
380
+ if (change.type === "replace") {
381
+ const local = getLocal(change.id);
382
+ guarded.push(local ? { ...change, item: keepLocalGeometry(change.item, local) } : change);
383
+ continue;
384
+ }
385
+ guarded.push(change);
386
+ }
387
+ return guarded;
388
+ }
389
+
359
390
  // src/graph.ts
360
391
  function clampChildToParent(position, childSize, parentSize) {
361
392
  const maxX = Math.max(0, parentSize.width - childSize.width);
@@ -522,6 +553,26 @@ var HistoryManager = class {
522
553
  this.last = this.snapshot();
523
554
  });
524
555
  }
556
+ /**
557
+ * Fold an external change into every stored snapshot.
558
+ *
559
+ * Snapshots are structural, so a restore rewrites the whole graph — which in a
560
+ * shared document means undoing past a peer's edit would silently erase it.
561
+ * {@link rebase} can't help: it only refreshes the rolling baseline, while the
562
+ * stale states are the ones already on the stacks. Replaying the incoming
563
+ * change over each of them keeps every stored step consistent with work this
564
+ * client doesn't own.
565
+ *
566
+ * The trade-off is that an undo touching a node a peer has since changed no
567
+ * longer moves it — the peer's newer value survives instead. That's the
568
+ * intended precedence: a local undo may drop its own effect, never someone
569
+ * else's edit.
570
+ */
571
+ rewrite(transform) {
572
+ this.undoStack = this.undoStack.map(transform);
573
+ this.redoStack = this.redoStack.map(transform);
574
+ this.last = transform(this.last);
575
+ }
525
576
  flush() {
526
577
  this.scheduled = false;
527
578
  this.undoStack.push(this.last);
@@ -575,177 +626,596 @@ var HistoryManager = class {
575
626
  }
576
627
  };
577
628
 
578
- // src/store.ts
579
- var RivetGraphStore = class {
580
- nodes = /* @__PURE__ */ new Map();
581
- edges = /* @__PURE__ */ new Map();
582
- handles = /* @__PURE__ */ new Map();
583
- anchors = /* @__PURE__ */ new Map();
584
- viewport;
585
- viewportClamp = null;
586
- pending = null;
587
- nodeVersions = /* @__PURE__ */ new Map();
588
- nodeListeners = /* @__PURE__ */ new Map();
589
- anchorVersions = /* @__PURE__ */ new Map();
590
- anchorListeners = /* @__PURE__ */ new Map();
591
- edgesVersion = 0;
592
- edgesListeners = /* @__PURE__ */ new Set();
593
- nodeElements = /* @__PURE__ */ new Map();
594
- viewportListeners = /* @__PURE__ */ new Set();
595
- frameListeners = /* @__PURE__ */ new Set();
596
- selectionListeners = /* @__PURE__ */ new Set();
597
- selectedNodeIds = /* @__PURE__ */ new Set();
598
- selectionBoxActive = false;
599
- selectionBoxVersion = 0;
600
- selectionBoxListeners = /* @__PURE__ */ new Set();
601
- hoveredNodeId = null;
602
- focusedNodeId = null;
603
- focusListeners = /* @__PURE__ */ new Set();
604
- selectedEdgeId = null;
605
- nodeDragging = false;
606
- nodeResizing = false;
607
- edgeAlignment = "manual";
608
- draggingNodeIds = /* @__PURE__ */ new Set();
609
- displayedSides = /* @__PURE__ */ new Map();
610
- edgeLabelAnchors = /* @__PURE__ */ new Map();
611
- alignmentGuides = [];
612
- nodeChangeHandler = null;
613
- edgeChangeHandler = null;
614
- reconciling = false;
615
- history = new HistoryManager(() => this.historySnapshot());
616
- lastSelectionKey;
617
- worldCache = null;
618
- renderRequester = () => {
629
+ // src/viewport.ts
630
+ function worldToScreen(point, viewport) {
631
+ return {
632
+ x: point.x * viewport.zoom + viewport.x,
633
+ y: point.y * viewport.zoom + viewport.y
619
634
  };
620
- constructor(init) {
621
- this.viewport = init.viewport;
622
- for (const node of init.nodes) this.writeNode(node.id, node);
623
- for (const edge of init.edges) this.edges.set(edge.id, edge);
624
- for (const node of init.nodes) if (node.selected) this.selectedNodeIds.add(node.id);
625
- this.selectedEdgeId = init.edges.find((edge) => edge.selected)?.id ?? null;
626
- this.lastSelectionKey = this.selectionKey();
627
- this.history.clear();
635
+ }
636
+ function screenToWorld(point, viewport) {
637
+ return {
638
+ x: (point.x - viewport.x) / viewport.zoom,
639
+ y: (point.y - viewport.y) / viewport.zoom
640
+ };
641
+ }
642
+ function zoomAt(viewport, anchor, nextZoom, minZoom = 0.1, maxZoom = 4) {
643
+ const zoom = clamp(nextZoom, minZoom, maxZoom);
644
+ const world = screenToWorld(anchor, viewport);
645
+ return {
646
+ zoom,
647
+ x: anchor.x - world.x * zoom,
648
+ y: anchor.y - world.y * zoom
649
+ };
650
+ }
651
+ function viewportToCss(viewport) {
652
+ return `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`;
653
+ }
654
+ function visibleWorldRect(viewport, width, height) {
655
+ const topLeft = screenToWorld({ x: 0, y: 0 }, viewport);
656
+ const bottomRight = screenToWorld({ x: width, y: height }, viewport);
657
+ return {
658
+ x: topLeft.x,
659
+ y: topLeft.y,
660
+ width: bottomRight.x - topLeft.x,
661
+ height: bottomRight.y - topLeft.y
662
+ };
663
+ }
664
+ function rectsIntersect(a, b) {
665
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
666
+ }
667
+
668
+ // src/renderer/edge-paths.ts
669
+ var SIDE_DIR = {
670
+ left: { x: -1, y: 0 },
671
+ right: { x: 1, y: 0 },
672
+ top: { x: 0, y: -1 },
673
+ bottom: { x: 0, y: 1 }
674
+ };
675
+ var BEZIER_SEGMENT_PX = 8;
676
+ var BEZIER_MIN_SAMPLES = 16;
677
+ var BEZIER_MAX_SAMPLES = 160;
678
+ var CORNER_RADIUS = 8;
679
+ var ARC_SAMPLES = 6;
680
+ function cubicAt(a, b, c, d, t) {
681
+ const mt = 1 - t;
682
+ return mt * mt * mt * a + 3 * mt * mt * t * b + 3 * mt * t * t * c + t * t * t * d;
683
+ }
684
+ function quadAt(a, b, c, t) {
685
+ const mt = 1 - t;
686
+ return mt * mt * a + 2 * mt * t * b + t * t * c;
687
+ }
688
+ var getStraightPath = ({ sourceX, sourceY, targetX, targetY }) => ({
689
+ points: [
690
+ { x: sourceX, y: sourceY },
691
+ { x: targetX, y: targetY }
692
+ ],
693
+ labelX: (sourceX + targetX) / 2,
694
+ labelY: (sourceY + targetY) / 2
695
+ });
696
+ var getBezierPath = (params) => {
697
+ const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = params;
698
+ const sDir = SIDE_DIR[sourcePosition];
699
+ const tDir = SIDE_DIR[targetPosition];
700
+ const dist = Math.hypot(targetX - sourceX, targetY - sourceY);
701
+ const reach = Math.max(40, dist * 0.4);
702
+ const p0 = { x: sourceX, y: sourceY };
703
+ const p1 = { x: sourceX + sDir.x * reach, y: sourceY + sDir.y * reach };
704
+ const p2 = { x: targetX + tDir.x * reach, y: targetY + tDir.y * reach };
705
+ const p3 = { x: targetX, y: targetY };
706
+ const ctrlLen = Math.hypot(p1.x - p0.x, p1.y - p0.y) + Math.hypot(p2.x - p1.x, p2.y - p1.y) + Math.hypot(p3.x - p2.x, p3.y - p2.y);
707
+ const samples = Math.max(
708
+ BEZIER_MIN_SAMPLES,
709
+ Math.min(BEZIER_MAX_SAMPLES, Math.ceil(ctrlLen / BEZIER_SEGMENT_PX))
710
+ );
711
+ const points = [];
712
+ for (let i = 0; i <= samples; i++) {
713
+ const t = i / samples;
714
+ points.push({
715
+ x: cubicAt(p0.x, p1.x, p2.x, p3.x, t),
716
+ y: cubicAt(p0.y, p1.y, p2.y, p3.y, t)
717
+ });
628
718
  }
629
- // --- internal bookkeeping ------------------------------------------------
630
- invalidateWorld() {
631
- this.worldCache = null;
719
+ return {
720
+ points,
721
+ labelX: cubicAt(p0.x, p1.x, p2.x, p3.x, 0.5),
722
+ labelY: cubicAt(p0.y, p1.y, p2.y, p3.y, 0.5)
723
+ };
724
+ };
725
+ var STEP_OFFSET = 20;
726
+ function simplifyCorners(corners) {
727
+ const out = [];
728
+ for (const p of corners) {
729
+ const a = out[out.length - 2];
730
+ const b = out[out.length - 1];
731
+ if (b && b.x === p.x && b.y === p.y) continue;
732
+ if (a && b && (a.x === b.x && b.x === p.x || a.y === b.y && b.y === p.y)) out.pop();
733
+ out.push(p);
632
734
  }
633
- /**
634
- * The single node-map write path. Invalidates the world-position cache exactly
635
- * when a layout-affecting field changed (the node's position or its parent
636
- * chain) or the node is new so size/lane/selection/hover writes skip the
637
- * O(n) rebuild, and no mutator can forget to invalidate.
638
- */
639
- writeNode(id, next) {
640
- const prev = this.nodes.get(id);
641
- this.nodes.set(id, next);
642
- if (!prev || prev.parentId !== next.parentId || prev.position.x !== next.position.x || prev.position.y !== next.position.y) {
643
- this.invalidateWorld();
735
+ return out;
736
+ }
737
+ function stepCorners(params) {
738
+ const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = params;
739
+ const s = { x: sourceX, y: sourceY };
740
+ const t = { x: targetX, y: targetY };
741
+ const sDir = SIDE_DIR[sourcePosition];
742
+ const tDir = SIDE_DIR[targetPosition];
743
+ const sg = { x: sourceX + sDir.x * STEP_OFFSET, y: sourceY + sDir.y * STEP_OFFSET };
744
+ const tg = { x: targetX + tDir.x * STEP_OFFSET, y: targetY + tDir.y * STEP_OFFSET };
745
+ return simplifyCorners(routeCorners(s, t, sg, tg, sDir, tDir));
746
+ }
747
+ function routeCorners(s, t, sg, tg, sDir, tDir) {
748
+ const sHoriz = sDir.x !== 0;
749
+ const tHoriz = tDir.x !== 0;
750
+ if (sHoriz && tHoriz) {
751
+ if (sDir.x * tDir.x < 0) {
752
+ if ((t.x - s.x) * sDir.x >= 2 * STEP_OFFSET) {
753
+ const midX = (s.x + t.x) / 2;
754
+ return [s, { x: midX, y: s.y }, { x: midX, y: t.y }, t];
755
+ }
756
+ const midY = (s.y + t.y) / 2;
757
+ return [s, sg, { x: sg.x, y: midY }, { x: tg.x, y: midY }, tg, t];
644
758
  }
759
+ const railX = sDir.x > 0 ? Math.max(sg.x, tg.x) : Math.min(sg.x, tg.x);
760
+ return [s, { x: railX, y: s.y }, { x: railX, y: t.y }, t];
645
761
  }
646
- /** The single node-map delete path. Always invalidates — a removal shifts descendants. */
647
- deleteNode(id) {
648
- this.nodes.delete(id);
649
- this.invalidateWorld();
650
- }
651
- selectionKey() {
652
- return `${[...this.selectedNodeIds].sort().join(",")}|${this.selectedEdgeId ?? ""}`;
762
+ if (!sHoriz && !tHoriz) {
763
+ if (sDir.y * tDir.y < 0) {
764
+ if ((t.y - s.y) * sDir.y >= 2 * STEP_OFFSET) {
765
+ const midY = (s.y + t.y) / 2;
766
+ return [s, { x: s.x, y: midY }, { x: t.x, y: midY }, t];
767
+ }
768
+ const midX = (s.x + t.x) / 2;
769
+ return [s, sg, { x: midX, y: sg.y }, { x: midX, y: tg.y }, tg, t];
770
+ }
771
+ const railY = sDir.y > 0 ? Math.max(sg.y, tg.y) : Math.min(sg.y, tg.y);
772
+ return [s, { x: s.x, y: railY }, { x: t.x, y: railY }, t];
653
773
  }
654
- buildSelection() {
655
- return {
656
- nodes: [...this.selectedNodeIds].map((id) => this.nodes.get(id)).filter((n) => n !== void 0),
657
- edges: this.selectedEdgeId ? [this.edges.get(this.selectedEdgeId)].filter((e) => e !== void 0) : []
774
+ const corner = sHoriz ? { x: t.x, y: s.y } : { x: s.x, y: t.y };
775
+ const exitsSource = sHoriz ? (corner.x - s.x) * sDir.x >= STEP_OFFSET : (corner.y - s.y) * sDir.y >= STEP_OFFSET;
776
+ const exitsTarget = tHoriz ? (corner.x - t.x) * tDir.x >= STEP_OFFSET : (corner.y - t.y) * tDir.y >= STEP_OFFSET;
777
+ if (exitsSource && exitsTarget) return [s, corner, t];
778
+ const bend = sHoriz ? { x: sg.x, y: tg.y } : { x: tg.x, y: sg.y };
779
+ return [s, sg, bend, tg, t];
780
+ }
781
+ function roundCorners(corners, radius) {
782
+ if (corners.length <= 2) return corners;
783
+ const points = [];
784
+ const first = corners[0];
785
+ if (first) points.push(first);
786
+ for (let i = 1; i < corners.length - 1; i++) {
787
+ const prev = corners[i - 1];
788
+ const curr = corners[i];
789
+ const next = corners[i + 1];
790
+ if (!prev || !curr || !next) continue;
791
+ const inLen = Math.hypot(curr.x - prev.x, curr.y - prev.y);
792
+ const outLen = Math.hypot(next.x - curr.x, next.y - curr.y);
793
+ const r = Math.min(radius, inLen / 2, outLen / 2);
794
+ const inPt = {
795
+ x: curr.x + (prev.x - curr.x) / (inLen || 1) * r,
796
+ y: curr.y + (prev.y - curr.y) / (inLen || 1) * r
658
797
  };
798
+ const outPt = {
799
+ x: curr.x + (next.x - curr.x) / (outLen || 1) * r,
800
+ y: curr.y + (next.y - curr.y) / (outLen || 1) * r
801
+ };
802
+ points.push(inPt);
803
+ for (let j = 1; j < ARC_SAMPLES; j++) {
804
+ const tt = j / ARC_SAMPLES;
805
+ points.push({
806
+ x: quadAt(inPt.x, curr.x, outPt.x, tt),
807
+ y: quadAt(inPt.y, curr.y, outPt.y, tt)
808
+ });
809
+ }
810
+ points.push(outPt);
659
811
  }
660
- notifySelection() {
661
- const key = this.selectionKey();
662
- if (key === this.lastSelectionKey) return;
663
- this.lastSelectionKey = key;
664
- if (this.selectionBoxActive) this.bumpSelectionBox();
665
- if (this.selectionListeners.size === 0) return;
666
- const selection = this.buildSelection();
667
- for (const listener of this.selectionListeners) listener(selection);
668
- }
669
- /**
670
- * The one emission policy. Unless a `reconcile` is applying incoming props
671
- * (which must never echo them back out), record the batch to history — when
672
- * `record` is set — then hand it to the controlled consumer. Recording happens
673
- * before the handler so history works in uncontrolled mode too.
674
- */
675
- emit(changes, handler, record) {
676
- if (this.reconciling || changes.length === 0) return;
677
- if (record) this.history.record(hasRecordableChange(changes));
678
- else if (hasRecordableChange(changes)) this.history.rebase();
679
- handler?.(changes);
680
- }
681
- emitNodeChanges(changes, record = true) {
682
- this.emit(changes, this.nodeChangeHandler, record);
683
- }
684
- emitEdgeChanges(changes, record = true) {
685
- this.emit(changes, this.edgeChangeHandler, record);
686
- }
687
- bumpNode(id) {
688
- this.nodeVersions.set(id, (this.nodeVersions.get(id) ?? 0) + 1);
689
- const listeners = this.nodeListeners.get(id);
690
- if (listeners) for (const listener of listeners) listener();
691
- if (this.selectionBoxActive && this.selectedNodeIds.has(id)) this.bumpSelectionBox();
692
- }
693
- bumpSelectionBox() {
694
- this.selectionBoxVersion += 1;
695
- for (const listener of this.selectionBoxListeners) listener();
696
- }
697
- bumpAnchors(id) {
698
- this.anchorVersions.set(id, (this.anchorVersions.get(id) ?? 0) + 1);
699
- const listeners = this.anchorListeners.get(id);
700
- if (listeners) for (const listener of listeners) listener();
812
+ const last = corners[corners.length - 1];
813
+ if (last) points.push(last);
814
+ return points;
815
+ }
816
+ function stepLabel(corners, params) {
817
+ const i = Math.floor((corners.length - 1) / 2);
818
+ const a = corners[i];
819
+ const b = corners[i + 1];
820
+ if (!a || !b) return { x: params.sourceX, y: params.sourceY };
821
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
822
+ }
823
+ var getSmoothStepPath = (params) => {
824
+ const corners = stepCorners(params);
825
+ const label = stepLabel(corners, params);
826
+ return { points: roundCorners(corners, CORNER_RADIUS), labelX: label.x, labelY: label.y };
827
+ };
828
+ var getStepPath = (params) => {
829
+ const corners = stepCorners(params);
830
+ const label = stepLabel(corners, params);
831
+ return { points: corners, labelX: label.x, labelY: label.y };
832
+ };
833
+ var BUILTIN_EDGE_TYPES = {
834
+ bezier: getBezierPath,
835
+ smoothstep: getSmoothStepPath,
836
+ step: getStepPath,
837
+ straight: getStraightPath
838
+ };
839
+
840
+ // src/renderer/edge-geometry.ts
841
+ var EDGE_COLOR = "rgba(100, 116, 139, 0.75)";
842
+ var EDGE_COLOR_SELECTED = "#6366f1";
843
+ var EDGE_COLOR_HOVERED = "rgba(99, 102, 241, 0.9)";
844
+ var EDGE_WIDTH = 1.5;
845
+ var EDGE_WIDTH_HOVERED = 2.5;
846
+ var PENDING_COLOR = "#6366f1";
847
+ var OPPOSITE = {
848
+ left: "right",
849
+ right: "left",
850
+ top: "bottom",
851
+ bottom: "top"
852
+ };
853
+ var falseToNull = (m) => m || null;
854
+ function buildEdges(edges, nodes, viewport, edgeTypes, defaults, extras) {
855
+ const handles = extras?.handles;
856
+ const hovered = extras?.hovered;
857
+ const reconnecting = extras?.reconnecting;
858
+ const worldPositions = extras?.worldPositions;
859
+ const resolved = [];
860
+ for (const edge of edges) {
861
+ if (edge.id === reconnecting) continue;
862
+ const source = nodes.get(edge.source);
863
+ const target = nodes.get(edge.target);
864
+ if (!source || !target) continue;
865
+ const frame = { handles, viewport, worldPositions, extras };
866
+ const start = resolveEnd(edge, "source", source, target, frame);
867
+ const end = resolveEnd(edge, "target", target, source, frame);
868
+ const params = {
869
+ sourceX: start.point.x,
870
+ sourceY: start.point.y,
871
+ targetX: end.point.x,
872
+ targetY: end.point.y,
873
+ sourcePosition: start.position,
874
+ targetPosition: end.position
875
+ };
876
+ const pathFn = edgeTypes[edge.type ?? defaults.type ?? "bezier"] ?? getBezierPath;
877
+ const path = pathFn(params);
878
+ if (path.points.length < 2) continue;
879
+ const style = { ...defaults.style, ...edge.style };
880
+ const isHovered = edge.id === hovered;
881
+ resolved.push({
882
+ id: edge.id,
883
+ points: path.points,
884
+ source: start.point,
885
+ target: end.point,
886
+ label: edge.label !== void 0 ? { x: path.labelX, y: path.labelY } : void 0,
887
+ stroke: edge.selected ? EDGE_COLOR_SELECTED : isHovered ? EDGE_COLOR_HOVERED : style.stroke ?? EDGE_COLOR,
888
+ width: style.strokeWidth ?? (isHovered ? EDGE_WIDTH_HOVERED : EDGE_WIDTH),
889
+ opacity: style.opacity ?? 1,
890
+ animated: edge.animated ?? defaults.animated ?? false,
891
+ dash: style.strokeDasharray,
892
+ markerStart: falseToNull(edge.markerStart ?? defaults.markerStart),
893
+ markerEnd: falseToNull(edge.markerEnd ?? defaults.markerEnd)
894
+ });
701
895
  }
702
- bumpEdges() {
703
- this.edgesVersion += 1;
704
- for (const listener of this.edgesListeners) listener();
896
+ return resolved;
897
+ }
898
+ function buildPendingPath(pending, handles, viewport) {
899
+ const record = handles?.get(handleKey(pending.source, pending.sourceHandle));
900
+ const sourcePosition = record?.position ?? (pending.sourceType === "target" ? "left" : "right");
901
+ const from = worldToScreen(pending.from, viewport);
902
+ const to = worldToScreen(pending.to, viewport);
903
+ const targetPosition = pending.toPosition ?? OPPOSITE[sourcePosition];
904
+ return getBezierPath({
905
+ sourceX: from.x,
906
+ sourceY: from.y,
907
+ targetX: to.x,
908
+ targetY: to.y,
909
+ sourcePosition,
910
+ targetPosition
911
+ }).points;
912
+ }
913
+ function pointOnSide(rect, side, alongPct = 50) {
914
+ const t = alongPct / 100;
915
+ switch (side) {
916
+ case "left":
917
+ return { x: rect.x, y: rect.y + rect.height * t };
918
+ case "right":
919
+ return { x: rect.x + rect.width, y: rect.y + rect.height * t };
920
+ case "top":
921
+ return { x: rect.x + rect.width * t, y: rect.y };
922
+ case "bottom":
923
+ return { x: rect.x + rect.width * t, y: rect.y + rect.height };
705
924
  }
706
- // Node selection is React state (nodes are DOM), so bump only what flips.
707
- // `target` is the exact set that should end up selected.
708
- applyNodeSelection(target) {
709
- const changes = [];
710
- for (const [nodeId, node] of this.nodes) {
711
- const selected = target.has(nodeId);
712
- if (Boolean(node.selected) !== selected) {
713
- this.writeNode(nodeId, { ...node, selected });
714
- this.bumpNode(nodeId);
715
- changes.push({ type: "select", id: nodeId, selected });
716
- }
925
+ }
926
+ function nodeRect(node, worldPositions) {
927
+ const origin = worldPositions?.get(node.id) ?? node.position;
928
+ const size = node.size ?? DEFAULT_NODE_SIZE;
929
+ return { x: origin.x, y: origin.y, width: size.width, height: size.height };
930
+ }
931
+ var WANTED_TYPES = {
932
+ source: ["source", "either"],
933
+ target: ["target", "either"]
934
+ };
935
+ function handleOnSide(nodeId, side, role, handles) {
936
+ if (!handles) return null;
937
+ for (const record of handles.values()) {
938
+ if (record.nodeId === nodeId && record.position === side && WANTED_TYPES[role].includes(record.type)) {
939
+ return record;
717
940
  }
718
- this.selectedNodeIds.clear();
719
- for (const id of target) if (this.nodes.has(id)) this.selectedNodeIds.add(id);
720
- this.emitNodeChanges(changes);
721
941
  }
722
- setNodeHover(id) {
723
- if (this.hoveredNodeId === id) return;
724
- const prev = this.hoveredNodeId;
725
- this.hoveredNodeId = id;
726
- if (prev !== null) {
727
- const node = this.nodes.get(prev);
728
- if (node) {
729
- this.writeNode(prev, { ...node, hovered: false });
730
- this.bumpNode(prev);
942
+ return null;
943
+ }
944
+ function reachableSides(nodeId, role, handles) {
945
+ if (!handles) return [];
946
+ const sides = [];
947
+ for (const record of handles.values()) {
948
+ if (record.nodeId !== nodeId) continue;
949
+ if (!WANTED_TYPES[role].includes(record.type)) continue;
950
+ if (!sides.includes(record.position)) sides.push(record.position);
951
+ }
952
+ return sides;
953
+ }
954
+ function resolveEnd(edge, role, node, peer, frame) {
955
+ const { handles, viewport, worldPositions, extras } = frame;
956
+ const handleId = role === "source" ? edge.sourceHandle : edge.targetHandle;
957
+ const rect = nodeRect(node, worldPositions);
958
+ const anchorId = parseAnchorAutoHandleId(handleId) ?? parseAnchorHandleId(handleId)?.anchorId ?? null;
959
+ const endAt = (side2) => {
960
+ const alongPct = (anchorId ? extras?.anchorPlacement?.(node.id, anchorId, side2) : null) ?? void 0;
961
+ if (alongPct === void 0 && !anchorId) {
962
+ const candidate = handleOnSide(node.id, side2, role, handles);
963
+ if (candidate) {
964
+ return {
965
+ point: worldToScreen(
966
+ { x: rect.x + candidate.offset.x, y: rect.y + candidate.offset.y },
967
+ viewport
968
+ ),
969
+ position: side2
970
+ };
731
971
  }
732
972
  }
733
- if (id !== null) {
734
- const node = this.nodes.get(id);
735
- if (node) {
736
- this.writeNode(id, { ...node, hovered: true });
737
- this.bumpNode(id);
738
- }
973
+ return { point: worldToScreen(pointOnSide(rect, side2, alongPct), viewport), position: side2 };
974
+ };
975
+ const registered = handleId && handles ? handles.get(handleKey(node.id, handleId)) : void 0;
976
+ const pinnedSide = parseAnchorHandleId(handleId)?.side ?? registered?.position ?? parseSideHandleId(handleId);
977
+ const live = extras?.liveAlignNodes !== void 0 && (extras.liveAlignNodes.has(edge.source) || extras.liveAlignNodes.has(edge.target)) && edge.source !== edge.target;
978
+ if (pinnedSide && !live) {
979
+ if (registered) {
980
+ return {
981
+ point: worldToScreen(
982
+ { x: rect.x + registered.offset.x, y: rect.y + registered.offset.y },
983
+ viewport
984
+ ),
985
+ position: registered.position
986
+ };
739
987
  }
988
+ return endAt(pinnedSide);
740
989
  }
741
- // Edge selection is canvas-only, so it just invalidates the frame.
742
- setEdgeSelection(id) {
743
- if (this.selectedEdgeId === id) return;
744
- const changes = [];
745
- if (this.selectedEdgeId) {
746
- const prev = this.edges.get(this.selectedEdgeId);
747
- if (prev) {
748
- this.edges.set(this.selectedEdgeId, { ...prev, selected: false });
990
+ const key = displayedSideKey(edge.id, role);
991
+ const side = resolveFacingSideAmong(
992
+ rect,
993
+ nodeRect(peer, worldPositions),
994
+ anchorId ? [] : reachableSides(node.id, role, handles),
995
+ extras?.displayedSides?.get(key)
996
+ );
997
+ extras?.displayedSides?.set(key, side);
998
+ return endAt(side);
999
+ }
1000
+ function distanceToPolyline(point, points) {
1001
+ let best = Number.POSITIVE_INFINITY;
1002
+ for (let i = 0; i < points.length - 1; i++) {
1003
+ const a = points[i];
1004
+ const b = points[i + 1];
1005
+ if (!a || !b) continue;
1006
+ best = Math.min(best, distanceToSegment(point, a, b));
1007
+ }
1008
+ return best;
1009
+ }
1010
+ function distanceToSegment(p, a, b) {
1011
+ const dx = b.x - a.x;
1012
+ const dy = b.y - a.y;
1013
+ const lenSq = dx * dx + dy * dy;
1014
+ if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
1015
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
1016
+ t = Math.max(0, Math.min(1, t));
1017
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
1018
+ }
1019
+
1020
+ // src/store.ts
1021
+ var RivetGraphStore = class {
1022
+ nodes = /* @__PURE__ */ new Map();
1023
+ edges = /* @__PURE__ */ new Map();
1024
+ handles = /* @__PURE__ */ new Map();
1025
+ anchors = /* @__PURE__ */ new Map();
1026
+ // Arrow-wrapped so the (later-initialized) requestRender field is looked up
1027
+ // at call time, not while this field is being constructed.
1028
+ presence = createPresenceRegistry({
1029
+ requestRender: () => this.requestRender(),
1030
+ requestCursorRender: () => this.requestCursorRender()
1031
+ });
1032
+ locks = createLockRegistry(() => this.requestRender());
1033
+ viewport;
1034
+ viewportClamp = null;
1035
+ pending = null;
1036
+ nodeVersions = /* @__PURE__ */ new Map();
1037
+ nodeListeners = /* @__PURE__ */ new Map();
1038
+ anchorVersions = /* @__PURE__ */ new Map();
1039
+ anchorListeners = /* @__PURE__ */ new Map();
1040
+ edgesVersion = 0;
1041
+ edgesListeners = /* @__PURE__ */ new Set();
1042
+ nodeElements = /* @__PURE__ */ new Map();
1043
+ viewportListeners = /* @__PURE__ */ new Set();
1044
+ frameListeners = /* @__PURE__ */ new Set();
1045
+ selectionListeners = /* @__PURE__ */ new Set();
1046
+ selectedNodeIds = /* @__PURE__ */ new Set();
1047
+ selectionBoxActive = false;
1048
+ selectionBoxVersion = 0;
1049
+ selectionBoxListeners = /* @__PURE__ */ new Set();
1050
+ hoveredNodeId = null;
1051
+ focusedNodeId = null;
1052
+ focusListeners = /* @__PURE__ */ new Set();
1053
+ selectedEdgeId = null;
1054
+ nodeDragging = false;
1055
+ nodeResizing = false;
1056
+ edgeAlignment = "manual";
1057
+ draggingNodeIds = /* @__PURE__ */ new Set();
1058
+ heldNodeIds = /* @__PURE__ */ new Set();
1059
+ heldListeners = /* @__PURE__ */ new Set();
1060
+ gestureStartHandler = null;
1061
+ gestureEndHandler = null;
1062
+ lockConflictHandler = null;
1063
+ displayedSides = /* @__PURE__ */ new Map();
1064
+ edgeLabelAnchors = /* @__PURE__ */ new Map();
1065
+ alignmentGuides = [];
1066
+ nodeChangeHandler = null;
1067
+ edgeChangeHandler = null;
1068
+ reconciling = false;
1069
+ history = new HistoryManager(() => this.historySnapshot());
1070
+ lastSelectionKey;
1071
+ worldCache = null;
1072
+ /** {@link worldCache} with peers' live gesture boxes folded in. */
1073
+ peerWorldCache = null;
1074
+ /** Presence transform version {@link peerWorldCache} was built from. */
1075
+ peerWorldVersion = -1;
1076
+ renderRequester = () => {
1077
+ };
1078
+ /**
1079
+ * Null until a runtime claims it, and then `requestCursorRender` narrows to
1080
+ * the cursor layer. Falling back to a full frame is the safe default: a
1081
+ * consumer without the layer mounted still gets its paint.
1082
+ */
1083
+ cursorRenderRequester = null;
1084
+ constructor(init) {
1085
+ this.viewport = init.viewport;
1086
+ for (const node of init.nodes) this.writeNode(node.id, node);
1087
+ for (const edge of init.edges) this.edges.set(edge.id, edge);
1088
+ for (const node of init.nodes) if (node.selected) this.selectedNodeIds.add(node.id);
1089
+ this.selectedEdgeId = init.edges.find((edge) => edge.selected)?.id ?? null;
1090
+ this.lastSelectionKey = this.selectionKey();
1091
+ this.history.clear();
1092
+ }
1093
+ // --- internal bookkeeping ------------------------------------------------
1094
+ invalidateWorld() {
1095
+ this.worldCache = null;
1096
+ this.peerWorldCache = null;
1097
+ }
1098
+ /**
1099
+ * The single node-map write path. Invalidates the world-position cache exactly
1100
+ * when a layout-affecting field changed (the node's position or its parent
1101
+ * chain) or the node is new — so size/lane/selection/hover writes skip the
1102
+ * O(n) rebuild, and no mutator can forget to invalidate.
1103
+ */
1104
+ writeNode(id, next) {
1105
+ const prev = this.nodes.get(id);
1106
+ this.nodes.set(id, next);
1107
+ if (!prev || prev.parentId !== next.parentId || prev.position.x !== next.position.x || prev.position.y !== next.position.y) {
1108
+ this.invalidateWorld();
1109
+ }
1110
+ }
1111
+ /** The single node-map delete path. Always invalidates — a removal shifts descendants. */
1112
+ deleteNode(id) {
1113
+ this.nodes.delete(id);
1114
+ this.invalidateWorld();
1115
+ }
1116
+ selectionKey() {
1117
+ return `${[...this.selectedNodeIds].sort().join(",")}|${this.selectedEdgeId ?? ""}`;
1118
+ }
1119
+ buildSelection() {
1120
+ return {
1121
+ nodes: [...this.selectedNodeIds].map((id) => this.nodes.get(id)).filter((n) => n !== void 0),
1122
+ edges: this.selectedEdgeId ? [this.edges.get(this.selectedEdgeId)].filter((e) => e !== void 0) : []
1123
+ };
1124
+ }
1125
+ notifySelection() {
1126
+ const key = this.selectionKey();
1127
+ if (key === this.lastSelectionKey) return;
1128
+ this.lastSelectionKey = key;
1129
+ if (this.selectionBoxActive) this.bumpSelectionBox();
1130
+ if (this.selectionListeners.size === 0) return;
1131
+ const selection = this.buildSelection();
1132
+ for (const listener of this.selectionListeners) listener(selection);
1133
+ }
1134
+ notifyHeld() {
1135
+ this.peerWorldCache = null;
1136
+ this.requestRender();
1137
+ for (const listener of this.heldListeners) listener();
1138
+ }
1139
+ /**
1140
+ * The one emission policy. Unless a `reconcile` is applying incoming props
1141
+ * (which must never echo them back out), record the batch to history — when
1142
+ * `record` is set — then hand it to the controlled consumer. Recording happens
1143
+ * before the handler so history works in uncontrolled mode too.
1144
+ */
1145
+ emit(changes, handler, record, origin = "local") {
1146
+ if (this.reconciling || changes.length === 0) return;
1147
+ if (record) this.history.record(hasRecordableChange(changes));
1148
+ else if (hasRecordableChange(changes)) this.history.rebase();
1149
+ handler?.(changes, { origin });
1150
+ }
1151
+ emitNodeChanges(changes, record = true) {
1152
+ this.emit(changes, this.nodeChangeHandler, record);
1153
+ }
1154
+ emitEdgeChanges(changes, record = true) {
1155
+ this.emit(changes, this.edgeChangeHandler, record);
1156
+ }
1157
+ bumpNode(id) {
1158
+ this.nodeVersions.set(id, (this.nodeVersions.get(id) ?? 0) + 1);
1159
+ const listeners = this.nodeListeners.get(id);
1160
+ if (listeners) for (const listener of listeners) listener();
1161
+ if (this.selectionBoxActive && this.selectedNodeIds.has(id)) this.bumpSelectionBox();
1162
+ }
1163
+ bumpSelectionBox() {
1164
+ this.selectionBoxVersion += 1;
1165
+ for (const listener of this.selectionBoxListeners) listener();
1166
+ }
1167
+ bumpAnchors(id) {
1168
+ this.anchorVersions.set(id, (this.anchorVersions.get(id) ?? 0) + 1);
1169
+ const listeners = this.anchorListeners.get(id);
1170
+ if (listeners) for (const listener of listeners) listener();
1171
+ }
1172
+ bumpEdges() {
1173
+ this.edgesVersion += 1;
1174
+ for (const listener of this.edgesListeners) listener();
1175
+ }
1176
+ // Node selection is React state (nodes are DOM), so bump only what flips.
1177
+ // `target` is the exact set that should end up selected.
1178
+ applyNodeSelection(target) {
1179
+ const changes = [];
1180
+ for (const [nodeId, node] of this.nodes) {
1181
+ const selected = target.has(nodeId);
1182
+ if (Boolean(node.selected) !== selected) {
1183
+ this.writeNode(nodeId, { ...node, selected });
1184
+ this.bumpNode(nodeId);
1185
+ changes.push({ type: "select", id: nodeId, selected });
1186
+ }
1187
+ }
1188
+ this.selectedNodeIds.clear();
1189
+ for (const id of target) if (this.nodes.has(id)) this.selectedNodeIds.add(id);
1190
+ this.emitNodeChanges(changes);
1191
+ }
1192
+ setNodeHover(id) {
1193
+ if (this.hoveredNodeId === id) return;
1194
+ const prev = this.hoveredNodeId;
1195
+ this.hoveredNodeId = id;
1196
+ if (prev !== null) {
1197
+ const node = this.nodes.get(prev);
1198
+ if (node) {
1199
+ this.writeNode(prev, { ...node, hovered: false });
1200
+ this.bumpNode(prev);
1201
+ }
1202
+ }
1203
+ if (id !== null) {
1204
+ const node = this.nodes.get(id);
1205
+ if (node) {
1206
+ this.writeNode(id, { ...node, hovered: true });
1207
+ this.bumpNode(id);
1208
+ }
1209
+ }
1210
+ }
1211
+ // Edge selection is canvas-only, so it just invalidates the frame.
1212
+ setEdgeSelection(id) {
1213
+ if (this.selectedEdgeId === id) return;
1214
+ const changes = [];
1215
+ if (this.selectedEdgeId) {
1216
+ const prev = this.edges.get(this.selectedEdgeId);
1217
+ if (prev) {
1218
+ this.edges.set(this.selectedEdgeId, { ...prev, selected: false });
749
1219
  changes.push({ type: "select", id: this.selectedEdgeId, selected: false });
750
1220
  }
751
1221
  }
@@ -821,10 +1291,11 @@ var RivetGraphStore = class {
821
1291
  this.writeNode(node.id, node);
822
1292
  continue;
823
1293
  }
824
- if (nodeControlledEqual(existing, node)) continue;
825
- this.writeNode(node.id, existing.hovered ? { ...node, hovered: true } : node);
1294
+ const settled = this.heldNodeIds.has(node.id) ? keepLocalGeometry(node, existing) : node;
1295
+ if (nodeControlledEqual(existing, settled)) continue;
1296
+ this.writeNode(node.id, existing.hovered ? { ...settled, hovered: true } : settled);
826
1297
  this.bumpNode(node.id);
827
- if (existing.position.x !== node.position.x || existing.position.y !== node.position.y) {
1298
+ if (existing.position.x !== settled.position.x || existing.position.y !== settled.position.y) {
828
1299
  moved.push(node.id);
829
1300
  }
830
1301
  }
@@ -917,13 +1388,54 @@ var RivetGraphStore = class {
917
1388
  this.worldCache = /* @__PURE__ */ new Map();
918
1389
  for (const id of this.nodes.keys()) this.worldCache.set(id, worldPosition(this.nodes, id));
919
1390
  }
920
- return this.worldCache;
1391
+ const version = this.presence.getTransformVersion();
1392
+ if (this.presence.getNodeTransforms().size === 0) return this.worldCache;
1393
+ if (this.peerWorldCache && this.peerWorldVersion === version) return this.peerWorldCache;
1394
+ this.peerWorldVersion = version;
1395
+ this.peerWorldCache = this.withPeerGestures(this.worldCache);
1396
+ return this.peerWorldCache;
921
1397
  };
1398
+ /**
1399
+ * Fold peers' live gesture boxes into world positions.
1400
+ *
1401
+ * A node somebody else is dragging is drawn, hit-tested and wired at the box
1402
+ * they last sent — the node moves, rather than a ghost of it appearing
1403
+ * elsewhere. The graph underneath is untouched: nothing here is written back,
1404
+ * so their drag never enters the document, the change stream, or history, and
1405
+ * the node lands for real when their commit arrives.
1406
+ *
1407
+ * Descendants ride along by the same delta, since their own positions are
1408
+ * relative to a parent this map has just moved.
1409
+ */
1410
+ withPeerGestures(base) {
1411
+ const merged = new Map(base);
1412
+ for (const [id, rect] of this.presence.getNodeTransforms()) {
1413
+ if (this.heldNodeIds.has(id)) continue;
1414
+ const origin = base.get(id);
1415
+ if (!origin) continue;
1416
+ const dx = rect.x - origin.x;
1417
+ const dy = rect.y - origin.y;
1418
+ if (dx === 0 && dy === 0) continue;
1419
+ merged.set(id, { x: rect.x, y: rect.y });
1420
+ for (const childId of this.getDescendantIds(id)) {
1421
+ const child = merged.get(childId);
1422
+ if (child) merged.set(childId, { x: child.x + dx, y: child.y + dy });
1423
+ }
1424
+ }
1425
+ return merged;
1426
+ }
922
1427
  getNodeWorldPosition = (id) => this.getWorldPositions().get(id) ?? { x: 0, y: 0 };
923
1428
  getNodeRect = (id) => {
924
1429
  const origin = this.getNodeWorldPosition(id);
925
- const size = this.nodes.get(id)?.size ?? DEFAULT_NODE_SIZE;
926
- return { x: origin.x, y: origin.y, width: size.width, height: size.height };
1430
+ const live = this.heldNodeIds.has(id) ? void 0 : this.presence.getNodeTransforms().get(id);
1431
+ if (live) return { x: origin.x, y: origin.y, width: live.width, height: live.height };
1432
+ const node = this.nodes.get(id);
1433
+ return {
1434
+ x: origin.x,
1435
+ y: origin.y,
1436
+ width: node?.width ?? node?.size?.width ?? DEFAULT_NODE_SIZE.width,
1437
+ height: node?.height ?? node?.size?.height ?? DEFAULT_NODE_SIZE.height
1438
+ };
927
1439
  };
928
1440
  getNodeDepth = (id) => nodeDepth(this.nodes, id);
929
1441
  getDescendantIds = (id) => descendantIds(this.nodes, id);
@@ -1025,6 +1537,16 @@ var RivetGraphStore = class {
1025
1537
  * candidate (single-handle nodes, custom layouts, a culled peer whose
1026
1538
  * handles are unregistered) means the pin is kept.
1027
1539
  */
1540
+ /**
1541
+ * The side of `nodeId` that faces `peerRect` and can actually be reached by
1542
+ * this end. An anchor end is unconstrained — its stray places itself along
1543
+ * whichever side faces the peer — and so is a node with no compatible handle
1544
+ * at all, which attaches at side midpoints.
1545
+ */
1546
+ reachableFacingSide(nodeId, handleId, rect, peerRect, end) {
1547
+ const allowed = parseAnchorHandleId(handleId) || parseAnchorAutoHandleId(handleId) ? [] : reachableSides(nodeId, end, this.handles);
1548
+ return resolveFacingSideAmong(rect, peerRect, allowed);
1549
+ }
1028
1550
  realignEndHandle(nodeId, handleId, side, end) {
1029
1551
  if (!handleId || parseAnchorAutoHandleId(handleId)) return handleId;
1030
1552
  const anchor = parseAnchorHandleId(handleId);
@@ -1066,8 +1588,8 @@ var RivetGraphStore = class {
1066
1588
  const targetRect = this.getNodeRect(edge.target);
1067
1589
  const sourceKey = displayedSideKey(edgeId, "source");
1068
1590
  const targetKey = displayedSideKey(edgeId, "target");
1069
- const sourceSide = (live ? this.displayedSides.get(sourceKey) : void 0) ?? facingSide(sourceRect, targetRect);
1070
- const targetSide = (live ? this.displayedSides.get(targetKey) : void 0) ?? facingSide(targetRect, sourceRect);
1591
+ const sourceSide = (live ? this.displayedSides.get(sourceKey) : void 0) ?? this.reachableFacingSide(edge.source, edge.sourceHandle, sourceRect, targetRect, "source");
1592
+ const targetSide = (live ? this.displayedSides.get(targetKey) : void 0) ?? this.reachableFacingSide(edge.target, edge.targetHandle, targetRect, sourceRect, "target");
1071
1593
  const sourceHandle = this.realignEndHandle(
1072
1594
  edge.source,
1073
1595
  edge.sourceHandle,
@@ -1102,6 +1624,42 @@ var RivetGraphStore = class {
1102
1624
  if (!dragging) this.draggingNodeIds.clear();
1103
1625
  };
1104
1626
  isNodeResizing = () => this.nodeResizing;
1627
+ setGestureHandlers = (handlers) => {
1628
+ this.gestureStartHandler = handlers.start ?? null;
1629
+ this.gestureEndHandler = handlers.end ?? null;
1630
+ this.lockConflictHandler = handlers.conflict ?? null;
1631
+ };
1632
+ setLockedNodes = (locks) => {
1633
+ const opened = this.locks.setLocks(locks);
1634
+ if (opened.length === 0 || this.heldNodeIds.size === 0) return;
1635
+ const held = [...this.heldNodeIds];
1636
+ for (const nodeId of opened) {
1637
+ if (!this.heldNodeIds.has(nodeId)) continue;
1638
+ const holderId = this.locks.getHolder(nodeId);
1639
+ if (holderId) this.lockConflictHandler?.({ nodeId, holderId, ids: held });
1640
+ }
1641
+ };
1642
+ isNodeHeld = (id) => this.heldNodeIds.has(id);
1643
+ getHeldNodeIds = () => this.heldNodeIds;
1644
+ beginNodeGesture = (id, ids = [id]) => {
1645
+ const opened = ids.filter((nodeId) => !this.heldNodeIds.has(nodeId));
1646
+ if (opened.length === 0) return;
1647
+ for (const nodeId of opened) this.heldNodeIds.add(nodeId);
1648
+ this.gestureStartHandler?.({ id, ids: [...ids] });
1649
+ this.notifyHeld();
1650
+ };
1651
+ endNodeGesture = (id, ids = [id]) => {
1652
+ const closed = ids.filter((nodeId) => this.heldNodeIds.delete(nodeId));
1653
+ if (closed.length === 0) return;
1654
+ this.gestureEndHandler?.({ id, ids: [...ids] });
1655
+ this.notifyHeld();
1656
+ };
1657
+ subscribeHeldNodes = (listener) => {
1658
+ this.heldListeners.add(listener);
1659
+ return () => {
1660
+ this.heldListeners.delete(listener);
1661
+ };
1662
+ };
1105
1663
  selectNode = (id, additive = false) => {
1106
1664
  this.setSelectionBoxActive(false);
1107
1665
  if (id === null) {
@@ -1220,6 +1778,7 @@ var RivetGraphStore = class {
1220
1778
  }
1221
1779
  if (this.selectedNodeIds.size > 0) {
1222
1780
  for (const id of [...this.selectedNodeIds]) {
1781
+ if (!this.locks.allows(id, "delete")) continue;
1223
1782
  if (this.removeNodeInternal(id, removedEdges)) removedNodes.push(id);
1224
1783
  }
1225
1784
  }
@@ -1249,6 +1808,19 @@ var RivetGraphStore = class {
1249
1808
  this.history.rebase();
1250
1809
  this.requestRender();
1251
1810
  };
1811
+ applyRemote = ({ nodes = [], edges = [], origin = "remote" }) => {
1812
+ const guarded = guardNodeChanges(nodes, this.isNodeHeld, (id) => this.nodes.get(id));
1813
+ if (guarded.length === 0 && edges.length === 0) return;
1814
+ const currentNodes = [...this.nodes.values()];
1815
+ const currentEdges = [...this.edges.values()];
1816
+ this.reconcile(applyNodeChanges(guarded, currentNodes), applyEdgeChanges(edges, currentEdges));
1817
+ this.history.rewrite((snapshot) => ({
1818
+ nodes: guarded.length > 0 ? applyNodeChanges(guarded, snapshot.nodes) : snapshot.nodes,
1819
+ edges: edges.length > 0 ? applyEdgeChanges(edges, snapshot.edges) : snapshot.edges
1820
+ }));
1821
+ this.emit(guarded, this.nodeChangeHandler, false, origin);
1822
+ this.emit(edges, this.edgeChangeHandler, false, origin);
1823
+ };
1252
1824
  // --- undo / redo ---------------------------------------------------------
1253
1825
  /** Structural snapshot for history — no selection/hover/drag state. */
1254
1826
  historySnapshot() {
@@ -1453,6 +2025,13 @@ var RivetGraphStore = class {
1453
2025
  bindRenderRequester = (fn) => {
1454
2026
  this.renderRequester = fn;
1455
2027
  };
2028
+ requestCursorRender = () => {
2029
+ if (this.cursorRenderRequester) this.cursorRenderRequester();
2030
+ else this.renderRequester();
2031
+ };
2032
+ bindCursorRenderRequester = (fn) => {
2033
+ this.cursorRenderRequester = fn;
2034
+ };
1456
2035
  // --- reconnect gateway (runtime-bound; consulted by <Handle>) ------------
1457
2036
  reconnectDelegate = null;
1458
2037
  getSelectedEdge = () => this.selectedEdgeId;
@@ -1465,66 +2044,27 @@ function createRivetStore(init) {
1465
2044
  return new RivetGraphStore(init);
1466
2045
  }
1467
2046
 
1468
- // src/viewport.ts
1469
- function worldToScreen(point, viewport) {
2047
+ // src/connection.ts
2048
+ function clientToWorld(store, paneRef, clientX, clientY) {
2049
+ const rect = paneRef.current?.getBoundingClientRect();
2050
+ const point = { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) };
2051
+ return screenToWorld(point, store.getViewport());
2052
+ }
2053
+ function isCompatible(from, hit) {
2054
+ const canSource = from.type !== "target";
2055
+ const canTarget = from.type !== "source";
2056
+ return hit.nodeId !== from.nodeId && (canSource && hit.type !== "source" || canTarget && hit.type !== "target");
2057
+ }
2058
+ function findHandleAt(clientX, clientY) {
2059
+ const element = document.elementFromPoint(clientX, clientY);
2060
+ const handleEl = element instanceof Element ? element.closest(SELECTOR.handle) : null;
2061
+ if (!handleEl) return null;
2062
+ const { rivetHandleNode, rivetHandleId, rivetHandleType } = handleEl.dataset;
2063
+ if (!rivetHandleNode || !rivetHandleId || !rivetHandleType) return null;
1470
2064
  return {
1471
- x: point.x * viewport.zoom + viewport.x,
1472
- y: point.y * viewport.zoom + viewport.y
1473
- };
1474
- }
1475
- function screenToWorld(point, viewport) {
1476
- return {
1477
- x: (point.x - viewport.x) / viewport.zoom,
1478
- y: (point.y - viewport.y) / viewport.zoom
1479
- };
1480
- }
1481
- function zoomAt(viewport, anchor, nextZoom, minZoom = 0.1, maxZoom = 4) {
1482
- const zoom = clamp(nextZoom, minZoom, maxZoom);
1483
- const world = screenToWorld(anchor, viewport);
1484
- return {
1485
- zoom,
1486
- x: anchor.x - world.x * zoom,
1487
- y: anchor.y - world.y * zoom
1488
- };
1489
- }
1490
- function viewportToCss(viewport) {
1491
- return `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`;
1492
- }
1493
- function visibleWorldRect(viewport, width, height) {
1494
- const topLeft = screenToWorld({ x: 0, y: 0 }, viewport);
1495
- const bottomRight = screenToWorld({ x: width, y: height }, viewport);
1496
- return {
1497
- x: topLeft.x,
1498
- y: topLeft.y,
1499
- width: bottomRight.x - topLeft.x,
1500
- height: bottomRight.y - topLeft.y
1501
- };
1502
- }
1503
- function rectsIntersect(a, b) {
1504
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1505
- }
1506
-
1507
- // src/connection.ts
1508
- function clientToWorld(store, paneRef, clientX, clientY) {
1509
- const rect = paneRef.current?.getBoundingClientRect();
1510
- const point = { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) };
1511
- return screenToWorld(point, store.getViewport());
1512
- }
1513
- function isCompatible(from, hit) {
1514
- const canSource = from.type !== "target";
1515
- const canTarget = from.type !== "source";
1516
- return hit.nodeId !== from.nodeId && (canSource && hit.type !== "source" || canTarget && hit.type !== "target");
1517
- }
1518
- function findHandleAt(clientX, clientY) {
1519
- const element = document.elementFromPoint(clientX, clientY);
1520
- const handleEl = element instanceof Element ? element.closest(SELECTOR.handle) : null;
1521
- if (!handleEl) return null;
1522
- const { rivetHandleNode, rivetHandleId, rivetHandleType } = handleEl.dataset;
1523
- if (!rivetHandleNode || !rivetHandleId || !rivetHandleType) return null;
1524
- return {
1525
- nodeId: rivetHandleNode,
1526
- handleId: rivetHandleId,
1527
- type: rivetHandleType
2065
+ nodeId: rivetHandleNode,
2066
+ handleId: rivetHandleId,
2067
+ type: rivetHandleType
1528
2068
  };
1529
2069
  }
1530
2070
  function handleWorldPosition(store, hit) {
@@ -1595,8 +2135,17 @@ function startConnectionDrag(config) {
1595
2135
  validate,
1596
2136
  map,
1597
2137
  onEnd,
1598
- resolveHit = resolveConnectionHit
2138
+ resolveHit: resolveHitInput = resolveConnectionHit
1599
2139
  } = config;
2140
+ if (!store.locks.allows(from.nodeId, "connect")) {
2141
+ onEnd?.();
2142
+ return;
2143
+ }
2144
+ const resolveHit = (...args) => {
2145
+ const hit = resolveHitInput(...args);
2146
+ if (hit && !store.locks.allows(hit.nodeId, "connect")) return null;
2147
+ return hit;
2148
+ };
1600
2149
  store.beginConnection(begin);
1601
2150
  const onMove = (event) => {
1602
2151
  const hit = resolveHit(store, paneRef, from, event.clientX, event.clientY);
@@ -1699,6 +2248,10 @@ function Handle({
1699
2248
  if (event.key !== "Enter" && event.key !== " ") return;
1700
2249
  event.preventDefault();
1701
2250
  const self = { nodeId, handleId, type: resolvedType };
2251
+ if (!store.locks.allows(nodeId, "connect")) {
2252
+ announce(`${nodeName(nodeId)} is locked by someone else.`);
2253
+ return;
2254
+ }
1702
2255
  const pending = store.getPending();
1703
2256
  if (!pending) {
1704
2257
  const origin = handleWorldPosition(store, self);
@@ -1751,6 +2304,7 @@ function Handle({
1751
2304
  const onFocus = () => {
1752
2305
  const pending = store.getPending();
1753
2306
  if (!pending || pending.source === nodeId && pending.sourceHandle === handleId) return;
2307
+ if (!store.locks.allows(nodeId, "connect")) return;
1754
2308
  const world = handleWorldPosition(store, { nodeId, handleId});
1755
2309
  if (world) store.updateConnection(world, place);
1756
2310
  };
@@ -1835,7 +2389,12 @@ var baseStyle = {
1835
2389
  minWidth: 120,
1836
2390
  padding: "10px 14px",
1837
2391
  borderRadius: 8,
1838
- border: "1px solid rgba(100, 116, 139, 0.4)",
2392
+ // Longhand, not the `border` shorthand: the hover/selected styles below
2393
+ // override `borderColor` alone, and React warns when a shorthand and a
2394
+ // longhand for the same value are mixed across renders.
2395
+ borderWidth: 1,
2396
+ borderStyle: "solid",
2397
+ borderColor: "rgba(100, 116, 139, 0.4)",
1839
2398
  background: "#ffffff",
1840
2399
  color: "#0f172a",
1841
2400
  fontSize: 13,
@@ -1867,7 +2426,10 @@ var baseStyle2 = {
1867
2426
  minHeight: 100,
1868
2427
  boxSizing: "border-box",
1869
2428
  borderRadius: 10,
1870
- border: "1.5px dashed rgba(100, 116, 139, 0.45)",
2429
+ // Longhand see the note in `default-node.tsx`.
2430
+ borderWidth: 1.5,
2431
+ borderStyle: "dashed",
2432
+ borderColor: "rgba(100, 116, 139, 0.45)",
1871
2433
  background: "rgba(100, 116, 139, 0.06)"
1872
2434
  };
1873
2435
  var hoveredStyle2 = {
@@ -2051,6 +2613,11 @@ function NodeResizer({
2051
2613
  }) {
2052
2614
  const { store } = useRivetContext();
2053
2615
  const { nodeId } = useRivetNodeContext();
2616
+ const resizable = useSyncExternalStore(
2617
+ store.locks.subscribe,
2618
+ useCallback(() => store.locks.allows(nodeId, "resize"), [store, nodeId]),
2619
+ useCallback(() => store.locks.allows(nodeId, "resize"), [store, nodeId])
2620
+ );
2054
2621
  const startResize = (dir) => (event) => {
2055
2622
  if (event.button !== 0) return;
2056
2623
  event.stopPropagation();
@@ -2082,7 +2649,16 @@ function NodeResizer({
2082
2649
  };
2083
2650
  return { size: { width, height }, position };
2084
2651
  };
2652
+ let started = false;
2085
2653
  const onMove = (moveEvent) => {
2654
+ if (!started) {
2655
+ started = true;
2656
+ store.beginNodeGesture(nodeId);
2657
+ } else if (!store.isNodeHeld(nodeId)) {
2658
+ window.removeEventListener("pointermove", onMove);
2659
+ window.removeEventListener("pointerup", onUp);
2660
+ return;
2661
+ }
2086
2662
  const { size, position } = compute(moveEvent);
2087
2663
  const moved = dir.left || dir.top;
2088
2664
  store.resizeNode(nodeId, size, moved ? position : void 0, false);
@@ -2091,14 +2667,17 @@ function NodeResizer({
2091
2667
  const onUp = (upEvent) => {
2092
2668
  window.removeEventListener("pointermove", onMove);
2093
2669
  window.removeEventListener("pointerup", onUp);
2670
+ if (started && !store.isNodeHeld(nodeId)) return;
2094
2671
  const { size, position } = compute(upEvent);
2095
2672
  const moved = dir.left || dir.top;
2096
2673
  store.resizeNode(nodeId, size, moved ? position : void 0, true);
2674
+ if (started) store.endNodeGesture(nodeId);
2097
2675
  onResizeEnd?.(size, position);
2098
2676
  };
2099
2677
  window.addEventListener("pointermove", onMove);
2100
2678
  window.addEventListener("pointerup", onUp);
2101
2679
  };
2680
+ if (!resizable) return null;
2102
2681
  return /* @__PURE__ */ jsx(Fragment, { children: HANDLES.map((dir) => /* @__PURE__ */ jsx(
2103
2682
  "div",
2104
2683
  {
@@ -2114,6 +2693,106 @@ function NodeResizer({
2114
2693
  dir.key
2115
2694
  )) });
2116
2695
  }
2696
+
2697
+ // src/presence/local.ts
2698
+ var sameIds = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
2699
+ var samePresence = (a, b) => a.cursor?.x === b.cursor?.x && a.cursor?.y === b.cursor?.y && sameIds(a.selection, b.selection) && sameIds(a.holding, b.holding);
2700
+ function trackLocalPresence({
2701
+ store,
2702
+ pane,
2703
+ emit,
2704
+ throttleMs
2705
+ }) {
2706
+ let cursor = null;
2707
+ let screenPoint = null;
2708
+ let last = null;
2709
+ let timer = null;
2710
+ let lastEmit = Number.NEGATIVE_INFINITY;
2711
+ const publish = () => {
2712
+ if (timer !== null) {
2713
+ clearTimeout(timer);
2714
+ timer = null;
2715
+ }
2716
+ lastEmit = performance.now();
2717
+ const next = {
2718
+ cursor,
2719
+ selection: store.getSelectedNodes(),
2720
+ holding: [...store.getHeldNodeIds()]
2721
+ };
2722
+ if (last && samePresence(last, next)) return;
2723
+ last = next;
2724
+ emit(next);
2725
+ };
2726
+ const schedule = () => {
2727
+ if (timer !== null) return;
2728
+ const wait = throttleMs - (performance.now() - lastEmit);
2729
+ if (wait <= 0) {
2730
+ publish();
2731
+ return;
2732
+ }
2733
+ timer = setTimeout(() => {
2734
+ timer = null;
2735
+ publish();
2736
+ }, wait);
2737
+ };
2738
+ const project = () => {
2739
+ if (!screenPoint) {
2740
+ cursor = null;
2741
+ return;
2742
+ }
2743
+ cursor = screenToWorld(screenPoint, store.getViewport());
2744
+ };
2745
+ const onPointerMove = (event) => {
2746
+ const rect = pane.getBoundingClientRect();
2747
+ screenPoint = { x: event.clientX - rect.left, y: event.clientY - rect.top };
2748
+ project();
2749
+ schedule();
2750
+ };
2751
+ const onPointerLeave = () => {
2752
+ screenPoint = null;
2753
+ cursor = null;
2754
+ publish();
2755
+ };
2756
+ const unsubscribeViewport = store.subscribeViewport(() => {
2757
+ if (!screenPoint) return;
2758
+ project();
2759
+ schedule();
2760
+ });
2761
+ const unsubscribeSelection = store.subscribeSelection(() => publish());
2762
+ const unsubscribeHeld = store.subscribeHeldNodes(() => publish());
2763
+ pane.addEventListener("pointermove", onPointerMove);
2764
+ pane.addEventListener("pointerleave", onPointerLeave);
2765
+ return () => {
2766
+ if (timer !== null) clearTimeout(timer);
2767
+ pane.removeEventListener("pointermove", onPointerMove);
2768
+ pane.removeEventListener("pointerleave", onPointerLeave);
2769
+ unsubscribeViewport();
2770
+ unsubscribeSelection();
2771
+ unsubscribeHeld();
2772
+ };
2773
+ }
2774
+
2775
+ // src/hooks/use-local-presence.ts
2776
+ function useLocalPresence({
2777
+ store,
2778
+ paneRef,
2779
+ onLocalPresence,
2780
+ throttleMs
2781
+ }) {
2782
+ const handlerRef = useRef(onLocalPresence);
2783
+ handlerRef.current = onLocalPresence;
2784
+ const enabled = Boolean(onLocalPresence);
2785
+ useEffect(() => {
2786
+ const pane = paneRef.current;
2787
+ if (!enabled || !pane) return;
2788
+ return trackLocalPresence({
2789
+ store,
2790
+ pane,
2791
+ emit: (presence) => handlerRef.current?.(presence),
2792
+ throttleMs
2793
+ });
2794
+ }, [store, paneRef, enabled, throttleMs]);
2795
+ }
2117
2796
  function shallowEqual(a, b) {
2118
2797
  if (Object.is(a, b)) return true;
2119
2798
  if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
@@ -2169,6 +2848,11 @@ function useRivetConfig(params) {
2169
2848
  () => ({ ...DEFAULT_ANCHOR_OPTIONS, ...anchorOptionsInput }),
2170
2849
  [anchorOptionsInput]
2171
2850
  );
2851
+ const presenceOptionsInput = useShallowStable(params.presenceOptions);
2852
+ const presenceOptions = useMemo(
2853
+ () => ({ ...DEFAULT_PRESENCE_OPTIONS, ...presenceOptionsInput }),
2854
+ [presenceOptionsInput]
2855
+ );
2172
2856
  const isValidConnection = useStableOptional(params.isValidConnection);
2173
2857
  const mapConnection = useStableOptional(params.mapConnection);
2174
2858
  const onConnect = useStableOptional(params.onConnect);
@@ -2182,6 +2866,7 @@ function useRivetConfig(params) {
2182
2866
  snapGrid,
2183
2867
  swimlaneMargin,
2184
2868
  anchorOptions,
2869
+ presenceOptions,
2185
2870
  panButtons,
2186
2871
  selectionKeys,
2187
2872
  multiSelectionKeys,
@@ -2221,7 +2906,7 @@ function createKeyboardHandler(store, getSnapGrid) {
2221
2906
  }
2222
2907
  const dir = ARROW_DIRS[event.key];
2223
2908
  if (!dir) return;
2224
- const movers = store.getMovers(store.getSelectedNodes());
2909
+ const movers = store.getMovers(store.getSelectedNodes()).filter((id) => store.locks.allows(id, "drag"));
2225
2910
  if (movers.length === 0) return;
2226
2911
  event.preventDefault();
2227
2912
  const snapGrid = getSnapGrid?.() ?? null;
@@ -2299,6 +2984,7 @@ function createMarqueeController(deps) {
2299
2984
  const ids = [];
2300
2985
  for (const [id, node] of store.nodes) {
2301
2986
  if (node.selectable === false) continue;
2987
+ if (!store.locks.allows(id, "select")) continue;
2302
2988
  if (rectsIntersect(worldRect, store.getNodeRect(id))) ids.push(id);
2303
2989
  }
2304
2990
  store.selectNodes(additive ? [.../* @__PURE__ */ new Set([...base, ...ids])] : ids);
@@ -2365,454 +3051,221 @@ function createPanController(deps) {
2365
3051
  schedule();
2366
3052
  },
2367
3053
  end(event) {
2368
- if (!panning) return;
2369
- panning = false;
2370
- pane.releasePointerCapture(event.pointerId);
2371
- },
2372
- suppressNextContextMenu() {
2373
- if (!suppressContextMenu) return false;
2374
- suppressContextMenu = false;
2375
- return true;
2376
- }
2377
- };
2378
- }
2379
-
2380
- // src/input/resize-controller.ts
2381
- function createResizeController(deps) {
2382
- const { pane, bgCanvas, size, edgeRenderer, foregroundRenderer, schedule } = deps;
2383
- const resize = () => {
2384
- const rect = pane.getBoundingClientRect();
2385
- size.width = rect.width;
2386
- size.height = rect.height;
2387
- size.dpr = window.devicePixelRatio || 1;
2388
- bgCanvas.width = Math.max(1, Math.round(rect.width * size.dpr));
2389
- bgCanvas.height = Math.max(1, Math.round(rect.height * size.dpr));
2390
- bgCanvas.style.width = `${rect.width}px`;
2391
- bgCanvas.style.height = `${rect.height}px`;
2392
- edgeRenderer.resize(rect.width, rect.height, size.dpr);
2393
- foregroundRenderer.resize(rect.width, rect.height, size.dpr);
2394
- schedule();
2395
- };
2396
- const observer = new ResizeObserver(resize);
2397
- observer.observe(pane);
2398
- resize();
2399
- return { dispose: () => observer.disconnect() };
2400
- }
2401
-
2402
- // src/input/zoom-controller.ts
2403
- var WHEEL_LINE_PX = 16;
2404
- var ZOOM_EASE = 0.22;
2405
- function createZoomController(deps) {
2406
- const { store, pane, size, minZoom, maxZoom, zoomSpeed, scrollToPan, schedule } = deps;
2407
- let target = null;
2408
- return {
2409
- cancel() {
2410
- target = null;
2411
- },
2412
- step() {
2413
- if (!target) return;
2414
- const vp = store.getViewport();
2415
- const diff = target.zoom - vp.zoom;
2416
- if (Math.abs(diff) < 5e-4) {
2417
- store.setViewport(zoomAt(vp, target.anchor, target.zoom, minZoom, maxZoom));
2418
- target = null;
2419
- return;
2420
- }
2421
- store.setViewport(zoomAt(vp, target.anchor, vp.zoom + diff * ZOOM_EASE, minZoom, maxZoom));
2422
- schedule();
2423
- },
2424
- onWheel(event) {
2425
- event.preventDefault();
2426
- const scale = event.deltaMode === 1 ? WHEEL_LINE_PX : event.deltaMode === 2 ? size.height : 1;
2427
- const deltaX = event.deltaX * scale;
2428
- const deltaY = event.deltaY * scale;
2429
- if (scrollToPan && !event.ctrlKey) {
2430
- target = null;
2431
- const viewport = store.getViewport();
2432
- store.setViewport({ ...viewport, x: viewport.x - deltaX, y: viewport.y - deltaY });
2433
- schedule();
2434
- return;
2435
- }
2436
- const rect = pane.getBoundingClientRect();
2437
- const anchor = { x: event.clientX - rect.left, y: event.clientY - rect.top };
2438
- const base = target?.zoom ?? store.getViewport().zoom;
2439
- const nextZoom = clamp(base * Math.exp(-deltaY * zoomSpeed), minZoom, maxZoom);
2440
- target = { zoom: nextZoom, anchor };
2441
- schedule();
2442
- }
2443
- };
2444
- }
2445
-
2446
- // src/renderer/background.ts
2447
- function drawDotGrid(ctx, width, height, viewport, options) {
2448
- const gap = options?.gap ?? 24;
2449
- const radius = options?.radius ?? 1;
2450
- const color = options?.color ?? "rgba(100, 116, 139, 0.35)";
2451
- const step = gap * viewport.zoom;
2452
- if (step < 8) return;
2453
- const offsetX = (viewport.x % step + step) % step;
2454
- const offsetY = (viewport.y % step + step) % step;
2455
- ctx.fillStyle = color;
2456
- ctx.beginPath();
2457
- for (let x = offsetX; x < width; x += step) {
2458
- for (let y = offsetY; y < height; y += step) {
2459
- ctx.moveTo(x + radius, y);
2460
- ctx.arc(x, y, radius, 0, Math.PI * 2);
2461
- }
2462
- }
2463
- ctx.fill();
2464
- }
2465
-
2466
- // src/renderer/edge-paths.ts
2467
- var SIDE_DIR = {
2468
- left: { x: -1, y: 0 },
2469
- right: { x: 1, y: 0 },
2470
- top: { x: 0, y: -1 },
2471
- bottom: { x: 0, y: 1 }
2472
- };
2473
- var BEZIER_SEGMENT_PX = 8;
2474
- var BEZIER_MIN_SAMPLES = 16;
2475
- var BEZIER_MAX_SAMPLES = 160;
2476
- var CORNER_RADIUS = 8;
2477
- var ARC_SAMPLES = 6;
2478
- function cubicAt(a, b, c, d, t) {
2479
- const mt = 1 - t;
2480
- return mt * mt * mt * a + 3 * mt * mt * t * b + 3 * mt * t * t * c + t * t * t * d;
2481
- }
2482
- function quadAt(a, b, c, t) {
2483
- const mt = 1 - t;
2484
- return mt * mt * a + 2 * mt * t * b + t * t * c;
2485
- }
2486
- var getStraightPath = ({ sourceX, sourceY, targetX, targetY }) => ({
2487
- points: [
2488
- { x: sourceX, y: sourceY },
2489
- { x: targetX, y: targetY }
2490
- ],
2491
- labelX: (sourceX + targetX) / 2,
2492
- labelY: (sourceY + targetY) / 2
2493
- });
2494
- var getBezierPath = (params) => {
2495
- const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = params;
2496
- const sDir = SIDE_DIR[sourcePosition];
2497
- const tDir = SIDE_DIR[targetPosition];
2498
- const dist = Math.hypot(targetX - sourceX, targetY - sourceY);
2499
- const reach = Math.max(40, dist * 0.4);
2500
- const p0 = { x: sourceX, y: sourceY };
2501
- const p1 = { x: sourceX + sDir.x * reach, y: sourceY + sDir.y * reach };
2502
- const p2 = { x: targetX + tDir.x * reach, y: targetY + tDir.y * reach };
2503
- const p3 = { x: targetX, y: targetY };
2504
- const ctrlLen = Math.hypot(p1.x - p0.x, p1.y - p0.y) + Math.hypot(p2.x - p1.x, p2.y - p1.y) + Math.hypot(p3.x - p2.x, p3.y - p2.y);
2505
- const samples = Math.max(
2506
- BEZIER_MIN_SAMPLES,
2507
- Math.min(BEZIER_MAX_SAMPLES, Math.ceil(ctrlLen / BEZIER_SEGMENT_PX))
2508
- );
2509
- const points = [];
2510
- for (let i = 0; i <= samples; i++) {
2511
- const t = i / samples;
2512
- points.push({
2513
- x: cubicAt(p0.x, p1.x, p2.x, p3.x, t),
2514
- y: cubicAt(p0.y, p1.y, p2.y, p3.y, t)
2515
- });
2516
- }
2517
- return {
2518
- points,
2519
- labelX: cubicAt(p0.x, p1.x, p2.x, p3.x, 0.5),
2520
- labelY: cubicAt(p0.y, p1.y, p2.y, p3.y, 0.5)
2521
- };
2522
- };
2523
- var STEP_OFFSET = 20;
2524
- function simplifyCorners(corners) {
2525
- const out = [];
2526
- for (const p of corners) {
2527
- const a = out[out.length - 2];
2528
- const b = out[out.length - 1];
2529
- if (b && b.x === p.x && b.y === p.y) continue;
2530
- if (a && b && (a.x === b.x && b.x === p.x || a.y === b.y && b.y === p.y)) out.pop();
2531
- out.push(p);
2532
- }
2533
- return out;
2534
- }
2535
- function stepCorners(params) {
2536
- const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = params;
2537
- const s = { x: sourceX, y: sourceY };
2538
- const t = { x: targetX, y: targetY };
2539
- const sDir = SIDE_DIR[sourcePosition];
2540
- const tDir = SIDE_DIR[targetPosition];
2541
- const sg = { x: sourceX + sDir.x * STEP_OFFSET, y: sourceY + sDir.y * STEP_OFFSET };
2542
- const tg = { x: targetX + tDir.x * STEP_OFFSET, y: targetY + tDir.y * STEP_OFFSET };
2543
- return simplifyCorners(routeCorners(s, t, sg, tg, sDir, tDir));
2544
- }
2545
- function routeCorners(s, t, sg, tg, sDir, tDir) {
2546
- const sHoriz = sDir.x !== 0;
2547
- const tHoriz = tDir.x !== 0;
2548
- if (sHoriz && tHoriz) {
2549
- if (sDir.x * tDir.x < 0) {
2550
- if ((t.x - s.x) * sDir.x >= 2 * STEP_OFFSET) {
2551
- const midX = (s.x + t.x) / 2;
2552
- return [s, { x: midX, y: s.y }, { x: midX, y: t.y }, t];
2553
- }
2554
- const midY = (s.y + t.y) / 2;
2555
- return [s, sg, { x: sg.x, y: midY }, { x: tg.x, y: midY }, tg, t];
2556
- }
2557
- const railX = sDir.x > 0 ? Math.max(sg.x, tg.x) : Math.min(sg.x, tg.x);
2558
- return [s, { x: railX, y: s.y }, { x: railX, y: t.y }, t];
2559
- }
2560
- if (!sHoriz && !tHoriz) {
2561
- if (sDir.y * tDir.y < 0) {
2562
- if ((t.y - s.y) * sDir.y >= 2 * STEP_OFFSET) {
2563
- const midY = (s.y + t.y) / 2;
2564
- return [s, { x: s.x, y: midY }, { x: t.x, y: midY }, t];
2565
- }
2566
- const midX = (s.x + t.x) / 2;
2567
- return [s, sg, { x: midX, y: sg.y }, { x: midX, y: tg.y }, tg, t];
2568
- }
2569
- const railY = sDir.y > 0 ? Math.max(sg.y, tg.y) : Math.min(sg.y, tg.y);
2570
- return [s, { x: s.x, y: railY }, { x: t.x, y: railY }, t];
2571
- }
2572
- const corner = sHoriz ? { x: t.x, y: s.y } : { x: s.x, y: t.y };
2573
- const exitsSource = sHoriz ? (corner.x - s.x) * sDir.x >= STEP_OFFSET : (corner.y - s.y) * sDir.y >= STEP_OFFSET;
2574
- const exitsTarget = tHoriz ? (corner.x - t.x) * tDir.x >= STEP_OFFSET : (corner.y - t.y) * tDir.y >= STEP_OFFSET;
2575
- if (exitsSource && exitsTarget) return [s, corner, t];
2576
- const bend = sHoriz ? { x: sg.x, y: tg.y } : { x: tg.x, y: sg.y };
2577
- return [s, sg, bend, tg, t];
2578
- }
2579
- function roundCorners(corners, radius) {
2580
- if (corners.length <= 2) return corners;
2581
- const points = [];
2582
- const first = corners[0];
2583
- if (first) points.push(first);
2584
- for (let i = 1; i < corners.length - 1; i++) {
2585
- const prev = corners[i - 1];
2586
- const curr = corners[i];
2587
- const next = corners[i + 1];
2588
- if (!prev || !curr || !next) continue;
2589
- const inLen = Math.hypot(curr.x - prev.x, curr.y - prev.y);
2590
- const outLen = Math.hypot(next.x - curr.x, next.y - curr.y);
2591
- const r = Math.min(radius, inLen / 2, outLen / 2);
2592
- const inPt = {
2593
- x: curr.x + (prev.x - curr.x) / (inLen || 1) * r,
2594
- y: curr.y + (prev.y - curr.y) / (inLen || 1) * r
2595
- };
2596
- const outPt = {
2597
- x: curr.x + (next.x - curr.x) / (outLen || 1) * r,
2598
- y: curr.y + (next.y - curr.y) / (outLen || 1) * r
2599
- };
2600
- points.push(inPt);
2601
- for (let j = 1; j < ARC_SAMPLES; j++) {
2602
- const tt = j / ARC_SAMPLES;
2603
- points.push({
2604
- x: quadAt(inPt.x, curr.x, outPt.x, tt),
2605
- y: quadAt(inPt.y, curr.y, outPt.y, tt)
2606
- });
2607
- }
2608
- points.push(outPt);
2609
- }
2610
- const last = corners[corners.length - 1];
2611
- if (last) points.push(last);
2612
- return points;
2613
- }
2614
- function stepLabel(corners, params) {
2615
- const i = Math.floor((corners.length - 1) / 2);
2616
- const a = corners[i];
2617
- const b = corners[i + 1];
2618
- if (!a || !b) return { x: params.sourceX, y: params.sourceY };
2619
- return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2620
- }
2621
- var getSmoothStepPath = (params) => {
2622
- const corners = stepCorners(params);
2623
- const label = stepLabel(corners, params);
2624
- return { points: roundCorners(corners, CORNER_RADIUS), labelX: label.x, labelY: label.y };
2625
- };
2626
- var getStepPath = (params) => {
2627
- const corners = stepCorners(params);
2628
- const label = stepLabel(corners, params);
2629
- return { points: corners, labelX: label.x, labelY: label.y };
2630
- };
2631
- var BUILTIN_EDGE_TYPES = {
2632
- bezier: getBezierPath,
2633
- smoothstep: getSmoothStepPath,
2634
- step: getStepPath,
2635
- straight: getStraightPath
2636
- };
2637
-
2638
- // src/renderer/edge-geometry.ts
2639
- var EDGE_COLOR = "rgba(100, 116, 139, 0.75)";
2640
- var EDGE_COLOR_SELECTED = "#6366f1";
2641
- var EDGE_COLOR_HOVERED = "rgba(99, 102, 241, 0.9)";
2642
- var EDGE_WIDTH = 1.5;
2643
- var EDGE_WIDTH_HOVERED = 2.5;
2644
- var PENDING_COLOR = "#6366f1";
2645
- var OPPOSITE = {
2646
- left: "right",
2647
- right: "left",
2648
- top: "bottom",
2649
- bottom: "top"
2650
- };
2651
- var falseToNull = (m) => m || null;
2652
- function buildEdges(edges, nodes, viewport, edgeTypes, defaults, extras) {
2653
- const handles = extras?.handles;
2654
- const hovered = extras?.hovered;
2655
- const reconnecting = extras?.reconnecting;
2656
- const worldPositions = extras?.worldPositions;
2657
- const resolved = [];
2658
- for (const edge of edges) {
2659
- if (edge.id === reconnecting) continue;
2660
- const source = nodes.get(edge.source);
2661
- const target = nodes.get(edge.target);
2662
- if (!source || !target) continue;
2663
- const frame = { handles, viewport, worldPositions, extras };
2664
- const start = resolveEnd(edge, "source", source, target, frame);
2665
- const end = resolveEnd(edge, "target", target, source, frame);
2666
- const params = {
2667
- sourceX: start.point.x,
2668
- sourceY: start.point.y,
2669
- targetX: end.point.x,
2670
- targetY: end.point.y,
2671
- sourcePosition: start.position,
2672
- targetPosition: end.position
2673
- };
2674
- const pathFn = edgeTypes[edge.type ?? defaults.type ?? "bezier"] ?? getBezierPath;
2675
- const path = pathFn(params);
2676
- if (path.points.length < 2) continue;
2677
- const style = { ...defaults.style, ...edge.style };
2678
- const isHovered = edge.id === hovered;
2679
- resolved.push({
2680
- id: edge.id,
2681
- points: path.points,
2682
- source: start.point,
2683
- target: end.point,
2684
- label: edge.label !== void 0 ? { x: path.labelX, y: path.labelY } : void 0,
2685
- stroke: edge.selected ? EDGE_COLOR_SELECTED : isHovered ? EDGE_COLOR_HOVERED : style.stroke ?? EDGE_COLOR,
2686
- width: style.strokeWidth ?? (isHovered ? EDGE_WIDTH_HOVERED : EDGE_WIDTH),
2687
- opacity: style.opacity ?? 1,
2688
- animated: edge.animated ?? defaults.animated ?? false,
2689
- dash: style.strokeDasharray,
2690
- markerStart: falseToNull(edge.markerStart ?? defaults.markerStart),
2691
- markerEnd: falseToNull(edge.markerEnd ?? defaults.markerEnd)
2692
- });
2693
- }
2694
- return resolved;
2695
- }
2696
- function buildPendingPath(pending, handles, viewport) {
2697
- const record = handles?.get(handleKey(pending.source, pending.sourceHandle));
2698
- const sourcePosition = record?.position ?? (pending.sourceType === "target" ? "left" : "right");
2699
- const from = worldToScreen(pending.from, viewport);
2700
- const to = worldToScreen(pending.to, viewport);
2701
- const targetPosition = pending.toPosition ?? OPPOSITE[sourcePosition];
2702
- return getBezierPath({
2703
- sourceX: from.x,
2704
- sourceY: from.y,
2705
- targetX: to.x,
2706
- targetY: to.y,
2707
- sourcePosition,
2708
- targetPosition
2709
- }).points;
3054
+ if (!panning) return;
3055
+ panning = false;
3056
+ pane.releasePointerCapture(event.pointerId);
3057
+ },
3058
+ suppressNextContextMenu() {
3059
+ if (!suppressContextMenu) return false;
3060
+ suppressContextMenu = false;
3061
+ return true;
3062
+ }
3063
+ };
2710
3064
  }
2711
- function pointOnSide(rect, side, alongPct = 50) {
2712
- const t = alongPct / 100;
2713
- switch (side) {
2714
- case "left":
2715
- return { x: rect.x, y: rect.y + rect.height * t };
2716
- case "right":
2717
- return { x: rect.x + rect.width, y: rect.y + rect.height * t };
2718
- case "top":
2719
- return { x: rect.x + rect.width * t, y: rect.y };
2720
- case "bottom":
2721
- return { x: rect.x + rect.width * t, y: rect.y + rect.height };
2722
- }
3065
+
3066
+ // src/input/resize-controller.ts
3067
+ function createResizeController(deps) {
3068
+ const { pane, bgCanvas, size, edgeRenderer, foregroundRenderer, onResize, schedule } = deps;
3069
+ const resize = () => {
3070
+ const rect = pane.getBoundingClientRect();
3071
+ size.width = rect.width;
3072
+ size.height = rect.height;
3073
+ size.dpr = window.devicePixelRatio || 1;
3074
+ bgCanvas.width = Math.max(1, Math.round(rect.width * size.dpr));
3075
+ bgCanvas.height = Math.max(1, Math.round(rect.height * size.dpr));
3076
+ bgCanvas.style.width = `${rect.width}px`;
3077
+ bgCanvas.style.height = `${rect.height}px`;
3078
+ edgeRenderer.resize(rect.width, rect.height, size.dpr);
3079
+ foregroundRenderer.resize(rect.width, rect.height, size.dpr);
3080
+ onResize?.();
3081
+ schedule();
3082
+ };
3083
+ const observer = new ResizeObserver(resize);
3084
+ observer.observe(pane);
3085
+ resize();
3086
+ return { dispose: () => observer.disconnect() };
2723
3087
  }
2724
- function nodeRect(node, worldPositions) {
2725
- const origin = worldPositions?.get(node.id) ?? node.position;
2726
- const size = node.size ?? DEFAULT_NODE_SIZE;
2727
- return { x: origin.x, y: origin.y, width: size.width, height: size.height };
3088
+
3089
+ // src/input/zoom-controller.ts
3090
+ var WHEEL_LINE_PX = 16;
3091
+ var ZOOM_EASE = 0.22;
3092
+ function createZoomController(deps) {
3093
+ const { store, pane, size, minZoom, maxZoom, zoomSpeed, scrollToPan, schedule } = deps;
3094
+ let target = null;
3095
+ return {
3096
+ cancel() {
3097
+ target = null;
3098
+ },
3099
+ step() {
3100
+ if (!target) return;
3101
+ const vp = store.getViewport();
3102
+ const diff = target.zoom - vp.zoom;
3103
+ if (Math.abs(diff) < 5e-4) {
3104
+ store.setViewport(zoomAt(vp, target.anchor, target.zoom, minZoom, maxZoom));
3105
+ target = null;
3106
+ return;
3107
+ }
3108
+ store.setViewport(zoomAt(vp, target.anchor, vp.zoom + diff * ZOOM_EASE, minZoom, maxZoom));
3109
+ schedule();
3110
+ },
3111
+ onWheel(event) {
3112
+ event.preventDefault();
3113
+ const scale = event.deltaMode === 1 ? WHEEL_LINE_PX : event.deltaMode === 2 ? size.height : 1;
3114
+ const deltaX = event.deltaX * scale;
3115
+ const deltaY = event.deltaY * scale;
3116
+ if (scrollToPan && !event.ctrlKey) {
3117
+ target = null;
3118
+ const viewport = store.getViewport();
3119
+ store.setViewport({ ...viewport, x: viewport.x - deltaX, y: viewport.y - deltaY });
3120
+ schedule();
3121
+ return;
3122
+ }
3123
+ const rect = pane.getBoundingClientRect();
3124
+ const anchor = { x: event.clientX - rect.left, y: event.clientY - rect.top };
3125
+ const base = target?.zoom ?? store.getViewport().zoom;
3126
+ const nextZoom = clamp(base * Math.exp(-deltaY * zoomSpeed), minZoom, maxZoom);
3127
+ target = { zoom: nextZoom, anchor };
3128
+ schedule();
3129
+ }
3130
+ };
2728
3131
  }
2729
- var WANTED_TYPES = {
2730
- source: ["source", "either"],
2731
- target: ["target", "either"]
2732
- };
2733
- function handleOnSide(nodeId, side, role, handles) {
2734
- if (!handles) return null;
2735
- for (const record of handles.values()) {
2736
- if (record.nodeId === nodeId && record.position === side && WANTED_TYPES[role].includes(record.type)) {
2737
- return record;
3132
+
3133
+ // src/renderer/background.ts
3134
+ function drawDotGrid(ctx, width, height, viewport, options) {
3135
+ const gap = options?.gap ?? 24;
3136
+ const radius = options?.radius ?? 1;
3137
+ const color = options?.color ?? "rgba(100, 116, 139, 0.35)";
3138
+ const step = gap * viewport.zoom;
3139
+ if (step < 8) return;
3140
+ const offsetX = (viewport.x % step + step) % step;
3141
+ const offsetY = (viewport.y % step + step) % step;
3142
+ ctx.fillStyle = color;
3143
+ ctx.beginPath();
3144
+ for (let x = offsetX; x < width; x += step) {
3145
+ for (let y = offsetY; y < height; y += step) {
3146
+ ctx.moveTo(x + radius, y);
3147
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
2738
3148
  }
2739
3149
  }
2740
- return null;
3150
+ ctx.fill();
2741
3151
  }
2742
- function reachableSides(nodeId, role, handles) {
2743
- if (!handles) return [];
2744
- const sides = [];
2745
- for (const record of handles.values()) {
2746
- if (record.nodeId !== nodeId) continue;
2747
- if (!WANTED_TYPES[role].includes(record.type)) continue;
2748
- if (!sides.includes(record.position)) sides.push(record.position);
3152
+
3153
+ // src/renderer/presence.ts
3154
+ var HELD_LINE_WIDTH = 2;
3155
+ var SELECTED_LINE_WIDTH = 1.5;
3156
+ var SELECTED_DASH = [4, 4];
3157
+ var OUTLINE_RADIUS = 6;
3158
+ var CURSOR_POINTS = [
3159
+ { x: 0, y: 0 },
3160
+ { x: 0, y: 16 },
3161
+ { x: 4.2, y: 12.2 },
3162
+ { x: 6.8, y: 17.6 },
3163
+ { x: 9.2, y: 16.6 },
3164
+ { x: 6.6, y: 11.4 },
3165
+ { x: 11.4, y: 11.4 }
3166
+ ];
3167
+ var CURSOR_LABEL_OFFSET = { x: 12, y: 17 };
3168
+ var LABEL_FONT = "500 11px ui-sans-serif, system-ui, -apple-system, sans-serif";
3169
+ var LABEL_PADDING_X = 6;
3170
+ var LABEL_HEIGHT = 17;
3171
+ var LABEL_RADIUS = 4;
3172
+ var CULL_MARGIN_PX = 64;
3173
+ function traceRect(ctx, rect, radius) {
3174
+ ctx.beginPath();
3175
+ if (typeof ctx.roundRect === "function") {
3176
+ ctx.roundRect(rect.x, rect.y, rect.width, rect.height, radius);
3177
+ } else {
3178
+ ctx.rect(rect.x, rect.y, rect.width, rect.height);
2749
3179
  }
2750
- return sides;
2751
3180
  }
2752
- function resolveEnd(edge, role, node, peer, frame) {
2753
- const { handles, viewport, worldPositions, extras } = frame;
2754
- const handleId = role === "source" ? edge.sourceHandle : edge.targetHandle;
2755
- const rect = nodeRect(node, worldPositions);
2756
- const anchorId = parseAnchorAutoHandleId(handleId) ?? parseAnchorHandleId(handleId)?.anchorId ?? null;
2757
- const endAt = (side2) => {
2758
- const alongPct = (anchorId ? extras?.anchorPlacement?.(node.id, anchorId, side2) : null) ?? void 0;
2759
- if (alongPct === void 0 && !anchorId) {
2760
- const candidate = handleOnSide(node.id, side2, role, handles);
2761
- if (candidate) {
2762
- return {
2763
- point: worldToScreen(
2764
- { x: rect.x + candidate.offset.x, y: rect.y + candidate.offset.y },
2765
- viewport
2766
- ),
2767
- position: side2
2768
- };
2769
- }
2770
- }
2771
- return { point: worldToScreen(pointOnSide(rect, side2, alongPct), viewport), position: side2 };
3181
+ function projectRect(rect, viewport) {
3182
+ const origin = worldToScreen({ x: rect.x, y: rect.y }, viewport);
3183
+ return {
3184
+ x: origin.x,
3185
+ y: origin.y,
3186
+ width: rect.width * viewport.zoom,
3187
+ height: rect.height * viewport.zoom
2772
3188
  };
2773
- const registered = handleId && handles ? handles.get(handleKey(node.id, handleId)) : void 0;
2774
- const pinnedSide = parseAnchorHandleId(handleId)?.side ?? registered?.position ?? parseSideHandleId(handleId);
2775
- const live = extras?.liveAlignNodes !== void 0 && (extras.liveAlignNodes.has(edge.source) || extras.liveAlignNodes.has(edge.target)) && edge.source !== edge.target;
2776
- if (pinnedSide && !live) {
2777
- if (registered) {
2778
- return {
2779
- point: worldToScreen(
2780
- { x: rect.x + registered.offset.x, y: rect.y + registered.offset.y },
2781
- viewport
2782
- ),
2783
- position: registered.position
2784
- };
3189
+ }
3190
+ function onScreen(rect, size) {
3191
+ return rect.x + rect.width >= -CULL_MARGIN_PX && rect.y + rect.height >= -CULL_MARGIN_PX && rect.x <= size.width + CULL_MARGIN_PX && rect.y <= size.height + CULL_MARGIN_PX;
3192
+ }
3193
+ function drawNodeOutline(ctx, rect, color, mode) {
3194
+ ctx.strokeStyle = color;
3195
+ ctx.lineWidth = mode === "selected" ? SELECTED_LINE_WIDTH : HELD_LINE_WIDTH;
3196
+ ctx.setLineDash(mode === "selected" ? SELECTED_DASH : []);
3197
+ traceRect(ctx, rect, OUTLINE_RADIUS);
3198
+ ctx.stroke();
3199
+ ctx.setLineDash([]);
3200
+ }
3201
+ function drawCursor(ctx, point, color, name) {
3202
+ ctx.beginPath();
3203
+ const [first, ...rest] = CURSOR_POINTS;
3204
+ if (!first) return;
3205
+ ctx.moveTo(point.x + first.x, point.y + first.y);
3206
+ for (const offset of rest) ctx.lineTo(point.x + offset.x, point.y + offset.y);
3207
+ ctx.closePath();
3208
+ ctx.fillStyle = color;
3209
+ ctx.fill();
3210
+ ctx.strokeStyle = "#ffffff";
3211
+ ctx.lineWidth = 1;
3212
+ ctx.stroke();
3213
+ if (!name) return;
3214
+ ctx.font = LABEL_FONT;
3215
+ ctx.textBaseline = "middle";
3216
+ const width = ctx.measureText(name).width + LABEL_PADDING_X * 2;
3217
+ const x = point.x + CURSOR_LABEL_OFFSET.x;
3218
+ const y = point.y + CURSOR_LABEL_OFFSET.y;
3219
+ traceRect(ctx, { x, y, width, height: LABEL_HEIGHT }, LABEL_RADIUS);
3220
+ ctx.fillStyle = color;
3221
+ ctx.fill();
3222
+ ctx.fillStyle = "#ffffff";
3223
+ ctx.fillText(name, x + LABEL_PADDING_X, y + LABEL_HEIGHT / 2);
3224
+ }
3225
+ function drawPeerOutlines(ctx, peers, { viewport, size, getNodeRect, locks }) {
3226
+ if (peers.length === 0 && locks.size === 0) return;
3227
+ ctx.save();
3228
+ ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
3229
+ const painted = /* @__PURE__ */ new Set();
3230
+ for (const peer of peers) {
3231
+ const held = /* @__PURE__ */ new Set([...peer.holding, ...peer.transforms.keys()]);
3232
+ for (const id of peer.selection) {
3233
+ if (held.has(id)) continue;
3234
+ const rect = getNodeRect(id);
3235
+ if (!rect) continue;
3236
+ const screen = projectRect(rect, viewport);
3237
+ if (onScreen(screen, size)) drawNodeOutline(ctx, screen, peer.color, "selected");
3238
+ }
3239
+ for (const id of held) {
3240
+ painted.add(id);
3241
+ const rect = getNodeRect(id);
3242
+ if (!rect) continue;
3243
+ const screen = projectRect(rect, viewport);
3244
+ if (onScreen(screen, size)) drawNodeOutline(ctx, screen, peer.color, "held");
2785
3245
  }
2786
- return endAt(pinnedSide);
2787
3246
  }
2788
- const key = displayedSideKey(edge.id, role);
2789
- const side = resolveFacingSideAmong(
2790
- rect,
2791
- nodeRect(peer, worldPositions),
2792
- anchorId ? [] : reachableSides(node.id, role, handles),
2793
- extras?.displayedSides?.get(key)
2794
- );
2795
- extras?.displayedSides?.set(key, side);
2796
- return endAt(side);
2797
- }
2798
- function distanceToPolyline(point, points) {
2799
- let best = Number.POSITIVE_INFINITY;
2800
- for (let i = 0; i < points.length - 1; i++) {
2801
- const a = points[i];
2802
- const b = points[i + 1];
2803
- if (!a || !b) continue;
2804
- best = Math.min(best, distanceToSegment(point, a, b));
3247
+ if (locks.size > 0) {
3248
+ const declared = new Map(peers.map((peer) => [peer.id, peer.color]));
3249
+ for (const [id, holderId] of locks) {
3250
+ if (painted.has(id)) continue;
3251
+ const rect = getNodeRect(id);
3252
+ if (!rect) continue;
3253
+ const screen = projectRect(rect, viewport);
3254
+ if (!onScreen(screen, size)) continue;
3255
+ drawNodeOutline(ctx, screen, declared.get(holderId) ?? peerColor(holderId), "held");
3256
+ }
2805
3257
  }
2806
- return best;
3258
+ ctx.restore();
2807
3259
  }
2808
- function distanceToSegment(p, a, b) {
2809
- const dx = b.x - a.x;
2810
- const dy = b.y - a.y;
2811
- const lenSq = dx * dx + dy * dy;
2812
- if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
2813
- let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
2814
- t = Math.max(0, Math.min(1, t));
2815
- return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
3260
+ function drawPeerCursors(ctx, peers, { viewport, size }) {
3261
+ ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
3262
+ ctx.clearRect(0, 0, size.width, size.height);
3263
+ for (const peer of peers) {
3264
+ if (!peer.cursor) continue;
3265
+ const point = worldToScreen(peer.cursor, viewport);
3266
+ if (!onScreen({ ...point, width: 0, height: 0 }, size)) continue;
3267
+ drawCursor(ctx, point, peer.color, peer.name);
3268
+ }
2816
3269
  }
2817
3270
 
2818
3271
  // src/renderer/swimlane.ts
@@ -2845,7 +3298,7 @@ function drawSwimlanes(ctx, width, height, viewport, lanes, style) {
2845
3298
  }
2846
3299
 
2847
3300
  // src/hooks/use-rivet-runtime.ts
2848
- var CULL_MARGIN_PX = 240;
3301
+ var CULL_MARGIN_PX2 = 240;
2849
3302
  var ZOOM_STEP = 1.2;
2850
3303
  var WILL_CHANGE_IDLE_MS = 180;
2851
3304
  var GUIDE_COLOR = "rgba(236, 72, 153, 0.9)";
@@ -2890,7 +3343,7 @@ function drawEndpointBubbles(ctx, ends, size) {
2890
3343
  }
2891
3344
  function useRivetRuntime(params) {
2892
3345
  const { store, paneRef, nodeContainerRef } = params;
2893
- const { backgroundCanvasRef, edgeCanvasRef, foregroundCanvasRef } = params;
3346
+ const { backgroundCanvasRef, edgeCanvasRef, foregroundCanvasRef, cursorCanvasRef } = params;
2894
3347
  const { swimlaneLanes, scrollToPan, zoomSpeed } = params;
2895
3348
  const { minZoom, maxZoom, gridGap, edgeTypes, defaultEdgeOptions, anchorOptions } = params;
2896
3349
  const { edgeRenderer: createEdgeRenderer } = params;
@@ -2929,7 +3382,66 @@ function useRivetRuntime(params) {
2929
3382
  const size = sizeRef.current;
2930
3383
  let frame = 0;
2931
3384
  let dirty = true;
3385
+ let cursorsDirty = true;
2932
3386
  let hoveredEdgeId = null;
3387
+ let peerPositioned = /* @__PURE__ */ new Set();
3388
+ const positionPeerNodes = (worldPositions) => {
3389
+ const transforms = store.presence.getNodeTransforms();
3390
+ if (transforms.size === 0 && peerPositioned.size === 0) return;
3391
+ const current = /* @__PURE__ */ new Set();
3392
+ for (const id of transforms.keys()) {
3393
+ current.add(id);
3394
+ for (const childId of store.getDescendantIds(id)) current.add(childId);
3395
+ }
3396
+ for (const id of /* @__PURE__ */ new Set([...current, ...peerPositioned])) {
3397
+ const element = store.getNodeElement(id);
3398
+ const world = worldPositions.get(id);
3399
+ if (!element || !world) continue;
3400
+ element.style.transform = `translate(${world.x}px, ${world.y}px)`;
3401
+ const live = transforms.get(id);
3402
+ if (live && !store.isNodeHeld(id)) {
3403
+ element.style.width = `${live.width}px`;
3404
+ element.style.height = `${live.height}px`;
3405
+ } else if (peerPositioned.has(id)) {
3406
+ element.style.width = "";
3407
+ element.style.height = "";
3408
+ }
3409
+ }
3410
+ peerPositioned = current;
3411
+ };
3412
+ let cursorCanvas = null;
3413
+ let cursorCtx = null;
3414
+ let cursorAllocated = false;
3415
+ const sizeCursorLayer = () => {
3416
+ if (!cursorCanvas || !cursorAllocated) return;
3417
+ cursorCanvas.width = Math.max(1, Math.round(size.width * size.dpr));
3418
+ cursorCanvas.height = Math.max(1, Math.round(size.height * size.dpr));
3419
+ cursorCanvas.style.width = `${size.width}px`;
3420
+ cursorCanvas.style.height = `${size.height}px`;
3421
+ };
3422
+ const drawCursorLayer = () => {
3423
+ const canvas = cursorCanvasRef.current;
3424
+ if (canvas !== cursorCanvas) {
3425
+ cursorCanvas = canvas;
3426
+ cursorCtx = canvas?.getContext("2d") ?? null;
3427
+ cursorAllocated = false;
3428
+ }
3429
+ if (!cursorCtx) return;
3430
+ const peers = store.presence.getPeers();
3431
+ const wanted = peers.some((peer) => peer.cursor);
3432
+ if (!wanted && !cursorAllocated) return;
3433
+ if (!cursorAllocated) {
3434
+ cursorAllocated = true;
3435
+ sizeCursorLayer();
3436
+ }
3437
+ drawPeerCursors(cursorCtx, peers, { viewport: store.getViewport(), size });
3438
+ };
3439
+ const stepPresence = () => {
3440
+ const version = store.presence.getTransformVersion();
3441
+ if (!store.presence.step(performance.now())) return;
3442
+ if (store.presence.getTransformVersion() !== version) schedule();
3443
+ else scheduleCursors();
3444
+ };
2933
3445
  const anchorPlacement = (nodeId, anchorId, side) => {
2934
3446
  const record = store.anchors.get(handleKey(nodeId, anchorId));
2935
3447
  return record?.geometry ? strayPlacementPct(record.geometry, side, anchorOptions) : null;
@@ -2947,6 +3459,7 @@ function useRivetRuntime(params) {
2947
3459
  });
2948
3460
  const render = () => {
2949
3461
  zoom.step();
3462
+ stepPresence();
2950
3463
  const viewport = store.getViewport();
2951
3464
  layerHint.apply(viewportToCss(viewport));
2952
3465
  bgCtx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
@@ -2954,6 +3467,7 @@ function useRivetRuntime(params) {
2954
3467
  drawDotGrid(bgCtx, size.width, size.height, viewport, { gap: gridGap });
2955
3468
  drawSwimlanes(bgCtx, size.width, size.height, viewport, swimlaneLanesRef.current);
2956
3469
  const worldPositions = store.getWorldPositions();
3470
+ positionPeerNodes(worldPositions);
2957
3471
  const edgeList = [...store.edges.values()];
2958
3472
  const pending = store.getPending();
2959
3473
  const liveAlignNodes = store.getEdgeAlignment() === "live" && store.isNodeDragging() ? store.getDraggingNodeIds() : void 0;
@@ -2982,9 +3496,18 @@ function useRivetRuntime(params) {
2982
3496
  if (ends) drawEndpointBubbles(fgCtx, ends, size);
2983
3497
  }
2984
3498
  }
3499
+ if (fgCtx && (!store.presence.isEmpty() || store.locks.size() > 0)) {
3500
+ drawPeerOutlines(fgCtx, store.presence.getPeers(), {
3501
+ viewport,
3502
+ size,
3503
+ getNodeRect: (id) => store.nodes.has(id) ? store.getNodeRect(id) : null,
3504
+ locks: store.locks.getLocks()
3505
+ });
3506
+ }
3507
+ drawCursorLayer();
2985
3508
  if (edgeList.some((edge) => edge.animated ?? defaultEdgeOptions?.animated)) schedule();
2986
3509
  const rect = visibleWorldRect(viewport, size.width, size.height);
2987
- const margin = CULL_MARGIN_PX / viewport.zoom;
3510
+ const margin = CULL_MARGIN_PX2 / viewport.zoom;
2988
3511
  const view = {
2989
3512
  x: rect.x - margin,
2990
3513
  y: rect.y - margin,
@@ -3013,16 +3536,29 @@ function useRivetRuntime(params) {
3013
3536
  };
3014
3537
  const tick = () => {
3015
3538
  frame = 0;
3016
- if (!dirty) return;
3017
- dirty = false;
3018
- render();
3539
+ if (dirty) {
3540
+ dirty = false;
3541
+ cursorsDirty = false;
3542
+ render();
3543
+ return;
3544
+ }
3545
+ if (!cursorsDirty) return;
3546
+ cursorsDirty = false;
3547
+ stepPresence();
3548
+ drawCursorLayer();
3019
3549
  };
3020
3550
  const schedule = () => {
3021
3551
  dirty = true;
3022
3552
  if (frame) return;
3023
3553
  frame = requestAnimationFrame(tick);
3024
3554
  };
3555
+ const scheduleCursors = () => {
3556
+ cursorsDirty = true;
3557
+ if (frame) return;
3558
+ frame = requestAnimationFrame(tick);
3559
+ };
3025
3560
  store.bindRenderRequester(schedule);
3561
+ store.bindCursorRenderRequester(scheduleCursors);
3026
3562
  const setEdgeHover = (id) => {
3027
3563
  if (hoveredEdgeId === id) return;
3028
3564
  hoveredEdgeId = id;
@@ -3232,6 +3768,7 @@ function useRivetRuntime(params) {
3232
3768
  size,
3233
3769
  edgeRenderer,
3234
3770
  foregroundRenderer,
3771
+ onResize: sizeCursorLayer,
3235
3772
  schedule
3236
3773
  });
3237
3774
  pane.addEventListener("wheel", zoom.onWheel, { passive: false });
@@ -3248,6 +3785,7 @@ function useRivetRuntime(params) {
3248
3785
  resizer.dispose();
3249
3786
  store.bindRenderRequester(() => {
3250
3787
  });
3788
+ store.bindCursorRenderRequester(null);
3251
3789
  store.bindReconnectDelegate(null);
3252
3790
  unsubscribeFocus();
3253
3791
  if (focusRetryFrame) cancelAnimationFrame(focusRetryFrame);
@@ -3275,6 +3813,7 @@ function useRivetRuntime(params) {
3275
3813
  backgroundCanvasRef,
3276
3814
  edgeCanvasRef,
3277
3815
  foregroundCanvasRef,
3816
+ cursorCanvasRef,
3278
3817
  scrollToPan,
3279
3818
  zoomSpeed,
3280
3819
  minZoom,
@@ -3668,7 +4207,7 @@ var labelStyle2 = {
3668
4207
  whiteSpace: "nowrap",
3669
4208
  pointerEvents: "none"
3670
4209
  };
3671
- function sameIds(a, b) {
4210
+ function sameIds2(a, b) {
3672
4211
  if (a.length !== b.length) return false;
3673
4212
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
3674
4213
  return true;
@@ -3694,7 +4233,7 @@ function EdgeLabelLayer() {
3694
4233
  return store.subscribeFrame(() => {
3695
4234
  const anchors = store.getEdgeLabelAnchors();
3696
4235
  const next = [...anchors.keys()];
3697
- setIds((prev) => sameIds(prev, next) ? prev : next);
4236
+ setIds((prev) => sameIds2(prev, next) ? prev : next);
3698
4237
  position();
3699
4238
  });
3700
4239
  }, [store, position]);
@@ -3730,6 +4269,8 @@ function startGroupDrag(params) {
3730
4269
  let started = false;
3731
4270
  let starts = /* @__PURE__ */ new Map();
3732
4271
  let affected = /* @__PURE__ */ new Set();
4272
+ let initiator;
4273
+ let anchor;
3733
4274
  const repositionAffected = () => {
3734
4275
  for (const affectedId of affected) {
3735
4276
  const affectedEl = store.getNodeElement(affectedId);
@@ -3740,8 +4281,9 @@ function startGroupDrag(params) {
3740
4281
  onFrame?.();
3741
4282
  };
3742
4283
  const beginDrag = () => {
4284
+ const movers = store.getMovers(store.getSelectedNodes()).filter((moverId) => store.locks.allows(moverId, "drag"));
4285
+ if (movers.length === 0) return false;
3743
4286
  started = true;
3744
- const movers = store.getMovers(store.getSelectedNodes());
3745
4287
  starts = /* @__PURE__ */ new Map();
3746
4288
  for (const moverId of movers) {
3747
4289
  const moverNode = store.nodes.get(moverId);
@@ -3753,17 +4295,42 @@ function startGroupDrag(params) {
3753
4295
  for (const descendant of store.getDescendantIds(moverId)) affected.add(descendant);
3754
4296
  }
3755
4297
  store.setNodeDragging(true);
4298
+ initiator = params.nodeId ?? movers[0];
4299
+ anchor = movers[0];
4300
+ if (initiator) store.beginNodeGesture(initiator, movers);
3756
4301
  el.setPointerCapture(pointerId);
3757
4302
  el.style.cursor = "grabbing";
3758
4303
  window.getSelection()?.removeAllRanges();
3759
4304
  document.body.style.userSelect = "none";
3760
4305
  document.body.style.webkitUserSelect = "none";
4306
+ return true;
4307
+ };
4308
+ const teardown = () => {
4309
+ window.removeEventListener("pointermove", onMove);
4310
+ window.removeEventListener("pointerup", onUp);
4311
+ if (!started) return;
4312
+ started = false;
4313
+ el.style.cursor = "grab";
4314
+ document.body.style.userSelect = "";
4315
+ document.body.style.webkitUserSelect = "";
4316
+ store.setAlignmentGuides([]);
4317
+ store.setNodeDragging(false);
4318
+ if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
3761
4319
  };
4320
+ const yielded = () => anchor !== void 0 && !store.isNodeHeld(anchor);
3762
4321
  const onMove = (moveEvent) => {
3763
4322
  if (!started) {
3764
4323
  const moved = Math.hypot(moveEvent.clientX - originX, moveEvent.clientY - originY);
3765
4324
  if (moved < DRAG_THRESHOLD) return;
3766
- beginDrag();
4325
+ if (!beginDrag()) {
4326
+ window.removeEventListener("pointermove", onMove);
4327
+ window.removeEventListener("pointerup", onUp);
4328
+ return;
4329
+ }
4330
+ }
4331
+ if (yielded()) {
4332
+ teardown();
4333
+ return;
3767
4334
  }
3768
4335
  const dx = (moveEvent.clientX - originX) / zoom;
3769
4336
  const dy = (moveEvent.clientY - originY) / zoom;
@@ -3798,6 +4365,10 @@ function startGroupDrag(params) {
3798
4365
  window.removeEventListener("pointermove", onMove);
3799
4366
  window.removeEventListener("pointerup", onUp);
3800
4367
  if (!started) return;
4368
+ if (yielded()) {
4369
+ teardown();
4370
+ return;
4371
+ }
3801
4372
  el.style.cursor = "grab";
3802
4373
  document.body.style.userSelect = "";
3803
4374
  document.body.style.webkitUserSelect = "";
@@ -3824,6 +4395,7 @@ function startGroupDrag(params) {
3824
4395
  }
3825
4396
  store.moveNode(moverId, position, true);
3826
4397
  }
4398
+ if (initiator) store.endNodeGesture(initiator, [...starts.keys()]);
3827
4399
  repositionAffected();
3828
4400
  };
3829
4401
  window.addEventListener("pointermove", onMove);
@@ -3951,7 +4523,11 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
3951
4523
  return node2?.ariaLabel ?? `node ${id}`;
3952
4524
  };
3953
4525
  const grab = () => {
3954
- const movers = store.getMovers(store.isNodeSelected(id) ? store.getSelectedNodes() : [id]);
4526
+ const movers = store.getMovers(store.isNodeSelected(id) ? store.getSelectedNodes() : [id]).filter((moverId) => store.locks.allows(moverId, "drag"));
4527
+ if (movers.length === 0) {
4528
+ announce(`${nodeName()} is locked by someone else.`);
4529
+ return;
4530
+ }
3955
4531
  const origins = /* @__PURE__ */ new Map();
3956
4532
  for (const moverId of movers) {
3957
4533
  const mover = store.nodes.get(moverId);
@@ -3959,6 +4535,7 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
3959
4535
  }
3960
4536
  grabOrigins.current = origins;
3961
4537
  setGrabbed(true);
4538
+ store.beginNodeGesture(id, movers);
3962
4539
  announce(`Grabbed ${nodeName()}. Use the arrow keys to move, Enter to drop, Escape to cancel.`);
3963
4540
  };
3964
4541
  const releaseGrab = (revert) => {
@@ -3968,9 +4545,11 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
3968
4545
  if (!origins) return;
3969
4546
  if (revert) {
3970
4547
  for (const [moverId, origin] of origins) store.moveNode(moverId, origin, true);
4548
+ store.endNodeGesture(id, [...origins.keys()]);
3971
4549
  announce("Move cancelled.");
3972
4550
  return;
3973
4551
  }
4552
+ store.endNodeGesture(id, [...origins.keys()]);
3974
4553
  const node2 = store.nodes.get(id);
3975
4554
  if (node2) {
3976
4555
  const { x, y } = node2.position;
@@ -3983,6 +4562,11 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
3983
4562
  );
3984
4563
  const getSnapshot = useCallback(() => store.getNodeVersion(id), [store, id]);
3985
4564
  useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
4565
+ const lockHolder = useSyncExternalStore(
4566
+ store.locks.subscribe,
4567
+ useCallback(() => store.locks.getHolder(id), [store, id]),
4568
+ useCallback(() => store.locks.getHolder(id), [store, id])
4569
+ );
3986
4570
  const nodeContext = useMemo(() => ({ nodeId: id, wrapperRef: ref }), [id]);
3987
4571
  useEffect(() => {
3988
4572
  const el = ref.current;
@@ -4015,6 +4599,13 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4015
4599
  if (event.target !== event.currentTarget) return;
4016
4600
  const node2 = store.nodes.get(id);
4017
4601
  if (!node2 || node2.selectable === false) return;
4602
+ if (grabOrigins.current && !store.isNodeHeld(id)) {
4603
+ grabOrigins.current = null;
4604
+ setGrabbed(false);
4605
+ announce(`${nodeName()} was taken by someone else.`);
4606
+ return;
4607
+ }
4608
+ if (!store.locks.allows(id, "select")) return;
4018
4609
  if (event.key === "Enter" || event.key === " ") {
4019
4610
  event.preventDefault();
4020
4611
  if (grabOrigins.current) {
@@ -4067,8 +4658,8 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4067
4658
  const node2 = store.nodes.get(id);
4068
4659
  if (!el || !node2) return;
4069
4660
  const target = event.target instanceof Element ? event.target : null;
4070
- const noSelect = node2.selectable === false || Boolean(target?.closest(SELECTOR.noSelect));
4071
- const noDrag = node2.draggable === false || Boolean(target?.closest(SELECTOR.noDrag));
4661
+ const noSelect = node2.selectable === false || Boolean(target?.closest(SELECTOR.noSelect)) || !store.locks.allows(id, "select");
4662
+ const noDrag = node2.draggable === false || Boolean(target?.closest(SELECTOR.noDrag)) || !store.locks.allows(id, "drag");
4072
4663
  const additive = keyHeld(event, multiSelectionKeys);
4073
4664
  if (additive) {
4074
4665
  if (!noSelect) store.selectNode(id, true);
@@ -4078,6 +4669,7 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4078
4669
  if (noDrag) return;
4079
4670
  startGroupDrag({
4080
4671
  store,
4672
+ nodeId: id,
4081
4673
  el,
4082
4674
  event,
4083
4675
  snapGrid,
@@ -4095,6 +4687,9 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4095
4687
  const Component = nodeTypes[key] ?? BUILTIN_NODE_TYPES[key] ?? DefaultNode;
4096
4688
  const world = store.getNodeWorldPosition(id);
4097
4689
  const depth = store.getNodeDepth(id);
4690
+ const locked = lockHolder !== null;
4691
+ const lockedForSelect = locked && !store.locks.allows(id, "select");
4692
+ const lockedForDrag = locked && !store.locks.allows(id, "drag");
4098
4693
  return (
4099
4694
  // biome-ignore lint/a11y/useSemanticElements: a node wraps arbitrary (often interactive) content, so it can't be a native <button>
4100
4695
  /* @__PURE__ */ jsx(
@@ -4103,12 +4698,14 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4103
4698
  ref,
4104
4699
  "data-rivet-node": id,
4105
4700
  "data-rivet-grabbed": grabbed ? "" : void 0,
4106
- tabIndex: node.selectable === false ? -1 : 0,
4701
+ "data-rivet-locked": locked ? lockHolder ?? "" : void 0,
4702
+ tabIndex: node.selectable === false || lockedForSelect ? -1 : 0,
4107
4703
  role: "button",
4108
4704
  "aria-roledescription": "graph node",
4109
4705
  "aria-pressed": Boolean(node.selected),
4110
4706
  "aria-label": node.ariaLabel,
4111
- "aria-describedby": node.selectable === false ? void 0 : nodeDescriptionId,
4707
+ "aria-disabled": lockedForSelect || void 0,
4708
+ "aria-describedby": node.selectable === false || lockedForSelect ? void 0 : nodeDescriptionId,
4112
4709
  onPointerDown,
4113
4710
  onPointerEnter,
4114
4711
  onPointerLeave,
@@ -4117,6 +4714,7 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4117
4714
  onBlur,
4118
4715
  style: {
4119
4716
  ...wrapperStyle,
4717
+ ...lockedForDrag ? { cursor: "not-allowed" } : {},
4120
4718
  transform: `translate(${world.x}px, ${world.y}px)`,
4121
4719
  zIndex: depth,
4122
4720
  // Explicit dimensions from NodeResizer override the intrinsic size.
@@ -4494,6 +5092,12 @@ function Rivet({
4494
5092
  snapGrid,
4495
5093
  alignmentGuides = false,
4496
5094
  anchorOptions,
5095
+ peers,
5096
+ onLocalPresence,
5097
+ presenceOptions,
5098
+ lockedNodes,
5099
+ canInteractWithLocked,
5100
+ onNodeLockConflict,
4497
5101
  panOnDrag = true,
4498
5102
  selectionOnDrag = false,
4499
5103
  selectionKeyCode = "Shift",
@@ -4521,6 +5125,8 @@ function Rivet({
4521
5125
  onReconnect,
4522
5126
  onReconnectStart,
4523
5127
  onReconnectEnd,
5128
+ onNodeDragStart,
5129
+ onNodeDragEnd,
4524
5130
  onSelectionChange,
4525
5131
  onFocusChange,
4526
5132
  className,
@@ -4543,6 +5149,7 @@ function Rivet({
4543
5149
  snapGrid,
4544
5150
  swimlaneMargin,
4545
5151
  anchorOptions,
5152
+ presenceOptions,
4546
5153
  panOnDrag,
4547
5154
  selectionKeyCode,
4548
5155
  multiSelectionKeyCode,
@@ -4583,14 +5190,28 @@ function Rivet({
4583
5190
  const hasEdgeChange = Boolean(onEdgesChange);
4584
5191
  useEffect(() => {
4585
5192
  store.setChangeHandlers({
4586
- nodes: hasNodeChange ? (changes) => onNodesChangeRef.current?.(changes) : void 0,
4587
- edges: hasEdgeChange ? (changes) => onEdgesChangeRef.current?.(changes) : void 0
5193
+ nodes: hasNodeChange ? (changes, meta) => onNodesChangeRef.current?.(changes, meta) : void 0,
5194
+ edges: hasEdgeChange ? (changes, meta) => onEdgesChangeRef.current?.(changes, meta) : void 0
4588
5195
  });
4589
5196
  return () => store.setChangeHandlers({});
4590
5197
  }, [store, hasNodeChange, hasEdgeChange]);
5198
+ const onNodeDragStartRef = useRef(onNodeDragStart);
5199
+ onNodeDragStartRef.current = onNodeDragStart;
5200
+ const onNodeDragEndRef = useRef(onNodeDragEnd);
5201
+ onNodeDragEndRef.current = onNodeDragEnd;
5202
+ const onNodeLockConflictRef = useRef(onNodeLockConflict);
5203
+ onNodeLockConflictRef.current = onNodeLockConflict;
5204
+ useEffect(() => {
5205
+ store.setGestureHandlers({
5206
+ start: (event) => onNodeDragStartRef.current?.(event),
5207
+ end: (event) => onNodeDragEndRef.current?.(event),
5208
+ conflict: (event) => onNodeLockConflictRef.current?.(event)
5209
+ });
5210
+ return () => store.setGestureHandlers({});
5211
+ }, [store]);
4591
5212
  const isControlled = controlledNodes !== void 0 || controlledEdges !== void 0;
4592
5213
  useEffect(() => {
4593
- if (!isControlled || store.isNodeDragging() || store.isNodeResizing()) return;
5214
+ if (!isControlled) return;
4594
5215
  store.reconcile(
4595
5216
  controlledNodes ?? [...store.nodes.values()],
4596
5217
  controlledEdges ?? [...store.edges.values()]
@@ -4643,6 +5264,24 @@ function Rivet({
4643
5264
  const backgroundCanvasRef = useRef(null);
4644
5265
  const edgeCanvasRef = useRef(null);
4645
5266
  const foregroundCanvasRef = useRef(null);
5267
+ const cursorCanvasRef = useRef(null);
5268
+ useEffect(() => {
5269
+ if (peers === void 0) return;
5270
+ store.presence.setPeers(peers);
5271
+ }, [store, peers]);
5272
+ const canInteractWithLockedRef = useRef(canInteractWithLocked);
5273
+ canInteractWithLockedRef.current = canInteractWithLocked;
5274
+ const hasLockPolicy = Boolean(canInteractWithLocked);
5275
+ useEffect(() => {
5276
+ store.locks.setPolicy(
5277
+ hasLockPolicy ? (event) => canInteractWithLockedRef.current?.(event) ?? true : null
5278
+ );
5279
+ return () => store.locks.setPolicy(null);
5280
+ }, [store, hasLockPolicy]);
5281
+ useEffect(() => {
5282
+ store.setLockedNodes(lockedNodes);
5283
+ }, [store, lockedNodes]);
5284
+ useLocalPresence({ store, paneRef, onLocalPresence, throttleMs: cfg.presenceOptions.throttleMs });
4646
5285
  const { visibleIds, controls, getViewportElements } = useRivetRuntime({
4647
5286
  store,
4648
5287
  paneRef,
@@ -4650,6 +5289,7 @@ function Rivet({
4650
5289
  backgroundCanvasRef,
4651
5290
  edgeCanvasRef,
4652
5291
  foregroundCanvasRef,
5292
+ cursorCanvasRef,
4653
5293
  swimlaneLanes: swimlanes.lanes,
4654
5294
  edgeTypes: cfg.edgeTypes,
4655
5295
  defaultEdgeOptions: cfg.defaultEdgeOptions,
@@ -4742,6 +5382,7 @@ function Rivet({
4742
5382
  /* @__PURE__ */ jsx("canvas", { ref: edgeCanvasRef, style: canvasStyle }),
4743
5383
  /* @__PURE__ */ jsx(NodeLayer, { containerRef: nodeContainerRef, visibleIds }),
4744
5384
  /* @__PURE__ */ jsx("canvas", { ref: foregroundCanvasRef, style: canvasStyle }),
5385
+ cfg.presenceOptions.renderCursors && /* @__PURE__ */ jsx("canvas", { ref: cursorCanvasRef, style: canvasStyle }),
4745
5386
  /* @__PURE__ */ jsx(EdgeLabelLayer, {}),
4746
5387
  /* @__PURE__ */ jsx(SwimlaneOverlay, {}),
4747
5388
  children,
@@ -4801,6 +5442,10 @@ function buildInstance(store, controls, getViewportElements) {
4801
5442
  ),
4802
5443
  getNodes,
4803
5444
  getNode: (id) => store.nodes.get(id),
5445
+ // The store's own `getNodeRect` answers for any id, falling back to the
5446
+ // origin and the default size; a public caller needs to tell "it's there,
5447
+ // at 0,0" from "it isn't there".
5448
+ getNodeRect: (id) => store.nodes.has(id) ? store.getNodeRect(id) : null,
4804
5449
  setNodes,
4805
5450
  addNodes: (nodes) => {
4806
5451
  for (const node of Array.isArray(nodes) ? nodes : [nodes]) store.addNode(node);
@@ -4825,6 +5470,7 @@ function buildInstance(store, controls, getViewportElements) {
4825
5470
  for (const id of edges ?? []) store.removeEdge(id);
4826
5471
  for (const id of nodes ?? []) store.removeNode(id);
4827
5472
  },
5473
+ applyRemote: (changes) => store.applyRemote(changes),
4828
5474
  registerAnchor: (nodeId, anchorId, element, options) => {
4829
5475
  store.registerAnchor(nodeId, anchorId, element, options);
4830
5476
  return {
@@ -4834,6 +5480,10 @@ function buildInstance(store, controls, getViewportElements) {
4834
5480
  },
4835
5481
  unregisterAnchor: (nodeId, anchorId) => store.unregisterAnchor(nodeId, anchorId),
4836
5482
  remeasureAnchors: (nodeId) => store.remeasureAnchors(nodeId),
5483
+ setPeerCursor: (peerId, point) => store.presence.setPeerCursor(peerId, point),
5484
+ setPeerNodeTransform: (peerId, nodeId, rect) => store.presence.setPeerNodeTransform(peerId, nodeId, rect),
5485
+ removePeer: (peerId) => store.presence.removePeer(peerId),
5486
+ releaseNodeGesture: (id, ids) => store.endNodeGesture(id, ids),
4837
5487
  copy: (ids) => {
4838
5488
  const targetIds = ids ?? store.getSelectedNodes();
4839
5489
  if (targetIds.length === 0) return;