living-documentation 3.7.0 → 4.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/README.md CHANGED
@@ -22,6 +22,8 @@ No cloud, no database, no build step — just point it at a folder of `.md` file
22
22
  - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
23
23
  - **Inline editing** — edit any document directly in the browser, saves to disk instantly
24
24
  - **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
25
+ - **Word Cloud** — visualise the dominant vocabulary of any folder on disk; supports `.md`, `.ts`, `.java`, `.kt`, `.py`, `.go`, `.rs`, `.cs`, `.swift`, `.rb`, `.html`, `.css`, `.yml`, `.json` and more; stop words filtered per language
26
+ - **Diagram editor** — built-in canvas diagram editor (vis-network); deep-link to any diagram with `?id=`
25
27
  - **Zero frontend build** — Tailwind and highlight.js loaded from CDN
26
28
 
27
29
  ---
@@ -1,9 +1,88 @@
1
1
  // ── Clipboard (copy / paste) ───────────────────────────────────────────────────
2
2
 
3
3
  import { st, markDirty } from './state.js';
4
- import { visNodeProps } from './node-rendering.js';
4
+ import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
5
5
  import { visEdgeProps } from './edge-rendering.js';
6
6
  import { showNodePanel } from './node-panel.js';
7
+ import { showToast } from './toast.js';
8
+
9
+ // ── Copy selection as PNG ─────────────────────────────────────────────────────
10
+
11
+ export async function copySelectionAsPng() {
12
+ if (!st.network || !st.selectedNodeIds.length) return;
13
+
14
+ const PAD = 20;
15
+ const dpr = window.devicePixelRatio || 1;
16
+
17
+ // Compute bounding box in canvas coordinates
18
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
19
+ for (const id of st.selectedNodeIds) {
20
+ const n = st.nodes.get(id);
21
+ const bn = st.network.body.nodes[id];
22
+ if (!bn) continue;
23
+ const defaults = SHAPE_DEFAULTS[(n && n.shapeType) || 'box'] || [100, 40];
24
+ const w = (n && n.nodeWidth) || defaults[0];
25
+ const h = (n && n.nodeHeight) || defaults[1];
26
+ const rot = (n && n.rotation) || 0;
27
+ let hw, hh;
28
+ if (rot === 0) {
29
+ hw = w / 2; hh = h / 2;
30
+ } else {
31
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
32
+ hw = (w * cos + h * sin) / 2;
33
+ hh = (w * sin + h * cos) / 2;
34
+ }
35
+ minX = Math.min(minX, bn.x - hw); maxX = Math.max(maxX, bn.x + hw);
36
+ minY = Math.min(minY, bn.y - hh); maxY = Math.max(maxY, bn.y + hh);
37
+ }
38
+
39
+ // Convert canvas coords to DOM pixels
40
+ const tl = st.network.canvasToDOM({ x: minX, y: minY });
41
+ const br = st.network.canvasToDOM({ x: maxX, y: maxY });
42
+
43
+ const cropX = Math.max(0, Math.floor(tl.x - PAD));
44
+ const cropY = Math.max(0, Math.floor(tl.y - PAD));
45
+ const cropW = Math.ceil(br.x - tl.x + PAD * 2);
46
+ const cropH = Math.ceil(br.y - tl.y + PAD * 2);
47
+
48
+ // Temporarily deselect everything so orange highlights don't appear in the PNG
49
+ const savedNodeIds = [...st.selectedNodeIds];
50
+ st.network.unselectAll();
51
+ st.network.redraw();
52
+
53
+ // Grab vis-network's canvas element
54
+ const visCanvas = document.querySelector('#vis-canvas canvas');
55
+ if (!visCanvas) {
56
+ st.network.selectNodes(savedNodeIds);
57
+ return;
58
+ }
59
+
60
+ // Crop into an offscreen canvas
61
+ const out = document.createElement('canvas');
62
+ out.width = cropW * dpr;
63
+ out.height = cropH * dpr;
64
+ const octx = out.getContext('2d');
65
+
66
+ // Fill background matching current theme
67
+ const isDark = document.documentElement.classList.contains('dark');
68
+ octx.fillStyle = isDark ? '#030712' : '#f9fafb';
69
+ octx.fillRect(0, 0, out.width, out.height);
70
+
71
+ octx.drawImage(visCanvas,
72
+ cropX * dpr, cropY * dpr, cropW * dpr, cropH * dpr,
73
+ 0, 0, out.width, out.height
74
+ );
75
+
76
+ try {
77
+ const blob = await new Promise((res) => out.toBlob(res, 'image/png'));
78
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
79
+ showToast('PNG copié dans le presse-papier');
80
+ } catch {
81
+ showToast('Impossible de copier l\'image', 'error');
82
+ } finally {
83
+ st.network.selectNodes(savedNodeIds);
84
+ }
85
+ }
7
86
 
8
87
  export function copySelected() {
9
88
  if (!st.network || !st.selectedNodeIds.length) return;
@@ -12,6 +12,7 @@ export const TOOL_BTN_MAP = {
12
12
  'addNode:actor': 'toolActor',
13
13
  'addNode:post-it': 'toolPostIt',
14
14
  'addNode:text-free': 'toolTextFree',
15
+ 'addNode:image': 'toolImage',
15
16
  addEdge: 'toolArrow',
16
17
  };
17
18
 
@@ -0,0 +1,99 @@
1
+ // ── Group management ──────────────────────────────────────────────────────────
2
+
3
+ import { st, markDirty } from './state.js';
4
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
5
+
6
+ // ── Create / destroy ──────────────────────────────────────────────────────────
7
+
8
+ export function groupNodes() {
9
+ if (st.selectedNodeIds.length < 2) return;
10
+ const groupId = 'g' + Date.now();
11
+ st.selectedNodeIds.forEach((id) => st.nodes.update({ id, groupId }));
12
+ markDirty();
13
+ }
14
+
15
+ export function ungroupNodes() {
16
+ if (!st.selectedNodeIds.length) return;
17
+ // Collect all members of any group touched by the selection
18
+ const groupIds = new Set(
19
+ st.selectedNodeIds.map((id) => { const n = st.nodes.get(id); return n && n.groupId; }).filter(Boolean)
20
+ );
21
+ st.nodes.get().forEach((n) => {
22
+ if (n.groupId && groupIds.has(n.groupId)) st.nodes.update({ id: n.id, groupId: null });
23
+ });
24
+ markDirty();
25
+ }
26
+
27
+ // ── Selection expansion ───────────────────────────────────────────────────────
28
+ // Called from onSelectNode — expands selection to all group members.
29
+
30
+ export function expandSelectionToGroup(nodeIds) {
31
+ const groupIds = new Set();
32
+ nodeIds.forEach((id) => {
33
+ const n = st.nodes.get(id);
34
+ if (n && n.groupId) groupIds.add(n.groupId);
35
+ });
36
+ if (!groupIds.size) return nodeIds;
37
+
38
+ const expanded = new Set(nodeIds);
39
+ st.nodes.get().forEach((n) => {
40
+ if (n.groupId && groupIds.has(n.groupId)) expanded.add(n.id);
41
+ });
42
+ return [...expanded];
43
+ }
44
+
45
+ // ── Group outline (drawn on canvas in afterDrawing) ───────────────────────────
46
+
47
+ function nodeBounds(id) {
48
+ const n = st.nodes.get(id);
49
+ const bodyNode = st.network.body.nodes[id];
50
+ if (!bodyNode) return null;
51
+ const shape = (n && n.shapeType) || 'box';
52
+ const defaults = SHAPE_DEFAULTS[shape] || [100, 40];
53
+ const W = (n && n.nodeWidth) || defaults[0];
54
+ const H = (n && n.nodeHeight) || defaults[1];
55
+ const rot = (n && n.rotation) || 0;
56
+ const cx = bodyNode.x, cy = bodyNode.y;
57
+ if (rot === 0) {
58
+ return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
59
+ }
60
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
61
+ const hw = (W * cos + H * sin) / 2;
62
+ const hh = (W * sin + H * cos) / 2;
63
+ return { minX: cx - hw, minY: cy - hh, maxX: cx + hw, maxY: cy + hh };
64
+ }
65
+
66
+ export function drawGroupOutlines(ctx) {
67
+ if (!st.network || !st.selectedNodeIds.length) return;
68
+
69
+ // Find groupIds that have at least one selected member
70
+ const selectedSet = new Set(st.selectedNodeIds);
71
+ const activeGroups = new Set();
72
+ st.selectedNodeIds.forEach((id) => {
73
+ const n = st.nodes.get(id);
74
+ if (n && n.groupId) activeGroups.add(n.groupId);
75
+ });
76
+ if (!activeGroups.size) return;
77
+
78
+ // For each active group, compute bounding box over ALL members
79
+ activeGroups.forEach((groupId) => {
80
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
81
+ st.nodes.get().forEach((n) => {
82
+ if (n.groupId !== groupId) return;
83
+ const b = nodeBounds(n.id);
84
+ if (!b) return;
85
+ minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
86
+ maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
87
+ });
88
+ if (minX === Infinity) return;
89
+
90
+ const PAD = 14;
91
+ ctx.save();
92
+ ctx.strokeStyle = '#6366f1';
93
+ ctx.lineWidth = 1.5;
94
+ ctx.setLineDash([6, 4]);
95
+ ctx.strokeRect(minX - PAD, minY - PAD, maxX - minX + PAD * 2, maxY - minY + PAD * 2);
96
+ ctx.setLineDash([]);
97
+ ctx.restore();
98
+ });
99
+ }
@@ -0,0 +1,34 @@
1
+ // ── Image upload helper ───────────────────────────────────────────────────────
2
+ // Converts a File or Blob to base64 and uploads it via POST /api/images/upload.
3
+ // Returns the absolute URL path usable in an <img> or ctx.drawImage(), e.g. "/images/foo.png".
4
+
5
+ async function toBase64(blob) {
6
+ return new Promise((resolve, reject) => {
7
+ const reader = new FileReader();
8
+ reader.onload = () => resolve(reader.result);
9
+ reader.onerror = reject;
10
+ reader.readAsDataURL(blob);
11
+ });
12
+ }
13
+
14
+ export async function uploadImageFile(file) {
15
+ const ext = (file.name.split('.').pop() || 'png').toLowerCase();
16
+ const base64 = await toBase64(file);
17
+ return _upload(base64, ext);
18
+ }
19
+
20
+ export async function uploadImageBlob(blob, ext = 'png') {
21
+ const base64 = await toBase64(blob);
22
+ return _upload(base64, ext);
23
+ }
24
+
25
+ async function _upload(base64, ext) {
26
+ const res = await fetch('/api/images/upload', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ data: base64, ext }),
30
+ });
31
+ if (!res.ok) throw new Error('Upload failed');
32
+ const { filename } = await res.json();
33
+ return `/images/${filename}`;
34
+ }
@@ -0,0 +1,138 @@
1
+ // ── Node link panel ───────────────────────────────────────────────────────────
2
+ // Lets the user attach a URL or diagram link to a node.
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { openDiagram } from './persistence.js';
6
+ import { showToast } from './toast.js';
7
+
8
+ let _panelNodeId = null;
9
+
10
+ export function showLinkPanel(nodeId) {
11
+ _panelNodeId = nodeId;
12
+ const n = st.nodes.get(nodeId);
13
+ const link = (n && n.nodeLink) || null;
14
+
15
+ const panel = document.getElementById('linkPanel');
16
+ panel.classList.remove('hidden');
17
+
18
+ // Populate form from existing link
19
+ const typeUrl = document.getElementById('linkTypeUrl');
20
+ const typeDiagram = document.getElementById('linkTypeDiagram');
21
+ const typeNew = document.getElementById('linkTypeNew');
22
+ const urlInput = document.getElementById('linkUrlInput');
23
+ const diagSelect = document.getElementById('linkDiagramSelect');
24
+ const newNameInput = document.getElementById('linkNewName');
25
+
26
+ // Populate diagram list
27
+ diagSelect.innerHTML = '';
28
+ st.diagrams.forEach((d) => {
29
+ if (d.id === st.currentDiagramId) return; // skip current
30
+ const opt = document.createElement('option');
31
+ opt.value = d.id;
32
+ opt.textContent = d.title;
33
+ diagSelect.appendChild(opt);
34
+ });
35
+
36
+ if (link && link.type === 'url') {
37
+ typeUrl.checked = true;
38
+ urlInput.value = link.value;
39
+ newNameInput.value = '';
40
+ } else if (link && link.type === 'diagram') {
41
+ typeDiagram.checked = true;
42
+ diagSelect.value = link.value;
43
+ urlInput.value = '';
44
+ newNameInput.value = '';
45
+ } else {
46
+ typeUrl.checked = true;
47
+ urlInput.value = '';
48
+ newNameInput.value = '';
49
+ }
50
+
51
+ _syncLinkPanelVisibility();
52
+ }
53
+
54
+ export function hideLinkPanel() {
55
+ document.getElementById('linkPanel').classList.add('hidden');
56
+ _panelNodeId = null;
57
+ }
58
+
59
+ function _syncLinkPanelVisibility() {
60
+ const typeUrl = document.getElementById('linkTypeUrl').checked;
61
+ const typeDiagram = document.getElementById('linkTypeDiagram').checked;
62
+ const typeNew = document.getElementById('linkTypeNew').checked;
63
+ document.getElementById('linkUrlRow').classList.toggle('hidden', !typeUrl);
64
+ document.getElementById('linkDiagramRow').classList.toggle('hidden', !typeDiagram);
65
+ document.getElementById('linkNewRow').classList.toggle('hidden', !typeNew);
66
+ }
67
+
68
+ export function saveLinkPanel() {
69
+ if (_panelNodeId === null) return;
70
+ const typeUrl = document.getElementById('linkTypeUrl').checked;
71
+ const typeDiagram = document.getElementById('linkTypeDiagram').checked;
72
+ const typeNew = document.getElementById('linkTypeNew').checked;
73
+
74
+ if (typeUrl) {
75
+ const value = document.getElementById('linkUrlInput').value.trim();
76
+ st.nodes.update({ id: _panelNodeId, nodeLink: value ? { type: 'url', value } : null });
77
+ markDirty();
78
+ hideLinkPanel();
79
+ } else if (typeDiagram) {
80
+ const value = document.getElementById('linkDiagramSelect').value;
81
+ if (!value) return;
82
+ st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value } });
83
+ markDirty();
84
+ hideLinkPanel();
85
+ } else if (typeNew) {
86
+ const name = document.getElementById('linkNewName').value.trim();
87
+ if (!name) return;
88
+ _createAndLinkDiagram(name);
89
+ }
90
+ }
91
+
92
+ async function _createAndLinkDiagram(title) {
93
+ const id = 'd' + Date.now();
94
+ await fetch(`/api/diagrams/${id}`, {
95
+ method: 'PUT',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ title, nodes: [], edges: [] }),
98
+ });
99
+ const res = await fetch('/api/diagrams');
100
+ st.diagrams = await res.json();
101
+ st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value: id } });
102
+ markDirty();
103
+ hideLinkPanel();
104
+ showToast(`Diagramme "${title}" créé et lié`);
105
+ }
106
+
107
+ export function removeLinkPanel() {
108
+ if (_panelNodeId === null) return;
109
+ st.nodes.update({ id: _panelNodeId, nodeLink: null });
110
+ markDirty();
111
+ hideLinkPanel();
112
+ }
113
+
114
+ // ── Navigation ────────────────────────────────────────────────────────────────
115
+ // Called on click (not drag). Returns true if navigation happened.
116
+
117
+ export function navigateNodeLink(nodeId) {
118
+ const n = st.nodes.get(nodeId);
119
+ const link = n && n.nodeLink;
120
+ if (!link) return false;
121
+ if (link.type === 'url') {
122
+ window.open(link.value, '_blank', 'noopener');
123
+ return true;
124
+ }
125
+ if (link.type === 'diagram') {
126
+ openDiagram(link.value);
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ // ── Wire radio buttons ────────────────────────────────────────────────────────
133
+ ['linkTypeUrl', 'linkTypeDiagram', 'linkTypeNew'].forEach((id) => {
134
+ document.getElementById(id).addEventListener('change', _syncLinkPanelVisibility);
135
+ });
136
+ document.getElementById('btnLinkSave').addEventListener('click', saveLinkPanel);
137
+ document.getElementById('btnLinkRemove').addEventListener('click', removeLinkPanel);
138
+ document.getElementById('btnLinkCancel').addEventListener('click', hideLinkPanel);
@@ -3,7 +3,9 @@
3
3
 
4
4
  import { st, markDirty } from './state.js';
5
5
  import { TOOL_BTN_MAP } from './constants.js';
6
- import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder, activateStamp, cancelStamp } from './node-panel.js';
6
+ import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder, activateStamp, cancelStamp, stepRotate } from './node-panel.js';
7
+ import { groupNodes, ungroupNodes } from './groups.js';
8
+ import { showLinkPanel, hideLinkPanel } from './link-panel.js';
7
9
  import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
8
10
  import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
9
11
  import { hideSelectionOverlay } from './selection-overlay.js';
@@ -11,7 +13,10 @@ import { togglePhysics, toggleGrid } from './grid.js';
11
13
  import { toggleDebug } from './debug.js';
12
14
  import { adjustZoom, resetZoom } from './zoom.js';
13
15
  import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
14
- import { copySelected, pasteClipboard } from './clipboard.js';
16
+ import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
17
+ import { createImageNode } from './network.js';
18
+ import { uploadImageBlob } from './image-upload.js';
19
+ import { showToast } from './toast.js';
15
20
 
16
21
  // ── Tool management ───────────────────────────────────────────────────────────
17
22
 
@@ -32,6 +37,14 @@ function setTool(tool, shape) {
32
37
  else if (st.network) st.network.disableEditMode();
33
38
  }
34
39
 
40
+ function selectAll() {
41
+ if (!st.network || !st.nodes) return;
42
+ const ids = st.nodes.getIds();
43
+ st.network.selectNodes(ids);
44
+ st.selectedNodeIds = ids;
45
+ showNodePanel();
46
+ }
47
+
35
48
  function deleteSelected() {
36
49
  if (!st.network) return;
37
50
  st.network.deleteSelected();
@@ -71,6 +84,7 @@ document.getElementById('toolCircle').addEventListener('click', () => setTool(
71
84
  document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
72
85
  document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
73
86
  document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
87
+ document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
74
88
  document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
75
89
 
76
90
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
@@ -96,6 +110,9 @@ document.getElementById('nodePanel').addEventListener('click', (e) => {
96
110
  if (colorBtn) setNodeColor(colorBtn.dataset.color);
97
111
  });
98
112
  document.getElementById('btnNodeLabelEdit').addEventListener('click', startLabelEdit);
113
+ document.getElementById('btnNodeLink').addEventListener('click', () => {
114
+ if (st.selectedNodeIds.length === 1) showLinkPanel(st.selectedNodeIds[0]);
115
+ });
99
116
  document.getElementById('btnNodeFontDecrease').addEventListener('click', () => changeNodeFontSize(-1));
100
117
  document.getElementById('btnNodeFontIncrease').addEventListener('click', () => changeNodeFontSize(1));
101
118
  document.getElementById('btnAlignLeft').addEventListener('click', () => setTextAlign('left'));
@@ -106,17 +123,21 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
106
123
  document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
107
124
  document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
108
125
  document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
126
+ document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
127
+ document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
109
128
  // Stamp buttons: capture targets on mousedown (before vis-network can fire
110
129
  // deselectNode), then activate the stamp mode on click.
111
- ['btnStampColor', 'btnStampRotation', 'btnStampFontSize'].forEach((id) => {
130
+ ['btnStampColor', 'btnStampFontSize'].forEach((id) => {
112
131
  document.getElementById(id).addEventListener('mousedown', (e) => {
113
132
  e.preventDefault(); // prevent canvas focus loss
114
133
  st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
115
134
  });
116
135
  });
117
136
  document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
118
- document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
119
137
  document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
138
+ document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
139
+ document.getElementById('btnGroup').addEventListener('click', () => groupNodes());
140
+ document.getElementById('btnUngroup').addEventListener('click', () => ungroupNodes());
120
141
 
121
142
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
122
143
 
@@ -134,10 +155,12 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
134
155
  document.addEventListener('keydown', (e) => {
135
156
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
136
157
  if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
137
- if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
158
+ if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
159
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
160
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
138
161
  if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
139
162
  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; }
163
+ if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
141
164
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
142
165
  if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
143
166
  if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
@@ -149,6 +172,28 @@ document.addEventListener('keydown', (e) => {
149
172
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
150
173
  });
151
174
 
175
+ // ── Paste image from clipboard ────────────────────────────────────────────────
176
+ document.addEventListener('paste', async (e) => {
177
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
178
+ if (!st.network) return;
179
+ const items = Array.from(e.clipboardData.items || []);
180
+ const imageItem = items.find((it) => it.type.startsWith('image/'));
181
+ if (!imageItem) return;
182
+ e.preventDefault();
183
+ const blob = imageItem.getAsFile();
184
+ if (!blob) return;
185
+ const ext = imageItem.type.split('/')[1] || 'png';
186
+ try {
187
+ const src = await uploadImageBlob(blob, ext);
188
+ // Place at centre of current viewport
189
+ const center = st.network.getViewPosition();
190
+ createImageNode(src, center.x, center.y);
191
+ showToast('Image ajoutée');
192
+ } catch {
193
+ showToast('Impossible d\'importer l\'image', 'error');
194
+ }
195
+ });
196
+
152
197
  // ── Dark mode initialisation ──────────────────────────────────────────────────
153
198
 
154
199
  document.getElementById('darkIcon').textContent =