living-documentation 7.41.0 → 7.43.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.
@@ -0,0 +1,96 @@
1
+ import { st } from './state.js';
2
+
3
+ export const CUSTOM_SHAPE_TYPE = 'custom-shape';
4
+ export const CUSTOM_SHAPE_TOOL_PREFIX = 'custom-shape:';
5
+
6
+ export const DEFAULT_CUSTOM_ANCHORS = [
7
+ { id: 'N', x: 0.5, y: 0 },
8
+ { id: 'NE', x: 1, y: 0 },
9
+ { id: 'E', x: 1, y: 0.5 },
10
+ { id: 'SE', x: 1, y: 1 },
11
+ { id: 'S', x: 0.5, y: 1 },
12
+ { id: 'SW', x: 0, y: 1 },
13
+ { id: 'W', x: 0, y: 0.5 },
14
+ { id: 'NW', x: 0, y: 0 },
15
+ ];
16
+
17
+ export function isCustomShapeTool(shape) {
18
+ return typeof shape === 'string' && shape.startsWith(CUSTOM_SHAPE_TOOL_PREFIX);
19
+ }
20
+
21
+ export function customShapeIdFromTool(shape) {
22
+ return isCustomShapeTool(shape) ? shape.slice(CUSTOM_SHAPE_TOOL_PREFIX.length) : null;
23
+ }
24
+
25
+ export function getCustomShapeDefinition(id) {
26
+ if (!id) return null;
27
+ return st.customShapeDefs && st.customShapeDefs.get(id) || null;
28
+ }
29
+
30
+ export function getCustomShapeDefaultSize(id) {
31
+ const def = getCustomShapeDefinition(id);
32
+ return [def && def.width || 96, def && def.height || 96];
33
+ }
34
+
35
+ export function getCustomShapeAnchors(id) {
36
+ const def = getCustomShapeDefinition(id);
37
+ return def && Array.isArray(def.anchors) && def.anchors.length
38
+ ? def.anchors
39
+ : DEFAULT_CUSTOM_ANCHORS;
40
+ }
41
+
42
+ export function getCustomShapeLabelPlacement(id) {
43
+ const def = getCustomShapeDefinition(id);
44
+ return def && def.labelPlacement === 'center' ? 'center' : 'below';
45
+ }
46
+
47
+ export async function loadCustomShapeLibraries() {
48
+ try {
49
+ const res = await fetch('/api/shape-libraries');
50
+ if (!res.ok) throw new Error('shape libraries unavailable');
51
+ const store = await res.json();
52
+ const libraries = Array.isArray(store.libraries) ? store.libraries : [];
53
+ st.customShapeLibraries = libraries;
54
+ st.customShapeDefs = new Map();
55
+ libraries.forEach((library) => {
56
+ (library.shapes || []).forEach((shape) => st.customShapeDefs.set(shape.id, shape));
57
+ });
58
+ } catch {
59
+ st.customShapeLibraries = [];
60
+ st.customShapeDefs = new Map();
61
+ }
62
+ }
63
+
64
+ export function renderCustomShapeBar() {
65
+ const bar = document.getElementById('customShapeBar');
66
+ const body = document.getElementById('customShapeBarBody');
67
+ if (!bar || !body) return;
68
+
69
+ body.innerHTML = '';
70
+ const libraries = st.customShapeLibraries || [];
71
+ const shapes = libraries.flatMap((library) =>
72
+ (library.shapes || []).map((shape) => ({ ...shape, libraryName: library.name })),
73
+ );
74
+ bar.classList.toggle('hidden', shapes.length === 0);
75
+
76
+ shapes.forEach((shape) => {
77
+ const btn = document.createElement('button');
78
+ btn.type = 'button';
79
+ btn.className = 'custom-shape-btn';
80
+ btn.title = `${shape.libraryName || ''}${shape.libraryName ? ' · ' : ''}${shape.name}`;
81
+ btn.dataset.customShapeId = shape.id;
82
+
83
+ const img = document.createElement('img');
84
+ img.src = shape.imageSrc;
85
+ img.alt = '';
86
+ img.draggable = false;
87
+ btn.appendChild(img);
88
+
89
+ btn.addEventListener('click', () => {
90
+ window.dispatchEvent(new CustomEvent('diagram:setTool', {
91
+ detail: { tool: 'addNode', shape: `${CUSTOM_SHAPE_TOOL_PREFIX}${shape.id}` },
92
+ }));
93
+ });
94
+ body.appendChild(btn);
95
+ });
96
+ }
@@ -88,9 +88,9 @@ export function showEdgePanel() {
88
88
  const dir = e.arrowDir ?? 'to';
89
89
  const dashes = e.dashes ?? false;
90
90
 
91
- ['edgeBtnNone', 'edgeBtnTo', 'edgeBtnBoth'].forEach((id) =>
91
+ ['edgeBtnNone', 'edgeBtnFrom', 'edgeBtnTo', 'edgeBtnBoth'].forEach((id) =>
92
92
  document.getElementById(id).classList.remove('edge-btn-active'));
93
- document.getElementById({ none: 'edgeBtnNone', to: 'edgeBtnTo', both: 'edgeBtnBoth' }[dir] || 'edgeBtnTo')
93
+ document.getElementById({ none: 'edgeBtnNone', from: 'edgeBtnFrom', to: 'edgeBtnTo', both: 'edgeBtnBoth' }[dir] || 'edgeBtnTo')
94
94
  .classList.add('edge-btn-active');
95
95
 
96
96
  ['edgeBtnSolid', 'edgeBtnDashed'].forEach((id) =>
@@ -5,7 +5,7 @@ export function visEdgeProps(arrowDir, dashes) {
5
5
  return {
6
6
  arrows: {
7
7
  to: { enabled: arrowDir === 'to' || arrowDir === 'both', scaleFactor: 0.7 },
8
- from: { enabled: arrowDir === 'both', scaleFactor: 0.7 },
8
+ from: { enabled: arrowDir === 'from' || arrowDir === 'both', scaleFactor: 0.7 },
9
9
  },
10
10
  dashes: dashes === true,
11
11
  };
@@ -22,6 +22,7 @@ import { promptImageName } from './image-name-modal.js';
22
22
  import { showToast } from './toast.js';
23
23
  import { t } from './t.js';
24
24
  import { initEvidenceMode, toggleEvidenceMode } from './evidence.js';
25
+ import { customShapeIdFromTool, isCustomShapeTool } from './custom-shapes.js';
25
26
 
26
27
  // ── Tool management ───────────────────────────────────────────────────────────
27
28
 
@@ -36,6 +37,12 @@ function setTool(tool, shape) {
36
37
  const key = tool === 'addNode' ? `addNode:${shape || st.pendingShape}` : tool;
37
38
  const btn = document.getElementById(TOOL_BTN_MAP[key]);
38
39
  if (btn) btn.classList.add('tool-active');
40
+ document.querySelectorAll('.custom-shape-btn').forEach((b) => {
41
+ b.classList.toggle(
42
+ 'tool-active',
43
+ tool === 'addNode' && isCustomShapeTool(shape || st.pendingShape) && b.dataset.customShapeId === customShapeIdFromTool(shape || st.pendingShape),
44
+ );
45
+ });
39
46
 
40
47
  document.getElementById('vis-canvas').classList.toggle('cursor-crosshair', tool === 'addNode' || tool === 'addEdge');
41
48
 
@@ -203,6 +210,7 @@ document.getElementById('btnUngroup').addEventListener('click', () => ungroupNod
203
210
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
204
211
 
205
212
  document.getElementById('edgeBtnNone').addEventListener('click', () => setEdgeArrow('none'));
213
+ document.getElementById('edgeBtnFrom').addEventListener('click', () => setEdgeArrow('from'));
206
214
  document.getElementById('edgeBtnTo').addEventListener('click', () => setEdgeArrow('to'));
207
215
  document.getElementById('edgeBtnBoth').addEventListener('click', () => setEdgeArrow('both'));
208
216
  document.getElementById('edgeBtnSolid').addEventListener('click', () => setEdgeDashes(false));
@@ -24,6 +24,7 @@ import { getNearestPort, getPortPosition, drawPortDots, drawPortEdge, distanceTo
24
24
  import { getLastFreeArrowStyle } from './edge-panel.js';
25
25
  import { getLastNodeStyle } from './node-panel.js';
26
26
  import { installUnlockHold } from './unlock-hold.js';
27
+ import { CUSTOM_SHAPE_TYPE, customShapeIdFromTool, getCustomShapeDefaultSize, getCustomShapeDefinition } from './custom-shapes.js';
27
28
 
28
29
  // Module-level port-hover state — shared between initNetwork event handlers and
29
30
  // module-level helpers (_onAnchorSnapConnect).
@@ -102,14 +103,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
102
103
  enabled: false,
103
104
  addEdge(data, callback) {
104
105
  // Block edges from/to locked nodes or anchors (free-arrow endpoints).
105
- // Also block when the target sits below the source in z-order the
106
- // mouseup handler turns that case into a free-arrow endpoint instead.
106
+ // Also block when the source sits on top of a lower-z target that
107
+ // contains it: that target is acting as a background surface, and the
108
+ // mouseup handler turns the gesture into a free-arrow endpoint instead.
107
109
  const fromNode = st.nodes.get(data.from) || {};
108
110
  const toNode = st.nodes.get(data.to) || {};
109
- const fromZ = st.canonicalOrder.indexOf(data.from);
110
- const toZ = st.canonicalOrder.indexOf(data.to);
111
- const toBelow = fromZ !== -1 && toZ !== -1 && toZ < fromZ;
112
- if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || toBelow) {
111
+ const targetContainsSource = lowerZTargetContainsSource(data.from, data.to);
112
+ if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || targetContainsSource) {
113
113
  _addEdgeFromPort = null;
114
114
  setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
115
115
  return;
@@ -761,14 +761,11 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
761
761
  // Locked targets are non-interactive: treat them exactly like empty canvas
762
762
  // so a free-arrow endpoint is created at the release point — matches the
763
763
  // behaviour of free arrows drawn over a locked shape (Path B).
764
- // Also: if the target sits BELOW the source in z-order (e.g. a post-it on
765
- // top of a background image), don't bind to it — the user is aiming at a
766
- // point on top of the source's layer, not at the underlying shape.
764
+ // Also: if the source sits on top of a lower-z target that contains it
765
+ // (e.g. a post-it on a background image), don't bind to that background.
767
766
  const targetLocked = !!(targetData && targetData.locked);
768
- const sourceZ = st.canonicalOrder.indexOf(_addEdgeFromId);
769
- const targetZ = targetId ? st.canonicalOrder.indexOf(targetId) : -1;
770
- const targetBelow = targetId && sourceZ !== -1 && targetZ !== -1 && targetZ < sourceZ;
771
- if (!targetId || targetLocked || targetBelow) {
767
+ const targetContainsSource = targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
768
+ if (!targetId || targetLocked || targetContainsSource) {
772
769
  pushSnapshot();
773
770
  const cp = st.network.DOMtoCanvas(pos);
774
771
  const anchorId = 'a' + Date.now();
@@ -1142,6 +1139,15 @@ function nodeContainsCanvasPoint(id, canvasPos) {
1142
1139
  return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
1143
1140
  }
1144
1141
 
1142
+ function lowerZTargetContainsSource(sourceId, targetId) {
1143
+ if (!sourceId || !targetId) return false;
1144
+ const sourceZ = st.canonicalOrder.indexOf(sourceId);
1145
+ const targetZ = st.canonicalOrder.indexOf(targetId);
1146
+ if (sourceZ === -1 || targetZ === -1 || targetZ >= sourceZ) return false;
1147
+ const sourcePos = st.network && st.network.getPositions([sourceId])[sourceId];
1148
+ return !!(sourcePos && nodeContainsCanvasPoint(targetId, sourcePos));
1149
+ }
1150
+
1145
1151
  // Returns the topmost (highest z-order) node containing canvasPos.
1146
1152
  // Ignores anchor nodes and respects st.canonicalOrder.
1147
1153
  function topmostNodeAt(canvasPos) {
@@ -1269,29 +1275,34 @@ function onDoubleClick(params) {
1269
1275
  const srcEvent = params.event && params.event.srcEvent;
1270
1276
  const clientPos = srcEvent ? { x: srcEvent.clientX, y: srcEvent.clientY } : null;
1271
1277
  const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1272
- const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1273
- const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1274
- const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
1275
- .filter((id, index, list) => id && list.indexOf(id) === index)
1276
- .filter((id) => isEdgeInteractive(st.edges.get(id)));
1277
- const edgeId = edgeCandidates.reduce((bestId, id) => {
1278
- if (!bestId) return id;
1279
- return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
1280
- }, null);
1281
-
1282
1278
  const topNodeId = topmostNodeAt(params.pointer.canvas);
1283
1279
  const topNode = topNodeId && st.nodes.get(topNodeId);
1284
1280
  const canEditTopNode = topNode && !topNode.locked && topNode.shapeType !== 'anchor';
1285
- const topNodeWins = canEditTopNode && !labelEdgeId && (!edgeId || st.canonicalOrder.indexOf(topNodeId) >= edgeDrawLevel(st.edges.get(edgeId)));
1286
1281
 
1287
- if (topNodeWins) {
1282
+ // Direct node double-clicks must edit the node. Dense port-edge diagrams can
1283
+ // have many visual edges crossing a node; those should not steal the edit.
1284
+ // The only exception is an actual edge-label hit, which is intentional.
1285
+ if (canEditTopNode && !labelEdgeId) {
1288
1286
  st.selectedNodeIds = [topNodeId];
1289
1287
  st.selectedEdgeIds = [];
1290
1288
  st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
1291
1289
  showNodePanel();
1292
1290
  hideEdgePanel();
1293
1291
  startLabelEdit();
1294
- } else if (edgeId) {
1292
+ return;
1293
+ }
1294
+
1295
+ const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
1296
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1297
+ const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
1298
+ .filter((id, index, list) => id && list.indexOf(id) === index)
1299
+ .filter((id) => isEdgeInteractive(st.edges.get(id)));
1300
+ const edgeId = edgeCandidates.reduce((bestId, id) => {
1301
+ if (!bestId) return id;
1302
+ return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
1303
+ }, null);
1304
+
1305
+ if (edgeId) {
1295
1306
  st.selectedNodeIds = [];
1296
1307
  st.selectedEdgeIds = [edgeId];
1297
1308
  st.network.setSelection({ nodes: [], edges: [edgeId] });
@@ -1304,9 +1315,12 @@ function onDoubleClick(params) {
1304
1315
  } else if (st.currentTool === 'addNode') {
1305
1316
  pushSnapshot();
1306
1317
  const id = 'n' + Date.now();
1307
- const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
1308
- const fallbackColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
1309
- const lastStyle = getLastNodeStyle(st.pendingShape);
1318
+ const customShapeId = customShapeIdFromTool(st.pendingShape);
1319
+ const shapeType = customShapeId ? CUSTOM_SHAPE_TYPE : st.pendingShape;
1320
+ const customDef = customShapeId ? getCustomShapeDefinition(customShapeId) : null;
1321
+ const defaults = customShapeId ? getCustomShapeDefaultSize(customShapeId) : SHAPE_DEFAULTS[shapeType] || [100, 40];
1322
+ const fallbackColor = shapeType === 'post-it' ? 'c-amber' : 'c-gray';
1323
+ const lastStyle = getLastNodeStyle(shapeType);
1310
1324
  const colorKey = lastStyle.colorKey || fallbackColor;
1311
1325
  const fontSize = lastStyle.fontSize || null;
1312
1326
  const textAlign = lastStyle.textAlign || null;
@@ -1314,13 +1328,13 @@ function onDoubleClick(params) {
1314
1328
  const rawPos = params.pointer.canvas;
1315
1329
  const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
1316
1330
  st.nodes.add({
1317
- id, label: st.pendingShape === 'text-free' ? t('diagram.label_input.placeholder') : 'Node',
1318
- shapeType: st.pendingShape, colorKey,
1331
+ id, label: shapeType === 'text-free' ? t('diagram.label_input.placeholder') : (customDef ? customDef.name : 'Node'),
1332
+ shapeType, customShapeId: customShapeId || null, colorKey,
1319
1333
  nodeWidth: defaults[0], nodeHeight: defaults[1],
1320
1334
  fontSize, textAlign, textValign,
1321
1335
  rotation: 0, labelRotation: 0,
1322
1336
  x: pos.x, y: pos.y,
1323
- ...visNodeProps(st.pendingShape, colorKey, defaults[0], defaults[1], fontSize, textAlign, textValign),
1337
+ ...visNodeProps(shapeType, colorKey, defaults[0], defaults[1], fontSize, textAlign, textValign),
1324
1338
  });
1325
1339
  markDirty();
1326
1340
  setTimeout(() => {
@@ -1467,28 +1481,22 @@ function onClickNode(params) {
1467
1481
  // passing it to getEdgeAt() causes a double-subtraction of the container rect and
1468
1482
  // returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
1469
1483
  if (params.nodes.length > 0) {
1470
- const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1471
- const nativeEdgeId = st.network.getEdgeAt(clientPos);
1472
- const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1473
- const edgeId = nativeEdgeId || (portEdge && portEdge.id);
1474
-
1475
- // First honour the app-managed z-order. vis-network may report an edge or a
1476
- // lower node at the click point; compare draw levels so the visibly top
1477
- // element gets the click.
1484
+ const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
1478
1485
  const top = topmostNodeAt(params.pointer.canvas);
1479
1486
  if (top) {
1480
1487
  const topNode = st.nodes.get(top);
1481
- const topLevel = st.canonicalOrder.indexOf(top);
1482
- const edgeLevel = edgeId ? edgeDrawLevel(st.edges.get(edgeId)) : -1;
1483
- if (topNode && !topNode.locked && topNode.shapeType !== 'anchor') {
1484
- if (!edgeId || topLevel >= edgeLevel) {
1485
- setTimeout(() => {
1486
- selectNodesFromClick(top, params.event.srcEvent);
1487
- }, 0);
1488
- return;
1489
- }
1488
+ if (topNode && !topNode.locked && topNode.shapeType !== 'anchor' && !labelEdgeId) {
1489
+ setTimeout(() => {
1490
+ selectNodesFromClick(top, params.event.srcEvent);
1491
+ }, 0);
1492
+ return;
1490
1493
  }
1491
1494
  }
1495
+
1496
+ const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
1497
+ const nativeEdgeId = st.network.getEdgeAt(clientPos);
1498
+ const portEdge = nearestPortEdgeAt(params.pointer.canvas);
1499
+ const edgeId = labelEdgeId || nativeEdgeId || (portEdge && portEdge.id);
1492
1500
  if (edgeId) {
1493
1501
  const edge = st.edges.get(edgeId);
1494
1502
  const fromN = edge && st.nodes.get(edge.from);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { NODE_COLORS } from "./constants.js";
8
8
  import { st } from "./state.js";
9
+ import { CUSTOM_SHAPE_TYPE, getCustomShapeDefinition } from "./custom-shapes.js";
9
10
 
10
11
  // Returns the color object for a key, checking runtime overrides first.
11
12
  function getNodeColor(colorKey) {
@@ -182,6 +183,19 @@ function nodeData(id, defaultW, defaultH, defaultColorKey) {
182
183
  };
183
184
  }
184
185
 
186
+ function textLines(label) {
187
+ return label ? String(label).split("\n") : [];
188
+ }
189
+
190
+ export function customShapeImageOffsetY(node, label) {
191
+ const def = getCustomShapeDefinition(node && node.customShapeId);
192
+ if (!def || def.labelPlacement !== "below" || !label) return 0;
193
+ const fontSize = (node && node.fontSize) || 13;
194
+ const lines = textLines(label);
195
+ if (!lines.length) return 0;
196
+ return -(lines.length * fontSize * 1.3 + 8) / 2;
197
+ }
198
+
185
199
  // ── Shape renderers ───────────────────────────────────────────────────────────
186
200
 
187
201
  export function makeBoxRenderer(colorKey) {
@@ -653,6 +667,86 @@ export function makeImageRenderer(colorKey) {
653
667
  };
654
668
  }
655
669
 
670
+ export function makeCustomShapeRenderer(colorKey) {
671
+ return function ({ ctx, x, y, id, state: visState, label }) {
672
+ const n = st.nodes && st.nodes.get(id);
673
+ const def = getCustomShapeDefinition(n && n.customShapeId);
674
+ const defaultW = def && def.width || 96;
675
+ const defaultH = def && def.height || 96;
676
+ const {
677
+ W,
678
+ H,
679
+ rotation,
680
+ labelRotation,
681
+ textAlign,
682
+ textValign,
683
+ fontSize,
684
+ c,
685
+ } = nodeData(id, defaultW, defaultH, colorKey || "c-gray");
686
+ const img = getCachedImage(def && def.imageSrc, () => st.network && st.network.redraw());
687
+ const labelBelow = def && def.labelPlacement === "below";
688
+ const lines = textLines(label);
689
+ const belowLabelH = labelBelow && lines.length ? lines.length * fontSize * 1.3 + 8 : 0;
690
+ const imageOffsetY = labelBelow ? -belowLabelH / 2 : 0;
691
+ const totalH = H + belowLabelH;
692
+ return {
693
+ drawNode() {
694
+ ctx.save();
695
+ ctx.translate(x, y);
696
+ ctx.rotate(rotation);
697
+
698
+ if (img) {
699
+ ctx.drawImage(img, -W / 2, imageOffsetY - H / 2, W, H);
700
+ } else {
701
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
702
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
703
+ ctx.lineWidth = 1.5;
704
+ roundRect(ctx, -W / 2, imageOffsetY - H / 2, W, H, 4);
705
+ ctx.fill();
706
+ ctx.stroke();
707
+ }
708
+
709
+ if (visState.selected || visState.hover) {
710
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
711
+ ctx.lineWidth = visState.selected ? 2 : 1;
712
+ ctx.setLineDash([4, 3]);
713
+ roundRect(ctx, -W / 2, -totalH / 2, W, totalH, 4);
714
+ ctx.stroke();
715
+ ctx.setLineDash([]);
716
+ }
717
+
718
+ if (labelBelow && lines.length) {
719
+ ctx.save();
720
+ if (labelRotation) ctx.rotate(labelRotation);
721
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
722
+ ctx.fillStyle = c.font;
723
+ ctx.textAlign = "center";
724
+ ctx.textBaseline = "top";
725
+ const lineH = fontSize * 1.3;
726
+ const startY = imageOffsetY + H / 2 + 4;
727
+ lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
728
+ ctx.restore();
729
+ } else {
730
+ drawLabel(
731
+ ctx,
732
+ label,
733
+ fontSize,
734
+ c.font,
735
+ textAlign,
736
+ textValign,
737
+ W,
738
+ H,
739
+ labelRotation,
740
+ );
741
+ }
742
+ drawLinkIndicator(ctx, id, W, H);
743
+ ctx.restore();
744
+ },
745
+ nodeDimensions: { width: W, height: totalH },
746
+ };
747
+ };
748
+ }
749
+
656
750
  // ── Public API ────────────────────────────────────────────────────────────────
657
751
 
658
752
  // Returns the rendered height from vis-network internals (used by node-panel
@@ -703,6 +797,7 @@ const RENDERER_MAP = {
703
797
  "text-free": makeTextFreeRenderer,
704
798
  actor: makeActorRenderer,
705
799
  image: makeImageRenderer,
800
+ [CUSTOM_SHAPE_TYPE]: makeCustomShapeRenderer,
706
801
  anchor: makeAnchorRenderer,
707
802
  };
708
803
 
@@ -716,6 +811,7 @@ export const SHAPE_DEFAULTS = {
716
811
  "post-it": [120, 100],
717
812
  "text-free": [80, 30],
718
813
  image: [160, 120],
814
+ [CUSTOM_SHAPE_TYPE]: [96, 96],
719
815
  anchor: [8, 8],
720
816
  };
721
817
 
@@ -126,6 +126,7 @@ export async function saveDiagram() {
126
126
  .map((n) => ({
127
127
  id: n.id, label: n.label,
128
128
  shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
129
+ customShapeId: n.customShapeId || null,
129
130
  kind: n.kind || null, renderAs: n.renderAs || null,
130
131
  description: n.description || null,
131
132
  evidence: Array.isArray(n.evidence) ? n.evidence : null,
@@ -7,7 +7,8 @@
7
7
  // vis-network's native rendering unchanged.
8
8
 
9
9
  import { st } from './state.js';
10
- import { SHAPE_DEFAULTS } from './node-rendering.js';
10
+ import { customShapeImageOffsetY, SHAPE_DEFAULTS } from './node-rendering.js';
11
+ import { CUSTOM_SHAPE_TYPE, getCustomShapeAnchors } from './custom-shapes.js';
11
12
 
12
13
  /**
13
14
  * Splits `text` into lines that each fit within `maxWidth` canvas units.
@@ -83,20 +84,52 @@ function nodeGeometry(nodeId) {
83
84
  const W = n.nodeWidth || defaults[0];
84
85
  // 'circle' is always square (H = W); 'ellipse' uses its own height.
85
86
  const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
86
- return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType };
87
+ const imageOffsetY = shapeType === CUSTOM_SHAPE_TYPE ? customShapeImageOffsetY(n, n.label) : 0;
88
+ return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType, customShapeId: n.customShapeId || null, imageOffsetY };
89
+ }
90
+
91
+ export function getPortKeys(nodeId) {
92
+ const n = st.nodes && st.nodes.get(nodeId);
93
+ if (n && n.shapeType === CUSTOM_SHAPE_TYPE) {
94
+ return getCustomShapeAnchors(n.customShapeId).map((anchor) => anchor.id);
95
+ }
96
+ return PORT_KEYS;
97
+ }
98
+
99
+ function customPortOffset(customShapeId, portKey) {
100
+ const anchor = getCustomShapeAnchors(customShapeId).find((item) => item.id === portKey);
101
+ if (!anchor) return null;
102
+ return [(anchor.x - 0.5) * 2, (anchor.y - 0.5) * 2];
103
+ }
104
+
105
+ function normalForPort(shapeType, customShapeId, portKey) {
106
+ if (shapeType === CUSTOM_SHAPE_TYPE) {
107
+ const offset = customPortOffset(customShapeId, portKey) || [0, -1];
108
+ const len = Math.hypot(offset[0], offset[1]) || 1;
109
+ return [offset[0] / len, offset[1] / len];
110
+ }
111
+ return PORT_NORMALS[portKey] || [0, -1];
112
+ }
113
+
114
+ function controlNormalForEdge(nodeId, portKey) {
115
+ const n = st.nodes && st.nodes.get(nodeId);
116
+ return normalForPort(n && n.shapeType, n && n.customShapeId, portKey);
87
117
  }
88
118
 
89
119
  /** Returns the world-space {x, y} of a port on a node. */
90
120
  export function getPortPosition(nodeId, portKey) {
91
121
  const geo = nodeGeometry(nodeId);
92
122
  if (!geo) return null;
93
- const { cx, cy, W, H, rotation, shapeType } = geo;
94
- const offsets = shapeType === 'database' ? PORT_OFFSETS_DATABASE
123
+ const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetY } = geo;
124
+ const offsets = shapeType === CUSTOM_SHAPE_TYPE ? null
125
+ : shapeType === 'database' ? PORT_OFFSETS_DATABASE
95
126
  : CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
96
127
  : PORT_OFFSETS_RECT;
97
- const [ox, oy] = offsets[portKey] || [0, 0];
128
+ const [ox, oy] = shapeType === CUSTOM_SHAPE_TYPE
129
+ ? customPortOffset(customShapeId, portKey) || [0, 0]
130
+ : offsets[portKey] || [0, 0];
98
131
  let dx = ox * W / 2;
99
- let dy = oy * H / 2;
132
+ let dy = oy * H / 2 + imageOffsetY;
100
133
  if (rotation) {
101
134
  const cos = Math.cos(rotation), sin = Math.sin(rotation);
102
135
  [dx, dy] = [dx * cos - dy * sin, dx * sin + dy * cos];
@@ -107,7 +140,7 @@ export function getPortPosition(nodeId, portKey) {
107
140
  /** Returns the port key whose position is closest to `canvasPos` (world coords). */
108
141
  export function getNearestPort(nodeId, canvasPos) {
109
142
  let minD2 = Infinity, nearest = 'N';
110
- for (const key of PORT_KEYS) {
143
+ for (const key of getPortKeys(nodeId)) {
111
144
  const p = getPortPosition(nodeId, key);
112
145
  if (!p) continue;
113
146
  const d2 = (canvasPos.x - p.x) ** 2 + (canvasPos.y - p.y) ** 2;
@@ -119,12 +152,12 @@ export function getNearestPort(nodeId, canvasPos) {
119
152
  // ── Port dot visualisation ────────────────────────────────────────────────────
120
153
 
121
154
  /**
122
- * Draws 8 port hint dots for `nodeId` in the vis-network afterDrawing context.
155
+ * Draws port hint dots for `nodeId` in the vis-network afterDrawing context.
123
156
  * `highlightedPort` (if any) is rendered larger and fully orange.
124
157
  */
125
158
  export function drawPortDots(ctx, nodeId, highlightedPort) {
126
159
  if (st.exportingPng) return;
127
- for (const key of PORT_KEYS) {
160
+ for (const key of getPortKeys(nodeId)) {
128
161
  const p = getPortPosition(nodeId, key);
129
162
  if (!p) continue;
130
163
  const hl = key === highlightedPort;
@@ -180,7 +213,7 @@ function isAnchorNode(nodeId) {
180
213
  * Handles:
181
214
  * - Bezier curve (when both ports are set and not in straight mode)
182
215
  * - Straight line fallback
183
- * - Arrowheads (to / both / none)
216
+ * - Arrowheads (from / to / both / none)
184
217
  * - Dashes
185
218
  * - Label (rotated or plain) at the curve midpoint
186
219
  */
@@ -219,8 +252,8 @@ export function drawPortEdge(ctx, edgeData) {
219
252
  if (useBezier) {
220
253
  const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
221
254
  const tension = Math.max(60, dist * 0.4);
222
- const fn = PORT_NORMALS[edgeData.fromPort];
223
- const tn = PORT_NORMALS[edgeData.toPort];
255
+ const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
256
+ const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
224
257
  cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
225
258
  cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
226
259
  }
@@ -235,7 +268,7 @@ export function drawPortEdge(ctx, edgeData) {
235
268
  // ── Arrowheads ─────────────────────────────────────────────────────────────
236
269
  const arrowDir = edgeData.arrowDir ?? 'to';
237
270
  const drawTo = arrowDir === 'to' || arrowDir === 'both';
238
- const drawFrom = arrowDir === 'both';
271
+ const drawFrom = arrowDir === 'from' || arrowDir === 'both';
239
272
 
240
273
  if (drawTo) {
241
274
  const a = cp2
@@ -358,8 +391,8 @@ export function distanceToPortEdge(edgeData, p) {
358
391
  if (!st.edgesStraight && edgeData.fromPort && !fromIsAnch && edgeData.toPort && !toIsAnch) {
359
392
  const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
360
393
  const tension = Math.max(60, dist * 0.4);
361
- const fn = PORT_NORMALS[edgeData.fromPort];
362
- const tn = PORT_NORMALS[edgeData.toPort];
394
+ const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
395
+ const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
363
396
  const cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
364
397
  const cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
365
398
  return _distToBezier(p, fromPos, cp1, cp2, toPos);
@@ -28,6 +28,8 @@ export const st = {
28
28
  edgesStraight: false, // when true, all edges use smooth: disabled (straight lines)
29
29
  resizeSymmetric: false, // when true, center is fixed during resize; when false, opposite corner is fixed
30
30
  nodeColorOverrides: {}, // colorKey → {bg, border, font, hbg, hborder} — set from config at boot
31
+ customShapeLibraries: [], // loaded from /api/shape-libraries
32
+ customShapeDefs: new Map(), // shape id → custom shape definition
31
33
  edgeLabelCanvasPos: {}, // edgeId → {x, y} canvas coords of last drawn label, used by label editor
32
34
  edgeLabelBBox: {}, // edgeId → {cx, cy, w, h, rotation} canvas world coords, used by label resize
33
35
  freeArrowFirstPoint: null, // addEdge two-click flow: {x, y} canvas coords of first click, or null