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.
- package/dist/src/frontend/diagram/constants.js +7 -5
- package/dist/src/frontend/diagram/grid.js +19 -2
- package/dist/src/frontend/diagram/main.js +18 -3
- package/dist/src/frontend/diagram/network.js +11 -11
- package/dist/src/frontend/diagram/node-panel.js +108 -10
- package/dist/src/frontend/diagram/node-rendering.js +285 -62
- package/dist/src/frontend/diagram/persistence.js +1 -0
- package/dist/src/frontend/diagram/selection-overlay.js +161 -54
- package/dist/src/frontend/diagram/state.js +4 -0
- package/dist/src/frontend/diagram.html +97 -1
- package/package.json +1 -1
|
@@ -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':
|
|
9
|
-
'addNode:ellipse':
|
|
10
|
-
'addNode:database':
|
|
11
|
-
'addNode:circle':
|
|
12
|
-
'addNode:actor':
|
|
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 ? '
|
|
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');
|
|
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');
|
|
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
|
-
//
|
|
78
|
-
//
|
|
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
|
|
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:
|
|
131
|
-
nodeWidth:
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
+
const n = st.nodes.get(id);
|
|
41
53
|
if (!n) return;
|
|
42
|
-
|
|
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
|
|
63
|
+
const n = st.nodes.get(id);
|
|
52
64
|
if (!n) return;
|
|
53
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
247
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
39
248
|
ctx.lineWidth = 2;
|
|
40
249
|
ctx.lineCap = 'round';
|
|
41
|
-
ctx.beginPath(); ctx.arc(
|
|
42
|
-
ctx.beginPath(); ctx.moveTo(
|
|
43
|
-
ctx.beginPath(); ctx.moveTo(
|
|
44
|
-
ctx.beginPath(); ctx.moveTo(
|
|
45
|
-
ctx.beginPath(); ctx.moveTo(
|
|
46
|
-
|
|
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 =
|
|
58
|
-
lines.forEach((line, i) => ctx.fillText(line,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
88
|
-
export function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
return { shape: 'custom', ctxRenderer:
|
|
330
|
+
const factory = RENDERER_MAP[shapeType];
|
|
331
|
+
if (factory) {
|
|
332
|
+
return { shape: 'custom', ctxRenderer: factory(colorKey), ...colorP, ...sizeP };
|
|
111
333
|
}
|
|
112
|
-
|
|
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
|
|
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
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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((
|
|
76
|
-
const initBoxH = startBBs.reduce((
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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 -->
|