living-documentation 3.6.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.

Potentially problematic release.


This version of living-documentation might be problematic. Click here for more details.

@@ -1,36 +1,44 @@
1
- // ── Selection / resize overlay ────────────────────────────────────────────────
2
- // Dashed selection box + corner resize handles for selected nodes.
1
+ // ── Selection / resize / rotate overlay ───────────────────────────────────────
2
+ // Dashed selection box, corner resize handles, and top-centre rotation handle.
3
3
 
4
4
  import { st, markDirty } from './state.js';
5
- import { visNodeProps } from './node-rendering.js';
5
+ import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
6
6
 
7
+ // ── Bounding box helper (works for all shapes including ctxRenderer) ──────────
8
+ function nodeBounds(id) {
9
+ const n = st.nodes.get(id);
10
+ const bodyNode = st.network.body.nodes[id];
11
+ if (!bodyNode) return null;
12
+ const cx = bodyNode.x, cy = bodyNode.y;
13
+ const shape = n && n.shapeType || 'box';
14
+ const defaults = SHAPE_DEFAULTS[shape] || [60, 28];
15
+ const W = (n && n.nodeWidth) || defaults[0];
16
+ const H = (n && n.nodeHeight) || defaults[1];
17
+ // Use the axis-aligned envelope of the (possibly rotated) bounding box.
18
+ const rot = (n && n.rotation) || 0;
19
+ if (rot === 0) {
20
+ return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
21
+ }
22
+ const cos = Math.abs(Math.cos(rot));
23
+ const sin = Math.abs(Math.sin(rot));
24
+ const hw = (W * cos + H * sin) / 2;
25
+ const hh = (W * sin + H * cos) / 2;
26
+ // Actor: head extends above cy - H/2 when unrotated
27
+ const headExtra = shape === 'actor' ? (28 * (H / 52) - H / 2) : 0;
28
+ return { minX: cx - hw, minY: cy - hh - headExtra, maxX: cx + hw, maxY: cy + hh };
29
+ }
30
+
31
+ // ── Overlay position ──────────────────────────────────────────────────────────
7
32
  export function updateSelectionOverlay() {
8
33
  if (!st.network || !st.selectedNodeIds.length) { hideSelectionOverlay(); return; }
9
34
 
10
35
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
11
36
  for (const id of st.selectedNodeIds) {
12
37
  try {
13
- const n = st.nodes.get(id);
14
- if (n && n.shapeType === 'actor') {
15
- // getBoundingBox() returns wrong values for custom ctxRenderer shapes.
16
- // Compute the visual bounds from the node's centre + scaled actor geometry.
17
- const bodyNode = st.network.body.nodes[id];
18
- if (!bodyNode) continue;
19
- const cx = bodyNode.x, cy = bodyNode.y;
20
- const W = n.nodeWidth || 30;
21
- const H = n.nodeHeight || 52;
22
- const sy = H / 52;
23
- minX = Math.min(minX, cx - W / 2);
24
- minY = Math.min(minY, cy - 28 * sy); // head top
25
- maxX = Math.max(maxX, cx + W / 2);
26
- maxY = Math.max(maxY, cy + 24 * sy); // legs bottom
27
- } else {
28
- const bb = st.network.getBoundingBox(id);
29
- minX = Math.min(minX, bb.left);
30
- minY = Math.min(minY, bb.top);
31
- maxX = Math.max(maxX, bb.right);
32
- maxY = Math.max(maxY, bb.bottom);
33
- }
38
+ const b = nodeBounds(id);
39
+ if (!b) continue;
40
+ minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
41
+ maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
34
42
  } catch (_) { /* node still being created */ }
35
43
  }
36
44
  if (minX === Infinity) { hideSelectionOverlay(); return; }
@@ -45,38 +53,41 @@ export function updateSelectionOverlay() {
45
53
  ov.style.width = br.x - tl.x + PAD * 2 + 'px';
46
54
  ov.style.height = br.y - tl.y + PAD * 2 + 'px';
47
55
 
56
+ // Position rotation handle: top-centre of the overlay, 28px above it.
57
+ const rh = document.getElementById('rh-rotate');
58
+ rh.style.left = (br.x - tl.x) / 2 + PAD - 8 + 'px';
59
+ rh.style.top = '-28px';
60
+
61
+ // Position label rotation handle: top-centre offset left by 24px to avoid overlap.
62
+ const lrh = document.getElementById('rh-label-rotate');
63
+ lrh.style.left = (br.x - tl.x) / 2 + PAD - 8 - 24 + 'px';
64
+ lrh.style.top = '-28px';
48
65
  }
49
66
 
50
67
  export function hideSelectionOverlay() {
51
68
  document.getElementById('selectionOverlay').style.display = 'none';
52
69
  }
53
70
 
71
+ // ── Resize ────────────────────────────────────────────────────────────────────
54
72
  function onResizeStart(e, corner) {
55
73
  if (!st.selectedNodeIds.length || !st.network) return;
56
74
  e.preventDefault();
57
75
  e.stopPropagation();
58
76
 
59
77
  const startBBs = st.selectedNodeIds.map((id) => {
60
- const n = st.nodes.get(id);
61
- // getBoundingBox() returns wrong values for custom ctxRenderer shapes (actor).
62
- // Fall back to the actor's reference dimensions when no resize has happened yet.
63
- let initW, initH;
64
- if (n && n.shapeType === 'actor') {
65
- initW = n.nodeWidth || 30;
66
- initH = n.nodeHeight || 52;
67
- } else {
68
- const bb = st.network.getBoundingBox(id);
69
- initW = n.nodeWidth || Math.round(bb.right - bb.left);
70
- initH = n.nodeHeight || Math.round(bb.bottom - bb.top);
71
- }
78
+ const n = st.nodes.get(id);
79
+ const b = nodeBounds(id);
80
+ const shape = (n && n.shapeType) || 'box';
81
+ const defaults = SHAPE_DEFAULTS[shape] || [60, 28];
82
+ const initW = (n && n.nodeWidth) || (b ? Math.round(b.maxX - b.minX) : defaults[0]);
83
+ const initH = (n && n.nodeHeight) || (b ? Math.round(b.maxY - b.minY) : defaults[1]);
72
84
  return { id, node: n, initW, initH };
73
85
  });
74
86
 
75
- const initBoxW = startBBs.reduce((max, b) => Math.max(max, b.initW), 0);
76
- const initBoxH = startBBs.reduce((max, b) => Math.max(max, b.initH), 0);
87
+ const initBoxW = startBBs.reduce((m, b) => Math.max(m, b.initW), 0);
88
+ const initBoxH = startBBs.reduce((m, b) => Math.max(m, b.initH), 0);
77
89
 
78
90
  st.resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH };
79
-
80
91
  st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: true }));
81
92
  document.getElementById('vis-canvas').style.pointerEvents = 'none';
82
93
  document.addEventListener('mousemove', onResizeDrag);
@@ -88,9 +99,8 @@ function onResizeDrag(e) {
88
99
  const scale = st.network.getScale();
89
100
  const cdx = (e.clientX - st.resizeDrag.startMouse.x) / scale;
90
101
  const cdy = (e.clientY - st.resizeDrag.startMouse.y) / scale;
91
- const MIN = 40;
102
+ const MIN = 20;
92
103
  const c = st.resizeDrag.corner;
93
-
94
104
  const updatedIds = [];
95
105
 
96
106
  if (st.resizeDrag.startBBs.length === 1) {
@@ -102,7 +112,7 @@ function onResizeDrag(e) {
102
112
  if (c === 'tl') { nW = initW - cdx; nH = initH - cdy; }
103
113
  nW = Math.max(MIN, Math.round(nW));
104
114
  nH = Math.max(MIN, Math.round(nH));
105
- st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign, nH) });
115
+ st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
106
116
  updatedIds.push(id);
107
117
  } else {
108
118
  const { initBoxW, initBoxH } = st.resizeDrag;
@@ -111,25 +121,17 @@ function onResizeDrag(e) {
111
121
  if (c === 'bl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
112
122
  if (c === 'tr') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
113
123
  if (c === 'tl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
114
- sx = Math.max(0.1, sx);
115
- sy = Math.max(0.1, sy);
124
+ sx = Math.max(0.1, sx); sy = Math.max(0.1, sy);
116
125
  for (const { id, node, initW, initH } of st.resizeDrag.startBBs) {
117
126
  const nW = Math.max(MIN, Math.round(initW * sx));
118
127
  const nH = Math.max(MIN, Math.round(initH * sy));
119
- st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign, nH) });
128
+ st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
120
129
  updatedIds.push(id);
121
130
  }
122
131
  }
123
132
 
124
- // vis-network's needsRefresh() can return false for certain shapes (e.g. database)
125
- // when only widthConstraint or heightConstraint changes, because the check is based
126
- // on label/font state, not constraints. Force the internal refresh flag and redraw.
127
- updatedIds.forEach((id) => {
128
- const bn = st.network.body.nodes[id];
129
- if (bn) bn.refreshNeeded = true;
130
- });
133
+ updatedIds.forEach((id) => { const bn = st.network.body.nodes[id]; if (bn) bn.refreshNeeded = true; });
131
134
  st.network.redraw();
132
-
133
135
  updateSelectionOverlay();
134
136
  }
135
137
 
@@ -143,7 +145,112 @@ function onResizeEnd() {
143
145
  markDirty();
144
146
  }
145
147
 
146
- // ── Wire corner handle mouse events ───────────────────────────────────────────
148
+ // ── Rotation ──────────────────────────────────────────────────────────────────
149
+ function onRotateStart(e) {
150
+ if (!st.selectedNodeIds.length || !st.network) return;
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+
154
+ // Barycentre of the selection in canvas coordinates.
155
+ const positions = st.network.getPositions(st.selectedNodeIds);
156
+ const ids = st.selectedNodeIds;
157
+ const cx = ids.reduce((s, id) => s + (positions[id] ? positions[id].x : 0), 0) / ids.length;
158
+ const cy = ids.reduce((s, id) => s + (positions[id] ? positions[id].y : 0), 0) / ids.length;
159
+
160
+ const nodeAngles = ids.map((id) => {
161
+ const n = st.nodes.get(id);
162
+ const pos = positions[id] || { x: 0, y: 0 };
163
+ return {
164
+ id,
165
+ initRotation: (n && n.rotation) || 0,
166
+ // Position relative to barycentre at drag start
167
+ relX: pos.x - cx,
168
+ relY: pos.y - cy,
169
+ };
170
+ });
171
+
172
+ // Horizontal drag → rotation: right = clockwise, left = counter-clockwise.
173
+ // 1 px = 1 degree.
174
+ st.rotateDrag = { startX: e.clientX, nodeAngles, cx, cy };
175
+ document.getElementById('vis-canvas').style.pointerEvents = 'none';
176
+ document.addEventListener('mousemove', onRotateDrag);
177
+ document.addEventListener('mouseup', onRotateEnd);
178
+ }
179
+
180
+ function onRotateDrag(e) {
181
+ if (!st.rotateDrag || !st.network) return;
182
+ const { startX, nodeAngles, cx, cy } = st.rotateDrag;
183
+ const dx = e.clientX - startX;
184
+ const delta = dx * (Math.PI / 180); // 1 px = 1 degree
185
+ const cos = Math.cos(delta);
186
+ const sin = Math.sin(delta);
187
+
188
+ nodeAngles.forEach(({ id, initRotation, relX, relY }) => {
189
+ // Rotate the node's position around the barycentre
190
+ const newX = cx + relX * cos - relY * sin;
191
+ const newY = cy + relX * sin + relY * cos;
192
+ st.network.moveNode(id, newX, newY);
193
+ // Rotate the node's own orientation
194
+ st.nodes.update({ id, rotation: initRotation + delta });
195
+ const bn = st.network.body.nodes[id];
196
+ if (bn) bn.refreshNeeded = true;
197
+ });
198
+ st.network.redraw();
199
+ updateSelectionOverlay();
200
+ }
201
+
202
+ function onRotateEnd() {
203
+ if (!st.rotateDrag) return;
204
+ document.getElementById('vis-canvas').style.pointerEvents = '';
205
+ document.removeEventListener('mousemove', onRotateDrag);
206
+ document.removeEventListener('mouseup', onRotateEnd);
207
+ st.rotateDrag = null;
208
+ markDirty();
209
+ }
210
+
211
+ // ── Label rotation ────────────────────────────────────────────────────────────
212
+ function onLabelRotateStart(e) {
213
+ if (!st.selectedNodeIds.length || !st.network) return;
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+
217
+ const nodeAngles = st.selectedNodeIds.map((id) => {
218
+ const n = st.nodes.get(id);
219
+ return { id, initLabelRotation: (n && n.labelRotation) || 0 };
220
+ });
221
+
222
+ st.labelRotateDrag = { startX: e.clientX, nodeAngles };
223
+ document.getElementById('vis-canvas').style.pointerEvents = 'none';
224
+ document.addEventListener('mousemove', onLabelRotateDrag);
225
+ document.addEventListener('mouseup', onLabelRotateEnd);
226
+ }
227
+
228
+ function onLabelRotateDrag(e) {
229
+ if (!st.labelRotateDrag || !st.network) return;
230
+ const { startX, nodeAngles } = st.labelRotateDrag;
231
+ const dx = e.clientX - startX;
232
+ const delta = dx * (Math.PI / 180); // 1 px = 1 degree
233
+
234
+ nodeAngles.forEach(({ id, initLabelRotation }) => {
235
+ st.nodes.update({ id, labelRotation: initLabelRotation + delta });
236
+ const bn = st.network.body.nodes[id];
237
+ if (bn) bn.refreshNeeded = true;
238
+ });
239
+ st.network.redraw();
240
+ }
241
+
242
+ function onLabelRotateEnd() {
243
+ if (!st.labelRotateDrag) return;
244
+ document.getElementById('vis-canvas').style.pointerEvents = '';
245
+ document.removeEventListener('mousemove', onLabelRotateDrag);
246
+ document.removeEventListener('mouseup', onLabelRotateEnd);
247
+ st.labelRotateDrag = null;
248
+ markDirty();
249
+ }
250
+
251
+ // ── Wire handles ──────────────────────────────────────────────────────────────
147
252
  ['tl', 'tr', 'bl', 'br'].forEach((corner) => {
148
253
  document.getElementById('rh-' + corner).addEventListener('mousedown', (e) => onResizeStart(e, corner));
149
254
  });
255
+ document.getElementById('rh-rotate').addEventListener('mousedown', onRotateStart);
256
+ document.getElementById('rh-label-rotate').addEventListener('mousedown', onLabelRotateStart);
@@ -19,6 +19,10 @@ export const st = {
19
19
  editingNodeId: null,
20
20
  editingEdgeId: null,
21
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
22
26
  clipboard: null, // { nodes: [], edges: [] }
23
27
  canonicalOrder: [], // user-defined z-order, immune to vis.js hover reordering
24
28
  };
@@ -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
+ }
@@ -151,7 +151,7 @@
151
151
  background: rgba(0, 0, 0, 0.78);
152
152
  }
153
153
 
154
- /* Resize / selection overlay */
154
+ /* Resize / rotation / selection overlay */
155
155
  #selectionOverlay {
156
156
  position: absolute;
157
157
  display: none;
@@ -174,6 +174,83 @@
174
174
  .dark .resize-handle {
175
175
  background: #374151;
176
176
  }
177
+ #rh-rotate {
178
+ position: absolute;
179
+ width: 16px;
180
+ height: 16px;
181
+ background: white;
182
+ border: 2px solid #f97316;
183
+ border-radius: 50%;
184
+ pointer-events: all;
185
+ z-index: 1;
186
+ cursor: grab;
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ font-size: 10px;
191
+ color: #f97316;
192
+ }
193
+ #rh-rotate:active { cursor: grabbing; }
194
+ .dark #rh-rotate { background: #374151; }
195
+ #rh-label-rotate {
196
+ position: absolute;
197
+ width: 16px;
198
+ height: 16px;
199
+ background: white;
200
+ border: 2px solid #6366f1;
201
+ border-radius: 50%;
202
+ pointer-events: all;
203
+ z-index: 1;
204
+ cursor: grab;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ font-size: 10px;
209
+ color: #6366f1;
210
+ }
211
+ #rh-label-rotate:active { cursor: grabbing; }
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
+ }
177
254
  </style>
178
255
  </head>
179
256
  <body
@@ -304,6 +381,35 @@
304
381
  <line x1="6" y1="10" x2="9.5" y2="15" />
305
382
  </svg>
306
383
  </button>
384
+ <button
385
+ id="toolPostIt"
386
+ class="tool-btn"
387
+ title="Post-it (P)"
388
+ >
389
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.4">
390
+ <path d="M1 1 H9 L12 4 V12 H1 Z" />
391
+ <path d="M9 1 V4 H12" stroke-opacity="0.5"/>
392
+ </svg>
393
+ </button>
394
+ <button
395
+ id="toolTextFree"
396
+ class="tool-btn"
397
+ title="Texte libre (T)"
398
+ style="font-size:11px; font-weight:600;"
399
+ >
400
+ T
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>
307
413
  <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
308
414
 
309
415
  <button
@@ -458,6 +564,9 @@
458
564
  <!-- Debug overlay layer -->
459
565
  <div id="debugLayer"></div>
460
566
 
567
+ <!-- Stamp overlay: intercepts canvas clicks during stamp mode -->
568
+ <div id="stampOverlay" style="position:absolute;inset:0;display:none;z-index:9;"></div>
569
+
461
570
  <!-- Selection / resize overlay -->
462
571
  <div id="selectionOverlay">
463
572
  <div
@@ -480,6 +589,15 @@
480
589
  class="resize-handle"
481
590
  style="bottom: -5px; right: -5px; cursor: se-resize"
482
591
  ></div>
592
+ <div id="rh-rotate" title="Rotation forme">↻</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>
483
601
  </div>
484
602
 
485
603
  <!-- Node panel -->
@@ -752,6 +870,60 @@
752
870
  <rect x="5" y="1" width="8" height="8" rx="1" />
753
871
  </svg>
754
872
  </button>
873
+
874
+ <div class="panel-sep"></div>
875
+
876
+ <!-- Stamp: copy color (goutte) -->
877
+ <button
878
+ id="btnStampColor"
879
+ class="tool-btn !w-7 !h-6"
880
+ title="Tampon couleur — sélectionner les cibles, cliquer ici, puis cliquer la source"
881
+ >
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">
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"/>
884
+ </svg>
885
+ </button>
886
+
887
+ <!-- Stamp: copy font size -->
888
+ <button
889
+ id="btnStampFontSize"
890
+ class="tool-btn !w-7 !h-6 font-mono text-xs font-bold"
891
+ title="Tampon taille police — sélectionner les cibles, cliquer ici, puis cliquer la source"
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>
755
927
  </div>
756
928
 
757
929
  <!-- Edge panel -->
@@ -873,6 +1045,7 @@
873
1045
  </div>
874
1046
  </div>
875
1047
 
1048
+ <div id="toastContainer"></div>
876
1049
  <script type="module" src="/diagram/main.js"></script>
877
1050
  </body>
878
1051
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "3.6.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": {