living-documentation 4.1.0 → 4.3.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 +21 -10
- package/dist/src/frontend/diagram/image-name-modal.js +48 -0
- package/dist/src/frontend/diagram/image-upload.js +8 -6
- package/dist/src/frontend/diagram/main.js +31 -17
- package/dist/src/frontend/diagram/network.js +216 -27
- package/dist/src/frontend/diagram/node-rendering.js +440 -167
- package/dist/src/frontend/diagram/persistence.js +4 -2
- package/dist/src/frontend/diagram/selection-overlay.js +28 -6
- package/dist/src/frontend/diagram/state.js +1 -0
- package/dist/src/frontend/diagram.html +43 -0
- package/dist/src/frontend/index.html +261 -16
- package/dist/src/frontend/wordcloud.js +572 -209
- package/dist/src/routes/browse.d.ts.map +1 -1
- package/dist/src/routes/browse.js +2 -1
- package/dist/src/routes/browse.js.map +1 -1
- package/dist/src/routes/diagrams.d.ts.map +1 -1
- package/dist/src/routes/diagrams.js +2 -1
- package/dist/src/routes/diagrams.js.map +1 -1
- package/dist/src/routes/images.d.ts.map +1 -1
- package/dist/src/routes/images.js +14 -7
- package/dist/src/routes/images.js.map +1 -1
- package/dist/src/routes/wordcloud.d.ts.map +1 -1
- package/dist/src/routes/wordcloud.js +42 -20
- package/dist/src/routes/wordcloud.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A CLI tool that serves a local Markdown documentation viewer in your browser.
|
|
4
4
|
|
|
5
|
-
No cloud, no database, no build step — just point it at a folder of `.md` files.
|
|
5
|
+
No cloud, no database, no build step — just point it at a folder where you add your project's folder documentation composed of `.md` files (ADR : Architecture Decision Records, generally).
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|

|
|
@@ -11,20 +11,31 @@ No cloud, no database, no build step — just point it at a folder of `.md` file
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Sidebar** grouped by category, sorted alphabetically by full filename
|
|
14
|
+
[](/diagram?id=d1775399110713)
|
|
15
|
+
|
|
16
|
+
- **Categories Sections and General section** — Categories are extracted from the fileName pattern of your Markdown documents (that may be Architecture Decision Records ADRs).
|
|
17
|
+
ExtraFiles (added in the admin section) are always first, always expanded in a `GENERAL Section` that holds uncategorized docs and extra files
|
|
18
|
+
[](/diagram?id=d1775399110713)
|
|
19
|
+
|
|
14
20
|
- **Recursive folder scanning** — subdirectories are scanned automatically; subdirectory name becomes the category
|
|
15
|
-
|
|
16
|
-
- **Extra files** —
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
- **Extra files** — You can add custom ExtraFiles to your documentation that are outside the docs folder (e.g. `README.md`, `CLAUDE.md`) in the `Admin` Page
|
|
23
|
+
[](/diagram?id=d1775399110713)
|
|
24
|
+
|
|
18
25
|
- **Dark mode** — follows system preference, manually toggleable
|
|
19
26
|
- **Syntax highlighting** — always dark, high-contrast code blocks
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **
|
|
27
|
+
[](/diagram?id=d1775399110713)
|
|
28
|
+
|
|
29
|
+
- **Full-text search** — instant filter + server-side content search. Returns all the files containing searched occurences, and for each file lists all the occurences, highlight them, and visit them.
|
|
30
|
+
[](/diagram?id=d1775399110713)
|
|
31
|
+
|
|
23
32
|
- **Inline editing** — edit any document directly in the browser, saves to disk instantly
|
|
24
33
|
- **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
|
|
34
|
+
- **Export to PDF** — Export the markdown as a PDF document
|
|
35
|
+
- **Diagram editor** — built-in canvas diagram editor; deep-link to any diagram in the C4 Model Style; Paste images into diagrams; Export PNG From Images; And Many more features ...
|
|
36
|
+
|
|
37
|
+
- **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
|
|
25
38
|
- **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=`
|
|
27
|
-
- **Zero frontend build** — Tailwind and highlight.js loaded from CDN
|
|
28
39
|
|
|
29
40
|
---
|
|
30
41
|
|
|
@@ -222,4 +233,4 @@ The compiled package is self-contained inside `dist/`. Only `dist/` is included
|
|
|
222
233
|
|
|
223
234
|
## License
|
|
224
235
|
|
|
225
|
-
MIT
|
|
236
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ── Image filename modal ───────────────────────────────────────────────────────
|
|
2
|
+
// Returns a Promise<string|null>:
|
|
3
|
+
// string (possibly empty) → user confirmed (empty = use auto name)
|
|
4
|
+
// null → user cancelled
|
|
5
|
+
|
|
6
|
+
const IMAGE_NAME_RE = /^[a-z0-9_-]*$/i;
|
|
7
|
+
|
|
8
|
+
export function promptImageName() {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const modal = document.getElementById('imageNameModal');
|
|
11
|
+
const input = document.getElementById('imageNameInput');
|
|
12
|
+
const error = document.getElementById('imageNameError');
|
|
13
|
+
const confirm = document.getElementById('imageNameConfirm');
|
|
14
|
+
const cancel = document.getElementById('imageNameCancel');
|
|
15
|
+
|
|
16
|
+
input.value = '';
|
|
17
|
+
error.classList.add('hidden');
|
|
18
|
+
modal.style.display = 'flex';
|
|
19
|
+
setTimeout(() => input.focus(), 50);
|
|
20
|
+
|
|
21
|
+
function validate() {
|
|
22
|
+
const val = input.value.trim();
|
|
23
|
+
const ok = val === '' || IMAGE_NAME_RE.test(val);
|
|
24
|
+
error.classList.toggle('hidden', ok);
|
|
25
|
+
return ok;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function close(name) {
|
|
29
|
+
modal.style.display = 'none';
|
|
30
|
+
confirm.removeEventListener('click', onConfirm);
|
|
31
|
+
cancel.removeEventListener('click', onCancel);
|
|
32
|
+
input.removeEventListener('input', validate);
|
|
33
|
+
input.removeEventListener('keydown', onKey);
|
|
34
|
+
resolve(name);
|
|
35
|
+
}
|
|
36
|
+
function onConfirm() { if (validate()) close(input.value.trim()); }
|
|
37
|
+
function onCancel() { close(null); }
|
|
38
|
+
function onKey(e) {
|
|
39
|
+
if (e.key === 'Enter') { e.preventDefault(); if (validate()) close(input.value.trim()); }
|
|
40
|
+
if (e.key === 'Escape') { e.preventDefault(); close(null); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
confirm.addEventListener('click', onConfirm);
|
|
44
|
+
cancel.addEventListener('click', onCancel);
|
|
45
|
+
input.addEventListener('input', validate);
|
|
46
|
+
input.addEventListener('keydown', onKey);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -11,22 +11,24 @@ async function toBase64(blob) {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export async function uploadImageFile(file) {
|
|
14
|
+
export async function uploadImageFile(file, name = '') {
|
|
15
15
|
const ext = (file.name.split('.').pop() || 'png').toLowerCase();
|
|
16
16
|
const base64 = await toBase64(file);
|
|
17
|
-
return _upload(base64, ext);
|
|
17
|
+
return _upload(base64, ext, name);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function uploadImageBlob(blob, ext = 'png') {
|
|
20
|
+
export async function uploadImageBlob(blob, ext = 'png', name = '') {
|
|
21
21
|
const base64 = await toBase64(blob);
|
|
22
|
-
return _upload(base64, ext);
|
|
22
|
+
return _upload(base64, ext, name);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
async function _upload(base64, ext) {
|
|
25
|
+
async function _upload(base64, ext, name = '') {
|
|
26
|
+
const body = { data: base64, ext };
|
|
27
|
+
if (name) body.name = name;
|
|
26
28
|
const res = await fetch('/api/images/upload', {
|
|
27
29
|
method: 'POST',
|
|
28
30
|
headers: { 'Content-Type': 'application/json' },
|
|
29
|
-
body: JSON.stringify(
|
|
31
|
+
body: JSON.stringify(body),
|
|
30
32
|
});
|
|
31
33
|
if (!res.ok) throw new Error('Upload failed');
|
|
32
34
|
const { filename } = await res.json();
|
|
@@ -14,8 +14,9 @@ import { toggleDebug } from './debug.js';
|
|
|
14
14
|
import { adjustZoom, resetZoom } from './zoom.js';
|
|
15
15
|
import { loadDiagramList, newDiagram, saveDiagram } from './persistence.js';
|
|
16
16
|
import { copySelected, pasteClipboard, copySelectionAsPng } from './clipboard.js';
|
|
17
|
-
import { createImageNode } from './network.js';
|
|
17
|
+
import { createImageNode, toggleEdgeStraight } from './network.js';
|
|
18
18
|
import { uploadImageBlob } from './image-upload.js';
|
|
19
|
+
import { promptImageName } from './image-name-modal.js';
|
|
19
20
|
import { showToast } from './toast.js';
|
|
20
21
|
|
|
21
22
|
// ── Tool management ───────────────────────────────────────────────────────────
|
|
@@ -90,6 +91,7 @@ document.getElementById('toolArrow').addEventListener('click', () => setTool(
|
|
|
90
91
|
document.getElementById('btnDelete').addEventListener('click', deleteSelected);
|
|
91
92
|
document.getElementById('btnPhysics').addEventListener('click', togglePhysics);
|
|
92
93
|
document.getElementById('btnGrid').addEventListener('click', toggleGrid);
|
|
94
|
+
document.getElementById('btnEdgeStraight').addEventListener('click', toggleEdgeStraight);
|
|
93
95
|
|
|
94
96
|
document.getElementById('btnZoomOut').addEventListener('click', () => adjustZoom(-0.2));
|
|
95
97
|
document.getElementById('btnZoomIn').addEventListener('click', () => adjustZoom(0.2));
|
|
@@ -158,7 +160,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
158
160
|
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); selectAll(); return; }
|
|
159
161
|
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && e.shiftKey) { e.preventDefault(); copySelectionAsPng(); return; }
|
|
160
162
|
if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
|
|
161
|
-
|
|
163
|
+
// Cmd+V is handled in the 'paste' event below (needs clipboardData access)
|
|
162
164
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
|
|
163
165
|
if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
|
|
164
166
|
if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
|
|
@@ -172,25 +174,37 @@ document.addEventListener('keydown', (e) => {
|
|
|
172
174
|
if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
|
|
173
175
|
});
|
|
174
176
|
|
|
175
|
-
// ── Paste image
|
|
177
|
+
// ── Paste (image or shapes) ───────────────────────────────────────────────────
|
|
176
178
|
document.addEventListener('paste', async (e) => {
|
|
177
179
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
178
180
|
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
181
|
e.preventDefault();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
182
|
+
|
|
183
|
+
const items = Array.from(e.clipboardData?.items || []);
|
|
184
|
+
const imageItem = items.find((it) => it.type.startsWith('image/'));
|
|
185
|
+
|
|
186
|
+
if (imageItem) {
|
|
187
|
+
const blob = imageItem.getAsFile();
|
|
188
|
+
if (!blob) return;
|
|
189
|
+
const ext = imageItem.type.split('/')[1] || 'png';
|
|
190
|
+
|
|
191
|
+
const name = await promptImageName();
|
|
192
|
+
if (name === null) return; // user cancelled
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const src = await uploadImageBlob(blob, ext, name);
|
|
196
|
+
const center = st.network.getViewPosition();
|
|
197
|
+
createImageNode(src, center.x, center.y);
|
|
198
|
+
showToast('Image ajoutée');
|
|
199
|
+
// Clear system clipboard so the next Cmd+V pastes shapes, not the image again
|
|
200
|
+
// (our Cmd+C only writes to st.clipboard, not the system clipboard)
|
|
201
|
+
navigator.clipboard.writeText('').catch(() => {});
|
|
202
|
+
} catch {
|
|
203
|
+
showToast('Impossible d\'importer l\'image', 'error');
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
// No image — paste shapes from internal clipboard
|
|
207
|
+
pasteClipboard();
|
|
194
208
|
}
|
|
195
209
|
});
|
|
196
210
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { st, markDirty } from './state.js';
|
|
6
6
|
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
7
7
|
import { uploadImageFile } from './image-upload.js';
|
|
8
|
+
import { promptImageName } from './image-name-modal.js';
|
|
8
9
|
import { showToast } from './toast.js';
|
|
9
10
|
import { visEdgeProps } from './edge-rendering.js';
|
|
10
11
|
import { showNodePanel, hideNodePanel } from './node-panel.js';
|
|
@@ -17,9 +18,11 @@ import { updateZoomDisplay } from './zoom.js';
|
|
|
17
18
|
import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
|
|
18
19
|
import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
|
|
19
20
|
|
|
20
|
-
export function initNetwork(savedNodes, savedEdges) {
|
|
21
|
+
export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
21
22
|
const container = document.getElementById('vis-canvas');
|
|
22
23
|
|
|
24
|
+
const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
|
|
25
|
+
|
|
23
26
|
st.nodes = new vis.DataSet(
|
|
24
27
|
savedNodes.map((n) => ({
|
|
25
28
|
...n,
|
|
@@ -27,11 +30,16 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
27
30
|
}))
|
|
28
31
|
);
|
|
29
32
|
st.edges = new vis.DataSet(
|
|
30
|
-
savedEdges.map((e) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
savedEdges.map((e) => {
|
|
34
|
+
const toNode = savedNodes.find((n) => n.id === e.to);
|
|
35
|
+
const isAnchor = toNode && toNode.shapeType === 'anchor';
|
|
36
|
+
return {
|
|
37
|
+
...e,
|
|
38
|
+
...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
|
|
39
|
+
smooth: isAnchor ? { enabled: false } : edgeSmooth,
|
|
40
|
+
...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
|
|
41
|
+
};
|
|
42
|
+
})
|
|
35
43
|
);
|
|
36
44
|
|
|
37
45
|
const options = {
|
|
@@ -42,7 +50,7 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
42
50
|
},
|
|
43
51
|
interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
|
|
44
52
|
nodes: { font: { size: 13, face: 'system-ui,-apple-system,sans-serif' }, borderWidth: 1.5, borderWidthSelected: 2.5, shadow: false, widthConstraint: { minimum: 60 }, heightConstraint: { minimum: 28 } },
|
|
45
|
-
edges: { smooth:
|
|
53
|
+
edges: { smooth: edgeSmooth, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
|
|
46
54
|
manipulation: {
|
|
47
55
|
enabled: false,
|
|
48
56
|
addEdge(data, callback) {
|
|
@@ -81,16 +89,70 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
81
89
|
for (const edgeId of Object.keys(bodyEdges)) {
|
|
82
90
|
const edge = bodyEdges[edgeId];
|
|
83
91
|
if (!edge.connected) continue;
|
|
84
|
-
|
|
92
|
+
// Edges are drawn just before the node at their assigned level, so they
|
|
93
|
+
// appear on top of all nodes below that level.
|
|
94
|
+
// Use Math.max so the edge follows the higher-z endpoint — it stays visible
|
|
95
|
+
// above any intermediate nodes between the two endpoints.
|
|
96
|
+
// Anchor nodes are floating endpoints that must not raise the edge above the
|
|
97
|
+
// real source: for anchor edges, use the non-anchor endpoint's level.
|
|
98
|
+
const fromData = st.nodes.get(edge.fromId);
|
|
99
|
+
const toData = st.nodes.get(edge.toId);
|
|
100
|
+
const fromIsAnchor = fromData && fromData.shapeType === 'anchor';
|
|
101
|
+
const toIsAnchor = toData && toData.shapeType === 'anchor';
|
|
102
|
+
let level;
|
|
103
|
+
if (toIsAnchor && !fromIsAnchor) {
|
|
104
|
+
level = orderMap.get(edge.fromId) ?? 0;
|
|
105
|
+
} else if (fromIsAnchor && !toIsAnchor) {
|
|
106
|
+
level = orderMap.get(edge.toId) ?? 0;
|
|
107
|
+
} else {
|
|
108
|
+
level = Math.min(orderMap.get(edge.fromId) ?? 0, orderMap.get(edge.toId) ?? 0);
|
|
109
|
+
}
|
|
85
110
|
if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
|
|
86
111
|
edgesByLevel.get(level).push(edge);
|
|
87
112
|
}
|
|
88
113
|
|
|
114
|
+
// Build a map: anchorId → the edge(s) that connect to it, so we can draw
|
|
115
|
+
// each anchor dot AFTER its edge (giving the "planted arrowhead" effect).
|
|
116
|
+
const anchorEdgeLevel = new Map(); // anchorId → level at which its edge is drawn
|
|
117
|
+
for (const [level, edges] of edgesByLevel) {
|
|
118
|
+
for (const edge of edges) {
|
|
119
|
+
const fromData = st.nodes.get(edge.fromId);
|
|
120
|
+
const toData = st.nodes.get(edge.toId);
|
|
121
|
+
if (toData && toData.shapeType === 'anchor') anchorEdgeLevel.set(edge.toId, level);
|
|
122
|
+
if (fromData && fromData.shapeType === 'anchor') anchorEdgeLevel.set(edge.fromId, level);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Anchors with no connected edge are drawn at level 0 (bottom).
|
|
127
|
+
for (const id of st.canonicalOrder) {
|
|
128
|
+
const n = st.nodes.get(id);
|
|
129
|
+
if (!n || n.shapeType !== 'anchor') continue;
|
|
130
|
+
if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
for (let i = 0; i < st.canonicalOrder.length; i++) {
|
|
90
|
-
const id
|
|
91
|
-
|
|
134
|
+
const id = st.canonicalOrder[i];
|
|
135
|
+
const n = st.nodes.get(id);
|
|
136
|
+
|
|
137
|
+
// Draw edges whose level is i, before drawing the node at level i.
|
|
92
138
|
const edges = edgesByLevel.get(i);
|
|
93
|
-
if (edges)
|
|
139
|
+
if (edges) {
|
|
140
|
+
edges.forEach((e) => e.draw(ctx));
|
|
141
|
+
// Draw any anchor whose edge was just drawn, so the dot appears on top.
|
|
142
|
+
for (const [anchorId, level] of anchorEdgeLevel) {
|
|
143
|
+
if (level !== i) continue;
|
|
144
|
+
const anchorNode = bodyNodes[anchorId];
|
|
145
|
+
if (!anchorNode) continue;
|
|
146
|
+
if (alwaysShow === true || anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true) {
|
|
147
|
+
anchorNode.draw(ctx);
|
|
148
|
+
} else {
|
|
149
|
+
anchorNode.updateBoundingBox(ctx, anchorNode.selected);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Skip anchor nodes here — they were drawn right after their edge above.
|
|
155
|
+
if (n && n.shapeType === 'anchor') continue;
|
|
94
156
|
|
|
95
157
|
const node = bodyNodes[id];
|
|
96
158
|
if (!node) continue;
|
|
@@ -115,9 +177,30 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
115
177
|
const existing = new Set(st.canonicalOrder);
|
|
116
178
|
items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
|
|
117
179
|
});
|
|
118
|
-
st.nodes.on('remove', (_, { items }) => {
|
|
180
|
+
st.nodes.on('remove', (_, { items, oldData }) => {
|
|
119
181
|
const removed = new Set(items);
|
|
120
182
|
st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
|
|
183
|
+
// If an anchor was deleted directly, also remove its connected edges.
|
|
184
|
+
(oldData || []).forEach((n) => {
|
|
185
|
+
if (n.shapeType !== 'anchor') return;
|
|
186
|
+
const connected = st.edges.get({ filter: (e) => e.from === n.id || e.to === n.id });
|
|
187
|
+
if (connected.length) st.edges.remove(connected.map((e) => e.id));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// When an edge is removed, delete any anchor node that has no remaining edges.
|
|
192
|
+
st.edges.on('remove', (_, { oldData }) => {
|
|
193
|
+
const anchorsToCheck = new Set();
|
|
194
|
+
(oldData || []).forEach((edge) => {
|
|
195
|
+
const toData = st.nodes.get(edge.to);
|
|
196
|
+
const fromData = st.nodes.get(edge.from);
|
|
197
|
+
if (toData && toData.shapeType === 'anchor') anchorsToCheck.add(edge.to);
|
|
198
|
+
if (fromData && fromData.shapeType === 'anchor') anchorsToCheck.add(edge.from);
|
|
199
|
+
});
|
|
200
|
+
anchorsToCheck.forEach((anchorId) => {
|
|
201
|
+
const remaining = st.edges.get({ filter: (e) => e.from === anchorId || e.to === anchorId });
|
|
202
|
+
if (remaining.length === 0) st.nodes.remove(anchorId);
|
|
203
|
+
});
|
|
121
204
|
});
|
|
122
205
|
|
|
123
206
|
st.network.on('click', onClickNode);
|
|
@@ -134,6 +217,41 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
134
217
|
st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
|
|
135
218
|
st.network.on('afterDrawing', () => drawDebugOverlay());
|
|
136
219
|
|
|
220
|
+
// ── Free-floating edge: drop on empty canvas creates an anchor node ──────────
|
|
221
|
+
// vis-network only fires addEdge callback when dropping on an existing node.
|
|
222
|
+
// We intercept mousedown (capture source) + mouseup (detect empty-canvas drop).
|
|
223
|
+
let _addEdgeFromId = null;
|
|
224
|
+
const visCanvas = document.getElementById('vis-canvas');
|
|
225
|
+
|
|
226
|
+
visCanvas.addEventListener('mousedown', (e) => {
|
|
227
|
+
if (st.currentTool !== 'addEdge') return;
|
|
228
|
+
_addEdgeFromId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
visCanvas.addEventListener('mouseup', (e) => {
|
|
232
|
+
if (st.currentTool !== 'addEdge' || !_addEdgeFromId) { _addEdgeFromId = null; return; }
|
|
233
|
+
const pos = { x: e.offsetX, y: e.offsetY };
|
|
234
|
+
if (!st.network.getNodeAt(pos)) {
|
|
235
|
+
const cp = st.network.DOMtoCanvas(pos);
|
|
236
|
+
const anchorId = 'a' + Date.now();
|
|
237
|
+
st.nodes.add({
|
|
238
|
+
id: anchorId, label: '', shapeType: 'anchor', colorKey: 'c-gray',
|
|
239
|
+
nodeWidth: 8, nodeHeight: 8, fontSize: null, rotation: 0, labelRotation: 0,
|
|
240
|
+
x: cp.x, y: cp.y,
|
|
241
|
+
...visNodeProps('anchor', 'c-gray', 8, 8, null, null, null),
|
|
242
|
+
});
|
|
243
|
+
st.edges.add({
|
|
244
|
+
id: 'e' + Date.now(), from: _addEdgeFromId, to: anchorId,
|
|
245
|
+
arrowDir: 'to', dashes: false,
|
|
246
|
+
smooth: { enabled: false },
|
|
247
|
+
...visEdgeProps('to', false),
|
|
248
|
+
});
|
|
249
|
+
markDirty();
|
|
250
|
+
setTimeout(() => st.network.addEdgeMode(), 0);
|
|
251
|
+
}
|
|
252
|
+
_addEdgeFromId = null;
|
|
253
|
+
});
|
|
254
|
+
|
|
137
255
|
document.getElementById('emptyState').classList.add('hidden');
|
|
138
256
|
updateZoomDisplay();
|
|
139
257
|
}
|
|
@@ -184,8 +302,10 @@ function pickAndCreateImageNode(canvasX, canvasY) {
|
|
|
184
302
|
input.onchange = async () => {
|
|
185
303
|
const file = input.files && input.files[0];
|
|
186
304
|
if (!file) return;
|
|
305
|
+
const name = await promptImageName();
|
|
306
|
+
if (name === null) return; // user cancelled
|
|
187
307
|
try {
|
|
188
|
-
const src = await uploadImageFile(file);
|
|
308
|
+
const src = await uploadImageFile(file, name);
|
|
189
309
|
createImageNode(src, canvasX, canvasY);
|
|
190
310
|
} catch {
|
|
191
311
|
showToast('Impossible d\'importer l\'image', 'error');
|
|
@@ -196,27 +316,96 @@ function pickAndCreateImageNode(canvasX, canvasY) {
|
|
|
196
316
|
|
|
197
317
|
export function createImageNode(imageSrc, canvasX, canvasY) {
|
|
198
318
|
if (!st.network) return;
|
|
199
|
-
const id
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
319
|
+
const id = 'n' + Date.now();
|
|
320
|
+
const captionId = id + 'c';
|
|
321
|
+
|
|
322
|
+
const addNode = (nW, nH) => {
|
|
323
|
+
const filename = imageSrc.split('/').pop() || '';
|
|
324
|
+
const textDefs = SHAPE_DEFAULTS['text-free'];
|
|
325
|
+
const captionH = textDefs[1];
|
|
326
|
+
const GAP = 8;
|
|
327
|
+
|
|
328
|
+
const groupId = 'g' + Date.now();
|
|
329
|
+
st.nodes.add({
|
|
330
|
+
id, label: '', imageSrc, groupId,
|
|
331
|
+
shapeType: 'image', colorKey: 'c-gray',
|
|
332
|
+
nodeWidth: nW, nodeHeight: nH,
|
|
333
|
+
fontSize: null, rotation: 0, labelRotation: 0,
|
|
334
|
+
x: canvasX, y: canvasY,
|
|
335
|
+
...visNodeProps('image', 'c-gray', nW, nH, null, null, null),
|
|
336
|
+
});
|
|
337
|
+
st.nodes.add({
|
|
338
|
+
id: captionId, label: filename, groupId,
|
|
339
|
+
shapeType: 'text-free', colorKey: 'c-gray',
|
|
340
|
+
nodeWidth: nW, nodeHeight: captionH,
|
|
341
|
+
fontSize: null, rotation: 0, labelRotation: 0,
|
|
342
|
+
x: canvasX, y: canvasY + nH / 2 + GAP + captionH / 2,
|
|
343
|
+
...visNodeProps('text-free', 'c-gray', nW, captionH, null, null, null),
|
|
344
|
+
});
|
|
345
|
+
markDirty();
|
|
346
|
+
setTimeout(() => {
|
|
347
|
+
st.network.selectNodes([id]);
|
|
348
|
+
st.selectedNodeIds = [id];
|
|
349
|
+
showNodePanel();
|
|
350
|
+
}, 50);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const img = new Image();
|
|
354
|
+
img.onload = () => {
|
|
355
|
+
const MAX = 300;
|
|
356
|
+
const ratio = img.naturalWidth / img.naturalHeight;
|
|
357
|
+
let nW = img.naturalWidth, nH = img.naturalHeight;
|
|
358
|
+
if (nW > MAX) { nW = MAX; nH = Math.round(MAX / ratio); }
|
|
359
|
+
addNode(nW, nH);
|
|
360
|
+
};
|
|
361
|
+
img.onerror = () => {
|
|
362
|
+
const d = SHAPE_DEFAULTS['image'];
|
|
363
|
+
addNode(d[0], d[1]);
|
|
364
|
+
};
|
|
365
|
+
img.src = imageSrc;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Edge straight / curved toggle ────────────────────────────────────────────
|
|
369
|
+
export function toggleEdgeStraight() {
|
|
370
|
+
if (!st.network) return;
|
|
371
|
+
st.edgesStraight = !st.edgesStraight;
|
|
372
|
+
const smooth = st.edgesStraight ? { enabled: false } : { type: 'continuous' };
|
|
373
|
+
// Update global network option first (overrides per-edge inherited defaults).
|
|
374
|
+
st.network.setOptions({ edges: { smooth } });
|
|
375
|
+
// Then update each edge individually, keeping anchor edges always straight.
|
|
376
|
+
const updates = st.edges.get().map((e) => {
|
|
377
|
+
const toData = st.nodes.get(e.to);
|
|
378
|
+
const s = (toData && toData.shapeType === 'anchor') ? { enabled: false } : smooth;
|
|
379
|
+
return { id: e.id, smooth: s };
|
|
208
380
|
});
|
|
381
|
+
if (updates.length) st.edges.update(updates);
|
|
382
|
+
document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
|
|
209
383
|
markDirty();
|
|
210
|
-
setTimeout(() => {
|
|
211
|
-
st.network.selectNodes([id]);
|
|
212
|
-
st.selectedNodeIds = [id];
|
|
213
|
-
showNodePanel();
|
|
214
|
-
}, 50);
|
|
215
384
|
}
|
|
216
385
|
|
|
217
386
|
function onClickNode(params) {
|
|
218
387
|
if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
|
|
219
388
|
navigateNodeLink(params.nodes[0]);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Anchor nodes have nodeDimensions=0 so vis-network cannot detect clicks on
|
|
392
|
+
// them. Manually check proximity (8px threshold in canvas space).
|
|
393
|
+
if (params.nodes.length === 0 && params.edges.length === 0) {
|
|
394
|
+
const cp = params.pointer.canvas;
|
|
395
|
+
const THRESHOLD = 8;
|
|
396
|
+
const anchors = st.nodes.get({ filter: (n) => n.shapeType === 'anchor' });
|
|
397
|
+
const positions = st.network.getPositions(anchors.map((a) => a.id));
|
|
398
|
+
for (const anchor of anchors) {
|
|
399
|
+
const pos = positions[anchor.id];
|
|
400
|
+
if (!pos) continue;
|
|
401
|
+
const dx = cp.x - pos.x, dy = cp.y - pos.y;
|
|
402
|
+
if (Math.sqrt(dx * dx + dy * dy) <= THRESHOLD) {
|
|
403
|
+
st.network.selectNodes([anchor.id]);
|
|
404
|
+
st.selectedNodeIds = [anchor.id];
|
|
405
|
+
showNodePanel();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
220
409
|
}
|
|
221
410
|
}
|
|
222
411
|
|