UNPKG

@teachinglab/omd

Version:

omd

323 lines (286 loc) 14.2 kB
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; } }