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.
@@ -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;
@@ -5,11 +5,14 @@ export const GRID_SIZE = 40;
5
5
 
6
6
  export const TOOL_BTN_MAP = {
7
7
  select: 'toolSelect',
8
- 'addNode:box': 'toolBox',
9
- 'addNode:ellipse': 'toolEllipse',
10
- 'addNode:database': 'toolDatabase',
11
- 'addNode:circle': 'toolCircle',
12
- 'addNode:actor': 'toolActor',
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
+ 'addNode:image': 'toolImage',
13
16
  addEdge: 'toolArrow',
14
17
  };
15
18
 
@@ -4,12 +4,29 @@
4
4
  import { st, markDirty } from './state.js';
5
5
  import { GRID_SIZE } from './constants.js';
6
6
 
7
+
7
8
  export function togglePhysics() {
8
9
  st.physicsEnabled = !st.physicsEnabled;
9
- if (st.network) st.network.setOptions({ physics: { enabled: st.physicsEnabled } });
10
10
  const btn = document.getElementById('btnPhysics');
11
11
  btn.classList.toggle('tool-active', st.physicsEnabled);
12
- btn.title = st.physicsEnabled ? 'Auto-espacement actif' : 'Auto-espacement inactif';
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
+ });
13
30
  }
14
31
 
15
32
  export function toggleGrid() {
@@ -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 } 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();
@@ -69,6 +80,9 @@ document.getElementById('toolEllipse').addEventListener('click', () => setTool(
69
80
  document.getElementById('toolDatabase').addEventListener('click', () => setTool('addNode', 'database'));
70
81
  document.getElementById('toolCircle').addEventListener('click', () => setTool('addNode', 'circle'));
71
82
  document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
83
+ document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
84
+ document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
85
+ document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
72
86
  document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
73
87
 
74
88
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
@@ -104,6 +118,19 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
104
118
  document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
105
119
  document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
106
120
  document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
121
+ document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
122
+ document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
123
+ // Stamp buttons: capture targets on mousedown (before vis-network can fire
124
+ // deselectNode), then activate the stamp mode on click.
125
+ ['btnStampColor', 'btnStampFontSize'].forEach((id) => {
126
+ document.getElementById(id).addEventListener('mousedown', (e) => {
127
+ e.preventDefault(); // prevent canvas focus loss
128
+ st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
129
+ });
130
+ });
131
+ document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
132
+ document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
133
+ document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
107
134
 
108
135
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
109
136
 
@@ -121,19 +148,45 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
121
148
  document.addEventListener('keydown', (e) => {
122
149
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
123
150
  if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
124
- 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; }
125
154
  if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
126
155
  if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
127
- if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { setTool('select'); return; }
156
+ if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
128
157
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
129
158
  if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
130
159
  if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
131
160
  if (e.key === 'c' || e.key === 'C') { setTool('addNode', 'circle'); return; }
132
161
  if (e.key === 'a' || e.key === 'A') { setTool('addNode', 'actor'); return; }
133
- if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
162
+ if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
163
+ if (e.key === 'p' || e.key === 'P') { setTool('addNode', 'post-it'); return; }
164
+ if (e.key === 't' || e.key === 'T') { setTool('addNode', 'text-free'); return; }
134
165
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
135
166
  });
136
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
+
137
190
  // ── Dark mode initialisation ──────────────────────────────────────────────────
138
191
 
139
192
  document.getElementById('darkIcon').textContent =
@@ -3,7 +3,9 @@
3
3
  // and wires all network-level events.
4
4
 
5
5
  import { st, markDirty } from './state.js';
6
- import { visNodeProps } from './node-rendering.js';
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';
@@ -74,11 +76,8 @@ export function initNetwork(savedNodes, savedEdges) {
74
76
  if (!node) continue;
75
77
  if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
76
78
  const r = node.draw(ctx);
77
- // Custom shapes (actor) draw their own label inside drawNode(); skip
78
- // vis-network's external-label callback to avoid double-rendering.
79
- if (r.drawExternalLabel != null && node.options.shape !== 'custom') {
80
- drawExternalLabelCallbacks.push(r.drawExternalLabel);
81
- }
79
+ // All shapes are ctxRenderer (shape:'custom') and draw their own labels.
80
+ // Skip drawExternalLabel entirely to avoid double-rendering.
82
81
  } else {
83
82
  node.updateBoundingBox(ctx, node.selected);
84
83
  }
@@ -123,14 +122,20 @@ function onDoubleClick(params) {
123
122
  st.selectedEdgeIds = [params.edges[0]];
124
123
  showEdgePanel();
125
124
  startEdgeLabelEdit();
125
+ } else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
126
+ const canvasPos = params.pointer.canvas;
127
+ pickAndCreateImageNode(canvasPos.x, canvasPos.y);
126
128
  } else if (st.currentTool === 'addNode') {
127
- const id = 'n' + Date.now();
129
+ const id = 'n' + Date.now();
130
+ const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
131
+ const defaultColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
128
132
  st.nodes.add({
129
- id, label: 'Node',
130
- shapeType: st.pendingShape, colorKey: 'c-gray',
131
- nodeWidth: null, nodeHeight: null, fontSize: null,
133
+ id, label: st.pendingShape === 'text-free' ? 'Texte' : 'Node',
134
+ shapeType: st.pendingShape, colorKey: defaultColor,
135
+ nodeWidth: defaults[0], nodeHeight: defaults[1],
136
+ fontSize: null, rotation: 0, labelRotation: 0,
132
137
  x: params.pointer.canvas.x, y: params.pointer.canvas.y,
133
- ...visNodeProps(st.pendingShape, 'c-gray', null, null, null, null, null),
138
+ ...visNodeProps(st.pendingShape, defaultColor, defaults[0], defaults[1], null, null, null),
134
139
  });
135
140
  markDirty();
136
141
  setTimeout(() => {
@@ -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 = [];
@@ -2,7 +2,18 @@
2
2
  // Floating formatting toolbar for selected nodes (color, font, alignment, z-order).
3
3
 
4
4
  import { st, markDirty } from './state.js';
5
- import { visNodeProps, getActualNodeHeight } from './node-rendering.js';
5
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
6
+
7
+ // All shapes are ctxRenderers — vis-network never re-reads the closure after
8
+ // nodes.update(). Force refreshNeeded + redraw so the new colorKey/fontSize/
9
+ // textAlign/textValign values are picked up on the next draw call via st.nodes.get(id).
10
+ function forceRedraw() {
11
+ st.selectedNodeIds.forEach((id) => {
12
+ const bn = st.network && st.network.body.nodes[id];
13
+ if (bn) bn.refreshNeeded = true;
14
+ });
15
+ if (st.network) st.network.redraw();
16
+ }
6
17
 
7
18
  export function showNodePanel() {
8
19
  document.getElementById('nodePanel').classList.remove('hidden');
@@ -17,8 +28,9 @@ export function setNodeColor(colorKey) {
17
28
  st.selectedNodeIds.forEach((id) => {
18
29
  const n = st.nodes.get(id);
19
30
  if (!n) return;
20
- st.nodes.update({ id, colorKey, ...visNodeProps(n.shapeType || 'box', colorKey, n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign) });
31
+ st.nodes.update({ id, colorKey });
21
32
  });
33
+ forceRedraw();
22
34
  markDirty();
23
35
  }
24
36
 
@@ -28,31 +40,133 @@ export function changeNodeFontSize(delta) {
28
40
  const n = st.nodes.get(id);
29
41
  if (!n) return;
30
42
  const newSize = Math.max(8, Math.min(48, (n.fontSize || 13) + delta));
31
- const ah = getActualNodeHeight(id);
32
- st.nodes.update({ id, fontSize: newSize, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, newSize, n.textAlign, n.textValign, ah) });
43
+ st.nodes.update({ id, fontSize: newSize });
33
44
  });
45
+ forceRedraw();
34
46
  markDirty();
35
47
  }
36
48
 
37
49
  export function setTextAlign(align) {
38
50
  if (!st.selectedNodeIds.length) return;
39
51
  st.selectedNodeIds.forEach((id) => {
40
- const n = st.nodes.get(id);
52
+ const n = st.nodes.get(id);
41
53
  if (!n) return;
42
- const ah = getActualNodeHeight(id);
43
- st.nodes.update({ id, textAlign: align, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, align, n.textValign, ah) });
54
+ st.nodes.update({ id, textAlign: align });
44
55
  });
56
+ forceRedraw();
45
57
  markDirty();
46
58
  }
47
59
 
48
60
  export function setTextValign(valign) {
49
61
  if (!st.selectedNodeIds.length) return;
50
62
  st.selectedNodeIds.forEach((id) => {
63
+ const n = st.nodes.get(id);
64
+ if (!n) return;
65
+ st.nodes.update({ id, textValign: valign });
66
+ });
67
+ forceRedraw();
68
+ markDirty();
69
+ }
70
+
71
+ // ── Stamp (format painter) ────────────────────────────────────────────────────
72
+ // Uses a transparent DOM overlay (#stampOverlay) that intercepts canvas clicks
73
+ // during stamp mode. This bypasses vis-network's event system entirely, avoiding
74
+ // the deselectNode/click ordering problems that make st.activeStamp unreliable.
75
+
76
+ const STAMP_BTNS = { color: 'btnStampColor', rotation: 'btnStampRotation', fontSize: 'btnStampFontSize' };
77
+
78
+ export function activateStamp(type) {
79
+ if (!st.stampTargetIds.length) return; // targets were saved on mousedown
80
+ st.activeStamp = type;
81
+ const overlay = document.getElementById('stampOverlay');
82
+ overlay.style.display = 'block';
83
+ Object.entries(STAMP_BTNS).forEach(([t, id]) =>
84
+ document.getElementById(id).classList.toggle('tool-active', t === type)
85
+ );
86
+ }
87
+
88
+ export function cancelStamp() {
89
+ st.activeStamp = null;
90
+ st.stampTargetIds = [];
91
+ document.getElementById('stampOverlay').style.display = 'none';
92
+ Object.values(STAMP_BTNS).forEach((id) =>
93
+ document.getElementById(id).classList.remove('tool-active')
94
+ );
95
+ }
96
+
97
+ function applyStamp(sourceId) {
98
+ const source = st.nodes.get(sourceId);
99
+ if (!source || !st.activeStamp || !st.stampTargetIds.length) return;
100
+ const type = st.activeStamp;
101
+ const targets = [...st.stampTargetIds]; // snapshot before cancelStamp clears the array
102
+ cancelStamp();
103
+
104
+ targets.forEach((id) => {
105
+ if (id === sourceId) return;
106
+ const target = st.nodes.get(id);
107
+ if (!target) return;
108
+ if (type === 'color') st.nodes.update({ id, colorKey: source.colorKey || 'c-gray' });
109
+ if (type === 'rotation') st.nodes.update({ id, rotation: source.rotation || 0 });
110
+ if (type === 'fontSize') st.nodes.update({ id, fontSize: source.fontSize || 13 });
111
+ const bn = st.network && st.network.body.nodes[id];
112
+ if (bn) bn.refreshNeeded = true;
113
+ });
114
+ if (st.network) st.network.redraw();
115
+ markDirty();
116
+ }
117
+
118
+ // getNodeAt() is unreliable for shape:'custom' (bounding box near-zero).
119
+ // Manual AABB hit test using DOMtoCanvas + node dimensions, topmost node first.
120
+ function getNodeAtDOMPoint(domX, domY) {
121
+ if (!st.network || !st.nodes) return undefined;
122
+ const cp = st.network.DOMtoCanvas({ x: domX, y: domY });
123
+ for (let i = st.canonicalOrder.length - 1; i >= 0; i--) {
124
+ const id = st.canonicalOrder[i];
51
125
  const n = st.nodes.get(id);
126
+ const bn = st.network.body.nodes[id];
127
+ if (!n || !bn) continue;
128
+ const defaults = SHAPE_DEFAULTS[n.shapeType] || [100, 40];
129
+ const w = n.nodeWidth || defaults[0];
130
+ const h = n.nodeHeight || defaults[1];
131
+ const rot = n.rotation || 0;
132
+ let hw, hh;
133
+ if (rot === 0) {
134
+ hw = w / 2; hh = h / 2;
135
+ } else {
136
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
137
+ hw = (w * cos + h * sin) / 2;
138
+ hh = (w * sin + h * cos) / 2;
139
+ }
140
+ if (Math.abs(cp.x - bn.x) <= hw && Math.abs(cp.y - bn.y) <= hh) return id;
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ // Wire the stamp overlay click.
146
+ document.getElementById('stampOverlay').addEventListener('click', (e) => {
147
+ if (!st.activeStamp || !st.network) return;
148
+ const rect = document.getElementById('vis-canvas').getBoundingClientRect();
149
+ const nodeId = getNodeAtDOMPoint(e.clientX - rect.left, e.clientY - rect.top);
150
+ if (nodeId !== undefined) {
151
+ applyStamp(nodeId);
152
+ } else {
153
+ cancelStamp();
154
+ }
155
+ });
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);
52
164
  if (!n) return;
53
- const ah = getActualNodeHeight(id);
54
- st.nodes.update({ id, textValign: valign, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, valign, ah) });
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;
55
168
  });
169
+ if (st.network) st.network.redraw();
56
170
  markDirty();
57
171
  }
58
172