UNPKG

@teachinglab/omd

Version:

omd

435 lines (386 loc) 17.6 kB
import { omdEquationNode } from '../nodes/omdEquationNode.js'; import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js'; import { getNodeForAST } from '../core/omdUtilities.js'; import { jsvgContainer } from '@teachinglab/jsvg'; /** * OMD Renderer - Handles rendering of mathematical expressions * This class provides a cleaner API for rendering expressions without * being tied to specific DOM elements or UI concerns. */ export class omdDisplay { constructor(container, options = {}) { this.container = container; this.options = { fontSize: 32, centerContent: true, topMargin: 40, bottomMargin: 16, fitToContent: false, // Only fit to content when explicitly requested autoScale: true, // Automatically scale content to fit container maxScale: 1, // Do not upscale beyond 1 by default edgePadding: 16, // Horizontal padding from edges when scaling ...options }; // Create SVG container this.svg = new jsvgContainer(); this.node = null; // Set up the SVG this._setupSVG(); } _setupSVG() { const width = this.container.offsetWidth || 800; const height = this.container.offsetHeight || 600; this.svg.setViewbox(width, height); this.svg.svgObject.style.verticalAlign = "middle"; // Enable internal scrolling via native SVG scrolling if content overflows this.svg.svgObject.style.overflow = 'hidden'; this.container.appendChild(this.svg.svgObject); // Handle resize if (window.ResizeObserver) { this.resizeObserver = new ResizeObserver(() => { this._handleResize(); }); this.resizeObserver.observe(this.container); } } _handleResize() { const width = this.container.offsetWidth; const height = this.container.offsetHeight; this.svg.setViewbox(width, height); if (this.options.centerContent && this.node) { this.centerNode(); } // Reposition overlay toolbar (if any) on resize this._repositionOverlayToolbar(); } centerNode() { if (!this.node) return; const containerWidth = this.container.offsetWidth || 0; const containerHeight = this.container.offsetHeight || 0; // Determine actual content size (prefer sequence/current step when available) let contentWidth = this.node.width || 0; let contentHeight = this.node.height || 0; if (this.node.getSequence) { const seq = this.node.getSequence(); if (seq) { if (seq.width && seq.height) { contentWidth = seq.width; contentHeight = seq.height; } if (seq.getCurrentStep) { const step = seq.getCurrentStep(); if (step && step.width && step.height) { contentWidth = Math.max(contentWidth, step.width); contentHeight = Math.max(contentHeight, step.height); } } } } // Compute scale to keep within bounds let scale = 1; if (this.options.autoScale && contentWidth > 0 && contentHeight > 0) { const hPad = this.options.edgePadding || 0; const vPadTop = this.options.topMargin || 0; const vPadBottom = this.options.bottomMargin || 0; // Reserve extra space for overlay toolbar if needed let reserveBottom = vPadBottom; if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) { const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0; reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16)); } const availW = Math.max(0, containerWidth - hPad * 2); const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom)); const sx = availW > 0 ? (availW / contentWidth) : 1; const sy = availH > 0 ? (availH / contentHeight) : 1; const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1; scale = Math.min(sx, sy, maxScale); if (!isFinite(scale) || scale <= 0) scale = 1; } // Apply scale if (typeof this.node.setScale === 'function') { this.node.setScale(scale); } // Compute X so that equals anchor (if present) is centered after scaling let x; if (this.node.type === 'omdEquationSequenceNode' && this.node.alignPointX !== undefined) { const screenCenterX = containerWidth / 2; x = screenCenterX - (this.node.alignPointX * scale); } else { const scaledWidth = (this.node.width || contentWidth) * scale; x = (containerWidth - scaledWidth) / 2; } // Y is top margin; scaled content will grow downward this.node.setPosition(x, this.options.topMargin); // Reposition overlay toolbar (if any) this._repositionOverlayToolbar(); // If content still exceeds available height (even after scaling), enable container scroll const totalNeededH = (contentHeight * scale) + (this.options.topMargin || 0) + (this.options.bottomMargin || 0); if (totalNeededH > containerHeight) { // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift this.container.style.overflowY = 'auto'; this.container.style.overflowX = 'hidden'; } else { this.container.style.overflow = 'hidden'; } } fitToContent() { if (!this.node) { return; } // Try to get actual rendered dimensions let actualWidth = 0; let actualHeight = 0; // Get both sequence and current step dimensions let sequenceWidth = 0, sequenceHeight = 0; let stepWidth = 0, stepHeight = 0; if (this.node.getSequence) { const sequence = this.node.getSequence(); if (sequence && sequence.width && sequence.height) { sequenceWidth = sequence.width; sequenceHeight = sequence.height; // Check current step dimensions too if (sequence.getCurrentStep) { const currentStep = sequence.getCurrentStep(); if (currentStep && currentStep.width && currentStep.height) { stepWidth = currentStep.width; stepHeight = currentStep.height; } } // Use the larger of sequence or step dimensions actualWidth = Math.max(sequenceWidth, stepWidth); actualHeight = Math.max(sequenceHeight, stepHeight); } } // Fallback to node dimensions only if sequence/step dimensions aren't available if ((actualWidth === 0 || actualHeight === 0) && this.node.width && this.node.height) { actualWidth = this.node.width; actualHeight = this.node.height; } // Fallback dimensions if (actualWidth === 0 || actualHeight === 0) { actualWidth = 200; actualHeight = 60; } const padding = 10; // More comfortable padding to match user expectation const newWidth = actualWidth + (padding * 2); const newHeight = actualHeight + (padding * 2); // Position the content at the minimal padding offset FIRST if (this.node && this.node.setPosition) { this.node.setPosition(padding, padding); } // Update SVG dimensions with viewBox starting from 0,0 since we repositioned content this.svg.setViewbox(newWidth, newHeight); this.svg.setWidthAndHeight(newWidth, newHeight); // Update container this.container.style.width = `${newWidth}px`; this.container.style.height = `${newHeight}px`; } /** * Renders a mathematical expression or equation * @param {string|omdNode} expression - Expression string or node * @returns {omdNode} The rendered node */ render(expression) { // Clear previous node if (this.node) { this.svg.removeChild(this.node); } // Create node from expression if (typeof expression === 'string') { if (expression.includes(';')) { // Multiple equations const equationStrings = expression.split(';').filter(s => s.trim() !== ''); const steps = equationStrings.map(str => omdEquationNode.fromString(str)); this.node = new omdStepVisualizer(steps); } else { // Single expression or equation if (expression.includes('=')) { const firstStep = omdEquationNode.fromString(expression); this.node = new omdStepVisualizer([firstStep]); } else { // Create node directly from expression const parsedAST = math.parse(expression); const NodeClass = getNodeForAST(parsedAST); const firstStep = new NodeClass(parsedAST); this.node = new omdStepVisualizer([firstStep]); } } } else { // Assume it's already a node this.node = expression; } // Initialize and render const sequence = this.node.getSequence ? this.node.getSequence() : null; if (sequence) { sequence.setFontSize(this.options.fontSize); // Apply filtering based on filterLevel sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === sequence.getFilterLevel()); } this.svg.addChild(this.node); // Apply any stored font settings if (this.options.fontFamily) { // Small delay to ensure SVG elements are fully rendered setTimeout(() => { this.setFont(this.options.fontFamily, this.options.fontWeight || '400'); }, 10); } // Only use fitToContent for tight sizing when explicitly requested if (this.options.fitToContent) { this.fitToContent(); } else if (this.options.centerContent) { this.centerNode(); } // Ensure overlay toolbar is positioned initially this._repositionOverlayToolbar(); // Provide a default global refresh function if not present if (typeof window !== 'undefined' && !window.refreshDisplayAndFilters) { window.refreshDisplayAndFilters = () => { try { const node = this.getCurrentNode(); const sequence = node?.getSequence ? node.getSequence() : null; if (sequence) { if (typeof sequence.simplifyAll === 'function') { sequence.simplifyAll(); } if (typeof sequence.updateStepsVisibility === 'function') { sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === 0); } if (typeof node.updateLayout === 'function') { node.updateLayout(); } } if (this.options.centerContent) { this.centerNode(); } } catch (e) { // no-op } }; } return this.node; } /** * Updates the display with a new node * @param {omdNode} newNode - The new node to display */ update(newNode) { if (this.node) { this.svg.removeChild(this.node); } this.node = newNode; this.node.setFontSize(this.options.fontSize); this.node.initialize(); this.svg.addChild(this.node); if (this.options.centerContent) { this.centerNode(); } // Ensure overlay toolbar is positioned on updates this._repositionOverlayToolbar(); } /** * Gets the current node * @returns {omdNode|null} The current node */ getCurrentNode() { return this.node; } /** * Repositions overlay toolbar if current node supports it * @private */ _repositionOverlayToolbar() { const rect = this.container.getBoundingClientRect(); const paddingTop = parseFloat(getComputedStyle(this.container).paddingTop || '0'); const paddingBottom = parseFloat(getComputedStyle(this.container).paddingBottom || '0'); const paddingLeft = parseFloat(getComputedStyle(this.container).paddingLeft || '0'); const paddingRight = parseFloat(getComputedStyle(this.container).paddingRight || '0'); const containerWidth = (rect.width - paddingLeft - paddingRight) || this.container.clientWidth || 0; const containerHeight = (rect.height - paddingTop - paddingBottom) || this.container.clientHeight || 0; const node = this.node; if (!node) return; const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function'; if (hasOverlayApi && node.isToolbarOverlay()) { node.positionToolbarOverlay(containerWidth, containerHeight, 16); } } /** * Sets the font size * @param {number} size - The font size */ setFontSize(size) { this.options.fontSize = size; if (this.node) { // Apply font size - handle different node types if (this.node.getSequence && typeof this.node.getSequence === 'function') { // For omdEquationStack, set font size on the sequence this.node.getSequence().setFontSize(size); } else if (this.node.setFontSize && typeof this.node.setFontSize === 'function') { // For regular nodes with setFontSize method this.node.setFontSize(size); } this.node.initialize(); if (this.options.centerContent) { this.centerNode(); } } } /** * Sets the font family for all elements in the display * @param {string} fontFamily - CSS font-family string (e.g., '"Shantell Sans", cursive') * @param {string} fontWeight - CSS font-weight (default: '400') */ setFont(fontFamily, fontWeight = '400') { if (this.svg?.svgObject) { const applyFont = (element) => { if (element.style) { element.style.fontFamily = fontFamily; element.style.fontWeight = fontWeight; } // Recursively apply to all children Array.from(element.children || []).forEach(applyFont); }; // Apply font to the entire SVG applyFont(this.svg.svgObject); // Store font settings for future use this.options.fontFamily = fontFamily; this.options.fontWeight = fontWeight; } } /** * Clears the display */ clear() { if (this.node) { this.svg.removeChild(this.node); this.node = null; } } /** * Destroys the renderer and cleans up resources */ destroy() { this.clear(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.container.contains(this.svg.svgObject)) { this.container.removeChild(this.svg.svgObject); } } /** * Repositions overlay toolbar if current node supports it * @private */ _repositionOverlayToolbar() { // Use same width calculation as centering to ensure consistency const containerWidth = this.container.offsetWidth || 0; const containerHeight = this.container.offsetHeight || 0; const node = this.node; if (!node) return; const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function'; if (hasOverlayApi && node.isToolbarOverlay()) { const padding = (typeof node.getOverlayPadding === 'function') ? node.getOverlayPadding() : 16; node.positionToolbarOverlay(containerWidth, containerHeight, padding); } } }