Files
2026-01-18 00:13:02 +09:00

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');
}