@kolosal-ai/rivet 0.4.0 → 0.6.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,5 +1,5 @@
1
- import { findSelectableAnchor, parseAnchorHandleId, ANCHOR_SIDES, anchorDotHandleId, strayPlacementPct, anchorHandleId, anchorAutoHandleId, parseAnchorAutoHandleId, normalizeAnchorConnection, computeAnchorGeometry, anchorGeometryEqual, DEFAULT_ANCHOR_OPTIONS, anchorRect } from './chunk-W5HBJ4VF.js';
2
- import { snapToGrid, boundingRect, clampChildToParent, anchorTargetKey, createPresenceRegistry, createLockRegistry, nodeControlledEqual, buildChildIndex, collectDescendants, worldPosition, nodeDepth, descendantIds, hasAncestorIn, edgeTargetKey, DEFAULT_PRESENCE_OPTIONS, collectPeerOutlines, peerOutlinesEqual, usePeerIdentities, serializeGraph, presenceTargetKey, peerColor, isNodeTarget, presenceTargetEdgeId } from './chunk-TT2N7KWU.js';
1
+ import { findSelectableAnchor, parseAnchorHandleId, orderedSelectableAnchors, ANCHOR_SIDES, anchorDotHandleId, strayPoint, anchorHandleId, parseAnchorAutoHandleId, anchorAutoHandleId, normalizeAnchorConnection, computeAnchorGeometry, anchorGeometryEqual, DEFAULT_ANCHOR_OPTIONS, anchorRect, strayPlacementPct } from './chunk-76GHEXDP.js';
2
+ import { snapToGrid, anchorTargetKey, boundingRect, clampChildToParent, createPresenceRegistry, createLockRegistry, nodeControlledEqual, buildChildIndex, collectDescendants, worldPosition, nodeDepth, descendantIds, hasAncestorIn, edgeTargetKey, DEFAULT_PRESENCE_OPTIONS, collectPeerOutlines, peerOutlinesEqual, usePeerIdentities, serializeGraph, presenceTargetKey, peerColor, isNodeTarget, presenceTargetEdgeId, parsePresenceTarget } from './chunk-TT2N7KWU.js';
3
3
  export { LOCK_DEFAULT_REFUSED, boundingRect, clampChildToParent, serializeGraph, snapToGrid, useNodeLock, useNodeLockAllows } from './chunk-TT2N7KWU.js';
4
4
  import { useRivetContext, RivetNodeContext, useRivetNodeContext, useRivetControls, RivetContext, useRivetViewport } from './chunk-VQYN27OK.js';
5
5
  export { useRivetContext, useRivetControls, useRivetFocusedNode, useRivetHistory, useRivetViewport } from './chunk-VQYN27OK.js';
@@ -888,7 +888,8 @@ function resolveEnd(edge, role, node, peer, frame) {
888
888
  rect,
889
889
  nodeRect(peer, worldPositions),
890
890
  anchorId ? [] : reachableSides(node.id, role, handles),
891
- extras?.displayedSides?.get(key)
891
+ extras?.displayedSides?.get(key),
892
+ extras?.alignmentHysteresis
892
893
  );
893
894
  extras?.displayedSides?.set(key, side);
894
895
  return endAt(side);
@@ -938,6 +939,20 @@ function distanceToSegment(p, a, b) {
938
939
  }
939
940
 
940
941
  // src/store.ts
942
+ function unionRect(rects) {
943
+ let left = Number.POSITIVE_INFINITY;
944
+ let top = Number.POSITIVE_INFINITY;
945
+ let right = Number.NEGATIVE_INFINITY;
946
+ let bottom = Number.NEGATIVE_INFINITY;
947
+ for (const rect of rects) {
948
+ left = Math.min(left, rect.left);
949
+ top = Math.min(top, rect.top);
950
+ right = Math.max(right, rect.right);
951
+ bottom = Math.max(bottom, rect.bottom);
952
+ }
953
+ if (!Number.isFinite(left)) return { left: 0, top: 0, width: 0, height: 0 };
954
+ return { left, top, width: right - left, height: bottom - top };
955
+ }
941
956
  var RivetGraphStore = class {
942
957
  nodes = /* @__PURE__ */ new Map();
943
958
  edges = /* @__PURE__ */ new Map();
@@ -984,6 +999,7 @@ var RivetGraphStore = class {
984
999
  nodeDragging = false;
985
1000
  nodeResizing = false;
986
1001
  edgeAlignment = "manual";
1002
+ alignmentHysteresis = DEFAULT_ALIGNMENT_HYSTERESIS;
987
1003
  draggingNodeIds = /* @__PURE__ */ new Set();
988
1004
  heldNodeIds = /* @__PURE__ */ new Set();
989
1005
  heldListeners = /* @__PURE__ */ new Set();
@@ -1467,6 +1483,13 @@ var RivetGraphStore = class {
1467
1483
  this.edgeAlignment = mode;
1468
1484
  };
1469
1485
  getEdgeAlignment = () => this.edgeAlignment;
1486
+ setAlignmentHysteresis = (ratio) => {
1487
+ const next = Number.isFinite(ratio) ? Math.max(1, ratio) : DEFAULT_ALIGNMENT_HYSTERESIS;
1488
+ if (next === this.alignmentHysteresis) return;
1489
+ this.alignmentHysteresis = next;
1490
+ this.requestRender();
1491
+ };
1492
+ getAlignmentHysteresis = () => this.alignmentHysteresis;
1470
1493
  getDisplayedSides = () => this.displayedSides;
1471
1494
  getDraggingNodeIds = () => this.draggingNodeIds;
1472
1495
  /**
@@ -1487,22 +1510,77 @@ var RivetGraphStore = class {
1487
1510
  */
1488
1511
  reachableFacingSide(nodeId, handleId, rect, peerRect, end) {
1489
1512
  const allowed = parseAnchorHandleId(handleId) || parseAnchorAutoHandleId(handleId) ? [] : reachableSides(nodeId, end, this.handles);
1490
- return resolveFacingSideAmong(rect, peerRect, allowed);
1513
+ return resolveFacingSideAmong(rect, peerRect, allowed, void 0, this.alignmentHysteresis);
1491
1514
  }
1492
- realignEndHandle(nodeId, handleId, side, end) {
1493
- if (!handleId || parseAnchorAutoHandleId(handleId)) return handleId;
1515
+ /**
1516
+ * What an end does about its new side, decided without choosing a handle yet.
1517
+ *
1518
+ * The choice is held back because it isn't an independent one: several ends
1519
+ * can want the same side of the same node, and which handle each takes is
1520
+ * only right relative to the others (see {@link assignSideHandles}).
1521
+ */
1522
+ planEndHandle(nodeId, handleId, side, end) {
1523
+ if (!handleId || parseAnchorAutoHandleId(handleId)) return { kind: "fixed", handleId };
1494
1524
  const anchor = parseAnchorHandleId(handleId);
1495
- if (anchor) return anchor.side === side ? handleId : anchorHandleId(anchor.anchorId, side);
1525
+ if (anchor) {
1526
+ return {
1527
+ kind: "fixed",
1528
+ handleId: anchor.side === side ? handleId : anchorHandleId(anchor.anchorId, side)
1529
+ };
1530
+ }
1496
1531
  const record = this.handles.get(handleKey(nodeId, handleId));
1497
- if (!record) return parseSideHandleId(handleId) ? side : handleId;
1498
- if (record.position === side) return handleId;
1499
- const wanted = end === "source" ? ["source", "either"] : ["target", "either"];
1500
- for (const candidate of this.handles.values()) {
1501
- if (candidate.nodeId === nodeId && candidate.position === side && wanted.includes(candidate.type)) {
1502
- return candidate.handleId;
1532
+ if (!record) {
1533
+ return { kind: "fixed", handleId: parseSideHandleId(handleId) ? side : handleId };
1534
+ }
1535
+ if (record.position === side) return { kind: "fixed", handleId };
1536
+ return { kind: "assign", nodeId, side, role: end };
1537
+ }
1538
+ /**
1539
+ * Hand out one node side's handles to the ends that want it, **in the order
1540
+ * their peers sit along that side**.
1541
+ *
1542
+ * Occupancy alone spreads edges across a side but says nothing about which
1543
+ * gets which, so the assignment fell out of registry order — and an edge from
1544
+ * a node above taking the lower handle while one from below takes the upper
1545
+ * is two lines crossing for no reason. Sorting both sides of the assignment
1546
+ * by the same axis removes exactly those crossings: peers in order, handles
1547
+ * in order, paired off.
1548
+ *
1549
+ * This is local, not global. It can't uncross edges that don't share a side,
1550
+ * which needs a pass over pairs rather than over one node's occupancy.
1551
+ */
1552
+ assignSideHandles(requests, occupied) {
1553
+ const assigned = /* @__PURE__ */ new Map();
1554
+ const groups = /* @__PURE__ */ new Map();
1555
+ for (const request of requests) {
1556
+ const key = `${request.nodeId}\0${request.side}`;
1557
+ const group = groups.get(key);
1558
+ if (group) group.push(request);
1559
+ else groups.set(key, [request]);
1560
+ }
1561
+ for (const group of groups.values()) {
1562
+ const first = group[0];
1563
+ if (!first) continue;
1564
+ const { nodeId, side } = first;
1565
+ const vertical = side === "left" || side === "right";
1566
+ const candidates = [...this.handles.values()].filter((handle) => handle.nodeId === nodeId && handle.position === side).map((handle, index) => ({ handle, index })).sort((a, b) => {
1567
+ const delta = vertical ? a.handle.offset.y - b.handle.offset.y : a.handle.offset.x - b.handle.offset.x;
1568
+ return delta !== 0 ? delta : a.index - b.index;
1569
+ }).map(({ handle }) => handle);
1570
+ const ordered = [...group].sort(
1571
+ (a, b) => a.along !== b.along ? a.along - b.along : a.seq - b.seq
1572
+ );
1573
+ for (const request of ordered) {
1574
+ const wanted = request.role === "source" ? ["source", "either"] : ["target", "either"];
1575
+ const usable = candidates.filter((handle) => wanted.includes(handle.type));
1576
+ const free = usable.find((handle) => !occupied.has(handleKey(nodeId, handle.handleId)));
1577
+ const picked = free ?? usable[0];
1578
+ if (!picked) continue;
1579
+ occupied.add(handleKey(nodeId, picked.handleId));
1580
+ assigned.set(request.id, picked.handleId);
1503
1581
  }
1504
1582
  }
1505
- return handleId;
1583
+ return assigned;
1506
1584
  }
1507
1585
  /**
1508
1586
  * Under `"on-move"` and `"live"`: re-side both ends of the moved node's
@@ -1522,6 +1600,15 @@ var RivetGraphStore = class {
1522
1600
  if (this.edgeAlignment === "manual") return;
1523
1601
  const live = this.edgeAlignment === "live";
1524
1602
  const changes = [];
1603
+ const occupied = /* @__PURE__ */ new Set();
1604
+ for (const edge of this.edges.values()) {
1605
+ const moved = edge.source === nodeId || edge.target === nodeId;
1606
+ if (moved) continue;
1607
+ if (edge.sourceHandle) occupied.add(handleKey(edge.source, edge.sourceHandle));
1608
+ if (edge.targetHandle) occupied.add(handleKey(edge.target, edge.targetHandle));
1609
+ }
1610
+ const planned = [];
1611
+ const requests = [];
1525
1612
  for (const [edgeId, edge] of this.edges) {
1526
1613
  if (edge.source !== nodeId && edge.target !== nodeId) continue;
1527
1614
  if (edge.source === edge.target) continue;
@@ -1532,18 +1619,42 @@ var RivetGraphStore = class {
1532
1619
  const targetKey = displayedSideKey(edgeId, "target");
1533
1620
  const sourceSide = (live ? this.displayedSides.get(sourceKey) : void 0) ?? this.reachableFacingSide(edge.source, edge.sourceHandle, sourceRect, targetRect, "source");
1534
1621
  const targetSide = (live ? this.displayedSides.get(targetKey) : void 0) ?? this.reachableFacingSide(edge.target, edge.targetHandle, targetRect, sourceRect, "target");
1535
- const sourceHandle = this.realignEndHandle(
1536
- edge.source,
1537
- edge.sourceHandle,
1538
- sourceSide,
1539
- "source"
1540
- );
1541
- const targetHandle = this.realignEndHandle(
1542
- edge.target,
1543
- edge.targetHandle,
1544
- targetSide,
1545
- "target"
1546
- );
1622
+ const source = this.planEndHandle(edge.source, edge.sourceHandle, sourceSide, "source");
1623
+ const target = this.planEndHandle(edge.target, edge.targetHandle, targetSide, "target");
1624
+ if (source.kind === "fixed" && source.handleId) {
1625
+ occupied.add(handleKey(edge.source, source.handleId));
1626
+ }
1627
+ if (target.kind === "fixed" && target.handleId) {
1628
+ occupied.add(handleKey(edge.target, target.handleId));
1629
+ }
1630
+ const along = (side, peer) => side === "left" || side === "right" ? peer.y + peer.height / 2 : peer.x + peer.width / 2;
1631
+ if (source.kind === "assign") {
1632
+ requests.push({
1633
+ id: `${edgeId}:source`,
1634
+ nodeId: source.nodeId,
1635
+ side: source.side,
1636
+ role: source.role,
1637
+ along: along(source.side, targetRect),
1638
+ seq: requests.length
1639
+ });
1640
+ }
1641
+ if (target.kind === "assign") {
1642
+ requests.push({
1643
+ id: `${edgeId}:target`,
1644
+ nodeId: target.nodeId,
1645
+ side: target.side,
1646
+ role: target.role,
1647
+ along: along(target.side, sourceRect),
1648
+ seq: requests.length
1649
+ });
1650
+ }
1651
+ planned.push({ edgeId, edge, source, target, sourceKey, targetKey });
1652
+ }
1653
+ const assigned = this.assignSideHandles(requests, occupied);
1654
+ const resolve = (plan, id, fallback) => plan.kind === "fixed" ? plan.handleId : assigned.get(id) ?? fallback;
1655
+ for (const { edgeId, edge, source, target, sourceKey, targetKey } of planned) {
1656
+ const sourceHandle = resolve(source, `${edgeId}:source`, edge.sourceHandle);
1657
+ const targetHandle = resolve(target, `${edgeId}:target`, edge.targetHandle);
1547
1658
  if (live) {
1548
1659
  const pinned = (h) => Boolean(h) && !parseAnchorAutoHandleId(h);
1549
1660
  if (pinned(edge.sourceHandle)) this.displayedSides.delete(sourceKey);
@@ -1873,16 +1984,20 @@ var RivetGraphStore = class {
1873
1984
  */
1874
1985
  measureAnchor(key) {
1875
1986
  const record = this.anchors.get(key);
1876
- if (!record?.element?.isConnected) return false;
1877
- const element = record.element;
1987
+ if (!record) return false;
1988
+ const live = record.elements.filter((element) => element.isConnected);
1989
+ const firstElement = live[0];
1990
+ const lastElement = live[live.length - 1];
1991
+ if (!firstElement || !lastElement) return false;
1878
1992
  const nodeEl = this.nodeElements.get(record.nodeId);
1879
1993
  if (!nodeEl) return false;
1880
1994
  const root = nodeEl.getBoundingClientRect();
1881
- const box = element.getBoundingClientRect();
1995
+ const box = unionRect(live.map((element) => element.getBoundingClientRect()));
1882
1996
  if (root.width <= 0 || root.height <= 0 || box.width <= 0 && box.height <= 0) return false;
1883
- const rects = element.getClientRects();
1884
- const first = rects.item(0) ?? box;
1885
- const last = rects.item(rects.length - 1) ?? first;
1997
+ const firstRects = firstElement.getClientRects();
1998
+ const lastRects = lastElement.getClientRects();
1999
+ const first = firstRects.item(0) ?? firstElement.getBoundingClientRect();
2000
+ const last = lastRects.item(lastRects.length - 1) ?? lastElement.getBoundingClientRect();
1886
2001
  const geometry = computeAnchorGeometry(root, first, last, box);
1887
2002
  if (anchorGeometryEqual(record.geometry, geometry)) return false;
1888
2003
  this.anchors.set(key, { ...record, geometry });
@@ -1891,6 +2006,7 @@ var RivetGraphStore = class {
1891
2006
  registerAnchor = (nodeId, anchorId, element, options) => {
1892
2007
  const key = handleKey(nodeId, anchorId);
1893
2008
  const prev = this.anchors.get(key);
2009
+ const elements = Array.isArray(element) ? [...element] : [element];
1894
2010
  this.anchors.set(key, {
1895
2011
  nodeId,
1896
2012
  anchorId,
@@ -1898,7 +2014,8 @@ var RivetGraphStore = class {
1898
2014
  strays: options?.strays ?? prev?.strays,
1899
2015
  selectable: options?.selectable ?? prev?.selectable,
1900
2016
  data: options?.data ?? prev?.data,
1901
- element,
2017
+ elements,
2018
+ element: elements[0] ?? null,
1902
2019
  // Keep the last-known geometry until the next successful measurement, so
1903
2020
  // a re-bind (display remount, editor DOM swap) never blanks the chrome.
1904
2021
  geometry: prev?.geometry ?? null
@@ -1910,8 +2027,8 @@ var RivetGraphStore = class {
1910
2027
  detachAnchor = (nodeId, anchorId) => {
1911
2028
  const key = handleKey(nodeId, anchorId);
1912
2029
  const record = this.anchors.get(key);
1913
- if (!record || record.element === null) return;
1914
- this.anchors.set(key, { ...record, element: null });
2030
+ if (!record || record.elements.length === 0) return;
2031
+ this.anchors.set(key, { ...record, elements: [], element: null });
1915
2032
  this.bumpAnchors(nodeId);
1916
2033
  };
1917
2034
  unregisterAnchor = (nodeId, anchorId) => {
@@ -3201,6 +3318,9 @@ var HELD_LINE_WIDTH = 2;
3201
3318
  var SELECTED_LINE_WIDTH = 1.5;
3202
3319
  var SELECTED_DASH = [4, 4];
3203
3320
  var OUTLINE_RADIUS = 6;
3321
+ var ANCHOR_CLAIM_ALPHA = 0.28;
3322
+ var ANCHOR_CLAIM_LINE_WIDTH = 1;
3323
+ var ANCHOR_CLAIM_RADIUS = 3;
3204
3324
  var CURSOR_POINTS = [
3205
3325
  { x: 0, y: 0 },
3206
3326
  { x: 0, y: 16 },
@@ -3239,6 +3359,17 @@ function projectRect(rect, viewport) {
3239
3359
  function onScreen(rect, size) {
3240
3360
  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;
3241
3361
  }
3362
+ function drawAnchorClaim(ctx, rect, color) {
3363
+ traceRect(ctx, rect, ANCHOR_CLAIM_RADIUS);
3364
+ ctx.globalAlpha = ANCHOR_CLAIM_ALPHA;
3365
+ ctx.fillStyle = color;
3366
+ ctx.fill();
3367
+ ctx.globalAlpha = 1;
3368
+ ctx.strokeStyle = color;
3369
+ ctx.lineWidth = ANCHOR_CLAIM_LINE_WIDTH;
3370
+ ctx.setLineDash([]);
3371
+ ctx.stroke();
3372
+ }
3242
3373
  function drawNodeOutline(ctx, rect, color, mode) {
3243
3374
  ctx.strokeStyle = color;
3244
3375
  ctx.lineWidth = mode === "selected" ? SELECTED_LINE_WIDTH : HELD_LINE_WIDTH;
@@ -3271,18 +3402,31 @@ function drawCursor(ctx, point, color, name) {
3271
3402
  ctx.fillStyle = "#ffffff";
3272
3403
  ctx.fillText(name, x + LABEL_PADDING_X, y + LABEL_HEIGHT / 2);
3273
3404
  }
3274
- function drawPeerOutlines(ctx, peers, { viewport, size, getNodeRect, locks }) {
3405
+ function drawPeerOutlines(ctx, peers, { viewport, size, getNodeRect, getAnchorRect, locks }) {
3275
3406
  if (peers.length === 0 && locks.size === 0) return;
3276
3407
  ctx.save();
3277
3408
  ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
3278
3409
  const painted = /* @__PURE__ */ new Set();
3410
+ const paintAnchor = (key, color) => {
3411
+ const target = parsePresenceTarget(key);
3412
+ if (target.kind !== "anchor") return;
3413
+ const rect = getAnchorRect(target.nodeId, target.anchorId);
3414
+ if (!rect) return;
3415
+ const screen = projectRect(rect, viewport);
3416
+ if (onScreen(screen, size)) drawAnchorClaim(ctx, screen, color);
3417
+ };
3279
3418
  for (const peer of peers) {
3280
3419
  const held = /* @__PURE__ */ new Set([
3281
3420
  ...peer.holding.filter((key) => isNodeTarget(key)),
3282
3421
  ...peer.transforms.keys()
3283
3422
  ]);
3284
3423
  for (const id of peer.selection) {
3285
- if (!isNodeTarget(id) || held.has(id)) continue;
3424
+ if (!isNodeTarget(id)) {
3425
+ painted.add(id);
3426
+ paintAnchor(id, peer.color);
3427
+ continue;
3428
+ }
3429
+ if (held.has(id)) continue;
3286
3430
  const rect = getNodeRect(id);
3287
3431
  if (!rect) continue;
3288
3432
  const screen = projectRect(rect, viewport);
@@ -3299,7 +3443,11 @@ function drawPeerOutlines(ctx, peers, { viewport, size, getNodeRect, locks }) {
3299
3443
  if (locks.size > 0) {
3300
3444
  const declared = new Map(peers.map((peer) => [peer.id, peer.color]));
3301
3445
  for (const [id, holderId] of locks) {
3302
- if (!isNodeTarget(id) || painted.has(id)) continue;
3446
+ if (painted.has(id)) continue;
3447
+ if (!isNodeTarget(id)) {
3448
+ paintAnchor(id, declared.get(holderId) ?? peerColor(holderId));
3449
+ continue;
3450
+ }
3303
3451
  const rect = getNodeRect(id);
3304
3452
  if (!rect) continue;
3305
3453
  const screen = projectRect(rect, viewport);
@@ -3560,6 +3708,12 @@ function useRivetRuntime(params) {
3560
3708
  if (store.presence.getTransformVersion() !== version) schedule();
3561
3709
  else scheduleCursors();
3562
3710
  };
3711
+ const peerAnchorRect = (nodeId, anchorId) => {
3712
+ if (!store.nodes.has(nodeId)) return null;
3713
+ const record = store.anchors.get(handleKey(nodeId, anchorId));
3714
+ if (!record?.geometry) return null;
3715
+ return anchorRect(store.getNodeRect(nodeId), record.geometry);
3716
+ };
3563
3717
  const anchorPlacement = (nodeId, anchorId, side) => {
3564
3718
  const record = store.anchors.get(handleKey(nodeId, anchorId));
3565
3719
  return record?.geometry ? strayPlacementPct(record.geometry, side, anchorOptions) : null;
@@ -3600,6 +3754,7 @@ function useRivetRuntime(params) {
3600
3754
  worldPositions,
3601
3755
  displayedSides: store.getDisplayedSides(),
3602
3756
  liveAlignNodes,
3757
+ alignmentHysteresis: store.getAlignmentHysteresis(),
3603
3758
  anchorPlacement,
3604
3759
  peerEdges
3605
3760
  });
@@ -3633,6 +3788,7 @@ function useRivetRuntime(params) {
3633
3788
  viewport,
3634
3789
  size,
3635
3790
  getNodeRect: (id) => store.nodes.has(id) ? store.getNodeRect(id) : null,
3791
+ getAnchorRect: peerAnchorRect,
3636
3792
  locks: store.locks.getLocks()
3637
3793
  });
3638
3794
  }
@@ -4567,38 +4723,49 @@ function clampToParent(store, parentId, position, size) {
4567
4723
  }
4568
4724
 
4569
4725
  // src/anchor/select-gesture.ts
4570
- function attachAnchorSelection(store, record) {
4571
- const element = record.element;
4572
- if (!record.selectable || !(element instanceof HTMLElement || element instanceof SVGElement)) {
4573
- return null;
4574
- }
4726
+ function attachAnchorSelection(store, record, multiSelectionKeys = []) {
4727
+ const elements = record.elements.filter(
4728
+ (candidate) => candidate instanceof HTMLElement || candidate instanceof SVGElement
4729
+ );
4730
+ if (!record.selectable || elements.length === 0) return null;
4575
4731
  const { nodeId, anchorId } = record;
4576
- const previous = element.style.pointerEvents;
4577
- element.style.pointerEvents = "auto";
4732
+ const previous = elements.map((element) => element.style.pointerEvents);
4733
+ for (const element of elements) element.style.pointerEvents = "auto";
4578
4734
  const onPointerDown = (event) => {
4579
4735
  if (!(event instanceof MouseEvent)) return;
4580
4736
  if (event.button !== 0) return;
4581
4737
  const originX = event.clientX;
4582
4738
  const originY = event.clientY;
4739
+ const additive = keyHeld(event, multiSelectionKeys);
4583
4740
  const onUp = (upEvent) => {
4584
4741
  window.removeEventListener("pointerup", onUp);
4585
4742
  if (!(upEvent instanceof MouseEvent)) return;
4586
4743
  const moved = Math.hypot(upEvent.clientX - originX, upEvent.clientY - originY);
4587
4744
  if (moved >= DRAG_THRESHOLD) return;
4588
4745
  if (!store.locks.allows(anchorTargetKey(nodeId, anchorId), "select")) return;
4589
- store.setSelectedAnchors([{ nodeId, anchorId }]);
4746
+ if (!additive) {
4747
+ store.setSelectedAnchors([{ nodeId, anchorId }]);
4748
+ return;
4749
+ }
4750
+ const selected = store.getSelectedAnchors();
4751
+ const without = selected.filter((ref) => ref.nodeId !== nodeId || ref.anchorId !== anchorId);
4752
+ store.setSelectedAnchors(
4753
+ without.length === selected.length ? [...selected, { nodeId, anchorId }] : without
4754
+ );
4590
4755
  };
4591
4756
  window.addEventListener("pointerup", onUp);
4592
4757
  };
4593
- element.addEventListener("pointerdown", onPointerDown);
4758
+ for (const element of elements) element.addEventListener("pointerdown", onPointerDown);
4594
4759
  return () => {
4595
- element.removeEventListener("pointerdown", onPointerDown);
4596
- element.style.pointerEvents = previous;
4760
+ elements.forEach((element, index) => {
4761
+ element.removeEventListener("pointerdown", onPointerDown);
4762
+ element.style.pointerEvents = previous[index] ?? "";
4763
+ });
4597
4764
  };
4598
4765
  }
4599
4766
  var DEFAULT_ANCHOR_COLOR = "#6366f1";
4600
4767
  function NodeAnchors({ nodeId }) {
4601
- const { store, anchorOptions } = useRivetContext();
4768
+ const { store, anchorOptions, multiSelectionKeys } = useRivetContext();
4602
4769
  const subscribeAnchors = useCallback(
4603
4770
  (listener) => store.subscribeNodeAnchors(nodeId, listener),
4604
4771
  [store, nodeId]
@@ -4618,13 +4785,13 @@ function NodeAnchors({ nodeId }) {
4618
4785
  useEffect(() => {
4619
4786
  const cleanups = [];
4620
4787
  for (const record of store.getNodeAnchors(nodeId)) {
4621
- const cleanup = attachAnchorSelection(store, record);
4788
+ const cleanup = attachAnchorSelection(store, record, multiSelectionKeys);
4622
4789
  if (cleanup) cleanups.push(cleanup);
4623
4790
  }
4624
4791
  return () => {
4625
4792
  for (const cleanup of cleanups) cleanup();
4626
4793
  };
4627
- }, [store, nodeId, anchorsVersion]);
4794
+ }, [store, nodeId, anchorsVersion, multiSelectionKeys]);
4628
4795
  const records = store.getNodeAnchors(nodeId);
4629
4796
  if (records.length === 0) return null;
4630
4797
  const dotsVisible = Boolean(store.nodes.get(nodeId)?.hovered);
@@ -4681,7 +4848,7 @@ function AnchorSelectionOutline({ record }) {
4681
4848
  }
4682
4849
  function AnchorDots(props) {
4683
4850
  const { record, options } = props;
4684
- if (!record.geometry || !record.element?.isConnected) return null;
4851
+ if (!record.geometry || !record.elements.some((element) => element.isConnected)) return null;
4685
4852
  const dots = record.geometry.dots;
4686
4853
  return /* @__PURE__ */ jsx(Fragment, { children: ANCHOR_SIDES.map((side) => {
4687
4854
  const dot = dots[side];
@@ -4712,11 +4879,11 @@ function AnchorDots(props) {
4712
4879
  function AnchorStrayHandles(props) {
4713
4880
  const { record, options } = props;
4714
4881
  const geometry = record.geometry;
4715
- if (!geometry) return null;
4716
4882
  const sides = props.connectedSides && props.connectedSides.length > 0 ? props.connectedSides : [options.defaultSide];
4883
+ useLiveStrayPlacement(record, sides, options);
4884
+ if (!geometry) return null;
4717
4885
  return /* @__PURE__ */ jsx(Fragment, { children: sides.map((side) => {
4718
- const placed = strayPlacementPct(geometry, side, options);
4719
- const axis = side === "top" || side === "bottom" ? { left: `${placed}%` } : { top: `${placed}%` };
4886
+ const point = strayPoint(geometry, side, options);
4720
4887
  return /* @__PURE__ */ jsx(
4721
4888
  Handle,
4722
4889
  {
@@ -4724,17 +4891,89 @@ function AnchorStrayHandles(props) {
4724
4891
  type: "either",
4725
4892
  position: side,
4726
4893
  style: {
4894
+ left: `${point.xPct}%`,
4895
+ top: `${point.yPct}%`,
4896
+ right: "auto",
4897
+ bottom: "auto",
4898
+ transform: "translate(-50%, -50%)",
4727
4899
  width: options.strayHandleSize,
4728
4900
  height: options.strayHandleSize,
4729
4901
  background: record.color ?? DEFAULT_ANCHOR_COLOR,
4730
- border: "none",
4731
- ...axis
4902
+ border: "none"
4732
4903
  }
4733
4904
  },
4734
- `${side}:${placed.toFixed(1)}`
4905
+ `${side}:${point.xPct.toFixed(1)},${point.yPct.toFixed(1)}`
4735
4906
  );
4736
4907
  }) });
4737
4908
  }
4909
+ function useLiveStrayPlacement(record, sides, options) {
4910
+ const { store } = useRivetContext();
4911
+ const { wrapperRef } = useRivetNodeContext();
4912
+ const sideKey = sides.join(",");
4913
+ useEffect(() => {
4914
+ const wrapper = wrapperRef.current;
4915
+ const geometry = record.geometry;
4916
+ if (!wrapper || !geometry) return;
4917
+ let displaced = false;
4918
+ const element = (side) => wrapper.querySelector(
4919
+ `[data-rivet-handle-id="${anchorHandleId(record.anchorId, side)}"]`
4920
+ );
4921
+ const place = (side, at) => {
4922
+ const el = element(side);
4923
+ if (!el) return;
4924
+ const point = strayPoint(geometry, at, options);
4925
+ el.style.left = `${point.xPct}%`;
4926
+ el.style.top = `${point.yPct}%`;
4927
+ };
4928
+ return store.subscribeFrame(() => {
4929
+ if (!store.isNodeDragging()) {
4930
+ if (displaced) {
4931
+ displaced = false;
4932
+ for (const side of sides) place(side, side);
4933
+ }
4934
+ return;
4935
+ }
4936
+ const autoSide = sides.length === 1 ? sides[0] ?? null : null;
4937
+ const shown = displayedAnchorSides(store, record, autoSide);
4938
+ for (const side of sides) {
4939
+ const at = shown.get(side);
4940
+ if (at && at !== side) {
4941
+ displaced = true;
4942
+ place(side, at);
4943
+ } else if (displaced) {
4944
+ place(side, side);
4945
+ }
4946
+ }
4947
+ });
4948
+ }, [store, wrapperRef, record, sideKey, options]);
4949
+ }
4950
+ function displayedAnchorSides(store, record, autoSide) {
4951
+ const shown = /* @__PURE__ */ new Map();
4952
+ const displayed = store.getDisplayedSides();
4953
+ if (displayed.size === 0) return shown;
4954
+ for (const [edgeId, edge] of store.edges) {
4955
+ for (const end of ["source", "target"]) {
4956
+ const nodeId = end === "source" ? edge.source : edge.target;
4957
+ if (nodeId !== record.nodeId) continue;
4958
+ const handleId = end === "source" ? edge.sourceHandle : edge.targetHandle;
4959
+ const at = displayed.get(displayedSideKey(edgeId, end));
4960
+ if (!at) continue;
4961
+ const anchor = parseAnchorHandleId(handleId);
4962
+ if (anchor?.anchorId === record.anchorId) {
4963
+ if (!shown.has(anchor.side)) shown.set(anchor.side, at);
4964
+ continue;
4965
+ }
4966
+ if (autoSide && parseAnchorAutoHandleId(handleId) === record.anchorId && !shown.has(autoSide)) {
4967
+ shown.set(autoSide, at);
4968
+ }
4969
+ }
4970
+ }
4971
+ return shown;
4972
+ }
4973
+ var ANCHOR_STEP_KEYS = {
4974
+ ArrowRight: 1,
4975
+ ArrowLeft: -1
4976
+ };
4738
4977
  var wrapperStyle = {
4739
4978
  position: "absolute",
4740
4979
  top: 0,
@@ -4827,6 +5066,21 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4827
5066
  if (next instanceof Element && next.closest(SELECTOR.node)) return;
4828
5067
  if (store.getFocusedNode() === id) store.setFocusedNode(null);
4829
5068
  };
5069
+ const stepAnchor = (step) => {
5070
+ const anchors = orderedSelectableAnchors(store.getNodeAnchors(id)).filter(
5071
+ (record2) => store.locks.allows(anchorTargetKey(id, record2.anchorId), "select")
5072
+ );
5073
+ if (anchors.length === 0) return;
5074
+ const selected = store.getSelectedAnchors();
5075
+ const current = anchors.findLastIndex(
5076
+ (record2) => selected.some((ref2) => ref2.nodeId === id && ref2.anchorId === record2.anchorId)
5077
+ );
5078
+ const next = current < 0 ? step > 0 ? 0 : anchors.length - 1 : current + step;
5079
+ const record = anchors[(next + anchors.length) % anchors.length];
5080
+ if (!record) return;
5081
+ store.setSelectedAnchors([{ nodeId: id, anchorId: record.anchorId }]);
5082
+ announce(`${record.anchorId} selected in ${nodeName()}.`);
5083
+ };
4830
5084
  const onKeyDown = (event) => {
4831
5085
  if (event.target !== event.currentTarget) return;
4832
5086
  const node2 = store.nodes.get(id);
@@ -4858,9 +5112,21 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4858
5112
  releaseGrab(true);
4859
5113
  return;
4860
5114
  }
5115
+ if (store.getSelectedAnchors().some((ref2) => ref2.nodeId === id)) {
5116
+ event.preventDefault();
5117
+ store.setSelectedAnchors([]);
5118
+ store.selectNode(id);
5119
+ announce(`${nodeName()} selected.`);
5120
+ return;
5121
+ }
4861
5122
  store.selectNode(null);
4862
5123
  return;
4863
5124
  }
5125
+ if (event.altKey && !grabOrigins.current && ANCHOR_STEP_KEYS[event.key]) {
5126
+ event.preventDefault();
5127
+ stepAnchor(ANCHOR_STEP_KEYS[event.key] ?? 1);
5128
+ return;
5129
+ }
4864
5130
  const dir = ARROW_DIRS[event.key];
4865
5131
  if (!dir || !grabOrigins.current) return;
4866
5132
  event.preventDefault();
@@ -4892,12 +5158,13 @@ var NodeWrapper = memo(function NodeWrapper2({ id }) {
4892
5158
  const target = event.target instanceof Element ? event.target : null;
4893
5159
  const noSelect = node2.selectable === false || Boolean(target?.closest(SELECTOR.noSelect)) || !store.locks.allows(id, "select");
4894
5160
  const noDrag = node2.draggable === false || Boolean(target?.closest(SELECTOR.noDrag)) || !store.locks.allows(id, "drag");
5161
+ const onAnchor = target !== null && findSelectableAnchor(store.getNodeAnchors(id), target) !== null;
4895
5162
  const additive = keyHeld(event, multiSelectionKeys);
4896
5163
  if (additive) {
5164
+ if (onAnchor) return;
4897
5165
  if (!noSelect) store.selectNode(id, true);
4898
5166
  return;
4899
5167
  }
4900
- const onAnchor = target !== null && findSelectableAnchor(store.getNodeAnchors(id), target) !== null;
4901
5168
  const selectNode = () => {
4902
5169
  if (!noSelect && !store.isNodeSelected(id)) store.selectNode(id);
4903
5170
  };
@@ -5474,7 +5741,7 @@ var srOnlyStyle = {
5474
5741
  whiteSpace: "nowrap",
5475
5742
  border: 0
5476
5743
  };
5477
- var NODE_KEYBOARD_INSTRUCTIONS = "Press Enter to select and grab the node. While grabbed, use the arrow keys to move it, Enter to drop, Escape to cancel. Press Delete to remove the selection.";
5744
+ var NODE_KEYBOARD_INSTRUCTIONS = "Press Enter to select and grab the node. While grabbed, use the arrow keys to move it, Enter to drop, Escape to cancel. Hold Alt with Left or Right to step through the node's anchors, Escape to leave them. Press Delete to remove the selection.";
5478
5745
  var DEFAULT_MULTI_SELECTION_KEY_CODE = ["Meta", "Control", "Shift"];
5479
5746
  var EMPTY_NODE_TYPES = {};
5480
5747
  var EMPTY_NODES = [];
@@ -5529,6 +5796,7 @@ function Rivet({
5529
5796
  onConnect,
5530
5797
  edgesReconnectable,
5531
5798
  edgeAlignment = "manual",
5799
+ edgeAlignmentHysteresis = DEFAULT_ALIGNMENT_HYSTERESIS,
5532
5800
  onReconnect,
5533
5801
  onReconnectStart,
5534
5802
  onReconnectEnd,
@@ -5577,6 +5845,9 @@ function Rivet({
5577
5845
  useEffect(() => {
5578
5846
  store.setEdgeAlignment(edgeAlignment);
5579
5847
  }, [store, edgeAlignment]);
5848
+ useEffect(() => {
5849
+ store.setAlignmentHysteresis(edgeAlignmentHysteresis);
5850
+ }, [store, edgeAlignmentHysteresis]);
5580
5851
  const onSelectionChangeRef = useRef(onSelectionChange);
5581
5852
  onSelectionChangeRef.current = onSelectionChange;
5582
5853
  useEffect(
@@ -5888,6 +6159,7 @@ function buildInstance(store, controls, getViewportElements) {
5888
6159
  unregister: () => store.unregisterAnchor(nodeId, anchorId)
5889
6160
  };
5890
6161
  },
6162
+ detachAnchor: (nodeId, anchorId) => store.detachAnchor(nodeId, anchorId),
5891
6163
  unregisterAnchor: (nodeId, anchorId) => store.unregisterAnchor(nodeId, anchorId),
5892
6164
  remeasureAnchors: (nodeId) => store.remeasureAnchors(nodeId),
5893
6165
  getAnchor: (nodeId, anchorId) => store.getNodeAnchors(nodeId).find((record) => record.anchorId === anchorId) ?? null,