UNPKG

@teachinglab/omd

Version:

omd

547 lines (488 loc) 26 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) { // Pass through step visualizer styling options const stepVisualizerStyling = this.stylingOptions?.stepVisualizer || {}; this.sequence = new omdStepVisualizer(steps, stepVisualizerStyling); } else { this.sequence = new omdEquationSequenceNode(steps); } // Apply equation background styling if provided if (this.stylingOptions?.equationBackground) { this.sequence.setDefaultEquationBackground(this.stylingOptions.equationBackground); } // Apply step visualizer background styling if provided if (options.stepVisualizer && this.stylingOptions?.stepVisualizerBackground) { if (typeof this.sequence.setBackgroundStyle === 'function') { this.sequence.setBackgroundStyle(this.stylingOptions.stepVisualizerBackground); } } // 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); this._overlayChildren = []; // 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'; } // Update any overlay children so they remain locked to container anchors try { this.updateOverlayChildren(containerWidth, containerHeight, padding); } catch (_) {} } /** * General helper to position an overlay child relative to a chosen anchor. * Does not assume only the toolbar; supports various anchors and options. * @param {object} child - A jsvg node (or any object with setPosition/setScale) * @param {number} containerWidth - Width of the container (px) * @param {number} containerHeight - Height of the container (px) * @param {object} [opts] - Options * @param {string} [opts.anchor='toolbar-center'] - One of: 'toolbar-center','toolbar-left','toolbar-right','top-left','top-center','top-right','custom' * @param {number} [opts.offsetX=0] - Horizontal offset in screen pixels (positive -> right) * @param {number} [opts.offsetY=0] - Vertical offset in screen pixels (positive -> down) * @param {number} [opts.padding=16] - Padding from edges when computing anchor * @param {boolean} [opts.counterScale=true] - Whether to counter-scale the child to keep constant on-screen size * @param {boolean} [opts.addToStack=true] - Whether to add the child to this stack's children (default true) * @param {{x:number,y:number}|null} [opts.customCoords=null] - If anchor==='custom', use these screen coords * @returns {object|null} The child or null if not applicable */ addOverlayChild(child, containerWidth, containerHeight, opts = {}) { const { anchor = 'toolbar-center', offsetX = 0, offsetY = 0, padding = 16, counterScale = true, addToStack = true, customCoords = null } = opts || {}; // Basic validation if (!child || typeof containerWidth !== 'number' || typeof containerHeight !== 'number') return null; const stackX = this.xpos || 0; const stackY = this.ypos || 0; const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1; // Determine base container (screen) coordinates for the anchor let containerX = 0; let containerY = 0; if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') { containerX = Math.round(customCoords.x); containerY = Math.round(customCoords.y); } else if (anchor.startsWith('toolbar')) { if (!this.toolbar) return null; const tbW = this.toolbar.elements?.background?.width || 0; const tbH = this.toolbar.elements?.background?.height || 0; const left = (containerWidth - tbW) / 2; const right = left + tbW; const center = left + (tbW / 2); containerY = Math.round(containerHeight - tbH - (typeof padding === 'number' ? padding : this.overlayPadding)); if (anchor === 'toolbar-center') containerX = Math.round(center); else if (anchor === 'toolbar-left') containerX = Math.round(left); else if (anchor === 'toolbar-right') containerX = Math.round(right); else containerX = Math.round(center); } else if (anchor.startsWith('top')) { const topY = Math.round(typeof padding === 'number' ? padding : 16); const leftX = Math.round(typeof padding === 'number' ? padding : 16); const rightX = Math.round(containerWidth - (typeof padding === 'number' ? padding : 16)); containerY = topY; if (anchor === 'top-left') containerX = leftX; else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2); else if (anchor === 'top-right') containerX = rightX; else containerX = leftX; } else { // fallback: center containerX = Math.round(containerWidth / 2); containerY = Math.round(containerHeight / 2); } // Apply offsets (in screen pixels) containerX = Math.round(containerX + (offsetX || 0)); containerY = Math.round(containerY + (offsetY || 0)); // Convert to stack-local coordinates const x = (containerX - stackX) / s; const y = (containerY - stackY) / s; // Optionally counter-scale child to keep constant on-screen size if (counterScale && child && typeof child.setScale === 'function') { try { child.setScale(1 / s); } catch (_) {} } // Position child in stack-local coords if (child && typeof child.setPosition === 'function') { try { child.setPosition(x, y); } catch (_) {} } // Optionally add to this stack if (addToStack) { try { this.addChild(child); } catch (_) { try { this.layoutGroup.addChild(child); } catch (_) {} } } // Remember the overlay child and its options so we can reposition it when // the stack's scale/position changes (e.g., during zoom/center operations). try { // Store a shallow copy of opts to avoid external mutation surprises const stored = { anchor, offsetX, offsetY, padding, counterScale, addToStack, customCoords }; this._overlayChildren.push({ child, opts: stored }); } catch (_) {} // Make sure it's visible and above toolbar if (child && child.svgObject) { try { child.svgObject.style.zIndex = '1001'; } catch (_) {} try { child.svgObject.style.display = 'block'; } catch (_) {} } return child; } /** * Recompute and apply positions for tracked overlay children. * Called automatically during `positionToolbarOverlay` and can be called * manually if you change container size/stack position outside normal flows. */ updateOverlayChildren(containerWidth, containerHeight, padding = 16) { if (!Array.isArray(this._overlayChildren) || this._overlayChildren.length === 0) return; const stackX = this.xpos || 0; const stackY = this.ypos || 0; const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1; for (const entry of this._overlayChildren) { if (!entry || !entry.child) continue; const child = entry.child; const o = entry.opts || {}; const anchor = o.anchor || 'toolbar-center'; const offsetX = o.offsetX || 0; const offsetY = o.offsetY || 0; const pad = (typeof o.padding === 'number') ? o.padding : padding; const counterScale = (typeof o.counterScale === 'boolean') ? o.counterScale : true; const customCoords = o.customCoords || null; // Compute container anchor coords (duplicated logic from addOverlayChild) let containerX = 0; let containerY = 0; if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') { containerX = Math.round(customCoords.x + offsetX); containerY = Math.round(customCoords.y + offsetY); } else if (anchor.startsWith('toolbar')) { if (!this.toolbar) continue; const tbW = this.toolbar.elements?.background?.width || 0; const tbH = this.toolbar.elements?.background?.height || 0; const left = (containerWidth - tbW) / 2; const right = left + tbW; const center = left + (tbW / 2); containerY = Math.round(containerHeight - tbH - pad + offsetY); if (anchor === 'toolbar-center') containerX = Math.round(center + offsetX); else if (anchor === 'toolbar-left') containerX = Math.round(left + offsetX); else if (anchor === 'toolbar-right') containerX = Math.round(right + offsetX); else containerX = Math.round(center + offsetX); } else if (anchor.startsWith('top')) { const topY = Math.round(pad); const leftX = Math.round(pad); const rightX = Math.round(containerWidth - pad); containerY = topY + offsetY; if (anchor === 'top-left') containerX = leftX + offsetX; else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2) + offsetX; else if (anchor === 'top-right') containerX = rightX + offsetX; else containerX = leftX + offsetX; } else { containerX = Math.round(containerWidth / 2 + offsetX); containerY = Math.round(containerHeight / 2 + offsetY); } // Convert to stack-local coordinates const x = (containerX - stackX) / s; const y = (containerY - stackY) / s; // Apply counter-scaling and position if (counterScale && child && typeof child.setScale === 'function') { try { child.setScale(1 / s); } catch (_) {} } if (child && typeof child.setPosition === 'function') { try { child.setPosition(x, y); } catch (_) {} } } } /** * Remove a previously added overlay child (if present). */ removeOverlayChild(child) { if (!child || !Array.isArray(this._overlayChildren)) return false; let idx = -1; for (let i = 0; i < this._overlayChildren.length; i++) { if (this._overlayChildren[i].child === child) { idx = i; break; } } if (idx === -1) return false; this._overlayChildren.splice(idx, 1); try { this.removeChild(child); } catch (_) {} return true; } /** * 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 { // Clear all step visualizer highlights before rebuilding if (seq.highlighting && typeof seq.highlighting.clearAllExplainHighlights === 'function') { seq.highlighting.clearAllExplainHighlights(); } seq.rebuildVisualizer(); } catch (_) {} } else if (typeof seq._initializeVisualElements === 'function') { try { // Clear all step visualizer highlights before rebuilding if (seq.highlighting && typeof seq.highlighting.clearAllExplainHighlights === 'function') { seq.highlighting.clearAllExplainHighlights(); } 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(true); // Allow repositioning for equation stack changes seq.layoutManager.updateVisualVisibility(); seq.layoutManager.updateAllLinePositions(); } catch (_) {} } } catch (_) {} // Refresh stack layout this.updateLayout(); return true; } /** * Returns the SVG element for the entire equation stack. * @returns {SVGElement} The SVG element representing the equation stack. */ getSvg() { return this.svgObject; } }