@teachinglab/omd
Version:
omd
502 lines (446 loc) • 23.8 kB
JavaScript
import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
import { omdEquationNode } from '../nodes/omdEquationNode.js';
import { omdColor } from '../../src/omdColor.js';
import { jsvgGroup, jsvgRect, jsvgLayoutGroup, jsvgButton } from '@teachinglab/jsvg';
/**
* A toolbar component for applying mathematical operations to an omdEquationSequenceNode.
*/
export class omdToolbar {
/**
* Creates an instance of the omdToolbar.
* @param {jsvgGroup} parentContainer - The parent SVG group to render the toolbar into.
* @param {omdEquationSequenceNode} sequence - The sequence node to apply operations to.
* @param {object} [options={}] - Configuration options for the toolbar.
*/
constructor(parentContainer, sequence, options = {}) {
this.parentContainer = parentContainer;
this.sequence = sequence;
this.config = {
height: 60, padding: 6, spacing: 8, borderRadius: 30,
fontFamily: "'Albert Sans', sans-serif", fontWeight: '500',
colors: { background: omdColor.mediumGray, button: 'white', popup: omdColor.lightGray, undo: '#87D143' },
buttonSize: 48, checkMarkSize: 24, mainFontSize: 32,
inputFontSize: 28, menuFontSize: 24, inputWidth: 120,
popupDirection: 'below',
showUndoButton: false,
undoIconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzciIGhlaWdodD0iNDQiIHZpZXdCb3g9IjAgMCAzNyA0NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik0xOC4zNjc2IDQzLjgzMDFDMTMuNTkxMyA0My44MjM3IDkuMDEyNTYgNDEuOTIzNCA1LjYzNTI1IDM4LjU0NTlDMi4yNTc5MiAzNS4xNjg1IDAuMzU3NjYgMzAuNTg5OSAwLjM1MTA3NCAyNS44MTM2QzAuMzUxMDc0IDI1LjMxOTQgMC41NDc0NDEgMjQuODQ1MiAwLjg5Njk2MSAyNC40OTU4QzEuMjQ2NDggMjQuMTQ2MiAxLjcyMDU1IDIzLjk0OTggMi4yMTQ4NSAyMy45NDk4QzIuNzA5MTUgMjMuOTQ5OCAzLjE4MzIyIDI0LjE0NjIgMy41MzI3NCAyNC40OTU4QzMuODgyMjYgMjQuODQ1MiA0LjA3ODYzIDI1LjMxOTQgNC4wNzg2MyAyNS44MTM2QzQuMDc4NjMgMjguNjM5NiA0LjkxNjY2IDMxLjQwMjIgNi40ODY3NSAzMy43NTIxQzguMDU2ODUgMzYuMTAxOSAxMC4yODg1IDM3LjkzMzQgMTIuODk5NCAzOS4wMTQ5QzE1LjUxMDMgNDAuMDk2NCAxOC4zODM1IDQwLjM3OTQgMjEuMTU1MyAzOS44MjhDMjMuOTI3MSAzOS4yNzY4IDI2LjQ3MyAzNy45MTU3IDI4LjQ3MTUgMzUuOTE3NUMzMC40Njk3IDMzLjkxOTEgMzEuODMwOCAzMS4zNzMxIDMyLjM4MTkgMjguNjAxM0MzMi45MzM0IDI1LjgyOTUgMzIuNjUwMyAyMi45NTYzIDMxLjU2ODggMjAuMzQ1NkMzMC40ODczIDE3LjczNDUgMjguNjU1OSAxNS41MDI5IDI2LjMwNiAxMy45MzI4QzIzLjk1NjIgMTIuMzYyNyAyMS4xOTM2IDExLjUyNDcgMTguMzY3NiAxMS41MjQ3SDEyLjE1NUMxMS42NjA3IDExLjUyNDcgMTEuMTg2NiAxMS4zMjgzIDEwLjgzNzEgMTAuOTc4OEMxMC40ODc2IDEwLjYyOTMgMTAuMjkxMiAxMC4xNTUyIDEwLjI5MTIgOS42NjA5QzEwLjI5MTIgOS4xNjY2IDEwLjQ4NzYgOC42OTI1MyAxMC44MzcxIDguMzQzMDFDMTEuMTg2NiA3Ljk5MzQ5IDExLjY2MDcgNy43OTcxMiAxMi4xNTUgNy43OTcxMkgxOC4zNjc2QzIzLjE0NTggNy43OTcxMiAyNy43Mjg1IDkuNjk1MjkgMzEuMTA3MSAxMy4wNzRDMzQuNDg2IDE2LjQ1MjggMzYuMzg0MSAyMS4wMzU0IDM2LjM4NDEgMjUuODEzNkMzNi4zODQxIDMwLjU5MTkgMzQuNDg2IDM1LjE3NDUgMzEuMTA3MSAzOC41NTMyQzI3LjcyODUgNDEuOTMyMSAyMy4xNDU4IDQzLjgzMDEgMTguMzY3NiA0My44MzAxWiIgZmlsbD0id2hpdGUiLz4NCjxwYXRoIGQ9Ik0xOC4zNjc1IDE4Ljk3OThDMTguMTIyNyAxOC45ODEgMTcuODc5OSAxOC45MzMzIDE3LjY1MzggMTguODM5NEMxNy40Mjc3IDE4Ljc0NTQgMTcuMjIyNyAxOC42MDczIDE3LjA1MDQgMTguNDMzMUw5LjU5NTM2IDEwLjk3OEM5LjI0NjM0IDEwLjYyODYgOS4wNTAyOSAxMC4xNTQ5IDkuMDUwMjkgOS42NjA5NkM5LjA1MDI5IDkuMTY3MDYgOS4yNDYzNCA4LjY5MzM2IDkuNTk1MzYgOC4zNDM4OUwxNy4wNTA0IDAuODg4Nzg5QzE3LjIyMTIgMC43MDU2NjcgMTcuNDI2OSAwLjU1ODgwMSAxNy42NTU1IDAuNDU2OTM5QzE3Ljg4NDIgMC4zNTUwNzggMTguMTMwOSAwLjMwMDMwOCAxOC4zODEyIDAuMjk1ODg0QzE4LjYzMTQgMC4yOTE0NjEgMTguODc5OSAwLjMzNzUwOCAxOS4xMTIgMC40MzEyNDRDMTkuMzQ0MSAwLjUyNDk3OSAxOS41NTQ5IDAuNjY0NDg5IDE5LjczMTggMC44NDE0NzNDMTkuOTA5IDEuMDE4NDYgMjAuMDQ4NCAxLjIyOTI5IDIwLjE0MjEgMS40NjEzNEMyMC4yMzYgMS42OTM0MiAyMC4yODIgMS45NDIgMjAuMjc3NSAyLjE5MjI0QzIwLjI3MyAyLjQ0MjUxIDIwLjIxODQgMi42ODkzIDIwLjExNjUgMi45MTc5MkMyMC4wMTQ2IDMuMTQ2NTQgMTkuODY3NyAzLjM1MjMgMTkuNjg0NiAzLjUyMjkzTDEzLjU0NjUgOS42NjA5NkwxOS42ODQ2IDE1Ljc5OUMyMC4wMzM3IDE2LjE0ODUgMjAuMjI5OCAxNi42MjIyIDIwLjIyOTggMTcuMTE2QzIwLjIyOTggMTcuNjEgMjAuMDMzNyAxOC4wODM3IDE5LjY4NDYgMTguNDMzMUMxOS41MTI2IDE4LjYwNzMgMTkuMzA3MyAxOC43NDU0IDE5LjA4MTIgMTguODM5NEMxOC44NTUxIDE4LjkzMzMgMTguNjEyNSAxOC45ODEgMTguMzY3NSAxOC45Nzk4WiIgZmlsbD0id2hpdGUiLz4NCjwvc3ZnPg0K',
onUndo: null,
...options
};
// Support structured styles from equation stack toolbar options
if (options.styles && typeof options.styles === 'object') {
const s = options.styles;
if (s.backgroundColor) this.config.colors.background = s.backgroundColor;
if (s.buttonColor) this.config.colors.button = s.buttonColor;
if (s.popupBackgroundColor) this.config.colors.popup = s.popupBackgroundColor;
if (typeof s.borderRadius === 'number') this.config.borderRadius = s.borderRadius;
if (typeof s.buttonSize === 'number') this.config.buttonSize = s.buttonSize;
if (typeof s.mainFontSize === 'number') this.config.mainFontSize = s.mainFontSize;
if (typeof s.inputFontSize === 'number') this.config.inputFontSize = s.inputFontSize;
if (typeof s.menuFontSize === 'number') this.config.menuFontSize = s.menuFontSize;
if (typeof s.inputWidth === 'number') this.config.inputWidth = s.inputWidth;
if (typeof s.padding === 'number') this.config.padding = s.padding;
if (typeof s.spacing === 'number') this.config.spacing = s.spacing;
}
// Simple aliases remain supported
if (options.backgroundColor) this.config.colors.background = options.backgroundColor;
if (options.popupBackgroundColor) this.config.colors.popup = options.popupBackgroundColor;
// If no explicit popup color was provided, default it to the toolbar background color
const popupProvided = !!(options.popupBackgroundColor || (options.styles && options.styles.popupBackgroundColor));
if (!popupProvided) {
this.config.colors.popup = this.config.colors.background;
}
this.state = {
activePopup: null,
selectedOperation: '+',
inputValue: ''
};
this.elements = {};
this._render();
this._updateApplyButtonState();
}
/**
* Renders the initial toolbar UI components.
* @private
*/
_render() {
this.elements.toolbarGroup = new jsvgGroup();
this.parentContainer.addChild(this.elements.toolbarGroup);
if (this.config.x || this.config.y) {
this.elements.toolbarGroup.setPosition(this.config.x || 0, this.config.y || 0);
}
this.elements.toolbarGroup.svgObject.style.userSelect = 'none';
this.elements.background = new jsvgRect();
this.elements.background.setWidthAndHeight(362, this.config.height);
this.elements.background.setCornerRadius(this.config.borderRadius);
this.elements.background.setFillColor(this.config.colors.background);
this.elements.toolbarGroup.addChild(this.elements.background);
this.elements.leftButton = this._createButton({
text: this.state.selectedOperation,
callback: () => this._togglePopup('operations')
});
this.elements.toolbarGroup.addChild(this.elements.leftButton);
this.elements.middleInputButton = this._createButton({
width: this.config.inputWidth,
text: this.state.inputValue,
fontSize: this.config.inputFontSize,
cornerRadius: 10,
callback: () => this._togglePopup('input')
});
this.elements.toolbarGroup.addChild(this.elements.middleInputButton);
const checkmarkSVG = `<svg width="43" height="33" viewBox="0 0 43 33" xmlns="http://www.w3.org/2000/svg"><rect x="9.86" y="28.63" width="40.04" height="5.74" transform="rotate(-45 9.86 28.63)" fill="black"/><rect x="13.9" y="32.69" width="19.64" height="5.74" transform="rotate(-135 13.9 32.69)" fill="black"/></svg>`;
this.elements.rightButton = this._createButton({
svg: checkmarkSVG,
callback: () => this._applyOperation()
});
this.elements.toolbarGroup.addChild(this.elements.rightButton);
if (this.config.showUndoButton) {
this.elements.undoButton = this._createButton({
size: this.config.buttonSize,
iconUrl: this.config.undoIconUrl,
callback: () => this._handleUndo()
});
// Set the circular fill color to requested green
this.elements.undoButton.setFillColor(this.config.colors.undo || '#87D143');
this.elements.toolbarGroup.addChild(this.elements.undoButton);
}
this._updateToolbarLayout();
}
/**
* Moves an SVG element to the top of its parent's stacking order.
* @param {jsvgObject|undefined} node
* @private
*/
_bringToFront(node) {
try {
const el = node?.svgObject;
const parent = el?.parentNode;
if (el && parent) {
parent.appendChild(el);
}
} catch (_) { /* no-op */ }
}
/**
* Toggles the visibility of a popup menu (operations or input).
* Ensures only one popup is visible at a time.
* @param {string} popupType - The type of popup to toggle ('operations' or 'input').
* @private
*/
_togglePopup(popupType) {
if (this.state.activePopup && this.state.activePopup.type === popupType) {
// Remove from toolbar group to keep transform context consistent
this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
this.state.activePopup = null;
return;
}
if (this.state.activePopup) {
this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
this.state.activePopup = null;
}
let popupGroup;
if (popupType === 'operations') {
popupGroup = this._renderOperationsMenu();
} else if (popupType === 'input') {
popupGroup = this.state.selectedOperation === 'f'
? this._renderFunctionMenu()
: this._renderDigitGrid();
}
if (popupGroup) {
// Attach to toolbar group so it inherits toolbar counter-scaling (keeps constant on-screen size)
this.elements.toolbarGroup.addChild(popupGroup);
this.state.activePopup = { type: popupType, group: popupGroup };
// Ensure the toolbar and popup are on top of all siblings inside the SVG
this._bringToFront(this.elements.toolbarGroup);
this._bringToFront(popupGroup);
}
}
/**
* Creates and positions a generic popup group.
* @param {Function} contentFactory - A function that returns the jsvgGroup content for the popup.
* @param {jsvgGroup} anchorButton - The button to anchor the popup to.
* @returns {jsvgGroup} The fully rendered and positioned popup group.
* @private
*/
_renderPopup(contentFactory, anchorButton) {
const popupGroup = new jsvgGroup();
// Ensure popup captures interactions and overlays content
if (popupGroup.svgObject) {
popupGroup.svgObject.style.pointerEvents = 'auto';
}
const content = contentFactory();
const bgWidth = content.width + 16;
const bgHeight = content.height + 16;
const background = new jsvgRect();
background.setWidthAndHeight(bgWidth, bgHeight);
background.setCornerRadius(this.config.borderRadius);
background.setFillColor(this.config.colors.popup);
popupGroup.addChild(background);
content.setPosition(8, 8);
popupGroup.addChild(content);
popupGroup.width = bgWidth;
popupGroup.height = bgHeight;
// Anchor centered horizontally on the button, in toolbar-local coordinates
const popupX = anchorButton.xpos + (anchorButton.width / 2) - (bgWidth / 2);
// Determine vertical placement based on configuration (toolbar-local coordinates)
const placeAbove = String(this.config.popupDirection || 'below') === 'above';
const popupY = placeAbove
? (anchorButton.ypos) - bgHeight - this.config.spacing
: (anchorButton.ypos + anchorButton.height + this.config.spacing);
popupGroup.setPosition(Math.round(popupX), Math.round(popupY));
return popupGroup;
}
/**
* Renders the operations menu popup ('f', '÷', '×', '–', '+').
* @returns {jsvgGroup} The rendered operations menu group.
* @private
*/
_renderOperationsMenu() {
const operations = ['f', '÷', '×', '–', '+'];
return this._renderPopup(() => {
const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
operations.forEach(op => {
const button = this._createButton({ text: op, fontSize: this.config.menuFontSize, callback: () => this._selectOperation(op) });
layout.addChild(button);
});
layout.doVerticalLayout();
return layout;
}, this.elements.leftButton);
}
/**
* Renders the function selection menu popup ('sqrt', 'cos', etc.).
* @returns {jsvgGroup} The rendered function menu group.
* @private
*/
_renderFunctionMenu() {
const functions = ['sqrt', 'cos', 'sin', 'tan', 'ln'];
return this._renderPopup(() => {
const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
functions.forEach(func => {
const button = this._createButton({
width: 80, height: 48, cornerRadius: 10, text: func,
fontSize: this.config.inputFontSize, callback: () => this._handleFunctionClick(func)
});
layout.addChild(button);
});
layout.doVerticalLayout();
return layout;
}, this.elements.middleInputButton);
}
/**
* Renders the digit grid (number pad) popup.
* @returns {jsvgGroup} The rendered digit grid group.
* @private
*/
_renderDigitGrid() {
const digits = [ ['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['←', '0', 'x'] ];
return this._renderPopup(() => {
const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
digits.forEach(rowItems => {
const rowGroup = new jsvgLayoutGroup({ spacer: this.config.spacing });
rowItems.forEach(digit => {
const button = this._createButton({ text: digit, fontSize: this.config.inputFontSize, callback: () => this._handleDigitClick(digit) });
rowGroup.addChild(button);
});
rowGroup.doHorizontalLayout();
layout.addChild(rowGroup);
});
layout.doVerticalLayout();
return layout;
}, this.elements.middleInputButton);
}
/**
* Handles clicks on the function menu buttons.
* @param {string} func - The name of the function that was clicked.
* @private
*/
_handleFunctionClick(func) {
this.setInputText(func);
this._togglePopup('input');
}
/**
* Handles clicks on the digit grid buttons.
* @param {string} digit - The digit or action ('←') that was clicked.
* @private
*/
_handleDigitClick(digit) {
if (digit === '←') {
this.state.inputValue = this.state.inputValue.slice(0, -1);
} else {
this.state.inputValue += digit;
}
this.setInputText(this.state.inputValue);
}
/**
* Sets the text of the middle input button.
* @param {string} text - The text to display.
*/
setInputText(text) {
this.state.inputValue = text;
const button = this.elements.middleInputButton;
button.setText(text);
// Get the button's text element and set font size
const textElement = button.buttonText;
textElement.setFontSize(this.config.inputFontSize);
this._updateApplyButtonState();
}
/**
* Handles the selection of a new operation from the menu.
* @param {string} operation - The selected operation symbol.
* @private
*/
_selectOperation(operation) {
// Clear input text when switching to or from function mode.
if (this.state.selectedOperation === 'f' || operation === 'f') {
this.setInputText('');
}
this.state.selectedOperation = operation;
this.elements.leftButton.setText(operation);
this._togglePopup('operations');
// If we switched to function mode and the input popup was open, refresh it.
if (operation === 'f' && this.state.activePopup?.type === 'input') {
this._togglePopup('input'); // Close number pad
this._togglePopup('input'); // Open function menu
}
}
/**
* Applies the selected operation and value to the sequence.
* @private
*/
_applyOperation() {
const op = this.state.selectedOperation;
const val = this.state.inputValue;
if (!this.sequence || val === '') return;
if (op === 'f') {
this.sequence.applyEquationFunction(val);
} else {
const operationMap = {
'÷': 'divide',
'×': 'multiply',
'–': 'subtract',
'+': 'add'
};
const operationName = operationMap[op];
let valueToApply;
let isValid = false;
// Try to parse as number first
const numericValue = parseFloat(val);
if (!isNaN(numericValue) && String(numericValue) === val.trim()) {
valueToApply = numericValue;
isValid = true;
} else if (typeof window.math !== 'undefined' && window.math.parse) {
try {
valueToApply = window.math.parse(val);
isValid = true;
} catch (e) {
isValid = false;
}
}
if (operationName && isValid) {
this.sequence.applyEquationOperation(valueToApply, operationName);
}
}
// Clear the input after applying the operation
this.setInputText('');
if (this.state.activePopup) {
// Remove from toolbar group where it was attached
this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
this.state.activePopup = null;
}
// Notify host to refresh display and any active external visualizations
try {
if (typeof window !== 'undefined') {
if (typeof window.refreshDisplayAndFilters === 'function') {
window.refreshDisplayAndFilters();
}
if (typeof window.onOMDOperationApplied === 'function') {
window.onOMDOperationApplied(this.sequence);
}
}
} catch (_) { /* no-op */ }
}
/**
* Creates a button component with the specified configuration.
* @param {object} config - The button configuration.
* @param {number} [config.width] - The width of the button.
* @param {number} [config.height] - The height of the button.
* @param {number} [config.size] - The size for both width and height.
* @param {string} [config.text] - The text label for the button.
* @param {string} [config.svg] - The SVG content for the button icon.
* @param {number} [config.fontSize] - The font size for the text.
* @param {number} [config.cornerRadius] - The corner radius of the button.
* @param {Function} config.callback - The function to call on click.
* @returns {jsvgButton} The created button.
* @private
*/
_createButton({ width, height, size, text, svg, iconUrl, fontSize, cornerRadius, callback }) {
const button = new jsvgButton();
const w = width || size || this.config.buttonSize;
const h = height || size || this.config.buttonSize;
button.setWidthAndHeight(w, h);
button.setCornerRadius(cornerRadius !== undefined ? cornerRadius : w / 2);
button.setFillColor(this.config.colors.button);
button.setText(text || '');
button.setFontSize(fontSize || this.config.mainFontSize);
button.setFontFamily(this.config.fontFamily);
button.buttonText.setFontWeight(this.config.fontWeight);
// Adjust vertical position of text for better centering
button.buttonText.setPosition(w/2, h/2 + (fontSize || this.config.mainFontSize)/3);
if (svg) {
const dataURI = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
button.addImage(dataURI, this.config.checkMarkSize, this.config.checkMarkSize);
} else if (iconUrl) {
// Use default icon size (same as checkmark) centered inside the circular button
const sz = this.config.checkMarkSize;
button.addImage(iconUrl, sz, sz);
}
button.setClickCallback(callback);
return button;
}
/**
* Updates the enabled/disabled state of the apply button.
* @private
*/
_updateApplyButtonState() {
const button = this.elements.rightButton;
const hasValue = this.state.inputValue.length > 0;
if (hasValue) {
button.setOpacity(1.0);
button.setClickCallback(() => this._applyOperation());
} else {
button.setOpacity(0.5);
button.setClickCallback(null);
}
}
/**
* Updates the positions of the toolbar elements.
* @private
*/
_updateToolbarLayout() {
const totalWidth = this.config.buttonSize * 2 + this.config.inputWidth + this.config.spacing * 2 + this.config.padding * 2;
this.elements.background.setWidth(totalWidth);
const yPos = this.config.padding;
let xPos = this.config.padding;
this.elements.leftButton.setPosition(xPos, yPos);
xPos += this.elements.leftButton.width + this.config.spacing;
this.elements.middleInputButton.setPosition(xPos, yPos);
xPos += this.elements.middleInputButton.width + this.config.spacing;
this.elements.rightButton.setPosition(xPos, yPos);
// Position optional undo button directly to the right of the toolbar background
if (this.elements.undoButton) {
const undoX = this.elements.background.width + this.config.spacing;
this.elements.undoButton.setPosition(undoX, yPos);
}
}
_handleUndo() {
if (typeof this.config.onUndo === 'function') {
try { this.config.onUndo(this.sequence); } catch (_) {}
return;
}
// Fallback: emit a global hook
try {
if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
window.onOMDToolbarUndo(this.sequence);
}
} catch (_) { /* no-op */ }
}
}