@teachinglab/omd 0.2.5 → 0.2.6
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.
- package/docs/api/omdToolbar.md +130 -130
- package/omd/core/omdEquationStack.js +521 -521
- package/omd/nodes/omdBinaryExpressionNode.js +459 -459
- package/omd/nodes/omdEquationNode.js +1273 -1222
- package/omd/nodes/omdEquationSequenceNode.js +1246 -1246
- package/omd/nodes/omdFunctionNode.js +351 -351
- package/omd/nodes/omdNode.js +556 -556
- package/omd/nodes/omdSqrtNode.js +307 -307
- package/package.json +1 -1
- package/src/omdMetaExpression.js +1 -1
|
@@ -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);
|
|
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
|
}
|