alpha-mask-events
Version:
Enable click‑through on transparent regions of images (PNG, WebP, AVIF, GIF, SVG) using alpha masks.
1,236 lines (1,085 loc) • 54.4 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
/**
* Default transparency threshold - pixels with alpha less than or equal to this value
* will be click-through. Set very close to 1 to make nearly transparent pixels click-through.
*/
const DEFAULT_THRESHOLD = 0.999; // Pixels with alpha > this are considered opaque
/**
* Global image cache to avoid reloading identical images
*/
const MASK_CACHE = new Map();
/**
* Supported image formats with transparency capability
*/
const SUPPORTED_FORMATS = {
// Formats with full alpha channel support
'png': { hasAlpha: true, browserSupport: 'universal' },
'webp': { hasAlpha: true, browserSupport: 'modern' }, // Chrome 23+, Firefox 65+, Safari 14+
'avif': { hasAlpha: true, browserSupport: 'latest' }, // Chrome 85+, Firefox 93+, Safari 16.4+
// Formats with limited transparency
'gif': { hasAlpha: 'limited', browserSupport: 'universal' }, // Binary transparency only
// Formats without transparency (processed but with warnings)
'jpg': { hasAlpha: false, browserSupport: 'universal' },
'jpeg': { hasAlpha: false, browserSupport: 'universal' },
'bmp': { hasAlpha: false, browserSupport: 'limited' },
'tiff': { hasAlpha: 'limited', browserSupport: 'limited' },
'ico': { hasAlpha: 'limited', browserSupport: 'limited' },
'svg': { hasAlpha: true, browserSupport: 'modern' } // SVG can have transparency via CSS/opacity
};
/**
* Detect and validate image format from URL
* @param {string} src - Image source URL
* @returns {Object} Format information
*/
function detectImageFormat(src) {
// Extract extension from URL (handle query params, fragments)
const urlPath = src.split('?')[0].split('#')[0];
const extension = urlPath.split('.').pop()?.toLowerCase();
if (!extension) {
return { format: 'unknown', info: null, warning: 'Unable to detect image format from URL' };
}
const formatInfo = SUPPORTED_FORMATS[extension];
if (!formatInfo) {
return {
format: extension,
info: null,
warning: `Unsupported or unknown format: ${extension}. Transparency detection may not work.`
};
}
const warnings = [];
// Add browser support warnings
if (formatInfo.browserSupport === 'modern') {
warnings.push(`${extension.toUpperCase()} requires modern browser support`);
} else if (formatInfo.browserSupport === 'latest') {
warnings.push(`${extension.toUpperCase()} requires very recent browser support`);
}
// Add transparency capability warnings
if (formatInfo.hasAlpha === false) {
warnings.push(`${extension.toUpperCase()} format does not support transparency`);
} else if (formatInfo.hasAlpha === 'limited') {
warnings.push(`${extension.toUpperCase()} has limited transparency support`);
}
return {
format: extension,
info: formatInfo,
warning: warnings.length > 0 ? warnings.join('; ') : null
};
}
/**
* Manager class that handles the core functionality for alpha mask event processing.
*
* This class maintains a registry of elements with alpha masks, processes pointer events,
* and dynamically adjusts pointer-events CSS properties based on pixel transparency.
*/
class Manager {
/**
* Create a new Manager instance.
*
* @param {Object} options - Configuration options
* @param {number} [options.threshold=0.999] - Global transparency threshold (0-1). Alpha values *strictly greater* than this are considered opaque.
* @param {boolean} [options.log=false] - Enable debug logging
* @param {boolean} [options.useIntersectionObserver=true] - Enable automatic performance optimization for off-screen elements
* @param {string} [options.intersectionRootMargin='100px'] - Root margin for IntersectionObserver
*/
constructor({ threshold = DEFAULT_THRESHOLD, log = false, useIntersectionObserver = true, intersectionRootMargin = '100px' } = {}) {
this.threshold = threshold;
this.log = log;
this.useIntersectionObserver = useIntersectionObserver;
this.intersectionRootMargin = intersectionRootMargin;
// Registry stores: { el, canvas, ctx, threshold, originalPointerEvents, img, imageLoaded, currentSrc }
this.registry = new Map(); // Use Map for easier element lookup/removal
this._handler = this._onPointerEvent.bind(this);
this._rafPending = false; // Flag to prevent redundant animation frames
this._lastEvent = null; // Store most recent event for delayed processing
this._mutationObserver = null; // For observing DOM changes
this._resizeObservers = new WeakMap(); // Track resize observers per element
this._intersectionObserver = null; // For performance optimization
this._intersectionElements = new Set(); // Track elements under intersection observation
this._compatibilityWarningShown = false; // Track if browser compatibility warning has been shown
this._listenersAttached = false; // Track if global listeners are attached
}
/**
* Scan the document for elements with the 'alpha-mask-events' class and register them.
* Also sets up observation for future DOM changes to auto-register new elements.
*/
scan() {
// Show browser compatibility warnings if needed
if (this.log) {
this._showBrowserCompatibilityWarning();
}
document.querySelectorAll('.alpha-mask-events')
.forEach(el => this.add(el));
this._observeMutations();
this._setupIntersectionObserver();
}
/**
* Register an element for alpha mask hit-testing.
*
* @param {HTMLElement|string} elOrSelector - DOM element or CSS selector to register
* @param {Object} opts - Configuration options for this specific element
* @param {number} [opts.threshold] - Per-element transparency threshold (0-1)
* @returns {void}
*/
add(elOrSelector, opts = {}) {
const el = typeof elOrSelector === 'string'
? document.querySelector(elOrSelector)
: elOrSelector;
if (!el || !(el instanceof HTMLElement) || this.registry.has(el)) {
if (this.log && el && this.registry.has(el)) console.log('AME: Element already registered', el);
else if (this.log && !el) console.warn('AME: Element not found for selector', elOrSelector);
else if (this.log) console.warn('AME: Invalid element provided', el);
return;
}
const threshold = opts.threshold ?? this.threshold;
const computedStyle = getComputedStyle(el);
let src;
// Determine image source (img.src or background-image)
if (el.tagName === 'IMG') {
src = el.currentSrc || el.src; // Use currentSrc for responsive images
} else {
const bg = computedStyle.backgroundImage;
const match = bg.match(/url\((['"]?)(.*?)\1\)/); // More robust regex
if (!match || !match[2]) {
if (this.log) console.warn('AME: No background-image URL found for element', el);
return;
}
src = match[2];
}
if (!src) {
if (this.log) console.warn('AME: Could not determine image source for element', el);
return;
}
// Detect and validate image format
const formatDetection = detectImageFormat(src);
if (this.log && formatDetection.warning) {
console.warn(`AME: Format warning for ${src}: ${formatDetection.warning}`);
}
// Enhanced logging for format detection
if (this.log) {
console.log(`AME: Detected format: ${formatDetection.format?.toUpperCase() || 'unknown'} for ${src}`);
if (formatDetection.info) {
const alphaSupportText = formatDetection.info.hasAlpha === true ? '✅ Full alpha' :
formatDetection.info.hasAlpha === 'limited' ? '⚠️ Limited alpha' :
'❌ No alpha';
console.log(`AME: Format capabilities - ${alphaSupportText}, Browser: ${formatDetection.info.browserSupport}`);
}
}
// Create canvas and context immediately
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
console.error('AME: Failed to get 2D context for canvas. Alpha masking disabled for element.', el);
return;
}
// Store original pointer-events and set to 'none' initially
const originalPointerEvents = el.style.pointerEvents || computedStyle.pointerEvents; // Get computed if not inline
el.style.pointerEvents = 'none'; // Start as non-interactive
// Prepare registry entry
const entry = {
el,
canvas,
ctx,
threshold,
originalPointerEvents,
img: null, // Image object will be loaded
imageLoaded: false,
currentSrc: src,
isVisible: true, // Assume visible initially (will be updated by IntersectionObserver if enabled)
_lastOpaqueState: null, // Track opaque/transparent state for custom events
_transformCache: null, // Cache for transform matrix calculations (performance optimization)
_lastTransform: computedStyle.transform // Track transform changes for cache invalidation
};
this.registry.set(el, entry);
if (this.log) console.log('AME: Registering element', el, 'with src:', src, 'threshold:', threshold);
// Check cache before loading
if (MASK_CACHE.has(src)) {
const cachedImg = MASK_CACHE.get(src);
entry.img = cachedImg;
entry.imageLoaded = true;
this._drawBackgroundToCanvas(entry);
// Setup observers immediately since image is already loaded
if ('ResizeObserver' in window) {
const ro = new ResizeObserver(() => this._updateCanvas(entry));
ro.observe(el);
this._resizeObservers.set(el, ro);
} else if (this.log) {
console.warn('AME: ResizeObserver not supported. Layout changes might affect accuracy.');
}
if (this.useIntersectionObserver) {
if (!this._intersectionObserver) {
this._setupIntersectionObserver();
}
if (this._intersectionObserver && !this._intersectionElements.has(el)) {
this._intersectionObserver.observe(el);
this._intersectionElements.add(el);
entry.isVisible = true;
}
}
} else {
// Load new image
const img = new window.Image();
img.crossOrigin = 'Anonymous';
img.onload = () => {
if (this.log) console.log('AME: Image loaded successfully for', el, src);
entry.img = img;
entry.imageLoaded = true;
this._drawBackgroundToCanvas(entry);
// Setup observers
if ('ResizeObserver' in window) {
const ro = new ResizeObserver(() => this._updateCanvas(entry));
ro.observe(el);
this._resizeObservers.set(el, ro);
} else if (this.log) {
console.warn('AME: ResizeObserver not supported. Layout changes might affect accuracy.');
}
if (this.useIntersectionObserver) {
if (!this._intersectionObserver) {
this._setupIntersectionObserver();
}
if (this._intersectionObserver && !this._intersectionElements.has(el)) {
this._intersectionObserver.observe(el);
this._intersectionElements.add(el);
entry.isVisible = true;
}
}
// Add to cache for future use
MASK_CACHE.set(src, img);
};
img.onerror = (e) => {
const errorDetails = {
element: el,
src,
error: e.type || 'unknown',
format: formatDetection.format,
timestamp: new Date().toISOString()
};
console.error('AME: Image loading failed - implementing recovery strategies', errorDetails);
console.group('AME: Image Loading Recovery Guide');
console.info('🔧 Troubleshooting Steps:');
console.info('1. Check if the image URL is accessible:', src);
console.info('2. Verify CORS headers if cross-origin:',
src.startsWith('http') && !src.startsWith(window.location.origin) ?
'⚠️ Cross-origin detected' : '✓ Same-origin');
console.info('3. Image format compatibility:');
if (formatDetection.format && formatDetection.info) {
console.info(` • Format: ${formatDetection.format.toUpperCase()}`);
console.info(` • Browser support: ${formatDetection.info.browserSupport}`);
console.info(` • Alpha support: ${formatDetection.info.hasAlpha === true ? 'Full' :
formatDetection.info.hasAlpha === 'limited' ? 'Limited' : 'None'}`);
// Format-specific advice
if (formatDetection.format === 'webp') {
console.info(' 💡 WebP: Ensure browser supports WebP (Chrome 23+, Firefox 65+, Safari 14+)');
} else if (formatDetection.format === 'avif') {
console.info(' 💡 AVIF: Requires very recent browser (Chrome 85+, Firefox 93+, Safari 16.4+)');
} else if (!formatDetection.info.hasAlpha) {
console.info(' ⚠️ This format doesn\'t support transparency');
}
} else {
console.info(' ⚠️ Unknown or unsupported format detected');
console.info(' 📋 Fully supported: PNG, WebP, AVIF, GIF');
console.info(' 📋 Partially supported: SVG, JPEG (no transparency), BMP, TIFF');
}
console.info('4. Check network connectivity and server availability');
console.info('💡 Alternative Solutions:');
console.info('• Use the CLI tool to pre-generate masks: npx ame-generate-masks');
console.info('• Implement server-side image processing');
console.info('• Use PNG format for maximum compatibility');
console.info('• Use same-origin images when possible');
console.groupEnd();
// Clean up if image fails to load
this.remove(el);
};
img.src = src; // Start loading
// Add to cache after successful load
MASK_CACHE.set(src, img);
}
}
/**
* Unregister an element from alpha mask hit-testing.
*
* @param {HTMLElement|string} elOrSelector - DOM element or CSS selector to unregister
*/
remove(elOrSelector) {
const el = typeof elOrSelector === 'string'
? document.querySelector(elOrSelector)
: elOrSelector;
if (!el || !this.registry.has(el)) {
if (this.log && !el) console.warn('AME: Element not found for removal', elOrSelector);
else if (this.log) console.log('AME: Element not registered, cannot remove', el);
return;
}
const entry = this.registry.get(el);
// Restore original pointer-events
el.style.pointerEvents = entry.originalPointerEvents;
// Disconnect ResizeObserver
const ro = this._resizeObservers.get(el);
if (ro) {
ro.disconnect();
this._resizeObservers.delete(el);
}
// Remove from IntersectionObserver if enabled
if (this._intersectionObserver && this._intersectionElements.has(el)) {
this._intersectionObserver.unobserve(el);
this._intersectionElements.delete(el);
}
// Remove from registry
this.registry.delete(el);
if (this.log) console.log('AME: Unregistered element', el);
// If no elements left, consider detaching global listeners (optional optimization)
// if (this.registry.size === 0) {
// this.detachListeners();
// }
}
/**
* Set the transparency threshold for all registered elements or a specific one.
*
* @param {number} val - New threshold value (0-1)
* @param {HTMLElement|string} [elOrSelector] - Optional element or selector to target
*/
setThreshold(val, elOrSelector) {
const threshold = Math.max(0, Math.min(1, val)); // Clamp between 0 and 1
if (elOrSelector) {
const el = typeof elOrSelector === 'string'
? document.querySelector(elOrSelector)
: elOrSelector;
if (el && this.registry.has(el)) {
this.registry.get(el).threshold = threshold;
if (this.log) console.log('AME: Updated threshold for specific element', el, threshold);
} else if (this.log) {
console.warn('AME: Element not found or not registered for setThreshold', elOrSelector);
}
} else {
this.threshold = threshold; // Update global default
this.registry.forEach(entry => {
entry.threshold = threshold; // Update all existing entries
});
if (this.log) console.log('AME: Updated global threshold', threshold);
}
}
/**
* Attach global event listeners for pointer events.
*/
attachListeners() {
// Check if listeners are already attached (simple check)
if (this._listenersAttached) return;
// Use pointer events if available, fallback to mouse/touch
if (window.PointerEvent) {
document.addEventListener('pointermove', this._handler, { passive: true });
document.addEventListener('pointerdown', this._handler, { passive: true }); // Handle before clicks/taps
document.addEventListener('pointerover', this._handler, { passive: true }); // Handle hover/enter
} else {
document.addEventListener('mousemove', this._handler, { passive: true });
document.addEventListener('touchmove', this._handler, { passive: true });
document.addEventListener('mousedown', this._handler, { passive: true }); // Handle before clicks
document.addEventListener('touchstart', this._handler, { passive: true }); // Handle before taps
document.addEventListener('mouseover', this._handler, { passive: true }); // Handle hover
}
this._listenersAttached = true;
if (this.log) console.log('AME: Attached global listeners');
}
/**
* Remove all global event listeners and clean up observers.
*/
detachListeners() {
if (!this._listenersAttached) return;
if (window.PointerEvent) {
document.removeEventListener('pointermove', this._handler);
document.removeEventListener('pointerdown', this._handler);
document.removeEventListener('pointerover', this._handler);
} else {
document.removeEventListener('mousemove', this._handler);
document.removeEventListener('touchmove', this._handler);
document.removeEventListener('mousedown', this._handler);
document.removeEventListener('touchstart', this._handler);
document.removeEventListener('mouseover', this._handler);
}
// Clean up MutationObserver
if (this._mutationObserver) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
// Clean up IntersectionObserver
if (this._intersectionObserver) {
this._intersectionObserver.disconnect();
this._intersectionObserver = null;
this._intersectionElements.clear();
}
// Clean up all ResizeObservers and remove elements from registry
this.registry.forEach((entry, el) => {
this.remove(el); // Use remove to handle cleanup logic
});
// Ensure registry and observer map are clear
this.registry.clear();
this._resizeObservers = new WeakMap(); // Re-initialize
this._listenersAttached = false;
if (this.log) console.log('AME: Detached global listeners and cleaned up observers/registry');
}
/**
* Handle pointer/touch/mouse events with performance optimization (RAF throttling).
*
* @param {Event} e - The original DOM event (PointerEvent, MouseEvent, TouchEvent)
* @private
*/
_onPointerEvent(e) {
this._lastEvent = e; // Store the latest event
if (!this._rafPending) {
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._lastEvent) { // Ensure an event exists
this._hitTest(this._lastEvent);
}
});
}
}
/**
* Performs the core hit-testing logic. Iterates through registered elements,
* checks if the pointer is within bounds, samples the alpha value from the
* internal canvas, and sets pointer-events accordingly.
*
* @param {Event} e - The pointer, touch, or mouse event to process
* @private
*/
_hitTest(e) {
let clientX, clientY;
// Extract coordinates consistently
if (e.touches && e.touches.length > 0) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else if (typeof e.clientX !== 'undefined' && typeof e.clientY !== 'undefined') {
clientX = e.clientX;
clientY = e.clientY;
} else {
// Event type doesn't have coordinates we can use
return;
}
// Iterate through registered elements
this.registry.forEach((entry) => {
const { el, canvas, ctx, threshold, originalPointerEvents, imageLoaded, isVisible } = entry;
// Performance optimization: Skip processing for off-screen elements
if (this.useIntersectionObserver && isVisible === false) {
return; // Element is not visible, skip expensive hit-testing
}
// Skip if image hasn't loaded yet or canvas context failed
if (!imageLoaded || !ctx) {
// Ensure pointerEvents remains 'none' until ready
if (el.style.pointerEvents !== 'none') {
el.style.pointerEvents = 'none';
}
return;
}
const rect = el.getBoundingClientRect();
// Check if pointer is within the element's bounding box
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
// Pointer is inside the element bounds, perform alpha check
// Map screen coordinates to canvas coordinates with CSS transform support
// This handles rotation, scaling, skewing, and other CSS transforms
const { canvasX, canvasY } = this._mapPointerToCanvasCoordinates(
clientX, clientY, el, rect, canvas, entry
);
let alpha = 0; // Default to transparent if sampling fails
// Ensure coordinates are within canvas bounds before sampling
if (canvasX >= 0 && canvasX < canvas.width && canvasY >= 0 && canvasY < canvas.height) {
try {
// Sample the alpha value from the corresponding pixel on the internal canvas
const pixelData = ctx.getImageData(canvasX, canvasY, 1, 1).data;
alpha = pixelData[3] / 255; // Alpha is the 4th component (0-255)
} catch (err) {
// Enhanced CORS error recovery with fallback strategies
if (this.log && !entry._loggedImageDataError) {
console.warn('AME: getImageData failed - implementing fallback strategy.', {
element: el,
error: err.message,
src: entry.currentSrc,
fallback: 'Using bounding box approximation'
});
console.info('AME: CORS Recovery Tips:\n' +
'• Ensure images have crossOrigin="anonymous" attribute\n' +
'• Verify server sends Access-Control-Allow-Origin headers\n' +
'• Consider using the CLI tool for pre-generated masks\n' +
'• Use same-origin images when possible');
entry._loggedImageDataError = true;
}
// Fallback Strategy 1: Bounding box approximation
// Use element's bounding rectangle to approximate click behavior
alpha = this._approximateAlphaFromBounds(el, clientX, clientY, rect);
}
} else {
if (this.log) console.log('AME: Calculated coords outside canvas bounds', { canvasX, canvasY, canvasW: canvas.width, canvasH: canvas.height });
// Treat as transparent if outside calculated canvas bounds
alpha = 0;
}
// Apply threshold: Opaque => 'auto'; Transparent => 'none'
const newPointerEvents = alpha > threshold ? 'auto' : 'none';
const isOpaque = alpha > threshold;
// Dispatch custom events when transitioning between opaque/transparent states
if (entry._lastOpaqueState !== isOpaque) {
if (isOpaque) {
// Transitioning from transparent to opaque
this._dispatchAlphaMaskEvent(el, 'alpha-mask-over', {
alpha,
coordinates: { x: canvasX, y: canvasY },
threshold,
element: el
});
} else {
// Transitioning from opaque to transparent
this._dispatchAlphaMaskEvent(el, 'alpha-mask-out', {
alpha,
coordinates: { x: canvasX, y: canvasY },
threshold,
element: el
});
}
entry._lastOpaqueState = isOpaque;
}
// Update style only if it changed to avoid unnecessary style recalcs
if (el.style.pointerEvents !== newPointerEvents) {
el.style.pointerEvents = newPointerEvents;
if (this.log > 1) console.log(`AME: Set pointerEvents=${newPointerEvents} (alpha=${alpha.toFixed(3)}) on`, el);
}
} else {
// Pointer is outside the element bounds, restore original style if needed
if (el.style.pointerEvents !== originalPointerEvents) {
el.style.pointerEvents = originalPointerEvents;
if (this.log > 1) console.log(`AME: Pointer left bounds, restored pointerEvents=${originalPointerEvents} on`, el);
}
// Dispatch alpha-mask-out event if we were previously in an opaque state
if (entry._lastOpaqueState === true) {
this._dispatchAlphaMaskEvent(el, 'alpha-mask-out', {
alpha: 0, // Outside bounds, treat as transparent
coordinates: { x: -1, y: -1 }, // Invalid coordinates to indicate outside bounds
threshold,
element: el
});
entry._lastOpaqueState = null; // Reset state when outside bounds
}
}
});
}
/**
* Dispatch a custom alpha mask event on the specified element.
*
* @param {HTMLElement} element - The element to dispatch the event on
* @param {string} eventType - The event type ('alpha-mask-over' or 'alpha-mask-out')
* @param {Object} detail - Event detail object
* @private
*/
_dispatchAlphaMaskEvent(element, eventType, detail) {
try {
const event = new CustomEvent(eventType, {
detail,
bubbles: false, // Don't bubble by default
cancelable: false // Not cancelable
});
element.dispatchEvent(event);
if (this.log > 1) {
console.log(`AME: Dispatched ${eventType} on`, element, 'with detail:', detail);
}
} catch (error) {
if (this.log) {
console.warn(`AME: Failed to dispatch ${eventType} event:`, error);
}
}
}
/**
* Sets up a MutationObserver to automatically add/remove elements with the class.
* @private
*/
_observeMutations() {
if (!('MutationObserver' in window) || this._mutationObserver) return;
this._mutationObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
// Handle added nodes
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check the node itself
if (node.matches && node.matches('.alpha-mask-events')) {
this.add(node);
}
// Check descendants if the added node is a container
if (node.querySelectorAll) {
node.querySelectorAll('.alpha-mask-events').forEach(el => this.add(el));
}
}
});
// Handle removed nodes
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check the node itself
if (this.registry.has(node)) { // Check if it was registered
this.remove(node);
}
// Check descendants (less common, but possible if a container was removed)
if (node.querySelectorAll) {
node.querySelectorAll('*').forEach(el => {
if (this.registry.has(el)) {
this.remove(el);
}
});
}
}
});
// Handle attribute changes (e.g., class added/removed, src changed)
if (mutation.type === 'attributes') {
const targetElement = mutation.target;
if (targetElement.nodeType === Node.ELEMENT_NODE) {
const wasRegistered = this.registry.has(targetElement);
const hasClass = targetElement.classList.contains('alpha-mask-events');
const currentSrc = targetElement.tagName === 'IMG' ? (targetElement.currentSrc || targetElement.src) : getComputedStyle(targetElement).backgroundImage.match(/url\((['"]?)(.*?)\1\)/)?.[2];
const registeredEntry = this.registry.get(targetElement);
if (hasClass && !wasRegistered) {
// Class added, register it
this.add(targetElement);
} else if (!hasClass && wasRegistered) {
// Class removed, unregister it
this.remove(targetElement);
} else if (wasRegistered && currentSrc && registeredEntry && currentSrc !== registeredEntry.currentSrc) {
// Source changed (e.g., img src or background-image)
if (this.log) console.log('AME: Image source changed, re-processing element', targetElement);
// Re-process: remove old, add new (simplest way to handle src change)
this.remove(targetElement);
this.add(targetElement); // Re-add will pick up the new source
} else if (wasRegistered && registeredEntry && mutation.attributeName === 'style') {
// Style changed - invalidate transform cache for transform-related changes
const currentTransform = getComputedStyle(targetElement).transform;
if (registeredEntry._transformCache && registeredEntry._lastTransform !== currentTransform) {
if (this.log > 1) console.log('AME: Transform changed, invalidating cache', targetElement);
registeredEntry._transformCache = null; // Clear transform cache
registeredEntry._lastTransform = currentTransform;
}
}
}
}
});
});
this._mutationObserver.observe(document.body, {
childList: true, // Observe direct children additions/removals
subtree: true, // Observe all descendants
attributes: true, // Observe attribute changes
attributeFilter: ['class', 'src', 'style'] // Focus on relevant attributes (style for background-image)
});
if (this.log) console.log('AME: MutationObserver attached');
}
/**
* Sets up IntersectionObserver for performance optimization.
* Automatically disables hit-testing for off-screen elements.
* @private
*/
_setupIntersectionObserver() {
if (!this.useIntersectionObserver || !('IntersectionObserver' in window) || this._intersectionObserver) {
return;
}
this._intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const el = entry.target;
const registryEntry = this.registry.get(el);
if (!registryEntry) return; // Element may have been unregistered
const isVisible = entry.isIntersecting;
registryEntry.isVisible = isVisible;
if (this.log > 1) {
console.log(`AME: Element ${isVisible ? 'entered' : 'left'} viewport`, el);
}
// Optionally disable pointer event processing for invisible elements
// This can significantly improve performance on pages with many images
if (!isVisible) {
// Temporarily restore original pointer events when off-screen
el.style.pointerEvents = registryEntry.originalPointerEvents;
}
});
}, {
rootMargin: this.intersectionRootMargin,
threshold: [0, 0.1] // Trigger when element starts entering/leaving viewport
});
if (this.log) console.log('AME: IntersectionObserver setup complete');
}
/**
* Updates the canvas for an element, typically after a resize.
* Re-calculates dimensions and redraws the background respecting CSS styles.
*
* @param {object} entry - The registry entry for the element.
* @private
*/
_updateCanvas(entry) {
if (!entry || !entry.imageLoaded || !entry.el || !entry.canvas) {
if (this.log) console.warn('AME: _updateCanvas called with invalid or incomplete entry', entry);
return;
}
const { el, canvas } = entry;
const rect = el.getBoundingClientRect();
// Check if dimensions actually changed to avoid unnecessary redraws
// Note: Using integer dimensions for canvas
const newWidth = Math.round(rect.width);
const newHeight = Math.round(rect.height);
if (canvas.width === newWidth && canvas.height === newHeight) {
// Dimensions haven't changed significantly, no redraw needed
return;
}
if (newWidth <= 0 || newHeight <= 0) {
if (this.log) console.log('AME: Element resized to zero or negative dimensions, skipping canvas update', el);
// Optionally clear the canvas or handle as needed
canvas.width = 1; // Set to minimal size
canvas.height = 1;
entry.ctx.clearRect(0, 0, 1, 1);
return;
}
if (this.log) console.log('AME: Resizing canvas for element', el, `from ${canvas.width}x${canvas.height} to ${newWidth}x${newHeight}`);
// Resize the canvas
canvas.width = newWidth;
canvas.height = newHeight;
// Redraw the background with new dimensions/styles
this._drawBackgroundToCanvas(entry);
}
/**
* Draws the element's image (src or background) onto its internal canvas,
* attempting to respect background-size and background-position.
*
* @param {object} entry - The registry entry { el, canvas, ctx, img, ... }
* @private
*/
_drawBackgroundToCanvas(entry) {
const { el, canvas, ctx, img } = entry;
if (!img || !ctx || !canvas || canvas.width <= 0 || canvas.height <= 0) {
if (this.log) console.warn('AME: Cannot draw background, missing image, context, or canvas dimensions are invalid', entry);
return; // Cannot draw if image isn't loaded or canvas is invalid
}
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const imgWidth = img.naturalWidth;
const imgHeight = img.naturalHeight;
if (imgWidth <= 0 || imgHeight <= 0) {
if (this.log) console.warn('AME: Image has zero dimensions, cannot draw.', img);
return;
}
const computedStyle = getComputedStyle(el);
const bgSize = computedStyle.backgroundSize;
const bgPos = computedStyle.backgroundPosition;
// const bgRepeat = computedStyle.backgroundRepeat; // TODO: Handle repeat? Complex.
ctx.clearRect(0, 0, canvasWidth, canvasHeight); // Clear previous content
// --- Calculate destination rectangle (dx, dy, dw, dh) based on background-size ---
let dw, dh;
const imgRatio = imgWidth / imgHeight;
const canvasRatio = canvasWidth / canvasHeight;
if (bgSize === 'cover') {
if (imgRatio > canvasRatio) { // Image wider than canvas ratio
dh = canvasHeight;
dw = dh * imgRatio;
} else { // Image taller than canvas ratio
dw = canvasWidth;
dh = dw / imgRatio;
}
} else if (bgSize === 'contain') {
if (imgRatio > canvasRatio) { // Image wider than canvas ratio
dw = canvasWidth;
dh = dw / imgRatio;
} else { // Image taller than canvas ratio
dh = canvasHeight;
dw = dh * imgRatio;
}
} else if (bgSize === 'auto' || bgSize === 'auto auto') {
dw = imgWidth;
dh = imgHeight;
} else {
// Try parsing pixel/percentage values (simplistic)
const parts = bgSize.split(' ');
const sizeX = this._parseCssDimension(parts[0], canvasWidth, imgWidth);
const sizeY = this._parseCssDimension(parts[1] || parts[0], canvasHeight, imgHeight); // Use first value if second is missing
if (parts[0] === 'auto' && parts[1] && parts[1] !== 'auto') {
dh = sizeY;
dw = dh * imgRatio;
} else if (parts[1] === 'auto' && parts[0] && parts[0] !== 'auto') {
dw = sizeX;
dh = dw / imgRatio;
} else {
dw = sizeX;
dh = sizeY;
}
}
// Ensure dimensions are positive
dw = Math.max(1, Math.round(dw));
dh = Math.max(1, Math.round(dh));
// --- Calculate destination offset (dx, dy) based on background-position ---
let dx, dy;
const posParts = bgPos.split(' ');
const posX = this._parseCssPosition(posParts[0], canvasWidth, dw);
const posY = this._parseCssPosition(posParts[1] || posParts[0], canvasHeight, dh); // Use first if second missing
dx = Math.round(posX);
dy = Math.round(posY);
// --- Draw the image ---
// We draw the *entire* source image into the calculated destination rectangle
try {
// Use the 9-argument drawImage to draw the whole source image into the calculated dest rect
ctx.drawImage(img, 0, 0, imgWidth, imgHeight, dx, dy, dw, dh);
if (this.log > 1) console.log(`AME: Drew image to canvas for ${el.id || el.tagName}`, { dx, dy, dw, dh, canvasW: canvasWidth, canvasH: canvasHeight });
entry._loggedImageDataError = false; // Reset error log flag on successful draw
} catch (e) {
// This might happen with certain image types or extreme scaling
console.error('AME: Error during ctx.drawImage:', e, { el, img: img.src, dx, dy, dw, dh });
}
}
/**
* Helper to parse CSS dimension values (px, %, auto).
* @param {string} value - CSS value string
* @param {number} containerSize - Size of the container (width or height)
* @param {number} imageSize - Natural size of the image (width or height)
* @returns {number} - Calculated size in pixels
* @private
*/
_parseCssDimension(value, containerSize, imageSize) {
if (!value || value === 'auto') {
return imageSize;
}
if (value.endsWith('%')) {
return (parseFloat(value) / 100) * containerSize;
}
if (value.endsWith('px')) {
return parseFloat(value);
}
// Assume pixels if no unit (though spec requires unit)
return parseFloat(value);
}
/**
* Helper to parse CSS position values (px, %, keywords).
* @param {string} value - CSS value string (e.g., 'left', 'center', 'right', '50%', '10px')
* @param {number} containerSize - Size of the container (width or height)
* @param {number} itemSize - Size of the item being positioned (dw or dh)
* @returns {number} - Calculated position offset (dx or dy) in pixels
* @private
*/
_parseCssPosition(value, containerSize, itemSize) {
if (!value) return 0; // Default to top/left
switch (value) {
case 'left':
case 'top':
return 0;
case 'center':
return (containerSize - itemSize) / 2;
case 'right':
case 'bottom':
return containerSize - itemSize;
}
if (value.endsWith('%')) {
// Percentage positioning relates the item's % point to the container's % point
// Formula: (containerSize - itemSize) * (percentage / 100)
return (containerSize - itemSize) * (parseFloat(value) / 100);
}
if (value.endsWith('px')) {
return parseFloat(value);
}
// Assume pixels if no unit
return parseFloat(value);
}
/**
* Fallback alpha approximation when canvas is tainted (CORS issues).
* Uses element geometry and typical image layout patterns for best guess.
*
* @param {HTMLElement} el - The element
* @param {number} clientX - Mouse X coordinate
* @param {number} clientY - Mouse Y coordinate
* @param {DOMRect} rect - Element bounding rectangle
* @returns {number} - Approximated alpha value (0-1)
* @private
*/
_approximateAlphaFromBounds(el, clientX, clientY, rect) {
// Strategy: Use conservative heuristics for common image patterns
// Calculate relative position within element (0-1)
const relX = (clientX - rect.left) / rect.width;
const relY = (clientY - rect.top) / rect.height;
// Fallback 1: Center-weighted approximation (most images have content in center)
const centerX = 0.5, centerY = 0.5;
const distanceFromCenter = Math.sqrt(
Math.pow(relX - centerX, 2) + Math.pow(relY - centerY, 2)
);
// Assume center 70% is likely opaque, edges likely transparent
const centerOpacityRadius = 0.35; // 70% diameter
if (distanceFromCenter <= centerOpacityRadius) {
return 1.0; // Likely opaque
} else {
// Edge regions - use threshold-aware fallback
const edgeDistance = (distanceFromCenter - centerOpacityRadius) / (0.707 - centerOpacityRadius); // 0.707 = corner distance
return Math.max(0, 1 - edgeDistance * 1.5); // Gradual falloff
}
}
/**
* Maps pointer coordinates to canvas coordinates, accounting for CSS transforms.
* Handles rotation, scaling, skewing, translation, and complex transform matrices.
*
* @param {number} clientX - Pointer X coordinate in viewport space
* @param {number} clientY - Pointer Y coordinate in viewport space
* @param {HTMLElement} el - The element being tested
* @param {DOMRect} rect - Element bounding rectangle
* @param {HTMLCanvasElement} canvas - Internal canvas for the element
* @param {Object} entry - Registry entry for caching/optimization
* @returns {Object} - { canvasX, canvasY } coordinates in canvas space
* @private
*/
_mapPointerToCanvasCoordinates(clientX, clientY, el, rect, canvas, entry) {
// Calculate relative coordinates within element bounds (basic case)
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
// Get current computed transform
const computedStyle = getComputedStyle(el);
const transform = computedStyle.transform;
// Check if we need to handle transforms
if (!transform || transform === 'none') {
// No transforms - use simple coordinate mapping
return {
canvasX: Math.floor(relativeX * (canvas.width / rect.width)),
canvasY: Math.floor(relativeY * (canvas.height / rect.height))
};
}
// Cache transform matrix calculations for performance
const transformCacheKey = `${transform}_${rect.width}_${rect.height}`;
if (entry._transformCache?.key === transformCacheKey) {
// Use cached inverse transform
const { inverseMatrix } = entry._transformCache;
const transformedCoords = this._applyInverseTransform(
relativeX - rect.width / 2, // Center-relative coordinates
relativeY - rect.height / 2,
inverseMatrix
);
return {
canvasX: Math.floor((transformedCoords.x + rect.width / 2) * (canvas.width / rect.width)),
canvasY: Math.floor((transformedCoords.y + rect.height / 2) * (canvas.height / rect.height))
};
}
// Parse and invert the transform matrix
try {
const matrix = this._parseTransformMatrix(transform);
const inverseMatrix = this._invertMatrix(matrix);
// Cache the result for performance
entry._transformCache = {
key: transformCacheKey,
matrix,
inverseMatrix
};
// Apply inverse transform to get coordinates in element's local space
const transformedCoords = this._applyInverseTransform(
relativeX - rect.width / 2, // Center-relative coordinates
relativeY - rect.height / 2,
inverseMatrix
);
// Convert back to canvas coordinates
return {
canvasX: Math.floor((transformedCoords.x + rect.width / 2) * (canvas.width / rect.width)),
canvasY: Math.floor((transformedCoords.y + rect.height / 2) * (canvas.height / rect.height))
};
} catch (error) {
// Transform parsing failed - fall back to simple mapping
if (this.log) {
console.warn('AME: Transform parsing failed, using simple coordinate mapping', {
element: el,
transform,
error: error.message
});
}
return {
canvasX: Math.floor(relativeX * (canvas.width / rect.width)),
canvasY: Math.floor(relativeY * (canvas.height / rect.height))
};
}
}
/**
* Parses a CSS transform matrix string into a numeric matrix array.
* Supports both matrix() and matrix3d() formats.
*
* @param {string} transformString - CSS transform value (e.g., "matrix(1, 0, 0, 1, 0, 0)")
* @returns {Array<number>} - 6-element array for 2D matrix [a, b, c, d, e, f]
* @private
*/
_parseTransformMatrix(transformString) {
// Handle matrix3d - extract 2D components
if (transformString.includes('matrix3d')) {
const match = transformString.match(/matrix3d\(([-\d.\s,]+)\)/);
if (match) {
const values = match[1].split(',').map(v => parseFloat(v.trim()));
// Extract 2D transformation from 3D matrix (take relevant 2D components)
return [values[0], values[1], values[4], values[5], values[12], values[13]];
}
}
// Handle matrix
if (transformString.includes('matrix')) {
const match = transformString.match(/matrix\(([-\d.\s,]+)\)/);
if (match) {
const values = match[1].split(',').map(v => parseFloat(v.trim()));
return values; // [a, b, c, d, e, f]
}
}
// Handle individual transform functions (rotate, scale, etc.)
// This is more complex but covers common cases
if (transformString.includes('rotate') || transformString.includes('scale') ||
transformString.includes('skew') || transformString.includes('translate')) {
// Create a temporary element to let the browser compute the matrix
const tempEl = document.createElement('div');
tempEl.style.transform = transformString;
tempEl.style.position = 'absolute';
tempEl.style.visibility = 'hidden';
document.body.appendChild(tempEl);
try {
const computedTransform = getComputedStyle(tempEl).transform;
document.body.removeChild(tempEl);
if (computedTransform && computedTransform !== 'none') {
return this._parseTransformMatrix(computedTransform);
}
} catch (error) {
document.body.removeChild(tempEl);
throw error;
}
}
// Fallback - identity matrix
return [1, 0, 0, 1, 0, 0];
}
/**
* Calculates the inverse of a 2D transformation matrix.
* Uses the mathematical formula for 2D matrix inversion.
*
* @param {Array<number>} matrix - 6-element matrix [a, b, c, d, e, f]
* @returns {Array<number>} - Inverted 6-element matrix
* @private
*/
_invertMatrix(matrix) {
const [a, b, c, d, e, f] = matrix;
// Calculate determinant
const det = a * d - b * c;
// Check for singular matrix (non-invertible)
if (Math.abs(det) < 1e-10) {
if (this.log) {
console.warn('AME: Transform matrix is singular (non-invertible), using identity');
}
return [1, 0, 0, 1, 0, 0];
}
// Calculate inverse matrix elements
const invDet = 1 / det;
return [
d * invDet, // a'
-b * invDet, // b'
-c * invDet, // c'
a * invDet, // d'
(c * f - d * e) * invDet, // e'
(b * e - a * f) * invDet // f'
];
}
/**
* Applies an inverse transform matrix to a point.
*
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {Array<number>} inverseMatrix - 6-element inverse transform matrix
* @returns {Object} - Transformed coordinates { x, y }
* @private
*/
_applyInverseTransform(x, y, inverseMatrix) {
const [a, b, c, d, e, f] = inverseMatrix;
return {
x: a * x + c * y + e,
y: b * x + d * y + f
};
}
/**
* Display browser compatibility warnings for missing features.
* @private
*/
_showBrowserCompatibilityWarning() {
if (this._compatibilityWarningShown) return; // Show only once
this._comp