UNPKG

@teachinglab/omd

Version:

omd

1,204 lines (1,038 loc) 48.7 kB
import { jsvgGroup, jsvgRect, jsvgButton, jsvgLayoutGroup, jsvgTextInput } from '@teachinglab/jsvg'; /** * omdPopup - Handles popup creation and management for node overlays */ export class omdPopup { constructor(targetNode, parentElement, options = {}) { this.targetNode = targetNode; this.parentElement = parentElement; this.options = { editable: true, animationDuration: 200, ...options }; // State this.popup = null; this.popupBackground = null; this.popupTextInput = null; this.penCanvas = null; this.penCanvasCleanup = null; this.currentMode = 'text'; // 'text' or 'pen' this.popupAnimationId = null; // Buttons this.penButton = null; this.textButton = null; this.clearButton = null; this.submitButton = null; // Callbacks this.onValidateCallback = null; this.onClearCallback = null; // Layout configuration - centralized variables this.popupWidth = 400; this.buttonSize = 18; this.margin = 10; this.buttonSpacing = 4; this.canvasMinWidth = 100; this.canvasMinHeight = 60; this.popupHeightMultiplier = 2; this.targetNodeDefaultHeight = 40; this.canvasTopOffset = this.margin; this.canvasLeftOffset = this.margin; } /** * Creates and shows the popup * @param {number} x - X position * @param {number} y - Y position * @returns {Promise} Promise that resolves when animation completes */ show(x, y) { if (this.popup) { return this.hide().then(() => this.show(x, y)); } this._createPopup(); this._positionPopup(x, y); this.parentElement.addChild(this.popup); // Start invisible and animate in this.popup.setOpacity(0); return this._animateOpacity(0, 1, this.options.animationDuration); } /** * Hides the popup * @returns {Promise} Promise that resolves when animation completes */ hide() { if (!this.popup) return Promise.resolve(); // Store original opacities for animation const originalPopupOpacity = this.popup.opacity; const originalTextInputOpacity = this.popupTextInput?.div?.style.opacity || '1'; // Ensure canvas and text input are visible for animation but preserve canvas drawing if (this.penCanvas && this.penCanvas.container) { this.penCanvas.container.style.display = 'block'; // Don't fade canvas opacity - keep strokes visible during popup hide } if (this.popupTextInput && this.popupTextInput.div) { this.popupTextInput.div.style.display = 'flex'; this.popupTextInput.div.style.opacity = originalTextInputOpacity; } // Animate popup and canvas together const duration = this.options.animationDuration || 300; const startTime = performance.now(); const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Easing function for smooth animation const easeOut = 1 - Math.pow(1 - progress, 3); const currentOpacity = originalPopupOpacity * (1 - easeOut); // Animate popup if (this.popup) { this.popup.setOpacity(currentOpacity); } // Animate canvas container with same opacity curve if (this.penCanvas && this.penCanvas.container) { this.penCanvas.container.style.opacity = currentOpacity; } // Animate text input with proper opacity setting if (this.popupTextInput && this.popupTextInput.div) { this.popupTextInput.div.style.opacity = currentOpacity; } if (progress < 1) { this.popupAnimationId = requestAnimationFrame(animate); } else { // Animation complete - hide and cleanup both popup and canvas if (this.penCanvas && this.penCanvas.container) { this.penCanvas.container.style.display = 'none'; this.penCanvas.container.style.opacity = '1'; // Reset for next show } if (this.popupTextInput && this.popupTextInput.div) { this.popupTextInput.div.style.display = 'none'; } this._cleanup(); } }; this.popupAnimationId = requestAnimationFrame(animate); return new Promise((resolve) => { setTimeout(resolve, duration); }); } /** * Toggles popup visibility * @param {number} x - X position for showing * @param {number} y - Y position for showing * @returns {Promise} Promise that resolves when animation completes */ toggle(x, y) { if (this.popup && this.popup.visible) { return this.hide(); } else { return this.show(x, y); } } /** * Sets the validation callback * @param {Function} callback - Function to call for validation */ setValidationCallback(callback) { this.onValidateCallback = callback; } /** * Sets the clear callback * @param {Function} callback - Function to call when clearing */ setClearCallback(callback) { this.onClearCallback = callback; } /** * Gets the current input value * @returns {string} Current input value */ getValue() { // If we have transcribed text from pen mode, return it if (this.transcribedText) { const text = this.transcribedText; // Clear the transcribed text after returning it this.transcribedText = null; return text; } if (this.currentMode === 'text' && this.popupTextInput) { return this.popupTextInput.getText(); } return ''; } /** * Sets the input value * @param {string} value - Value to set */ setValue(value) { if (this.currentMode === 'text' && this.popupTextInput) { this.popupTextInput.setText(value); } } /** * Switches between text and pen modes * @param {string} mode - 'text' or 'pen' */ switchToMode(mode) { if (this.currentMode === mode || !this.popup) { return; } this.currentMode = mode; if (mode === 'pen') { this._showPenMode(); } else { this._showTextMode(); } // Update button states this._updateButtonStates(); } /** * Creates the popup structure * @private */ _createPopup() { const popupHeight = (this.targetNode?.height || this.targetNodeDefaultHeight) * this.popupHeightMultiplier; // Create popup container this.popup = new jsvgLayoutGroup(); // Create popup background this.popupBackground = new jsvgRect(); this.popupBackground.setWidthAndHeight(this.popupWidth, popupHeight); this.popupBackground.setFillColor('white'); this.popupBackground.setStrokeColor('black'); this.popupBackground.setStrokeWidth(2); this.popupBackground.setCornerRadius(8); this.popup.addChild(this.popupBackground); // Create buttons this._createButtons(this.popupWidth, popupHeight, this.buttonSize, this.margin, this.buttonSpacing); // Create text input (default mode) this._createTextInput(this.popupWidth, popupHeight, this.margin); // Set initial mode this.currentMode = 'text'; this._updateButtonStates(); } /** * Creates the popup buttons * @private */ _createButtons(popupWidth, popupHeight, buttonSize, margin, buttonSpacing) { const buttonX = popupWidth - buttonSize - margin; // Clear button const clearButtonY = popupHeight - (buttonSize * 2) - margin - buttonSpacing; this.clearButton = new jsvgButton(); this.clearButton.setText("C"); this.clearButton.setWidthAndHeight(buttonSize, buttonSize); this.clearButton.setFillColor('#E65423'); this.clearButton.setFontColor('white'); this.clearButton.buttonText.setFontWeight('bold'); this.clearButton.setPosition(buttonX, clearButtonY); this.clearButton.setClickCallback(() => { if (this.currentMode === 'pen' && this.penCanvas) { // Clear the canvas this.penCanvas.clear(); } else if (this.currentMode === 'text' && this.popupTextInput) { // Clear the text input this.popupTextInput.setText(''); } // Also call the external clear callback if provided if (this.onClearCallback) { this.onClearCallback(); } }); this.popup.addChild(this.clearButton); // Submit button const submitButtonY = popupHeight - buttonSize - margin; this.submitButton = new jsvgButton(); this.submitButton.setText("✓"); this.submitButton.setWidthAndHeight(buttonSize, buttonSize); this.submitButton.setFillColor('#2ECC71'); this.submitButton.setFontColor('white'); this.submitButton.buttonText.setFontWeight('bold'); this.submitButton.setPosition(buttonX, submitButtonY); this.submitButton.setClickCallback(() => { if (this.currentMode === 'pen' && this.penCanvas) { // For pen mode, transcribe the canvas first this._setSubmitButtonLoading(true); this._downloadCanvasAsBitmap(); } else if (this.onValidateCallback) { // For text mode, validate directly this.onValidateCallback(); } }); this.popup.addChild(this.submitButton); // Mode buttons (P and T) - positioned in bottom left, aligned with other buttons const leftMargin = margin; // Text button (above pen button, aligned with clear button) this.textButton = new jsvgButton(); this.textButton.setText("T"); this.textButton.setWidthAndHeight(buttonSize, buttonSize); this.textButton.setFillColor('#28a745'); this.textButton.setFontColor('white'); this.textButton.buttonText.setFontWeight('bold'); this.textButton.setPosition(leftMargin, clearButtonY); this.textButton.setClickCallback(() => { this.switchToMode('text'); }); this.popup.addChild(this.textButton); // Pen button (bottom, aligned with submit button) this.penButton = new jsvgButton(); this.penButton.setText(""); this.penButton.setWidthAndHeight(buttonSize, buttonSize); this.penButton.setFillColor('#007bff'); this.penButton.setPosition(leftMargin, submitButtonY); this.penButton.setClickCallback(() => this.switchToMode('pen')); // Add pencil icon (same as canvas toolbar) const pencilIconSvg = `<svg width="12" height="12" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.3658 4.68008C13.7041 4.34179 13.8943 3.88294 13.8943 3.40447C13.8944 2.926 13.7044 2.4671 13.3661 2.12872C13.0278 1.79035 12.5689 1.60022 12.0905 1.60016C11.612 1.6001 11.1531 1.79011 10.8147 2.1284L2.27329 10.6718C2.12469 10.8199 2.0148 11.0023 1.95329 11.203L1.10785 13.9882C1.09131 14.0436 1.09006 14.1024 1.10423 14.1584C1.11841 14.2144 1.14748 14.2655 1.18836 14.3063C1.22924 14.3471 1.28041 14.3761 1.33643 14.3902C1.39246 14.4043 1.45125 14.403 1.50657 14.3863L4.29249 13.5415C4.49292 13.4806 4.67532 13.3713 4.82369 13.2234L13.3658 4.68008Z" stroke="white" stroke-width="1.28" stroke-linecap="round" stroke-linejoin="round"/> <path d="M9.41443 3.52039L11.9744 6.08039" stroke="white" stroke-width="1.28" stroke-linecap="round" stroke-linejoin="round"/> </svg>`; const dataURI = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pencilIconSvg); this.penButton.addImage(dataURI, 12, 12); this.popup.addChild(this.penButton); } /** * Creates the text input * @private */ _createTextInput(popupWidth, popupHeight, margin) { const buttonAreaWidth = (this.buttonSize * 2) + (margin * 2) + this.margin; // Text input covers the middle area between the left and right button areas const inputWidth = popupWidth - buttonAreaWidth - (margin * 2); const inputHeight = popupHeight - (margin * 2); const inputX = this.buttonSize + (margin * 2); const inputY = margin; this.popupTextInput = new jsvgTextInput(); this.popupTextInput.setWidthAndHeight(inputWidth, inputHeight); this.popupTextInput.setPosition(inputX, inputY); // Make background transparent/invisible this.popupTextInput.setFillColor('transparent'); this.popupTextInput.setStrokeColor('transparent'); this.popupTextInput.setStrokeWidth(0); this.popupTextInput.setPlaceholderText(""); // Style the actual input for large centered text if (this.popupTextInput.div) { this.popupTextInput.div.style.border = 'none'; this.popupTextInput.div.style.outline = 'none'; this.popupTextInput.div.style.background = 'transparent'; this.popupTextInput.div.style.textAlign = 'center'; this.popupTextInput.div.style.fontSize = '48px'; this.popupTextInput.div.style.fontFamily = 'Albert Sans, Arial, sans-serif'; this.popupTextInput.div.style.resize = 'none'; this.popupTextInput.div.style.width = '100%'; this.popupTextInput.div.style.height = '100%'; this.popupTextInput.div.style.display = 'flex'; this.popupTextInput.div.style.alignItems = 'center'; this.popupTextInput.div.style.justifyContent = 'center'; this.popupTextInput.div.style.boxSizing = 'border-box'; } // Add to popup in text mode this.popup.addChild(this.popupTextInput); } /** * Creates the pen canvas * @private */ _createPenCanvas() { // Use class variables directly const popupWidth = this.popupWidth; const popupHeight = (this.targetNode?.height || this.targetNodeDefaultHeight) * this.popupHeightMultiplier; // Calculate canvas dimensions based on popup size const leftMargin = this.buttonSize; const rightMargin = 2 * this.buttonSize + this.buttonSpacing; // Space for buttons // Calculate canvas dimensions based on popup size const canvasWidth = Math.max(popupWidth - leftMargin - rightMargin, this.canvasMinWidth); // Calculate canvas height to fit in available space const buttonAreaHeight = (this.buttonSize * 2) + this.buttonSpacing + (this.margin * 2); const availableHeight = popupHeight - buttonAreaHeight; const canvasHeight = Math.max(availableHeight, this.canvasMinHeight); const canvasX = leftMargin; // Try to create a regular HTML container first (fallback approach) let canvasContainer; let foreignObject = null; // Check if we're in an SVG context if (this.parentElement && this.parentElement.element && this.parentElement.element.namespaceURI === 'http://www.w3.org/2000/svg') { // Create an SVG foreignObject to embed HTML canvas foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); foreignObject.setAttribute('width', canvasWidth); foreignObject.setAttribute('height', canvasHeight); foreignObject.setAttribute('x', canvasX); foreignObject.setAttribute('y', this.canvasTopOffset); // Create the HTML container inside foreignObject canvasContainer = document.createElement('div'); canvasContainer.style.width = `${canvasWidth}px`; canvasContainer.style.height = `${canvasHeight}px`; canvasContainer.style.border = '1px solid #ddd'; canvasContainer.style.borderRadius = '4px'; canvasContainer.style.backgroundColor = '#f8f9fa'; canvasContainer.style.margin = '0'; canvasContainer.style.padding = '0'; // Add the container to foreignObject foreignObject.appendChild(canvasContainer); } else { // Create a regular HTML container canvasContainer = document.createElement('div'); canvasContainer.style.width = `${canvasWidth}px`; canvasContainer.style.height = `${canvasHeight}px`; canvasContainer.style.position = 'absolute'; canvasContainer.style.left = `${canvasX}px`; canvasContainer.style.top = `${this.canvasTopOffset}px`; canvasContainer.style.border = 'none'; // No border canvasContainer.style.borderRadius = '0px'; canvasContainer.style.backgroundColor = '#ffffff'; // White background canvasContainer.style.zIndex = '1000'; canvasContainer.style.pointerEvents = 'auto'; canvasContainer.style.cursor = 'crosshair'; } // Import our new modular canvas system import('../../canvas/index.js').then(({ createCanvas }) => { // Ensure we have valid positive dimensions const finalWidth = canvasWidth; const finalHeight = canvasHeight; // Create the canvas using our new system this.penCanvas = createCanvas(canvasContainer, { width: finalWidth - this.buttonSize, height: finalHeight, showToolbar: false, showGrid: false, backgroundColor: '#ffffff', // White background strokeWidth: 3, strokeColor: '#000000' }); // Store the container and foreignObject references for cleanup this.penCanvas.container = canvasContainer; this.penCanvas.foreignObject = foreignObject; // Set pencil as the default tool this.penCanvas.toolManager.setActiveTool('pencil'); // Cursor will be shown/hidden by EventManager based on mouse enter/leave // Add event listeners for debugging this.penCanvas.on('strokeStarted', (event) => {}); this.penCanvas.on('strokeCompleted', (event) => {}); // Add pointer event debugging canvasContainer.addEventListener('pointerdown', (e) => {}); // Add click debugging canvasContainer.addEventListener('click', (e) => {}); // Store cleanup function this.penCanvasCleanup = () => { if (this.penCanvas) { this.penCanvas.destroy(); } if (foreignObject && foreignObject.parentNode) { foreignObject.parentNode.removeChild(foreignObject); } else if (canvasContainer && canvasContainer.parentNode) { canvasContainer.parentNode.removeChild(canvasContainer); } // Remove step visualizer listeners this._removeStepVisualizerListeners(); }; // Set up step visualizer change detection this._setupStepVisualizerListeners(); // If we're currently in pen mode, show the canvas if (this.currentMode === 'pen' && this.popup) { this._addCanvasToParent(foreignObject || canvasContainer); } }).catch(console.error); } /** * Shows pen mode * @private */ _showPenMode() { // Hide text input by making it invisible instead of removing it if (this.popupTextInput && this.popupTextInput.div) { this.popupTextInput.div.style.display = 'none'; this.popupTextInput.div.style.visibility = 'hidden'; } // Create pen canvas if it doesn't exist if (!this.penCanvas) { this._createPenCanvas(); } else { // Show existing canvas and ensure it's fully opaque const element = this.penCanvas.foreignObject || this.penCanvas.container; if (element) { element.style.display = 'block'; element.style.opacity = '1'; // Ensure full opacity for stroke visibility } this._addCanvasToParent(); } } /** * Shows text mode * @private */ _showTextMode() { // Hide pen canvas by removing its element if (this.penCanvas) { const element = this.penCanvas.foreignObject || this.penCanvas.container; if (element && element.parentNode) { element.parentNode.removeChild(element); } } // Show text input and ensure it's visible and focused if (this.popupTextInput && this.popup) { // Make sure text input is visible if (this.popupTextInput.div) { this.popupTextInput.div.style.display = 'flex'; this.popupTextInput.div.style.visibility = 'visible'; this.popupTextInput.div.style.opacity = '1'; } // Add to popup (jsvgLayoutGroup handles duplicates automatically) this.popup.addChild(this.popupTextInput); // Focus the text input after a short delay to ensure it's rendered setTimeout(() => { if (this.popupTextInput.div) { this.popupTextInput.div.focus(); } }, 100); } } /** * Adds canvas to parent element (same level as popup) * @private */ _addCanvasToParent(element = null) { // If no element provided and canvas already exists, re-add it to parent if (!element && this.penCanvas && this.penCanvas.container) { // Re-add the canvas container to the parent if (this.penCanvas.container.parentNode) { this.penCanvas.container.parentNode.removeChild(this.penCanvas.container); } document.body.appendChild(this.penCanvas.container); this._updateCanvasPosition(); return; } if (this.penCanvas && this.popup && this.parentElement) { const popupX = this.popup.xpos || 0; const popupY = this.popup.ypos || 0; // If we have an element to add if (element) { // Check if it's a foreignObject (SVG) or regular HTML element if (element.namespaceURI === 'http://www.w3.org/2000/svg') { // SVG foreignObject element.setAttribute('x', popupX + this.canvasLeftOffset); element.setAttribute('y', popupY + this.canvasTopOffset); // Add the foreignObject to the parent element if (this.parentElement.element) { this.parentElement.element.appendChild(element); } else if (this.parentElement.appendChild) { this.parentElement.appendChild(element); } } else { // Regular HTML element const popupRect = this.popup.svgObject ? this.popup.svgObject.getBoundingClientRect() : null; if (popupRect) { // Calculate position to match popup exactly const leftButtonArea = this.buttonSize + (this.margin * 2); const rightButtonArea = (this.buttonSize * 2) + (this.margin * 2) + this.margin; // Position canvas in the middle content area const absoluteX = popupRect.left + leftButtonArea; const absoluteY = popupRect.top + this.margin; element.style.left = `${absoluteX}px`; element.style.top = `${absoluteY}px`; element.style.position = 'fixed'; element.style.zIndex = '9999'; element.style.pointerEvents = 'auto'; // Set canvas size to match popup content area const contentWidth = popupRect.width - leftButtonArea - rightButtonArea; element.style.width = `${contentWidth}px`; // Store popup reference for resize handling this.penCanvas.popupRect = popupRect; // Add resize observer to track popup changes this._setupResizeObserver(); } else { // Fallback positioning const absoluteX = (window.innerWidth - 280) / 2; const absoluteY = (window.innerHeight - 60) / 2; element.style.left = `${absoluteX}px`; element.style.top = `${absoluteY}px`; element.style.position = 'fixed'; element.style.zIndex = '9999'; element.style.pointerEvents = 'auto'; } // Add to document body for HTML approach document.body.appendChild(element); } } } } /** * Updates button visual states based on current mode * @private */ _updateButtonStates() { if (!this.penButton || !this.textButton) return; if (this.currentMode === 'pen') { this.penButton.setFillColor('#2980B9'); // Darker blue for active this.textButton.setFillColor('#9B59B6'); // Normal purple } else { this.penButton.setFillColor('#3498DB'); // Normal blue this.textButton.setFillColor('#8E44AD'); // Darker purple for active } } /** * Positions the popup * @private */ _positionPopup(x, y) { if (!this.popup) return; this.popup.setPosition(x, y); } /** * Animates popup opacity * @private */ _animateOpacity(fromOpacity, toOpacity, duration) { return new Promise((resolve) => { if (!this.popup) { resolve(); return; } if (this.popupAnimationId) { cancelAnimationFrame(this.popupAnimationId); } const startTime = performance.now(); const deltaOpacity = toOpacity - fromOpacity; const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = 1 - Math.pow(1 - progress, 3); const currentOpacity = fromOpacity + (deltaOpacity * easedProgress); // Animate popup this.popup.setOpacity(currentOpacity); // Animate canvas with same opacity if it exists if (this.penCanvas && this.penCanvas.container && this.currentMode === 'pen') { this.penCanvas.container.style.opacity = currentOpacity; } if (progress < 1) { this.popupAnimationId = requestAnimationFrame(animate); } else { this.popupAnimationId = null; // Ensure canvas is fully opaque when animation completes if (this.penCanvas && this.penCanvas.container && this.currentMode === 'pen') { this.penCanvas.container.style.opacity = '1'; } resolve(); } }; this.popupAnimationId = requestAnimationFrame(animate); }); } /** * Flashes the popup background to indicate validation result * @param {boolean} isValid - Whether validation was successful */ flashValidation(isValid) { if (!this.popupBackground) return; const flashColor = isValid ? '#E8F5E8' : '#FFE6E6'; this._flashAllElements(flashColor); } /** * Flash all elements with the same color and return to white * @private */ _flashAllElements(flashColor) { // Flash popup background this.popupBackground.setFillColor(flashColor); // Flash canvas container if (this.penCanvas && this.penCanvas.container) { this.penCanvas.container.style.backgroundColor = flashColor; } // Flash canvas drawing area if (this.penCanvas && this.penCanvas.svg) { this.penCanvas.svg.style.backgroundColor = flashColor; } // Return all elements to white after 300ms setTimeout(() => { this.popupBackground.setFillColor('white'); if (this.penCanvas && this.penCanvas.container) { this.penCanvas.container.style.backgroundColor = 'white'; } if (this.penCanvas && this.penCanvas.svg) { this.penCanvas.svg.style.backgroundColor = 'white'; } }, 300); } /** * Checks if two mathematical expressions are equivalent * @param {string} expr1 - First expression * @param {string} expr2 - Second expression * @returns {boolean} True if expressions are mathematically equivalent */ areExpressionsEquivalent(expr1, expr2) { // Robust equivalence: compare evaluated results for random variable assignments if (!window.math || !window.math.simplify || !window.math.parse) { return false; } try { const expr1Trimmed = expr1.trim(); const expr2Trimmed = expr2.trim(); const node1 = window.math.simplify(expr1Trimmed); const node2 = window.math.simplify(expr2Trimmed); // If ASTs match, return true if (node1.equals(node2)) return true; // Otherwise, compare evaluated results for random variable assignments // Find all variable names const getVars = expr => { const node = window.math.parse(expr); const vars = new Set(); node.traverse(n => { if (n.isSymbolNode) vars.add(n.name); }); return Array.from(vars); }; const vars = Array.from(new Set([...getVars(expr1Trimmed), ...getVars(expr2Trimmed)])); if (vars.length === 0) { // No variables, just compare evaluated results return node1.evaluate() === node2.evaluate(); } // Try several random assignments for (let i = 0; i < 100; i++) { const scope = {}; for (const v of vars) { scope[v] = Math.floor(Math.random() * 1000 + 1); // random int 1-10 } const val1 = node1.evaluate(scope); const val2 = node2.evaluate(scope); if (Math.abs(val1 - val2) > 1e-9) return false; } return true; } catch (e) { return false; } } /** * Cleanup popup and all associated elements * @private */ _cleanup() { if (this.popup && this.parentElement) { this.parentElement.removeChild(this.popup); } // Remove pen canvas from parent if it exists if (this.penCanvas) { // Remove both foreignObject and container if they exist if (this.penCanvas.foreignObject && this.penCanvas.foreignObject.parentNode) { this.penCanvas.foreignObject.parentNode.removeChild(this.penCanvas.foreignObject); } if (this.penCanvas.container && this.penCanvas.container.parentNode) { this.penCanvas.container.parentNode.removeChild(this.penCanvas.container); } } // Clean up pen canvas if (this.penCanvasCleanup) { this.penCanvasCleanup(); this.penCanvasCleanup = null; } // Clean up resize observer if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } // Clean up step visualizer listeners this._removeStepVisualizerListeners(); // Clean up animation if (this.popupAnimationId) { cancelAnimationFrame(this.popupAnimationId); this.popupAnimationId = null; } // Reset all references this.popup = null; this.popupBackground = null; this.popupTextInput = null; this.penCanvas = null; this.penButton = null; this.textButton = null; this.clearButton = null; this.submitButton = null; } /** * Destroys the popup completely */ destroy() { return this.hide(); } /** * Debug function to test canvas positioning */ debugCanvasPosition() { if (this.penCanvas && this.penCanvas.container) { const container = this.penCanvas.container; // Flash the border to make it visible container.style.border = '3px solid #00ff00'; setTimeout(() => { container.style.border = '2px solid #ff0000'; }, 1000); } } /** * Reposition canvas to center of screen */ centerCanvas() { if (this.penCanvas && this.penCanvas.container) { const container = this.penCanvas.container; const centerX = (window.innerWidth - 280) / 2; const centerY = (window.innerHeight - 60) / 2; container.style.left = `${centerX}px`; container.style.top = `${centerY}px`; } } /** * Setup resize observer to track popup changes * @private */ _setupResizeObserver() { if (!this.penCanvas || !this.popup || !this.popup.svgObject) return; // Create a resize observer to track popup changes this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { this._updateCanvasPosition(); } }); // Observe the popup element this.resizeObserver.observe(this.popup.svgObject); // Also observe the parent element for position changes if (this.parentElement && this.parentElement.svgObject) { this.resizeObserver.observe(this.parentElement.svgObject); } // Also observe the document body for zoom changes this.resizeObserver.observe(document.body); } /** * Setup step visualizer listeners to track expand/collapse * @private */ _setupStepVisualizerListeners() { if (!this.penCanvas) return; // Listen for step visualizer events that might change layout this._stepVisualizerUpdateHandler = () => { console.log('[Step Visualizer Debug] Layout change detected, updating canvas position'); // Small delay to allow layout to settle setTimeout(() => this._updateCanvasPosition(), 50); }; // Listen for various events that might indicate step visualizer changes document.addEventListener('click', this._stepVisualizerUpdateHandler); window.addEventListener('resize', this._stepVisualizerUpdateHandler); // Listen for custom step visualizer events if they exist if (this.parentElement && this.parentElement.element) { this.parentElement.element.addEventListener('stepVisualizerChanged', this._stepVisualizerUpdateHandler); } // Set up mutation observer to detect DOM changes in step visualizer if (this.parentElement && this.parentElement.element) { this._mutationObserver = new MutationObserver((mutations) => { let shouldUpdate = false; for (const mutation of mutations) { // Check if any changes might affect layout if (mutation.type === 'attributes' && (mutation.attributeName === 'style' || mutation.attributeName === 'class' || mutation.attributeName === 'transform')) { console.log('[Step Visualizer Debug] Mutation detected:', mutation.attributeName, mutation.target); shouldUpdate = true; break; } if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) { console.log('[Step Visualizer Debug] Child list mutation detected'); shouldUpdate = true; break; } } if (shouldUpdate) { setTimeout(() => this._updateCanvasPosition(), 10); } }); // Observe the parent element and its children this._mutationObserver.observe(this.parentElement.element, { childList: true, attributes: true, subtree: true, attributeFilter: ['style', 'class', 'transform', 'viewBox'] }); } } /** * Remove step visualizer listeners * @private */ _removeStepVisualizerListeners() { if (this._stepVisualizerUpdateHandler) { document.removeEventListener('click', this._stepVisualizerUpdateHandler); window.removeEventListener('resize', this._stepVisualizerUpdateHandler); if (this.parentElement && this.parentElement.element) { this.parentElement.element.removeEventListener('stepVisualizerChanged', this._stepVisualizerUpdateHandler); } this._stepVisualizerUpdateHandler = null; } if (this._mutationObserver) { this._mutationObserver.disconnect(); this._mutationObserver = null; } } /** * Update canvas position to match popup * @private */ _updateCanvasPosition() { if (!this.penCanvas || !this.penCanvas.container || !this.popup) return; const container = this.penCanvas.container; const popupRect = this.popup.svgObject ? this.popup.svgObject.getBoundingClientRect() : null; console.log('[Canvas Position Debug] Update triggered:', { hasCanvas: !!this.penCanvas, hasContainer: !!container, strokeCount: this.penCanvas?.strokes?.size || 0, popupRect: popupRect ? `${popupRect.width}x${popupRect.height}` : 'null' }); if (popupRect && popupRect.width > 0 && popupRect.height > 0) { // Calculate button areas based on popup dimensions const leftButtonArea = this.buttonSize + (this.margin * 2); const rightButtonArea = (this.buttonSize * 2) + (this.margin * 2) + this.margin; // Calculate content area within popup const contentWidth = Math.max(popupRect.width - leftButtonArea - rightButtonArea, this.canvasMinWidth); const contentHeight = Math.max(popupRect.height - (this.margin * 2) - (this.buttonSize * 2) - this.buttonSpacing, this.canvasMinHeight); // Position canvas within popup bounds const absoluteX = popupRect.left + leftButtonArea; const absoluteY = popupRect.top + this.margin; // Ensure canvas doesn't exceed popup bounds const maxWidth = popupRect.width - leftButtonArea - rightButtonArea; const maxHeight = popupRect.height - (this.margin * 2) - (this.buttonSize * 2) - this.buttonSpacing; const finalWidth = Math.min(Math.max(contentWidth, this.canvasMinWidth), maxWidth); const finalHeight = Math.min(Math.max(contentHeight, this.canvasMinHeight), maxHeight); console.log('[Canvas Position Debug] Moving canvas:', { from: `${container.style.left}, ${container.style.top}`, to: `${absoluteX}px, ${absoluteY}px`, size: `${finalWidth}x${finalHeight}` }); container.style.left = `${absoluteX}px`; container.style.top = `${absoluteY}px`; container.style.width = `${finalWidth}px`; container.style.height = `${finalHeight}px`; // Check stroke count after move setTimeout(() => { console.log('[Canvas Position Debug] After move stroke count:', this.penCanvas?.strokes?.size || 0); }, 50); } } /** * Download canvas as bitmap and transcribe * @private */ async _downloadCanvasAsBitmap() { if (!this.penCanvas) { return; } try { // Get the canvas SVG element const svgElement = this.penCanvas.svg; if (!svgElement) { return; } // Create a canvas element for conversion const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // Set canvas size to match SVG const svgRect = svgElement.getBoundingClientRect(); canvas.width = svgRect.width; canvas.height = svgRect.height; // Convert SVG to data URL const svgData = new XMLSerializer().serializeToString(svgElement); const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); // Create an image from the SVG const img = new Image(); img.onload = async () => { // Draw the image to canvas ctx.drawImage(img, 0, 0); // Convert to blob and transcribe (no download) canvas.toBlob(async (blob) => { URL.revokeObjectURL(url); await this._transcribeCanvas(blob); }, 'image/png'); }; img.src = url; } catch (error) { } } /** * Transcribe canvas content * @private */ async _transcribeCanvas(imageBlob) { try { // Import transcription service const { omdTranscriptionService } = await import('./omdTranscriptionService.js'); // Create transcription service instance (no API keys needed - server handles them) const transcriptionService = new omdTranscriptionService({ defaultProvider: 'gemini' }); // Check if service is available if (!transcriptionService.isAvailable()) { return; } // Transcribe with fallback const result = await transcriptionService.transcribeWithFallback(imageBlob, { prompt: 'Transcribe this handwritten mathematical expression. Return ONLY the pure mathematical expression with no formatting, no LaTeX, no dollar signs, no explanations. Use ^ for powers (e.g., 3^2), use / for fractions (e.g., (2x+1)/(x-3)), use * for multiplication, use + and - for addition/subtraction. Return only the expression.' }); if (result.text) { this._setSubmitButtonLoading(false); this.flashValidation(true); this.transcribedText = result.text; if (this.onValidateCallback) { this.onValidateCallback(); } } else { this._setSubmitButtonLoading(false); } } catch (error) { this.flashValidation(false); this._setSubmitButtonLoading(false); } } /** * Set submit button loading state * @private */ _setSubmitButtonLoading(isLoading) { if (!this.submitButton) return; if (isLoading) { // Start blinking animation this._startBlinkingAnimation(); } else { // Stop blinking and restore original state this._stopBlinkingAnimation(); this.submitButton.setText("✓"); this.submitButton.setFillColor('#2ECC71'); this.submitButton.setFontColor('white'); } } /** * Start blinking animation for submit button * @private */ _startBlinkingAnimation() { if (!this.submitButton) return; let isOrange = true; const blink = () => { if (isOrange) { this.submitButton.setFillColor('#FFA500'); // Orange } else { this.submitButton.setFillColor('#2ECC71'); // Green } isOrange = !isOrange; this.blinkAnimationId = setTimeout(blink, 300); // Blink every 300ms }; // Start blinking immediately blink(); } /** * Stop blinking animation for submit button * @private */ _stopBlinkingAnimation() { if (this.blinkAnimationId) { clearTimeout(this.blinkAnimationId); this.blinkAnimationId = null; } } /** * Reposition canvas relative to popup */ repositionCanvasRelativeToPopup() { this._updateCanvasPosition(); } }