living-documentation 4.2.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 CHANGED
@@ -26,15 +26,16 @@ ExtraFiles (added in the admin section) are always first, always expanded in a `
26
26
  - **Syntax highlighting** — always dark, high-contrast code blocks
27
27
  [![README Diagrams](./images/readme-code-blocks.png)](/diagram?id=d1775399110713)
28
28
 
29
- - **Full-text search** — instant filter + server-side content search
30
- - **Export to PDF** — print-friendly layout via `window.print()`
31
- - **Deep links** — share a direct URL to any document (`?doc=…`)
32
- - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
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
+ [![README Diagrams](./images/readme-intelligent-search-demo.png)](/diagram?id=d1775399110713)
31
+
33
32
  - **Inline editing** — edit any document directly in the browser, saves to disk instantly
34
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
35
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
36
- - **Diagram editor** — built-in canvas diagram editor (vis-network); deep-link to any diagram with `?id=`
37
- - **Zero frontend build** — Tailwind and highlight.js loaded from CDN
38
39
 
39
40
  ---
40
41
 
@@ -14,7 +14,7 @@ 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
19
  import { promptImageName } from './image-name-modal.js';
20
20
  import { showToast } from './toast.js';
@@ -91,6 +91,7 @@ document.getElementById('toolArrow').addEventListener('click', () => setTool(
91
91
  document.getElementById('btnDelete').addEventListener('click', deleteSelected);
92
92
  document.getElementById('btnPhysics').addEventListener('click', togglePhysics);
93
93
  document.getElementById('btnGrid').addEventListener('click', toggleGrid);
94
+ document.getElementById('btnEdgeStraight').addEventListener('click', toggleEdgeStraight);
94
95
 
95
96
  document.getElementById('btnZoomOut').addEventListener('click', () => adjustZoom(-0.2));
96
97
  document.getElementById('btnZoomIn').addEventListener('click', () => adjustZoom(0.2));
@@ -18,9 +18,11 @@ import { updateZoomDisplay } from './zoom.js';
18
18
  import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
19
19
  import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
20
20
 
21
- export function initNetwork(savedNodes, savedEdges) {
21
+ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
22
22
  const container = document.getElementById('vis-canvas');
23
23
 
24
+ const edgeSmooth = edgesStraight ? { enabled: false } : { type: 'continuous' };
25
+
24
26
  st.nodes = new vis.DataSet(
25
27
  savedNodes.map((n) => ({
26
28
  ...n,
@@ -28,11 +30,16 @@ export function initNetwork(savedNodes, savedEdges) {
28
30
  }))
29
31
  );
30
32
  st.edges = new vis.DataSet(
31
- savedEdges.map((e) => ({
32
- ...e,
33
- ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
34
- ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
35
- }))
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
+ })
36
43
  );
37
44
 
38
45
  const options = {
@@ -43,7 +50,7 @@ export function initNetwork(savedNodes, savedEdges) {
43
50
  },
44
51
  interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
45
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 } },
46
- edges: { smooth: { type: 'continuous' }, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
53
+ edges: { smooth: edgeSmooth, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
47
54
  manipulation: {
48
55
  enabled: false,
49
56
  addEdge(data, callback) {
@@ -82,16 +89,70 @@ export function initNetwork(savedNodes, savedEdges) {
82
89
  for (const edgeId of Object.keys(bodyEdges)) {
83
90
  const edge = bodyEdges[edgeId];
84
91
  if (!edge.connected) continue;
85
- const level = Math.min(orderMap.get(edge.fromId) ?? 0, orderMap.get(edge.toId) ?? 0);
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
+ }
86
110
  if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
87
111
  edgesByLevel.get(level).push(edge);
88
112
  }
89
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
+
90
133
  for (let i = 0; i < st.canonicalOrder.length; i++) {
91
- const id = st.canonicalOrder[i];
92
- // Draw edges whose topmost node is at this level, before drawing the node.
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.
93
138
  const edges = edgesByLevel.get(i);
94
- if (edges) edges.forEach((e) => e.draw(ctx));
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;
95
156
 
96
157
  const node = bodyNodes[id];
97
158
  if (!node) continue;
@@ -116,9 +177,30 @@ export function initNetwork(savedNodes, savedEdges) {
116
177
  const existing = new Set(st.canonicalOrder);
117
178
  items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
118
179
  });
119
- st.nodes.on('remove', (_, { items }) => {
180
+ st.nodes.on('remove', (_, { items, oldData }) => {
120
181
  const removed = new Set(items);
121
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
+ });
122
204
  });
123
205
 
124
206
  st.network.on('click', onClickNode);
@@ -135,6 +217,41 @@ export function initNetwork(savedNodes, savedEdges) {
135
217
  st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
136
218
  st.network.on('afterDrawing', () => drawDebugOverlay());
137
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
+
138
255
  document.getElementById('emptyState').classList.add('hidden');
139
256
  updateZoomDisplay();
140
257
  }
@@ -248,9 +365,47 @@ export function createImageNode(imageSrc, canvasX, canvasY) {
248
365
  img.src = imageSrc;
249
366
  }
250
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 };
380
+ });
381
+ if (updates.length) st.edges.update(updates);
382
+ document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
383
+ markDirty();
384
+ }
385
+
251
386
  function onClickNode(params) {
252
387
  if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
253
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
+ }
254
409
  }
255
410
  }
256
411