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.
- package/dist/src/frontend/diagram/edge-panel.js +57 -24
- package/dist/src/frontend/diagram/network.js +153 -48
- package/dist/src/frontend/diagram/node-panel.js +67 -10
- package/dist/src/frontend/diagram/unlock-hold.js +24 -0
- package/dist/src/frontend/i18n/en.json +3 -1
- package/dist/src/frontend/i18n/fr.json +3 -1
- package/dist/starting-doc/.diagrams.json +24 -24
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
116
|
+
const { allLocked, edgeIds } = selectedEdgeLockState();
|
|
117
|
+
if (!edgeIds.length) return;
|
|
118
|
+
const nextLocked = !allLocked;
|
|
73
119
|
pushSnapshot();
|
|
74
|
-
|
|
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
|
-
|
|
401
|
-
container.addEventListener('mousemove', (e) => {
|
|
401
|
+
function edgeLabelHitAt(clientX, clientY) {
|
|
402
402
|
if (!st.network || !st.edgeLabelBBox) return;
|
|
403
|
-
const
|
|
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
|
-
|
|
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
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
if (e.
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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 (
|
|
1188
|
-
st.
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
const { allLocked, nodeIds, edgeIds } = selectedLockState();
|
|
113
|
+
if (!nodeIds.length && !edgeIds.length) return;
|
|
114
|
+
const nextLocked = !allLocked;
|
|
61
115
|
pushSnapshot();
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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": -
|
|
1906
|
-
"y":
|
|
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":
|
|
2000
|
-
"edgeLabelOffsetY": -
|
|
2001
|
-
"fromPort":
|
|
2002
|
-
"toPort":
|
|
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": "
|
|
2010
|
-
"from": "
|
|
2011
|
-
"to": "
|
|
2012
|
-
"label": "
|
|
2013
|
-
"arrowDir": "
|
|
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":
|
|
2018
|
-
"edgeLabelOffsetY":
|
|
2019
|
-
"fromPort":
|
|
2020
|
-
"toPort":
|
|
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":
|
|
2024
|
+
"edgeLabelWidth": 126.26385498046875
|
|
2025
2025
|
},
|
|
2026
2026
|
{
|
|
2027
|
-
"id": "
|
|
2028
|
-
"from": "
|
|
2029
|
-
"to": "
|
|
2030
|
-
"label": "
|
|
2031
|
-
"arrowDir": "
|
|
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":
|
|
2038
|
-
"toPort":
|
|
2037
|
+
"fromPort": "E",
|
|
2038
|
+
"toPort": "W",
|
|
2039
2039
|
"edgeColor": null,
|
|
2040
2040
|
"edgeWidth": null,
|
|
2041
2041
|
"edgeLocked": false,
|
|
2042
|
-
"edgeLabelWidth":
|
|
2042
|
+
"edgeLabelWidth": 178.28289794921875
|
|
2043
2043
|
}
|
|
2044
2044
|
],
|
|
2045
2045
|
"edgesStraight": false,
|