living-documentation 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/frontend/diagram/clipboard.js +70 -1
- package/dist/src/frontend/diagram/constants.js +8 -5
- package/dist/src/frontend/diagram/grid.js +19 -2
- package/dist/src/frontend/diagram/image-upload.js +34 -0
- package/dist/src/frontend/diagram/main.js +58 -5
- package/dist/src/frontend/diagram/network.js +55 -11
- package/dist/src/frontend/diagram/node-panel.js +123 -9
- package/dist/src/frontend/diagram/node-rendering.js +347 -61
- package/dist/src/frontend/diagram/persistence.js +2 -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/toast.js +21 -0
- package/dist/src/frontend/diagram.html +174 -1
- package/package.json +1 -1
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
// ── Clipboard (copy / paste) ───────────────────────────────────────────────────
|
|
2
2
|
|
|
3
3
|
import { st, markDirty } from './state.js';
|
|
4
|
-
import { visNodeProps } from './node-rendering.js';
|
|
4
|
+
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
5
5
|
import { visEdgeProps } from './edge-rendering.js';
|
|
6
6
|
import { showNodePanel } from './node-panel.js';
|
|
7
|
+
import { showToast } from './toast.js';
|
|
8
|
+
|
|
9
|
+
// ── Copy selection as PNG ─────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export async function copySelectionAsPng() {
|
|
12
|
+
if (!st.network || !st.selectedNodeIds.length) return;
|
|
13
|
+
|
|
14
|
+
const PAD = 20;
|
|
15
|
+
const dpr = window.devicePixelRatio || 1;
|
|
16
|
+
|
|
17
|
+
// Compute bounding box in canvas coordinates
|
|
18
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
19
|
+
for (const id of st.selectedNodeIds) {
|
|
20
|
+
const n = st.nodes.get(id);
|
|
21
|
+
const bn = st.network.body.nodes[id];
|
|
22
|
+
if (!bn) continue;
|
|
23
|
+
const defaults = SHAPE_DEFAULTS[(n && n.shapeType) || 'box'] || [100, 40];
|
|
24
|
+
const w = (n && n.nodeWidth) || defaults[0];
|
|
25
|
+
const h = (n && n.nodeHeight) || defaults[1];
|
|
26
|
+
const rot = (n && n.rotation) || 0;
|
|
27
|
+
let hw, hh;
|
|
28
|
+
if (rot === 0) {
|
|
29
|
+
hw = w / 2; hh = h / 2;
|
|
30
|
+
} else {
|
|
31
|
+
const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
|
|
32
|
+
hw = (w * cos + h * sin) / 2;
|
|
33
|
+
hh = (w * sin + h * cos) / 2;
|
|
34
|
+
}
|
|
35
|
+
minX = Math.min(minX, bn.x - hw); maxX = Math.max(maxX, bn.x + hw);
|
|
36
|
+
minY = Math.min(minY, bn.y - hh); maxY = Math.max(maxY, bn.y + hh);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Convert canvas coords to DOM pixels
|
|
40
|
+
const tl = st.network.canvasToDOM({ x: minX, y: minY });
|
|
41
|
+
const br = st.network.canvasToDOM({ x: maxX, y: maxY });
|
|
42
|
+
|
|
43
|
+
const cropX = Math.max(0, Math.floor(tl.x - PAD));
|
|
44
|
+
const cropY = Math.max(0, Math.floor(tl.y - PAD));
|
|
45
|
+
const cropW = Math.ceil(br.x - tl.x + PAD * 2);
|
|
46
|
+
const cropH = Math.ceil(br.y - tl.y + PAD * 2);
|
|
47
|
+
|
|
48
|
+
// Grab vis-network's canvas element
|
|
49
|
+
const visCanvas = document.querySelector('#vis-canvas canvas');
|
|
50
|
+
if (!visCanvas) return;
|
|
51
|
+
|
|
52
|
+
// Crop into an offscreen canvas
|
|
53
|
+
const out = document.createElement('canvas');
|
|
54
|
+
out.width = cropW * dpr;
|
|
55
|
+
out.height = cropH * dpr;
|
|
56
|
+
const octx = out.getContext('2d');
|
|
57
|
+
|
|
58
|
+
// Fill background matching current theme
|
|
59
|
+
const isDark = document.documentElement.classList.contains('dark');
|
|
60
|
+
octx.fillStyle = isDark ? '#030712' : '#f9fafb';
|
|
61
|
+
octx.fillRect(0, 0, out.width, out.height);
|
|
62
|
+
|
|
63
|
+
octx.drawImage(visCanvas,
|
|
64
|
+
cropX * dpr, cropY * dpr, cropW * dpr, cropH * dpr,
|
|
65
|
+
0, 0, out.width, out.height
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const blob = await new Promise((res) => out.toBlob(res, 'image/png'));
|
|
70
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
71
|
+
showToast('PNG copié dans le presse-papier');
|
|
72
|
+
} catch {
|
|
73
|
+
showToast('Impossible de copier l\'image', 'error');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
7
76
|
|
|
8
77
|
export function copySelected() {
|
|
9
78
|
if (!st.network || !st.selectedNodeIds.length) return;
|
|
@@ -5,11 +5,14 @@ export const GRID_SIZE = 40;
|
|
|
5
5
|
|
|
6
6
|
export const TOOL_BTN_MAP = {
|
|
7
7
|
select: 'toolSelect',
|
|
8
|
-
'addNode:box':
|
|
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',
|
|
15
|
+
'addNode:image': 'toolImage',
|
|
13
16
|
addEdge: 'toolArrow',
|
|
14
17
|
};
|
|
15
18
|
|
|
@@ -4,12 +4,29 @@
|
|
|
4
4
|
import { st, markDirty } from './state.js';
|
|
5
5
|
import { GRID_SIZE } from './constants.js';
|
|
6
6
|
|
|
7
|
+
|
|
7
8
|
export function togglePhysics() {
|
|
8
9
|
st.physicsEnabled = !st.physicsEnabled;
|
|
9
|
-
if (st.network) st.network.setOptions({ physics: { enabled: st.physicsEnabled } });
|
|
10
10
|
const btn = document.getElementById('btnPhysics');
|
|
11
11
|
btn.classList.toggle('tool-active', st.physicsEnabled);
|
|
12
|
-
btn.title = st.physicsEnabled ? '
|
|
12
|
+
btn.title = st.physicsEnabled ? 'Anti-chevauchement actif' : 'Anti-chevauchement (espace les nœuds qui se superposent)';
|
|
13
|
+
|
|
14
|
+
if (!st.network) return;
|
|
15
|
+
|
|
16
|
+
st.network.setOptions({
|
|
17
|
+
physics: {
|
|
18
|
+
enabled: st.physicsEnabled,
|
|
19
|
+
stabilization: { enabled: false }, // no auto-stop — stays on until user toggles off
|
|
20
|
+
barnesHut: {
|
|
21
|
+
gravitationalConstant: -800, // mild repulsion — only pushes overlapping nodes
|
|
22
|
+
centralGravity: 0, // no pull toward centre — distant nodes stay put
|
|
23
|
+
springLength: 100,
|
|
24
|
+
springConstant: 0.01,
|
|
25
|
+
damping: 0.6, // high damping — nodes settle fast, no oscillation
|
|
26
|
+
avoidOverlap: 1, // vis-network built-in overlap avoidance
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
13
30
|
}
|
|
14
31
|
|
|
15
32
|
export function toggleGrid() {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ── Image upload helper ───────────────────────────────────────────────────────
|
|
2
|
+
// Converts a File or Blob to base64 and uploads it via POST /api/images/upload.
|
|
3
|
+
// Returns the absolute URL path usable in an <img> or ctx.drawImage(), e.g. "/images/foo.png".
|
|
4
|
+
|
|
5
|
+
async function toBase64(blob) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const reader = new FileReader();
|
|
8
|
+
reader.onload = () => resolve(reader.result);
|
|
9
|
+
reader.onerror = reject;
|
|
10
|
+
reader.readAsDataURL(blob);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function uploadImageFile(file) {
|
|
15
|
+
const ext = (file.name.split('.').pop() || 'png').toLowerCase();
|
|
16
|
+
const base64 = await toBase64(file);
|
|
17
|
+
return _upload(base64, ext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function uploadImageBlob(blob, ext = 'png') {
|
|
21
|
+
const base64 = await toBase64(blob);
|
|
22
|
+
return _upload(base64, ext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function _upload(base64, ext) {
|
|
26
|
+
const res = await fetch('/api/images/upload', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ data: base64, ext }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
32
|
+
const { filename } = await res.json();
|
|
33
|
+
return `/images/${filename}`;
|
|
34
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { st, markDirty } from './state.js';
|
|
5
5
|
import { TOOL_BTN_MAP } from './constants.js';
|
|
6
|
-
import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder } from './node-panel.js';
|
|
6
|
+
import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder, activateStamp, cancelStamp, stepRotate } from './node-panel.js';
|
|
7
7
|
import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
|
|
8
8
|
import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
|
|
9
9
|
import { hideSelectionOverlay } from './selection-overlay.js';
|
|
@@ -11,7 +11,10 @@ import { togglePhysics, toggleGrid } from './grid.js';
|
|
|
11
11
|
import { toggleDebug } from './debug.js';
|
|
12
12
|
import { adjustZoom, resetZoom } from './zoom.js';
|
|
13
13
|
import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
|
|
14
|
-
import { copySelected, pasteClipboard } from './clipboard.js';
|
|
14
|
+
import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
|
|
15
|
+
import { createImageNode } from './network.js';
|
|
16
|
+
import { uploadImageBlob } from './image-upload.js';
|
|
17
|
+
import { showToast } from './toast.js';
|
|
15
18
|
|
|
16
19
|
// ── Tool management ───────────────────────────────────────────────────────────
|
|
17
20
|
|
|
@@ -32,6 +35,14 @@ function setTool(tool, shape) {
|
|
|
32
35
|
else if (st.network) st.network.disableEditMode();
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
function selectAll() {
|
|
39
|
+
if (!st.network || !st.nodes) return;
|
|
40
|
+
const ids = st.nodes.getIds();
|
|
41
|
+
st.network.selectNodes(ids);
|
|
42
|
+
st.selectedNodeIds = ids;
|
|
43
|
+
showNodePanel();
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
function deleteSelected() {
|
|
36
47
|
if (!st.network) return;
|
|
37
48
|
st.network.deleteSelected();
|
|
@@ -69,6 +80,9 @@ document.getElementById('toolEllipse').addEventListener('click', () => setTool(
|
|
|
69
80
|
document.getElementById('toolDatabase').addEventListener('click', () => setTool('addNode', 'database'));
|
|
70
81
|
document.getElementById('toolCircle').addEventListener('click', () => setTool('addNode', 'circle'));
|
|
71
82
|
document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
|
|
83
|
+
document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
|
|
84
|
+
document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
|
|
85
|
+
document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
|
|
72
86
|
document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
|
|
73
87
|
|
|
74
88
|
document.getElementById('btnDelete').addEventListener('click', deleteSelected);
|
|
@@ -104,6 +118,19 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
|
|
|
104
118
|
document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
|
|
105
119
|
document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
|
|
106
120
|
document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
|
|
121
|
+
document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
|
|
122
|
+
document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
|
|
123
|
+
// Stamp buttons: capture targets on mousedown (before vis-network can fire
|
|
124
|
+
// deselectNode), then activate the stamp mode on click.
|
|
125
|
+
['btnStampColor', 'btnStampFontSize'].forEach((id) => {
|
|
126
|
+
document.getElementById(id).addEventListener('mousedown', (e) => {
|
|
127
|
+
e.preventDefault(); // prevent canvas focus loss
|
|
128
|
+
st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
|
|
132
|
+
document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
|
|
133
|
+
document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
|
|
107
134
|
|
|
108
135
|
// ── Edge panel wiring ─────────────────────────────────────────────────────────
|
|
109
136
|
|
|
@@ -121,19 +148,45 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
|
|
|
121
148
|
document.addEventListener('keydown', (e) => {
|
|
122
149
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
123
150
|
if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
|
|
124
|
-
if ((e.metaKey || e.ctrlKey) && e.key === '
|
|
151
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
|
|
152
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
|
|
153
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
|
|
125
154
|
if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
|
|
126
155
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
|
|
127
|
-
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { setTool('select');
|
|
156
|
+
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
|
|
128
157
|
if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
|
|
129
158
|
if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
|
|
130
159
|
if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
|
|
131
160
|
if (e.key === 'c' || e.key === 'C') { setTool('addNode', 'circle'); return; }
|
|
132
161
|
if (e.key === 'a' || e.key === 'A') { setTool('addNode', 'actor'); return; }
|
|
133
|
-
if (e.key === 'f' || e.key === 'F') { setTool('addEdge');
|
|
162
|
+
if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
|
|
163
|
+
if (e.key === 'p' || e.key === 'P') { setTool('addNode', 'post-it'); return; }
|
|
164
|
+
if (e.key === 't' || e.key === 'T') { setTool('addNode', 'text-free'); return; }
|
|
134
165
|
if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
|
|
135
166
|
});
|
|
136
167
|
|
|
168
|
+
// ── Paste image from clipboard ────────────────────────────────────────────────
|
|
169
|
+
document.addEventListener('paste', async (e) => {
|
|
170
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
171
|
+
if (!st.network) return;
|
|
172
|
+
const items = Array.from(e.clipboardData.items || []);
|
|
173
|
+
const imageItem = items.find((it) => it.type.startsWith('image/'));
|
|
174
|
+
if (!imageItem) return;
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
const blob = imageItem.getAsFile();
|
|
177
|
+
if (!blob) return;
|
|
178
|
+
const ext = imageItem.type.split('/')[1] || 'png';
|
|
179
|
+
try {
|
|
180
|
+
const src = await uploadImageBlob(blob, ext);
|
|
181
|
+
// Place at centre of current viewport
|
|
182
|
+
const center = st.network.getViewPosition();
|
|
183
|
+
createImageNode(src, center.x, center.y);
|
|
184
|
+
showToast('Image ajoutée');
|
|
185
|
+
} catch {
|
|
186
|
+
showToast('Impossible d\'importer l\'image', 'error');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
137
190
|
// ── Dark mode initialisation ──────────────────────────────────────────────────
|
|
138
191
|
|
|
139
192
|
document.getElementById('darkIcon').textContent =
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// and wires all network-level events.
|
|
4
4
|
|
|
5
5
|
import { st, markDirty } from './state.js';
|
|
6
|
-
import { visNodeProps } from './node-rendering.js';
|
|
6
|
+
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
7
|
+
import { uploadImageFile } from './image-upload.js';
|
|
8
|
+
import { showToast } from './toast.js';
|
|
7
9
|
import { visEdgeProps } from './edge-rendering.js';
|
|
8
10
|
import { showNodePanel, hideNodePanel } from './node-panel.js';
|
|
9
11
|
import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
|
|
@@ -74,11 +76,8 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
74
76
|
if (!node) continue;
|
|
75
77
|
if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
|
|
76
78
|
const r = node.draw(ctx);
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
if (r.drawExternalLabel != null && node.options.shape !== 'custom') {
|
|
80
|
-
drawExternalLabelCallbacks.push(r.drawExternalLabel);
|
|
81
|
-
}
|
|
79
|
+
// All shapes are ctxRenderer (shape:'custom') and draw their own labels.
|
|
80
|
+
// Skip drawExternalLabel entirely to avoid double-rendering.
|
|
82
81
|
} else {
|
|
83
82
|
node.updateBoundingBox(ctx, node.selected);
|
|
84
83
|
}
|
|
@@ -123,14 +122,20 @@ function onDoubleClick(params) {
|
|
|
123
122
|
st.selectedEdgeIds = [params.edges[0]];
|
|
124
123
|
showEdgePanel();
|
|
125
124
|
startEdgeLabelEdit();
|
|
125
|
+
} else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
|
|
126
|
+
const canvasPos = params.pointer.canvas;
|
|
127
|
+
pickAndCreateImageNode(canvasPos.x, canvasPos.y);
|
|
126
128
|
} else if (st.currentTool === 'addNode') {
|
|
127
|
-
const id
|
|
129
|
+
const id = 'n' + Date.now();
|
|
130
|
+
const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
|
|
131
|
+
const defaultColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
|
|
128
132
|
st.nodes.add({
|
|
129
|
-
id, label: 'Node',
|
|
130
|
-
shapeType: st.pendingShape, colorKey:
|
|
131
|
-
nodeWidth:
|
|
133
|
+
id, label: st.pendingShape === 'text-free' ? 'Texte' : 'Node',
|
|
134
|
+
shapeType: st.pendingShape, colorKey: defaultColor,
|
|
135
|
+
nodeWidth: defaults[0], nodeHeight: defaults[1],
|
|
136
|
+
fontSize: null, rotation: 0, labelRotation: 0,
|
|
132
137
|
x: params.pointer.canvas.x, y: params.pointer.canvas.y,
|
|
133
|
-
...visNodeProps(st.pendingShape,
|
|
138
|
+
...visNodeProps(st.pendingShape, defaultColor, defaults[0], defaults[1], null, null, null),
|
|
134
139
|
});
|
|
135
140
|
markDirty();
|
|
136
141
|
setTimeout(() => {
|
|
@@ -142,6 +147,45 @@ function onDoubleClick(params) {
|
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
// ── Image node creation ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function pickAndCreateImageNode(canvasX, canvasY) {
|
|
153
|
+
const input = document.createElement('input');
|
|
154
|
+
input.type = 'file';
|
|
155
|
+
input.accept = 'image/*';
|
|
156
|
+
input.onchange = async () => {
|
|
157
|
+
const file = input.files && input.files[0];
|
|
158
|
+
if (!file) return;
|
|
159
|
+
try {
|
|
160
|
+
const src = await uploadImageFile(file);
|
|
161
|
+
createImageNode(src, canvasX, canvasY);
|
|
162
|
+
} catch {
|
|
163
|
+
showToast('Impossible d\'importer l\'image', 'error');
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
input.click();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createImageNode(imageSrc, canvasX, canvasY) {
|
|
170
|
+
if (!st.network) return;
|
|
171
|
+
const id = 'n' + Date.now();
|
|
172
|
+
const defaults = SHAPE_DEFAULTS['image'];
|
|
173
|
+
st.nodes.add({
|
|
174
|
+
id, label: '', imageSrc,
|
|
175
|
+
shapeType: 'image', colorKey: 'c-gray',
|
|
176
|
+
nodeWidth: defaults[0], nodeHeight: defaults[1],
|
|
177
|
+
fontSize: null, rotation: 0, labelRotation: 0,
|
|
178
|
+
x: canvasX, y: canvasY,
|
|
179
|
+
...visNodeProps('image', 'c-gray', defaults[0], defaults[1], null, null, null),
|
|
180
|
+
});
|
|
181
|
+
markDirty();
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
st.network.selectNodes([id]);
|
|
184
|
+
st.selectedNodeIds = [id];
|
|
185
|
+
showNodePanel();
|
|
186
|
+
}, 50);
|
|
187
|
+
}
|
|
188
|
+
|
|
145
189
|
function onSelectNode(params) {
|
|
146
190
|
st.selectedNodeIds = params.nodes;
|
|
147
191
|
st.selectedEdgeIds = [];
|
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
// Floating formatting toolbar for selected nodes (color, font, alignment, z-order).
|
|
3
3
|
|
|
4
4
|
import { st, markDirty } from './state.js';
|
|
5
|
-
import {
|
|
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,31 +40,133 @@ export function changeNodeFontSize(delta) {
|
|
|
28
40
|
const n = st.nodes.get(id);
|
|
29
41
|
if (!n) return;
|
|
30
42
|
const newSize = Math.max(8, Math.min(48, (n.fontSize || 13) + delta));
|
|
31
|
-
|
|
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) => {
|
|
63
|
+
const n = st.nodes.get(id);
|
|
64
|
+
if (!n) return;
|
|
65
|
+
st.nodes.update({ id, textValign: valign });
|
|
66
|
+
});
|
|
67
|
+
forceRedraw();
|
|
68
|
+
markDirty();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Stamp (format painter) ────────────────────────────────────────────────────
|
|
72
|
+
// Uses a transparent DOM overlay (#stampOverlay) that intercepts canvas clicks
|
|
73
|
+
// during stamp mode. This bypasses vis-network's event system entirely, avoiding
|
|
74
|
+
// the deselectNode/click ordering problems that make st.activeStamp unreliable.
|
|
75
|
+
|
|
76
|
+
const STAMP_BTNS = { color: 'btnStampColor', rotation: 'btnStampRotation', fontSize: 'btnStampFontSize' };
|
|
77
|
+
|
|
78
|
+
export function activateStamp(type) {
|
|
79
|
+
if (!st.stampTargetIds.length) return; // targets were saved on mousedown
|
|
80
|
+
st.activeStamp = type;
|
|
81
|
+
const overlay = document.getElementById('stampOverlay');
|
|
82
|
+
overlay.style.display = 'block';
|
|
83
|
+
Object.entries(STAMP_BTNS).forEach(([t, id]) =>
|
|
84
|
+
document.getElementById(id).classList.toggle('tool-active', t === type)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cancelStamp() {
|
|
89
|
+
st.activeStamp = null;
|
|
90
|
+
st.stampTargetIds = [];
|
|
91
|
+
document.getElementById('stampOverlay').style.display = 'none';
|
|
92
|
+
Object.values(STAMP_BTNS).forEach((id) =>
|
|
93
|
+
document.getElementById(id).classList.remove('tool-active')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyStamp(sourceId) {
|
|
98
|
+
const source = st.nodes.get(sourceId);
|
|
99
|
+
if (!source || !st.activeStamp || !st.stampTargetIds.length) return;
|
|
100
|
+
const type = st.activeStamp;
|
|
101
|
+
const targets = [...st.stampTargetIds]; // snapshot before cancelStamp clears the array
|
|
102
|
+
cancelStamp();
|
|
103
|
+
|
|
104
|
+
targets.forEach((id) => {
|
|
105
|
+
if (id === sourceId) return;
|
|
106
|
+
const target = st.nodes.get(id);
|
|
107
|
+
if (!target) return;
|
|
108
|
+
if (type === 'color') st.nodes.update({ id, colorKey: source.colorKey || 'c-gray' });
|
|
109
|
+
if (type === 'rotation') st.nodes.update({ id, rotation: source.rotation || 0 });
|
|
110
|
+
if (type === 'fontSize') st.nodes.update({ id, fontSize: source.fontSize || 13 });
|
|
111
|
+
const bn = st.network && st.network.body.nodes[id];
|
|
112
|
+
if (bn) bn.refreshNeeded = true;
|
|
113
|
+
});
|
|
114
|
+
if (st.network) st.network.redraw();
|
|
115
|
+
markDirty();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// getNodeAt() is unreliable for shape:'custom' (bounding box near-zero).
|
|
119
|
+
// Manual AABB hit test using DOMtoCanvas + node dimensions, topmost node first.
|
|
120
|
+
function getNodeAtDOMPoint(domX, domY) {
|
|
121
|
+
if (!st.network || !st.nodes) return undefined;
|
|
122
|
+
const cp = st.network.DOMtoCanvas({ x: domX, y: domY });
|
|
123
|
+
for (let i = st.canonicalOrder.length - 1; i >= 0; i--) {
|
|
124
|
+
const id = st.canonicalOrder[i];
|
|
51
125
|
const n = st.nodes.get(id);
|
|
126
|
+
const bn = st.network.body.nodes[id];
|
|
127
|
+
if (!n || !bn) continue;
|
|
128
|
+
const defaults = SHAPE_DEFAULTS[n.shapeType] || [100, 40];
|
|
129
|
+
const w = n.nodeWidth || defaults[0];
|
|
130
|
+
const h = n.nodeHeight || defaults[1];
|
|
131
|
+
const rot = n.rotation || 0;
|
|
132
|
+
let hw, hh;
|
|
133
|
+
if (rot === 0) {
|
|
134
|
+
hw = w / 2; hh = h / 2;
|
|
135
|
+
} else {
|
|
136
|
+
const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
|
|
137
|
+
hw = (w * cos + h * sin) / 2;
|
|
138
|
+
hh = (w * sin + h * cos) / 2;
|
|
139
|
+
}
|
|
140
|
+
if (Math.abs(cp.x - bn.x) <= hw && Math.abs(cp.y - bn.y) <= hh) return id;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Wire the stamp overlay click.
|
|
146
|
+
document.getElementById('stampOverlay').addEventListener('click', (e) => {
|
|
147
|
+
if (!st.activeStamp || !st.network) return;
|
|
148
|
+
const rect = document.getElementById('vis-canvas').getBoundingClientRect();
|
|
149
|
+
const nodeId = getNodeAtDOMPoint(e.clientX - rect.left, e.clientY - rect.top);
|
|
150
|
+
if (nodeId !== undefined) {
|
|
151
|
+
applyStamp(nodeId);
|
|
152
|
+
} else {
|
|
153
|
+
cancelStamp();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── Step rotation ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function stepRotate(degrees) {
|
|
160
|
+
if (!st.selectedNodeIds.length) return;
|
|
161
|
+
const delta = degrees * (Math.PI / 180);
|
|
162
|
+
st.selectedNodeIds.forEach((id) => {
|
|
163
|
+
const n = st.nodes.get(id);
|
|
52
164
|
if (!n) return;
|
|
53
|
-
|
|
54
|
-
|
|
165
|
+
st.nodes.update({ id, rotation: (n.rotation || 0) + delta });
|
|
166
|
+
const bn = st.network && st.network.body.nodes[id];
|
|
167
|
+
if (bn) bn.refreshNeeded = true;
|
|
55
168
|
});
|
|
169
|
+
if (st.network) st.network.redraw();
|
|
56
170
|
markDirty();
|
|
57
171
|
}
|
|
58
172
|
|