living-documentation 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,13 @@ 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',
13
15
  addEdge: 'toolArrow',
14
16
  };
15
17
 
@@ -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() {
@@ -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 } 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';
@@ -69,6 +69,8 @@ document.getElementById('toolEllipse').addEventListener('click', () => setTool(
69
69
  document.getElementById('toolDatabase').addEventListener('click', () => setTool('addNode', 'database'));
70
70
  document.getElementById('toolCircle').addEventListener('click', () => setTool('addNode', 'circle'));
71
71
  document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
72
+ document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
73
+ document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
72
74
  document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
73
75
 
74
76
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
@@ -104,6 +106,17 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
104
106
  document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
105
107
  document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
106
108
  document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
109
+ // Stamp buttons: capture targets on mousedown (before vis-network can fire
110
+ // deselectNode), then activate the stamp mode on click.
111
+ ['btnStampColor', 'btnStampRotation', 'btnStampFontSize'].forEach((id) => {
112
+ document.getElementById(id).addEventListener('mousedown', (e) => {
113
+ e.preventDefault(); // prevent canvas focus loss
114
+ st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
115
+ });
116
+ });
117
+ document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
118
+ document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
119
+ document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
107
120
 
108
121
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
109
122
 
@@ -124,13 +137,15 @@ document.addEventListener('keydown', (e) => {
124
137
  if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
125
138
  if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
126
139
  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; }
140
+ if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
128
141
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
129
142
  if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
130
143
  if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
131
144
  if (e.key === 'c' || e.key === 'C') { setTool('addNode', 'circle'); return; }
132
145
  if (e.key === 'a' || e.key === 'A') { setTool('addNode', 'actor'); return; }
133
- if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
146
+ if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
147
+ if (e.key === 'p' || e.key === 'P') { setTool('addNode', 'post-it'); return; }
148
+ if (e.key === 't' || e.key === 'T') { setTool('addNode', 'text-free'); return; }
134
149
  if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
135
150
  });
136
151
 
@@ -3,7 +3,7 @@
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
7
  import { visEdgeProps } from './edge-rendering.js';
8
8
  import { showNodePanel, hideNodePanel } from './node-panel.js';
9
9
  import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
@@ -74,11 +74,8 @@ export function initNetwork(savedNodes, savedEdges) {
74
74
  if (!node) continue;
75
75
  if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
76
76
  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
- }
77
+ // All shapes are ctxRenderer (shape:'custom') and draw their own labels.
78
+ // Skip drawExternalLabel entirely to avoid double-rendering.
82
79
  } else {
83
80
  node.updateBoundingBox(ctx, node.selected);
84
81
  }
@@ -124,13 +121,16 @@ function onDoubleClick(params) {
124
121
  showEdgePanel();
125
122
  startEdgeLabelEdit();
126
123
  } else if (st.currentTool === 'addNode') {
127
- const id = 'n' + Date.now();
124
+ const id = 'n' + Date.now();
125
+ const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
126
+ const defaultColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
128
127
  st.nodes.add({
129
- id, label: 'Node',
130
- shapeType: st.pendingShape, colorKey: 'c-gray',
131
- nodeWidth: null, nodeHeight: null, fontSize: null,
128
+ id, label: st.pendingShape === 'text-free' ? 'Texte' : 'Node',
129
+ shapeType: st.pendingShape, colorKey: defaultColor,
130
+ nodeWidth: defaults[0], nodeHeight: defaults[1],
131
+ fontSize: null, rotation: 0, labelRotation: 0,
132
132
  x: params.pointer.canvas.x, y: params.pointer.canvas.y,
133
- ...visNodeProps(st.pendingShape, 'c-gray', null, null, null, null, null),
133
+ ...visNodeProps(st.pendingShape, defaultColor, defaults[0], defaults[1], null, null, null),
134
134
  });
135
135
  markDirty();
136
136
  setTimeout(() => {
@@ -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,34 +40,120 @@ 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) => {
51
- const n = st.nodes.get(id);
63
+ const n = st.nodes.get(id);
52
64
  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) });
65
+ st.nodes.update({ id, textValign: valign });
55
66
  });
67
+ forceRedraw();
56
68
  markDirty();
57
69
  }
58
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];
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
+
59
157
  export function changeZOrder(direction) {
60
158
  // direction: +1 = bring to front (last in canonicalOrder = drawn on top)
61
159
  // -1 = send to back (first in canonicalOrder = drawn below)
@@ -1,113 +1,336 @@
1
1
  // ── Node rendering ────────────────────────────────────────────────────────────
2
- // Actor canvas renderer + vis.js node property builder.
2
+ // All shapes are ctxRenderers so rotation (ctx.rotate) works uniformly.
3
+ // Each renderer reads live node data from st.nodes.get(id) on every draw call
4
+ // (vis-network caches the ctxRenderer reference and never re-reads it from the
5
+ // DataSet, so dimensions/rotation/alignment must be fetched at draw time).
3
6
 
4
7
  import { NODE_COLORS } from './constants.js';
5
8
  import { st } from './state.js';
6
9
 
7
- // Reference dimensions of the actor figure at scale 1.
10
+ // ── Drawing helpers ───────────────────────────────────────────────────────────
11
+
12
+ // Draw multi-line label centred at (0,0) in the current (possibly rotated) ctx.
13
+ // labelRotation is applied around the label's own centre (independent of shape rotation).
14
+ function drawLabel(ctx, label, fontSize, color, textAlign, textValign, W, H, labelRotation) {
15
+ if (!label) return;
16
+ const pad = 8;
17
+ ctx.save();
18
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
19
+ ctx.fillStyle = color;
20
+ ctx.textBaseline = 'middle';
21
+
22
+ let xPos = 0;
23
+ if (textAlign === 'left') { ctx.textAlign = 'left'; xPos = W ? -W / 2 + pad : -40; }
24
+ else if (textAlign === 'right') { ctx.textAlign = 'right'; xPos = W ? W / 2 - pad : 40; }
25
+ else { ctx.textAlign = 'center'; xPos = 0; }
26
+
27
+ let yOff = 0;
28
+ if (W && H) {
29
+ if (textValign === 'top') yOff = -(H / 2 - fontSize / 2 - pad);
30
+ else if (textValign === 'bottom') yOff = H / 2 - fontSize / 2 - pad;
31
+ }
32
+
33
+ const lines = String(label).split('\n');
34
+ const lineH = fontSize * 1.3;
35
+ const startY = yOff - (lines.length - 1) * lineH / 2;
36
+
37
+ // Apply independent label rotation around the label centre.
38
+ if (labelRotation) ctx.rotate(labelRotation);
39
+
40
+ lines.forEach((line, i) => ctx.fillText(line, xPos, startY + i * lineH));
41
+ ctx.restore();
42
+ }
43
+
44
+ // Polyfill-safe rounded rectangle (ctx.roundRect not universally available).
45
+ function roundRect(ctx, x, y, w, h, r) {
46
+ r = Math.min(r, w / 2, h / 2);
47
+ ctx.beginPath();
48
+ ctx.moveTo(x + r, y);
49
+ ctx.lineTo(x + w - r, y);
50
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
51
+ ctx.lineTo(x + w, y + h - r);
52
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
53
+ ctx.lineTo(x + r, y + h);
54
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
55
+ ctx.lineTo(x, y + r);
56
+ ctx.quadraticCurveTo(x, y, x + r, y);
57
+ ctx.closePath();
58
+ }
59
+
60
+ // Read ALL live node data from the DataSet on every draw call.
61
+ // vis-network caches the ctxRenderer closure and never re-reads it after
62
+ // nodes.update(), so colour, font size, dimensions, rotation, and alignment
63
+ // must all be fetched here to reflect the latest values.
64
+ function nodeData(id, defaultW, defaultH, defaultColorKey) {
65
+ const n = st.nodes && st.nodes.get(id);
66
+ const colorKey = (n && n.colorKey) || defaultColorKey || 'c-gray';
67
+ return {
68
+ W: (n && n.nodeWidth) || defaultW,
69
+ H: (n && n.nodeHeight) || defaultH,
70
+ rotation: (n && n.rotation) || 0,
71
+ labelRotation: (n && n.labelRotation) || 0,
72
+ textAlign: (n && n.textAlign) || 'center',
73
+ textValign: (n && n.textValign) || 'middle',
74
+ fontSize: (n && n.fontSize) || 13,
75
+ colorKey,
76
+ c: NODE_COLORS[colorKey] || NODE_COLORS['c-gray'],
77
+ };
78
+ }
79
+
80
+ // ── Shape renderers ───────────────────────────────────────────────────────────
81
+
82
+ export function makeBoxRenderer(colorKey) {
83
+ return function ({ ctx, x, y, id, state: visState, label }) {
84
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 100, 40, colorKey);
85
+ return {
86
+ drawNode() {
87
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
88
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
89
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
90
+ ctx.lineWidth = 1.5;
91
+ roundRect(ctx, -W / 2, -H / 2, W, H, 4);
92
+ ctx.fill(); ctx.stroke();
93
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
94
+ ctx.restore();
95
+ },
96
+ nodeDimensions: { width: W, height: H },
97
+ };
98
+ };
99
+ }
100
+
101
+ export function makeEllipseRenderer(colorKey) {
102
+ return function ({ ctx, x, y, id, state: visState, label }) {
103
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 110, 50, colorKey);
104
+ return {
105
+ drawNode() {
106
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
107
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
108
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
109
+ ctx.lineWidth = 1.5;
110
+ ctx.beginPath(); ctx.ellipse(0, 0, W / 2, H / 2, 0, 0, Math.PI * 2);
111
+ ctx.fill(); ctx.stroke();
112
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
113
+ ctx.restore();
114
+ },
115
+ nodeDimensions: { width: W, height: H },
116
+ };
117
+ };
118
+ }
119
+
120
+ export function makeCircleRenderer(colorKey) {
121
+ return function ({ ctx, x, y, id, state: visState, label }) {
122
+ const { W, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 55, 55, colorKey);
123
+ const R = W / 2;
124
+ return {
125
+ drawNode() {
126
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
127
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
128
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
129
+ ctx.lineWidth = 1.5;
130
+ ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2);
131
+ ctx.fill(); ctx.stroke();
132
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, W, labelRotation);
133
+ ctx.restore();
134
+ },
135
+ nodeDimensions: { width: W, height: W },
136
+ };
137
+ };
138
+ }
139
+
140
+ export function makeDatabaseRenderer(colorKey) {
141
+ return function ({ ctx, x, y, id, state: visState, label }) {
142
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 50, 70, colorKey);
143
+ const rx = W / 2;
144
+ const ry = Math.max(H * 0.12, 6);
145
+ const bodyTop = -H / 2 + ry;
146
+ const bodyBottom = H / 2 - ry;
147
+ return {
148
+ drawNode() {
149
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
150
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
151
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
152
+ ctx.lineWidth = 1.5;
153
+ ctx.fillRect(-rx, bodyTop, W, bodyBottom - bodyTop);
154
+ ctx.beginPath(); ctx.ellipse(0, bodyBottom, rx, ry, 0, 0, Math.PI * 2);
155
+ ctx.fill(); ctx.stroke();
156
+ ctx.beginPath();
157
+ ctx.moveTo(-rx, bodyTop); ctx.lineTo(-rx, bodyBottom);
158
+ ctx.moveTo( rx, bodyTop); ctx.lineTo( rx, bodyBottom);
159
+ ctx.stroke();
160
+ ctx.beginPath(); ctx.ellipse(0, bodyTop, rx, ry, 0, 0, Math.PI * 2);
161
+ ctx.fill(); ctx.stroke();
162
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
163
+ ctx.restore();
164
+ },
165
+ nodeDimensions: { width: W, height: H },
166
+ };
167
+ };
168
+ }
169
+
170
+ // Post-IT: sticky note with folded top-right corner.
171
+ export function makePostItRenderer(colorKey) {
172
+ return function ({ ctx, x, y, id, state: visState, label }) {
173
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 120, 100, colorKey || 'c-amber');
174
+ const fold = Math.min(W, H) * 0.18;
175
+ return {
176
+ drawNode() {
177
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
178
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
179
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
180
+ ctx.lineWidth = 1.5;
181
+ ctx.beginPath();
182
+ ctx.moveTo(-W / 2, -H / 2);
183
+ ctx.lineTo( W / 2 - fold, -H / 2);
184
+ ctx.lineTo( W / 2, -H / 2 + fold);
185
+ ctx.lineTo( W / 2, H / 2);
186
+ ctx.lineTo(-W / 2, H / 2);
187
+ ctx.closePath();
188
+ ctx.fill(); ctx.stroke();
189
+ ctx.globalAlpha = 0.3;
190
+ ctx.fillStyle = c.border;
191
+ ctx.beginPath();
192
+ ctx.moveTo(W / 2 - fold, -H / 2);
193
+ ctx.lineTo(W / 2, -H / 2 + fold);
194
+ ctx.lineTo(W / 2 - fold, -H / 2 + fold);
195
+ ctx.closePath();
196
+ ctx.fill();
197
+ ctx.globalAlpha = 1;
198
+ ctx.beginPath();
199
+ ctx.moveTo(W / 2 - fold, -H / 2);
200
+ ctx.lineTo(W / 2 - fold, -H / 2 + fold);
201
+ ctx.lineTo(W / 2, -H / 2 + fold);
202
+ ctx.stroke();
203
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
204
+ ctx.restore();
205
+ },
206
+ nodeDimensions: { width: W, height: H },
207
+ };
208
+ };
209
+ }
210
+
211
+ // Free Text: no visible border or background — just the label.
212
+ export function makeTextFreeRenderer(colorKey) {
213
+ return function ({ ctx, x, y, id, state: visState, label }) {
214
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 80, 30, colorKey);
215
+ return {
216
+ drawNode() {
217
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
218
+ if (visState.selected || visState.hover) {
219
+ ctx.strokeStyle = '#f97316';
220
+ ctx.lineWidth = 1;
221
+ ctx.setLineDash([4, 3]);
222
+ roundRect(ctx, -W / 2, -H / 2, W, H, 3);
223
+ ctx.stroke();
224
+ ctx.setLineDash([]);
225
+ }
226
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
227
+ ctx.restore();
228
+ },
229
+ nodeDimensions: { width: W, height: H },
230
+ };
231
+ };
232
+ }
233
+
234
+ // ── Actor (stick figure) ──────────────────────────────────────────────────────
8
235
  const ACTOR_W0 = 30;
9
236
  const ACTOR_H0 = 52;
10
237
 
11
- // Factory: returns a vis.js ctxRenderer for the "actor" custom shape.
12
- //
13
- // Key design: this is a STABLE function — it is created ONCE per colorKey and
14
- // never replaced. vis-network caches the ctxRenderer reference inside its
15
- // CustomShape object and does NOT re-read it from the DataSet after updates,
16
- // so rebuilding the closure on every resize would have no effect.
17
- //
18
- // Instead, the renderer reads live dimensions on every draw call via the node
19
- // `id` that vis-network passes to ctxRenderer. `st.nodes.get(id)` always
20
- // returns the latest nodeWidth / nodeHeight written by the resize handler.
21
238
  export function makeActorRenderer(colorKey) {
22
- const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
23
-
24
- return function ({ ctx, x, y, id, state: visState, label, style }) {
25
- // Read the current dimensions from the DataSet on every draw.
26
- const n = st.nodes && st.nodes.get(id);
27
- const W = (n && n.nodeWidth) || ACTOR_W0;
28
- const H = (n && n.nodeHeight) || ACTOR_H0;
239
+ return function ({ ctx, x, y, id, state: visState, label }) {
240
+ const { W, H, rotation, labelRotation, fontSize, c } = nodeData(id, ACTOR_W0, ACTOR_H0, colorKey);
29
241
  const sx = W / ACTOR_W0;
30
242
  const sy = H / ACTOR_H0;
31
- const fontSize = (style && style.font && style.font.size) ? style.font.size : 13;
32
-
33
243
  return {
34
244
  drawNode() {
35
- // ── Scaled stick figure ───────────────────────────────────────────────
36
- ctx.save();
245
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
37
246
  ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
38
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
247
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
39
248
  ctx.lineWidth = 2;
40
249
  ctx.lineCap = 'round';
41
- ctx.beginPath(); ctx.arc(x, y - 20*sy, 8*sy, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
42
- ctx.beginPath(); ctx.moveTo(x, y - 12*sy); ctx.lineTo(x, y + 8*sy); ctx.stroke();
43
- ctx.beginPath(); ctx.moveTo(x - 13*sx, y - 3*sy); ctx.lineTo(x + 13*sx, y - 3*sy); ctx.stroke();
44
- ctx.beginPath(); ctx.moveTo(x, y + 8*sy); ctx.lineTo(x - 10*sx, y + 24*sy); ctx.stroke();
45
- ctx.beginPath(); ctx.moveTo(x, y + 8*sy); ctx.lineTo(x + 10*sx, y + 24*sy); ctx.stroke();
46
- ctx.restore();
47
-
48
- // ── Label below the figure ────────────────────────────────────────────
250
+ ctx.beginPath(); ctx.arc(0, -20 * sy, 8 * sy, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
251
+ ctx.beginPath(); ctx.moveTo(0, -12 * sy); ctx.lineTo(0, 8 * sy); ctx.stroke();
252
+ ctx.beginPath(); ctx.moveTo(-13 * sx, -3 * sy); ctx.lineTo(13 * sx, -3 * sy); ctx.stroke();
253
+ ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo(-10 * sx, 24 * sy); ctx.stroke();
254
+ ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo( 10 * sx, 24 * sy); ctx.stroke();
255
+ // Label below figure (rotates with the actor)
49
256
  if (label) {
50
257
  ctx.save();
258
+ if (labelRotation) ctx.rotate(labelRotation);
51
259
  ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
52
260
  ctx.fillStyle = c.font;
53
261
  ctx.textAlign = 'center';
54
262
  ctx.textBaseline = 'top';
55
263
  const lines = String(label).split('\n');
56
264
  const lineH = fontSize * 1.3;
57
- const startY = y + 24*sy + 4; // 4 px gap below scaled leg tips
58
- lines.forEach((line, i) => ctx.fillText(line, x, startY + i * lineH));
265
+ const startY = 24 * sy + 4;
266
+ lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
59
267
  ctx.restore();
60
268
  }
269
+ ctx.restore();
61
270
  },
62
- // nodeDimensions must also reflect the current size so vis-network uses
63
- // the right bounding box for collision detection and layout.
64
271
  nodeDimensions: { width: W, height: H },
65
272
  };
66
273
  };
67
274
  }
68
275
 
69
- // Vertical text offset for top/middle/bottom alignment inside a node.
70
- export function computeVadjust(textValign, nodeHeight, fontSize) {
71
- if (!textValign || textValign === 'middle') return 0;
72
- const h = nodeHeight || 50;
73
- const fs = fontSize || 13;
74
- const pad = 8;
75
- if (textValign === 'top') return -(h / 2 - fs / 2 - pad);
76
- if (textValign === 'bottom') return h / 2 - fs / 2 - pad;
77
- return 0;
78
- }
276
+ // ── Public API ────────────────────────────────────────────────────────────────
79
277
 
80
- // Returns the rendered height of a node from vis.js internals, or null.
278
+ // Returns the rendered height from vis-network internals (used by node-panel
279
+ // for vadjust calculations — legacy, kept for API compat).
81
280
  export function getActualNodeHeight(id) {
82
281
  if (!st.network) return null;
83
282
  const bn = st.network.body.nodes[id];
84
283
  return bn && bn.shape && bn.shape.height ? bn.shape.height : null;
85
284
  }
86
285
 
87
- // Builds the full vis.js node property object (color, font, size constraints, shape).
88
- export function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, textValign, vadjustHeight) {
89
- const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
90
- const size = fontSize || 13;
91
- const align = textAlign || 'center';
92
- const vadjust = computeVadjust(textValign, vadjustHeight || nodeHeight, size);
286
+ // Kept for backward compat (called by node-panel but irrelevant for ctxRenderers).
287
+ export function computeVadjust() { return 0; }
288
+
289
+ const RENDERER_MAP = {
290
+ box: makeBoxRenderer,
291
+ ellipse: makeEllipseRenderer,
292
+ circle: makeCircleRenderer,
293
+ database: makeDatabaseRenderer,
294
+ 'post-it': makePostItRenderer,
295
+ 'text-free':makeTextFreeRenderer,
296
+ actor: makeActorRenderer,
297
+ };
298
+
299
+ // Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
300
+ export const SHAPE_DEFAULTS = {
301
+ box: [100, 40],
302
+ ellipse: [110, 50],
303
+ circle: [55, 55],
304
+ database: [50, 70],
305
+ actor: [30, 52],
306
+ 'post-it': [120, 100],
307
+ 'text-free':[80, 30],
308
+ };
309
+
310
+ // Builds the full vis.js node property object.
311
+ // All shapes are rendered via ctxRenderer so rotation works uniformly.
312
+ export function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, _textValign) {
313
+ const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
314
+ const size = fontSize || 13;
315
+ const align = textAlign || 'center';
93
316
 
94
317
  const colorP = {
95
318
  color: {
96
- background: c.bg,
97
- border: c.border,
98
- highlight: { background: c.hbg, border: c.hborder },
99
- hover: { background: c.hbg, border: c.hborder },
319
+ background: c.bg, border: c.border,
320
+ highlight: { background: c.hbg, border: c.hborder },
321
+ hover: { background: c.hbg, border: c.hborder },
100
322
  },
101
- font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align, vadjust },
323
+ font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align },
102
324
  };
103
325
 
104
326
  const sizeP = {};
105
327
  if (nodeWidth) sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
106
328
  if (nodeHeight) sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
107
329
 
108
- if (shapeType === 'actor') {
109
- // The renderer reads dimensions from st.nodes at draw time — never recreated.
110
- return { shape: 'custom', ctxRenderer: makeActorRenderer(colorKey), ...colorP, ...sizeP };
330
+ const factory = RENDERER_MAP[shapeType];
331
+ if (factory) {
332
+ return { shape: 'custom', ctxRenderer: factory(colorKey), ...colorP, ...sizeP };
111
333
  }
112
- return { shape: shapeType, ...colorP, ...sizeP };
334
+ // Unknown shape fall back to box
335
+ return { shape: 'custom', ctxRenderer: makeBoxRenderer(colorKey), ...colorP, ...sizeP };
113
336
  }
@@ -115,6 +115,7 @@ export async function saveDiagram() {
115
115
  shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
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
+ rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
118
119
  x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
119
120
  }));
120
121
 
@@ -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
  };
@@ -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,42 @@
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; }
177
213
  </style>
178
214
  </head>
179
215
  <body
@@ -304,6 +340,24 @@
304
340
  <line x1="6" y1="10" x2="9.5" y2="15" />
305
341
  </svg>
306
342
  </button>
343
+ <button
344
+ id="toolPostIt"
345
+ class="tool-btn"
346
+ title="Post-it (P)"
347
+ >
348
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.4">
349
+ <path d="M1 1 H9 L12 4 V12 H1 Z" />
350
+ <path d="M9 1 V4 H12" stroke-opacity="0.5"/>
351
+ </svg>
352
+ </button>
353
+ <button
354
+ id="toolTextFree"
355
+ class="tool-btn"
356
+ title="Texte libre (T)"
357
+ style="font-size:11px; font-weight:600;"
358
+ >
359
+ T
360
+ </button>
307
361
  <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
308
362
 
309
363
  <button
@@ -458,6 +512,9 @@
458
512
  <!-- Debug overlay layer -->
459
513
  <div id="debugLayer"></div>
460
514
 
515
+ <!-- Stamp overlay: intercepts canvas clicks during stamp mode -->
516
+ <div id="stampOverlay" style="position:absolute;inset:0;display:none;z-index:9;"></div>
517
+
461
518
  <!-- Selection / resize overlay -->
462
519
  <div id="selectionOverlay">
463
520
  <div
@@ -480,6 +537,8 @@
480
537
  class="resize-handle"
481
538
  style="bottom: -5px; right: -5px; cursor: se-resize"
482
539
  ></div>
540
+ <div id="rh-rotate" title="Rotation forme">↻</div>
541
+ <div id="rh-label-rotate" title="Rotation texte" style="left: 0; top: -28px;">T↻</div>
483
542
  </div>
484
543
 
485
544
  <!-- Node panel -->
@@ -752,6 +811,43 @@
752
811
  <rect x="5" y="1" width="8" height="8" rx="1" />
753
812
  </svg>
754
813
  </button>
814
+
815
+ <div class="panel-sep"></div>
816
+
817
+ <!-- Stamp: copy color -->
818
+ <button
819
+ id="btnStampColor"
820
+ class="tool-btn !w-7 !h-6"
821
+ title="Tampon couleur — sélectionner les cibles, cliquer ici, puis cliquer la source"
822
+ >
823
+ <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"/>
842
+ </svg>
843
+ </button>
844
+
845
+ <!-- Stamp: copy font size -->
846
+ <button
847
+ id="btnStampFontSize"
848
+ class="tool-btn !w-7 !h-6 font-mono text-xs font-bold"
849
+ title="Tampon taille police — sélectionner les cibles, cliquer ici, puis cliquer la source"
850
+ >Aa</button>
755
851
  </div>
756
852
 
757
853
  <!-- Edge panel -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {