@teachinglab/omd
Version:
omd
435 lines (386 loc) • 17.6 kB
JavaScript
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);
}
}
}