388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
/**
|
|
* 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');
|
|
}
|