living-documentation 3.7.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 +1 -0
- package/dist/src/frontend/diagram/image-upload.js +34 -0
- package/dist/src/frontend/diagram/main.js +43 -5
- package/dist/src/frontend/diagram/network.js +44 -0
- package/dist/src/frontend/diagram/node-panel.js +16 -0
- package/dist/src/frontend/diagram/node-rendering.js +63 -0
- package/dist/src/frontend/diagram/persistence.js +1 -0
- package/dist/src/frontend/diagram/toast.js +21 -0
- package/dist/src/frontend/diagram.html +97 -20
- 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;
|
|
@@ -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, activateStamp, cancelStamp } 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();
|
|
@@ -71,6 +82,7 @@ document.getElementById('toolCircle').addEventListener('click', () => setTool(
|
|
|
71
82
|
document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
|
|
72
83
|
document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
|
|
73
84
|
document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
|
|
85
|
+
document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
|
|
74
86
|
document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
|
|
75
87
|
|
|
76
88
|
document.getElementById('btnDelete').addEventListener('click', deleteSelected);
|
|
@@ -106,17 +118,19 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
|
|
|
106
118
|
document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
|
|
107
119
|
document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
|
|
108
120
|
document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
|
|
121
|
+
document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
|
|
122
|
+
document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
|
|
109
123
|
// Stamp buttons: capture targets on mousedown (before vis-network can fire
|
|
110
124
|
// deselectNode), then activate the stamp mode on click.
|
|
111
|
-
['btnStampColor', '
|
|
125
|
+
['btnStampColor', 'btnStampFontSize'].forEach((id) => {
|
|
112
126
|
document.getElementById(id).addEventListener('mousedown', (e) => {
|
|
113
127
|
e.preventDefault(); // prevent canvas focus loss
|
|
114
128
|
st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
|
|
115
129
|
});
|
|
116
130
|
});
|
|
117
131
|
document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
|
|
118
|
-
document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
|
|
119
132
|
document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
|
|
133
|
+
document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
|
|
120
134
|
|
|
121
135
|
// ── Edge panel wiring ─────────────────────────────────────────────────────────
|
|
122
136
|
|
|
@@ -134,7 +148,9 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
|
|
|
134
148
|
document.addEventListener('keydown', (e) => {
|
|
135
149
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
136
150
|
if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
|
|
137
|
-
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; }
|
|
138
154
|
if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
|
|
139
155
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
|
|
140
156
|
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
|
|
@@ -149,6 +165,28 @@ document.addEventListener('keydown', (e) => {
|
|
|
149
165
|
if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
|
|
150
166
|
});
|
|
151
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
|
+
|
|
152
190
|
// ── Dark mode initialisation ──────────────────────────────────────────────────
|
|
153
191
|
|
|
154
192
|
document.getElementById('darkIcon').textContent =
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { st, markDirty } from './state.js';
|
|
6
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';
|
|
@@ -120,6 +122,9 @@ function onDoubleClick(params) {
|
|
|
120
122
|
st.selectedEdgeIds = [params.edges[0]];
|
|
121
123
|
showEdgePanel();
|
|
122
124
|
startEdgeLabelEdit();
|
|
125
|
+
} else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
|
|
126
|
+
const canvasPos = params.pointer.canvas;
|
|
127
|
+
pickAndCreateImageNode(canvasPos.x, canvasPos.y);
|
|
123
128
|
} else if (st.currentTool === 'addNode') {
|
|
124
129
|
const id = 'n' + Date.now();
|
|
125
130
|
const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
|
|
@@ -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 = [];
|
|
@@ -154,6 +154,22 @@ document.getElementById('stampOverlay').addEventListener('click', (e) => {
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
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);
|
|
164
|
+
if (!n) return;
|
|
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;
|
|
168
|
+
});
|
|
169
|
+
if (st.network) st.network.redraw();
|
|
170
|
+
markDirty();
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
export function changeZOrder(direction) {
|
|
158
174
|
// direction: +1 = bring to front (last in canonicalOrder = drawn on top)
|
|
159
175
|
// -1 = send to back (first in canonicalOrder = drawn below)
|
|
@@ -273,6 +273,67 @@ export function makeActorRenderer(colorKey) {
|
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// ── Image node ────────────────────────────────────────────────────────────────
|
|
277
|
+
// Images are loaded once and cached. When still loading a placeholder is drawn;
|
|
278
|
+
// once the image is ready network.redraw() is called so the frame updates.
|
|
279
|
+
|
|
280
|
+
const _imgCache = new Map(); // src → HTMLImageElement | 'loading' | 'error'
|
|
281
|
+
|
|
282
|
+
function getCachedImage(src, redrawFn) {
|
|
283
|
+
if (!src) return null;
|
|
284
|
+
const cached = _imgCache.get(src);
|
|
285
|
+
if (cached === 'loading' || cached === 'error') return null;
|
|
286
|
+
if (cached) return cached;
|
|
287
|
+
_imgCache.set(src, 'loading');
|
|
288
|
+
const img = new Image();
|
|
289
|
+
img.onload = () => { _imgCache.set(src, img); redrawFn && redrawFn(); };
|
|
290
|
+
img.onerror = () => { _imgCache.set(src, 'error'); };
|
|
291
|
+
img.src = src;
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function makeImageRenderer(colorKey) {
|
|
296
|
+
return function ({ ctx, x, y, id, state: visState, label }) {
|
|
297
|
+
const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 160, 120, colorKey || 'c-gray');
|
|
298
|
+
const n = st.nodes && st.nodes.get(id);
|
|
299
|
+
const src = n && n.imageSrc;
|
|
300
|
+
const img = getCachedImage(src, () => st.network && st.network.redraw());
|
|
301
|
+
return {
|
|
302
|
+
drawNode() {
|
|
303
|
+
ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
|
|
304
|
+
// Border (always visible, orange when selected)
|
|
305
|
+
ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
|
|
306
|
+
ctx.lineWidth = visState.selected ? 2 : 1;
|
|
307
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
308
|
+
ctx.stroke();
|
|
309
|
+
|
|
310
|
+
if (img) {
|
|
311
|
+
// Clip to rounded rect then draw image
|
|
312
|
+
ctx.save();
|
|
313
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
314
|
+
ctx.clip();
|
|
315
|
+
ctx.drawImage(img, -W / 2, -H / 2, W, H);
|
|
316
|
+
ctx.restore();
|
|
317
|
+
} else {
|
|
318
|
+
// Placeholder: light fill + icon
|
|
319
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
320
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
321
|
+
ctx.fill();
|
|
322
|
+
ctx.fillStyle = c.border;
|
|
323
|
+
ctx.font = `${Math.round(Math.min(W, H) * 0.25)}px system-ui`;
|
|
324
|
+
ctx.textAlign = 'center';
|
|
325
|
+
ctx.textBaseline = 'middle';
|
|
326
|
+
ctx.fillText(src ? '…' : '🖼', 0, 0);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (label) drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
330
|
+
ctx.restore();
|
|
331
|
+
},
|
|
332
|
+
nodeDimensions: { width: W, height: H },
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
276
337
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
277
338
|
|
|
278
339
|
// Returns the rendered height from vis-network internals (used by node-panel
|
|
@@ -294,6 +355,7 @@ const RENDERER_MAP = {
|
|
|
294
355
|
'post-it': makePostItRenderer,
|
|
295
356
|
'text-free':makeTextFreeRenderer,
|
|
296
357
|
actor: makeActorRenderer,
|
|
358
|
+
image: makeImageRenderer,
|
|
297
359
|
};
|
|
298
360
|
|
|
299
361
|
// Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
|
|
@@ -305,6 +367,7 @@ export const SHAPE_DEFAULTS = {
|
|
|
305
367
|
actor: [30, 52],
|
|
306
368
|
'post-it': [120, 100],
|
|
307
369
|
'text-free':[80, 30],
|
|
370
|
+
image: [160, 120],
|
|
308
371
|
};
|
|
309
372
|
|
|
310
373
|
// Builds the full vis.js node property object.
|
|
@@ -116,6 +116,7 @@ export async function saveDiagram() {
|
|
|
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
118
|
rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
|
|
119
|
+
imageSrc: n.imageSrc || null,
|
|
119
120
|
x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
|
|
120
121
|
}));
|
|
121
122
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ── Toast notifications ───────────────────────────────────────────────────────
|
|
2
|
+
// Minimal sonner-style toasts, no dependencies.
|
|
3
|
+
|
|
4
|
+
export function showToast(message, type = 'success', duration = 3500) {
|
|
5
|
+
const container = document.getElementById('toastContainer');
|
|
6
|
+
const el = document.createElement('div');
|
|
7
|
+
el.className = 'ld-toast ld-toast--' + type;
|
|
8
|
+
el.textContent = message;
|
|
9
|
+
container.appendChild(el);
|
|
10
|
+
|
|
11
|
+
// Animate in on next frame
|
|
12
|
+
requestAnimationFrame(() => el.classList.add('ld-toast--visible'));
|
|
13
|
+
|
|
14
|
+
const hide = () => {
|
|
15
|
+
el.classList.remove('ld-toast--visible');
|
|
16
|
+
el.addEventListener('transitionend', () => el.remove(), { once: true });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const timer = setTimeout(hide, duration);
|
|
20
|
+
el.addEventListener('click', () => { clearTimeout(timer); hide(); });
|
|
21
|
+
}
|
|
@@ -210,6 +210,47 @@
|
|
|
210
210
|
}
|
|
211
211
|
#rh-label-rotate:active { cursor: grabbing; }
|
|
212
212
|
.dark #rh-label-rotate { background: #374151; }
|
|
213
|
+
|
|
214
|
+
/* Toast notifications */
|
|
215
|
+
#toastContainer {
|
|
216
|
+
position: fixed;
|
|
217
|
+
bottom: 1.5rem;
|
|
218
|
+
right: 1.5rem;
|
|
219
|
+
display: flex;
|
|
220
|
+
flex-direction: column-reverse;
|
|
221
|
+
gap: 0.5rem;
|
|
222
|
+
z-index: 1000;
|
|
223
|
+
pointer-events: none;
|
|
224
|
+
}
|
|
225
|
+
.ld-toast {
|
|
226
|
+
pointer-events: all;
|
|
227
|
+
padding: 0.6rem 1rem;
|
|
228
|
+
border-radius: 0.5rem;
|
|
229
|
+
font-size: 0.8rem;
|
|
230
|
+
font-weight: 500;
|
|
231
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
opacity: 0;
|
|
234
|
+
transform: translateY(0.5rem);
|
|
235
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
236
|
+
background: #1c1917;
|
|
237
|
+
color: #fafaf9;
|
|
238
|
+
border: 1px solid #292524;
|
|
239
|
+
}
|
|
240
|
+
.dark .ld-toast {
|
|
241
|
+
background: #fafaf9;
|
|
242
|
+
color: #1c1917;
|
|
243
|
+
border-color: #e7e5e4;
|
|
244
|
+
}
|
|
245
|
+
.ld-toast--error {
|
|
246
|
+
background: #7f1d1d;
|
|
247
|
+
color: #fef2f2;
|
|
248
|
+
border-color: #991b1b;
|
|
249
|
+
}
|
|
250
|
+
.ld-toast--visible {
|
|
251
|
+
opacity: 1;
|
|
252
|
+
transform: translateY(0);
|
|
253
|
+
}
|
|
213
254
|
</style>
|
|
214
255
|
</head>
|
|
215
256
|
<body
|
|
@@ -358,6 +399,17 @@
|
|
|
358
399
|
>
|
|
359
400
|
T
|
|
360
401
|
</button>
|
|
402
|
+
<button
|
|
403
|
+
id="toolImage"
|
|
404
|
+
class="tool-btn"
|
|
405
|
+
title="Image — double-clic sur le canvas pour choisir un fichier, ou coller (⌘V) depuis le presse-papier"
|
|
406
|
+
>
|
|
407
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
408
|
+
<rect x="1" y="1" width="12" height="12" rx="1.5"/>
|
|
409
|
+
<circle cx="4.5" cy="4.5" r="1.2"/>
|
|
410
|
+
<path d="M1 9.5 L4 6.5 L6.5 9 L9 7 L13 10.5"/>
|
|
411
|
+
</svg>
|
|
412
|
+
</button>
|
|
361
413
|
<div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
|
|
362
414
|
|
|
363
415
|
<button
|
|
@@ -538,7 +590,14 @@
|
|
|
538
590
|
style="bottom: -5px; right: -5px; cursor: se-resize"
|
|
539
591
|
></div>
|
|
540
592
|
<div id="rh-rotate" title="Rotation forme">↻</div>
|
|
541
|
-
<div id="rh-label-rotate" title="Rotation texte" style="left: 0; top: -28px;">
|
|
593
|
+
<div id="rh-label-rotate" title="Rotation texte" style="left: 0; top: -28px;">
|
|
594
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
595
|
+
<g transform="rotate(25,5,5)">
|
|
596
|
+
<line x1="2.5" y1="3" x2="7.5" y2="3"/>
|
|
597
|
+
<line x1="5" y1="3" x2="5" y2="8"/>
|
|
598
|
+
</g>
|
|
599
|
+
</svg>
|
|
600
|
+
</div>
|
|
542
601
|
</div>
|
|
543
602
|
|
|
544
603
|
<!-- Node panel -->
|
|
@@ -814,31 +873,14 @@
|
|
|
814
873
|
|
|
815
874
|
<div class="panel-sep"></div>
|
|
816
875
|
|
|
817
|
-
<!-- Stamp: copy color -->
|
|
876
|
+
<!-- Stamp: copy color (goutte) -->
|
|
818
877
|
<button
|
|
819
878
|
id="btnStampColor"
|
|
820
879
|
class="tool-btn !w-7 !h-6"
|
|
821
880
|
title="Tampon couleur — sélectionner les cibles, cliquer ici, puis cliquer la source"
|
|
822
881
|
>
|
|
823
882
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
824
|
-
<
|
|
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"/>
|
|
883
|
+
<path d="M7 2 C7 2 3 6.5 3 9 a4 4 0 0 0 8 0 C11 6.5 7 2 7 2 Z" fill="currentColor" fill-opacity="0.25"/>
|
|
842
884
|
</svg>
|
|
843
885
|
</button>
|
|
844
886
|
|
|
@@ -848,6 +890,40 @@
|
|
|
848
890
|
class="tool-btn !w-7 !h-6 font-mono text-xs font-bold"
|
|
849
891
|
title="Tampon taille police — sélectionner les cibles, cliquer ici, puis cliquer la source"
|
|
850
892
|
>Aa</button>
|
|
893
|
+
|
|
894
|
+
<div class="panel-sep"></div>
|
|
895
|
+
|
|
896
|
+
<!-- Rotation anti-horaire 10° -->
|
|
897
|
+
<button
|
|
898
|
+
id="btnRotateCCW"
|
|
899
|
+
class="tool-btn !w-7 !h-6"
|
|
900
|
+
title="Rotation anti-horaire 10°"
|
|
901
|
+
style="font-size:15px; line-height:1;"
|
|
902
|
+
>↺</button>
|
|
903
|
+
|
|
904
|
+
<!-- Rotation horaire 10° -->
|
|
905
|
+
<button
|
|
906
|
+
id="btnRotateCW"
|
|
907
|
+
class="tool-btn !w-7 !h-6"
|
|
908
|
+
title="Rotation horaire 10°"
|
|
909
|
+
style="font-size:15px; line-height:1;"
|
|
910
|
+
>↻</button>
|
|
911
|
+
|
|
912
|
+
<div class="panel-sep"></div>
|
|
913
|
+
|
|
914
|
+
<!-- Copy as PNG -->
|
|
915
|
+
<button
|
|
916
|
+
id="btnCopyPng"
|
|
917
|
+
class="tool-btn !h-6 px-1.5 font-mono text-xs font-semibold flex items-center gap-0.5"
|
|
918
|
+
title="Copier la sélection en PNG (⌘⇧C)"
|
|
919
|
+
>
|
|
920
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
921
|
+
<line x1="5" y1="7" x2="5" y2="1"/>
|
|
922
|
+
<polyline points="2,4 5,1 8,4"/>
|
|
923
|
+
<line x1="1" y1="9" x2="9" y2="9"/>
|
|
924
|
+
</svg>
|
|
925
|
+
PNG
|
|
926
|
+
</button>
|
|
851
927
|
</div>
|
|
852
928
|
|
|
853
929
|
<!-- Edge panel -->
|
|
@@ -969,6 +1045,7 @@
|
|
|
969
1045
|
</div>
|
|
970
1046
|
</div>
|
|
971
1047
|
|
|
1048
|
+
<div id="toastContainer"></div>
|
|
972
1049
|
<script type="module" src="/diagram/main.js"></script>
|
|
973
1050
|
</body>
|
|
974
1051
|
</html>
|