living-documentation 7.36.0 → 7.38.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.
@@ -4,6 +4,7 @@
4
4
  import { st, markDirty } from './state.js';
5
5
  import { visEdgeProps } from './edge-rendering.js';
6
6
  import { pushSnapshot } from './history.js';
7
+ import { t } from './t.js';
7
8
 
8
9
  const DEFAULT_EDGE_COLOR = '#a8a29e';
9
10
  const FREE_ARROW_STYLE_KEY = 'ld-free-arrow-style';
@@ -33,12 +34,55 @@ export function getLastFreeArrowStyle() {
33
34
  catch { return {}; }
34
35
  }
35
36
 
37
+ function isEdgeLocked(edge) {
38
+ if (!edge) return false;
39
+ const fromN = st.nodes && st.nodes.get(edge.from);
40
+ const toN = st.nodes && st.nodes.get(edge.to);
41
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
42
+ return isFreeArrow ? !!(fromN.locked && toN.locked) : !!edge.edgeLocked;
43
+ }
44
+
45
+ function selectedEdgeLockState() {
46
+ const edgeIds = (st.selectedEdgeIds || []).filter((id) => st.edges && st.edges.get(id));
47
+ if (!edgeIds.length) return { allLocked: false, edgeIds };
48
+ return {
49
+ allLocked: edgeIds.every((id) => isEdgeLocked(st.edges.get(id))),
50
+ edgeIds,
51
+ };
52
+ }
53
+
54
+ function syncEdgeLockButton() {
55
+ const btn = document.getElementById('btnEdgeLock');
56
+ if (!btn) return;
57
+ const { allLocked } = selectedEdgeLockState();
58
+ btn.textContent = allLocked ? '🔓' : '🔒';
59
+ btn.title = t(allLocked ? 'diagram.edge_panel.unlock' : 'diagram.edge_panel.lock');
60
+ btn.setAttribute('aria-label', btn.title);
61
+ btn.classList.toggle('tool-active', allLocked);
62
+ }
63
+
64
+ function setEdgeLocked(edge, locked) {
65
+ if (!edge) return;
66
+ const fromN = st.nodes && st.nodes.get(edge.from);
67
+ const toN = st.nodes && st.nodes.get(edge.to);
68
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
69
+ if (isFreeArrow) {
70
+ [edge.from, edge.to].forEach((nodeId) => {
71
+ st.nodes.update({ id: nodeId, locked, fixed: locked ? { x: true, y: true } : false, draggable: !locked });
72
+ const bn = st.network && st.network.body.nodes[nodeId];
73
+ if (bn) bn.refreshNeeded = true;
74
+ });
75
+ } else {
76
+ st.edges.update({ id: edge.id, edgeLocked: locked });
77
+ }
78
+ }
79
+
36
80
  export function showEdgePanel() {
37
81
  if (!st.selectedEdgeIds.length) return;
38
82
  const e = st.edges.get(st.selectedEdgeIds[0]);
39
83
  if (!e) return;
40
84
 
41
- document.getElementById('btnEdgeLock').classList.remove('tool-active');
85
+ syncEdgeLockButton();
42
86
  document.getElementById('edgePanelControls').classList.remove('hidden');
43
87
 
44
88
  const dir = e.arrowDir ?? 'to';
@@ -69,33 +113,22 @@ export function showEdgePanel() {
69
113
  }
70
114
 
71
115
  export function toggleEdgeLock() {
72
- if (!st.selectedEdgeIds.length) return;
116
+ const { allLocked, edgeIds } = selectedEdgeLockState();
117
+ if (!edgeIds.length) return;
118
+ const nextLocked = !allLocked;
73
119
  pushSnapshot();
74
- // Locking is a one-way UI action — once locked, the only way back is the
75
- // long-press on the shape itself (see unlock-hold.js).
76
- st.selectedEdgeIds.forEach((id) => {
77
- const e = st.edges.get(id);
78
- if (!e) return;
79
- const fromN = st.nodes && st.nodes.get(e.from);
80
- const toN = st.nodes && st.nodes.get(e.to);
81
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
82
- if (isFreeArrow) {
83
- [e.from, e.to].forEach((nodeId) => {
84
- st.nodes.update({ id: nodeId, locked: true, fixed: { x: true, y: true }, draggable: false });
85
- const bn = st.network && st.network.body.nodes[nodeId];
86
- if (bn) bn.refreshNeeded = true;
87
- });
88
- } else {
89
- st.edges.update({ id, edgeLocked: true });
90
- }
91
- });
120
+ edgeIds.forEach((id) => setEdgeLocked(st.edges.get(id), nextLocked));
92
121
  if (st.network) {
93
- st.network.unselectAll();
94
122
  st.network.redraw();
123
+ if (nextLocked) st.network.unselectAll();
124
+ }
125
+ if (nextLocked) {
126
+ st.selectedNodeIds = [];
127
+ st.selectedEdgeIds = [];
128
+ hideEdgePanel();
129
+ } else {
130
+ syncEdgeLockButton();
95
131
  }
96
- st.selectedNodeIds = [];
97
- st.selectedEdgeIds = [];
98
- hideEdgePanel();
99
132
  markDirty();
100
133
  }
101
134
 
@@ -35,6 +35,7 @@ let _rehookEdgeId = null;
35
35
  let _rehookHoveredNodeId = null;
36
36
  let _rehookHoveredPortKey = null;
37
37
  let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
38
+ let _edgeLabelPointerAbort = null;
38
39
 
39
40
  // Returns true when an edge can enter rehook mode (at least one non-anchor endpoint).
40
41
  // Works for both port edges and native vis-network edges.
@@ -397,14 +398,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
397
398
  ctx.restore();
398
399
  });
399
400
 
400
- // Track hover during mousemove — computed before any mousedown fires.
401
- container.addEventListener('mousemove', (e) => {
401
+ function edgeLabelHitAt(clientX, clientY) {
402
402
  if (!st.network || !st.edgeLabelBBox) return;
403
- 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 });
404
405
  const hr = 8 / st.network.getScale();
405
406
 
406
407
  // ── Resize handle detection ──────────────────────────────────────────────
407
- let found = null;
408
408
  for (const edgeId of (st.selectedEdgeIds || [])) {
409
409
  const edge = st.edges && st.edges.get(edgeId);
410
410
  if (!edge || !edge.label) continue;
@@ -417,18 +417,12 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
417
417
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
418
418
 
419
419
  if (Math.hypot(lx - (-bbox.w / 2), ly) < hr || Math.hypot(lx - (bbox.w / 2), ly) < hr) {
420
- found = { edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
421
- break;
420
+ return { type: 'handle', edgeId, bboxCx: bbox.cx, bboxCy: bbox.cy, rotation: bbox.rotation || 0 };
422
421
  }
423
422
  }
424
- if (Boolean(found) !== Boolean(_hoverHandle)) {
425
- container.style.cursor = found ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
426
- }
427
- _hoverHandle = found;
428
423
 
429
- // ── Label box hover detection (drag) — only when no handle hovered ───────
430
- let labelFound = null;
431
- 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) {
432
426
  const edgeId = st.selectedEdgeIds[0];
433
427
  const edge = st.edges && st.edges.get(edgeId);
434
428
  if (edge && edge.label) {
@@ -439,39 +433,104 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
439
433
  const lx = dx * Math.cos(r) - dy * Math.sin(r);
440
434
  const ly = dx * Math.sin(r) + dy * Math.cos(r);
441
435
  if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
442
- labelFound = { edgeId };
436
+ return { type: 'label', edgeId };
443
437
  }
444
438
  }
445
439
  }
446
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;
447
457
  if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
448
458
  if (!found) container.style.cursor = labelFound ? 'grab' : '';
449
459
  }
450
460
  _hoverLabelDrag = labelFound;
451
461
  });
452
462
 
453
- // Mousedown: start resize (handles take priority) or label drag.
454
- container.addEventListener('mousedown', (e) => {
455
- if (e.button !== 0) return;
456
- 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 };
457
471
  _lr = { ..._hoverHandle, dragging: false };
458
472
  st.network.setOptions({ interaction: { dragView: false } });
459
- } else if (_hoverLabelDrag) {
460
- const edge = st.edges && st.edges.get(_hoverLabelDrag.edgeId);
461
- 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 };
462
480
  _ld = {
463
- edgeId: _hoverLabelDrag.edgeId,
481
+ edgeId: hit.edgeId,
464
482
  startMouse: { x: e.clientX, y: e.clientY },
465
483
  startOffsetX: edge.edgeLabelOffsetX || 0,
466
484
  startOffsetY: edge.edgeLabelOffsetY || 0,
467
485
  dragging: false,
468
486
  };
469
487
  st.network.setOptions({ interaction: { dragView: false } });
488
+ return true;
470
489
  }
471
- }, { 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 });
472
531
 
473
532
  // Update width (resize) or offset (label drag) while dragging.
474
- document.addEventListener('mousemove', (e) => {
533
+ function onEdgeLabelPointerMove(e) {
475
534
  if (!st.network) return;
476
535
  if (_lr) {
477
536
  if (!_lr.dragging) { _lr.dragging = true; pushSnapshot(); }
@@ -497,10 +556,12 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
497
556
  });
498
557
  st.network.redraw();
499
558
  }
500
- });
559
+ }
560
+ document.addEventListener('pointermove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
561
+ document.addEventListener('mousemove', onEdgeLabelPointerMove, { signal: _edgeLabelPointerAbort.signal });
501
562
 
502
563
  // Commit on mouseup.
503
- document.addEventListener('mouseup', () => {
564
+ function onEdgeLabelPointerUp() {
504
565
  if (_lr) {
505
566
  st.network.setOptions({ interaction: { dragView: true } });
506
567
  if (_lr.dragging) markDirty();
@@ -512,7 +573,9 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
512
573
  _ld = null;
513
574
  }
514
575
  container.style.cursor = _hoverHandle ? 'ew-resize' : (_hoverLabelDrag ? 'grab' : '');
515
- });
576
+ }
577
+ document.addEventListener('pointerup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
578
+ document.addEventListener('mouseup', onEdgeLabelPointerUp, { signal: _edgeLabelPointerAbort.signal });
516
579
  }
517
580
 
518
581
  // ── Free-arrow body drag ──────────────────────────────────────────────────────
@@ -1115,14 +1178,45 @@ function nearestPortEdgeAt(canvasPos, threshold = 8) {
1115
1178
  return nearest && nearestDist <= threshold ? nearest : null;
1116
1179
  }
1117
1180
 
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
+ }
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
+
1118
1215
  function selectableEdgesForNodes(nodeIds) {
1119
1216
  const selectedSet = new Set(nodeIds);
1120
1217
  return st.edges.get().filter((e) => {
1121
1218
  if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
1122
- const fromN = st.nodes.get(e.from);
1123
- const toN = st.nodes.get(e.to);
1124
- const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
1125
- return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
1219
+ return isEdgeInteractive(e);
1126
1220
  }).map((e) => e.id);
1127
1221
  }
1128
1222
 
@@ -1167,25 +1261,36 @@ function selectNodesFromClick(nodeId, srcEvent) {
1167
1261
  }
1168
1262
 
1169
1263
  function onDoubleClick(params) {
1170
- // Locked nodes never intercept double-click — filter them out first.
1171
- const unlockedNodes = params.nodes.filter(id => { const n = st.nodes.get(id); return n && !n.locked; });
1172
-
1173
- // When nodes and edges both match the click position, honour z-order:
1174
- // only give the click to a node if it is truly the topmost element there.
1175
- // Otherwise fall through to edge handling.
1176
- let effectiveNodes = unlockedNodes;
1177
- if (unlockedNodes.length > 0 && params.edges.length > 0) {
1178
- const top = topmostNodeAt(params.pointer.canvas);
1179
- effectiveNodes = (top && unlockedNodes.includes(top)) ? [top] : [];
1180
- }
1181
-
1182
- if (effectiveNodes.length > 0) {
1183
- st.selectedNodeIds = effectiveNodes;
1184
- st.network.selectNodes(st.selectedNodeIds);
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: [] });
1185
1286
  showNodePanel();
1287
+ hideEdgePanel();
1186
1288
  startLabelEdit();
1187
- } else if (params.edges.length > 0) {
1188
- 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();
1189
1294
  showEdgePanel();
1190
1295
  startEdgeLabelEdit();
1191
1296
  } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
@@ -4,6 +4,7 @@
4
4
  import { st, markDirty } from './state.js';
5
5
  import { SHAPE_DEFAULTS } from './node-rendering.js';
6
6
  import { pushSnapshot } from './history.js';
7
+ import { t } from './t.js';
7
8
 
8
9
  // ── Last-used style persistence (per shape type) ──────────────────────────────
9
10
  // Saves colorKey/fontSize/textAlign/textValign per shapeType to localStorage so
@@ -38,10 +39,61 @@ function forceRedraw() {
38
39
  if (st.network) st.network.redraw();
39
40
  }
40
41
 
42
+ function isEdgeLocked(edge) {
43
+ if (!edge) return false;
44
+ const fromN = st.nodes && st.nodes.get(edge.from);
45
+ const toN = st.nodes && st.nodes.get(edge.to);
46
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
47
+ return isFreeArrow ? !!(fromN.locked && toN.locked) : !!(edge.edgeLocked || (fromN && fromN.locked && toN && toN.locked));
48
+ }
49
+
50
+ function selectedLockState() {
51
+ const nodeIds = (st.selectedNodeIds || []).filter((id) => {
52
+ const n = st.nodes && st.nodes.get(id);
53
+ return n && n.shapeType !== 'anchor';
54
+ });
55
+ const edgeIds = (st.selectedEdgeIds || []).filter((id) => st.edges && st.edges.get(id));
56
+ const total = nodeIds.length + edgeIds.length;
57
+ if (!total) return { allLocked: false, nodeIds, edgeIds };
58
+
59
+ const nodesLocked = nodeIds.every((id) => {
60
+ const n = st.nodes.get(id);
61
+ return !!(n && n.locked);
62
+ });
63
+ const edgesLocked = edgeIds.every((id) => isEdgeLocked(st.edges.get(id)));
64
+ return { allLocked: nodesLocked && edgesLocked, nodeIds, edgeIds };
65
+ }
66
+
67
+ function syncNodeLockButton() {
68
+ const btn = document.getElementById('btnNodeLock');
69
+ if (!btn) return;
70
+ const { allLocked } = selectedLockState();
71
+ btn.textContent = allLocked ? '🔓' : '🔒';
72
+ btn.title = t(allLocked ? 'diagram.node_panel.unlock' : 'diagram.node_panel.lock');
73
+ btn.setAttribute('aria-label', btn.title);
74
+ btn.classList.toggle('tool-active', allLocked);
75
+ }
76
+
77
+ function setEdgeLocked(edge, locked) {
78
+ if (!edge) return;
79
+ const fromN = st.nodes && st.nodes.get(edge.from);
80
+ const toN = st.nodes && st.nodes.get(edge.to);
81
+ const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
82
+ if (isFreeArrow) {
83
+ [edge.from, edge.to].forEach((nodeId) => {
84
+ st.nodes.update({ id: nodeId, locked, fixed: locked ? { x: true, y: true } : false, draggable: !locked });
85
+ const bn = st.network && st.network.body.nodes[nodeId];
86
+ if (bn) bn.refreshNeeded = true;
87
+ });
88
+ } else {
89
+ st.edges.update({ id: edge.id, edgeLocked: locked });
90
+ }
91
+ }
92
+
41
93
  export function showNodePanel() {
42
94
  document.getElementById('nodePanel').classList.remove('hidden');
43
95
  document.getElementById('nodePanelControls').classList.remove('hidden');
44
- document.getElementById('btnNodeLock').classList.remove('tool-active');
96
+ syncNodeLockButton();
45
97
  // Sync the opacity slider with the first selected node's current value so the
46
98
  // slider reflects the live state rather than whatever position it was left at.
47
99
  const slider = document.getElementById('nodeBgOpacity');
@@ -57,22 +109,27 @@ export function hideNodePanel() {
57
109
  }
58
110
 
59
111
  export function toggleNodeLock() {
60
- if (!st.selectedNodeIds.length) return;
112
+ const { allLocked, nodeIds, edgeIds } = selectedLockState();
113
+ if (!nodeIds.length && !edgeIds.length) return;
114
+ const nextLocked = !allLocked;
61
115
  pushSnapshot();
62
- // Locking is a one-way UI action — once locked, the only way back is the
63
- // long-press on the shape itself (see unlock-hold.js).
64
- st.selectedNodeIds.forEach((id) => {
65
- st.nodes.update({ id, locked: true, fixed: { x: true, y: true }, draggable: false });
116
+ nodeIds.forEach((id) => {
117
+ st.nodes.update({ id, locked: nextLocked, fixed: nextLocked ? { x: true, y: true } : false, draggable: !nextLocked });
66
118
  const bn = st.network && st.network.body.nodes[id];
67
119
  if (bn) bn.refreshNeeded = true;
68
120
  });
121
+ edgeIds.forEach((id) => setEdgeLocked(st.edges.get(id), nextLocked));
69
122
  if (st.network) {
70
- st.network.unselectAll();
71
123
  st.network.redraw();
124
+ if (nextLocked) st.network.unselectAll();
125
+ }
126
+ if (nextLocked) {
127
+ st.selectedNodeIds = [];
128
+ st.selectedEdgeIds = [];
129
+ hideNodePanel();
130
+ } else {
131
+ syncNodeLockButton();
72
132
  }
73
- st.selectedNodeIds = [];
74
- st.selectedEdgeIds = [];
75
- hideNodePanel();
76
133
  markDirty();
77
134
  }
78
135
 
@@ -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
 
@@ -428,7 +428,8 @@
428
428
  "diagram.sidebar.empty": "No diagrams",
429
429
  "diagram.sidebar.delete_title": "Delete",
430
430
 
431
- "diagram.node_panel.lock": "Lock shape",
431
+ "diagram.node_panel.lock": "Lock selection",
432
+ "diagram.node_panel.unlock": "Unlock selection",
432
433
  "diagram.node_panel.edit_label": "Edit text (double-click)",
433
434
  "diagram.node_panel.edit_link": "Add / edit link",
434
435
  "diagram.node_panel.bg_opacity": "Background opacity",
@@ -461,6 +462,7 @@
461
462
  "diagram.link_panel.remove_btn": "Remove",
462
463
 
463
464
  "diagram.edge_panel.lock": "Lock arrow",
465
+ "diagram.edge_panel.unlock": "Unlock arrow",
464
466
  "diagram.edge_panel.no_arrow": "Simple line",
465
467
  "diagram.edge_panel.arrow_to": "Directional arrow",
466
468
  "diagram.edge_panel.arrow_both": "Bidirectional",
@@ -428,7 +428,8 @@
428
428
  "diagram.sidebar.empty": "Aucun diagramme",
429
429
  "diagram.sidebar.delete_title": "Supprimer",
430
430
 
431
- "diagram.node_panel.lock": "Verrouiller la forme",
431
+ "diagram.node_panel.lock": "Verrouiller la sélection",
432
+ "diagram.node_panel.unlock": "Déverrouiller la sélection",
432
433
  "diagram.node_panel.edit_label": "Modifier le texte (double-clic)",
433
434
  "diagram.node_panel.edit_link": "Ajouter / modifier un lien",
434
435
  "diagram.node_panel.bg_opacity": "Opacité du fond",
@@ -461,6 +462,7 @@
461
462
  "diagram.link_panel.remove_btn": "Retirer",
462
463
 
463
464
  "diagram.edge_panel.lock": "Verrouiller la flèche",
465
+ "diagram.edge_panel.unlock": "Déverrouiller la flèche",
464
466
  "diagram.edge_panel.no_arrow": "Trait simple",
465
467
  "diagram.edge_panel.arrow_to": "Flèche directionnelle",
466
468
  "diagram.edge_panel.arrow_both": "Bidirectionnelle",
@@ -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.36.0",
3
+ "version": "7.38.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {