living-documentation 7.42.0 → 7.44.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.
@@ -3,9 +3,12 @@
3
3
 
4
4
  import { st, markDirty } from './state.js';
5
5
  import { SHAPE_DEFAULTS } from './node-rendering.js';
6
+ import { CUSTOM_SHAPE_TYPE, getCustomShapeLabelPlacement } from './custom-shapes.js';
6
7
  import { pushSnapshot } from './history.js';
7
8
  import { t } from './t.js';
8
9
 
10
+ const CUSTOM_LABEL_PLACEMENTS = ['below', 'above', 'right', 'left', 'center'];
11
+
9
12
  // ── Last-used style persistence (per shape type) ──────────────────────────────
10
13
  // Saves colorKey/fontSize/textAlign/textValign per shapeType to localStorage so
11
14
  // the next shape of that type is created with the same style.
@@ -90,6 +93,36 @@ function syncNodeFontSizeValue() {
90
93
  el.textContent = sizes.every((size) => size === first) ? String(first) : '–';
91
94
  }
92
95
 
96
+ function selectedCustomShapeIds() {
97
+ return (st.selectedNodeIds || []).filter((id) => {
98
+ const n = st.nodes && st.nodes.get(id);
99
+ return n && n.shapeType === CUSTOM_SHAPE_TYPE;
100
+ });
101
+ }
102
+
103
+ function effectiveCustomShapeLabelPlacement(node) {
104
+ return CUSTOM_LABEL_PLACEMENTS.includes(node && node.labelPlacement)
105
+ ? node.labelPlacement
106
+ : getCustomShapeLabelPlacement(node && node.customShapeId);
107
+ }
108
+
109
+ function syncCustomShapeLabelPlacementControls() {
110
+ const controls = document.getElementById('customShapeLabelPlacementControls');
111
+ if (!controls) return;
112
+ const customIds = selectedCustomShapeIds();
113
+ controls.classList.toggle('hidden', customIds.length === 0);
114
+ if (!customIds.length) return;
115
+
116
+ const placements = customIds
117
+ .map((id) => effectiveCustomShapeLabelPlacement(st.nodes.get(id)))
118
+ .filter(Boolean);
119
+ const first = placements[0];
120
+ const shared = placements.length && placements.every((placement) => placement === first) ? first : null;
121
+ controls.querySelectorAll('[data-label-placement]').forEach((btn) => {
122
+ btn.classList.toggle('tool-active', !!shared && btn.dataset.labelPlacement === shared);
123
+ });
124
+ }
125
+
93
126
  function setEdgeLocked(edge, locked) {
94
127
  if (!edge) return;
95
128
  const fromN = st.nodes && st.nodes.get(edge.from);
@@ -111,6 +144,7 @@ export function showNodePanel() {
111
144
  document.getElementById('nodePanelControls').classList.remove('hidden');
112
145
  syncNodeLockButton();
113
146
  syncNodeFontSizeValue();
147
+ syncCustomShapeLabelPlacementControls();
114
148
  // Sync the opacity slider with the first selected node's current value so the
115
149
  // slider reflects the live state rather than whatever position it was left at.
116
150
  const slider = document.getElementById('nodeBgOpacity');
@@ -219,6 +253,19 @@ export function setTextValign(valign) {
219
253
  markDirty();
220
254
  }
221
255
 
256
+ export function setCustomShapeLabelPlacement(placement) {
257
+ if (!CUSTOM_LABEL_PLACEMENTS.includes(placement)) return;
258
+ const ids = selectedCustomShapeIds();
259
+ if (!ids.length) return;
260
+ pushSnapshot();
261
+ ids.forEach((id) => {
262
+ st.nodes.update({ id, labelPlacement: placement });
263
+ });
264
+ syncCustomShapeLabelPlacementControls();
265
+ forceRedraw();
266
+ markDirty();
267
+ }
268
+
222
269
  // ── Stamp (format painter) ────────────────────────────────────────────────────
223
270
  // Uses a transparent DOM overlay (#stampOverlay) that intercepts canvas clicks
224
271
  // during stamp mode. This bypasses vis-network's event system entirely, avoiding
@@ -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, CUSTOM_SHAPE_DEFAULT_SIZE } 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,93 @@ 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
+ function normalizeCustomLabelPlacement(value) {
191
+ return ["center", "below", "above", "right", "left"].includes(value) ? value : null;
192
+ }
193
+
194
+ function isExternalCustomLabelPlacement(placement) {
195
+ return ["above", "below", "right", "left"].includes(placement);
196
+ }
197
+
198
+ function measureTextWidth(line, fontSize, ctx) {
199
+ const font = `${fontSize}px system-ui,-apple-system,sans-serif`;
200
+ if (ctx) {
201
+ const prevFont = ctx.font;
202
+ ctx.font = font;
203
+ const width = ctx.measureText(line).width;
204
+ ctx.font = prevFont;
205
+ return width;
206
+ }
207
+ if (typeof document === "undefined") return String(line).length * fontSize * 0.58;
208
+ if (!measureTextWidth._ctx) {
209
+ measureTextWidth._ctx = document.createElement("canvas").getContext("2d");
210
+ }
211
+ measureTextWidth._ctx.font = font;
212
+ return measureTextWidth._ctx.measureText(line).width;
213
+ }
214
+
215
+ export function customShapeLayout(node, label, ctx) {
216
+ const def = getCustomShapeDefinition(node && node.customShapeId);
217
+ const W =
218
+ (node && node.nodeWidth) || (def && def.width) || CUSTOM_SHAPE_DEFAULT_SIZE;
219
+ const H =
220
+ (node && node.nodeHeight) ||
221
+ (def && def.height) ||
222
+ CUSTOM_SHAPE_DEFAULT_SIZE;
223
+ const placement = normalizeCustomLabelPlacement(node && node.labelPlacement)
224
+ || normalizeCustomLabelPlacement(def && def.labelPlacement)
225
+ || "below";
226
+ const fontSize = (node && node.fontSize) || 13;
227
+ const lines = textLines(label);
228
+ const lineH = fontSize * 1.3;
229
+ if (!lines.length || !isExternalCustomLabelPlacement(placement)) {
230
+ return {
231
+ placement,
232
+ lines,
233
+ lineH,
234
+ imageOffsetX: 0,
235
+ imageOffsetY: 0,
236
+ labelBlockW: 0,
237
+ labelBlockH: 0,
238
+ totalW: W,
239
+ totalH: H,
240
+ };
241
+ }
242
+
243
+ const maxTextW = Math.max(...lines.map((line) => measureTextWidth(line, fontSize, ctx)));
244
+ const labelBlockW = maxTextW + 16;
245
+ const labelBlockH = lines.length * lineH + 8;
246
+ const horizontal = placement === "left" || placement === "right";
247
+ const totalW = horizontal ? W + labelBlockW : Math.max(W, labelBlockW);
248
+ const totalH = horizontal ? Math.max(H, labelBlockH) : H + labelBlockH;
249
+ const imageOffsetX = placement === "right" ? -labelBlockW / 2
250
+ : placement === "left" ? labelBlockW / 2
251
+ : 0;
252
+ const imageOffsetY = placement === "below" ? -labelBlockH / 2
253
+ : placement === "above" ? labelBlockH / 2
254
+ : 0;
255
+
256
+ return {
257
+ placement,
258
+ lines,
259
+ lineH,
260
+ imageOffsetX,
261
+ imageOffsetY,
262
+ labelBlockW,
263
+ labelBlockH,
264
+ totalW,
265
+ totalH,
266
+ };
267
+ }
268
+
269
+ export function customShapeImageOffsetY(node, label) {
270
+ return customShapeLayout(node, label).imageOffsetY;
271
+ }
272
+
185
273
  // ── Shape renderers ───────────────────────────────────────────────────────────
186
274
 
187
275
  export function makeBoxRenderer(colorKey) {
@@ -653,6 +741,106 @@ export function makeImageRenderer(colorKey) {
653
741
  };
654
742
  }
655
743
 
744
+ export function makeCustomShapeRenderer(colorKey) {
745
+ return function ({ ctx, x, y, id, state: visState, label }) {
746
+ const n = st.nodes && st.nodes.get(id);
747
+ const def = getCustomShapeDefinition(n && n.customShapeId);
748
+ const defaultW = (def && def.width) || CUSTOM_SHAPE_DEFAULT_SIZE;
749
+ const defaultH = (def && def.height) || CUSTOM_SHAPE_DEFAULT_SIZE;
750
+ const {
751
+ W,
752
+ H,
753
+ rotation,
754
+ labelRotation,
755
+ textAlign,
756
+ textValign,
757
+ fontSize,
758
+ c,
759
+ } = nodeData(id, defaultW, defaultH, colorKey || "c-gray");
760
+ const img = getCachedImage(def && def.imageSrc, () => st.network && st.network.redraw());
761
+ const layout = customShapeLayout({ ...(n || {}), nodeWidth: W, nodeHeight: H, fontSize }, label, ctx);
762
+ const {
763
+ placement,
764
+ lines,
765
+ lineH,
766
+ imageOffsetX,
767
+ imageOffsetY,
768
+ totalW,
769
+ totalH,
770
+ } = layout;
771
+ const externalLabel = isExternalCustomLabelPlacement(placement) && lines.length;
772
+ return {
773
+ drawNode() {
774
+ ctx.save();
775
+ ctx.translate(x, y);
776
+ ctx.rotate(rotation);
777
+
778
+ if (img) {
779
+ ctx.drawImage(img, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H);
780
+ } else {
781
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
782
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
783
+ ctx.lineWidth = 1.5;
784
+ roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
785
+ ctx.fill();
786
+ ctx.stroke();
787
+ }
788
+
789
+ if (visState.selected || visState.hover) {
790
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
791
+ ctx.lineWidth = visState.selected ? 2 : 1;
792
+ ctx.setLineDash([4, 3]);
793
+ roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
794
+ ctx.stroke();
795
+ ctx.setLineDash([]);
796
+ }
797
+
798
+ if (externalLabel) {
799
+ ctx.save();
800
+ if (labelRotation) ctx.rotate(labelRotation);
801
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
802
+ ctx.fillStyle = c.font;
803
+ if (placement === "right" || placement === "left") {
804
+ const textX = placement === "right"
805
+ ? imageOffsetX + W / 2 + 8
806
+ : imageOffsetX - W / 2 - 8;
807
+ const startY = -((lines.length - 1) * lineH) / 2;
808
+ ctx.textAlign = placement === "right" ? "left" : "right";
809
+ ctx.textBaseline = "middle";
810
+ lines.forEach((line, i) => ctx.fillText(line, textX, startY + i * lineH));
811
+ } else {
812
+ ctx.textAlign = "center";
813
+ ctx.textBaseline = "top";
814
+ const startY = placement === "below"
815
+ ? imageOffsetY + H / 2 + 4
816
+ : -totalH / 2 + 4;
817
+ lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
818
+ }
819
+ ctx.restore();
820
+ } else {
821
+ drawLabel(
822
+ ctx,
823
+ label,
824
+ fontSize,
825
+ c.font,
826
+ textAlign,
827
+ textValign,
828
+ W,
829
+ H,
830
+ labelRotation,
831
+ );
832
+ }
833
+ ctx.save();
834
+ ctx.translate(imageOffsetX, imageOffsetY);
835
+ drawLinkIndicator(ctx, id, W, H);
836
+ ctx.restore();
837
+ ctx.restore();
838
+ },
839
+ nodeDimensions: { width: totalW, height: totalH },
840
+ };
841
+ };
842
+ }
843
+
656
844
  // ── Public API ────────────────────────────────────────────────────────────────
657
845
 
658
846
  // Returns the rendered height from vis-network internals (used by node-panel
@@ -703,6 +891,7 @@ const RENDERER_MAP = {
703
891
  "text-free": makeTextFreeRenderer,
704
892
  actor: makeActorRenderer,
705
893
  image: makeImageRenderer,
894
+ [CUSTOM_SHAPE_TYPE]: makeCustomShapeRenderer,
706
895
  anchor: makeAnchorRenderer,
707
896
  };
708
897
 
@@ -716,6 +905,7 @@ export const SHAPE_DEFAULTS = {
716
905
  "post-it": [120, 100],
717
906
  "text-free": [80, 30],
718
907
  image: [160, 120],
908
+ [CUSTOM_SHAPE_TYPE]: [CUSTOM_SHAPE_DEFAULT_SIZE, CUSTOM_SHAPE_DEFAULT_SIZE],
719
909
  anchor: [8, 8],
720
910
  };
721
911
 
@@ -126,6 +126,8 @@ 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,
130
+ labelPlacement: n.labelPlacement || null,
129
131
  kind: n.kind || null, renderAs: n.renderAs || null,
130
132
  description: n.description || null,
131
133
  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 { customShapeLayout, 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,54 @@ 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 customLayout = shapeType === CUSTOM_SHAPE_TYPE ? customShapeLayout(n, n.label) : null;
88
+ const imageOffsetX = customLayout ? customLayout.imageOffsetX : 0;
89
+ const imageOffsetY = customLayout ? customLayout.imageOffsetY : 0;
90
+ return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType, customShapeId: n.customShapeId || null, imageOffsetX, imageOffsetY };
91
+ }
92
+
93
+ export function getPortKeys(nodeId) {
94
+ const n = st.nodes && st.nodes.get(nodeId);
95
+ if (n && n.shapeType === CUSTOM_SHAPE_TYPE) {
96
+ return getCustomShapeAnchors(n.customShapeId).map((anchor) => anchor.id);
97
+ }
98
+ return PORT_KEYS;
99
+ }
100
+
101
+ function customPortOffset(customShapeId, portKey) {
102
+ const anchor = getCustomShapeAnchors(customShapeId).find((item) => item.id === portKey);
103
+ if (!anchor) return null;
104
+ return [(anchor.x - 0.5) * 2, (anchor.y - 0.5) * 2];
105
+ }
106
+
107
+ function normalForPort(shapeType, customShapeId, portKey) {
108
+ if (shapeType === CUSTOM_SHAPE_TYPE) {
109
+ const offset = customPortOffset(customShapeId, portKey) || [0, -1];
110
+ const len = Math.hypot(offset[0], offset[1]) || 1;
111
+ return [offset[0] / len, offset[1] / len];
112
+ }
113
+ return PORT_NORMALS[portKey] || [0, -1];
114
+ }
115
+
116
+ function controlNormalForEdge(nodeId, portKey) {
117
+ const n = st.nodes && st.nodes.get(nodeId);
118
+ return normalForPort(n && n.shapeType, n && n.customShapeId, portKey);
87
119
  }
88
120
 
89
121
  /** Returns the world-space {x, y} of a port on a node. */
90
122
  export function getPortPosition(nodeId, portKey) {
91
123
  const geo = nodeGeometry(nodeId);
92
124
  if (!geo) return null;
93
- const { cx, cy, W, H, rotation, shapeType } = geo;
94
- const offsets = shapeType === 'database' ? PORT_OFFSETS_DATABASE
125
+ const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetX, imageOffsetY } = geo;
126
+ const offsets = shapeType === CUSTOM_SHAPE_TYPE ? null
127
+ : shapeType === 'database' ? PORT_OFFSETS_DATABASE
95
128
  : CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
96
129
  : PORT_OFFSETS_RECT;
97
- const [ox, oy] = offsets[portKey] || [0, 0];
98
- let dx = ox * W / 2;
99
- let dy = oy * H / 2;
130
+ const [ox, oy] = shapeType === CUSTOM_SHAPE_TYPE
131
+ ? customPortOffset(customShapeId, portKey) || [0, 0]
132
+ : offsets[portKey] || [0, 0];
133
+ let dx = ox * W / 2 + imageOffsetX;
134
+ let dy = oy * H / 2 + imageOffsetY;
100
135
  if (rotation) {
101
136
  const cos = Math.cos(rotation), sin = Math.sin(rotation);
102
137
  [dx, dy] = [dx * cos - dy * sin, dx * sin + dy * cos];
@@ -107,7 +142,7 @@ export function getPortPosition(nodeId, portKey) {
107
142
  /** Returns the port key whose position is closest to `canvasPos` (world coords). */
108
143
  export function getNearestPort(nodeId, canvasPos) {
109
144
  let minD2 = Infinity, nearest = 'N';
110
- for (const key of PORT_KEYS) {
145
+ for (const key of getPortKeys(nodeId)) {
111
146
  const p = getPortPosition(nodeId, key);
112
147
  if (!p) continue;
113
148
  const d2 = (canvasPos.x - p.x) ** 2 + (canvasPos.y - p.y) ** 2;
@@ -119,12 +154,12 @@ export function getNearestPort(nodeId, canvasPos) {
119
154
  // ── Port dot visualisation ────────────────────────────────────────────────────
120
155
 
121
156
  /**
122
- * Draws 8 port hint dots for `nodeId` in the vis-network afterDrawing context.
157
+ * Draws port hint dots for `nodeId` in the vis-network afterDrawing context.
123
158
  * `highlightedPort` (if any) is rendered larger and fully orange.
124
159
  */
125
160
  export function drawPortDots(ctx, nodeId, highlightedPort) {
126
161
  if (st.exportingPng) return;
127
- for (const key of PORT_KEYS) {
162
+ for (const key of getPortKeys(nodeId)) {
128
163
  const p = getPortPosition(nodeId, key);
129
164
  if (!p) continue;
130
165
  const hl = key === highlightedPort;
@@ -219,8 +254,8 @@ export function drawPortEdge(ctx, edgeData) {
219
254
  if (useBezier) {
220
255
  const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
221
256
  const tension = Math.max(60, dist * 0.4);
222
- const fn = PORT_NORMALS[edgeData.fromPort];
223
- const tn = PORT_NORMALS[edgeData.toPort];
257
+ const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
258
+ const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
224
259
  cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
225
260
  cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
226
261
  }
@@ -358,8 +393,8 @@ export function distanceToPortEdge(edgeData, p) {
358
393
  if (!st.edgesStraight && edgeData.fromPort && !fromIsAnch && edgeData.toPort && !toIsAnch) {
359
394
  const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
360
395
  const tension = Math.max(60, dist * 0.4);
361
- const fn = PORT_NORMALS[edgeData.fromPort];
362
- const tn = PORT_NORMALS[edgeData.toPort];
396
+ const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
397
+ const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
363
398
  const cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
364
399
  const cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
365
400
  return _distToBezier(p, fromPos, cp1, cp2, toPos);
@@ -2,7 +2,8 @@
2
2
  // Dashed selection box, corner resize handles, and top-centre rotation handle.
3
3
 
4
4
  import { st, markDirty } from './state.js';
5
- import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
5
+ import { customShapeLayout, visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
6
+ import { CUSTOM_SHAPE_TYPE } from './custom-shapes.js';
6
7
  import { t } from './t.js';
7
8
  import { pushSnapshot } from './history.js';
8
9
  import { snapToGrid } from './grid.js';
@@ -31,12 +32,71 @@ function nodeBounds(id) {
31
32
  return { minX: cx - hw, minY: cy - hh - headExtra, maxX: cx + hw, maxY: cy + hh };
32
33
  }
33
34
 
35
+ function customShapeImageFrame(id) {
36
+ const n = st.nodes.get(id);
37
+ const bodyNode = st.network.body.nodes[id];
38
+ if (!n || !bodyNode || n.shapeType !== CUSTOM_SHAPE_TYPE) return null;
39
+ const defaults = SHAPE_DEFAULTS[CUSTOM_SHAPE_TYPE] || [96, 96];
40
+ const W = n.nodeWidth || defaults[0];
41
+ const H = n.nodeHeight || defaults[1];
42
+ const rotation = n.rotation || 0;
43
+ const layout = customShapeLayout(n, n.label);
44
+ const cos = Math.cos(rotation);
45
+ const sin = Math.sin(rotation);
46
+ const cx = bodyNode.x + layout.imageOffsetX * cos - layout.imageOffsetY * sin;
47
+ const cy = bodyNode.y + layout.imageOffsetX * sin + layout.imageOffsetY * cos;
48
+ return { cx, cy, W, H, rotation };
49
+ }
50
+
51
+ function syncOverlayHandles(width, height, pad) {
52
+ const rh = document.getElementById('rh-rotate');
53
+ rh.style.left = width / 2 + pad - 8 + 'px';
54
+ rh.style.top = '-28px';
55
+
56
+ const lrh = document.getElementById('rh-label-rotate');
57
+ lrh.style.left = width / 2 + pad - 8 - 24 + 'px';
58
+ lrh.style.top = '-28px';
59
+ }
60
+
61
+ function updateSingleCustomShapeOverlay(id) {
62
+ const frame = customShapeImageFrame(id);
63
+ if (!frame) return false;
64
+
65
+ const PAD = 10;
66
+ const scale = st.network.getScale();
67
+ const center = st.network.canvasToDOM({ x: frame.cx, y: frame.cy });
68
+ const width = frame.W * scale;
69
+ const height = frame.H * scale;
70
+ const ov = document.getElementById('selectionOverlay');
71
+ ov.style.display = 'block';
72
+ ov.style.left = center.x - width / 2 - PAD + 'px';
73
+ ov.style.top = center.y - height / 2 - PAD + 'px';
74
+ ov.style.width = width + PAD * 2 + 'px';
75
+ ov.style.height = height + PAD * 2 + 'px';
76
+ ov.style.transformOrigin = '50% 50%';
77
+ ov.style.transform = `rotate(${frame.rotation}rad)`;
78
+ syncOverlayHandles(width, height, PAD);
79
+ return true;
80
+ }
81
+
34
82
  // ── Overlay position ──────────────────────────────────────────────────────────
35
83
  export function updateSelectionOverlay() {
36
84
  if (!st.network || !st.selectedNodeIds.length) { hideSelectionOverlay(); return; }
37
85
  // Anchor-only selections (free arrow drag) have no meaningful bounding box.
38
- const hasNonAnchor = st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
39
- if (!hasNonAnchor) { hideSelectionOverlay(); return; }
86
+ const nonAnchorIds = st.selectedNodeIds.filter((id) => { const n = st.nodes && st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
87
+ if (!nonAnchorIds.length) { hideSelectionOverlay(); return; }
88
+
89
+ const ov = document.getElementById('selectionOverlay');
90
+ ov.style.transform = '';
91
+ ov.style.transformOrigin = '';
92
+
93
+ if (nonAnchorIds.length === 1 && updateSingleCustomShapeOverlay(nonAnchorIds[0])) {
94
+ const anyLocked = st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return n && n.locked; });
95
+ ['rh-tl','rh-tr','rh-bl','rh-br','rh-rotate','rh-label-rotate'].forEach((handleId) => {
96
+ document.getElementById(handleId).style.display = anyLocked ? 'none' : '';
97
+ });
98
+ return;
99
+ }
40
100
 
41
101
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
42
102
  for (const id of st.selectedNodeIds) {
@@ -52,7 +112,6 @@ export function updateSelectionOverlay() {
52
112
  const PAD = 10;
53
113
  const tl = st.network.canvasToDOM({ x: minX, y: minY });
54
114
  const br = st.network.canvasToDOM({ x: maxX, y: maxY });
55
- const ov = document.getElementById('selectionOverlay');
56
115
  ov.style.display = 'block';
57
116
  ov.style.left = tl.x - PAD + 'px';
58
117
  ov.style.top = tl.y - PAD + 'px';
@@ -65,15 +124,7 @@ export function updateSelectionOverlay() {
65
124
  document.getElementById(id).style.display = anyLocked ? 'none' : '';
66
125
  });
67
126
 
68
- // Position rotation handle: top-centre of the overlay, 28px above it.
69
- const rh = document.getElementById('rh-rotate');
70
- rh.style.left = (br.x - tl.x) / 2 + PAD - 8 + 'px';
71
- rh.style.top = '-28px';
72
-
73
- // Position label rotation handle: top-centre offset left by 24px to avoid overlap.
74
- const lrh = document.getElementById('rh-label-rotate');
75
- lrh.style.left = (br.x - tl.x) / 2 + PAD - 8 - 24 + 'px';
76
- lrh.style.top = '-28px';
127
+ syncOverlayHandles(br.x - tl.x, br.y - tl.y, PAD);
77
128
  }
78
129
 
79
130
  export function hideSelectionOverlay() {
@@ -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