UserScriptProjectTemplate/src/utils/mobile.ts

365 lines
9.4 KiB
TypeScript

/**
* Mobile browser detection and touch event utilities for UserScript development
*/
/**
* Mobile browser detection interface
*/
interface MobileDetection {
isMobile: boolean;
isAndroid: boolean;
isIOS: boolean;
isTablet: boolean;
hasTouch: boolean;
userAgent: string;
browser: {
isKiwi: boolean;
isEdgeMobile: boolean;
isFirefoxMobile: boolean;
isSafariMobile: boolean;
isYandex: boolean;
};
}
/**
* Touch event handler interface
*/
interface TouchPosition {
x: number;
y: number;
identifier: number;
}
/**
* Mobile utility class for UserScript development
*/
export class MobileUtils {
private static _detection: MobileDetection | null = null;
/**
* Detect mobile browser and capabilities
*/
static detect(): MobileDetection {
if (this._detection) {
return this._detection;
}
const ua = navigator.userAgent.toLowerCase();
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// Mobile OS detection
const isAndroid = /android/.test(ua);
const isIOS = /ipad|iphone|ipod/.test(ua);
const isTablet = /ipad/.test(ua) || (isAndroid && !/mobile/.test(ua));
// Mobile browser detection
const isKiwi = /kiwi/.test(ua) || (/chrome/.test(ua) && isAndroid);
const isEdgeMobile = /edg\//.test(ua) && (isAndroid || isIOS);
const isFirefoxMobile = /firefox/.test(ua) && (isAndroid || isIOS);
const isSafariMobile = /safari/.test(ua) && isIOS && !/chrome|crios|fxios/.test(ua);
const isYandex = /yabrowser/.test(ua);
const isMobile =
isAndroid ||
isIOS ||
hasTouch ||
/mobile|phone|android|iphone|ipod|blackberry|windows phone/.test(ua);
this._detection = {
isMobile,
isAndroid,
isIOS,
isTablet,
hasTouch,
userAgent: navigator.userAgent,
browser: {
isKiwi,
isEdgeMobile,
isFirefoxMobile,
isSafariMobile,
isYandex,
},
};
return this._detection;
}
/**
* Check if current browser supports UserScripts
*/
static supportsUserScripts(): boolean {
const detection = this.detect();
// Desktop browsers - full support
if (!detection.isMobile) {
return true;
}
// Mobile browsers with known UserScript support
return (
detection.browser.isKiwi ||
detection.browser.isEdgeMobile ||
detection.browser.isFirefoxMobile ||
detection.browser.isSafariMobile ||
detection.browser.isYandex
);
}
/**
* Get recommended UserScript manager for current browser
*/
static getRecommendedUserScriptManager(): string {
const detection = this.detect();
if (!detection.isMobile) {
return 'Tampermonkey (Desktop)';
}
if (detection.browser.isKiwi) {
return 'Built-in Chrome Extension support';
}
if (detection.browser.isEdgeMobile) {
return 'Tampermonkey for Edge Mobile';
}
if (detection.browser.isFirefoxMobile) {
return 'Greasemonkey or Tampermonkey';
}
if (detection.browser.isSafariMobile) {
return 'Tampermonkey or Userscripts App';
}
if (detection.browser.isYandex) {
return 'Built-in extension support';
}
return 'Not supported';
}
/**
* Normalize touch/mouse event to get position
*/
static getEventPosition(event: TouchEvent | MouseEvent): TouchPosition {
if ('touches' in event && event.touches.length > 0) {
const touch = event.touches[0];
return {
x: touch.clientX,
y: touch.clientY,
identifier: touch.identifier,
};
} else if ('clientX' in event) {
return {
x: event.clientX,
y: event.clientY,
identifier: 0,
};
}
return { x: 0, y: 0, identifier: 0 };
}
/**
* Get all touch positions from touch event
*/
static getAllTouchPositions(event: TouchEvent): TouchPosition[] {
const positions: TouchPosition[] = [];
for (let i = 0; i < event.touches.length; i++) {
const touch = event.touches[i];
positions.push({
x: touch.clientX,
y: touch.clientY,
identifier: touch.identifier,
});
}
return positions;
}
/**
* Add unified touch/mouse event listener
*/
static addUnifiedEventListener(
element: Element,
eventType: 'start' | 'move' | 'end',
handler: (event: TouchEvent | MouseEvent) => void,
options?: AddEventListenerOptions
): () => void {
const detection = this.detect();
const removeListeners: (() => void)[] = [];
if (detection.hasTouch) {
const touchEvent = {
start: 'touchstart',
move: 'touchmove',
end: 'touchend',
}[eventType];
element.addEventListener(touchEvent, handler as EventListener, options);
removeListeners.push(() => {
element.removeEventListener(touchEvent, handler as EventListener, options);
});
}
// Always add mouse events as fallback
const mouseEvent = {
start: 'mousedown',
move: 'mousemove',
end: 'mouseup',
}[eventType];
element.addEventListener(mouseEvent, handler as EventListener, options);
removeListeners.push(() => {
element.removeEventListener(mouseEvent, handler as EventListener, options);
});
return () => {
removeListeners.forEach(remove => remove());
};
}
/**
* Add CSS for mobile-friendly interface
*/
static addMobileStyles(): void {
const detection = this.detect();
if (!detection.isMobile) {
return;
}
const css = `
/* Mobile-first UserScript styles */
.userscript-mobile-container {
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.userscript-mobile-button {
min-height: 44px; /* iOS minimum touch target */
min-width: 44px;
padding: 12px 16px;
font-size: 16px; /* Prevents zoom on iOS */
border-radius: 8px;
cursor: pointer;
touch-action: manipulation;
}
.userscript-mobile-input {
font-size: 16px; /* Prevents zoom on iOS */
padding: 12px;
border-radius: 4px;
}
.userscript-mobile-menu {
position: fixed;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 10000;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.userscript-mobile-menu {
background: rgba(40, 40, 40, 0.95);
color: white;
}
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1024px) {
.userscript-mobile-container {
max-width: 600px;
margin: 0 auto;
}
}
/* Phone optimizations */
@media (max-width: 767px) {
.userscript-mobile-container {
padding: 16px;
}
.userscript-mobile-menu {
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
top: auto !important;
max-height: 60vh;
}
}
`;
const styleElement = document.createElement('style');
styleElement.id = 'userscript-mobile-styles';
styleElement.textContent = css;
(document.head || document.documentElement).appendChild(styleElement);
}
/**
* Check if viewport is in portrait mode
*/
static isPortrait(): boolean {
return window.innerHeight > window.innerWidth;
}
/**
* Check if viewport is in landscape mode
*/
static isLandscape(): boolean {
return window.innerWidth > window.innerHeight;
}
/**
* Get safe area insets (for devices with notches)
*/
static getSafeAreaInsets(): { top: number; bottom: number; left: number; right: number } {
const computedStyle = getComputedStyle(document.documentElement);
return {
top: parseInt(computedStyle.getPropertyValue('env(safe-area-inset-top)') || '0'),
bottom: parseInt(computedStyle.getPropertyValue('env(safe-area-inset-bottom)') || '0'),
left: parseInt(computedStyle.getPropertyValue('env(safe-area-inset-left)') || '0'),
right: parseInt(computedStyle.getPropertyValue('env(safe-area-inset-right)') || '0'),
};
}
/**
* Prevent page zoom when double-tapping elements
*/
static preventDoubleTapZoom(element: Element): void {
let lastTap = 0;
element.addEventListener('touchend', event => {
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTap;
if (tapLength < 500 && tapLength > 0) {
event.preventDefault();
}
lastTap = currentTime;
});
}
/**
* Log mobile detection information for debugging
*/
static logMobileInfo(): void {
const detection = this.detect();
const manager = this.getRecommendedUserScriptManager();
console.group('📱 Mobile Detection Info');
console.log('Is Mobile:', detection.isMobile);
console.log('Platform:', detection.isAndroid ? 'Android' : detection.isIOS ? 'iOS' : 'Desktop');
console.log('Has Touch:', detection.hasTouch);
console.log('Browser:', detection.browser);
console.log('UserScript Support:', this.supportsUserScripts());
console.log('Recommended Manager:', manager);
console.log('User Agent:', detection.userAgent);
console.groupEnd();
}
}