@teachinglab/omd 0.2.5 → 0.2.7

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.
@@ -1,522 +1,522 @@
1
- import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
2
- import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js';
3
- import { omdToolbar } from '../display/omdToolbar.js';
4
- import { jsvgGroup, jsvgLayoutGroup } from '@teachinglab/jsvg';
5
- import { omdEquationNode } from '../nodes/omdEquationNode.js';
6
- import { omdOperationDisplayNode } from '../nodes/omdOperationDisplayNode.js';
7
-
8
- /**
9
- * A renderable component that bundles a sequence and optional UI controls.
10
- * It acts as a node that can be rendered by an omdDisplay.
11
- * @extends jsvgGroup
12
- */
13
- export class omdEquationStack extends jsvgGroup {
14
- /**
15
- * @param {Array<omdNode>} [steps=[]] - An initial array of equation steps.
16
- * @param {Object} [options={}] - Configuration options.
17
- * @param {boolean} [options.toolbar=false] - If true, creates a toolbar-driven sequence.
18
- * @param {boolean} [options.stepVisualizer=false] - If true, creates a sequence with a step visualizer.
19
- */
20
- constructor(steps = [], options = {}) {
21
- super();
22
- this.options = { ...options };
23
-
24
- // Normalize new structured options
25
- this.toolbarOptions = null;
26
- if (typeof options.toolbar === 'object') {
27
- this.toolbarOptions = { enabled: true, ...options.toolbar };
28
- } else if (options.toolbar === true) {
29
- this.toolbarOptions = { enabled: true };
30
- } else if (options.toolbar === false) {
31
- this.toolbarOptions = { enabled: false };
32
- }
33
- this.stylingOptions = options.styling || null;
34
-
35
- // The sequence is the core. If a visualizer is needed, that's our sequence.
36
- if (options.stepVisualizer) {
37
- this.sequence = new omdStepVisualizer(steps);
38
- } else {
39
- this.sequence = new omdEquationSequenceNode(steps);
40
- }
41
-
42
- // Apply equation background styling if provided
43
- if (this.stylingOptions?.equationBackground) {
44
- this.sequence.setDefaultEquationBackground(this.stylingOptions.equationBackground);
45
- }
46
-
47
- // If a toolbar is needed, create it.
48
- if (this.toolbarOptions?.enabled) {
49
- // Default undo: call global hook if provided
50
- const toolbarOpts = { ...this.toolbarOptions };
51
- if (toolbarOpts.showUndoButton && !toolbarOpts.onUndo) {
52
- toolbarOpts.onUndo = () => {
53
- if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
54
- try { window.onOMDToolbarUndo(this.sequence); } catch (_) {}
55
- }
56
- };
57
- }
58
- this.toolbar = new omdToolbar(this, this.sequence, toolbarOpts);
59
- }
60
-
61
- // Overlay padding (distance from bottom when overlayed)
62
- this.overlayPadding = typeof this.toolbarOptions?.overlayPadding === 'number'
63
- ? this.toolbarOptions.overlayPadding
64
- : 34; // Default a bit above the very bottom to match buttons
65
-
66
- // Create a vertical layout group to hold the sequence and toolbar
67
- this.layoutGroup = new jsvgLayoutGroup();
68
- this.layoutGroup.setSpacer(16); // Adjust as needed for spacing
69
- this.layoutGroup.addChild(this.sequence);
70
- this._overlayChildren = [];
71
-
72
- // Handle toolbar positioning
73
- const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
74
- const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
75
-
76
- if (this.toolbar) {
77
- if (overlayBottom) {
78
- // For overlay positioning, add toolbar directly to this (not layoutGroup)
79
- this.addChild(this.toolbar.elements.toolbarGroup);
80
- } else {
81
- // For in-flow positioning, add to layout group
82
- this.layoutGroup.addChild(this.toolbar.elements.toolbarGroup);
83
- }
84
- }
85
-
86
- this.addChild(this.layoutGroup);
87
- this.updateLayout();
88
- }
89
-
90
- /**
91
- * Updates the layout and positioning of internal components.
92
- */
93
- updateLayout() {
94
- this.sequence.updateLayout();
95
- this.layoutGroup.doVerticalLayout();
96
-
97
- // Handle toolbar positioning based on overlay flag
98
- const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
99
- const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
100
-
101
- if (this.toolbar && !overlayBottom) {
102
- // Center the toolbar under the stack if in-flow and their widths differ
103
- const stackWidth = this.sequence.width;
104
- const toolbarWidth = this.toolbar.elements.background.width;
105
- const toolbarGroup = this.toolbar.elements.toolbarGroup;
106
- // Center toolbar horizontally under the stack
107
- toolbarGroup.setPosition(
108
- (stackWidth - toolbarWidth) / 2,
109
- toolbarGroup.ypos // y is handled by layout group
110
- );
111
- }
112
-
113
- this.width = this.layoutGroup.width;
114
- this.height = this.layoutGroup.height;
115
- }
116
-
117
- /**
118
- * Returns the underlying sequence instance.
119
- * @returns {omdEquationSequenceNode|omdStepVisualizer} The managed sequence instance.
120
- */
121
- getSequence() {
122
- return this.sequence;
123
- }
124
-
125
- /**
126
- * Expose overlay padding to the display so it can pass it during reposition
127
- */
128
- getOverlayPadding() {
129
- return this.overlayPadding;
130
- }
131
-
132
- /**
133
- * Returns the visual height in pixels of the toolbar background (unscaled), if present.
134
- * Useful for reserving space when overlaying the toolbar.
135
- */
136
- getToolbarVisualHeight() {
137
- if (this.toolbar && this.toolbar.elements && this.toolbar.elements.background) {
138
- return this.toolbar.elements.background.height || 0;
139
- }
140
- return 0;
141
- }
142
-
143
- /**
144
- * Whether the toolbar is configured to be overlayed at the bottom of the container
145
- * @returns {boolean}
146
- */
147
- isToolbarOverlay() {
148
- const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
149
- return !!(this.toolbar && (position === 'bottom' || position === 'overlay-bottom'));
150
- }
151
-
152
- /**
153
- * Positions the toolbar overlay at the bottom center of the container
154
- * @param {number} containerWidth - Width of the container
155
- * @param {number} containerHeight - Height of the container
156
- * @param {number} [padding=16] - Padding from the bottom edge
157
- */
158
- positionToolbarOverlay(containerWidth, containerHeight, padding = 16) {
159
- if (!this.toolbar || !this.isToolbarOverlay()) return;
160
-
161
- const toolbarGroup = this.toolbar.elements.toolbarGroup;
162
- const toolbarWidth = this.toolbar.elements.background.width;
163
- const toolbarHeight = this.toolbar.elements.background.height;
164
-
165
- // Position at bottom center of the DISPLAY (container) while this toolbar
166
- // lives inside the stack's local coordinate system, which may be scaled.
167
- // Convert container (global) coordinates to stack-local by subtracting
168
- // the stack's position and dividing by its scale.
169
- const stackX = this.xpos || 0;
170
- const stackY = this.ypos || 0;
171
- const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
172
- const effectivePadding = (typeof padding === 'number') ? padding : this.overlayPadding;
173
-
174
- // Compute top-left of toolbar in container coordinates using UN-SCALED toolbar size
175
- // because we counter-scale the toolbar by 1/s to keep constant on-screen size.
176
- let containerX = (containerWidth - toolbarWidth) / 2;
177
- let containerY = containerHeight - toolbarHeight - effectivePadding;
178
- // Snap to integer pixels to avoid subpixel jitter when scaling
179
- containerX = Math.round(containerX);
180
- containerY = Math.round(containerY);
181
-
182
- // Convert to stack-local coordinates
183
- const x = (containerX - stackX) / s;
184
- const y = (containerY - stackY) / s;
185
-
186
- // Find the root SVG to check its viewBox
187
- let rootSVG = toolbarGroup.svgObject;
188
- while (rootSVG && rootSVG.tagName !== 'svg' && rootSVG.parentElement) {
189
- rootSVG = rootSVG.parentElement;
190
- }
191
- const svgViewBox = rootSVG?.getAttribute?.('viewBox') || 'unknown';
192
-
193
-
194
- // Counter-scale the toolbar so it remains a constant on-screen size
195
- if (typeof toolbarGroup.setScale === 'function') {
196
- toolbarGroup.setScale(1 / s);
197
- }
198
- toolbarGroup.setPosition(x, y);
199
-
200
- // Ensure toolbar is visible and on top
201
- if (toolbarGroup.svgObject) {
202
- toolbarGroup.svgObject.style.display = 'block';
203
- toolbarGroup.svgObject.style.zIndex = '1000';
204
- }
205
-
206
- // Update any overlay children so they remain locked to container anchors
207
- try {
208
- this.updateOverlayChildren(containerWidth, containerHeight, padding);
209
- } catch (_) {}
210
- }
211
- /**
212
- * General helper to position an overlay child relative to a chosen anchor.
213
- * Does not assume only the toolbar; supports various anchors and options.
214
- * @param {object} child - A jsvg node (or any object with setPosition/setScale)
215
- * @param {number} containerWidth - Width of the container (px)
216
- * @param {number} containerHeight - Height of the container (px)
217
- * @param {object} [opts] - Options
218
- * @param {string} [opts.anchor='toolbar-center'] - One of: 'toolbar-center','toolbar-left','toolbar-right','top-left','top-center','top-right','custom'
219
- * @param {number} [opts.offsetX=0] - Horizontal offset in screen pixels (positive -> right)
220
- * @param {number} [opts.offsetY=0] - Vertical offset in screen pixels (positive -> down)
221
- * @param {number} [opts.padding=16] - Padding from edges when computing anchor
222
- * @param {boolean} [opts.counterScale=true] - Whether to counter-scale the child to keep constant on-screen size
223
- * @param {boolean} [opts.addToStack=true] - Whether to add the child to this stack's children (default true)
224
- * @param {{x:number,y:number}|null} [opts.customCoords=null] - If anchor==='custom', use these screen coords
225
- * @returns {object|null} The child or null if not applicable
226
- */
227
- addOverlayChild(child, containerWidth, containerHeight, opts = {}) {
228
- const {
229
- anchor = 'toolbar-center',
230
- offsetX = 0,
231
- offsetY = 0,
232
- padding = 16,
233
- counterScale = true,
234
- addToStack = true,
235
- customCoords = null
236
- } = opts || {};
237
-
238
- // Basic validation
239
- if (!child || typeof containerWidth !== 'number' || typeof containerHeight !== 'number') return null;
240
-
241
- const stackX = this.xpos || 0;
242
- const stackY = this.ypos || 0;
243
- const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
244
-
245
- // Determine base container (screen) coordinates for the anchor
246
- let containerX = 0;
247
- let containerY = 0;
248
-
249
- if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
250
- containerX = Math.round(customCoords.x);
251
- containerY = Math.round(customCoords.y);
252
- } else if (anchor.startsWith('toolbar')) {
253
- if (!this.toolbar) return null;
254
- const tbW = this.toolbar.elements?.background?.width || 0;
255
- const tbH = this.toolbar.elements?.background?.height || 0;
256
- const left = (containerWidth - tbW) / 2;
257
- const right = left + tbW;
258
- const center = left + (tbW / 2);
259
- containerY = Math.round(containerHeight - tbH - (typeof padding === 'number' ? padding : this.overlayPadding));
260
- if (anchor === 'toolbar-center') containerX = Math.round(center);
261
- else if (anchor === 'toolbar-left') containerX = Math.round(left);
262
- else if (anchor === 'toolbar-right') containerX = Math.round(right);
263
- else containerX = Math.round(center);
264
- } else if (anchor.startsWith('top')) {
265
- const topY = Math.round(typeof padding === 'number' ? padding : 16);
266
- const leftX = Math.round(typeof padding === 'number' ? padding : 16);
267
- const rightX = Math.round(containerWidth - (typeof padding === 'number' ? padding : 16));
268
- containerY = topY;
269
- if (anchor === 'top-left') containerX = leftX;
270
- else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2);
271
- else if (anchor === 'top-right') containerX = rightX;
272
- else containerX = leftX;
273
- } else {
274
- // fallback: center
275
- containerX = Math.round(containerWidth / 2);
276
- containerY = Math.round(containerHeight / 2);
277
- }
278
-
279
- // Apply offsets (in screen pixels)
280
- containerX = Math.round(containerX + (offsetX || 0));
281
- containerY = Math.round(containerY + (offsetY || 0));
282
-
283
- // Convert to stack-local coordinates
284
- const x = (containerX - stackX) / s;
285
- const y = (containerY - stackY) / s;
286
-
287
- // Optionally counter-scale child to keep constant on-screen size
288
- if (counterScale && child && typeof child.setScale === 'function') {
289
- try { child.setScale(1 / s); } catch (_) {}
290
- }
291
-
292
- // Position child in stack-local coords
293
- if (child && typeof child.setPosition === 'function') {
294
- try { child.setPosition(x, y); } catch (_) {}
295
- }
296
-
297
- // Optionally add to this stack
298
- if (addToStack) {
299
- try { this.addChild(child); } catch (_) {
300
- try { this.layoutGroup.addChild(child); } catch (_) {}
301
- }
302
- }
303
-
304
- // Remember the overlay child and its options so we can reposition it when
305
- // the stack's scale/position changes (e.g., during zoom/center operations).
306
- try {
307
- // Store a shallow copy of opts to avoid external mutation surprises
308
- const stored = { anchor, offsetX, offsetY, padding, counterScale, addToStack, customCoords };
309
- this._overlayChildren.push({ child, opts: stored });
310
- } catch (_) {}
311
-
312
- // Make sure it's visible and above toolbar
313
- if (child && child.svgObject) {
314
- try { child.svgObject.style.zIndex = '1001'; } catch (_) {}
315
- try { child.svgObject.style.display = 'block'; } catch (_) {}
316
- }
317
-
318
- return child;
319
- }
320
-
321
- /**
322
- * Recompute and apply positions for tracked overlay children.
323
- * Called automatically during `positionToolbarOverlay` and can be called
324
- * manually if you change container size/stack position outside normal flows.
325
- */
326
- updateOverlayChildren(containerWidth, containerHeight, padding = 16) {
327
- if (!Array.isArray(this._overlayChildren) || this._overlayChildren.length === 0) return;
328
-
329
- const stackX = this.xpos || 0;
330
- const stackY = this.ypos || 0;
331
- const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
332
-
333
- for (const entry of this._overlayChildren) {
334
- if (!entry || !entry.child) continue;
335
- const child = entry.child;
336
- const o = entry.opts || {};
337
- const anchor = o.anchor || 'toolbar-center';
338
- const offsetX = o.offsetX || 0;
339
- const offsetY = o.offsetY || 0;
340
- const pad = (typeof o.padding === 'number') ? o.padding : padding;
341
- const counterScale = (typeof o.counterScale === 'boolean') ? o.counterScale : true;
342
- const customCoords = o.customCoords || null;
343
-
344
- // Compute container anchor coords (duplicated logic from addOverlayChild)
345
- let containerX = 0;
346
- let containerY = 0;
347
- if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
348
- containerX = Math.round(customCoords.x + offsetX);
349
- containerY = Math.round(customCoords.y + offsetY);
350
- } else if (anchor.startsWith('toolbar')) {
351
- if (!this.toolbar) continue;
352
- const tbW = this.toolbar.elements?.background?.width || 0;
353
- const tbH = this.toolbar.elements?.background?.height || 0;
354
- const left = (containerWidth - tbW) / 2;
355
- const right = left + tbW;
356
- const center = left + (tbW / 2);
357
- containerY = Math.round(containerHeight - tbH - pad + offsetY);
358
- if (anchor === 'toolbar-center') containerX = Math.round(center + offsetX);
359
- else if (anchor === 'toolbar-left') containerX = Math.round(left + offsetX);
360
- else if (anchor === 'toolbar-right') containerX = Math.round(right + offsetX);
361
- else containerX = Math.round(center + offsetX);
362
- } else if (anchor.startsWith('top')) {
363
- const topY = Math.round(pad);
364
- const leftX = Math.round(pad);
365
- const rightX = Math.round(containerWidth - pad);
366
- containerY = topY + offsetY;
367
- if (anchor === 'top-left') containerX = leftX + offsetX;
368
- else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2) + offsetX;
369
- else if (anchor === 'top-right') containerX = rightX + offsetX;
370
- else containerX = leftX + offsetX;
371
- } else {
372
- containerX = Math.round(containerWidth / 2 + offsetX);
373
- containerY = Math.round(containerHeight / 2 + offsetY);
374
- }
375
-
376
- // Convert to stack-local coordinates
377
- const x = (containerX - stackX) / s;
378
- const y = (containerY - stackY) / s;
379
-
380
- // Apply counter-scaling and position
381
- if (counterScale && child && typeof child.setScale === 'function') {
382
- try { child.setScale(1 / s); } catch (_) {}
383
- }
384
- if (child && typeof child.setPosition === 'function') {
385
- try { child.setPosition(x, y); } catch (_) {}
386
- }
387
- }
388
- }
389
-
390
- /**
391
- * Remove a previously added overlay child (if present).
392
- */
393
- removeOverlayChild(child) {
394
- if (!child || !Array.isArray(this._overlayChildren)) return false;
395
- let idx = -1;
396
- for (let i = 0; i < this._overlayChildren.length; i++) {
397
- if (this._overlayChildren[i].child === child) { idx = i; break; }
398
- }
399
- if (idx === -1) return false;
400
- this._overlayChildren.splice(idx, 1);
401
- try { this.removeChild(child); } catch (_) {}
402
- return true;
403
- }
404
-
405
- /**
406
- * Returns the toolbar instance, if one was created.
407
- * @returns {omdToolbar|undefined}
408
- */
409
- getToolbar() {
410
- return this.toolbar;
411
- }
412
-
413
- /**
414
- * Undo the last operation (remove bottom-most equation and its preceding operation display)
415
- * Also updates a step visualizer if present.
416
- * @returns {boolean} Whether an operation was undone
417
- */
418
- undoLastOperation() {
419
- const seq = this.sequence;
420
- if (!seq || !Array.isArray(seq.steps) || seq.steps.length === 0) return false;
421
-
422
- // Find bottom-most equation
423
- let eqIndex = -1;
424
- for (let i = seq.steps.length - 1; i >= 0; i--) {
425
- const st = seq.steps[i];
426
- const name = st?.constructor?.name;
427
- if (st instanceof omdEquationNode || name === 'omdEquationNode') { eqIndex = i; break; }
428
- }
429
- if (eqIndex === -1) return false;
430
-
431
- // Find nearest preceding operation display (if any)
432
- let startIndex = eqIndex;
433
- for (let i = eqIndex; i >= 0; i--) {
434
- const st = seq.steps[i];
435
- const name = st?.constructor?.name;
436
- if (st instanceof omdOperationDisplayNode || name === 'omdOperationDisplayNode') { startIndex = i; break; }
437
- }
438
- // Remove DOM children and steps from startIndex to end
439
- for (let i = seq.steps.length - 1; i >= startIndex; i--) {
440
- const step = seq.steps[i];
441
- try { seq.removeChild(step); } catch (_) {}
442
- }
443
- seq.steps.splice(startIndex);
444
- seq.argumentNodeList.steps = seq.steps;
445
- if (Array.isArray(seq.stepDescriptions)) seq.stepDescriptions.length = seq.steps.length;
446
- if (Array.isArray(seq.importanceLevels)) seq.importanceLevels.length = seq.steps.length;
447
-
448
- // Adjust current index
449
- if (typeof seq.currentStepIndex === 'number' && seq.currentStepIndex >= seq.steps.length) {
450
- seq.currentStepIndex = Math.max(0, seq.steps.length - 1);
451
- }
452
-
453
- // Rebuild maps and layout on sequence
454
- if (typeof seq.rebuildNodeMap === 'function') seq.rebuildNodeMap();
455
- if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
456
- if (typeof seq.updateLayout === 'function') seq.updateLayout();
457
-
458
- // If this is a step visualizer, rebuild its dots/lines
459
- if (typeof seq.rebuildVisualizer === 'function') {
460
- try {
461
- seq.rebuildVisualizer();
462
- } catch (_) {}
463
- } else if (typeof seq._initializeVisualElements === 'function') {
464
- try {
465
- seq._initializeVisualElements();
466
- if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
467
- if (typeof seq.updateLayout === 'function') seq.updateLayout();
468
- } catch (_) {}
469
- }
470
-
471
- // Safety: ensure dot/line counts match equations and prune orphan dots
472
- try {
473
- const isEquation = (s) => (s instanceof omdEquationNode) || (s?.constructor?.name === 'omdEquationNode');
474
- const equationsCount = Array.isArray(seq.steps) ? seq.steps.filter(isEquation).length : 0;
475
-
476
- // Remove dots whose equationRef is no longer present in steps
477
- if (Array.isArray(seq.stepDots) && seq.visualContainer) {
478
- const eqSet = new Set(seq.steps.filter(isEquation));
479
- const keptDots = [];
480
- for (const dot of seq.stepDots) {
481
- if (!dot || !dot.equationRef || !eqSet.has(dot.equationRef)) {
482
- try { seq.visualContainer.removeChild(dot); } catch (_) {}
483
- } else {
484
- keptDots.push(dot);
485
- }
486
- }
487
- seq.stepDots = keptDots;
488
- }
489
- // Also purge any children in visualContainer that are not current dots or lines
490
- if (seq.visualContainer && Array.isArray(seq.visualContainer.childList)) {
491
- const valid = new Set([...(seq.stepDots||[]), ...(seq.stepLines||[])]);
492
- const toRemove = [];
493
- seq.visualContainer.childList.forEach(child => { if (!valid.has(child)) toRemove.push(child); });
494
- toRemove.forEach(child => { try { seq.visualContainer.removeChild(child); } catch (_) {} });
495
- }
496
- if (Array.isArray(seq.stepDots) && seq.visualContainer) {
497
- while (seq.stepDots.length > equationsCount) {
498
- const dot = seq.stepDots.pop();
499
- try { seq.visualContainer.removeChild(dot); } catch (_) {}
500
- }
501
- }
502
- if (Array.isArray(seq.stepLines) && seq.visualContainer) {
503
- const targetLines = Math.max(0, equationsCount - 1);
504
- while (seq.stepLines.length > targetLines) {
505
- const line = seq.stepLines.pop();
506
- try { seq.visualContainer.removeChild(line); } catch (_) {}
507
- }
508
- }
509
- if (seq.layoutManager) {
510
- try {
511
- seq.layoutManager.updateVisualLayout();
512
- seq.layoutManager.updateVisualVisibility();
513
- seq.layoutManager.updateAllLinePositions();
514
- } catch (_) {}
515
- }
516
- } catch (_) {}
517
-
518
- // Refresh stack layout
519
- this.updateLayout();
520
- return true;
521
- }
1
+ import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
2
+ import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js';
3
+ import { omdToolbar } from '../display/omdToolbar.js';
4
+ import { jsvgGroup, jsvgLayoutGroup } from '@teachinglab/jsvg';
5
+ import { omdEquationNode } from '../nodes/omdEquationNode.js';
6
+ import { omdOperationDisplayNode } from '../nodes/omdOperationDisplayNode.js';
7
+
8
+ /**
9
+ * A renderable component that bundles a sequence and optional UI controls.
10
+ * It acts as a node that can be rendered by an omdDisplay.
11
+ * @extends jsvgGroup
12
+ */
13
+ export class omdEquationStack extends jsvgGroup {
14
+ /**
15
+ * @param {Array<omdNode>} [steps=[]] - An initial array of equation steps.
16
+ * @param {Object} [options={}] - Configuration options.
17
+ * @param {boolean} [options.toolbar=false] - If true, creates a toolbar-driven sequence.
18
+ * @param {boolean} [options.stepVisualizer=false] - If true, creates a sequence with a step visualizer.
19
+ */
20
+ constructor(steps = [], options = {}) {
21
+ super();
22
+ this.options = { ...options };
23
+
24
+ // Normalize new structured options
25
+ this.toolbarOptions = null;
26
+ if (typeof options.toolbar === 'object') {
27
+ this.toolbarOptions = { enabled: true, ...options.toolbar };
28
+ } else if (options.toolbar === true) {
29
+ this.toolbarOptions = { enabled: true };
30
+ } else if (options.toolbar === false) {
31
+ this.toolbarOptions = { enabled: false };
32
+ }
33
+ this.stylingOptions = options.styling || null;
34
+
35
+ // The sequence is the core. If a visualizer is needed, that's our sequence.
36
+ if (options.stepVisualizer) {
37
+ this.sequence = new omdStepVisualizer(steps, this.stylingOptions || {});
38
+ } else {
39
+ this.sequence = new omdEquationSequenceNode(steps);
40
+ }
41
+
42
+ // Apply equation background styling if provided
43
+ if (this.stylingOptions?.equationBackground) {
44
+ this.sequence.setDefaultEquationBackground(this.stylingOptions.equationBackground);
45
+ }
46
+
47
+ // If a toolbar is needed, create it.
48
+ if (this.toolbarOptions?.enabled) {
49
+ // Default undo: call global hook if provided
50
+ const toolbarOpts = { ...this.toolbarOptions };
51
+ if (toolbarOpts.showUndoButton && !toolbarOpts.onUndo) {
52
+ toolbarOpts.onUndo = () => {
53
+ if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
54
+ try { window.onOMDToolbarUndo(this.sequence); } catch (_) {}
55
+ }
56
+ };
57
+ }
58
+ this.toolbar = new omdToolbar(this, this.sequence, toolbarOpts);
59
+ }
60
+
61
+ // Overlay padding (distance from bottom when overlayed)
62
+ this.overlayPadding = typeof this.toolbarOptions?.overlayPadding === 'number'
63
+ ? this.toolbarOptions.overlayPadding
64
+ : 34; // Default a bit above the very bottom to match buttons
65
+
66
+ // Create a vertical layout group to hold the sequence and toolbar
67
+ this.layoutGroup = new jsvgLayoutGroup();
68
+ this.layoutGroup.setSpacer(16); // Adjust as needed for spacing
69
+ this.layoutGroup.addChild(this.sequence);
70
+ this._overlayChildren = [];
71
+
72
+ // Handle toolbar positioning
73
+ const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
74
+ const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
75
+
76
+ if (this.toolbar) {
77
+ if (overlayBottom) {
78
+ // For overlay positioning, add toolbar directly to this (not layoutGroup)
79
+ this.addChild(this.toolbar.elements.toolbarGroup);
80
+ } else {
81
+ // For in-flow positioning, add to layout group
82
+ this.layoutGroup.addChild(this.toolbar.elements.toolbarGroup);
83
+ }
84
+ }
85
+
86
+ this.addChild(this.layoutGroup);
87
+ this.updateLayout();
88
+ }
89
+
90
+ /**
91
+ * Updates the layout and positioning of internal components.
92
+ */
93
+ updateLayout() {
94
+ this.sequence.updateLayout();
95
+ this.layoutGroup.doVerticalLayout();
96
+
97
+ // Handle toolbar positioning based on overlay flag
98
+ const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
99
+ const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
100
+
101
+ if (this.toolbar && !overlayBottom) {
102
+ // Center the toolbar under the stack if in-flow and their widths differ
103
+ const stackWidth = this.sequence.width;
104
+ const toolbarWidth = this.toolbar.elements.background.width;
105
+ const toolbarGroup = this.toolbar.elements.toolbarGroup;
106
+ // Center toolbar horizontally under the stack
107
+ toolbarGroup.setPosition(
108
+ (stackWidth - toolbarWidth) / 2,
109
+ toolbarGroup.ypos // y is handled by layout group
110
+ );
111
+ }
112
+
113
+ this.width = this.layoutGroup.width;
114
+ this.height = this.layoutGroup.height;
115
+ }
116
+
117
+ /**
118
+ * Returns the underlying sequence instance.
119
+ * @returns {omdEquationSequenceNode|omdStepVisualizer} The managed sequence instance.
120
+ */
121
+ getSequence() {
122
+ return this.sequence;
123
+ }
124
+
125
+ /**
126
+ * Expose overlay padding to the display so it can pass it during reposition
127
+ */
128
+ getOverlayPadding() {
129
+ return this.overlayPadding;
130
+ }
131
+
132
+ /**
133
+ * Returns the visual height in pixels of the toolbar background (unscaled), if present.
134
+ * Useful for reserving space when overlaying the toolbar.
135
+ */
136
+ getToolbarVisualHeight() {
137
+ if (this.toolbar && this.toolbar.elements && this.toolbar.elements.background) {
138
+ return this.toolbar.elements.background.height || 0;
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ /**
144
+ * Whether the toolbar is configured to be overlayed at the bottom of the container
145
+ * @returns {boolean}
146
+ */
147
+ isToolbarOverlay() {
148
+ const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
149
+ return !!(this.toolbar && (position === 'bottom' || position === 'overlay-bottom'));
150
+ }
151
+
152
+ /**
153
+ * Positions the toolbar overlay at the bottom center of the container
154
+ * @param {number} containerWidth - Width of the container
155
+ * @param {number} containerHeight - Height of the container
156
+ * @param {number} [padding=16] - Padding from the bottom edge
157
+ */
158
+ positionToolbarOverlay(containerWidth, containerHeight, padding = 16) {
159
+ if (!this.toolbar || !this.isToolbarOverlay()) return;
160
+
161
+ const toolbarGroup = this.toolbar.elements.toolbarGroup;
162
+ const toolbarWidth = this.toolbar.elements.background.width;
163
+ const toolbarHeight = this.toolbar.elements.background.height;
164
+
165
+ // Position at bottom center of the DISPLAY (container) while this toolbar
166
+ // lives inside the stack's local coordinate system, which may be scaled.
167
+ // Convert container (global) coordinates to stack-local by subtracting
168
+ // the stack's position and dividing by its scale.
169
+ const stackX = this.xpos || 0;
170
+ const stackY = this.ypos || 0;
171
+ const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
172
+ const effectivePadding = (typeof padding === 'number') ? padding : this.overlayPadding;
173
+
174
+ // Compute top-left of toolbar in container coordinates using UN-SCALED toolbar size
175
+ // because we counter-scale the toolbar by 1/s to keep constant on-screen size.
176
+ let containerX = (containerWidth - toolbarWidth) / 2;
177
+ let containerY = containerHeight - toolbarHeight - effectivePadding;
178
+ // Snap to integer pixels to avoid subpixel jitter when scaling
179
+ containerX = Math.round(containerX);
180
+ containerY = Math.round(containerY);
181
+
182
+ // Convert to stack-local coordinates
183
+ const x = (containerX - stackX) / s;
184
+ const y = (containerY - stackY) / s;
185
+
186
+ // Find the root SVG to check its viewBox
187
+ let rootSVG = toolbarGroup.svgObject;
188
+ while (rootSVG && rootSVG.tagName !== 'svg' && rootSVG.parentElement) {
189
+ rootSVG = rootSVG.parentElement;
190
+ }
191
+ const svgViewBox = rootSVG?.getAttribute?.('viewBox') || 'unknown';
192
+
193
+
194
+ // Counter-scale the toolbar so it remains a constant on-screen size
195
+ if (typeof toolbarGroup.setScale === 'function') {
196
+ toolbarGroup.setScale(1 / s);
197
+ }
198
+ toolbarGroup.setPosition(x, y);
199
+
200
+ // Ensure toolbar is visible and on top
201
+ if (toolbarGroup.svgObject) {
202
+ toolbarGroup.svgObject.style.display = 'block';
203
+ toolbarGroup.svgObject.style.zIndex = '1000';
204
+ }
205
+
206
+ // Update any overlay children so they remain locked to container anchors
207
+ try {
208
+ this.updateOverlayChildren(containerWidth, containerHeight, padding);
209
+ } catch (_) {}
210
+ }
211
+ /**
212
+ * General helper to position an overlay child relative to a chosen anchor.
213
+ * Does not assume only the toolbar; supports various anchors and options.
214
+ * @param {object} child - A jsvg node (or any object with setPosition/setScale)
215
+ * @param {number} containerWidth - Width of the container (px)
216
+ * @param {number} containerHeight - Height of the container (px)
217
+ * @param {object} [opts] - Options
218
+ * @param {string} [opts.anchor='toolbar-center'] - One of: 'toolbar-center','toolbar-left','toolbar-right','top-left','top-center','top-right','custom'
219
+ * @param {number} [opts.offsetX=0] - Horizontal offset in screen pixels (positive -> right)
220
+ * @param {number} [opts.offsetY=0] - Vertical offset in screen pixels (positive -> down)
221
+ * @param {number} [opts.padding=16] - Padding from edges when computing anchor
222
+ * @param {boolean} [opts.counterScale=true] - Whether to counter-scale the child to keep constant on-screen size
223
+ * @param {boolean} [opts.addToStack=true] - Whether to add the child to this stack's children (default true)
224
+ * @param {{x:number,y:number}|null} [opts.customCoords=null] - If anchor==='custom', use these screen coords
225
+ * @returns {object|null} The child or null if not applicable
226
+ */
227
+ addOverlayChild(child, containerWidth, containerHeight, opts = {}) {
228
+ const {
229
+ anchor = 'toolbar-center',
230
+ offsetX = 0,
231
+ offsetY = 0,
232
+ padding = 16,
233
+ counterScale = true,
234
+ addToStack = true,
235
+ customCoords = null
236
+ } = opts || {};
237
+
238
+ // Basic validation
239
+ if (!child || typeof containerWidth !== 'number' || typeof containerHeight !== 'number') return null;
240
+
241
+ const stackX = this.xpos || 0;
242
+ const stackY = this.ypos || 0;
243
+ const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
244
+
245
+ // Determine base container (screen) coordinates for the anchor
246
+ let containerX = 0;
247
+ let containerY = 0;
248
+
249
+ if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
250
+ containerX = Math.round(customCoords.x);
251
+ containerY = Math.round(customCoords.y);
252
+ } else if (anchor.startsWith('toolbar')) {
253
+ if (!this.toolbar) return null;
254
+ const tbW = this.toolbar.elements?.background?.width || 0;
255
+ const tbH = this.toolbar.elements?.background?.height || 0;
256
+ const left = (containerWidth - tbW) / 2;
257
+ const right = left + tbW;
258
+ const center = left + (tbW / 2);
259
+ containerY = Math.round(containerHeight - tbH - (typeof padding === 'number' ? padding : this.overlayPadding));
260
+ if (anchor === 'toolbar-center') containerX = Math.round(center);
261
+ else if (anchor === 'toolbar-left') containerX = Math.round(left);
262
+ else if (anchor === 'toolbar-right') containerX = Math.round(right);
263
+ else containerX = Math.round(center);
264
+ } else if (anchor.startsWith('top')) {
265
+ const topY = Math.round(typeof padding === 'number' ? padding : 16);
266
+ const leftX = Math.round(typeof padding === 'number' ? padding : 16);
267
+ const rightX = Math.round(containerWidth - (typeof padding === 'number' ? padding : 16));
268
+ containerY = topY;
269
+ if (anchor === 'top-left') containerX = leftX;
270
+ else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2);
271
+ else if (anchor === 'top-right') containerX = rightX;
272
+ else containerX = leftX;
273
+ } else {
274
+ // fallback: center
275
+ containerX = Math.round(containerWidth / 2);
276
+ containerY = Math.round(containerHeight / 2);
277
+ }
278
+
279
+ // Apply offsets (in screen pixels)
280
+ containerX = Math.round(containerX + (offsetX || 0));
281
+ containerY = Math.round(containerY + (offsetY || 0));
282
+
283
+ // Convert to stack-local coordinates
284
+ const x = (containerX - stackX) / s;
285
+ const y = (containerY - stackY) / s;
286
+
287
+ // Optionally counter-scale child to keep constant on-screen size
288
+ if (counterScale && child && typeof child.setScale === 'function') {
289
+ try { child.setScale(1 / s); } catch (_) {}
290
+ }
291
+
292
+ // Position child in stack-local coords
293
+ if (child && typeof child.setPosition === 'function') {
294
+ try { child.setPosition(x, y); } catch (_) {}
295
+ }
296
+
297
+ // Optionally add to this stack
298
+ if (addToStack) {
299
+ try { this.addChild(child); } catch (_) {
300
+ try { this.layoutGroup.addChild(child); } catch (_) {}
301
+ }
302
+ }
303
+
304
+ // Remember the overlay child and its options so we can reposition it when
305
+ // the stack's scale/position changes (e.g., during zoom/center operations).
306
+ try {
307
+ // Store a shallow copy of opts to avoid external mutation surprises
308
+ const stored = { anchor, offsetX, offsetY, padding, counterScale, addToStack, customCoords };
309
+ this._overlayChildren.push({ child, opts: stored });
310
+ } catch (_) {}
311
+
312
+ // Make sure it's visible and above toolbar
313
+ if (child && child.svgObject) {
314
+ try { child.svgObject.style.zIndex = '1001'; } catch (_) {}
315
+ try { child.svgObject.style.display = 'block'; } catch (_) {}
316
+ }
317
+
318
+ return child;
319
+ }
320
+
321
+ /**
322
+ * Recompute and apply positions for tracked overlay children.
323
+ * Called automatically during `positionToolbarOverlay` and can be called
324
+ * manually if you change container size/stack position outside normal flows.
325
+ */
326
+ updateOverlayChildren(containerWidth, containerHeight, padding = 16) {
327
+ if (!Array.isArray(this._overlayChildren) || this._overlayChildren.length === 0) return;
328
+
329
+ const stackX = this.xpos || 0;
330
+ const stackY = this.ypos || 0;
331
+ const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
332
+
333
+ for (const entry of this._overlayChildren) {
334
+ if (!entry || !entry.child) continue;
335
+ const child = entry.child;
336
+ const o = entry.opts || {};
337
+ const anchor = o.anchor || 'toolbar-center';
338
+ const offsetX = o.offsetX || 0;
339
+ const offsetY = o.offsetY || 0;
340
+ const pad = (typeof o.padding === 'number') ? o.padding : padding;
341
+ const counterScale = (typeof o.counterScale === 'boolean') ? o.counterScale : true;
342
+ const customCoords = o.customCoords || null;
343
+
344
+ // Compute container anchor coords (duplicated logic from addOverlayChild)
345
+ let containerX = 0;
346
+ let containerY = 0;
347
+ if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
348
+ containerX = Math.round(customCoords.x + offsetX);
349
+ containerY = Math.round(customCoords.y + offsetY);
350
+ } else if (anchor.startsWith('toolbar')) {
351
+ if (!this.toolbar) continue;
352
+ const tbW = this.toolbar.elements?.background?.width || 0;
353
+ const tbH = this.toolbar.elements?.background?.height || 0;
354
+ const left = (containerWidth - tbW) / 2;
355
+ const right = left + tbW;
356
+ const center = left + (tbW / 2);
357
+ containerY = Math.round(containerHeight - tbH - pad + offsetY);
358
+ if (anchor === 'toolbar-center') containerX = Math.round(center + offsetX);
359
+ else if (anchor === 'toolbar-left') containerX = Math.round(left + offsetX);
360
+ else if (anchor === 'toolbar-right') containerX = Math.round(right + offsetX);
361
+ else containerX = Math.round(center + offsetX);
362
+ } else if (anchor.startsWith('top')) {
363
+ const topY = Math.round(pad);
364
+ const leftX = Math.round(pad);
365
+ const rightX = Math.round(containerWidth - pad);
366
+ containerY = topY + offsetY;
367
+ if (anchor === 'top-left') containerX = leftX + offsetX;
368
+ else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2) + offsetX;
369
+ else if (anchor === 'top-right') containerX = rightX + offsetX;
370
+ else containerX = leftX + offsetX;
371
+ } else {
372
+ containerX = Math.round(containerWidth / 2 + offsetX);
373
+ containerY = Math.round(containerHeight / 2 + offsetY);
374
+ }
375
+
376
+ // Convert to stack-local coordinates
377
+ const x = (containerX - stackX) / s;
378
+ const y = (containerY - stackY) / s;
379
+
380
+ // Apply counter-scaling and position
381
+ if (counterScale && child && typeof child.setScale === 'function') {
382
+ try { child.setScale(1 / s); } catch (_) {}
383
+ }
384
+ if (child && typeof child.setPosition === 'function') {
385
+ try { child.setPosition(x, y); } catch (_) {}
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Remove a previously added overlay child (if present).
392
+ */
393
+ removeOverlayChild(child) {
394
+ if (!child || !Array.isArray(this._overlayChildren)) return false;
395
+ let idx = -1;
396
+ for (let i = 0; i < this._overlayChildren.length; i++) {
397
+ if (this._overlayChildren[i].child === child) { idx = i; break; }
398
+ }
399
+ if (idx === -1) return false;
400
+ this._overlayChildren.splice(idx, 1);
401
+ try { this.removeChild(child); } catch (_) {}
402
+ return true;
403
+ }
404
+
405
+ /**
406
+ * Returns the toolbar instance, if one was created.
407
+ * @returns {omdToolbar|undefined}
408
+ */
409
+ getToolbar() {
410
+ return this.toolbar;
411
+ }
412
+
413
+ /**
414
+ * Undo the last operation (remove bottom-most equation and its preceding operation display)
415
+ * Also updates a step visualizer if present.
416
+ * @returns {boolean} Whether an operation was undone
417
+ */
418
+ undoLastOperation() {
419
+ const seq = this.sequence;
420
+ if (!seq || !Array.isArray(seq.steps) || seq.steps.length === 0) return false;
421
+
422
+ // Find bottom-most equation
423
+ let eqIndex = -1;
424
+ for (let i = seq.steps.length - 1; i >= 0; i--) {
425
+ const st = seq.steps[i];
426
+ const name = st?.constructor?.name;
427
+ if (st instanceof omdEquationNode || name === 'omdEquationNode') { eqIndex = i; break; }
428
+ }
429
+ if (eqIndex === -1) return false;
430
+
431
+ // Find nearest preceding operation display (if any)
432
+ let startIndex = eqIndex;
433
+ for (let i = eqIndex; i >= 0; i--) {
434
+ const st = seq.steps[i];
435
+ const name = st?.constructor?.name;
436
+ if (st instanceof omdOperationDisplayNode || name === 'omdOperationDisplayNode') { startIndex = i; break; }
437
+ }
438
+ // Remove DOM children and steps from startIndex to end
439
+ for (let i = seq.steps.length - 1; i >= startIndex; i--) {
440
+ const step = seq.steps[i];
441
+ try { seq.removeChild(step); } catch (_) {}
442
+ }
443
+ seq.steps.splice(startIndex);
444
+ seq.argumentNodeList.steps = seq.steps;
445
+ if (Array.isArray(seq.stepDescriptions)) seq.stepDescriptions.length = seq.steps.length;
446
+ if (Array.isArray(seq.importanceLevels)) seq.importanceLevels.length = seq.steps.length;
447
+
448
+ // Adjust current index
449
+ if (typeof seq.currentStepIndex === 'number' && seq.currentStepIndex >= seq.steps.length) {
450
+ seq.currentStepIndex = Math.max(0, seq.steps.length - 1);
451
+ }
452
+
453
+ // Rebuild maps and layout on sequence
454
+ if (typeof seq.rebuildNodeMap === 'function') seq.rebuildNodeMap();
455
+ if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
456
+ if (typeof seq.updateLayout === 'function') seq.updateLayout();
457
+
458
+ // If this is a step visualizer, rebuild its dots/lines
459
+ if (typeof seq.rebuildVisualizer === 'function') {
460
+ try {
461
+ seq.rebuildVisualizer();
462
+ } catch (_) {}
463
+ } else if (typeof seq._initializeVisualElements === 'function') {
464
+ try {
465
+ seq._initializeVisualElements();
466
+ if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
467
+ if (typeof seq.updateLayout === 'function') seq.updateLayout();
468
+ } catch (_) {}
469
+ }
470
+
471
+ // Safety: ensure dot/line counts match equations and prune orphan dots
472
+ try {
473
+ const isEquation = (s) => (s instanceof omdEquationNode) || (s?.constructor?.name === 'omdEquationNode');
474
+ const equationsCount = Array.isArray(seq.steps) ? seq.steps.filter(isEquation).length : 0;
475
+
476
+ // Remove dots whose equationRef is no longer present in steps
477
+ if (Array.isArray(seq.stepDots) && seq.visualContainer) {
478
+ const eqSet = new Set(seq.steps.filter(isEquation));
479
+ const keptDots = [];
480
+ for (const dot of seq.stepDots) {
481
+ if (!dot || !dot.equationRef || !eqSet.has(dot.equationRef)) {
482
+ try { seq.visualContainer.removeChild(dot); } catch (_) {}
483
+ } else {
484
+ keptDots.push(dot);
485
+ }
486
+ }
487
+ seq.stepDots = keptDots;
488
+ }
489
+ // Also purge any children in visualContainer that are not current dots or lines
490
+ if (seq.visualContainer && Array.isArray(seq.visualContainer.childList)) {
491
+ const valid = new Set([...(seq.stepDots||[]), ...(seq.stepLines||[])]);
492
+ const toRemove = [];
493
+ seq.visualContainer.childList.forEach(child => { if (!valid.has(child)) toRemove.push(child); });
494
+ toRemove.forEach(child => { try { seq.visualContainer.removeChild(child); } catch (_) {} });
495
+ }
496
+ if (Array.isArray(seq.stepDots) && seq.visualContainer) {
497
+ while (seq.stepDots.length > equationsCount) {
498
+ const dot = seq.stepDots.pop();
499
+ try { seq.visualContainer.removeChild(dot); } catch (_) {}
500
+ }
501
+ }
502
+ if (Array.isArray(seq.stepLines) && seq.visualContainer) {
503
+ const targetLines = Math.max(0, equationsCount - 1);
504
+ while (seq.stepLines.length > targetLines) {
505
+ const line = seq.stepLines.pop();
506
+ try { seq.visualContainer.removeChild(line); } catch (_) {}
507
+ }
508
+ }
509
+ if (seq.layoutManager) {
510
+ try {
511
+ seq.layoutManager.updateVisualLayout();
512
+ seq.layoutManager.updateVisualVisibility();
513
+ seq.layoutManager.updateAllLinePositions();
514
+ } catch (_) {}
515
+ }
516
+ } catch (_) {}
517
+
518
+ // Refresh stack layout
519
+ this.updateLayout();
520
+ return true;
521
+ }
522
522
  }