/** * Browser Compatibility Module * * Detects browser features and provides fallbacks for cross-browser support. * * ## Supported Browsers * * - Chrome/Edge 90+ (primary development target) * - Firefox 88+ * - Safari 14+ * - Opera 76+ * * ## Feature Detection * * This module checks for: * - PointerEvent API (required) * - Path2D (required, has polyfill potential) * - OffscreenCanvas (optional, fallback to regular canvas) * - getCoalescedEvents (optional, for smoother fast strokes) * - ResizeObserver (required) * - requestAnimationFrame (required) * - Canvas 2D desynchronized hint (optional) */ const LOG_PREFIX = '[BrowserCompat]'; // Feature detection results cache let featuresDetected = false; let featureCache = {}; /** * Detects all browser features and caches the results. * Call this once at startup. * * @returns {Object} Feature detection results */ export function detectFeatures() { if (featuresDetected) { return featureCache; } featureCache = { // Required features pointerEvents: typeof PointerEvent !== 'undefined', path2D: typeof Path2D !== 'undefined', resizeObserver: typeof ResizeObserver !== 'undefined', requestAnimationFrame: typeof requestAnimationFrame !== 'undefined', canvas2D: (() => { try { const canvas = document.createElement('canvas'); return !!canvas.getContext('2d'); } catch (e) { return false; } })(), // Optional features with fallbacks offscreenCanvas: (() => { if (typeof OffscreenCanvas === 'undefined') return false; try { const oc = new OffscreenCanvas(1, 1); const ctx = oc.getContext('2d'); return !!ctx; } catch (e) { return false; } })(), coalescedEvents: (() => { if (typeof PointerEvent === 'undefined') return false; try { // Check if getCoalescedEvents exists on PointerEvent prototype return typeof PointerEvent.prototype.getCoalescedEvents === 'function'; } catch (e) { return false; } })(), desynchronizedCanvas: (() => { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { desynchronized: true }); // Check if the hint was actually applied return ctx && ctx.getContextAttributes?.()?.desynchronized === true; } catch (e) { return false; } })(), // Touch/pointer capabilities touchEvents: 'ontouchstart' in window || navigator.maxTouchPoints > 0, pointerCapture: typeof Element.prototype.setPointerCapture === 'function', // Performance APIs performanceNow: typeof performance !== 'undefined' && typeof performance.now === 'function', // WeakMap for caching weakMap: typeof WeakMap !== 'undefined', // Browser identification browser: detectBrowser(), isChromium: /Chrome|Chromium|Edge|Opera|Brave/.test(navigator.userAgent), isSafari: /^((?!chrome|android).)*safari/i.test(navigator.userAgent), isFirefox: /Firefox/.test(navigator.userAgent) }; featuresDetected = true; console.log(LOG_PREFIX, 'Feature detection complete:', featureCache); return featureCache; } /** * Identifies the browser name and version. * * @returns {{ name: string, version: string }} */ function detectBrowser() { const ua = navigator.userAgent; let name = 'Unknown'; let version = 'Unknown'; // Order matters - check Edge before Chrome as Edge contains "Chrome" in UA if (ua.includes('Edg/')) { name = 'Edge'; version = ua.match(/Edg\/(\d+)/)?.[1] || 'Unknown'; } else if (ua.includes('Chrome/')) { name = 'Chrome'; version = ua.match(/Chrome\/(\d+)/)?.[1] || 'Unknown'; } else if (ua.includes('Firefox/')) { name = 'Firefox'; version = ua.match(/Firefox\/(\d+)/)?.[1] || 'Unknown'; } else if (ua.includes('Safari/') && !ua.includes('Chrome')) { name = 'Safari'; version = ua.match(/Version\/(\d+)/)?.[1] || 'Unknown'; } else if (ua.includes('Opera/') || ua.includes('OPR/')) { name = 'Opera'; version = ua.match(/(?:Opera|OPR)\/(\d+)/)?.[1] || 'Unknown'; } return { name, version }; } /** * Checks if all required features are available. * * @returns {{ supported: boolean, missing: string[] }} */ export function checkRequiredFeatures() { const features = detectFeatures(); const required = [ { name: 'PointerEvent API', available: features.pointerEvents }, { name: 'Path2D', available: features.path2D }, { name: 'ResizeObserver', available: features.resizeObserver }, { name: 'requestAnimationFrame', available: features.requestAnimationFrame }, { name: 'Canvas 2D', available: features.canvas2D }, { name: 'WeakMap', available: features.weakMap } ]; const missing = required.filter(f => !f.available).map(f => f.name); return { supported: missing.length === 0, missing }; } /** * Logs a warning if running in an unsupported or partially supported browser. */ export function warnIfUnsupported() { const { supported, missing } = checkRequiredFeatures(); const features = detectFeatures(); if (!supported) { console.error( LOG_PREFIX, 'Browser missing required features:', missing.join(', '), '\nAnnotations extension may not work correctly.' ); return false; } // Warn about Safari-specific issues if (features.isSafari) { console.log(LOG_PREFIX, 'Safari detected. Some optimizations disabled.'); if (!features.offscreenCanvas) { console.log(LOG_PREFIX, 'Using regular canvas fallback (OffscreenCanvas not available).'); } } // Log feature availability for debugging if (!features.coalescedEvents) { console.log(LOG_PREFIX, 'getCoalescedEvents not available. Fast strokes may be less smooth.'); } if (!features.desynchronizedCanvas) { console.log(LOG_PREFIX, 'Desynchronized canvas not available. Using standard rendering.'); } return true; } /** * Gets a feature's availability status. * * @param {string} featureName * @returns {boolean} */ export function hasFeature(featureName) { const features = detectFeatures(); return !!features[featureName]; } /** * Gets all detected features. * * @returns {Object} */ export function getFeatures() { return detectFeatures(); } /** * Creates a canvas context with optimal settings for the current browser. * * @param {HTMLCanvasElement} canvas * @param {Object} options - Additional context options * @returns {CanvasRenderingContext2D} */ export function createOptimizedContext(canvas, options = {}) { const features = detectFeatures(); const contextOptions = { alpha: options.alpha !== false, willReadFrequently: options.willReadFrequently || false, ...options }; // Use desynchronized hint for Chromium browsers (reduces latency) if (features.isChromium && features.desynchronizedCanvas && options.desynchronized !== false) { contextOptions.desynchronized = true; } return canvas.getContext('2d', contextOptions); } /** * Creates an offscreen canvas or falls back to a regular canvas. * * @param {number} width * @param {number} height * @returns {HTMLCanvasElement | OffscreenCanvas} */ export function createOffscreenCanvasWithFallback(width, height) { const features = detectFeatures(); if (features.offscreenCanvas) { try { return new OffscreenCanvas(width, height); } catch (e) { console.warn(LOG_PREFIX, 'OffscreenCanvas creation failed, using fallback:', e); } } // Fallback to regular canvas const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; return canvas; } /** * Gets coalesced events from a pointer event, with fallback. * Returns an array with at least the original event. * * @param {PointerEvent} event * @returns {PointerEvent[]} */ export function getCoalescedEventsWithFallback(event) { const features = detectFeatures(); if (features.coalescedEvents && typeof event.getCoalescedEvents === 'function') { try { const coalesced = event.getCoalescedEvents(); if (coalesced && coalesced.length > 0) { return coalesced; } } catch (e) { // Fall through to return single event } } return [event]; } /** * Safari-specific workarounds and fixes. */ export const safariWorkarounds = { /** * Safari may report incorrect pointerId values. * Use this to normalize pointer tracking. */ normalizePointerId(event) { const features = detectFeatures(); if (features.isSafari) { // Safari sometimes uses -1 or other unusual values return event.pointerId > 0 ? event.pointerId : 1; } return event.pointerId; }, /** * Safari has quirks with touch-action CSS. * Apply this to the overlay element. */ applyTouchActionFix(element) { const features = detectFeatures(); if (features.isSafari) { element.style.touchAction = 'none'; element.style.webkitUserSelect = 'none'; element.style.webkitTouchCallout = 'none'; } } }; /** * Performance timing helper that falls back gracefully. */ export const timing = { now() { const features = detectFeatures(); if (features.performanceNow) { return performance.now(); } return Date.now(); } }; /** * Generates a browser compatibility report for debugging. * * @returns {string} */ export function generateCompatibilityReport() { const features = detectFeatures(); const { supported, missing } = checkRequiredFeatures(); const lines = [ '=== Browser Compatibility Report ===', '', `Browser: ${features.browser.name} ${features.browser.version}`, `User Agent: ${navigator.userAgent}`, '', '--- Required Features ---', `PointerEvent API: ${features.pointerEvents ? '✓' : '✗'}`, `Path2D: ${features.path2D ? '✓' : '✗'}`, `ResizeObserver: ${features.resizeObserver ? '✓' : '✗'}`, `requestAnimationFrame: ${features.requestAnimationFrame ? '✓' : '✗'}`, `Canvas 2D: ${features.canvas2D ? '✓' : '✗'}`, `WeakMap: ${features.weakMap ? '✓' : '✗'}`, '', '--- Optional Features ---', `OffscreenCanvas: ${features.offscreenCanvas ? '✓' : '✗ (using fallback)'}`, `getCoalescedEvents: ${features.coalescedEvents ? '✓' : '✗ (fast strokes less smooth)'}`, `Desynchronized Canvas: ${features.desynchronizedCanvas ? '✓' : '✗'}`, `Pointer Capture: ${features.pointerCapture ? '✓' : '✗'}`, '', '--- Touch/Input ---', `Touch Events: ${features.touchEvents ? '✓' : '✗'}`, `Max Touch Points: ${navigator.maxTouchPoints || 0}`, '', '--- Browser Flags ---', `Is Chromium: ${features.isChromium}`, `Is Safari: ${features.isSafari}`, `Is Firefox: ${features.isFirefox}`, '', `Overall Status: ${supported ? 'SUPPORTED' : 'UNSUPPORTED - Missing: ' + missing.join(', ')}`, '===================================' ]; return lines.join('\n'); }