living-documentation 3.7.0 → 3.8.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.
@@ -1,9 +1,78 @@
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
+ // Grab vis-network's canvas element
49
+ const visCanvas = document.querySelector('#vis-canvas canvas');
50
+ if (!visCanvas) return;
51
+
52
+ // Crop into an offscreen canvas
53
+ const out = document.createElement('canvas');
54
+ out.width = cropW * dpr;
55
+ out.height = cropH * dpr;
56
+ const octx = out.getContext('2d');
57
+
58
+ // Fill background matching current theme
59
+ const isDark = document.documentElement.classList.contains('dark');
60
+ octx.fillStyle = isDark ? '#030712' : '#f9fafb';
61
+ octx.fillRect(0, 0, out.width, out.height);
62
+
63
+ octx.drawImage(visCanvas,
64
+ cropX * dpr, cropY * dpr, cropW * dpr, cropH * dpr,
65
+ 0, 0, out.width, out.height
66
+ );
67
+
68
+ try {
69
+ const blob = await new Promise((res) => out.toBlob(res, 'image/png'));
70
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
71
+ showToast('PNG copié dans le presse-papier');
72
+ } catch {
73
+ showToast('Impossible de copier l\'image', 'error');
74
+ }
75
+ }
7
76
 
8
77
  export function copySelected() {
9
78
  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,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
+ }
@@ -3,7 +3,7 @@
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
7
  import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
8
8
  import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
9
9
  import { hideSelectionOverlay } from './selection-overlay.js';
@@ -11,7 +11,10 @@ import { togglePhysics, toggleGrid } from './grid.js';
11
11
  import { toggleDebug } from './debug.js';
12
12
  import { adjustZoom, resetZoom } from './zoom.js';
13
13
  import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
14
- import { copySelected, pasteClipboard } from './clipboard.js';
14
+ import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
15
+ import { createImageNode } from './network.js';
16
+ import { uploadImageBlob } from './image-upload.js';
17
+ import { showToast } from './toast.js';
15
18
 
16
19
  // ── Tool management ───────────────────────────────────────────────────────────
17
20
 
@@ -32,6 +35,14 @@ function setTool(tool, shape) {
32
35
  else if (st.network) st.network.disableEditMode();
33
36
  }
34
37
 
38
+ function selectAll() {
39
+ if (!st.network || !st.nodes) return;
40
+ const ids = st.nodes.getIds();
41
+ st.network.selectNodes(ids);
42
+ st.selectedNodeIds = ids;
43
+ showNodePanel();
44
+ }
45
+
35
46
  function deleteSelected() {
36
47
  if (!st.network) return;
37
48
  st.network.deleteSelected();
@@ -71,6 +82,7 @@ document.getElementById('toolCircle').addEventListener('click', () => setTool(
71
82
  document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
72
83
  document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
73
84
  document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
85
+ document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
74
86
  document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
75
87
 
76
88
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
@@ -106,17 +118,19 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
106
118
  document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
107
119
  document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
108
120
  document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
121
+ document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
122
+ document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
109
123
  // Stamp buttons: capture targets on mousedown (before vis-network can fire
110
124
  // deselectNode), then activate the stamp mode on click.
111
- ['btnStampColor', 'btnStampRotation', 'btnStampFontSize'].forEach((id) => {
125
+ ['btnStampColor', 'btnStampFontSize'].forEach((id) => {
112
126
  document.getElementById(id).addEventListener('mousedown', (e) => {
113
127
  e.preventDefault(); // prevent canvas focus loss
114
128
  st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
115
129
  });
116
130
  });
117
131
  document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
118
- document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
119
132
  document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
133
+ document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
120
134
 
121
135
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
122
136
 
@@ -134,7 +148,9 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
134
148
  document.addEventListener('keydown', (e) => {
135
149
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
136
150
  if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
137
- if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
151
+ if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
152
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
153
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
138
154
  if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
139
155
  if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
140
156
  if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
@@ -149,6 +165,28 @@ document.addEventListener('keydown', (e) => {
149
165
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
150
166
  });
151
167
 
168
+ // ── Paste image from clipboard ────────────────────────────────────────────────
169
+ document.addEventListener('paste', async (e) => {
170
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
171
+ if (!st.network) return;
172
+ const items = Array.from(e.clipboardData.items || []);
173
+ const imageItem = items.find((it) => it.type.startsWith('image/'));
174
+ if (!imageItem) return;
175
+ e.preventDefault();
176
+ const blob = imageItem.getAsFile();
177
+ if (!blob) return;
178
+ const ext = imageItem.type.split('/')[1] || 'png';
179
+ try {
180
+ const src = await uploadImageBlob(blob, ext);
181
+ // Place at centre of current viewport
182
+ const center = st.network.getViewPosition();
183
+ createImageNode(src, center.x, center.y);
184
+ showToast('Image ajoutée');
185
+ } catch {
186
+ showToast('Impossible d\'importer l\'image', 'error');
187
+ }
188
+ });
189
+
152
190
  // ── Dark mode initialisation ──────────────────────────────────────────────────
153
191
 
154
192
  document.getElementById('darkIcon').textContent =
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { st, markDirty } from './state.js';
6
6
  import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
7
+ import { uploadImageFile } from './image-upload.js';
8
+ import { showToast } from './toast.js';
7
9
  import { visEdgeProps } from './edge-rendering.js';
8
10
  import { showNodePanel, hideNodePanel } from './node-panel.js';
9
11
  import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
@@ -120,6 +122,9 @@ function onDoubleClick(params) {
120
122
  st.selectedEdgeIds = [params.edges[0]];
121
123
  showEdgePanel();
122
124
  startEdgeLabelEdit();
125
+ } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
126
+ const canvasPos = params.pointer.canvas;
127
+ pickAndCreateImageNode(canvasPos.x, canvasPos.y);
123
128
  } else if (st.currentTool === 'addNode') {
124
129
  const id = 'n' + Date.now();
125
130
  const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
@@ -142,6 +147,45 @@ function onDoubleClick(params) {
142
147
  }
143
148
  }
144
149
 
150
+ // ── Image node creation ───────────────────────────────────────────────────────
151
+
152
+ function pickAndCreateImageNode(canvasX, canvasY) {
153
+ const input = document.createElement('input');
154
+ input.type = 'file';
155
+ input.accept = 'image/*';
156
+ input.onchange = async () => {
157
+ const file = input.files && input.files[0];
158
+ if (!file) return;
159
+ try {
160
+ const src = await uploadImageFile(file);
161
+ createImageNode(src, canvasX, canvasY);
162
+ } catch {
163
+ showToast('Impossible d\'importer l\'image', 'error');
164
+ }
165
+ };
166
+ input.click();
167
+ }
168
+
169
+ export function createImageNode(imageSrc, canvasX, canvasY) {
170
+ if (!st.network) return;
171
+ const id = 'n' + Date.now();
172
+ const defaults = SHAPE_DEFAULTS['image'];
173
+ st.nodes.add({
174
+ id, label: '', imageSrc,
175
+ shapeType: 'image', colorKey: 'c-gray',
176
+ nodeWidth: defaults[0], nodeHeight: defaults[1],
177
+ fontSize: null, rotation: 0, labelRotation: 0,
178
+ x: canvasX, y: canvasY,
179
+ ...visNodeProps('image', 'c-gray', defaults[0], defaults[1], null, null, null),
180
+ });
181
+ markDirty();
182
+ setTimeout(() => {
183
+ st.network.selectNodes([id]);
184
+ st.selectedNodeIds = [id];
185
+ showNodePanel();
186
+ }, 50);
187
+ }
188
+
145
189
  function onSelectNode(params) {
146
190
  st.selectedNodeIds = params.nodes;
147
191
  st.selectedEdgeIds = [];
@@ -154,6 +154,22 @@ document.getElementById('stampOverlay').addEventListener('click', (e) => {
154
154
  }
155
155
  });
156
156
 
157
+ // ── Step rotation ─────────────────────────────────────────────────────────────
158
+
159
+ export function stepRotate(degrees) {
160
+ if (!st.selectedNodeIds.length) return;
161
+ const delta = degrees * (Math.PI / 180);
162
+ st.selectedNodeIds.forEach((id) => {
163
+ const n = st.nodes.get(id);
164
+ if (!n) return;
165
+ st.nodes.update({ id, rotation: (n.rotation || 0) + delta });
166
+ const bn = st.network && st.network.body.nodes[id];
167
+ if (bn) bn.refreshNeeded = true;
168
+ });
169
+ if (st.network) st.network.redraw();
170
+ markDirty();
171
+ }
172
+
157
173
  export function changeZOrder(direction) {
158
174
  // direction: +1 = bring to front (last in canonicalOrder = drawn on top)
159
175
  // -1 = send to back (first in canonicalOrder = drawn below)
@@ -273,6 +273,67 @@ export function makeActorRenderer(colorKey) {
273
273
  };
274
274
  }
275
275
 
276
+ // ── Image node ────────────────────────────────────────────────────────────────
277
+ // Images are loaded once and cached. When still loading a placeholder is drawn;
278
+ // once the image is ready network.redraw() is called so the frame updates.
279
+
280
+ const _imgCache = new Map(); // src → HTMLImageElement | 'loading' | 'error'
281
+
282
+ function getCachedImage(src, redrawFn) {
283
+ if (!src) return null;
284
+ const cached = _imgCache.get(src);
285
+ if (cached === 'loading' || cached === 'error') return null;
286
+ if (cached) return cached;
287
+ _imgCache.set(src, 'loading');
288
+ const img = new Image();
289
+ img.onload = () => { _imgCache.set(src, img); redrawFn && redrawFn(); };
290
+ img.onerror = () => { _imgCache.set(src, 'error'); };
291
+ img.src = src;
292
+ return null;
293
+ }
294
+
295
+ export function makeImageRenderer(colorKey) {
296
+ return function ({ ctx, x, y, id, state: visState, label }) {
297
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 160, 120, colorKey || 'c-gray');
298
+ const n = st.nodes && st.nodes.get(id);
299
+ const src = n && n.imageSrc;
300
+ const img = getCachedImage(src, () => st.network && st.network.redraw());
301
+ return {
302
+ drawNode() {
303
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
304
+ // Border (always visible, orange when selected)
305
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
306
+ ctx.lineWidth = visState.selected ? 2 : 1;
307
+ roundRect(ctx, -W / 2, -H / 2, W, H, 4);
308
+ ctx.stroke();
309
+
310
+ if (img) {
311
+ // Clip to rounded rect then draw image
312
+ ctx.save();
313
+ roundRect(ctx, -W / 2, -H / 2, W, H, 4);
314
+ ctx.clip();
315
+ ctx.drawImage(img, -W / 2, -H / 2, W, H);
316
+ ctx.restore();
317
+ } else {
318
+ // Placeholder: light fill + icon
319
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
320
+ roundRect(ctx, -W / 2, -H / 2, W, H, 4);
321
+ ctx.fill();
322
+ ctx.fillStyle = c.border;
323
+ ctx.font = `${Math.round(Math.min(W, H) * 0.25)}px system-ui`;
324
+ ctx.textAlign = 'center';
325
+ ctx.textBaseline = 'middle';
326
+ ctx.fillText(src ? '…' : '🖼', 0, 0);
327
+ }
328
+
329
+ if (label) drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
330
+ ctx.restore();
331
+ },
332
+ nodeDimensions: { width: W, height: H },
333
+ };
334
+ };
335
+ }
336
+
276
337
  // ── Public API ────────────────────────────────────────────────────────────────
277
338
 
278
339
  // Returns the rendered height from vis-network internals (used by node-panel
@@ -294,6 +355,7 @@ const RENDERER_MAP = {
294
355
  'post-it': makePostItRenderer,
295
356
  'text-free':makeTextFreeRenderer,
296
357
  actor: makeActorRenderer,
358
+ image: makeImageRenderer,
297
359
  };
298
360
 
299
361
  // Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
@@ -305,6 +367,7 @@ export const SHAPE_DEFAULTS = {
305
367
  actor: [30, 52],
306
368
  'post-it': [120, 100],
307
369
  'text-free':[80, 30],
370
+ image: [160, 120],
308
371
  };
309
372
 
310
373
  // Builds the full vis.js node property object.
@@ -116,6 +116,7 @@ export async function saveDiagram() {
116
116
  nodeWidth: n.nodeWidth || null, nodeHeight: n.nodeHeight || null,
117
117
  fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
118
118
  rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
119
+ imageSrc: n.imageSrc || null,
119
120
  x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
120
121
  }));
121
122
 
@@ -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
+ }
@@ -210,6 +210,47 @@
210
210
  }
211
211
  #rh-label-rotate:active { cursor: grabbing; }
212
212
  .dark #rh-label-rotate { background: #374151; }
213
+
214
+ /* Toast notifications */
215
+ #toastContainer {
216
+ position: fixed;
217
+ bottom: 1.5rem;
218
+ right: 1.5rem;
219
+ display: flex;
220
+ flex-direction: column-reverse;
221
+ gap: 0.5rem;
222
+ z-index: 1000;
223
+ pointer-events: none;
224
+ }
225
+ .ld-toast {
226
+ pointer-events: all;
227
+ padding: 0.6rem 1rem;
228
+ border-radius: 0.5rem;
229
+ font-size: 0.8rem;
230
+ font-weight: 500;
231
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
232
+ cursor: pointer;
233
+ opacity: 0;
234
+ transform: translateY(0.5rem);
235
+ transition: opacity 0.2s ease, transform 0.2s ease;
236
+ background: #1c1917;
237
+ color: #fafaf9;
238
+ border: 1px solid #292524;
239
+ }
240
+ .dark .ld-toast {
241
+ background: #fafaf9;
242
+ color: #1c1917;
243
+ border-color: #e7e5e4;
244
+ }
245
+ .ld-toast--error {
246
+ background: #7f1d1d;
247
+ color: #fef2f2;
248
+ border-color: #991b1b;
249
+ }
250
+ .ld-toast--visible {
251
+ opacity: 1;
252
+ transform: translateY(0);
253
+ }
213
254
  </style>
214
255
  </head>
215
256
  <body
@@ -358,6 +399,17 @@
358
399
  >
359
400
  T
360
401
  </button>
402
+ <button
403
+ id="toolImage"
404
+ class="tool-btn"
405
+ title="Image — double-clic sur le canvas pour choisir un fichier, ou coller (⌘V) depuis le presse-papier"
406
+ >
407
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
408
+ <rect x="1" y="1" width="12" height="12" rx="1.5"/>
409
+ <circle cx="4.5" cy="4.5" r="1.2"/>
410
+ <path d="M1 9.5 L4 6.5 L6.5 9 L9 7 L13 10.5"/>
411
+ </svg>
412
+ </button>
361
413
  <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
362
414
 
363
415
  <button
@@ -538,7 +590,14 @@
538
590
  style="bottom: -5px; right: -5px; cursor: se-resize"
539
591
  ></div>
540
592
  <div id="rh-rotate" title="Rotation forme">↻</div>
541
- <div id="rh-label-rotate" title="Rotation texte" style="left: 0; top: -28px;">T↻</div>
593
+ <div id="rh-label-rotate" title="Rotation texte" style="left: 0; top: -28px;">
594
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
595
+ <g transform="rotate(25,5,5)">
596
+ <line x1="2.5" y1="3" x2="7.5" y2="3"/>
597
+ <line x1="5" y1="3" x2="5" y2="8"/>
598
+ </g>
599
+ </svg>
600
+ </div>
542
601
  </div>
543
602
 
544
603
  <!-- Node panel -->
@@ -814,31 +873,14 @@
814
873
 
815
874
  <div class="panel-sep"></div>
816
875
 
817
- <!-- Stamp: copy color -->
876
+ <!-- Stamp: copy color (goutte) -->
818
877
  <button
819
878
  id="btnStampColor"
820
879
  class="tool-btn !w-7 !h-6"
821
880
  title="Tampon couleur — sélectionner les cibles, cliquer ici, puis cliquer la source"
822
881
  >
823
882
  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
824
- <rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" stroke="none" opacity="0.5"/>
825
- <rect x="7" y="7" width="6" height="6" rx="1" fill="currentColor" stroke="none" opacity="0.5"/>
826
- <path d="M7 3.5h4M3.5 7v4"/>
827
- <circle cx="10.5" cy="10.5" r="1" fill="currentColor" stroke="none"/>
828
- </svg>
829
- </button>
830
-
831
- <!-- Stamp: copy rotation -->
832
- <button
833
- id="btnStampRotation"
834
- class="tool-btn !w-7 !h-6"
835
- title="Tampon rotation — sélectionner les cibles, cliquer ici, puis cliquer la source"
836
- >
837
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
838
- <path d="M2 7a5 5 0 1 0 1.5-3.5"/>
839
- <polyline points="1,1 1.5,3.5 4,3"/>
840
- <line x1="7" y1="4" x2="7" y2="7" stroke-width="1.8"/>
841
- <line x1="7" y1="7" x2="9" y2="7" stroke-width="1.8"/>
883
+ <path d="M7 2 C7 2 3 6.5 3 9 a4 4 0 0 0 8 0 C11 6.5 7 2 7 2 Z" fill="currentColor" fill-opacity="0.25"/>
842
884
  </svg>
843
885
  </button>
844
886
 
@@ -848,6 +890,40 @@
848
890
  class="tool-btn !w-7 !h-6 font-mono text-xs font-bold"
849
891
  title="Tampon taille police — sélectionner les cibles, cliquer ici, puis cliquer la source"
850
892
  >Aa</button>
893
+
894
+ <div class="panel-sep"></div>
895
+
896
+ <!-- Rotation anti-horaire 10° -->
897
+ <button
898
+ id="btnRotateCCW"
899
+ class="tool-btn !w-7 !h-6"
900
+ title="Rotation anti-horaire 10°"
901
+ style="font-size:15px; line-height:1;"
902
+ >↺</button>
903
+
904
+ <!-- Rotation horaire 10° -->
905
+ <button
906
+ id="btnRotateCW"
907
+ class="tool-btn !w-7 !h-6"
908
+ title="Rotation horaire 10°"
909
+ style="font-size:15px; line-height:1;"
910
+ >↻</button>
911
+
912
+ <div class="panel-sep"></div>
913
+
914
+ <!-- Copy as PNG -->
915
+ <button
916
+ id="btnCopyPng"
917
+ class="tool-btn !h-6 px-1.5 font-mono text-xs font-semibold flex items-center gap-0.5"
918
+ title="Copier la sélection en PNG (⌘⇧C)"
919
+ >
920
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
921
+ <line x1="5" y1="7" x2="5" y2="1"/>
922
+ <polyline points="2,4 5,1 8,4"/>
923
+ <line x1="1" y1="9" x2="9" y2="9"/>
924
+ </svg>
925
+ PNG
926
+ </button>
851
927
  </div>
852
928
 
853
929
  <!-- Edge panel -->
@@ -969,6 +1045,7 @@
969
1045
  </div>
970
1046
  </div>
971
1047
 
1048
+ <div id="toastContainer"></div>
972
1049
  <script type="module" src="/diagram/main.js"></script>
973
1050
  </body>
974
1051
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {