@teachinglab/omd
Version:
omd
323 lines (286 loc) • 14.2 kB
JavaScript
import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js';
import { omdToolbar } from '../display/omdToolbar.js';
import { jsvgGroup, jsvgLayoutGroup } from '@teachinglab/jsvg';
import { omdEquationNode } from '../nodes/omdEquationNode.js';
import { omdOperationDisplayNode } from '../nodes/omdOperationDisplayNode.js';
/**
* A renderable component that bundles a sequence and optional UI controls.
* It acts as a node that can be rendered by an omdDisplay.
* @extends jsvgGroup
*/
export class omdEquationStack extends jsvgGroup {
/**
* @param {Array<omdNode>} [steps=[]] - An initial array of equation steps.
* @param {Object} [options={}] - Configuration options.
* @param {boolean} [options.toolbar=false] - If true, creates a toolbar-driven sequence.
* @param {boolean} [options.stepVisualizer=false] - If true, creates a sequence with a step visualizer.
*/
constructor(steps = [], options = {}) {
super();
this.options = { ...options };
// Normalize new structured options
this.toolbarOptions = null;
if (typeof options.toolbar === 'object') {
this.toolbarOptions = { enabled: true, ...options.toolbar };
} else if (options.toolbar === true) {
this.toolbarOptions = { enabled: true };
} else if (options.toolbar === false) {
this.toolbarOptions = { enabled: false };
}
this.stylingOptions = options.styling || null;
// The sequence is the core. If a visualizer is needed, that's our sequence.
if (options.stepVisualizer) {
this.sequence = new omdStepVisualizer(steps);
} else {
this.sequence = new omdEquationSequenceNode(steps);
}
// Apply equation background styling if provided
if (this.stylingOptions?.equationBackground) {
this.sequence.setDefaultEquationBackground(this.stylingOptions.equationBackground);
}
// If a toolbar is needed, create it.
if (this.toolbarOptions?.enabled) {
// Default undo: call global hook if provided
const toolbarOpts = { ...this.toolbarOptions };
if (toolbarOpts.showUndoButton && !toolbarOpts.onUndo) {
toolbarOpts.onUndo = () => {
if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
try { window.onOMDToolbarUndo(this.sequence); } catch (_) {}
}
};
}
this.toolbar = new omdToolbar(this, this.sequence, toolbarOpts);
}
// Overlay padding (distance from bottom when overlayed)
this.overlayPadding = typeof this.toolbarOptions?.overlayPadding === 'number'
? this.toolbarOptions.overlayPadding
: 34; // Default a bit above the very bottom to match buttons
// Create a vertical layout group to hold the sequence and toolbar
this.layoutGroup = new jsvgLayoutGroup();
this.layoutGroup.setSpacer(16); // Adjust as needed for spacing
this.layoutGroup.addChild(this.sequence);
// Handle toolbar positioning
const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
if (this.toolbar) {
if (overlayBottom) {
// For overlay positioning, add toolbar directly to this (not layoutGroup)
this.addChild(this.toolbar.elements.toolbarGroup);
} else {
// For in-flow positioning, add to layout group
this.layoutGroup.addChild(this.toolbar.elements.toolbarGroup);
}
}
this.addChild(this.layoutGroup);
this.updateLayout();
}
/**
* Updates the layout and positioning of internal components.
*/
updateLayout() {
this.sequence.updateLayout();
this.layoutGroup.doVerticalLayout();
// Handle toolbar positioning based on overlay flag
const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
const overlayBottom = position === 'bottom' || position === 'overlay-bottom';
if (this.toolbar && !overlayBottom) {
// Center the toolbar under the stack if in-flow and their widths differ
const stackWidth = this.sequence.width;
const toolbarWidth = this.toolbar.elements.background.width;
const toolbarGroup = this.toolbar.elements.toolbarGroup;
// Center toolbar horizontally under the stack
toolbarGroup.setPosition(
(stackWidth - toolbarWidth) / 2,
toolbarGroup.ypos // y is handled by layout group
);
}
this.width = this.layoutGroup.width;
this.height = this.layoutGroup.height;
}
/**
* Returns the underlying sequence instance.
* @returns {omdEquationSequenceNode|omdStepVisualizer} The managed sequence instance.
*/
getSequence() {
return this.sequence;
}
/**
* Expose overlay padding to the display so it can pass it during reposition
*/
getOverlayPadding() {
return this.overlayPadding;
}
/**
* Returns the visual height in pixels of the toolbar background (unscaled), if present.
* Useful for reserving space when overlaying the toolbar.
*/
getToolbarVisualHeight() {
if (this.toolbar && this.toolbar.elements && this.toolbar.elements.background) {
return this.toolbar.elements.background.height || 0;
}
return 0;
}
/**
* Whether the toolbar is configured to be overlayed at the bottom of the container
* @returns {boolean}
*/
isToolbarOverlay() {
const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
return !!(this.toolbar && (position === 'bottom' || position === 'overlay-bottom'));
}
/**
* Positions the toolbar overlay at the bottom center of the container
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @param {number} [padding=16] - Padding from the bottom edge
*/
positionToolbarOverlay(containerWidth, containerHeight, padding = 16) {
if (!this.toolbar || !this.isToolbarOverlay()) return;
const toolbarGroup = this.toolbar.elements.toolbarGroup;
const toolbarWidth = this.toolbar.elements.background.width;
const toolbarHeight = this.toolbar.elements.background.height;
// Position at bottom center of the DISPLAY (container) while this toolbar
// lives inside the stack's local coordinate system, which may be scaled.
// Convert container (global) coordinates to stack-local by subtracting
// the stack's position and dividing by its scale.
const stackX = this.xpos || 0;
const stackY = this.ypos || 0;
const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
const effectivePadding = (typeof padding === 'number') ? padding : this.overlayPadding;
// Compute top-left of toolbar in container coordinates using UN-SCALED toolbar size
// because we counter-scale the toolbar by 1/s to keep constant on-screen size.
let containerX = (containerWidth - toolbarWidth) / 2;
let containerY = containerHeight - toolbarHeight - effectivePadding;
// Snap to integer pixels to avoid subpixel jitter when scaling
containerX = Math.round(containerX);
containerY = Math.round(containerY);
// Convert to stack-local coordinates
const x = (containerX - stackX) / s;
const y = (containerY - stackY) / s;
// Find the root SVG to check its viewBox
let rootSVG = toolbarGroup.svgObject;
while (rootSVG && rootSVG.tagName !== 'svg' && rootSVG.parentElement) {
rootSVG = rootSVG.parentElement;
}
const svgViewBox = rootSVG?.getAttribute?.('viewBox') || 'unknown';
// Counter-scale the toolbar so it remains a constant on-screen size
if (typeof toolbarGroup.setScale === 'function') {
toolbarGroup.setScale(1 / s);
}
toolbarGroup.setPosition(x, y);
// Ensure toolbar is visible and on top
if (toolbarGroup.svgObject) {
toolbarGroup.svgObject.style.display = 'block';
toolbarGroup.svgObject.style.zIndex = '1000';
}
}
/**
* Returns the toolbar instance, if one was created.
* @returns {omdToolbar|undefined}
*/
getToolbar() {
return this.toolbar;
}
/**
* Undo the last operation (remove bottom-most equation and its preceding operation display)
* Also updates a step visualizer if present.
* @returns {boolean} Whether an operation was undone
*/
undoLastOperation() {
const seq = this.sequence;
if (!seq || !Array.isArray(seq.steps) || seq.steps.length === 0) return false;
// Find bottom-most equation
let eqIndex = -1;
for (let i = seq.steps.length - 1; i >= 0; i--) {
const st = seq.steps[i];
const name = st?.constructor?.name;
if (st instanceof omdEquationNode || name === 'omdEquationNode') { eqIndex = i; break; }
}
if (eqIndex === -1) return false;
// Find nearest preceding operation display (if any)
let startIndex = eqIndex;
for (let i = eqIndex; i >= 0; i--) {
const st = seq.steps[i];
const name = st?.constructor?.name;
if (st instanceof omdOperationDisplayNode || name === 'omdOperationDisplayNode') { startIndex = i; break; }
}
// Remove DOM children and steps from startIndex to end
for (let i = seq.steps.length - 1; i >= startIndex; i--) {
const step = seq.steps[i];
try { seq.removeChild(step); } catch (_) {}
}
seq.steps.splice(startIndex);
seq.argumentNodeList.steps = seq.steps;
if (Array.isArray(seq.stepDescriptions)) seq.stepDescriptions.length = seq.steps.length;
if (Array.isArray(seq.importanceLevels)) seq.importanceLevels.length = seq.steps.length;
// Adjust current index
if (typeof seq.currentStepIndex === 'number' && seq.currentStepIndex >= seq.steps.length) {
seq.currentStepIndex = Math.max(0, seq.steps.length - 1);
}
// Rebuild maps and layout on sequence
if (typeof seq.rebuildNodeMap === 'function') seq.rebuildNodeMap();
if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
if (typeof seq.updateLayout === 'function') seq.updateLayout();
// If this is a step visualizer, rebuild its dots/lines
if (typeof seq.rebuildVisualizer === 'function') {
try {
seq.rebuildVisualizer();
} catch (_) {}
} else if (typeof seq._initializeVisualElements === 'function') {
try {
seq._initializeVisualElements();
if (typeof seq.computeDimensions === 'function') seq.computeDimensions();
if (typeof seq.updateLayout === 'function') seq.updateLayout();
} catch (_) {}
}
// Safety: ensure dot/line counts match equations and prune orphan dots
try {
const isEquation = (s) => (s instanceof omdEquationNode) || (s?.constructor?.name === 'omdEquationNode');
const equationsCount = Array.isArray(seq.steps) ? seq.steps.filter(isEquation).length : 0;
// Remove dots whose equationRef is no longer present in steps
if (Array.isArray(seq.stepDots) && seq.visualContainer) {
const eqSet = new Set(seq.steps.filter(isEquation));
const keptDots = [];
for (const dot of seq.stepDots) {
if (!dot || !dot.equationRef || !eqSet.has(dot.equationRef)) {
try { seq.visualContainer.removeChild(dot); } catch (_) {}
} else {
keptDots.push(dot);
}
}
seq.stepDots = keptDots;
}
// Also purge any children in visualContainer that are not current dots or lines
if (seq.visualContainer && Array.isArray(seq.visualContainer.childList)) {
const valid = new Set([...(seq.stepDots||[]), ...(seq.stepLines||[])]);
const toRemove = [];
seq.visualContainer.childList.forEach(child => { if (!valid.has(child)) toRemove.push(child); });
toRemove.forEach(child => { try { seq.visualContainer.removeChild(child); } catch (_) {} });
}
if (Array.isArray(seq.stepDots) && seq.visualContainer) {
while (seq.stepDots.length > equationsCount) {
const dot = seq.stepDots.pop();
try { seq.visualContainer.removeChild(dot); } catch (_) {}
}
}
if (Array.isArray(seq.stepLines) && seq.visualContainer) {
const targetLines = Math.max(0, equationsCount - 1);
while (seq.stepLines.length > targetLines) {
const line = seq.stepLines.pop();
try { seq.visualContainer.removeChild(line); } catch (_) {}
}
}
if (seq.layoutManager) {
try {
seq.layoutManager.updateVisualLayout();
seq.layoutManager.updateVisualVisibility();
seq.layoutManager.updateAllLinePositions();
} catch (_) {}
}
} catch (_) {}
// Refresh stack layout
this.updateLayout();
return true;
}
}