living-documentation 3.5.0 → 3.7.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.
@@ -0,0 +1,58 @@
1
+ // ── Clipboard (copy / paste) ───────────────────────────────────────────────────
2
+
3
+ import { st, markDirty } from './state.js';
4
+ import { visNodeProps } from './node-rendering.js';
5
+ import { visEdgeProps } from './edge-rendering.js';
6
+ import { showNodePanel } from './node-panel.js';
7
+
8
+ export function copySelected() {
9
+ if (!st.network || !st.selectedNodeIds.length) return;
10
+
11
+ const positions = st.network.getPositions(st.selectedNodeIds);
12
+ const copiedNodes = st.selectedNodeIds.map((id) => {
13
+ const { ctxRenderer, ...rest } = st.nodes.get(id);
14
+ return { ...rest, x: positions[id]?.x ?? rest.x, y: positions[id]?.y ?? rest.y };
15
+ });
16
+
17
+ // Only copy edges where both endpoints are in the selection
18
+ const selectedSet = new Set(st.selectedNodeIds);
19
+ const copiedEdges = st.edges.get().filter((e) => selectedSet.has(e.from) && selectedSet.has(e.to));
20
+
21
+ st.clipboard = { nodes: copiedNodes, edges: copiedEdges };
22
+ }
23
+
24
+ export function pasteClipboard() {
25
+ if (!st.clipboard || !st.clipboard.nodes.length || !st.network) return;
26
+
27
+ const OFFSET = 40;
28
+ const idMap = {};
29
+ st.clipboard.nodes.forEach((n) => { idMap[n.id] = 'n' + Date.now() + Math.random().toString(36).slice(2); });
30
+
31
+ const newNodes = st.clipboard.nodes.map((n) => ({
32
+ ...n, id: idMap[n.id], x: (n.x || 0) + OFFSET, y: (n.y || 0) + OFFSET,
33
+ ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
34
+ }));
35
+ const newEdges = st.clipboard.edges.map((e) => ({
36
+ ...e,
37
+ id: 'e' + Date.now() + Math.random().toString(36).slice(2),
38
+ from: idMap[e.from], to: idMap[e.to],
39
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
40
+ ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
41
+ }));
42
+
43
+ st.nodes.add(newNodes);
44
+ st.edges.add(newEdges);
45
+
46
+ const newIds = newNodes.map((n) => n.id);
47
+ st.network.selectNodes(newIds);
48
+ st.selectedNodeIds = newIds;
49
+ st.selectedEdgeIds = [];
50
+ showNodePanel();
51
+ markDirty();
52
+
53
+ // Offset clipboard so each successive paste is staggered
54
+ st.clipboard = {
55
+ nodes: st.clipboard.nodes.map((n) => ({ ...n, x: (n.x || 0) + OFFSET, y: (n.y || 0) + OFFSET })),
56
+ edges: st.clipboard.edges,
57
+ };
58
+ }
@@ -0,0 +1,34 @@
1
+ // ── Constants ─────────────────────────────────────────────────────────────────
2
+ // Pure compile-time values shared across all diagram modules.
3
+
4
+ export const GRID_SIZE = 40;
5
+
6
+ export const TOOL_BTN_MAP = {
7
+ select: 'toolSelect',
8
+ 'addNode:box': 'toolBox',
9
+ 'addNode:ellipse': 'toolEllipse',
10
+ 'addNode:database': 'toolDatabase',
11
+ 'addNode:circle': 'toolCircle',
12
+ 'addNode:actor': 'toolActor',
13
+ 'addNode:post-it': 'toolPostIt',
14
+ 'addNode:text-free': 'toolTextFree',
15
+ addEdge: 'toolArrow',
16
+ };
17
+
18
+ export const NODE_COLORS = {
19
+ 'c-gray': { bg: '#f5f5f4', border: '#a8a29e', font: '#292524', hbg: '#e7e5e4', hborder: '#78716c' },
20
+ 'c-blue': { bg: '#dbeafe', border: '#3b82f6', font: '#1e40af', hbg: '#bfdbfe', hborder: '#2563eb' },
21
+ 'c-green': { bg: '#dcfce7', border: '#22c55e', font: '#166534', hbg: '#bbf7d0', hborder: '#16a34a' },
22
+ 'c-amber': { bg: '#fef9c3', border: '#f59e0b', font: '#78350f', hbg: '#fef08a', hborder: '#d97706' },
23
+ 'c-rose': { bg: '#ffe4e6', border: '#f43f5e', font: '#881337', hbg: '#fecdd3', hborder: '#e11d48' },
24
+ 'c-purple': { bg: '#ede9fe', border: '#8b5cf6', font: '#4c1d95', hbg: '#ddd6fe', hborder: '#7c3aed' },
25
+ 'c-teal': { bg: '#ccfbf1', border: '#14b8a6', font: '#134e4a', hbg: '#99f6e4', hborder: '#0d9488' },
26
+ 'c-orange': { bg: '#ffedd5', border: '#f97316', font: '#7c2d12', hbg: '#fed7aa', hborder: '#ea580c' },
27
+ 'c-cyan': { bg: '#cffafe', border: '#06b6d4', font: '#164e63', hbg: '#a5f3fc', hborder: '#0891b2' },
28
+ 'c-indigo': { bg: '#e0e7ff', border: '#6366f1', font: '#312e81', hbg: '#c7d2fe', hborder: '#4f46e5' },
29
+ 'c-pink': { bg: '#fce7f3', border: '#ec4899', font: '#831843', hbg: '#fbcfe8', hborder: '#db2777' },
30
+ 'c-lime': { bg: '#ecfccb', border: '#84cc16', font: '#365314', hbg: '#d9f99d', hborder: '#65a30d' },
31
+ 'c-red': { bg: '#fee2e2', border: '#ef4444', font: '#7f1d1d', hbg: '#fecaca', hborder: '#dc2626' },
32
+ 'c-sky': { bg: '#e0f2fe', border: '#0ea5e9', font: '#0c4a6e', hbg: '#bae6fd', hborder: '#0284c7' },
33
+ 'c-slate': { bg: '#f1f5f9', border: '#64748b', font: '#0f172a', hbg: '#e2e8f0', hborder: '#475569' },
34
+ };
@@ -0,0 +1,43 @@
1
+ // ── Debug overlay ─────────────────────────────────────────────────────────────
2
+ // DOM overlay showing node IDs and bounding-box coordinates for each node.
3
+ // Toggled by the showDiagramDebug config flag (Admin panel).
4
+
5
+ import { st } from './state.js';
6
+
7
+ export function toggleDebug() {
8
+ st.debugMode = !st.debugMode;
9
+ document.getElementById('btnDebug').classList.toggle('tool-active', st.debugMode);
10
+ if (st.network) st.network.redraw();
11
+ }
12
+
13
+ // Called on network "afterDrawing". Recycles existing .debug-box elements by node id.
14
+ export function drawDebugOverlay() {
15
+ const layer = document.getElementById('debugLayer');
16
+ if (!st.debugMode || !st.network) { layer.innerHTML = ''; return; }
17
+
18
+ const existing = new Map(
19
+ [...layer.querySelectorAll('.debug-box')].map((el) => [el.dataset.nid, el])
20
+ );
21
+ const seen = new Set();
22
+
23
+ for (const id of st.canonicalOrder) {
24
+ const bodyNode = st.network.body.nodes[id];
25
+ if (!bodyNode) continue;
26
+ const w = bodyNode.shape.width || 0;
27
+ const h = bodyNode.shape.height || 0;
28
+ const cx = bodyNode.x, cy = bodyNode.y;
29
+ const L = cx - w / 2, T = cy - h / 2;
30
+
31
+ const dom = st.network.canvasToDOM({ x: cx + w / 2 + 8, y: cy - h / 2 });
32
+ const text = [`id : ${id}`, `cx=${Math.round(cx)} cy=${Math.round(cy)}`, `w =${Math.round(w)} h =${Math.round(h)}`, `L =${Math.round(L)} T =${Math.round(T)}`].join('\n');
33
+
34
+ let box = existing.get(String(id));
35
+ if (!box) { box = document.createElement('div'); box.className = 'debug-box'; box.dataset.nid = String(id); layer.appendChild(box); }
36
+ box.textContent = text;
37
+ box.style.left = Math.round(dom.x) + 'px';
38
+ box.style.top = Math.round(dom.y) + 'px';
39
+ seen.add(String(id));
40
+ }
41
+
42
+ existing.forEach((el, nid) => { if (!seen.has(nid)) el.remove(); });
43
+ }
@@ -0,0 +1,61 @@
1
+ // ── Edge panel ────────────────────────────────────────────────────────────────
2
+ // Floating formatting toolbar for selected edges (arrow type, line style, font size).
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { visEdgeProps } from './edge-rendering.js';
6
+
7
+ export function showEdgePanel() {
8
+ if (!st.selectedEdgeIds.length) return;
9
+ const e = st.edges.get(st.selectedEdgeIds[0]);
10
+ if (!e) return;
11
+ const dir = e.arrowDir ?? 'to';
12
+ const dashes = e.dashes ?? false;
13
+
14
+ ['edgeBtnNone', 'edgeBtnTo', 'edgeBtnBoth'].forEach((id) =>
15
+ document.getElementById(id).classList.remove('edge-btn-active'));
16
+ document.getElementById({ none: 'edgeBtnNone', to: 'edgeBtnTo', both: 'edgeBtnBoth' }[dir] || 'edgeBtnTo')
17
+ .classList.add('edge-btn-active');
18
+
19
+ ['edgeBtnSolid', 'edgeBtnDashed'].forEach((id) =>
20
+ document.getElementById(id).classList.remove('edge-btn-active'));
21
+ document.getElementById(dashes ? 'edgeBtnDashed' : 'edgeBtnSolid').classList.add('edge-btn-active');
22
+
23
+ document.getElementById('edgePanel').classList.remove('hidden');
24
+ }
25
+
26
+ export function hideEdgePanel() {
27
+ document.getElementById('edgePanel').classList.add('hidden');
28
+ }
29
+
30
+ export function setEdgeArrow(dir) {
31
+ if (!st.selectedEdgeIds.length) return;
32
+ st.selectedEdgeIds.forEach((id) => {
33
+ const e = st.edges.get(id);
34
+ if (!e) return;
35
+ st.edges.update({ id, arrowDir: dir, ...visEdgeProps(dir, e.dashes ?? false) });
36
+ });
37
+ showEdgePanel();
38
+ markDirty();
39
+ }
40
+
41
+ export function setEdgeDashes(dashes) {
42
+ if (!st.selectedEdgeIds.length) return;
43
+ st.selectedEdgeIds.forEach((id) => {
44
+ const e = st.edges.get(id);
45
+ if (!e) return;
46
+ st.edges.update({ id, dashes, ...visEdgeProps(e.arrowDir ?? 'to', dashes) });
47
+ });
48
+ showEdgePanel();
49
+ markDirty();
50
+ }
51
+
52
+ export function changeEdgeFontSize(delta) {
53
+ if (!st.selectedEdgeIds.length) return;
54
+ st.selectedEdgeIds.forEach((id) => {
55
+ const e = st.edges.get(id);
56
+ if (!e) return;
57
+ const newSize = Math.max(8, Math.min(48, (e.fontSize || 11) + delta));
58
+ st.edges.update({ id, fontSize: newSize, font: { size: newSize, align: 'middle', color: '#6b7280' } });
59
+ });
60
+ markDirty();
61
+ }
@@ -0,0 +1,12 @@
1
+ // ── Edge rendering ────────────────────────────────────────────────────────────
2
+ // Builds vis.js edge property objects (arrows, dashes).
3
+
4
+ export function visEdgeProps(arrowDir, dashes) {
5
+ return {
6
+ arrows: {
7
+ to: { enabled: arrowDir === 'to' || arrowDir === 'both', scaleFactor: 0.7 },
8
+ from: { enabled: arrowDir === 'both', scaleFactor: 0.7 },
9
+ },
10
+ dashes: dashes === true,
11
+ };
12
+ }
@@ -0,0 +1,85 @@
1
+ // ── Grid & physics ────────────────────────────────────────────────────────────
2
+ // Grid rendering (beforeDrawing), snap-to-grid (dragEnd), physics toggle.
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { GRID_SIZE } from './constants.js';
6
+
7
+
8
+ export function togglePhysics() {
9
+ st.physicsEnabled = !st.physicsEnabled;
10
+ const btn = document.getElementById('btnPhysics');
11
+ btn.classList.toggle('tool-active', st.physicsEnabled);
12
+ btn.title = st.physicsEnabled ? 'Anti-chevauchement actif' : 'Anti-chevauchement (espace les nœuds qui se superposent)';
13
+
14
+ if (!st.network) return;
15
+
16
+ st.network.setOptions({
17
+ physics: {
18
+ enabled: st.physicsEnabled,
19
+ stabilization: { enabled: false }, // no auto-stop — stays on until user toggles off
20
+ barnesHut: {
21
+ gravitationalConstant: -800, // mild repulsion — only pushes overlapping nodes
22
+ centralGravity: 0, // no pull toward centre — distant nodes stay put
23
+ springLength: 100,
24
+ springConstant: 0.01,
25
+ damping: 0.6, // high damping — nodes settle fast, no oscillation
26
+ avoidOverlap: 1, // vis-network built-in overlap avoidance
27
+ },
28
+ },
29
+ });
30
+ }
31
+
32
+ export function toggleGrid() {
33
+ st.gridEnabled = !st.gridEnabled;
34
+ const btn = document.getElementById('btnGrid');
35
+ btn.classList.toggle('tool-active', st.gridEnabled);
36
+ btn.title = st.gridEnabled ? 'Grille activée (snap on)' : 'Grille / Snap to grid (G)';
37
+ if (st.network) st.network.redraw();
38
+ }
39
+
40
+ // Called on network "beforeDrawing" — draws grid lines in physical pixel space.
41
+ export function drawGrid(ctx) {
42
+ if (!st.gridEnabled || !st.network) return;
43
+ const isDark = document.documentElement.classList.contains('dark');
44
+ const color = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.15)';
45
+ const scale = st.network.getScale();
46
+ const center = st.network.getViewPosition();
47
+ const canvas = ctx.canvas;
48
+ const W = canvas.width, H = canvas.height;
49
+
50
+ // vis.js coordinates (center.x, scale) are in CSS pixels; must multiply by DPR
51
+ // so the grid aligns correctly on Retina/HiDPI displays.
52
+ const dpr = window.devicePixelRatio || 1;
53
+ const step = GRID_SIZE * scale * dpr;
54
+ const offsetX = (((W / 2 - center.x * scale * dpr) % step) + step) % step;
55
+ const offsetY = (((H / 2 - center.y * scale * dpr) % step) + step) % step;
56
+
57
+ ctx.save();
58
+ ctx.setTransform(1, 0, 0, 1, 0, 0); // physical pixel space
59
+ ctx.strokeStyle = color;
60
+ ctx.lineWidth = 1;
61
+ ctx.beginPath();
62
+ for (let x = offsetX; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
63
+ for (let y = offsetY; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
64
+ ctx.stroke();
65
+ ctx.restore();
66
+ }
67
+
68
+ // Called on network "dragEnd" — snaps dragged nodes to the grid.
69
+ export function onDragEnd(params) {
70
+ if (st.gridEnabled && params.nodes && params.nodes.length > 0) {
71
+ params.nodes.forEach((id) => {
72
+ const bodyNode = st.network.body.nodes[id];
73
+ if (!bodyNode) return;
74
+ // shape.width/height are set by resize() on each draw — exact visual size
75
+ const w = bodyNode.shape.width || 0;
76
+ const h = bodyNode.shape.height || 0;
77
+ const cx = bodyNode.x, cy = bodyNode.y;
78
+ // Snap the visual top-left corner to grid lines
79
+ const snappedLeft = Math.round((cx - w / 2) / GRID_SIZE) * GRID_SIZE;
80
+ const snappedTop = Math.round((cy - h / 2) / GRID_SIZE) * GRID_SIZE;
81
+ st.network.moveNode(id, snappedLeft + w / 2, snappedTop + h / 2);
82
+ });
83
+ }
84
+ markDirty();
85
+ }
@@ -0,0 +1,90 @@
1
+ // ── Label editor ──────────────────────────────────────────────────────────────
2
+ // Floating textarea for in-place editing of node and edge labels.
3
+
4
+ import { st, markDirty } from './state.js';
5
+
6
+ export function autoResizeTextarea(ta) {
7
+ ta.style.height = 'auto';
8
+ ta.style.height = ta.scrollHeight + 'px';
9
+ }
10
+
11
+ export function startLabelEdit() {
12
+ if (!st.selectedNodeIds.length || !st.network) return;
13
+ const nodeId = st.selectedNodeIds[0];
14
+ const n = st.nodes.get(nodeId);
15
+ if (!n) return;
16
+
17
+ st.editingNodeId = nodeId;
18
+ st.editingEdgeId = null;
19
+
20
+ const pos = st.network.getPositions([nodeId])[nodeId] || { x: 0, y: 0 };
21
+ const dom = st.network.canvasToDOM(pos);
22
+ const ta = document.getElementById('labelInput');
23
+ ta.value = n.label || '';
24
+ ta.style.left = dom.x - 75 + 'px';
25
+ ta.style.top = dom.y - 30 + 'px';
26
+ ta.style.width = '150px';
27
+ ta.classList.remove('hidden');
28
+ autoResizeTextarea(ta);
29
+ ta.focus();
30
+ ta.select();
31
+ }
32
+
33
+ export function startEdgeLabelEdit() {
34
+ if (!st.selectedEdgeIds.length || !st.network) return;
35
+ const edgeId = st.selectedEdgeIds[0];
36
+ const e = st.edges.get(edgeId);
37
+ if (!e) return;
38
+
39
+ st.editingEdgeId = edgeId;
40
+ st.editingNodeId = null;
41
+
42
+ const positions = st.network.getPositions([e.from, e.to]);
43
+ const fp = positions[e.from] || { x: 0, y: 0 };
44
+ const tp = positions[e.to] || { x: 0, y: 0 };
45
+ const mid = st.network.canvasToDOM({ x: (fp.x + tp.x) / 2, y: (fp.y + tp.y) / 2 });
46
+
47
+ const ta = document.getElementById('labelInput');
48
+ ta.value = e.label || '';
49
+ ta.style.left = mid.x - 60 + 'px';
50
+ ta.style.top = mid.y - 14 + 'px';
51
+ ta.style.width = '120px';
52
+ ta.classList.remove('hidden');
53
+ autoResizeTextarea(ta);
54
+ ta.focus();
55
+ ta.select();
56
+ }
57
+
58
+ export function commitLabelEdit() {
59
+ const ta = document.getElementById('labelInput');
60
+ if (st.editingNodeId) {
61
+ st.nodes.update({ id: st.editingNodeId, label: ta.value });
62
+ markDirty();
63
+ } else if (st.editingEdgeId) {
64
+ st.edges.update({ id: st.editingEdgeId, label: ta.value });
65
+ markDirty();
66
+ }
67
+ st.editingNodeId = null;
68
+ st.editingEdgeId = null;
69
+ }
70
+
71
+ export function hideLabelInput() {
72
+ document.getElementById('labelInput').classList.add('hidden');
73
+ st.editingNodeId = null;
74
+ st.editingEdgeId = null;
75
+ }
76
+
77
+ // ── Wire labelInput DOM events ────────────────────────────────────────────────
78
+ const labelInput = document.getElementById('labelInput');
79
+
80
+ labelInput.addEventListener('keydown', (e) => {
81
+ if (e.key === 'Enter' && !e.shiftKey) {
82
+ commitLabelEdit();
83
+ hideLabelInput();
84
+ e.preventDefault();
85
+ } else if (e.key === 'Escape') {
86
+ hideLabelInput();
87
+ }
88
+ });
89
+ labelInput.addEventListener('input', (e) => autoResizeTextarea(e.target));
90
+ labelInput.addEventListener('blur', () => { commitLabelEdit(); hideLabelInput(); });
@@ -0,0 +1,173 @@
1
+ // ── Application entry point ───────────────────────────────────────────────────
2
+ // Wires all toolbar buttons, keyboard shortcuts, and bootstraps the app.
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { TOOL_BTN_MAP } from './constants.js';
6
+ import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder, activateStamp, cancelStamp } from './node-panel.js';
7
+ import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
8
+ import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
9
+ import { hideSelectionOverlay } from './selection-overlay.js';
10
+ import { togglePhysics, toggleGrid } from './grid.js';
11
+ import { toggleDebug } from './debug.js';
12
+ import { adjustZoom, resetZoom } from './zoom.js';
13
+ import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
14
+ import { copySelected, pasteClipboard } from './clipboard.js';
15
+
16
+ // ── Tool management ───────────────────────────────────────────────────────────
17
+
18
+ function setTool(tool, shape) {
19
+ st.currentTool = tool;
20
+ if (shape) st.pendingShape = shape;
21
+
22
+ document.querySelectorAll('.tool-btn').forEach((b) => b.classList.remove('tool-active'));
23
+ if (st.physicsEnabled) document.getElementById('btnPhysics').classList.add('tool-active');
24
+
25
+ const key = tool === 'addNode' ? `addNode:${shape || st.pendingShape}` : tool;
26
+ const btn = document.getElementById(TOOL_BTN_MAP[key]);
27
+ if (btn) btn.classList.add('tool-active');
28
+
29
+ document.getElementById('vis-canvas').classList.toggle('cursor-crosshair', tool === 'addNode' || tool === 'addEdge');
30
+
31
+ if (tool === 'addEdge' && st.network) st.network.addEdgeMode();
32
+ else if (st.network) st.network.disableEditMode();
33
+ }
34
+
35
+ function deleteSelected() {
36
+ if (!st.network) return;
37
+ st.network.deleteSelected();
38
+ hideNodePanel();
39
+ hideEdgePanel();
40
+ hideLabelInput();
41
+ hideSelectionOverlay();
42
+ st.editingNodeId = null;
43
+ st.editingEdgeId = null;
44
+ markDirty();
45
+ }
46
+
47
+ // ── Sidebar & dark mode ───────────────────────────────────────────────────────
48
+
49
+ function toggleSidebar() {
50
+ st.sidebarOpen = !st.sidebarOpen;
51
+ const sb = document.getElementById('sidebar');
52
+ sb.style.width = st.sidebarOpen ? '14rem' : '0';
53
+ sb.style.overflow = st.sidebarOpen ? '' : 'hidden';
54
+ }
55
+
56
+ function toggleDark() {
57
+ const dark = document.documentElement.classList.toggle('dark');
58
+ localStorage.setItem('ld-dark', dark);
59
+ document.getElementById('darkIcon').textContent = dark ? '☀' : '☽';
60
+ }
61
+
62
+ // ── Toolbar button wiring ─────────────────────────────────────────────────────
63
+
64
+ document.getElementById('btnSidebar').addEventListener('click', toggleSidebar);
65
+
66
+ document.getElementById('toolSelect').addEventListener('click', () => setTool('select'));
67
+ document.getElementById('toolBox').addEventListener('click', () => setTool('addNode', 'box'));
68
+ document.getElementById('toolEllipse').addEventListener('click', () => setTool('addNode', 'ellipse'));
69
+ document.getElementById('toolDatabase').addEventListener('click', () => setTool('addNode', 'database'));
70
+ document.getElementById('toolCircle').addEventListener('click', () => setTool('addNode', 'circle'));
71
+ document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
72
+ document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
73
+ document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
74
+ document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
75
+
76
+ document.getElementById('btnDelete').addEventListener('click', deleteSelected);
77
+ document.getElementById('btnPhysics').addEventListener('click', togglePhysics);
78
+ document.getElementById('btnGrid').addEventListener('click', toggleGrid);
79
+
80
+ document.getElementById('btnZoomOut').addEventListener('click', () => adjustZoom(-0.2));
81
+ document.getElementById('btnZoomIn').addEventListener('click', () => adjustZoom(0.2));
82
+ document.getElementById('btnZoomReset').addEventListener('click', resetZoom);
83
+
84
+ document.getElementById('btnDark').addEventListener('click', toggleDark);
85
+ document.getElementById('btnDebug').addEventListener('click', toggleDebug);
86
+ document.getElementById('btnSave').addEventListener('click', saveDiagram);
87
+ document.getElementById('btnNewDiagram').addEventListener('click', newDiagram);
88
+
89
+ // Dirty state on title edits
90
+ document.getElementById('diagramTitle').addEventListener('input', markDirty);
91
+
92
+ // ── Node panel wiring ─────────────────────────────────────────────────────────
93
+
94
+ document.getElementById('nodePanel').addEventListener('click', (e) => {
95
+ const colorBtn = e.target.closest('[data-color]');
96
+ if (colorBtn) setNodeColor(colorBtn.dataset.color);
97
+ });
98
+ document.getElementById('btnNodeLabelEdit').addEventListener('click', startLabelEdit);
99
+ document.getElementById('btnNodeFontDecrease').addEventListener('click', () => changeNodeFontSize(-1));
100
+ document.getElementById('btnNodeFontIncrease').addEventListener('click', () => changeNodeFontSize(1));
101
+ document.getElementById('btnAlignLeft').addEventListener('click', () => setTextAlign('left'));
102
+ document.getElementById('btnAlignCenter').addEventListener('click', () => setTextAlign('center'));
103
+ document.getElementById('btnAlignRight').addEventListener('click', () => setTextAlign('right'));
104
+ document.getElementById('btnValignTop').addEventListener('click', () => setTextValign('top'));
105
+ document.getElementById('btnValignMiddle').addEventListener('click', () => setTextValign('middle'));
106
+ document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
107
+ document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
108
+ document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
109
+ // Stamp buttons: capture targets on mousedown (before vis-network can fire
110
+ // deselectNode), then activate the stamp mode on click.
111
+ ['btnStampColor', 'btnStampRotation', 'btnStampFontSize'].forEach((id) => {
112
+ document.getElementById(id).addEventListener('mousedown', (e) => {
113
+ e.preventDefault(); // prevent canvas focus loss
114
+ st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
115
+ });
116
+ });
117
+ document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
118
+ document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
119
+ document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
120
+
121
+ // ── Edge panel wiring ─────────────────────────────────────────────────────────
122
+
123
+ document.getElementById('edgeBtnNone').addEventListener('click', () => setEdgeArrow('none'));
124
+ document.getElementById('edgeBtnTo').addEventListener('click', () => setEdgeArrow('to'));
125
+ document.getElementById('edgeBtnBoth').addEventListener('click', () => setEdgeArrow('both'));
126
+ document.getElementById('edgeBtnSolid').addEventListener('click', () => setEdgeDashes(false));
127
+ document.getElementById('edgeBtnDashed').addEventListener('click', () => setEdgeDashes(true));
128
+ document.getElementById('btnEdgeFontDecrease').addEventListener('click', () => changeEdgeFontSize(-1));
129
+ document.getElementById('btnEdgeFontIncrease').addEventListener('click', () => changeEdgeFontSize(1));
130
+ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeLabelEdit);
131
+
132
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────────
133
+
134
+ document.addEventListener('keydown', (e) => {
135
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
136
+ if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
137
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
138
+ if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
139
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
140
+ if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
141
+ if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
142
+ if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
143
+ if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
144
+ if (e.key === 'c' || e.key === 'C') { setTool('addNode', 'circle'); return; }
145
+ if (e.key === 'a' || e.key === 'A') { setTool('addNode', 'actor'); return; }
146
+ if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
147
+ if (e.key === 'p' || e.key === 'P') { setTool('addNode', 'post-it'); return; }
148
+ if (e.key === 't' || e.key === 'T') { setTool('addNode', 'text-free'); return; }
149
+ if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
150
+ });
151
+
152
+ // ── Dark mode initialisation ──────────────────────────────────────────────────
153
+
154
+ document.getElementById('darkIcon').textContent =
155
+ document.documentElement.classList.contains('dark') ? '☀' : '☽';
156
+
157
+ // ── Config fetch & debug button visibility ────────────────────────────────────
158
+
159
+ (async () => {
160
+ try {
161
+ const cfg = await fetch('/api/config').then((r) => r.json());
162
+ if (cfg.showDiagramDebug) {
163
+ document.getElementById('btnDebug').classList.remove('hidden');
164
+ document.getElementById('sepDebug').classList.remove('hidden');
165
+ }
166
+ } catch {
167
+ /* config unavailable — debug button stays hidden */
168
+ }
169
+ })();
170
+
171
+ // ── Bootstrap ─────────────────────────────────────────────────────────────────
172
+
173
+ loadDiagramList();