@teachinglab/omd 0.2.6 → 0.2.8

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.
@@ -20,6 +20,7 @@ export class omdDisplay {
20
20
  autoScale: true, // Automatically scale content to fit container
21
21
  maxScale: 1, // Do not upscale beyond 1 by default
22
22
  edgePadding: 16, // Horizontal padding from edges when scaling
23
+ autoCloseStepVisualizer: true, // Close active step visualizer text boxes before autoscale to avoid shrink
23
24
  ...options
24
25
  };
25
26
 
@@ -41,6 +42,19 @@ export class omdDisplay {
41
42
  this.svg.svgObject.style.overflow = 'hidden';
42
43
  this.container.appendChild(this.svg.svgObject);
43
44
 
45
+ // Create a dedicated content group we can translate to compensate for
46
+ // viewBox origin changes (so expanding the origin doesn't visually move content).
47
+ try {
48
+ const ns = 'http://www.w3.org/2000/svg';
49
+ this._contentGroup = document.createElementNS(ns, 'g');
50
+ this._contentGroup.setAttribute('id', 'omd-content-root');
51
+ this.svg.svgObject.appendChild(this._contentGroup);
52
+ this._contentOffsetX = 0;
53
+ this._contentOffsetY = 0;
54
+ } catch (e) {
55
+ this._contentGroup = null;
56
+ }
57
+
44
58
  // Handle resize
45
59
  if (window.ResizeObserver) {
46
60
  this.resizeObserver = new ResizeObserver(() => {
@@ -61,6 +75,324 @@ export class omdDisplay {
61
75
 
62
76
  // Reposition overlay toolbar (if any) on resize
63
77
  this._repositionOverlayToolbar();
78
+ if (this.options.debugExtents) this._drawDebugOverlays();
79
+ }
80
+
81
+ /**
82
+ * Ensure the internal SVG viewBox is at least as large as the provided content dimensions.
83
+ * This prevents clipping when content is larger than the current viewBox.
84
+ * @param {number} contentWidth
85
+ * @param {number} contentHeight
86
+ */
87
+ _ensureViewboxFits(contentWidth, contentHeight) {
88
+ // If caller provided just width/height, but we prefer extents, bail early
89
+ if (!this.node) return;
90
+ const pad = 10;
91
+
92
+ // Prefer DOM measured extents (accounts for strokes, transforms, children SVG geometry)
93
+ let ext = null;
94
+ try {
95
+ const collected = this._collectNodeExtents(this.node);
96
+ if (collected) {
97
+ ext = { minX: collected.minX, minY: collected.minY, maxX: collected.maxX, maxY: collected.maxY };
98
+ }
99
+ } catch (e) {
100
+ ext = null;
101
+ }
102
+ if (!ext) {
103
+ ext = this._computeNodeExtents(this.node);
104
+ }
105
+ if (!ext) return;
106
+
107
+ const minX = Math.floor(ext.minX - pad);
108
+ const minY = Math.floor(ext.minY - pad);
109
+ const maxX = Math.ceil(ext.maxX + pad);
110
+ const maxY = Math.ceil(ext.maxY + pad);
111
+
112
+ const curView = this.svg.svgObject.getAttribute('viewBox') || '';
113
+ let curX = 0, curY = 0, curW = 0, curH = 0;
114
+ if (curView) {
115
+ const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
116
+ if (parts.length === 4) {
117
+ curX = parts[0];
118
+ curY = parts[1];
119
+ curW = parts[2];
120
+ curH = parts[3];
121
+ }
122
+ }
123
+
124
+ // To avoid shifting visible content, keep the current viewBox origin (curX,curY)
125
+ // and only expand width/height as needed. Changing the origin would change
126
+ // the mapping from SVG coordinates to screen coordinates and appear to move
127
+ // existing content.
128
+ const desiredX = curX;
129
+ const desiredY = curY;
130
+ const desiredRight = Math.max(curX + curW, maxX);
131
+ const desiredBottom = Math.max(curY + curH, maxY);
132
+ const desiredW = Math.max(curW, desiredRight - desiredX);
133
+ const desiredH = Math.max(curH, desiredBottom - desiredY);
134
+
135
+ if (desiredW !== curW || desiredH !== curH) {
136
+ this.svg.svgObject.setAttribute('viewBox', `${desiredX} ${desiredY} ${desiredW} ${desiredH}`);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Walk the node tree and compute absolute extents in SVG coordinates.
142
+ * Uses `xpos`/`ypos` and `width`/`height` properties; falls back to 0 when missing.
143
+ * @param {omdNode} root
144
+ * @returns {{minX:number,minY:number,maxX:number,maxY:number}}
145
+ */
146
+ _computeNodeExtents(root) {
147
+ if (!root) return null;
148
+ const visited = new Set();
149
+ const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0 }];
150
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
151
+
152
+ while (stack.length) {
153
+ const { node, absX, absY } = stack.pop();
154
+ if (!node || visited.has(node)) continue;
155
+ visited.add(node);
156
+
157
+ const w = node.width || 0;
158
+ const h = node.height || 0;
159
+ const nx = absX;
160
+ const ny = absY;
161
+ minX = Math.min(minX, nx);
162
+ minY = Math.min(minY, ny);
163
+ maxX = Math.max(maxX, nx + w);
164
+ maxY = Math.max(maxY, ny + h);
165
+
166
+ // push children
167
+ if (Array.isArray(node.childList)) {
168
+ for (const c of node.childList) {
169
+ if (!c) continue;
170
+ const cx = (c.xpos || 0) + nx;
171
+ const cy = (c.ypos || 0) + ny;
172
+ stack.push({ node: c, absX: cx, absY: cy });
173
+ }
174
+ }
175
+ if (node.argumentNodeList) {
176
+ for (const val of Object.values(node.argumentNodeList)) {
177
+ if (Array.isArray(val)) {
178
+ for (const v of val) {
179
+ if (!v) continue;
180
+ const vx = (v.xpos || 0) + nx;
181
+ const vy = (v.ypos || 0) + ny;
182
+ stack.push({ node: v, absX: vx, absY: vy });
183
+ }
184
+ } else if (val) {
185
+ const vx = (val.xpos || 0) + nx;
186
+ const vy = (val.ypos || 0) + ny;
187
+ stack.push({ node: val, absX: vx, absY: vy });
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ if (minX === Infinity) return null;
194
+ return { minX, minY, maxX, maxY };
195
+ }
196
+
197
+ /**
198
+ * Collect extents for each node and return per-node list plus overall extents.
199
+ * Useful for debugging elements that extend outside parent coordinates.
200
+ * @param {omdNode} root
201
+ * @returns {{nodes:Array, minX:number, minY:number, maxX:number, maxY:number}}
202
+ */
203
+ _collectNodeExtents(root) {
204
+ if (!root) return null;
205
+ const visited = new Set();
206
+ const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0, parent: null }];
207
+ const nodes = [];
208
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
209
+
210
+
211
+ while (stack.length) {
212
+ const { node, absX, absY, parent } = stack.pop();
213
+ if (!node || visited.has(node)) continue;
214
+ visited.add(node);
215
+
216
+ // Prefer DOM measurement if available for accuracy
217
+ let nx = absX;
218
+ let ny = absY;
219
+ let nminX = nx;
220
+ let nminY = ny;
221
+ let nmaxX = nx;
222
+ let nmaxY = ny;
223
+
224
+ try {
225
+ if (node.svgObject && typeof node.svgObject.getBBox === 'function' && typeof node.svgObject.getCTM === 'function') {
226
+ const bbox = node.svgObject.getBBox();
227
+ const ctm = node.svgObject.getCTM();
228
+ // Transform all four bbox corners into root SVG coordinates
229
+ const corners = [
230
+ { x: bbox.x, y: bbox.y },
231
+ { x: bbox.x + bbox.width, y: bbox.y },
232
+ { x: bbox.x, y: bbox.y + bbox.height },
233
+ { x: bbox.x + bbox.width, y: bbox.y + bbox.height }
234
+ ];
235
+ const tx = corners.map(p => ({
236
+ x: ctm.a * p.x + ctm.c * p.y + ctm.e,
237
+ y: ctm.b * p.x + ctm.d * p.y + ctm.f
238
+ }));
239
+ nminX = Math.min(...tx.map(t => t.x));
240
+ nminY = Math.min(...tx.map(t => t.y));
241
+ nmaxX = Math.max(...tx.map(t => t.x));
242
+ nmaxY = Math.max(...tx.map(t => t.y));
243
+ nx = nminX;
244
+ ny = nminY;
245
+ } else {
246
+ const w = node.width || 0;
247
+ const h = node.height || 0;
248
+ nx = absX;
249
+ ny = absY;
250
+ nminX = nx;
251
+ nminY = ny;
252
+ nmaxX = nx + w;
253
+ nmaxY = ny + h;
254
+ }
255
+ } catch (e) {
256
+ const w = node.width || 0;
257
+ const h = node.height || 0;
258
+ nx = absX;
259
+ ny = absY;
260
+ nminX = nx;
261
+ nminY = ny;
262
+ nmaxX = nx + w;
263
+ nmaxY = ny + h;
264
+ }
265
+
266
+ nodes.push({ node, minX: nminX, minY: nminY, maxX: nmaxX, maxY: nmaxY, parent });
267
+
268
+ minX = Math.min(minX, nminX);
269
+ minY = Math.min(minY, nminY);
270
+ maxX = Math.max(maxX, nmaxX);
271
+ maxY = Math.max(maxY, nmaxY);
272
+
273
+ // push children
274
+ if (Array.isArray(node.childList)) {
275
+ for (const c of node.childList) {
276
+ if (!c) continue;
277
+ const cx = (c.xpos || 0) + nx;
278
+ const cy = (c.ypos || 0) + ny;
279
+ stack.push({ node: c, absX: cx, absY: cy, parent: node });
280
+ }
281
+ }
282
+ if (node.argumentNodeList) {
283
+ for (const val of Object.values(node.argumentNodeList)) {
284
+ if (Array.isArray(val)) {
285
+ for (const v of val) {
286
+ if (!v) continue;
287
+ const vx = (v.xpos || 0) + nx;
288
+ const vy = (v.ypos || 0) + ny;
289
+ stack.push({ node: v, absX: vx, absY: vy, parent: node });
290
+ }
291
+ } else if (val) {
292
+ const vx = (val.xpos || 0) + nx;
293
+ const vy = (val.ypos || 0) + ny;
294
+ stack.push({ node: val, absX: vx, absY: vy, parent: node });
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ if (minX === Infinity) return null;
301
+ return { nodes, minX, minY, maxX, maxY };
302
+ }
303
+
304
+ _clearDebugOverlays() {
305
+ if (!this.svg || !this.svg.svgObject) return;
306
+ const existing = this.svg.svgObject.querySelector('#omd-debug-overlays');
307
+ if (existing) existing.remove();
308
+ }
309
+
310
+ _drawDebugOverlays() {
311
+ if (!this.options.debugExtents) return;
312
+ if (!this.svg || !this.svg.svgObject || !this.node) return;
313
+
314
+ this._clearDebugOverlays();
315
+
316
+ const ns = 'http://www.w3.org/2000/svg';
317
+ const group = document.createElementNS(ns, 'g');
318
+ group.setAttribute('id', 'omd-debug-overlays');
319
+ group.setAttribute('pointer-events', 'none');
320
+
321
+ // overall node extents
322
+ const collected = this._collectNodeExtents(this.node);
323
+ if (!collected) return;
324
+
325
+ const { nodes, minX, minY, maxX, maxY } = collected;
326
+
327
+ // Draw content extents (blue dashed)
328
+ const contentRect = document.createElementNS(ns, 'rect');
329
+ contentRect.setAttribute('x', String(minX));
330
+ contentRect.setAttribute('y', String(minY));
331
+ contentRect.setAttribute('width', String(maxX - minX));
332
+ contentRect.setAttribute('height', String(maxY - minY));
333
+ contentRect.setAttribute('fill', 'none');
334
+ contentRect.setAttribute('stroke', 'blue');
335
+ contentRect.setAttribute('stroke-dasharray', '6 4');
336
+ contentRect.setAttribute('stroke-width', '0.8');
337
+ group.appendChild(contentRect);
338
+
339
+ // Draw viewBox rect (orange)
340
+ const curView = this.svg.svgObject.getAttribute('viewBox') || '';
341
+ if (curView) {
342
+ const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
343
+ if (parts.length === 4) {
344
+ const [vx, vy, vw, vh] = parts;
345
+ const vbRect = document.createElementNS(ns, 'rect');
346
+ vbRect.setAttribute('x', String(vx));
347
+ vbRect.setAttribute('y', String(vy));
348
+ vbRect.setAttribute('width', String(vw));
349
+ vbRect.setAttribute('height', String(vh));
350
+ vbRect.setAttribute('fill', 'none');
351
+ vbRect.setAttribute('stroke', 'orange');
352
+ vbRect.setAttribute('stroke-width', '1');
353
+ vbRect.setAttribute('opacity', '0.9');
354
+ group.appendChild(vbRect);
355
+ }
356
+ }
357
+
358
+ // Per-node boxes: green if inside parent, red if overflowing parent bounds
359
+ const overflowing = [];
360
+ for (const item of nodes) {
361
+ const r = document.createElementNS(ns, 'rect');
362
+ r.setAttribute('x', String(item.minX));
363
+ r.setAttribute('y', String(item.minY));
364
+ r.setAttribute('width', String(Math.max(0, item.maxX - item.minX)));
365
+ r.setAttribute('height', String(Math.max(0, item.maxY - item.minY)));
366
+ r.setAttribute('fill', 'none');
367
+ r.setAttribute('stroke-width', '0.6');
368
+
369
+ let stroke = 'green';
370
+ if (item.parent) {
371
+ const pMinX = (item.parent.xpos || 0) + (item.parent._absX || 0);
372
+ const pMinY = (item.parent.ypos || 0) + (item.parent._absY || 0);
373
+ // fallback compute parent's absX/Y from nodes list if available
374
+ const parentEntry = nodes.find(n => n.node === item.parent);
375
+ const pminX = parentEntry ? parentEntry.minX : pMinX;
376
+ const pminY = parentEntry ? parentEntry.minY : pMinY;
377
+ const pmaxX = parentEntry ? parentEntry.maxX : pminX + (item.parent.width || 0);
378
+ const pmaxY = parentEntry ? parentEntry.maxY : pminY + (item.parent.height || 0);
379
+
380
+ if (item.minX < pminX || item.minY < pminY || item.maxX > pmaxX || item.maxY > pmaxY) {
381
+ stroke = 'red';
382
+ overflowing.push({ node: item.node, bounds: item });
383
+ }
384
+ }
385
+
386
+ r.setAttribute('stroke', stroke);
387
+ r.setAttribute('opacity', stroke === 'red' ? '0.9' : '0.6');
388
+ group.appendChild(r);
389
+ }
390
+
391
+ if (overflowing.length) {
392
+ console.warn('omdDisplay: debugExtents found overflowing nodes:', overflowing.map(o => ({ type: o.node?.type, bounds: o.bounds })));
393
+ }
394
+
395
+ this.svg.svgObject.appendChild(group);
64
396
  }
65
397
 
66
398
  centerNode() {
@@ -68,6 +400,19 @@ export class omdDisplay {
68
400
  const containerWidth = this.container.offsetWidth || 0;
69
401
  const containerHeight = this.container.offsetHeight || 0;
70
402
 
403
+ // Early auto-close of step visualizer UI before measuring dimensions to avoid transient height inflation
404
+ if (this.options.autoCloseStepVisualizer && this.node) {
405
+ try {
406
+ if (typeof this.node.forceCloseAll === 'function') {
407
+ this.node.forceCloseAll();
408
+ } else if (typeof this.node.closeAllTextBoxes === 'function') {
409
+ this.node.closeAllTextBoxes();
410
+ } else if (typeof this.node.closeActiveDot === 'function') {
411
+ this.node.closeActiveDot();
412
+ }
413
+ } catch (e) { /* no-op */ }
414
+ }
415
+
71
416
  // Determine actual content size (prefer sequence/current step when available)
72
417
  let contentWidth = this.node.width || 0;
73
418
  let contentHeight = this.node.height || 0;
@@ -75,38 +420,71 @@ export class omdDisplay {
75
420
  const seq = this.node.getSequence();
76
421
  if (seq) {
77
422
  if (seq.width && seq.height) {
78
- contentWidth = seq.width;
79
- contentHeight = seq.height;
423
+ // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
424
+ contentWidth = seq.sequenceWidth || seq.width;
425
+ contentHeight = seq.sequenceHeight || seq.height;
426
+ console.log('omdDisplay: Using sequence dimensions for autoscale - sequenceWidth:', seq.sequenceWidth, 'sequenceHeight:', seq.sequenceHeight, 'width:', seq.width, 'height:', seq.height, 'contentWidth:', contentWidth, 'contentHeight:', contentHeight);
80
427
  }
81
428
  if (seq.getCurrentStep) {
82
429
  const step = seq.getCurrentStep();
83
430
  if (step && step.width && step.height) {
84
- contentWidth = Math.max(contentWidth, step.width);
85
- contentHeight = Math.max(contentHeight, step.height);
431
+ // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
432
+ const stepWidth = seq.sequenceWidth || step.width;
433
+ const stepHeight = seq.sequenceHeight || step.height;
434
+ contentWidth = Math.max(contentWidth, stepWidth);
435
+ contentHeight = Math.max(contentHeight, stepHeight);
436
+ console.log('omdDisplay: Considering step dimensions - stepWidth:', stepWidth, 'stepHeight:', stepHeight, 'finalContentWidth:', contentWidth, 'finalContentHeight:', contentHeight);
86
437
  }
87
438
  }
88
439
  }
89
440
  }
441
+ console.log('omdDisplay: Final content dimensions for autoscale - width:', contentWidth, 'height:', contentHeight);
90
442
 
91
443
  // Compute scale to keep within bounds
92
444
  let scale = 1;
93
445
  if (this.options.autoScale && contentWidth > 0 && contentHeight > 0) {
94
- const hPad = this.options.edgePadding || 0;
95
- const vPadTop = this.options.topMargin || 0;
96
- const vPadBottom = this.options.bottomMargin || 0;
97
- // Reserve extra space for overlay toolbar if needed
98
- let reserveBottom = vPadBottom;
99
- if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) {
100
- const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0;
101
- reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16));
446
+ // Optionally close any open step visualizer textbox to prevent transient height expansion
447
+ if (this.options.autoCloseStepVisualizer && this.node) {
448
+ try {
449
+ if (typeof this.node.closeActiveDot === 'function') {
450
+ this.node.closeActiveDot();
451
+ } else if (typeof this.node.closeAllTextBoxes === 'function') {
452
+ this.node.closeAllTextBoxes();
453
+ }
454
+ } catch (e) { /* no-op */ }
455
+ }
456
+ // Detect step visualizer directly on node (getSequence returns underlying sequence only)
457
+ let hasStepVisualizer = false;
458
+ if (this.node) {
459
+ const ctorName = this.node.constructor?.name;
460
+ hasStepVisualizer = (ctorName === 'omdStepVisualizer') || this.node.type === 'omdStepVisualizer' || (typeof omdStepVisualizer !== 'undefined' && this.node instanceof omdStepVisualizer);
461
+ console.log('omdDisplay: Step visualizer detection (node) ctor:', ctorName, 'type:', this.node.type, 'detected:', hasStepVisualizer);
462
+ }
463
+
464
+ if (hasStepVisualizer) {
465
+ // Preserve existing scale if already set on node; otherwise lock to 1.
466
+ const existingScale = (this.node && typeof this.node.scale === 'number') ? this.node.scale : undefined;
467
+ scale = (existingScale && existingScale > 0) ? existingScale : 1;
468
+ console.log('omdDisplay: Step visualizer detected - locking scale at', scale);
469
+ } else {
470
+ const hPad = this.options.edgePadding || 0;
471
+ const vPadTop = this.options.topMargin || 0;
472
+ const vPadBottom = this.options.bottomMargin || 0;
473
+ // Reserve extra space for overlay toolbar if needed
474
+ let reserveBottom = vPadBottom;
475
+ if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) {
476
+ const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0;
477
+ reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16));
478
+ }
479
+ const availW = Math.max(0, containerWidth - hPad * 2);
480
+ const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom));
481
+ const sx = availW > 0 ? (availW / contentWidth) : 1;
482
+ const sy = availH > 0 ? (availH / contentHeight) : 1;
483
+ const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1;
484
+ scale = Math.min(sx, sy, maxScale);
485
+ if (!isFinite(scale) || scale <= 0) scale = 1;
486
+ console.log('omdDisplay: Autoscale calculation - availW:', availW, 'availH:', availH, 'contentW:', contentWidth, 'contentH:', contentHeight, 'scale:', scale);
102
487
  }
103
- const availW = Math.max(0, containerWidth - hPad * 2);
104
- const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom));
105
- const sx = availW > 0 ? (availW / contentWidth) : 1;
106
- const sy = availH > 0 ? (availH / contentHeight) : 1;
107
- const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1;
108
- scale = Math.min(sx, sy, maxScale);
109
- if (!isFinite(scale) || scale <= 0) scale = 1;
110
488
  }
111
489
 
112
490
  // Apply scale
@@ -120,24 +498,53 @@ export class omdDisplay {
120
498
  const screenCenterX = containerWidth / 2;
121
499
  x = screenCenterX - (this.node.alignPointX * scale);
122
500
  } else {
123
- const scaledWidth = (this.node.width || contentWidth) * scale;
501
+ const scaledWidth = contentWidth * scale;
124
502
  x = (containerWidth - scaledWidth) / 2;
125
503
  }
126
504
 
127
- // Y is top margin; scaled content will grow downward
128
- this.node.setPosition(x, this.options.topMargin);
505
+ // Decide whether positioning would move content outside container. If so,
506
+ // prefer expanding the SVG viewBox instead of moving nodes.
507
+ const scaledWidthFinal = contentWidth * scale;
508
+ const scaledHeightFinal = contentHeight * scale;
509
+ const totalNeededH = scaledHeightFinal + (this.options.topMargin || 0) + (this.options.bottomMargin || 0);
510
+
511
+ const willOverflowHoriz = scaledWidthFinal > containerWidth;
512
+ const willOverflowVert = totalNeededH > containerHeight;
513
+
514
+ if (willOverflowHoriz || willOverflowVert) {
515
+ // Set scale but do NOT reposition node (preserve its absolute positions).
516
+ if (this.node.setScale) this.node.setScale(scale);
517
+ // Expand viewBox to contain entire unscaled content so nothing is clipped.
518
+ this._ensureViewboxFits(contentWidth, contentHeight);
519
+ // Reposition overlay toolbar in case viewBox/container changed
520
+ this._repositionOverlayToolbar();
521
+
522
+ // If content still exceeds available height in the host, allow vertical scrolling
523
+ if (willOverflowVert) {
524
+ this.container.style.overflowY = 'auto';
525
+ this.container.style.overflowX = 'hidden';
526
+ } else {
527
+ this.container.style.overflow = 'hidden';
528
+ }
529
+ if (this.options.debugExtents) this._drawDebugOverlays();
530
+ } else {
531
+ // Y is top margin; scaled content will grow downward
532
+ this.node.setPosition(x, this.options.topMargin);
129
533
 
130
- // Reposition overlay toolbar (if any)
131
- this._repositionOverlayToolbar();
534
+ // Reposition overlay toolbar (if any)
535
+ this._repositionOverlayToolbar();
132
536
 
133
- // If content still exceeds available height (even after scaling), enable container scroll
134
- const totalNeededH = (contentHeight * scale) + (this.options.topMargin || 0) + (this.options.bottomMargin || 0);
135
- if (totalNeededH > containerHeight) {
136
- // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift
137
- this.container.style.overflowY = 'auto';
138
- this.container.style.overflowX = 'hidden';
139
- } else {
140
- this.container.style.overflow = 'hidden';
537
+ // Ensure viewBox can contain the (unscaled) content to avoid clipping in some hosts
538
+ this._ensureViewboxFits(contentWidth, contentHeight);
539
+
540
+ if (totalNeededH > containerHeight) {
541
+ // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift
542
+ this.container.style.overflowY = 'auto';
543
+ this.container.style.overflowX = 'hidden';
544
+ } else {
545
+ this.container.style.overflow = 'hidden';
546
+ }
547
+ if (this.options.debugExtents) this._drawDebugOverlays();
141
548
  }
142
549
  }
143
550
 
@@ -157,15 +564,17 @@ export class omdDisplay {
157
564
  if (this.node.getSequence) {
158
565
  const sequence = this.node.getSequence();
159
566
  if (sequence && sequence.width && sequence.height) {
160
- sequenceWidth = sequence.width;
161
- sequenceHeight = sequence.height;
567
+ // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
568
+ sequenceWidth = sequence.sequenceWidth || sequence.width;
569
+ sequenceHeight = sequence.sequenceHeight || sequence.height;
162
570
 
163
571
  // Check current step dimensions too
164
572
  if (sequence.getCurrentStep) {
165
573
  const currentStep = sequence.getCurrentStep();
166
574
  if (currentStep && currentStep.width && currentStep.height) {
167
- stepWidth = currentStep.width;
168
- stepHeight = currentStep.height;
575
+ // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
576
+ stepWidth = sequence.sequenceWidth || currentStep.width;
577
+ stepHeight = sequence.sequenceHeight || currentStep.height;
169
578
  }
170
579
  }
171
580
 
@@ -204,6 +613,8 @@ export class omdDisplay {
204
613
  // Update container
205
614
  this.container.style.width = `${newWidth}px`;
206
615
  this.container.style.height = `${newHeight}px`;
616
+ if (this.options.debugExtents) this._drawDebugOverlays();
617
+ else this._clearDebugOverlays();
207
618
  }
208
619
 
209
620
  /**
@@ -214,7 +625,11 @@ export class omdDisplay {
214
625
  render(expression) {
215
626
  // Clear previous node
216
627
  if (this.node) {
217
- this.svg.removeChild(this.node);
628
+ if (this._contentGroup && this.node && this.node.svgObject) {
629
+ this._contentGroup.removeChild(this.node.svgObject);
630
+ } else {
631
+ this.svg.removeChild(this.node);
632
+ }
218
633
  }
219
634
 
220
635
  // Create node from expression
@@ -223,18 +638,18 @@ export class omdDisplay {
223
638
  // Multiple equations
224
639
  const equationStrings = expression.split(';').filter(s => s.trim() !== '');
225
640
  const steps = equationStrings.map(str => omdEquationNode.fromString(str));
226
- this.node = new omdStepVisualizer(steps);
641
+ this.node = new omdStepVisualizer(steps, this.options.styling || {});
227
642
  } else {
228
643
  // Single expression or equation
229
644
  if (expression.includes('=')) {
230
645
  const firstStep = omdEquationNode.fromString(expression);
231
- this.node = new omdStepVisualizer([firstStep]);
646
+ this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
232
647
  } else {
233
648
  // Create node directly from expression
234
649
  const parsedAST = math.parse(expression);
235
650
  const NodeClass = getNodeForAST(parsedAST);
236
651
  const firstStep = new NodeClass(parsedAST);
237
- this.node = new omdStepVisualizer([firstStep]);
652
+ this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
238
653
  }
239
654
  }
240
655
  } else {
@@ -249,14 +664,16 @@ export class omdDisplay {
249
664
  // Apply filtering based on filterLevel
250
665
  sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === sequence.getFilterLevel());
251
666
  }
252
- this.svg.addChild(this.node);
667
+ // Prefer appending the node's svgObject into our content group so DOM measurements are consistent
668
+ if (this._contentGroup && this.node && this.node.svgObject) {
669
+ try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
670
+ } else {
671
+ this.svg.addChild(this.node);
672
+ }
253
673
 
254
674
  // Apply any stored font settings
255
675
  if (this.options.fontFamily) {
256
- // Small delay to ensure SVG elements are fully rendered
257
- setTimeout(() => {
258
676
  this.setFont(this.options.fontFamily, this.options.fontWeight || '400');
259
- }, 10);
260
677
  }
261
678
 
262
679
  // Only use fitToContent for tight sizing when explicitly requested
@@ -268,6 +685,14 @@ export class omdDisplay {
268
685
  // Ensure overlay toolbar is positioned initially
269
686
  this._repositionOverlayToolbar();
270
687
 
688
+ // Also ensure the viewBox is large enough to contain the node (avoid clipping)
689
+ const cw = (this.node && this.node.width) ? this.node.width : 0;
690
+ const ch = (this.node && this.node.height) ? this.node.height : 0;
691
+ this._ensureViewboxFits(cw, ch);
692
+
693
+ if (this.options.debugExtents) this._drawDebugOverlays();
694
+ else this._clearDebugOverlays();
695
+
271
696
  // Provide a default global refresh function if not present
272
697
  if (typeof window !== 'undefined' && !window.refreshDisplayAndFilters) {
273
698
  window.refreshDisplayAndFilters = () => {
@@ -282,7 +707,7 @@ export class omdDisplay {
282
707
  sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === 0);
283
708
  }
284
709
  if (typeof node.updateLayout === 'function') {
285
- node.updateLayout();
710
+ node.updateLayout();
286
711
  }
287
712
  }
288
713
  if (this.options.centerContent) {
@@ -303,7 +728,11 @@ export class omdDisplay {
303
728
  * @param {object} child - A jsvg node to add
304
729
  */
305
730
  addChild(child) {
306
- this.svg.addChild(child);
731
+ if (this._contentGroup && child && child.svgObject) {
732
+ try { this._contentGroup.appendChild(child.svgObject); } catch (e) { this.svg.addChild(child); }
733
+ } else {
734
+ this.svg.addChild(child);
735
+ }
307
736
 
308
737
  if (this.options.centerContent) this.centerNode();
309
738
 
@@ -317,10 +746,16 @@ export class omdDisplay {
317
746
  removeChild(child) {
318
747
  if (!this.svg) return;
319
748
  try {
320
- if (typeof this.svg.removeChild === 'function') {
749
+ if (child && child.svgObject) {
750
+ if (this._contentGroup && this._contentGroup.contains(child.svgObject)) {
751
+ this._contentGroup.removeChild(child.svgObject);
752
+ } else if (this.svg.svgObject && this.svg.svgObject.contains(child.svgObject)) {
753
+ this.svg.svgObject.removeChild(child.svgObject);
754
+ } else if (typeof this.svg.removeChild === 'function') {
755
+ this.svg.removeChild(child);
756
+ }
757
+ } else if (typeof this.svg.removeChild === 'function') {
321
758
  this.svg.removeChild(child);
322
- } else if (child && child.svgObject && this.svg.svgObject && this.svg.svgObject.contains(child.svgObject)) {
323
- this.svg.svgObject.removeChild(child.svgObject);
324
759
  }
325
760
  } catch (e) {
326
761
  // no-op
@@ -335,13 +770,21 @@ export class omdDisplay {
335
770
  */
336
771
  update(newNode) {
337
772
  if (this.node) {
338
- this.svg.removeChild(this.node);
773
+ if (this._contentGroup && this.node && this.node.svgObject && this._contentGroup.contains(this.node.svgObject)) {
774
+ this._contentGroup.removeChild(this.node.svgObject);
775
+ } else if (typeof this.svg.removeChild === 'function') {
776
+ this.svg.removeChild(this.node);
777
+ }
339
778
  }
340
779
 
341
780
  this.node = newNode;
342
781
  this.node.setFontSize(this.options.fontSize);
343
782
  this.node.initialize();
344
- this.svg.addChild(this.node);
783
+ if (this._contentGroup && this.node && this.node.svgObject) {
784
+ try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
785
+ } else {
786
+ this.svg.addChild(this.node);
787
+ }
345
788
 
346
789
  if (this.options.centerContent) {
347
790
  this.centerNode();
@@ -350,36 +793,7 @@ export class omdDisplay {
350
793
  this._repositionOverlayToolbar();
351
794
  }
352
795
 
353
- /**
354
- * Gets the current node
355
- * @returns {omdNode|null} The current node
356
- */
357
- getCurrentNode() {
358
- return this.node;
359
- }
360
-
361
- /**
362
- * Repositions overlay toolbar if current node supports it
363
- * @private
364
- */
365
- _repositionOverlayToolbar() {
366
- const rect = this.container.getBoundingClientRect();
367
- const paddingTop = parseFloat(getComputedStyle(this.container).paddingTop || '0');
368
- const paddingBottom = parseFloat(getComputedStyle(this.container).paddingBottom || '0');
369
- const paddingLeft = parseFloat(getComputedStyle(this.container).paddingLeft || '0');
370
- const paddingRight = parseFloat(getComputedStyle(this.container).paddingRight || '0');
371
- const containerWidth = (rect.width - paddingLeft - paddingRight) || this.container.clientWidth || 0;
372
- const containerHeight = (rect.height - paddingTop - paddingBottom) || this.container.clientHeight || 0;
373
- const node = this.node;
374
- if (!node) return;
375
- const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function';
376
- if (hasOverlayApi && node.isToolbarOverlay()) {
377
- node.positionToolbarOverlay(containerWidth, containerHeight, 16);
378
- }
379
- }
380
-
381
796
 
382
-
383
797
  /**
384
798
  * Sets the font size
385
799
  * @param {number} size - The font size