living-documentation 7.43.0 → 7.45.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,7 +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
+ import { CUSTOM_SHAPE_TYPE, getCustomShapeDefinition, CUSTOM_SHAPE_DEFAULT_SIZE } from "./custom-shapes.js";
10
10
 
11
11
  // Returns the color object for a key, checking runtime overrides first.
12
12
  function getNodeColor(colorKey) {
@@ -187,13 +187,87 @@ function textLines(label) {
187
187
  return label ? String(label).split("\n") : [];
188
188
  }
189
189
 
190
- export function customShapeImageOffsetY(node, label) {
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) {
191
216
  const def = getCustomShapeDefinition(node && node.customShapeId);
192
- if (!def || def.labelPlacement !== "below" || !label) return 0;
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";
193
226
  const fontSize = (node && node.fontSize) || 13;
194
227
  const lines = textLines(label);
195
- if (!lines.length) return 0;
196
- return -(lines.length * fontSize * 1.3 + 8) / 2;
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;
197
271
  }
198
272
 
199
273
  // ── Shape renderers ───────────────────────────────────────────────────────────
@@ -671,8 +745,8 @@ export function makeCustomShapeRenderer(colorKey) {
671
745
  return function ({ ctx, x, y, id, state: visState, label }) {
672
746
  const n = st.nodes && st.nodes.get(id);
673
747
  const def = getCustomShapeDefinition(n && n.customShapeId);
674
- const defaultW = def && def.width || 96;
675
- const defaultH = def && def.height || 96;
748
+ const defaultW = (def && def.width) || CUSTOM_SHAPE_DEFAULT_SIZE;
749
+ const defaultH = (def && def.height) || CUSTOM_SHAPE_DEFAULT_SIZE;
676
750
  const {
677
751
  W,
678
752
  H,
@@ -684,11 +758,17 @@ export function makeCustomShapeRenderer(colorKey) {
684
758
  c,
685
759
  } = nodeData(id, defaultW, defaultH, colorKey || "c-gray");
686
760
  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;
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;
692
772
  return {
693
773
  drawNode() {
694
774
  ctx.save();
@@ -696,12 +776,12 @@ export function makeCustomShapeRenderer(colorKey) {
696
776
  ctx.rotate(rotation);
697
777
 
698
778
  if (img) {
699
- ctx.drawImage(img, -W / 2, imageOffsetY - H / 2, W, H);
779
+ ctx.drawImage(img, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H);
700
780
  } else {
701
781
  ctx.fillStyle = visState.selected ? c.hbg : c.bg;
702
782
  ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
703
783
  ctx.lineWidth = 1.5;
704
- roundRect(ctx, -W / 2, imageOffsetY - H / 2, W, H, 4);
784
+ roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
705
785
  ctx.fill();
706
786
  ctx.stroke();
707
787
  }
@@ -710,21 +790,32 @@ export function makeCustomShapeRenderer(colorKey) {
710
790
  ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
711
791
  ctx.lineWidth = visState.selected ? 2 : 1;
712
792
  ctx.setLineDash([4, 3]);
713
- roundRect(ctx, -W / 2, -totalH / 2, W, totalH, 4);
793
+ roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
714
794
  ctx.stroke();
715
795
  ctx.setLineDash([]);
716
796
  }
717
797
 
718
- if (labelBelow && lines.length) {
798
+ if (externalLabel) {
719
799
  ctx.save();
720
800
  if (labelRotation) ctx.rotate(labelRotation);
721
801
  ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
722
802
  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));
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
+ }
728
819
  ctx.restore();
729
820
  } else {
730
821
  drawLabel(
@@ -739,10 +830,13 @@ export function makeCustomShapeRenderer(colorKey) {
739
830
  labelRotation,
740
831
  );
741
832
  }
833
+ ctx.save();
834
+ ctx.translate(imageOffsetX, imageOffsetY);
742
835
  drawLinkIndicator(ctx, id, W, H);
743
836
  ctx.restore();
837
+ ctx.restore();
744
838
  },
745
- nodeDimensions: { width: W, height: totalH },
839
+ nodeDimensions: { width: totalW, height: totalH },
746
840
  };
747
841
  };
748
842
  }
@@ -811,7 +905,7 @@ export const SHAPE_DEFAULTS = {
811
905
  "post-it": [120, 100],
812
906
  "text-free": [80, 30],
813
907
  image: [160, 120],
814
- [CUSTOM_SHAPE_TYPE]: [96, 96],
908
+ [CUSTOM_SHAPE_TYPE]: [CUSTOM_SHAPE_DEFAULT_SIZE, CUSTOM_SHAPE_DEFAULT_SIZE],
815
909
  anchor: [8, 8],
816
910
  };
817
911
 
@@ -127,6 +127,7 @@ export async function saveDiagram() {
127
127
  id: n.id, label: n.label,
128
128
  shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
129
129
  customShapeId: n.customShapeId || null,
130
+ labelPlacement: n.labelPlacement || null,
130
131
  kind: n.kind || null, renderAs: n.renderAs || null,
131
132
  description: n.description || null,
132
133
  evidence: Array.isArray(n.evidence) ? n.evidence : null,
@@ -7,7 +7,7 @@
7
7
  // vis-network's native rendering unchanged.
8
8
 
9
9
  import { st } from './state.js';
10
- import { customShapeImageOffsetY, SHAPE_DEFAULTS } from './node-rendering.js';
10
+ import { customShapeLayout, SHAPE_DEFAULTS } from './node-rendering.js';
11
11
  import { CUSTOM_SHAPE_TYPE, getCustomShapeAnchors } from './custom-shapes.js';
12
12
 
13
13
  /**
@@ -84,8 +84,10 @@ function nodeGeometry(nodeId) {
84
84
  const W = n.nodeWidth || defaults[0];
85
85
  // 'circle' is always square (H = W); 'ellipse' uses its own height.
86
86
  const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
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 };
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 };
89
91
  }
90
92
 
91
93
  export function getPortKeys(nodeId) {
@@ -120,7 +122,7 @@ function controlNormalForEdge(nodeId, portKey) {
120
122
  export function getPortPosition(nodeId, portKey) {
121
123
  const geo = nodeGeometry(nodeId);
122
124
  if (!geo) return null;
123
- const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetY } = geo;
125
+ const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetX, imageOffsetY } = geo;
124
126
  const offsets = shapeType === CUSTOM_SHAPE_TYPE ? null
125
127
  : shapeType === 'database' ? PORT_OFFSETS_DATABASE
126
128
  : CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
@@ -128,7 +130,7 @@ export function getPortPosition(nodeId, portKey) {
128
130
  const [ox, oy] = shapeType === CUSTOM_SHAPE_TYPE
129
131
  ? customPortOffset(customShapeId, portKey) || [0, 0]
130
132
  : offsets[portKey] || [0, 0];
131
- let dx = ox * W / 2;
133
+ let dx = ox * W / 2 + imageOffsetX;
132
134
  let dy = oy * H / 2 + imageOffsetY;
133
135
  if (rotation) {
134
136
  const cos = Math.cos(rotation), sin = Math.sin(rotation);
@@ -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() {
@@ -85,6 +85,12 @@
85
85
  .dark .panel-sep {
86
86
  background: #374151;
87
87
  }
88
+ #customShapeLabelPlacementControls {
89
+ display: contents;
90
+ }
91
+ #customShapeLabelPlacementControls.hidden {
92
+ display: none;
93
+ }
88
94
  .edge-btn-active {
89
95
  background: #fff7ed !important;
90
96
  color: #ea580c !important;
@@ -942,6 +948,34 @@
942
948
  </svg>
943
949
  </button>
944
950
  <div class="panel-sep"></div>
951
+ <div id="customShapeLabelPlacementControls" class="hidden">
952
+ <button
953
+ class="tool-btn !w-7 !h-6 font-mono text-[10px]"
954
+ data-label-placement="above"
955
+ data-i18n-title="diagram.node_panel.custom_label_above"
956
+ >T↑</button>
957
+ <button
958
+ class="tool-btn !w-7 !h-6 font-mono text-[10px]"
959
+ data-label-placement="below"
960
+ data-i18n-title="diagram.node_panel.custom_label_below"
961
+ >T↓</button>
962
+ <button
963
+ class="tool-btn !w-7 !h-6 font-mono text-[10px]"
964
+ data-label-placement="left"
965
+ data-i18n-title="diagram.node_panel.custom_label_left"
966
+ >←T</button>
967
+ <button
968
+ class="tool-btn !w-7 !h-6 font-mono text-[10px]"
969
+ data-label-placement="right"
970
+ data-i18n-title="diagram.node_panel.custom_label_right"
971
+ >T→</button>
972
+ <button
973
+ class="tool-btn !w-7 !h-6 font-mono text-[10px]"
974
+ data-label-placement="center"
975
+ data-i18n-title="diagram.node_panel.custom_label_center"
976
+ >T□</button>
977
+ <div class="panel-sep"></div>
978
+ </div>
945
979
  <button
946
980
  id="btnZOrderBack"
947
981
  class="tool-btn !w-7 !h-6"
@@ -443,6 +443,11 @@
443
443
  "diagram.node_panel.align_top": "Align top",
444
444
  "diagram.node_panel.align_middle": "Align middle",
445
445
  "diagram.node_panel.align_bottom": "Align bottom",
446
+ "diagram.node_panel.custom_label_above": "Place custom shape label above this instance",
447
+ "diagram.node_panel.custom_label_below": "Place custom shape label below this instance",
448
+ "diagram.node_panel.custom_label_left": "Place custom shape label to the left of this instance",
449
+ "diagram.node_panel.custom_label_right": "Place custom shape label to the right of this instance",
450
+ "diagram.node_panel.custom_label_center": "Center custom shape label in this instance",
446
451
  "diagram.node_panel.send_back": "Send to back",
447
452
  "diagram.node_panel.bring_front": "Bring to front",
448
453
  "diagram.node_panel.stamp_color": "Color stamp — select targets, click here, then click source",
@@ -517,5 +522,13 @@
517
522
  "diagram.toast.confirm_delete": "Delete this diagram?",
518
523
  "diagram.toast.new_diagram_title": "New diagram",
519
524
  "diagram.toast.untitled": "Untitled",
520
- "diagram.toast.diagram_linked": "Diagram \"{title}\" created and linked"
525
+ "diagram.toast.diagram_linked": "Diagram \"{title}\" created and linked",
526
+
527
+ "shape_editor.show_in_diagram_label": "Show in diagram palette",
528
+ "shape_editor.show_in_diagram_hint": "When disabled, existing diagram nodes still render but this shape is hidden from the bottom palette.",
529
+ "shape_editor.label_placement.below": "Below the shape",
530
+ "shape_editor.label_placement.above": "Above the shape",
531
+ "shape_editor.label_placement.right": "To the right",
532
+ "shape_editor.label_placement.left": "To the left",
533
+ "shape_editor.label_placement.center": "Centered in the shape"
521
534
  }
@@ -443,6 +443,11 @@
443
443
  "diagram.node_panel.align_top": "Aligner en haut",
444
444
  "diagram.node_panel.align_middle": "Centrer verticalement",
445
445
  "diagram.node_panel.align_bottom": "Aligner en bas",
446
+ "diagram.node_panel.custom_label_above": "Placer le libellé de cette instance au-dessus",
447
+ "diagram.node_panel.custom_label_below": "Placer le libellé de cette instance dessous",
448
+ "diagram.node_panel.custom_label_left": "Placer le libellé de cette instance à gauche",
449
+ "diagram.node_panel.custom_label_right": "Placer le libellé de cette instance à droite",
450
+ "diagram.node_panel.custom_label_center": "Centrer le libellé dans cette instance",
446
451
  "diagram.node_panel.send_back": "Envoyer en arrière",
447
452
  "diagram.node_panel.bring_front": "Amener au premier plan",
448
453
  "diagram.node_panel.stamp_color": "Tampon couleur — sélectionner les cibles, cliquer ici, puis cliquer la source",
@@ -517,5 +522,13 @@
517
522
  "diagram.toast.confirm_delete": "Supprimer ce diagramme ?",
518
523
  "diagram.toast.new_diagram_title": "Nouveau diagramme",
519
524
  "diagram.toast.untitled": "Sans titre",
520
- "diagram.toast.diagram_linked": "Diagramme \"{title}\" créé et lié"
525
+ "diagram.toast.diagram_linked": "Diagramme \"{title}\" créé et lié",
526
+
527
+ "shape_editor.show_in_diagram_label": "Afficher dans la palette du diagramme",
528
+ "shape_editor.show_in_diagram_hint": "Si désactivé, les nœuds existants restent rendus mais cette forme est masquée dans la palette du bas.",
529
+ "shape_editor.label_placement.below": "Sous la forme",
530
+ "shape_editor.label_placement.above": "Au-dessus de la forme",
531
+ "shape_editor.label_placement.right": "À droite",
532
+ "shape_editor.label_placement.left": "À gauche",
533
+ "shape_editor.label_placement.center": "Centré dans la forme"
521
534
  }