living-documentation 7.0.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/LICENSE +661 -0
- package/README.md +329 -0
- package/dist/bin/cli.d.ts +3 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +62 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/src/frontend/admin.html +1073 -0
- package/dist/src/frontend/annotations.js +546 -0
- package/dist/src/frontend/boot.js +90 -0
- package/dist/src/frontend/config.js +19 -0
- package/dist/src/frontend/dark-mode.js +20 -0
- package/dist/src/frontend/diagram/alignment.js +161 -0
- package/dist/src/frontend/diagram/clipboard.js +172 -0
- package/dist/src/frontend/diagram/constants.js +109 -0
- package/dist/src/frontend/diagram/debug.js +43 -0
- package/dist/src/frontend/diagram/edge-panel.js +260 -0
- package/dist/src/frontend/diagram/edge-rendering.js +12 -0
- package/dist/src/frontend/diagram/grid.js +78 -0
- package/dist/src/frontend/diagram/groups.js +102 -0
- package/dist/src/frontend/diagram/history.js +153 -0
- package/dist/src/frontend/diagram/image-name-modal.js +48 -0
- package/dist/src/frontend/diagram/image-upload.js +36 -0
- package/dist/src/frontend/diagram/label-editor.js +115 -0
- package/dist/src/frontend/diagram/link-panel.js +144 -0
- package/dist/src/frontend/diagram/main.js +299 -0
- package/dist/src/frontend/diagram/network.js +1473 -0
- package/dist/src/frontend/diagram/node-panel.js +267 -0
- package/dist/src/frontend/diagram/node-rendering.js +773 -0
- package/dist/src/frontend/diagram/persistence.js +161 -0
- package/dist/src/frontend/diagram/ports.js +386 -0
- package/dist/src/frontend/diagram/selection-overlay.js +336 -0
- package/dist/src/frontend/diagram/state.js +39 -0
- package/dist/src/frontend/diagram/t.js +3 -0
- package/dist/src/frontend/diagram/toast.js +21 -0
- package/dist/src/frontend/diagram/unlock-hold.js +182 -0
- package/dist/src/frontend/diagram/zoom.js +20 -0
- package/dist/src/frontend/diagram-link-modal.js +137 -0
- package/dist/src/frontend/diagram.html +1279 -0
- package/dist/src/frontend/documents.js +373 -0
- package/dist/src/frontend/export.js +338 -0
- package/dist/src/frontend/i18n/en.json +406 -0
- package/dist/src/frontend/i18n/fr.json +406 -0
- package/dist/src/frontend/i18n.js +32 -0
- package/dist/src/frontend/image-paste.js +101 -0
- package/dist/src/frontend/index.html +2314 -0
- package/dist/src/frontend/misc.js +25 -0
- package/dist/src/frontend/new-doc-modal.js +260 -0
- package/dist/src/frontend/new-folder-modal.js +174 -0
- package/dist/src/frontend/search.js +157 -0
- package/dist/src/frontend/sidebar-helpers.js +58 -0
- package/dist/src/frontend/sidebar.js +182 -0
- package/dist/src/frontend/snippet-detect.js +25 -0
- package/dist/src/frontend/snippet-table.js +85 -0
- package/dist/src/frontend/snippet-tree.js +94 -0
- package/dist/src/frontend/snippets.js +534 -0
- package/dist/src/frontend/state.js +28 -0
- package/dist/src/frontend/utils.js +21 -0
- package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
- package/dist/src/frontend/wordcloud.js +693 -0
- package/dist/src/lib/config.d.ts +17 -0
- package/dist/src/lib/config.d.ts.map +1 -0
- package/dist/src/lib/config.js +79 -0
- package/dist/src/lib/config.js.map +1 -0
- package/dist/src/lib/parser.d.ts +11 -0
- package/dist/src/lib/parser.d.ts.map +1 -0
- package/dist/src/lib/parser.js +111 -0
- package/dist/src/lib/parser.js.map +1 -0
- package/dist/src/mcp/server.d.ts +3 -0
- package/dist/src/mcp/server.d.ts.map +1 -0
- package/dist/src/mcp/server.js +986 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/mcp/tools/diagrams.d.ts +44 -0
- package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
- package/dist/src/mcp/tools/diagrams.js +245 -0
- package/dist/src/mcp/tools/diagrams.js.map +1 -0
- package/dist/src/mcp/tools/documents.d.ts +26 -0
- package/dist/src/mcp/tools/documents.d.ts.map +1 -0
- package/dist/src/mcp/tools/documents.js +127 -0
- package/dist/src/mcp/tools/documents.js.map +1 -0
- package/dist/src/mcp/tools/source.d.ts +29 -0
- package/dist/src/mcp/tools/source.d.ts.map +1 -0
- package/dist/src/mcp/tools/source.js +200 -0
- package/dist/src/mcp/tools/source.js.map +1 -0
- package/dist/src/routes/annotations.d.ts +3 -0
- package/dist/src/routes/annotations.d.ts.map +1 -0
- package/dist/src/routes/annotations.js +83 -0
- package/dist/src/routes/annotations.js.map +1 -0
- package/dist/src/routes/browse.d.ts +3 -0
- package/dist/src/routes/browse.d.ts.map +1 -0
- package/dist/src/routes/browse.js +75 -0
- package/dist/src/routes/browse.js.map +1 -0
- package/dist/src/routes/config.d.ts +3 -0
- package/dist/src/routes/config.d.ts.map +1 -0
- package/dist/src/routes/config.js +97 -0
- package/dist/src/routes/config.js.map +1 -0
- package/dist/src/routes/diagrams.d.ts +3 -0
- package/dist/src/routes/diagrams.d.ts.map +1 -0
- package/dist/src/routes/diagrams.js +69 -0
- package/dist/src/routes/diagrams.js.map +1 -0
- package/dist/src/routes/documents.d.ts +8 -0
- package/dist/src/routes/documents.d.ts.map +1 -0
- package/dist/src/routes/documents.js +332 -0
- package/dist/src/routes/documents.js.map +1 -0
- package/dist/src/routes/export.d.ts +3 -0
- package/dist/src/routes/export.d.ts.map +1 -0
- package/dist/src/routes/export.js +277 -0
- package/dist/src/routes/export.js.map +1 -0
- package/dist/src/routes/images.d.ts +3 -0
- package/dist/src/routes/images.d.ts.map +1 -0
- package/dist/src/routes/images.js +49 -0
- package/dist/src/routes/images.js.map +1 -0
- package/dist/src/routes/wordcloud.d.ts +3 -0
- package/dist/src/routes/wordcloud.d.ts.map +1 -0
- package/dist/src/routes/wordcloud.js +95 -0
- package/dist/src/routes/wordcloud.js.map +1 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +76 -0
- package/dist/src/server.js.map +1 -0
- package/dist/starting-doc/.annotations.json +3 -0
- package/dist/starting-doc/.diagrams.json +1884 -0
- package/dist/starting-doc/.living-doc.json +39 -0
- package/dist/starting-doc/1_tutorial/2026_04_11_13_25_[General]_crer_vos_dossiers.md +16 -0
- package/dist/starting-doc/1_tutorial/2026_04_11_18_58_[General]_creer_un_document_dans_un_dossier.md +9 -0
- package/dist/starting-doc/1_tutorial/2026_04_12_09_00_[General]_editer_et_sauvegarder.md +39 -0
- package/dist/starting-doc/1_tutorial/2026_04_12_10_00_[General]_utiliser_les_snippets.md +71 -0
- package/dist/starting-doc/2026_04_08_20_52_[General]_welcome.md +17 -0
- package/dist/starting-doc/2026_04_11_12_55_[General]_premiers_pas.md +271 -0
- package/dist/starting-doc/2_guide/2026_04_08_00_04_[DOCUMENT]_utilisation_des_images_plein_ecran_lien_clickable.md +40 -0
- package/dist/starting-doc/2_guide/2026_04_08_23_38_[Configuration]_demarrage_de_living_documentation.md +32 -0
- package/dist/starting-doc/2_guide/2026_04_09_09_00_[NAVIGATION]_recherche_plein_texte.md +65 -0
- package/dist/starting-doc/2_guide/2026_04_09_10_00_[EXPORT]_exporter_en_pdf.md +43 -0
- package/dist/starting-doc/2_guide/2026_04_09_11_00_[Configuration]_configurer_le_panneau_admin.md +55 -0
- package/dist/starting-doc/2_guide/2026_04_09_12_00_[Configuration]_extra_files.md +68 -0
- package/dist/starting-doc/2_guide/2026_04_09_13_00_[WORDCLOUD]_word_cloud.md +54 -0
- package/dist/starting-doc/2_guide/2026_04_09_14_00_[DIAGRAM]_creer_et_lier_un_diagramme.md +77 -0
- package/dist/starting-doc/3_concept/2026_04_08_20_58_[DOCUMENTING]_ADRS.md +20 -0
- package/dist/starting-doc/3_concept/2026_04_08_22_15_[DOCUMENTING]_living_documentation.md +17 -0
- package/dist/starting-doc/3_concept/2026_04_08_22_46_[METHODOLOGY]_diataxis_architecture_du_contenu.md +16 -0
- package/dist/starting-doc/4_reference/2026_04_08_23_14_[FUNDAMENTALS]_the_living_documentation_tool.md +41 -0
- package/dist/starting-doc/4_reference/2026_04_09_01_00_[REFERENCE]_raccourcis_clavier.md +61 -0
- package/dist/starting-doc/4_reference/2026_04_09_02_00_[REFERENCE]_tokens_pattern_nommage.md +75 -0
- package/dist/starting-doc/4_reference/2026_04_09_03_00_[REFERENCE]_types_de_snippets.md +68 -0
- package/dist/starting-doc/4_reference/2026_04_11_17_31_[FUNDAMENTALS]_architecturer_une_documentation.md +12 -0
- package/dist/starting-doc/4_reference/2026_04_12_14_07_[FUNDAMENTALS]_dossiers_et_catgories.md +89 -0
- package/dist/starting-doc/images/admin_screenshot.png +0 -0
- package/dist/starting-doc/images/ajout-document.png +0 -0
- package/dist/starting-doc/images/ajouter-document-categorie.png +0 -0
- package/dist/starting-doc/images/ajouter_un_document_dans_un_dossier.png +0 -0
- package/dist/starting-doc/images/architecturer_une_documentation_reference.png +0 -0
- package/dist/starting-doc/images/cr_er_un_document.png +0 -0
- package/dist/starting-doc/images/creation-nouveau-dossier.png +0 -0
- package/dist/starting-doc/images/creer-document-context-engineering.png +0 -0
- package/dist/starting-doc/images/creer-dossier-only-tutoriel.png +0 -0
- package/dist/starting-doc/images/creer-dossier-tutoriel.png +0 -0
- package/dist/starting-doc/images/creer-dossiers-done.png +0 -0
- package/dist/starting-doc/images/creer-un-document.png +0 -0
- package/dist/starting-doc/images/creer-vos-dossiers-tutoriel.png +0 -0
- package/dist/starting-doc/images/creer-vos-dossiers.png +0 -0
- package/dist/starting-doc/images/decouverte_adrs.png +0 -0
- package/dist/starting-doc/images/diataxis.png +0 -0
- package/dist/starting-doc/images/diataxis_callout.png +0 -0
- package/dist/starting-doc/images/document-cree.png +0 -0
- package/dist/starting-doc/images/liens_snippets.png +0 -0
- package/dist/starting-doc/images/living_documentation.png +0 -0
- package/dist/starting-doc/images/npm_logo.png +0 -0
- package/dist/starting-doc/images/popup-creer-document.png +0 -0
- package/dist/starting-doc/images/popup-creer-dossier.png +0 -0
- package/dist/starting-doc/images/popup-dossier-cree.png +0 -0
- package/dist/starting-doc/images/quatre-dossiers-crees.png +0 -0
- package/dist/starting-doc/images/screenshot-living-doc.png +0 -0
- package/dist/starting-doc/images/the_living_documentation_tool.png +0 -0
- package/package.json +49 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// ── Selection / resize / rotate overlay ───────────────────────────────────────
|
|
2
|
+
// Dashed selection box, corner resize handles, and top-centre rotation handle.
|
|
3
|
+
|
|
4
|
+
import { st, markDirty } from './state.js';
|
|
5
|
+
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
6
|
+
import { t } from './t.js';
|
|
7
|
+
import { pushSnapshot } from './history.js';
|
|
8
|
+
import { snapToGrid } from './grid.js';
|
|
9
|
+
|
|
10
|
+
// ── Bounding box helper (works for all shapes including ctxRenderer) ──────────
|
|
11
|
+
function nodeBounds(id) {
|
|
12
|
+
const n = st.nodes.get(id);
|
|
13
|
+
const bodyNode = st.network.body.nodes[id];
|
|
14
|
+
if (!bodyNode) return null;
|
|
15
|
+
const cx = bodyNode.x, cy = bodyNode.y;
|
|
16
|
+
const shape = n && n.shapeType || 'box';
|
|
17
|
+
const defaults = SHAPE_DEFAULTS[shape] || [60, 28];
|
|
18
|
+
const W = (n && n.nodeWidth) || defaults[0];
|
|
19
|
+
const H = (n && n.nodeHeight) || defaults[1];
|
|
20
|
+
// Use the axis-aligned envelope of the (possibly rotated) bounding box.
|
|
21
|
+
const rot = (n && n.rotation) || 0;
|
|
22
|
+
if (rot === 0) {
|
|
23
|
+
return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
|
|
24
|
+
}
|
|
25
|
+
const cos = Math.abs(Math.cos(rot));
|
|
26
|
+
const sin = Math.abs(Math.sin(rot));
|
|
27
|
+
const hw = (W * cos + H * sin) / 2;
|
|
28
|
+
const hh = (W * sin + H * cos) / 2;
|
|
29
|
+
// Actor: head extends above cy - H/2 when unrotated
|
|
30
|
+
const headExtra = shape === 'actor' ? (28 * (H / 52) - H / 2) : 0;
|
|
31
|
+
return { minX: cx - hw, minY: cy - hh - headExtra, maxX: cx + hw, maxY: cy + hh };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Overlay position ──────────────────────────────────────────────────────────
|
|
35
|
+
export function updateSelectionOverlay() {
|
|
36
|
+
if (!st.network || !st.selectedNodeIds.length) { hideSelectionOverlay(); return; }
|
|
37
|
+
// Anchor-only selections (free arrow drag) have no meaningful bounding box.
|
|
38
|
+
const hasNonAnchor = st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
|
|
39
|
+
if (!hasNonAnchor) { hideSelectionOverlay(); return; }
|
|
40
|
+
|
|
41
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
42
|
+
for (const id of st.selectedNodeIds) {
|
|
43
|
+
try {
|
|
44
|
+
const b = nodeBounds(id);
|
|
45
|
+
if (!b) continue;
|
|
46
|
+
minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
|
|
47
|
+
maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
|
|
48
|
+
} catch (_) { /* node still being created */ }
|
|
49
|
+
}
|
|
50
|
+
if (minX === Infinity) { hideSelectionOverlay(); return; }
|
|
51
|
+
|
|
52
|
+
const PAD = 10;
|
|
53
|
+
const tl = st.network.canvasToDOM({ x: minX, y: minY });
|
|
54
|
+
const br = st.network.canvasToDOM({ x: maxX, y: maxY });
|
|
55
|
+
const ov = document.getElementById('selectionOverlay');
|
|
56
|
+
ov.style.display = 'block';
|
|
57
|
+
ov.style.left = tl.x - PAD + 'px';
|
|
58
|
+
ov.style.top = tl.y - PAD + 'px';
|
|
59
|
+
ov.style.width = br.x - tl.x + PAD * 2 + 'px';
|
|
60
|
+
ov.style.height = br.y - tl.y + PAD * 2 + 'px';
|
|
61
|
+
|
|
62
|
+
// Hide resize/rotate handles when any selected node is locked.
|
|
63
|
+
const anyLocked = st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return n && n.locked; });
|
|
64
|
+
['rh-tl','rh-tr','rh-bl','rh-br','rh-rotate','rh-label-rotate'].forEach((id) => {
|
|
65
|
+
document.getElementById(id).style.display = anyLocked ? 'none' : '';
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Position rotation handle: top-centre of the overlay, 28px above it.
|
|
69
|
+
const rh = document.getElementById('rh-rotate');
|
|
70
|
+
rh.style.left = (br.x - tl.x) / 2 + PAD - 8 + 'px';
|
|
71
|
+
rh.style.top = '-28px';
|
|
72
|
+
|
|
73
|
+
// Position label rotation handle: top-centre offset left by 24px to avoid overlap.
|
|
74
|
+
const lrh = document.getElementById('rh-label-rotate');
|
|
75
|
+
lrh.style.left = (br.x - tl.x) / 2 + PAD - 8 - 24 + 'px';
|
|
76
|
+
lrh.style.top = '-28px';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hideSelectionOverlay() {
|
|
80
|
+
document.getElementById('selectionOverlay').style.display = 'none';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Resize ────────────────────────────────────────────────────────────────────
|
|
84
|
+
function onResizeStart(e, corner) {
|
|
85
|
+
if (!st.selectedNodeIds.length || !st.network) return;
|
|
86
|
+
// Skip if any selected node is locked.
|
|
87
|
+
if (st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return n && n.locked; })) return;
|
|
88
|
+
pushSnapshot();
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
|
|
92
|
+
const positions = st.network.getPositions(st.selectedNodeIds);
|
|
93
|
+
const startBBs = st.selectedNodeIds.map((id) => {
|
|
94
|
+
const n = st.nodes.get(id);
|
|
95
|
+
const b = nodeBounds(id);
|
|
96
|
+
const shape = (n && n.shapeType) || 'box';
|
|
97
|
+
const defaults = SHAPE_DEFAULTS[shape] || [60, 28];
|
|
98
|
+
const initW = (n && n.nodeWidth) || (b ? Math.round(b.maxX - b.minX) : defaults[0]);
|
|
99
|
+
const initH = (n && n.nodeHeight) || (b ? Math.round(b.maxY - b.minY) : defaults[1]);
|
|
100
|
+
const pos = positions[id] || { x: 0, y: 0 };
|
|
101
|
+
return { id, node: n, initW, initH, initX: pos.x, initY: pos.y };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// True bounding box of the whole selection (for scale reference and pivot)
|
|
105
|
+
let bbMinX = Infinity, bbMinY = Infinity, bbMaxX = -Infinity, bbMaxY = -Infinity;
|
|
106
|
+
for (const { id } of startBBs) {
|
|
107
|
+
const b = nodeBounds(id);
|
|
108
|
+
if (!b) continue;
|
|
109
|
+
bbMinX = Math.min(bbMinX, b.minX); bbMinY = Math.min(bbMinY, b.minY);
|
|
110
|
+
bbMaxX = Math.max(bbMaxX, b.maxX); bbMaxY = Math.max(bbMaxY, b.maxY);
|
|
111
|
+
}
|
|
112
|
+
const initBoxW = (bbMaxX - bbMinX) || 1;
|
|
113
|
+
const initBoxH = (bbMaxY - bbMinY) || 1;
|
|
114
|
+
|
|
115
|
+
st.resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH, bbMinX, bbMinY, bbMaxX, bbMaxY };
|
|
116
|
+
st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: true }));
|
|
117
|
+
document.getElementById('vis-canvas').style.pointerEvents = 'none';
|
|
118
|
+
document.addEventListener('mousemove', onResizeDrag);
|
|
119
|
+
document.addEventListener('mouseup', onResizeEnd);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onResizeDrag(e) {
|
|
123
|
+
if (!st.resizeDrag || !st.network) return;
|
|
124
|
+
const scale = st.network.getScale();
|
|
125
|
+
const cdx = (e.clientX - st.resizeDrag.startMouse.x) / scale;
|
|
126
|
+
const cdy = (e.clientY - st.resizeDrag.startMouse.y) / scale;
|
|
127
|
+
const MIN = 20;
|
|
128
|
+
const c = st.resizeDrag.corner;
|
|
129
|
+
const updatedIds = [];
|
|
130
|
+
|
|
131
|
+
if (st.resizeDrag.startBBs.length === 1) {
|
|
132
|
+
const { id, node, initW, initH, initX, initY } = st.resizeDrag.startBBs[0];
|
|
133
|
+
let nW = initW, nH = initH;
|
|
134
|
+
if (c === 'br') { nW = initW + cdx; nH = initH + cdy; }
|
|
135
|
+
if (c === 'bl') { nW = initW - cdx; nH = initH + cdy; }
|
|
136
|
+
if (c === 'tr') { nW = initW + cdx; nH = initH - cdy; }
|
|
137
|
+
if (c === 'tl') { nW = initW - cdx; nH = initH - cdy; }
|
|
138
|
+
if ((e.shiftKey || node.shapeType === 'actor') && initW > 0 && initH > 0) {
|
|
139
|
+
const s = Math.max(nW / initW, nH / initH);
|
|
140
|
+
nW = initW * s;
|
|
141
|
+
nH = initH * s;
|
|
142
|
+
}
|
|
143
|
+
nW = Math.max(MIN, Math.round(nW));
|
|
144
|
+
nH = Math.max(MIN, Math.round(nH));
|
|
145
|
+
st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
|
|
146
|
+
if (!st.resizeSymmetric) {
|
|
147
|
+
// Anchor the opposite corner: move center so that the fixed corner stays put.
|
|
148
|
+
let nx = initX, ny = initY;
|
|
149
|
+
if (c === 'br') { nx = initX - initW / 2 + nW / 2; ny = initY - initH / 2 + nH / 2; }
|
|
150
|
+
if (c === 'bl') { nx = initX + initW / 2 - nW / 2; ny = initY - initH / 2 + nH / 2; }
|
|
151
|
+
if (c === 'tr') { nx = initX - initW / 2 + nW / 2; ny = initY + initH / 2 - nH / 2; }
|
|
152
|
+
if (c === 'tl') { nx = initX + initW / 2 - nW / 2; ny = initY + initH / 2 - nH / 2; }
|
|
153
|
+
st.network.moveNode(id, nx, ny);
|
|
154
|
+
}
|
|
155
|
+
updatedIds.push(id);
|
|
156
|
+
} else {
|
|
157
|
+
const { initBoxW, initBoxH, bbMinX, bbMinY, bbMaxX, bbMaxY } = st.resizeDrag;
|
|
158
|
+
let sx = 1, sy = 1;
|
|
159
|
+
if (c === 'br') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
|
|
160
|
+
if (c === 'bl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
|
|
161
|
+
if (c === 'tr') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
|
|
162
|
+
if (c === 'tl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
|
|
163
|
+
sx = Math.max(0.1, sx); sy = Math.max(0.1, sy);
|
|
164
|
+
if (e.shiftKey) { const s = Math.max(sx, sy); sx = s; sy = s; }
|
|
165
|
+
|
|
166
|
+
// Pivot = corner opposite to the drag corner (stays fixed during scale)
|
|
167
|
+
const pivotX = (c === 'br' || c === 'tr') ? bbMinX : bbMaxX;
|
|
168
|
+
const pivotY = (c === 'br' || c === 'bl') ? bbMinY : bbMaxY;
|
|
169
|
+
|
|
170
|
+
for (const { id, node, initW, initH, initX, initY } of st.resizeDrag.startBBs) {
|
|
171
|
+
const actorLock = node.shapeType === 'actor';
|
|
172
|
+
const esx = actorLock ? Math.max(sx, sy) : sx;
|
|
173
|
+
const esy = actorLock ? Math.max(sx, sy) : sy;
|
|
174
|
+
const nW = Math.max(MIN, Math.round(initW * esx));
|
|
175
|
+
const nH = Math.max(MIN, Math.round(initH * esy));
|
|
176
|
+
st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
|
|
177
|
+
st.network.moveNode(id, pivotX + (initX - pivotX) * sx, pivotY + (initY - pivotY) * sy);
|
|
178
|
+
updatedIds.push(id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
updatedIds.forEach((id) => { const bn = st.network.body.nodes[id]; if (bn) bn.refreshNeeded = true; });
|
|
183
|
+
st.network.redraw();
|
|
184
|
+
updateSelectionOverlay();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onResizeEnd() {
|
|
188
|
+
if (!st.resizeDrag) return;
|
|
189
|
+
st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: false }));
|
|
190
|
+
document.getElementById('vis-canvas').style.pointerEvents = '';
|
|
191
|
+
document.removeEventListener('mousemove', onResizeDrag);
|
|
192
|
+
document.removeEventListener('mouseup', onResizeEnd);
|
|
193
|
+
if (st.gridEnabled && st.network) {
|
|
194
|
+
st.selectedNodeIds.forEach((id) => {
|
|
195
|
+
const bn = st.network.body.nodes[id];
|
|
196
|
+
if (!bn) return;
|
|
197
|
+
const snapped = snapToGrid(bn.x, bn.y);
|
|
198
|
+
st.network.moveNode(id, snapped.x, snapped.y);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
st.resizeDrag = null;
|
|
202
|
+
markDirty();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Rotation ──────────────────────────────────────────────────────────────────
|
|
206
|
+
function onRotateStart(e) {
|
|
207
|
+
if (!st.selectedNodeIds.length || !st.network) return;
|
|
208
|
+
// Skip if any selected node is locked.
|
|
209
|
+
if (st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return n && n.locked; })) return;
|
|
210
|
+
pushSnapshot();
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
|
|
214
|
+
// Barycentre of the selection in canvas coordinates.
|
|
215
|
+
const positions = st.network.getPositions(st.selectedNodeIds);
|
|
216
|
+
const ids = st.selectedNodeIds;
|
|
217
|
+
const cx = ids.reduce((s, id) => s + (positions[id] ? positions[id].x : 0), 0) / ids.length;
|
|
218
|
+
const cy = ids.reduce((s, id) => s + (positions[id] ? positions[id].y : 0), 0) / ids.length;
|
|
219
|
+
|
|
220
|
+
const nodeAngles = ids.map((id) => {
|
|
221
|
+
const n = st.nodes.get(id);
|
|
222
|
+
const pos = positions[id] || { x: 0, y: 0 };
|
|
223
|
+
return {
|
|
224
|
+
id,
|
|
225
|
+
initRotation: (n && n.rotation) || 0,
|
|
226
|
+
// Position relative to barycentre at drag start
|
|
227
|
+
relX: pos.x - cx,
|
|
228
|
+
relY: pos.y - cy,
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Horizontal drag → rotation: right = clockwise, left = counter-clockwise.
|
|
233
|
+
// 1 px = 1 degree.
|
|
234
|
+
st.rotateDrag = { startX: e.clientX, nodeAngles, cx, cy };
|
|
235
|
+
document.getElementById('vis-canvas').style.pointerEvents = 'none';
|
|
236
|
+
document.addEventListener('mousemove', onRotateDrag);
|
|
237
|
+
document.addEventListener('mouseup', onRotateEnd);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function onRotateDrag(e) {
|
|
241
|
+
if (!st.rotateDrag || !st.network) return;
|
|
242
|
+
const { startX, nodeAngles, cx, cy } = st.rotateDrag;
|
|
243
|
+
const dx = e.clientX - startX;
|
|
244
|
+
const delta = dx * (Math.PI / 180); // 1 px = 1 degree
|
|
245
|
+
const cos = Math.cos(delta);
|
|
246
|
+
const sin = Math.sin(delta);
|
|
247
|
+
|
|
248
|
+
nodeAngles.forEach(({ id, initRotation, relX, relY }) => {
|
|
249
|
+
// Rotate the node's position around the barycentre
|
|
250
|
+
const newX = cx + relX * cos - relY * sin;
|
|
251
|
+
const newY = cy + relX * sin + relY * cos;
|
|
252
|
+
st.network.moveNode(id, newX, newY);
|
|
253
|
+
// Rotate the node's own orientation
|
|
254
|
+
st.nodes.update({ id, rotation: initRotation + delta });
|
|
255
|
+
const bn = st.network.body.nodes[id];
|
|
256
|
+
if (bn) bn.refreshNeeded = true;
|
|
257
|
+
});
|
|
258
|
+
st.network.redraw();
|
|
259
|
+
updateSelectionOverlay();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function onRotateEnd() {
|
|
263
|
+
if (!st.rotateDrag) return;
|
|
264
|
+
document.getElementById('vis-canvas').style.pointerEvents = '';
|
|
265
|
+
document.removeEventListener('mousemove', onRotateDrag);
|
|
266
|
+
document.removeEventListener('mouseup', onRotateEnd);
|
|
267
|
+
st.rotateDrag = null;
|
|
268
|
+
markDirty();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Label rotation ────────────────────────────────────────────────────────────
|
|
272
|
+
function onLabelRotateStart(e) {
|
|
273
|
+
if (!st.selectedNodeIds.length || !st.network) return;
|
|
274
|
+
pushSnapshot();
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
e.stopPropagation();
|
|
277
|
+
|
|
278
|
+
const nodeAngles = st.selectedNodeIds.map((id) => {
|
|
279
|
+
const n = st.nodes.get(id);
|
|
280
|
+
return { id, initLabelRotation: (n && n.labelRotation) || 0 };
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
st.labelRotateDrag = { startX: e.clientX, nodeAngles };
|
|
284
|
+
document.getElementById('vis-canvas').style.pointerEvents = 'none';
|
|
285
|
+
document.addEventListener('mousemove', onLabelRotateDrag);
|
|
286
|
+
document.addEventListener('mouseup', onLabelRotateEnd);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onLabelRotateDrag(e) {
|
|
290
|
+
if (!st.labelRotateDrag || !st.network) return;
|
|
291
|
+
const { startX, nodeAngles } = st.labelRotateDrag;
|
|
292
|
+
const dx = e.clientX - startX;
|
|
293
|
+
const delta = dx * (Math.PI / 180); // 1 px = 1 degree
|
|
294
|
+
|
|
295
|
+
nodeAngles.forEach(({ id, initLabelRotation }) => {
|
|
296
|
+
st.nodes.update({ id, labelRotation: initLabelRotation + delta });
|
|
297
|
+
const bn = st.network.body.nodes[id];
|
|
298
|
+
if (bn) bn.refreshNeeded = true;
|
|
299
|
+
});
|
|
300
|
+
st.network.redraw();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function onLabelRotateEnd() {
|
|
304
|
+
if (!st.labelRotateDrag) return;
|
|
305
|
+
document.getElementById('vis-canvas').style.pointerEvents = '';
|
|
306
|
+
document.removeEventListener('mousemove', onLabelRotateDrag);
|
|
307
|
+
document.removeEventListener('mouseup', onLabelRotateEnd);
|
|
308
|
+
st.labelRotateDrag = null;
|
|
309
|
+
markDirty();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Wire handles ──────────────────────────────────────────────────────────────
|
|
313
|
+
['tl', 'tr', 'bl', 'br'].forEach((corner) => {
|
|
314
|
+
document.getElementById('rh-' + corner).addEventListener('mousedown', (e) => onResizeStart(e, corner));
|
|
315
|
+
});
|
|
316
|
+
document.getElementById('rh-rotate').addEventListener('mousedown', onRotateStart);
|
|
317
|
+
document.getElementById('rh-label-rotate').addEventListener('mousedown', onLabelRotateStart);
|
|
318
|
+
|
|
319
|
+
// ── Resize mode toggle ────────────────────────────────────────────────────────
|
|
320
|
+
export function toggleResizeMode() {
|
|
321
|
+
st.resizeSymmetric = !st.resizeSymmetric;
|
|
322
|
+
const btn = document.getElementById('btnResizeMode');
|
|
323
|
+
const iconCorner = document.getElementById('icon-resize-corner');
|
|
324
|
+
const iconCenter = document.getElementById('icon-resize-center');
|
|
325
|
+
if (st.resizeSymmetric) {
|
|
326
|
+
btn.title = t('diagram.toolbar.resize_center');
|
|
327
|
+
btn.classList.remove('active-tool');
|
|
328
|
+
iconCorner.classList.add('hidden');
|
|
329
|
+
iconCenter.classList.remove('hidden');
|
|
330
|
+
} else {
|
|
331
|
+
btn.title = t('diagram.toolbar.resize_corner');
|
|
332
|
+
btn.classList.add('active-tool');
|
|
333
|
+
iconCorner.classList.remove('hidden');
|
|
334
|
+
iconCenter.classList.add('hidden');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ── Shared mutable state ──────────────────────────────────────────────────────
|
|
2
|
+
// Single object imported by reference so all modules see the same mutations.
|
|
3
|
+
|
|
4
|
+
export const st = {
|
|
5
|
+
network: null,
|
|
6
|
+
nodes: null,
|
|
7
|
+
edges: null,
|
|
8
|
+
diagrams: [],
|
|
9
|
+
currentDiagramId: null,
|
|
10
|
+
currentTool: 'select',
|
|
11
|
+
pendingShape: 'box',
|
|
12
|
+
selectedNodeIds: [],
|
|
13
|
+
selectedEdgeIds: [],
|
|
14
|
+
alignGuides: true,
|
|
15
|
+
gridEnabled: true,
|
|
16
|
+
debugMode: false,
|
|
17
|
+
isDirty: false,
|
|
18
|
+
sidebarOpen: true,
|
|
19
|
+
editingNodeId: null,
|
|
20
|
+
editingEdgeId: null,
|
|
21
|
+
resizeDrag: null,
|
|
22
|
+
rotateDrag: null, // { startX, nodeAngles: [{id, initRotation}] }
|
|
23
|
+
labelRotateDrag: null, // { startX, nodeAngles: [{id, initLabelRotation}] }
|
|
24
|
+
activeStamp: null, // 'color' | 'rotation' | 'fontSize' | null
|
|
25
|
+
stampTargetIds: [], // node IDs waiting to receive the stamped property
|
|
26
|
+
clipboard: null, // { nodes: [], edges: [] }
|
|
27
|
+
canonicalOrder: [], // user-defined z-order, immune to vis.js hover reordering
|
|
28
|
+
edgesStraight: false, // when true, all edges use smooth: disabled (straight lines)
|
|
29
|
+
resizeSymmetric: false, // when true, center is fixed during resize; when false, opposite corner is fixed
|
|
30
|
+
nodeColorOverrides: {}, // colorKey → {bg, border, font, hbg, hborder} — set from config at boot
|
|
31
|
+
edgeLabelCanvasPos: {}, // edgeId → {x, y} canvas coords of last drawn label, used by label editor
|
|
32
|
+
edgeLabelBBox: {}, // edgeId → {cx, cy, w, h, rotation} canvas world coords, used by label resize
|
|
33
|
+
freeArrowFirstPoint: null, // addEdge two-click flow: {x, y} canvas coords of first click, or null
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function markDirty() {
|
|
37
|
+
st.isDirty = true;
|
|
38
|
+
document.getElementById('btnSave').disabled = false;
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ── Toast notifications ───────────────────────────────────────────────────────
|
|
2
|
+
// Minimal sonner-style toasts, no dependencies.
|
|
3
|
+
|
|
4
|
+
export function showToast(message, type = 'success', duration = 3500) {
|
|
5
|
+
const container = document.getElementById('toastContainer');
|
|
6
|
+
const el = document.createElement('div');
|
|
7
|
+
el.className = 'ld-toast ld-toast--' + type;
|
|
8
|
+
el.textContent = message;
|
|
9
|
+
container.appendChild(el);
|
|
10
|
+
|
|
11
|
+
// Animate in on next frame
|
|
12
|
+
requestAnimationFrame(() => el.classList.add('ld-toast--visible'));
|
|
13
|
+
|
|
14
|
+
const hide = () => {
|
|
15
|
+
el.classList.remove('ld-toast--visible');
|
|
16
|
+
el.addEventListener('transitionend', () => el.remove(), { once: true });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const timer = setTimeout(hide, duration);
|
|
20
|
+
el.addEventListener('click', () => { clearTimeout(timer); hide(); });
|
|
21
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// ── Long-press unlock ─────────────────────────────────────────────────────────
|
|
2
|
+
// Locked nodes/edges are fully non-interactive: any click on them is captured
|
|
3
|
+
// here before vis-network sees it. Holding the mouse button on a locked target
|
|
4
|
+
// for UNLOCK_HOLD_MS unlocks it, with a circular progress animation rendered
|
|
5
|
+
// next to the pointer. Releasing early or moving beyond a small tolerance
|
|
6
|
+
// cancels the hold without changing state.
|
|
7
|
+
|
|
8
|
+
import { st, markDirty } from './state.js';
|
|
9
|
+
import { pushSnapshot } from './history.js';
|
|
10
|
+
|
|
11
|
+
const UNLOCK_HOLD_MS = 2000;
|
|
12
|
+
const UNLOCK_MOVE_TOLERANCE = 8; // px
|
|
13
|
+
|
|
14
|
+
let _hold = null;
|
|
15
|
+
|
|
16
|
+
function isEdgeLocked(edge) {
|
|
17
|
+
if (!edge) return false;
|
|
18
|
+
const fromN = st.nodes && st.nodes.get(edge.from);
|
|
19
|
+
const toN = st.nodes && st.nodes.get(edge.to);
|
|
20
|
+
const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
|
|
21
|
+
return isFreeArrow ? !!(fromN.locked && toN.locked) : !!edge.edgeLocked;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Returns a target descriptor ({type, id, ...}) if the DOM point lands on a
|
|
25
|
+
// locked node or locked edge, otherwise null.
|
|
26
|
+
function hitTestLocked(container, clientX, clientY) {
|
|
27
|
+
if (!st.network || !st.nodes || !st.edges) return null;
|
|
28
|
+
const rect = container.getBoundingClientRect();
|
|
29
|
+
const pos = { x: clientX - rect.left, y: clientY - rect.top };
|
|
30
|
+
|
|
31
|
+
const nodeId = st.network.getNodeAt(pos);
|
|
32
|
+
if (nodeId) {
|
|
33
|
+
const n = st.nodes.get(nodeId);
|
|
34
|
+
if (n && n.locked) {
|
|
35
|
+
if (n.shapeType === 'anchor') {
|
|
36
|
+
// Locked anchor → resolve to its parent free-arrow so the whole arrow unlocks.
|
|
37
|
+
const edges = st.edges.get({ filter: (e) => e.from === nodeId || e.to === nodeId });
|
|
38
|
+
for (const e of edges) {
|
|
39
|
+
if (isEdgeLocked(e)) {
|
|
40
|
+
return { type: 'edge', id: e.id, isFreeArrow: true, fromId: e.from, toId: e.to };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { type: 'node', id: nodeId };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const edgeId = st.network.getEdgeAt(pos);
|
|
49
|
+
if (edgeId) {
|
|
50
|
+
const e = st.edges.get(edgeId);
|
|
51
|
+
if (isEdgeLocked(e)) {
|
|
52
|
+
const fromN = st.nodes.get(e.from);
|
|
53
|
+
const toN = st.nodes.get(e.to);
|
|
54
|
+
const isFreeArrow = fromN && fromN.shapeType === 'anchor' && toN && toN.shapeType === 'anchor';
|
|
55
|
+
return { type: 'edge', id: edgeId, isFreeArrow, fromId: e.from, toId: e.to };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createOverlay() {
|
|
62
|
+
const C = 2 * Math.PI * 20;
|
|
63
|
+
const el = document.createElement('div');
|
|
64
|
+
el.className = 'unlock-hold-overlay';
|
|
65
|
+
el.style.cssText = [
|
|
66
|
+
'position:fixed',
|
|
67
|
+
'pointer-events:none',
|
|
68
|
+
'width:56px', 'height:56px',
|
|
69
|
+
'z-index:10000',
|
|
70
|
+
'transform:translate(-50%,-50%)',
|
|
71
|
+
'transition:opacity 120ms',
|
|
72
|
+
'opacity:0',
|
|
73
|
+
].join(';');
|
|
74
|
+
el.innerHTML = `
|
|
75
|
+
<svg viewBox="0 0 56 56" width="56" height="56">
|
|
76
|
+
<circle cx="28" cy="28" r="24" fill="rgba(17,24,39,0.75)" stroke="rgba(255,255,255,0.25)" stroke-width="2"/>
|
|
77
|
+
<circle class="uh-progress" cx="28" cy="28" r="20" fill="none"
|
|
78
|
+
stroke="#f97316" stroke-width="3"
|
|
79
|
+
stroke-dasharray="${C}" stroke-dashoffset="${C}"
|
|
80
|
+
transform="rotate(-90 28 28)" stroke-linecap="round"/>
|
|
81
|
+
<g transform="translate(20 19)" fill="none" stroke="#f9fafb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
82
|
+
<rect x="2" y="7" width="12" height="10" rx="2"/>
|
|
83
|
+
<path d="M5 7V5a3 3 0 0 1 6 0"/>
|
|
84
|
+
</g>
|
|
85
|
+
</svg>`;
|
|
86
|
+
document.body.appendChild(el);
|
|
87
|
+
requestAnimationFrame(() => { el.style.opacity = '1'; });
|
|
88
|
+
return el;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function positionOverlay(el, clientX, clientY) {
|
|
92
|
+
el.style.left = (clientX + 28) + 'px';
|
|
93
|
+
el.style.top = (clientY + 28) + 'px';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function updateProgress(el, progress) {
|
|
97
|
+
const C = 2 * Math.PI * 20;
|
|
98
|
+
const ring = el.querySelector('.uh-progress');
|
|
99
|
+
if (ring) ring.setAttribute('stroke-dashoffset', String(C * (1 - progress)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function performUnlock(target) {
|
|
103
|
+
if (!st.nodes || !st.edges || !st.network) return;
|
|
104
|
+
pushSnapshot();
|
|
105
|
+
if (target.type === 'node') {
|
|
106
|
+
st.nodes.update({ id: target.id, locked: false, fixed: false, draggable: true });
|
|
107
|
+
const bn = st.network.body.nodes[target.id];
|
|
108
|
+
if (bn) bn.refreshNeeded = true;
|
|
109
|
+
} else if (target.isFreeArrow) {
|
|
110
|
+
[target.fromId, target.toId].forEach((nodeId) => {
|
|
111
|
+
st.nodes.update({ id: nodeId, locked: false, fixed: false, draggable: true });
|
|
112
|
+
const bn = st.network.body.nodes[nodeId];
|
|
113
|
+
if (bn) bn.refreshNeeded = true;
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
st.edges.update({ id: target.id, edgeLocked: false });
|
|
117
|
+
}
|
|
118
|
+
st.network.redraw();
|
|
119
|
+
markDirty();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cancelHold() {
|
|
123
|
+
if (!_hold) return;
|
|
124
|
+
clearTimeout(_hold.timeoutId);
|
|
125
|
+
if (_hold.rafId) cancelAnimationFrame(_hold.rafId);
|
|
126
|
+
if (_hold.overlay && _hold.overlay.parentNode) _hold.overlay.remove();
|
|
127
|
+
document.removeEventListener('mouseup', onUp, true);
|
|
128
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
129
|
+
_hold = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function tick() {
|
|
133
|
+
if (!_hold) return;
|
|
134
|
+
const elapsed = Date.now() - _hold.startTime;
|
|
135
|
+
const progress = Math.min(elapsed / UNLOCK_HOLD_MS, 1);
|
|
136
|
+
updateProgress(_hold.overlay, progress);
|
|
137
|
+
_hold.rafId = requestAnimationFrame(tick);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onMove(e) {
|
|
141
|
+
if (!_hold) return;
|
|
142
|
+
positionOverlay(_hold.overlay, e.clientX, e.clientY);
|
|
143
|
+
const dx = e.clientX - _hold.startPos.x;
|
|
144
|
+
const dy = e.clientY - _hold.startPos.y;
|
|
145
|
+
if (Math.hypot(dx, dy) > UNLOCK_MOVE_TOLERANCE) cancelHold();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function onUp() {
|
|
149
|
+
cancelHold();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Attaches the capture-phase mousedown handler that intercepts clicks on
|
|
153
|
+
// locked targets and starts the long-press unlock hold.
|
|
154
|
+
export function installUnlockHold(container) {
|
|
155
|
+
container.addEventListener('mousedown', (e) => {
|
|
156
|
+
if (e.button !== 0) return;
|
|
157
|
+
const target = hitTestLocked(container, e.clientX, e.clientY);
|
|
158
|
+
if (!target) return;
|
|
159
|
+
|
|
160
|
+
// Block vis-network, panels, and all other handlers.
|
|
161
|
+
e.stopImmediatePropagation();
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
|
|
164
|
+
cancelHold();
|
|
165
|
+
const overlay = createOverlay();
|
|
166
|
+
positionOverlay(overlay, e.clientX, e.clientY);
|
|
167
|
+
_hold = {
|
|
168
|
+
target,
|
|
169
|
+
startTime: Date.now(),
|
|
170
|
+
startPos: { x: e.clientX, y: e.clientY },
|
|
171
|
+
overlay,
|
|
172
|
+
rafId: null,
|
|
173
|
+
timeoutId: setTimeout(() => {
|
|
174
|
+
performUnlock(target);
|
|
175
|
+
cancelHold();
|
|
176
|
+
}, UNLOCK_HOLD_MS),
|
|
177
|
+
};
|
|
178
|
+
tick();
|
|
179
|
+
document.addEventListener('mouseup', onUp, true);
|
|
180
|
+
document.addEventListener('mousemove', onMove, true);
|
|
181
|
+
}, { capture: true });
|
|
182
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ── Zoom controls ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
import { st } from './state.js';
|
|
4
|
+
|
|
5
|
+
export function adjustZoom(delta) {
|
|
6
|
+
if (!st.network) return;
|
|
7
|
+
st.network.moveTo({ scale: Math.max(0.1, Math.min(3, st.network.getScale() + delta)), animation: false });
|
|
8
|
+
updateZoomDisplay();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resetZoom() {
|
|
12
|
+
if (!st.network) return;
|
|
13
|
+
st.network.fit({ animation: { duration: 300, easingFunction: 'easeInOutQuad' } });
|
|
14
|
+
setTimeout(updateZoomDisplay, 350);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function updateZoomDisplay() {
|
|
18
|
+
if (!st.network) return;
|
|
19
|
+
document.getElementById('zoomLevel').textContent = Math.round(st.network.getScale() * 100) + '%';
|
|
20
|
+
}
|