UNPKG

@teachinglab/omd

Version:

omd

639 lines (548 loc) 21.9 kB
/** * omdNodeOverlay - A utility class for covering up nodes with overlay elements * * This class creates a visual overlay that can be positioned over any node * to hide or mask it. Useful for step-by-step reveals, progressive disclosure, * or highlighting specific parts of expressions. */ import { omdPopup } from './omdPopup.js'; import { jsvgGroup, jsvgRect } from '@teachinglab/jsvg'; /** * A flexible overlay system for covering nodes with customizable elements * @extends jsvgGroup */ export class omdNodeOverlay extends jsvgGroup { /** * Creates a new node overlay * @param {Object} options - Configuration options * @param {string} options.fillColor - Fill color for the overlay (default: 'white') * @param {string} options.strokeColor - Stroke color for the overlay (default: 'black') * @param {number} options.strokeWidth - Stroke width (default: 2) * @param {number} options.opacity - Opacity of the overlay (default: 1.0) * @param {number} options.padding - Padding around the target node (default: 0) * @param {number} options.cornerRadius - Corner radius for rounded rectangles (default: 4) * @param {string} options.overlayType - Type of overlay: 'rectangle', 'ellipse', 'text' (default: 'rectangle') * @param {boolean} options.animated - Whether to animate show/hide transitions (default: false) * @param {string} options.text - Initial text for text overlays (default: '') * @param {boolean} options.editable - Whether text overlay should be editable (default: false) */ constructor(options = {}) { super(); this.options = { fillColor: 'white', strokeColor: 'black', strokeWidth: 2, opacity: 1.0, padding: 0, cornerRadius: 4, overlayType: 'rectangle', animated: false, animationDuration: 300, text: '', editable: false, ...options }; this.targetNode = null; this.overlayElement = null; this.textElement = null; this.isVisible = false; this.animationId = null; this.clickLayer = null; this.savedHandlers = []; this.omdPopup = null; this._createOverlayElement(); } /** * Creates the actual overlay element based on the overlay type * @private */ _createOverlayElement() { switch (this.options.overlayType) { case 'rectangle': this.overlayElement = new jsvgRect(); if (this.options.cornerRadius > 0) { this.overlayElement.setCornerRadius(this.options.cornerRadius); } break; case 'ellipse': this.overlayElement = new jsvgEllipse(); break; case 'text': this._createTextOverlay(); return; default: this.overlayElement = new jsvgRect(); } this.overlayElement.setFillColor(this.options.fillColor); this.overlayElement.setStrokeColor(this.options.strokeColor); this.overlayElement.setStrokeWidth(this.options.strokeWidth); this.overlayElement.setOpacity(this.options.opacity); this.addChild(this.overlayElement); this.setOpacity(0); } /** * Creates a text overlay with background and editable text * @private */ _createTextOverlay() { // Create background rectangle this.overlayElement = new jsvgRect(); if (this.options.cornerRadius > 0) { this.overlayElement.setCornerRadius(this.options.cornerRadius); } this.overlayElement.setFillColor(this.options.fillColor); this.overlayElement.setStrokeColor(this.options.strokeColor); this.overlayElement.setStrokeWidth(this.options.strokeWidth); this.overlayElement.setOpacity(this.options.opacity); this.addChild(this.overlayElement); // Create a click-only layer for editable overlays if (this.options.editable) { this.clickLayer = new jsvgRect(); this.clickLayer.setWidthAndHeight(this.overlayElement.width, this.overlayElement.height); this.clickLayer.setPosition(0, 0); this.clickLayer.setFillColor('transparent'); this.clickLayer.setStrokeColor('transparent'); this.addChild(this.clickLayer); this.clickLayer.setClickCallback(() => { this.togglePopup(); }); } // For editable overlays, create a popup instead of inline text if (this.options.editable) { this.textElement = null; } else { // For static text overlays, use jsvgTextLine this.textElement = new jsvgTextLine(); this.textElement.setText(this.options.text); this.textElement.setTextAnchor('middle'); this.textElement.svgObject.setAttribute('dominant-baseline', 'middle'); this.addChild(this.textElement); } this.setOpacity(0); } /** * Targets a specific node to overlay * @param {omdNode} node - The node to cover * @returns {omdNodeOverlay} This overlay for method chaining */ coverNode(node) { if (!node) { console.warn('omdNodeOverlay: Cannot cover null or undefined node'); return this; } this.targetNode = node; // Save and override parent mouse handlers to prevent highlighting this.savedHandlers = []; let current = this.targetNode; while (current) { if (current.svgObject && typeof current.svgObject.onmouseenter === 'function') { this.savedHandlers.push({ element: current.svgObject, onmouseenter: current.svgObject.onmouseenter, onmouseleave: current.svgObject.onmouseleave, }); current.svgObject.onmouseenter = null; current.svgObject.onmouseleave = null; } current = current.parent; } // Add the overlay as a child of the target node's parent if (this.targetNode.parent) { node.parent.addChild(this); } this._updateOverlayPosition(); return this; } /** * Manually sets the overlay position and size * @param {number} x - X position * @param {number} y - Y position * @param {number} width - Width of the overlay * @param {number} height - Height of the overlay * @param {boolean} clearTargetNode - Whether to clear the target node * @returns {omdNodeOverlay} This overlay for method chaining */ setCoverArea(x, y, width, height, clearTargetNode = true) { if (clearTargetNode) { this.targetNode = null; } const padding = this.options.padding || 0; const adjustedX = x - padding; const adjustedY = y - padding; const adjustedWidth = width + (padding * 2); const adjustedHeight = height + (padding * 2); this.setPosition(adjustedX, adjustedY); this.overlayElement.setWidthAndHeight(adjustedWidth, adjustedHeight); // Update click layer size to match overlay if (this.clickLayer) { this.clickLayer.setWidthAndHeight(adjustedWidth, adjustedHeight); } // Position text element for text overlays if (this.options.overlayType === 'text' && this.textElement) { this.textElement.setPosition(adjustedWidth / 2, adjustedHeight / 2); } return this; } /** * Updates the overlay position to match the target node * @private */ _updateOverlayPosition() { if (!this.targetNode) return; // Get the position relative to the overlay's parent let nodePos; if (this.parent === this.targetNode.parent) { nodePos = { x: this.targetNode.xpos || this.targetNode.x || 0, y: this.targetNode.ypos || this.targetNode.y || 0 }; } else { nodePos = this._getGlobalPosition(this.targetNode); } let nodeWidth = this.targetNode.width || 0; let nodeHeight = this.targetNode.height || 0; // Prefer backRect dimensions if available if (this.targetNode.backRect) { nodeWidth = this.targetNode.backRect.width || nodeWidth; nodeHeight = this.targetNode.backRect.height || nodeHeight; } this.setCoverArea(nodePos.x, nodePos.y, nodeWidth, nodeHeight, false); } /** * Attempts to match visual properties of the target node * @private */ _matchNodeProperties() { if (!this.targetNode) return; // Try to match corner radius for rectangles if (this.options.overlayType === 'rectangle' && this.overlayElement.setCornerRadius) { let cornerRadius = 0; // First, check if the node has a backRect (common in OMD nodes) if (this.targetNode.backRect && this.targetNode.backRect.cornerRadius !== undefined) { cornerRadius = this.targetNode.backRect.cornerRadius; } // Fallback: check the main SVG object for rx/ry attributes else if (this.targetNode.svgObject) { const rx = this.targetNode.svgObject.getAttribute('rx'); const ry = this.targetNode.svgObject.getAttribute('ry'); cornerRadius = parseFloat(rx || ry || 0); } // Another fallback: check if the SVG object is a rect with corner radius else if (this.targetNode.svgObject && this.targetNode.svgObject.tagName === 'rect') { const rx = this.targetNode.svgObject.getAttribute('rx'); const ry = this.targetNode.svgObject.getAttribute('ry'); cornerRadius = parseFloat(rx || ry || 0); } // Apply the corner radius if found if (cornerRadius > 0) { this.overlayElement.setCornerRadius(cornerRadius); this.options.cornerRadius = cornerRadius; } } // Match font properties for text overlays if (this.options.overlayType === 'text' && this.textElement) { this._matchFontProperties(); } // Try to match stroke width if target has stroke if (this.targetNode.svgObject && !this.options.preserveStrokeWidth) { const targetStrokeWidth = this.targetNode.svgObject.getAttribute('stroke-width'); if (targetStrokeWidth) { const strokeWidth = parseFloat(targetStrokeWidth); if (strokeWidth > 0) { this.overlayElement.setStrokeWidth(strokeWidth); } } } } /** * Matches font properties from the target node * @private */ _matchFontProperties() { if (!this.targetNode || !this.textElement) return; // Get font size from target node let fontSize = 32; // Default fallback if (this.targetNode.getFontSize && typeof this.targetNode.getFontSize === 'function') { fontSize = this.targetNode.getFontSize(); } else if (this.targetNode.fontSize) { fontSize = this.targetNode.fontSize; } this.textElement.setFontSize(fontSize); // Try to match font family and color from target node's text element if (this.targetNode.textElement) { const targetTextSvg = this.targetNode.textElement.svgObject; if (targetTextSvg) { // Match font family const fontFamily = targetTextSvg.style.fontFamily || 'Arial, Helvetica, sans-serif'; this.textElement.setFontFamily(fontFamily); // Match font color - handle both jsvgTextLine and jsvgTextInput const fontColor = targetTextSvg.style.fill || targetTextSvg.getAttribute('fill') || 'black'; if (this.textElement.setFontColor) { this.textElement.setFontColor(fontColor); } else if (this.textElement.div) { // For jsvgTextInput, set color on the div this.textElement.div.style.color = fontColor; } // Match font weight if available const fontWeight = targetTextSvg.style.fontWeight; if (fontWeight && this.textElement.setFontWeight) { this.textElement.setFontWeight(fontWeight); } else if (fontWeight && this.textElement.div) { // For jsvgTextInput, set font weight on the div this.textElement.div.style.fontWeight = fontWeight; } } } } /** * Calculates the global position of a node * @param {omdNode} node - The node to get position for * @returns {Object} Object with x and y coordinates * @private */ _getGlobalPosition(node) { // Try to use SVG bounding box for accurate positioning if (node.svgObject && node.svgObject.getCTM) { try { const ctm = node.svgObject.getCTM(); if (ctm) { return { x: ctm.e, y: ctm.f }; } } catch (e) { // Fall back to manual calculation } } let x = node.xpos || node.x || 0; let y = node.ypos || node.y || 0; // Walk up the parent chain let parent = node.parent; while (parent && parent.xpos !== undefined) { x += parent.xpos || parent.x || 0; y += parent.ypos || parent.y || 0; parent = parent.parent; } return { x, y }; } /** * Creates the popup instance * @private */ _createPopup() { if (!this.options.editable || this.omdPopup) return; // Create the popup instance this.omdPopup = new omdPopup(this.targetNode, this.parent, { animationDuration: this.options.animationDuration }); // Set initial text value this.omdPopup.setValue(this.options.text); // Set up validation callback this.omdPopup.setValidationCallback(() => { const currentText = this.omdPopup.getValue(); if (this.targetNode) { const nodeText = this.targetNode.text || this.targetNode.toString() || ""; if (this.omdPopup.areExpressionsEquivalent(currentText.trim(), nodeText.trim())) { // Show success and destroy overlay this.omdPopup.flashValidation(true); setTimeout(() => { this.destroy(); }, 500); } else { // Show error this.omdPopup.flashValidation(false); } } }); // Set up clear callback this.omdPopup.setClearCallback(() => { this.omdPopup.setValue(''); }); } /** * Toggles the popup visibility * @returns {Promise} Promise that resolves when animation completes */ togglePopup() { if (!this.options.editable) return Promise.resolve(); if (!this.omdPopup) { this._createPopup(); } const overlayX = this.xpos || this.x || 0; const overlayY = this.ypos || this.y || 0; const overlayWidth = this.overlayElement.width || this.targetNode.width || 100; const overlayHeight = this.overlayElement.height || this.targetNode.height || 40; const popupWidth = 400; const popupX = overlayX + (overlayWidth / 2) - (popupWidth / 2); const popupY = overlayY + overlayHeight + 10; return this.omdPopup.toggle(popupX, popupY); } /** * Hides and removes the popup * @private */ _hidePopup() { if (this.omdPopup) { this.omdPopup.destroy(); this.omdPopup = null; } } /** * Shows the overlay * @param {boolean} animated - Whether to animate the show * @returns {Promise} Promise that resolves when animation completes */ show(animated = this.options.animated) { return new Promise((resolve) => { if (this.isVisible) { resolve(); return; } this.isVisible = true; if (animated) { this._animateOpacity(0, this.options.opacity, this.options.animationDuration) .then(resolve); } else { this.setOpacity(this.options.opacity); resolve(); } }); } /** * Animates the opacity from one value to another * @private */ _animateOpacity(fromOpacity, toOpacity, duration) { return new Promise((resolve) => { if (this.animationId) { cancelAnimationFrame(this.animationId); } 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); this.setOpacity(currentOpacity); if (progress < 1) { this.animationId = requestAnimationFrame(animate); } else { this.animationId = null; resolve(); } }; this.animationId = requestAnimationFrame(animate); }); } /** * Removes the overlay and cleans up resources * @param {boolean} animated - Whether to animate the destruction * @returns {Promise} Promise that resolves when destruction is complete */ destroy(animated = this.options.animated) { return new Promise((resolve) => { if (animated && this.isVisible) { this._animateOpacity(this.options.opacity, 0, this.options.animationDuration) .then(() => { this._performDestroy(); resolve(); }); } else { this._performDestroy(); resolve(); } }); } /** * Performs the actual destruction without animation * @private */ _performDestroy() { if (this.animationId) { cancelAnimationFrame(this.animationId); } // Restore original mouse handlers this.savedHandlers.forEach(({ element, onmouseenter, onmouseleave }) => { if (element) { element.onmouseenter = onmouseenter; element.onmouseleave = onmouseleave; } }); this._hidePopup(); this.targetNode = null; this.isVisible = false; if (this.parent) { this.parent.removeChild(this); } } // === ESSENTIAL GETTERS/SETTERS === /** * Checks if the overlay is currently visible */ getIsVisible() { return this.isVisible; } /** * Gets the currently targeted node */ getTargetNode() { return this.targetNode; } /** * Sets the text content for text overlays */ setText(text) { if (this.textElement) { this.textElement.setText(text); this.options.text = text; } return this; } /** * Gets the text content from text overlays */ getText() { if (this.textElement) { return this.textElement.getText(); } return this.options.text; } } /** * Factory function for creating common overlay presets */ export class omdNodeOverlayPresets { /** * Creates a simple white overlay for hiding content */ static createHidingOverlay(enableTextInput = false, initialText = null) { const config = { fillColor: 'white', strokeColor: 'black', strokeWidth: 2, padding: 1, animated: true }; if (enableTextInput) { config.overlayType = 'text'; config.text = initialText || ''; config.editable = true; config.fillColor = 'white'; config.strokeColor = 'black'; config.strokeWidth = 2; } return new omdNodeOverlay(config); } /** * Creates a highlighted border overlay for feedback */ static createHighlightOverlay(color = '#ffff00') { return new omdNodeOverlay({ fillColor: 'none', strokeColor: color, strokeWidth: 3, padding: 2, cornerRadius: 4, animated: true }); } }