living-documentation 3.7.0 → 4.0.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/README.md +2 -0
- package/dist/src/frontend/diagram/clipboard.js +80 -1
- package/dist/src/frontend/diagram/constants.js +1 -0
- package/dist/src/frontend/diagram/groups.js +99 -0
- package/dist/src/frontend/diagram/image-upload.js +34 -0
- package/dist/src/frontend/diagram/link-panel.js +138 -0
- package/dist/src/frontend/diagram/main.js +51 -6
- package/dist/src/frontend/diagram/network.js +107 -7
- package/dist/src/frontend/diagram/node-panel.js +16 -0
- package/dist/src/frontend/diagram/node-rendering.js +130 -0
- package/dist/src/frontend/diagram/persistence.js +7 -1
- package/dist/src/frontend/diagram/toast.js +21 -0
- package/dist/src/frontend/diagram.html +177 -22
- package/dist/src/frontend/index.html +75 -239
- package/dist/src/frontend/wordcloud.js +321 -0
- package/dist/src/routes/wordcloud.d.ts +3 -0
- package/dist/src/routes/wordcloud.d.ts.map +1 -0
- package/dist/src/routes/wordcloud.js +73 -0
- package/dist/src/routes/wordcloud.js.map +1 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +2 -0
- package/dist/src/server.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ No cloud, no database, no build step — just point it at a folder of `.md` file
|
|
|
22
22
|
- **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
|
|
23
23
|
- **Inline editing** — edit any document directly in the browser, saves to disk instantly
|
|
24
24
|
- **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
|
|
25
|
+
- **Word Cloud** — visualise the dominant vocabulary of any folder on disk; supports `.md`, `.ts`, `.java`, `.kt`, `.py`, `.go`, `.rs`, `.cs`, `.swift`, `.rb`, `.html`, `.css`, `.yml`, `.json` and more; stop words filtered per language
|
|
26
|
+
- **Diagram editor** — built-in canvas diagram editor (vis-network); deep-link to any diagram with `?id=`
|
|
25
27
|
- **Zero frontend build** — Tailwind and highlight.js loaded from CDN
|
|
26
28
|
|
|
27
29
|
---
|
|
@@ -1,9 +1,88 @@
|
|
|
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
|
+
// Temporarily deselect everything so orange highlights don't appear in the PNG
|
|
49
|
+
const savedNodeIds = [...st.selectedNodeIds];
|
|
50
|
+
st.network.unselectAll();
|
|
51
|
+
st.network.redraw();
|
|
52
|
+
|
|
53
|
+
// Grab vis-network's canvas element
|
|
54
|
+
const visCanvas = document.querySelector('#vis-canvas canvas');
|
|
55
|
+
if (!visCanvas) {
|
|
56
|
+
st.network.selectNodes(savedNodeIds);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Crop into an offscreen canvas
|
|
61
|
+
const out = document.createElement('canvas');
|
|
62
|
+
out.width = cropW * dpr;
|
|
63
|
+
out.height = cropH * dpr;
|
|
64
|
+
const octx = out.getContext('2d');
|
|
65
|
+
|
|
66
|
+
// Fill background matching current theme
|
|
67
|
+
const isDark = document.documentElement.classList.contains('dark');
|
|
68
|
+
octx.fillStyle = isDark ? '#030712' : '#f9fafb';
|
|
69
|
+
octx.fillRect(0, 0, out.width, out.height);
|
|
70
|
+
|
|
71
|
+
octx.drawImage(visCanvas,
|
|
72
|
+
cropX * dpr, cropY * dpr, cropW * dpr, cropH * dpr,
|
|
73
|
+
0, 0, out.width, out.height
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const blob = await new Promise((res) => out.toBlob(res, 'image/png'));
|
|
78
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
79
|
+
showToast('PNG copié dans le presse-papier');
|
|
80
|
+
} catch {
|
|
81
|
+
showToast('Impossible de copier l\'image', 'error');
|
|
82
|
+
} finally {
|
|
83
|
+
st.network.selectNodes(savedNodeIds);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
7
86
|
|
|
8
87
|
export function copySelected() {
|
|
9
88
|
if (!st.network || !st.selectedNodeIds.length) return;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ── Group management ──────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
import { st, markDirty } from './state.js';
|
|
4
|
+
import { SHAPE_DEFAULTS } from './node-rendering.js';
|
|
5
|
+
|
|
6
|
+
// ── Create / destroy ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export function groupNodes() {
|
|
9
|
+
if (st.selectedNodeIds.length < 2) return;
|
|
10
|
+
const groupId = 'g' + Date.now();
|
|
11
|
+
st.selectedNodeIds.forEach((id) => st.nodes.update({ id, groupId }));
|
|
12
|
+
markDirty();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ungroupNodes() {
|
|
16
|
+
if (!st.selectedNodeIds.length) return;
|
|
17
|
+
// Collect all members of any group touched by the selection
|
|
18
|
+
const groupIds = new Set(
|
|
19
|
+
st.selectedNodeIds.map((id) => { const n = st.nodes.get(id); return n && n.groupId; }).filter(Boolean)
|
|
20
|
+
);
|
|
21
|
+
st.nodes.get().forEach((n) => {
|
|
22
|
+
if (n.groupId && groupIds.has(n.groupId)) st.nodes.update({ id: n.id, groupId: null });
|
|
23
|
+
});
|
|
24
|
+
markDirty();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Selection expansion ───────────────────────────────────────────────────────
|
|
28
|
+
// Called from onSelectNode — expands selection to all group members.
|
|
29
|
+
|
|
30
|
+
export function expandSelectionToGroup(nodeIds) {
|
|
31
|
+
const groupIds = new Set();
|
|
32
|
+
nodeIds.forEach((id) => {
|
|
33
|
+
const n = st.nodes.get(id);
|
|
34
|
+
if (n && n.groupId) groupIds.add(n.groupId);
|
|
35
|
+
});
|
|
36
|
+
if (!groupIds.size) return nodeIds;
|
|
37
|
+
|
|
38
|
+
const expanded = new Set(nodeIds);
|
|
39
|
+
st.nodes.get().forEach((n) => {
|
|
40
|
+
if (n.groupId && groupIds.has(n.groupId)) expanded.add(n.id);
|
|
41
|
+
});
|
|
42
|
+
return [...expanded];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Group outline (drawn on canvas in afterDrawing) ───────────────────────────
|
|
46
|
+
|
|
47
|
+
function nodeBounds(id) {
|
|
48
|
+
const n = st.nodes.get(id);
|
|
49
|
+
const bodyNode = st.network.body.nodes[id];
|
|
50
|
+
if (!bodyNode) return null;
|
|
51
|
+
const shape = (n && n.shapeType) || 'box';
|
|
52
|
+
const defaults = SHAPE_DEFAULTS[shape] || [100, 40];
|
|
53
|
+
const W = (n && n.nodeWidth) || defaults[0];
|
|
54
|
+
const H = (n && n.nodeHeight) || defaults[1];
|
|
55
|
+
const rot = (n && n.rotation) || 0;
|
|
56
|
+
const cx = bodyNode.x, cy = bodyNode.y;
|
|
57
|
+
if (rot === 0) {
|
|
58
|
+
return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
|
|
59
|
+
}
|
|
60
|
+
const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
|
|
61
|
+
const hw = (W * cos + H * sin) / 2;
|
|
62
|
+
const hh = (W * sin + H * cos) / 2;
|
|
63
|
+
return { minX: cx - hw, minY: cy - hh, maxX: cx + hw, maxY: cy + hh };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function drawGroupOutlines(ctx) {
|
|
67
|
+
if (!st.network || !st.selectedNodeIds.length) return;
|
|
68
|
+
|
|
69
|
+
// Find groupIds that have at least one selected member
|
|
70
|
+
const selectedSet = new Set(st.selectedNodeIds);
|
|
71
|
+
const activeGroups = new Set();
|
|
72
|
+
st.selectedNodeIds.forEach((id) => {
|
|
73
|
+
const n = st.nodes.get(id);
|
|
74
|
+
if (n && n.groupId) activeGroups.add(n.groupId);
|
|
75
|
+
});
|
|
76
|
+
if (!activeGroups.size) return;
|
|
77
|
+
|
|
78
|
+
// For each active group, compute bounding box over ALL members
|
|
79
|
+
activeGroups.forEach((groupId) => {
|
|
80
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
81
|
+
st.nodes.get().forEach((n) => {
|
|
82
|
+
if (n.groupId !== groupId) return;
|
|
83
|
+
const b = nodeBounds(n.id);
|
|
84
|
+
if (!b) return;
|
|
85
|
+
minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
|
|
86
|
+
maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
|
|
87
|
+
});
|
|
88
|
+
if (minX === Infinity) return;
|
|
89
|
+
|
|
90
|
+
const PAD = 14;
|
|
91
|
+
ctx.save();
|
|
92
|
+
ctx.strokeStyle = '#6366f1';
|
|
93
|
+
ctx.lineWidth = 1.5;
|
|
94
|
+
ctx.setLineDash([6, 4]);
|
|
95
|
+
ctx.strokeRect(minX - PAD, minY - PAD, maxX - minX + PAD * 2, maxY - minY + PAD * 2);
|
|
96
|
+
ctx.setLineDash([]);
|
|
97
|
+
ctx.restore();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ── Node link panel ───────────────────────────────────────────────────────────
|
|
2
|
+
// Lets the user attach a URL or diagram link to a node.
|
|
3
|
+
|
|
4
|
+
import { st, markDirty } from './state.js';
|
|
5
|
+
import { openDiagram } from './persistence.js';
|
|
6
|
+
import { showToast } from './toast.js';
|
|
7
|
+
|
|
8
|
+
let _panelNodeId = null;
|
|
9
|
+
|
|
10
|
+
export function showLinkPanel(nodeId) {
|
|
11
|
+
_panelNodeId = nodeId;
|
|
12
|
+
const n = st.nodes.get(nodeId);
|
|
13
|
+
const link = (n && n.nodeLink) || null;
|
|
14
|
+
|
|
15
|
+
const panel = document.getElementById('linkPanel');
|
|
16
|
+
panel.classList.remove('hidden');
|
|
17
|
+
|
|
18
|
+
// Populate form from existing link
|
|
19
|
+
const typeUrl = document.getElementById('linkTypeUrl');
|
|
20
|
+
const typeDiagram = document.getElementById('linkTypeDiagram');
|
|
21
|
+
const typeNew = document.getElementById('linkTypeNew');
|
|
22
|
+
const urlInput = document.getElementById('linkUrlInput');
|
|
23
|
+
const diagSelect = document.getElementById('linkDiagramSelect');
|
|
24
|
+
const newNameInput = document.getElementById('linkNewName');
|
|
25
|
+
|
|
26
|
+
// Populate diagram list
|
|
27
|
+
diagSelect.innerHTML = '';
|
|
28
|
+
st.diagrams.forEach((d) => {
|
|
29
|
+
if (d.id === st.currentDiagramId) return; // skip current
|
|
30
|
+
const opt = document.createElement('option');
|
|
31
|
+
opt.value = d.id;
|
|
32
|
+
opt.textContent = d.title;
|
|
33
|
+
diagSelect.appendChild(opt);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (link && link.type === 'url') {
|
|
37
|
+
typeUrl.checked = true;
|
|
38
|
+
urlInput.value = link.value;
|
|
39
|
+
newNameInput.value = '';
|
|
40
|
+
} else if (link && link.type === 'diagram') {
|
|
41
|
+
typeDiagram.checked = true;
|
|
42
|
+
diagSelect.value = link.value;
|
|
43
|
+
urlInput.value = '';
|
|
44
|
+
newNameInput.value = '';
|
|
45
|
+
} else {
|
|
46
|
+
typeUrl.checked = true;
|
|
47
|
+
urlInput.value = '';
|
|
48
|
+
newNameInput.value = '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_syncLinkPanelVisibility();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hideLinkPanel() {
|
|
55
|
+
document.getElementById('linkPanel').classList.add('hidden');
|
|
56
|
+
_panelNodeId = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _syncLinkPanelVisibility() {
|
|
60
|
+
const typeUrl = document.getElementById('linkTypeUrl').checked;
|
|
61
|
+
const typeDiagram = document.getElementById('linkTypeDiagram').checked;
|
|
62
|
+
const typeNew = document.getElementById('linkTypeNew').checked;
|
|
63
|
+
document.getElementById('linkUrlRow').classList.toggle('hidden', !typeUrl);
|
|
64
|
+
document.getElementById('linkDiagramRow').classList.toggle('hidden', !typeDiagram);
|
|
65
|
+
document.getElementById('linkNewRow').classList.toggle('hidden', !typeNew);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveLinkPanel() {
|
|
69
|
+
if (_panelNodeId === null) return;
|
|
70
|
+
const typeUrl = document.getElementById('linkTypeUrl').checked;
|
|
71
|
+
const typeDiagram = document.getElementById('linkTypeDiagram').checked;
|
|
72
|
+
const typeNew = document.getElementById('linkTypeNew').checked;
|
|
73
|
+
|
|
74
|
+
if (typeUrl) {
|
|
75
|
+
const value = document.getElementById('linkUrlInput').value.trim();
|
|
76
|
+
st.nodes.update({ id: _panelNodeId, nodeLink: value ? { type: 'url', value } : null });
|
|
77
|
+
markDirty();
|
|
78
|
+
hideLinkPanel();
|
|
79
|
+
} else if (typeDiagram) {
|
|
80
|
+
const value = document.getElementById('linkDiagramSelect').value;
|
|
81
|
+
if (!value) return;
|
|
82
|
+
st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value } });
|
|
83
|
+
markDirty();
|
|
84
|
+
hideLinkPanel();
|
|
85
|
+
} else if (typeNew) {
|
|
86
|
+
const name = document.getElementById('linkNewName').value.trim();
|
|
87
|
+
if (!name) return;
|
|
88
|
+
_createAndLinkDiagram(name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function _createAndLinkDiagram(title) {
|
|
93
|
+
const id = 'd' + Date.now();
|
|
94
|
+
await fetch(`/api/diagrams/${id}`, {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ title, nodes: [], edges: [] }),
|
|
98
|
+
});
|
|
99
|
+
const res = await fetch('/api/diagrams');
|
|
100
|
+
st.diagrams = await res.json();
|
|
101
|
+
st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value: id } });
|
|
102
|
+
markDirty();
|
|
103
|
+
hideLinkPanel();
|
|
104
|
+
showToast(`Diagramme "${title}" créé et lié`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function removeLinkPanel() {
|
|
108
|
+
if (_panelNodeId === null) return;
|
|
109
|
+
st.nodes.update({ id: _panelNodeId, nodeLink: null });
|
|
110
|
+
markDirty();
|
|
111
|
+
hideLinkPanel();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Navigation ────────────────────────────────────────────────────────────────
|
|
115
|
+
// Called on click (not drag). Returns true if navigation happened.
|
|
116
|
+
|
|
117
|
+
export function navigateNodeLink(nodeId) {
|
|
118
|
+
const n = st.nodes.get(nodeId);
|
|
119
|
+
const link = n && n.nodeLink;
|
|
120
|
+
if (!link) return false;
|
|
121
|
+
if (link.type === 'url') {
|
|
122
|
+
window.open(link.value, '_blank', 'noopener');
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (link.type === 'diagram') {
|
|
126
|
+
openDiagram(link.value);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Wire radio buttons ────────────────────────────────────────────────────────
|
|
133
|
+
['linkTypeUrl', 'linkTypeDiagram', 'linkTypeNew'].forEach((id) => {
|
|
134
|
+
document.getElementById(id).addEventListener('change', _syncLinkPanelVisibility);
|
|
135
|
+
});
|
|
136
|
+
document.getElementById('btnLinkSave').addEventListener('click', saveLinkPanel);
|
|
137
|
+
document.getElementById('btnLinkRemove').addEventListener('click', removeLinkPanel);
|
|
138
|
+
document.getElementById('btnLinkCancel').addEventListener('click', hideLinkPanel);
|
|
@@ -3,7 +3,9 @@
|
|
|
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
|
+
import { groupNodes, ungroupNodes } from './groups.js';
|
|
8
|
+
import { showLinkPanel, hideLinkPanel } from './link-panel.js';
|
|
7
9
|
import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
|
|
8
10
|
import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
|
|
9
11
|
import { hideSelectionOverlay } from './selection-overlay.js';
|
|
@@ -11,7 +13,10 @@ import { togglePhysics, toggleGrid } from './grid.js';
|
|
|
11
13
|
import { toggleDebug } from './debug.js';
|
|
12
14
|
import { adjustZoom, resetZoom } from './zoom.js';
|
|
13
15
|
import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
|
|
14
|
-
import { copySelected, pasteClipboard } from './clipboard.js';
|
|
16
|
+
import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
|
|
17
|
+
import { createImageNode } from './network.js';
|
|
18
|
+
import { uploadImageBlob } from './image-upload.js';
|
|
19
|
+
import { showToast } from './toast.js';
|
|
15
20
|
|
|
16
21
|
// ── Tool management ───────────────────────────────────────────────────────────
|
|
17
22
|
|
|
@@ -32,6 +37,14 @@ function setTool(tool, shape) {
|
|
|
32
37
|
else if (st.network) st.network.disableEditMode();
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
function selectAll() {
|
|
41
|
+
if (!st.network || !st.nodes) return;
|
|
42
|
+
const ids = st.nodes.getIds();
|
|
43
|
+
st.network.selectNodes(ids);
|
|
44
|
+
st.selectedNodeIds = ids;
|
|
45
|
+
showNodePanel();
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
function deleteSelected() {
|
|
36
49
|
if (!st.network) return;
|
|
37
50
|
st.network.deleteSelected();
|
|
@@ -71,6 +84,7 @@ document.getElementById('toolCircle').addEventListener('click', () => setTool(
|
|
|
71
84
|
document.getElementById('toolActor').addEventListener('click', () => setTool('addNode', 'actor'));
|
|
72
85
|
document.getElementById('toolPostIt').addEventListener('click', () => setTool('addNode', 'post-it'));
|
|
73
86
|
document.getElementById('toolTextFree').addEventListener('click', () => setTool('addNode', 'text-free'));
|
|
87
|
+
document.getElementById('toolImage').addEventListener('click', () => setTool('addNode', 'image'));
|
|
74
88
|
document.getElementById('toolArrow').addEventListener('click', () => setTool('addEdge'));
|
|
75
89
|
|
|
76
90
|
document.getElementById('btnDelete').addEventListener('click', deleteSelected);
|
|
@@ -96,6 +110,9 @@ document.getElementById('nodePanel').addEventListener('click', (e) => {
|
|
|
96
110
|
if (colorBtn) setNodeColor(colorBtn.dataset.color);
|
|
97
111
|
});
|
|
98
112
|
document.getElementById('btnNodeLabelEdit').addEventListener('click', startLabelEdit);
|
|
113
|
+
document.getElementById('btnNodeLink').addEventListener('click', () => {
|
|
114
|
+
if (st.selectedNodeIds.length === 1) showLinkPanel(st.selectedNodeIds[0]);
|
|
115
|
+
});
|
|
99
116
|
document.getElementById('btnNodeFontDecrease').addEventListener('click', () => changeNodeFontSize(-1));
|
|
100
117
|
document.getElementById('btnNodeFontIncrease').addEventListener('click', () => changeNodeFontSize(1));
|
|
101
118
|
document.getElementById('btnAlignLeft').addEventListener('click', () => setTextAlign('left'));
|
|
@@ -106,17 +123,21 @@ document.getElementById('btnValignMiddle').addEventListener('click', () => setTe
|
|
|
106
123
|
document.getElementById('btnValignBottom').addEventListener('click', () => setTextValign('bottom'));
|
|
107
124
|
document.getElementById('btnZOrderBack').addEventListener('click', () => changeZOrder(-1));
|
|
108
125
|
document.getElementById('btnZOrderFront').addEventListener('click', () => changeZOrder(1));
|
|
126
|
+
document.getElementById('btnRotateCW').addEventListener('click', () => stepRotate(10));
|
|
127
|
+
document.getElementById('btnRotateCCW').addEventListener('click', () => stepRotate(-10));
|
|
109
128
|
// Stamp buttons: capture targets on mousedown (before vis-network can fire
|
|
110
129
|
// deselectNode), then activate the stamp mode on click.
|
|
111
|
-
['btnStampColor', '
|
|
130
|
+
['btnStampColor', 'btnStampFontSize'].forEach((id) => {
|
|
112
131
|
document.getElementById(id).addEventListener('mousedown', (e) => {
|
|
113
132
|
e.preventDefault(); // prevent canvas focus loss
|
|
114
133
|
st.stampTargetIds = [...st.selectedNodeIds]; // save before any deselect fires
|
|
115
134
|
});
|
|
116
135
|
});
|
|
117
136
|
document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
|
|
118
|
-
document.getElementById('btnStampRotation').addEventListener('click', () => activateStamp('rotation'));
|
|
119
137
|
document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
|
|
138
|
+
document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
|
|
139
|
+
document.getElementById('btnGroup').addEventListener('click', () => groupNodes());
|
|
140
|
+
document.getElementById('btnUngroup').addEventListener('click', () => ungroupNodes());
|
|
120
141
|
|
|
121
142
|
// ── Edge panel wiring ─────────────────────────────────────────────────────────
|
|
122
143
|
|
|
@@ -134,10 +155,12 @@ document.getElementById('btnEdgeLabelEdit').addEventListener('click', startEdgeL
|
|
|
134
155
|
document.addEventListener('keydown', (e) => {
|
|
135
156
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
136
157
|
if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
|
|
137
|
-
if ((e.metaKey || e.ctrlKey) && e.key === '
|
|
158
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
|
|
159
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
|
|
160
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
|
|
138
161
|
if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
|
|
139
162
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
|
|
140
|
-
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
|
|
163
|
+
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
|
|
141
164
|
if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
|
|
142
165
|
if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
|
|
143
166
|
if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
|
|
@@ -149,6 +172,28 @@ document.addEventListener('keydown', (e) => {
|
|
|
149
172
|
if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
|
|
150
173
|
});
|
|
151
174
|
|
|
175
|
+
// ── Paste image from clipboard ────────────────────────────────────────────────
|
|
176
|
+
document.addEventListener('paste', async (e) => {
|
|
177
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
178
|
+
if (!st.network) return;
|
|
179
|
+
const items = Array.from(e.clipboardData.items || []);
|
|
180
|
+
const imageItem = items.find((it) => it.type.startsWith('image/'));
|
|
181
|
+
if (!imageItem) return;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
const blob = imageItem.getAsFile();
|
|
184
|
+
if (!blob) return;
|
|
185
|
+
const ext = imageItem.type.split('/')[1] || 'png';
|
|
186
|
+
try {
|
|
187
|
+
const src = await uploadImageBlob(blob, ext);
|
|
188
|
+
// Place at centre of current viewport
|
|
189
|
+
const center = st.network.getViewPosition();
|
|
190
|
+
createImageNode(src, center.x, center.y);
|
|
191
|
+
showToast('Image ajoutée');
|
|
192
|
+
} catch {
|
|
193
|
+
showToast('Impossible d\'importer l\'image', 'error');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
152
197
|
// ── Dark mode initialisation ──────────────────────────────────────────────────
|
|
153
198
|
|
|
154
199
|
document.getElementById('darkIcon').textContent =
|