living-documentation 7.35.0 → 7.37.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.
@@ -44,6 +44,12 @@ function setTool(tool, shape) {
44
44
  }
45
45
  }
46
46
 
47
+ window.addEventListener('diagram:setTool', (e) => {
48
+ const detail = e.detail || {};
49
+ if (!detail.tool) return;
50
+ setTool(detail.tool, detail.shape);
51
+ });
52
+
47
53
  function selectAll() {
48
54
  if (!st.network || !st.nodes || !st.edges) return;
49
55
  const ids = st.nodes.getIds();
@@ -34,6 +34,8 @@ let _draggingAnchorIds = new Set();
34
34
  let _rehookEdgeId = null;
35
35
  let _rehookHoveredNodeId = null;
36
36
  let _rehookHoveredPortKey = null;
37
+ let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
38
+ let _edgeLabelPointerAbort = null;
37
39
 
38
40
  // Returns true when an edge can enter rehook mode (at least one non-anchor endpoint).
39
41
  // Works for both port edges and native vis-network edges.
@@ -126,7 +128,11 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
126
128
  callback(data);
127
129
  markDirty();
128
130
  _addEdgeFromPort = null;
129
- setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
131
+ setTimeout(() => {
132
+ if (st.currentTool === 'addEdge') {
133
+ window.dispatchEvent(new CustomEvent('diagram:setTool', { detail: { tool: 'select' } }));
134
+ }
135
+ }, 0);
130
136
  },
131
137
  },
132
138
  };
@@ -138,6 +144,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
138
144
  // Must be registered BEFORE any other capture-phase mousedown listeners on
139
145
  // the container so it can stopImmediatePropagation for locked targets.
140
146
  installUnlockHold(container);
147
+ container.addEventListener('mousedown', (e) => {
148
+ if (e.button !== 0) return;
149
+ _pointerDownSelection = {
150
+ nodeIds: [...(st.selectedNodeIds || [])],
151
+ edgeIds: [...(st.selectedEdgeIds || [])],
152
+ };
153
+ }, { capture: true });
141
154
 
142
155
  // ── Z-order patch ──────────────────────────────────────────────────────────
143
156
  // vis.js renders in 3 passes (normal → selected → hovered), which breaks
@@ -189,7 +202,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
189
202
  } else if (fromIsAnchor && !toIsAnchor) {
190
203
  level = toLevel;
191
204
  } else {
192
- level = Math.min(fromLevel, toLevel);
205
+ level = Math.max(fromLevel, toLevel);
193
206
  }
194
207
  if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
195
208
  edgesByLevel.get(level).push(edge);
@@ -385,14 +398,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
385
398
  ctx.restore();
386
399
  });
387
400
 
388
- // Track hover during mousemove — computed before any mousedown fires.
389
- container.addEventListener('mousemove', (e) => {
401
+ function edgeLabelHitAt(clientX, clientY) {
390
402
  if (!st.network || !st.edgeLabelBBox) return;
391
- const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
403
+ const rect = container.getBoundingClientRect();
404
+ const cp = st.network.DOMtoCanvas({ x: clientX - rect.left, y: clientY - rect.top });
392
405
  const hr = 8 / st.network.getScale();
393
406
 
394
407
  // ── Resize handle detection ──────────────────────────────────────────────
395
- let found = null;
396
408
  for (const edgeId of (st.selectedEdgeIds || [])) {
397
409
  const edge = st.edges && st.edges.get(edgeId);
398
410
  if (!edge || !edge.label) continue;
@@ -405,18 +417,12 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
405
417
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
406
418
 
407
419
  if (Math.hypot(lx - (-bbox.w / 2), ly) < hr || Math.hypot(lx - (bbox.w / 2), ly) < hr) {
408
- found = { edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
409
- break;
420
+ return { type: 'handle', edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
410
421
  }
411
422
  }
412
- if (Boolean(found) !== Boolean(_hoverHandle)) {
413
- container.style.cursor = found ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
414
- }
415
- _hoverHandle = found;
416
423
 
417
- // ── Label box hover detection (drag) — only when no handle hovered ───────
418
- let labelFound = null;
419
- if (!found && st.selectedEdgeIds && st.selectedEdgeIds.length === 1) {
424
+ // ── Label box detection (drag) — only when no handle hovered ───────────
425
+ if (st.selectedEdgeIds && st.selectedEdgeIds.length === 1) {
420
426
  const edgeId = st.selectedEdgeIds[0];
421
427
  const edge = st.edges && st.edges.get(edgeId);
422
428
  if (edge && edge.label) {
@@ -427,39 +433,104 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
427
433
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
428
434
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
429
435
  if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
430
- labelFound = { edgeId };
436
+ return { type: 'label', edgeId };
431
437
  }
432
438
  }
433
439
  }
434
440
  }
441
+
442
+ return null;
443
+ }
444
+
445
+ // Track hover during mousemove — computed before any mousedown fires.
446
+ container.addEventListener('mousemove', (e) => {
447
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
448
+ const found = hit && hit.type === 'handle'
449
+ ? { edgeId: hit.edgeId, bboxCx: hit.bboxCx, bboxCy: hit.bboxCy, rotation: hit.rotation }
450
+ : null;
451
+ if (Boolean(found) !== Boolean(_hoverHandle)) {
452
+ container.style.cursor = found ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
453
+ }
454
+ _hoverHandle = found;
455
+
456
+ const labelFound = !found && hit && hit.type === 'label' ? { edgeId: hit.edgeId } : null;
435
457
  if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
436
458
  if (!found) container.style.cursor = labelFound ? 'grab' : '';
437
459
  }
438
460
  _hoverLabelDrag = labelFound;
439
461
  });
440
462
 
441
- // Mousedown: start resize (handles take priority) or label drag.
442
- container.addEventListener('mousedown', (e) => {
443
- if (e.button !== 0) return;
444
- if (_hoverHandle) {
463
+ function startEdgeLabelPointerInteraction(e) {
464
+ if (e.button !== 0) return false;
465
+ if (!container.contains(e.target)) return false;
466
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
467
+ if (hit && hit.type === 'handle') {
468
+ e.preventDefault();
469
+ e.stopImmediatePropagation();
470
+ _hoverHandle = { edgeId: hit.edgeId, bboxCx: hit.bboxCx, bboxCy: hit.bboxCy, rotation: hit.rotation };
445
471
  _lr = { ..._hoverHandle, dragging: false };
446
472
  st.network.setOptions({ interaction: { dragView: false } });
447
- } else if (_hoverLabelDrag) {
448
- const edge = st.edges && st.edges.get(_hoverLabelDrag.edgeId);
449
- if (!edge) return;
473
+ return true;
474
+ } else if (hit && hit.type === 'label') {
475
+ const edge = st.edges && st.edges.get(hit.edgeId);
476
+ if (!edge) return false;
477
+ e.preventDefault();
478
+ e.stopImmediatePropagation();
479
+ _hoverLabelDrag = { edgeId: hit.edgeId };
450
480
  _ld = {
451
- edgeId: _hoverLabelDrag.edgeId,
481
+ edgeId: hit.edgeId,
452
482
  startMouse: { x: e.clientX, y: e.clientY },
453
483
  startOffsetX: edge.edgeLabelOffsetX || 0,
454
484
  startOffsetY: edge.edgeLabelOffsetY || 0,
455
485
  dragging: false,
456
486
  };
457
487
  st.network.setOptions({ interaction: { dragView: false } });
488
+ return true;
458
489
  }
459
- }, { capture: true });
490
+ return false;
491
+ }
492
+
493
+ if (_edgeLabelPointerAbort) _edgeLabelPointerAbort.abort();
494
+ _edgeLabelPointerAbort = new AbortController();
495
+
496
+ // Mousedown: start resize (handles take priority) or label drag.
497
+ // The document-level capture listener runs before vis-network's internal
498
+ // pointer handlers, so a node underneath the edge label cannot start moving.
499
+ document.addEventListener('pointerdown', startEdgeLabelPointerInteraction, {
500
+ capture: true,
501
+ signal: _edgeLabelPointerAbort.signal,
502
+ });
503
+ document.addEventListener('mousedown', startEdgeLabelPointerInteraction, {
504
+ capture: true,
505
+ signal: _edgeLabelPointerAbort.signal,
506
+ });
507
+ container.addEventListener('pointerdown', (e) => {
508
+ startEdgeLabelPointerInteraction(e);
509
+ }, { capture: true, signal: _edgeLabelPointerAbort.signal });
510
+ container.addEventListener('mousedown', (e) => {
511
+ startEdgeLabelPointerInteraction(e);
512
+ }, { capture: true, signal: _edgeLabelPointerAbort.signal });
513
+
514
+ document.addEventListener('dblclick', (e) => {
515
+ if (!container.contains(e.target)) return;
516
+ const hit = edgeLabelHitAt(e.clientX, e.clientY);
517
+ if (!hit || hit.type !== 'label') return;
518
+
519
+ e.preventDefault();
520
+ e.stopImmediatePropagation();
521
+ _lr = null;
522
+ _ld = null;
523
+ st.network.setOptions({ interaction: { dragView: true } });
524
+ st.selectedNodeIds = [];
525
+ st.selectedEdgeIds = [hit.edgeId];
526
+ st.network.setSelection({ nodes: [], edges: [hit.edgeId] });
527
+ hideNodePanel();
528
+ showEdgePanel();
529
+ startEdgeLabelEdit();
530
+ }, { capture: true, signal: _edgeLabelPointerAbort.signal });
460
531
 
461
532
  // Update width (resize) or offset (label drag) while dragging.
462
- document.addEventListener('mousemove', (e) => {
533
+ function onEdgeLabelPointerMove(e) {
463
534
  if (!st.network) return;
464
535
  if (_lr) {
465
536
  if (!_lr.dragging) { _lr.dragging = true; pushSnapshot(); }
@@ -485,10 +556,12 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
485
556
  });
486
557
  st.network.redraw();
487
558
  }
488
- });
559
+ }
560
+ document.addEventListener('pointermove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
561
+ document.addEventListener('mousemove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
489
562
 
490
563
  // Commit on mouseup.
491
- document.addEventListener('mouseup', () => {
564
+ function onEdgeLabelPointerUp() {
492
565
  if (_lr) {
493
566
  st.network.setOptions({ interaction: { dragView: true } });
494
567
  if (_lr.dragging) markDirty();
@@ -500,7 +573,9 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
500
573
  _ld = null;
501
574
  }
502
575
  container.style.cursor = _hoverHandle ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
503
- });
576
+ }
577
+ document.addEventListener('pointerup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
578
+ document.addEventListener('mouseup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
504
579
  }
505
580
 
506
581
  // ── Free-arrow body drag ──────────────────────────────────────────────────────
@@ -1037,48 +1112,185 @@ function _distToSegment(px, py, ax, ay, bx, by) {
1037
1112
  }
1038
1113
 
1039
1114
 
1040
- // Returns the topmost (highest z-order) node whose bounding box contains canvasPos.
1115
+ function nodeContainsCanvasPoint(id, canvasPos) {
1116
+ const n = st.nodes.get(id);
1117
+ const bn = st.network && st.network.body.nodes[id];
1118
+ if (!n || !bn || n.shapeType === 'anchor') return false;
1119
+
1120
+ const shapeType = n.shapeType || 'box';
1121
+ const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
1122
+ const W = n.nodeWidth || defaults[0];
1123
+ const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
1124
+
1125
+ const dx = canvasPos.x - bn.x;
1126
+ const dy = canvasPos.y - bn.y;
1127
+ const rot = n.rotation || 0;
1128
+ const lx = rot ? dx * Math.cos(-rot) - dy * Math.sin(-rot) : dx;
1129
+ const ly = rot ? dx * Math.sin(-rot) + dy * Math.cos(-rot) : dy;
1130
+
1131
+ if (shapeType === 'circle' || shapeType === 'ellipse') {
1132
+ const rx = W / 2;
1133
+ const ry = H / 2;
1134
+ return rx > 0 && ry > 0 && ((lx * lx) / (rx * rx) + (ly * ly) / (ry * ry)) <= 1;
1135
+ }
1136
+
1137
+ return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
1138
+ }
1139
+
1140
+ // Returns the topmost (highest z-order) node containing canvasPos.
1041
1141
  // Ignores anchor nodes and respects st.canonicalOrder.
1042
1142
  function topmostNodeAt(canvasPos) {
1043
- let topmost = null;
1044
- let topmostIdx = -1;
1045
- for (const id of st.canonicalOrder) {
1046
- const n = st.nodes.get(id);
1047
- const bn = st.network && st.network.body.nodes[id];
1048
- if (!n || !bn || n.shapeType === 'anchor') continue;
1049
- const defaults = SHAPE_DEFAULTS[n.shapeType || 'box'] || [60, 28];
1050
- const W = n.nodeWidth || defaults[0];
1051
- const H = n.nodeHeight || defaults[1];
1052
- const cx = bn.x, cy = bn.y;
1053
- if (canvasPos.x >= cx - W / 2 && canvasPos.x <= cx + W / 2 &&
1054
- canvasPos.y >= cy - H / 2 && canvasPos.y <= cy + H / 2) {
1055
- const idx = st.canonicalOrder.indexOf(id);
1056
- if (idx > topmostIdx) { topmostIdx = idx; topmost = id; }
1143
+ for (let i = st.canonicalOrder.length - 1; i >= 0; i--) {
1144
+ const id = st.canonicalOrder[i];
1145
+ if (nodeContainsCanvasPoint(id, canvasPos)) return id;
1146
+ }
1147
+ return null;
1148
+ }
1149
+
1150
+ function edgeDrawLevel(edgeData) {
1151
+ if (!edgeData) return -1;
1152
+ const fromLevel = st.canonicalOrder.indexOf(edgeData.from);
1153
+ const toLevel = st.canonicalOrder.indexOf(edgeData.to);
1154
+ if (fromLevel === -1 && toLevel === -1) return -1;
1155
+ if (fromLevel === -1) return toLevel;
1156
+ if (toLevel === -1) return fromLevel;
1157
+
1158
+ const fromNode = st.nodes.get(edgeData.from);
1159
+ const toNode = st.nodes.get(edgeData.to);
1160
+ const fromIsAnchor = fromNode && fromNode.shapeType === 'anchor';
1161
+ const toIsAnchor = toNode && toNode.shapeType === 'anchor';
1162
+ if (toIsAnchor && !fromIsAnchor) return fromLevel;
1163
+ if (fromIsAnchor && !toIsAnchor) return toLevel;
1164
+ return Math.max(fromLevel, toLevel);
1165
+ }
1166
+
1167
+ function nearestPortEdgeAt(canvasPos, threshold = 8) {
1168
+ const portEdges = st.edges.get({ filter: (e) => e.fromPort || e.toPort });
1169
+ let nearest = null;
1170
+ let nearestDist = Infinity;
1171
+ for (const edge of portEdges) {
1172
+ const d = distanceToPortEdge(edge, canvasPos);
1173
+ if (d < nearestDist) {
1174
+ nearestDist = d;
1175
+ nearest = edge;
1057
1176
  }
1058
1177
  }
1059
- return topmost;
1178
+ return nearest && nearestDist <= threshold ? nearest : null;
1060
1179
  }
1061
1180
 
1062
- function onDoubleClick(params) {
1063
- // Locked nodes never intercept double-click — filter them out first.
1064
- const unlockedNodes = params.nodes.filter(id => { const n = st.nodes.get(id); return n && !n.locked; });
1065
-
1066
- // When nodes and edges both match the click position, honour z-order:
1067
- // only give the click to a node if it is truly the topmost element there.
1068
- // Otherwise fall through to edge handling.
1069
- let effectiveNodes = unlockedNodes;
1070
- if (unlockedNodes.length > 0 && params.edges.length > 0) {
1071
- const top = topmostNodeAt(params.pointer.canvas);
1072
- effectiveNodes = (top && unlockedNodes.includes(top)) ? [top] : [];
1181
+ function edgeLabelAtCanvasPoint(canvasPos) {
1182
+ if (!st.edgeLabelBBox || !st.edges) return null;
1183
+
1184
+ let bestEdgeId = null;
1185
+ let bestLevel = -1;
1186
+ for (const edge of st.edges.get()) {
1187
+ if (!edge || !edge.label) continue;
1188
+ const bbox = st.edgeLabelBBox[edge.id];
1189
+ if (!bbox) continue;
1190
+
1191
+ const r = -(bbox.rotation || 0);
1192
+ const dx = canvasPos.x - bbox.cx;
1193
+ const dy = canvasPos.y - bbox.cy;
1194
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
1195
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
1196
+ if (Math.abs(lx) > bbox.w / 2 || Math.abs(ly) > bbox.h / 2) continue;
1197
+
1198
+ const level = edgeDrawLevel(edge);
1199
+ if (level >= bestLevel) {
1200
+ bestLevel = level;
1201
+ bestEdgeId = edge.id;
1202
+ }
1073
1203
  }
1204
+ return bestEdgeId;
1205
+ }
1206
+
1207
+ function isEdgeInteractive(edge) {
1208
+ if (!edge) return false;
1209
+ const fromN = st.nodes.get(edge.from);
1210
+ const toN = st.nodes.get(edge.to);
1211
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1212
+ return isFreeArrow ? !(fromN.locked && toN.locked) : !edge.edgeLocked;
1213
+ }
1214
+
1215
+ function selectableEdgesForNodes(nodeIds) {
1216
+ const selectedSet = new Set(nodeIds);
1217
+ return st.edges.get().filter((e) => {
1218
+ if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1219
+ return isEdgeInteractive(e);
1220
+ }).map((e) => e.id);
1221
+ }
1074
1222
 
1075
- if (effectiveNodes.length > 0) {
1076
- st.selectedNodeIds = effectiveNodes;
1077
- st.network.selectNodes(st.selectedNodeIds);
1223
+ function selectNodesFromClick(nodeId, srcEvent) {
1224
+ const additive = !!(srcEvent && (srcEvent.metaKey || srcEvent.ctrlKey));
1225
+ const clicked = expandSelectionToGroup([nodeId]).filter((id) => {
1226
+ const n = st.nodes.get(id);
1227
+ return n && !n.locked && n.shapeType !== 'anchor';
1228
+ });
1229
+ if (!clicked.length) return;
1230
+
1231
+ let nodeIds;
1232
+ if (additive) {
1233
+ const next = new Set(_pointerDownSelection.nodeIds || []);
1234
+ const allAlreadySelected = clicked.every((id) => next.has(id));
1235
+ clicked.forEach((id) => {
1236
+ if (allAlreadySelected) next.delete(id);
1237
+ else next.add(id);
1238
+ });
1239
+ nodeIds = Array.from(next).filter((id) => {
1240
+ const n = st.nodes.get(id);
1241
+ return n && !n.locked && n.shapeType !== 'anchor';
1242
+ });
1243
+ } else {
1244
+ nodeIds = clicked;
1245
+ }
1246
+
1247
+ const edgeIds = selectableEdgesForNodes(nodeIds);
1248
+ _addingEdgesToSelection = true;
1249
+ st.network.setSelection({ nodes: nodeIds, edges: edgeIds });
1250
+ _addingEdgesToSelection = false;
1251
+ st.selectedNodeIds = nodeIds;
1252
+ st.selectedEdgeIds = edgeIds;
1253
+ _rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
1254
+
1255
+ hideEdgePanel();
1256
+ if (nodeIds.length) showNodePanel();
1257
+ else {
1258
+ hideNodePanel();
1259
+ hideSelectionOverlay();
1260
+ }
1261
+ }
1262
+
1263
+ function onDoubleClick(params) {
1264
+ const srcEvent = params.event && params.event.srcEvent;
1265
+ const clientPos = srcEvent ? { x: srcEvent.clientX, y: srcEvent.clientY } : null;
1266
+ const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1267
+ const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1268
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1269
+ const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
1270
+ .filter((id, index, list) => id && list.indexOf(id) === index)
1271
+ .filter((id) => isEdgeInteractive(st.edges.get(id)));
1272
+ const edgeId = edgeCandidates.reduce((bestId, id) => {
1273
+ if (!bestId) return id;
1274
+ return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
1275
+ }, null);
1276
+
1277
+ const topNodeId = topmostNodeAt(params.pointer.canvas);
1278
+ const topNode = topNodeId && st.nodes.get(topNodeId);
1279
+ const canEditTopNode = topNode && !topNode.locked && topNode.shapeType !== 'anchor';
1280
+ const topNodeWins = canEditTopNode && !labelEdgeId && (!edgeId || st.canonicalOrder.indexOf(topNodeId) >= edgeDrawLevel(st.edges.get(edgeId)));
1281
+
1282
+ if (topNodeWins) {
1283
+ st.selectedNodeIds = [topNodeId];
1284
+ st.selectedEdgeIds = [];
1285
+ st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
1078
1286
  showNodePanel();
1287
+ hideEdgePanel();
1079
1288
  startLabelEdit();
1080
- } else if (params.edges.length > 0) {
1081
- st.selectedEdgeIds = [params.edges[0]];
1289
+ } else if (edgeId) {
1290
+ st.selectedNodeIds = [];
1291
+ st.selectedEdgeIds = [edgeId];
1292
+ st.network.setSelection({ nodes: [], edges: [edgeId] });
1293
+ hideNodePanel();
1082
1294
  showEdgePanel();
1083
1295
  startEdgeLabelEdit();
1084
1296
  } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
@@ -1229,8 +1441,8 @@ function onClickNode(params) {
1229
1441
  update.color = { color: 'rgba(0,0,0,0)', highlight: 'rgba(0,0,0,0)', hover: 'rgba(0,0,0,0)' };
1230
1442
  update.arrows = { to: { enabled: false }, from: { enabled: false } };
1231
1443
  }
1232
- st.edges.update(update);
1233
1444
  pushSnapshot();
1445
+ st.edges.update(update);
1234
1446
  markDirty();
1235
1447
  const edgeId = edgeData.id;
1236
1448
  _rehookHoveredNodeId = _rehookHoveredPortKey = null;
@@ -1244,14 +1456,34 @@ function onClickNode(params) {
1244
1456
  }
1245
1457
  }
1246
1458
 
1247
- // When a node is reported, params.edges is always empty — vis-network short-circuits
1459
+ // When a node is reported, params.edges is usually empty — vis-network short-circuits
1248
1460
  // edge detection once a node is found. Fix: call getEdgeAt() directly with CLIENT
1249
1461
  // coordinates. params.pointer.DOM is already offset-relative to the container, so
1250
1462
  // passing it to getEdgeAt() causes a double-subtraction of the container rect and
1251
1463
  // returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
1252
1464
  if (params.nodes.length > 0) {
1253
1465
  const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1254
- const edgeId = st.network.getEdgeAt(clientPos);
1466
+ const nativeEdgeId = st.network.getEdgeAt(clientPos);
1467
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1468
+ const edgeId = nativeEdgeId || (portEdge && portEdge.id);
1469
+
1470
+ // First honour the app-managed z-order. vis-network may report an edge or a
1471
+ // lower node at the click point; compare draw levels so the visibly top
1472
+ // element gets the click.
1473
+ const top = topmostNodeAt(params.pointer.canvas);
1474
+ if (top) {
1475
+ const topNode = st.nodes.get(top);
1476
+ const topLevel = st.canonicalOrder.indexOf(top);
1477
+ const edgeLevel = edgeId ? edgeDrawLevel(st.edges.get(edgeId)) : -1;
1478
+ if (topNode && !topNode.locked && topNode.shapeType !== 'anchor') {
1479
+ if (!edgeId || topLevel >= edgeLevel) {
1480
+ setTimeout(() => {
1481
+ selectNodesFromClick(top, params.event.srcEvent);
1482
+ }, 0);
1483
+ return;
1484
+ }
1485
+ }
1486
+ }
1255
1487
  if (edgeId) {
1256
1488
  const edge = st.edges.get(edgeId);
1257
1489
  const fromN = edge && st.nodes.get(edge.from);
@@ -1277,12 +1509,12 @@ function onClickNode(params) {
1277
1509
  return;
1278
1510
  }
1279
1511
  // No edge at click position — apply z-order correction for the topmost node.
1280
- const top = topmostNodeAt(params.pointer.canvas);
1512
+ const fallbackTop = topmostNodeAt(params.pointer.canvas);
1281
1513
  const clickable = params.nodes.filter(id => {
1282
1514
  const n = st.nodes.get(id);
1283
1515
  return n && !n.locked && n.shapeType !== 'anchor';
1284
1516
  });
1285
- if (!top || !clickable.includes(top)) {
1517
+ if (!fallbackTop || !clickable.includes(fallbackTop)) {
1286
1518
  const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
1287
1519
  if (fallbackEdgeId) {
1288
1520
  setTimeout(() => {
@@ -1305,19 +1537,12 @@ function onClickNode(params) {
1305
1537
  // Also check the canvas for port-edge proximity when nothing was hit.
1306
1538
  if (params.nodes.length === 0 && params.edges.length === 0) {
1307
1539
  const cp = params.pointer.canvas;
1308
- const THRESHOLD = 8;
1309
1540
 
1310
1541
  // ── Port edge proximity check ──────────────────────────────────────────
1311
1542
  // vis-network's hit detection uses the invisible centre-to-centre ghost,
1312
1543
  // so port edges that diverge visually from that path are not selectable.
1313
- // We scan all port edges and pick the one whose bezier path is closest.
1314
- const portEdges = st.edges.get({ filter: (e) => e.fromPort || e.toPort });
1315
- let nearest = null, nearestDist = Infinity;
1316
- for (const edge of portEdges) {
1317
- const d = distanceToPortEdge(edge, cp);
1318
- if (d < nearestDist) { nearestDist = d; nearest = edge; }
1319
- }
1320
- if (nearest && nearestDist <= THRESHOLD) {
1544
+ const nearest = nearestPortEdgeAt(cp);
1545
+ if (nearest) {
1321
1546
  st.network.selectEdges([nearest.id]);
1322
1547
  st.selectedEdgeIds = [nearest.id];
1323
1548
  st.selectedNodeIds = [];
@@ -21,6 +21,29 @@ function isEdgeLocked(edge) {
21
21
  return isFreeArrow ? !!(fromN.locked && toN.locked) : !!edge.edgeLocked;
22
22
  }
23
23
 
24
+ function hitInteractiveEdgeLabel(container, clientX, clientY) {
25
+ if (!st.network || !st.edgeLabelBBox || !st.selectedEdgeIds || st.selectedEdgeIds.length !== 1) return false;
26
+ const edgeId = st.selectedEdgeIds[0];
27
+ const edge = st.edges && st.edges.get(edgeId);
28
+ if (!edge || !edge.label) return false;
29
+ const bbox = st.edgeLabelBBox[edgeId];
30
+ if (!bbox) return false;
31
+
32
+ const rect = container.getBoundingClientRect();
33
+ const cp = st.network.DOMtoCanvas({ x: clientX - rect.left, y: clientY - rect.top });
34
+ const r = -(bbox.rotation || 0);
35
+ const dx = cp.x - bbox.cx;
36
+ const dy = cp.y - bbox.cy;
37
+ const lx = dx * Math.cos(r) - dy * Math.sin(r);
38
+ const ly = dx * Math.sin(r) + dy * Math.cos(r);
39
+
40
+ const handleRadius = 8 / st.network.getScale();
41
+ const onLeftHandle = Math.hypot(lx + bbox.w / 2, ly) <= handleRadius;
42
+ const onRightHandle = Math.hypot(lx - bbox.w / 2, ly) <= handleRadius;
43
+ const insideLabelBox = Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2;
44
+ return onLeftHandle || onRightHandle || insideLabelBox;
45
+ }
46
+
24
47
  // Returns a target descriptor ({type, id, ...}) if the DOM point lands on a
25
48
  // locked node or locked edge, otherwise null.
26
49
  function hitTestLocked(container, clientX, clientY) {
@@ -154,6 +177,7 @@ function onUp() {
154
177
  export function installUnlockHold(container) {
155
178
  container.addEventListener('mousedown', (e) => {
156
179
  if (e.button !== 0) return;
180
+ if (hitInteractiveEdgeLabel(container, e.clientX, e.clientY)) return;
157
181
  const target = hitTestLocked(container, e.clientX, e.clientY);
158
182
  if (!target) return;
159
183
 
@@ -1902,8 +1902,8 @@
1902
1902
  "groupId": null,
1903
1903
  "nodeLink": null,
1904
1904
  "locked": false,
1905
- "x": -173,
1906
- "y": 51
1905
+ "x": -164,
1906
+ "y": 84
1907
1907
  },
1908
1908
  {
1909
1909
  "id": "n1",
@@ -1996,50 +1996,50 @@
1996
1996
  "dashes": false,
1997
1997
  "fontSize": null,
1998
1998
  "labelRotation": 0,
1999
- "edgeLabelOffsetX": -31,
2000
- "edgeLabelOffsetY": -23,
2001
- "fromPort": null,
2002
- "toPort": null,
1999
+ "edgeLabelOffsetX": 31,
2000
+ "edgeLabelOffsetY": -22,
2001
+ "fromPort": "E",
2002
+ "toPort": "W",
2003
2003
  "edgeColor": null,
2004
2004
  "edgeWidth": null,
2005
2005
  "edgeLocked": false,
2006
2006
  "edgeLabelWidth": 209.01385498046875
2007
2007
  },
2008
2008
  {
2009
- "id": "e2",
2010
- "from": "n3",
2011
- "to": "n1",
2012
- "label": "browses and edits docs via",
2013
- "arrowDir": "to",
2009
+ "id": "e3",
2010
+ "from": "n1",
2011
+ "to": "n4",
2012
+ "label": "reads from / writes to",
2013
+ "arrowDir": "both",
2014
2014
  "dashes": false,
2015
2015
  "fontSize": null,
2016
2016
  "labelRotation": 0,
2017
- "edgeLabelOffsetX": -53,
2018
- "edgeLabelOffsetY": 45,
2019
- "fromPort": null,
2020
- "toPort": null,
2017
+ "edgeLabelOffsetX": 0,
2018
+ "edgeLabelOffsetY": 0,
2019
+ "fromPort": "S",
2020
+ "toPort": "N",
2021
2021
  "edgeColor": null,
2022
2022
  "edgeWidth": null,
2023
2023
  "edgeLocked": false,
2024
- "edgeLabelWidth": 209.98614501953125
2024
+ "edgeLabelWidth": 126.26385498046875
2025
2025
  },
2026
2026
  {
2027
- "id": "e3",
2028
- "from": "n1",
2029
- "to": "n4",
2030
- "label": "reads from / writes to",
2031
- "arrowDir": "both",
2027
+ "id": "e1777978776487",
2028
+ "from": "n3",
2029
+ "to": "n1",
2030
+ "label": "browses and edits docs via",
2031
+ "arrowDir": "to",
2032
2032
  "dashes": false,
2033
2033
  "fontSize": null,
2034
2034
  "labelRotation": 0,
2035
2035
  "edgeLabelOffsetX": 0,
2036
2036
  "edgeLabelOffsetY": 0,
2037
- "fromPort": null,
2038
- "toPort": null,
2037
+ "fromPort": "E",
2038
+ "toPort": "W",
2039
2039
  "edgeColor": null,
2040
2040
  "edgeWidth": null,
2041
2041
  "edgeLocked": false,
2042
- "edgeLabelWidth": 126.26385498046875
2042
+ "edgeLabelWidth": 178.28289794921875
2043
2043
  }
2044
2044
  ],
2045
2045
  "edgesStraight": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "7.35.0",
3
+ "version": "7.37.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {