mirror of
https://github.com/JosunLP/UserScriptProjectTemplate.git
synced 2025-10-14 09:00:11 +00:00
Merge pull request #5 from JosunLP/dev
feat: Implement mobile support with touch gestures and responsive
This commit is contained in:
commit
dcb2a449aa
6 changed files with 842 additions and 60 deletions
58
README.md
58
README.md
|
@ -20,6 +20,9 @@ A modern, production-ready template for building UserScripts using TypeScript an
|
|||
• 🎨 **DOM Utilities:** Helper functions for element manipulation and waiting
|
||||
• 🔒 **Error Handling:** Comprehensive error boundary system
|
||||
• ⚡ **Event System:** Type-safe event emitter for module communication
|
||||
• 📱 **Mobile Support:** Touch-optimized interface with mobile browser detection
|
||||
• 🤏 **Touch Gestures:** Built-in touch event handling and gesture recognition
|
||||
• 📲 **Responsive Design:** Mobile-first CSS with safe area support for notched devices
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -202,6 +205,39 @@ export class MyModule extends EventEmitter<ModuleEvents> {
|
|||
}
|
||||
```
|
||||
|
||||
### Mobile Utilities
|
||||
|
||||
Mobile-specific functionality for touch-enabled devices:
|
||||
|
||||
```typescript
|
||||
import { MobileUtils } from '@/utils/mobile';
|
||||
|
||||
// Detect mobile browser and capabilities
|
||||
const detection = MobileUtils.detect();
|
||||
console.log('Is Mobile:', detection.isMobile);
|
||||
console.log('Has Touch:', detection.hasTouch);
|
||||
console.log('Browser:', detection.browser);
|
||||
|
||||
// Add mobile-optimized styles
|
||||
if (detection.isMobile) {
|
||||
MobileUtils.addMobileStyles();
|
||||
}
|
||||
|
||||
// Unified touch/mouse event handling
|
||||
MobileUtils.addUnifiedEventListener(element, 'start', event => {
|
||||
const position = MobileUtils.getEventPosition(event);
|
||||
console.log('Touch/click at:', position);
|
||||
});
|
||||
|
||||
// Create mobile-friendly buttons
|
||||
const button = mobileModule.createMobileButton('Action', () => {
|
||||
console.log('Button pressed');
|
||||
});
|
||||
|
||||
// Orientation detection
|
||||
console.log('Portrait mode:', MobileUtils.isPortrait());
|
||||
```
|
||||
|
||||
## UserScript Compatibility
|
||||
|
||||
• **Tampermonkey:** Full support with all GM\_\* APIs
|
||||
|
@ -209,6 +245,28 @@ export class MyModule extends EventEmitter<ModuleEvents> {
|
|||
• **Violentmonkey:** Full compatibility
|
||||
• **Safari:** Works with userscript managers
|
||||
|
||||
### Mobile Browser Support
|
||||
|
||||
**Android:**
|
||||
|
||||
- **Kiwi Browser:** Full Chrome extension + UserScript support
|
||||
- **Microsoft Edge Mobile:** Tampermonkey support
|
||||
- **Firefox Mobile:** Greasemonkey, Tampermonkey, Violentmonkey
|
||||
- **Yandex Browser:** Chrome extension support
|
||||
|
||||
**iOS:**
|
||||
|
||||
- **Safari Mobile:** Tampermonkey or Userscripts App
|
||||
- Limited support due to iOS restrictions
|
||||
|
||||
### Mobile Features
|
||||
|
||||
• **Touch Gestures:** Tap, swipe, and pinch detection
|
||||
• **Responsive Design:** Mobile-first CSS with viewport adaptation
|
||||
• **Safe Area Support:** Automatic handling of notched devices
|
||||
• **Orientation Detection:** Portrait/landscape change handling
|
||||
• **Mobile-Optimized UI:** Touch-friendly buttons and menus
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
|
|
|
@ -1,50 +1,58 @@
|
|||
{
|
||||
"updateUrl": "",
|
||||
"downloadUrl": "",
|
||||
"supportUrl": "",
|
||||
"iconUrl": "./assets/icon.png",
|
||||
"environments": {
|
||||
"development": {
|
||||
"includes": [
|
||||
"http://localhost:*/*",
|
||||
"https://localhost:*/*",
|
||||
"https://*.josunlp.de/*",
|
||||
"https://josunlp.de/*"
|
||||
],
|
||||
"excludes": [],
|
||||
"grants": [
|
||||
"GM_setValue",
|
||||
"GM_getValue",
|
||||
"GM_deleteValue",
|
||||
"GM_listValues",
|
||||
"GM_log",
|
||||
"GM_notification",
|
||||
"GM_registerMenuCommand",
|
||||
"GM_unregisterMenuCommand"
|
||||
]
|
||||
},
|
||||
"production": {
|
||||
"includes": [
|
||||
"https://*.josunlp.de/*",
|
||||
"https://josunlp.de/*"
|
||||
],
|
||||
"excludes": [],
|
||||
"grants": [
|
||||
"GM_setValue",
|
||||
"GM_getValue",
|
||||
"GM_deleteValue",
|
||||
"GM_listValues",
|
||||
"GM_registerMenuCommand"
|
||||
]
|
||||
}
|
||||
"updateUrl": "",
|
||||
"downloadUrl": "",
|
||||
"supportUrl": "",
|
||||
"iconUrl": "./assets/icon.png",
|
||||
"environments": {
|
||||
"development": {
|
||||
"includes": [
|
||||
"http://localhost:*/*",
|
||||
"https://localhost:*/*",
|
||||
"https://*.josunlp.de/*",
|
||||
"https://josunlp.de/*"
|
||||
],
|
||||
"excludes": [],
|
||||
"grants": [
|
||||
"GM_setValue",
|
||||
"GM_getValue",
|
||||
"GM_deleteValue",
|
||||
"GM_listValues",
|
||||
"GM_log",
|
||||
"GM_notification",
|
||||
"GM_registerMenuCommand",
|
||||
"GM_unregisterMenuCommand"
|
||||
]
|
||||
},
|
||||
"requires": [],
|
||||
"resources": [],
|
||||
"connecters": [],
|
||||
"matches": [],
|
||||
"matchAllFrames": false,
|
||||
"runAt": "document-start",
|
||||
"antifeatures": [],
|
||||
"noframes": false,
|
||||
"unwrap": false
|
||||
"production": {
|
||||
"includes": ["https://*.josunlp.de/*", "https://josunlp.de/*"],
|
||||
"excludes": [],
|
||||
"grants": [
|
||||
"GM_setValue",
|
||||
"GM_getValue",
|
||||
"GM_deleteValue",
|
||||
"GM_listValues",
|
||||
"GM_registerMenuCommand"
|
||||
]
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"supportedBrowsers": [
|
||||
"Kiwi Browser (Android)",
|
||||
"Microsoft Edge Mobile",
|
||||
"Firefox Mobile",
|
||||
"Safari Mobile (iOS)",
|
||||
"Yandex Browser"
|
||||
],
|
||||
"touchOptimized": true,
|
||||
"responsiveDesign": true
|
||||
},
|
||||
"requires": [],
|
||||
"resources": [],
|
||||
"connecters": [],
|
||||
"matches": [],
|
||||
"matchAllFrames": false,
|
||||
"runAt": "document-start",
|
||||
"antifeatures": [],
|
||||
"noframes": false,
|
||||
"unwrap": false
|
||||
}
|
||||
|
|
10
package.json
10
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "userscript-project-template",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern, production-ready template for building UserScripts using TypeScript and Vite. This template provides a solid foundation with best practices, type safety, and modern development tools for Tampermonkey and Greasemonkey scripts.",
|
||||
"description": "A modern, production-ready template for building UserScripts using TypeScript and Vite with mobile browser support, touch gestures, and responsive design for Tampermonkey and Greasemonkey scripts.",
|
||||
"main": "index.ts",
|
||||
"module": "node",
|
||||
"scripts": {
|
||||
|
@ -32,7 +32,13 @@
|
|||
"vite",
|
||||
"browser-automation",
|
||||
"web-enhancement",
|
||||
"browser-scripting"
|
||||
"browser-scripting",
|
||||
"mobile-support",
|
||||
"touch-gestures",
|
||||
"responsive-design",
|
||||
"mobile-browser",
|
||||
"kiwi-browser",
|
||||
"edge-mobile"
|
||||
],
|
||||
"author": "Jonas Pfalzgraf <info@josunlp.de>",
|
||||
"license": "MIT",
|
||||
|
|
91
src/index.ts
91
src/index.ts
|
@ -1,6 +1,7 @@
|
|||
import { ExampleModule } from '@/modules/example';
|
||||
import { DOMUtils } from '@/utils/dom';
|
||||
import { EventEmitter } from '@/utils/events';
|
||||
import { MobileUtils } from '@/utils/mobile';
|
||||
import { Storage } from '@/utils/storage';
|
||||
|
||||
/**
|
||||
|
@ -60,6 +61,14 @@ class App extends EventEmitter<AppEvents> {
|
|||
protected async main(): Promise<void> {
|
||||
console.log('👋 Hello from UserScript Template!');
|
||||
|
||||
// Mobile detection and setup
|
||||
const mobileInfo = MobileUtils.detect();
|
||||
if (mobileInfo.isMobile) {
|
||||
console.log('📱 Mobile browser detected:', mobileInfo.browser);
|
||||
MobileUtils.addMobileStyles();
|
||||
MobileUtils.logMobileInfo();
|
||||
}
|
||||
|
||||
// Example: Add some basic functionality
|
||||
this.addExampleFeatures();
|
||||
|
||||
|
@ -75,11 +84,27 @@ class App extends EventEmitter<AppEvents> {
|
|||
// Initialize example module
|
||||
const exampleModule = new ExampleModule();
|
||||
await exampleModule.initialize();
|
||||
|
||||
// Register the module
|
||||
this.registerModule('example', exampleModule);
|
||||
|
||||
// Listen to module events
|
||||
// Initialize mobile module if on mobile device
|
||||
const mobileInfo = MobileUtils.detect();
|
||||
if (mobileInfo.isMobile || mobileInfo.hasTouch) {
|
||||
const { MobileModule } = await import('@/modules/mobile');
|
||||
const mobileModule = new MobileModule();
|
||||
await mobileModule.initialize();
|
||||
this.registerModule('mobile', mobileModule);
|
||||
|
||||
// Listen to mobile module events
|
||||
mobileModule.on('gestureDetected', ({ type, position }) => {
|
||||
console.log(`📱 Gesture detected: ${type} at ${position.x}, ${position.y}`);
|
||||
});
|
||||
|
||||
mobileModule.on('orientationChanged', ({ orientation }) => {
|
||||
console.log(`📱 Orientation changed to: ${orientation}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to example module events
|
||||
exampleModule.on('actionPerformed', ({ action, timestamp }) => {
|
||||
console.log(
|
||||
`📡 Module action received: ${action} at ${new Date(timestamp).toLocaleString()}`
|
||||
|
@ -94,28 +119,72 @@ class App extends EventEmitter<AppEvents> {
|
|||
* Example features to demonstrate the template
|
||||
*/
|
||||
private addExampleFeatures(): void {
|
||||
// Example: Add custom styles
|
||||
DOMUtils.addStyles(
|
||||
`
|
||||
// Add mobile-optimized styles
|
||||
const mobileInfo = MobileUtils.detect();
|
||||
const baseCss = `
|
||||
.userscript-highlight {
|
||||
background-color: yellow !important;
|
||||
border: 2px solid red !important;
|
||||
}
|
||||
`,
|
||||
'userscript-styles'
|
||||
);
|
||||
`;
|
||||
|
||||
// Add mobile-specific styles if on mobile
|
||||
const mobileCss = mobileInfo.isMobile
|
||||
? `
|
||||
.userscript-highlight {
|
||||
padding: 8px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 16px !important; /* Prevent zoom on iOS */
|
||||
}
|
||||
`
|
||||
: '';
|
||||
|
||||
DOMUtils.addStyles(baseCss + mobileCss, 'userscript-styles');
|
||||
|
||||
// Example: Storage usage
|
||||
const visitCount = (Storage.get<number>('visitCount', 0) || 0) + 1;
|
||||
Storage.set('visitCount', visitCount);
|
||||
console.log(`📊 This is visit #${visitCount}`);
|
||||
|
||||
// Example: Add menu command
|
||||
// Example: Add menu command (with mobile detection)
|
||||
if (typeof GM_registerMenuCommand !== 'undefined') {
|
||||
GM_registerMenuCommand('Show Visit Count', () => {
|
||||
const count = Storage.get<number>('visitCount', 0);
|
||||
alert(`You have visited this page ${count} times!`);
|
||||
|
||||
if (mobileInfo.isMobile) {
|
||||
// Mobile-friendly notification
|
||||
if (typeof GM_notification !== 'undefined') {
|
||||
GM_notification(`Visit Count: ${count}`, 'UserScript Info', undefined, () => {
|
||||
console.log('Notification clicked');
|
||||
});
|
||||
} else {
|
||||
alert(`You have visited this page ${count} times!`);
|
||||
}
|
||||
} else {
|
||||
// Desktop alert
|
||||
alert(`You have visited this page ${count} times!`);
|
||||
}
|
||||
});
|
||||
|
||||
// Add mobile-specific menu command
|
||||
if (mobileInfo.isMobile) {
|
||||
GM_registerMenuCommand('Mobile Info', () => {
|
||||
MobileUtils.logMobileInfo();
|
||||
|
||||
const info = `
|
||||
Device: ${mobileInfo.isAndroid ? 'Android' : mobileInfo.isIOS ? 'iOS' : 'Other'}
|
||||
Touch Support: ${mobileInfo.hasTouch ? 'Yes' : 'No'}
|
||||
UserScript Support: ${MobileUtils.supportsUserScripts() ? 'Yes' : 'No'}
|
||||
Recommended Manager: ${MobileUtils.getRecommendedUserScriptManager()}
|
||||
`;
|
||||
|
||||
if (typeof GM_notification !== 'undefined') {
|
||||
GM_notification(info, 'Mobile Device Info');
|
||||
} else {
|
||||
alert(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
276
src/modules/mobile.ts
Normal file
276
src/modules/mobile.ts
Normal file
|
@ -0,0 +1,276 @@
|
|||
import { EventEmitter } from '@/utils/events';
|
||||
import { MobileUtils } from '@/utils/mobile';
|
||||
|
||||
/**
|
||||
* Mobile-specific module events
|
||||
*/
|
||||
interface MobileModuleEvents {
|
||||
gestureDetected: { type: 'tap' | 'swipe' | 'pinch'; position: { x: number; y: number } };
|
||||
orientationChanged: { orientation: 'portrait' | 'landscape' };
|
||||
touchStart: { touches: number };
|
||||
touchEnd: { touches: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile interaction module for touch gestures and mobile-specific features
|
||||
*/
|
||||
export class MobileModule extends EventEmitter<MobileModuleEvents> {
|
||||
private isInitialized = false;
|
||||
private swipeThreshold = 50;
|
||||
private tapTimeout = 300;
|
||||
private lastTap = 0;
|
||||
private touchStartPos: { x: number; y: number } | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the mobile module
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detection = MobileUtils.detect();
|
||||
|
||||
if (!detection.hasTouch) {
|
||||
console.log('⚠️ Mobile module: No touch support detected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📱 Initializing mobile module...');
|
||||
|
||||
this.setupTouchEvents();
|
||||
this.setupOrientationDetection();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('✅ Mobile module initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup touch event handlers
|
||||
*/
|
||||
private setupTouchEvents(): void {
|
||||
// Handle touch start
|
||||
document.addEventListener(
|
||||
'touchstart',
|
||||
event => {
|
||||
const position = MobileUtils.getEventPosition(event);
|
||||
this.touchStartPos = { x: position.x, y: position.y };
|
||||
|
||||
this.emit('touchStart', { touches: event.touches.length });
|
||||
|
||||
// Detect taps
|
||||
this.detectTap(event);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Handle touch move for swipe detection
|
||||
document.addEventListener(
|
||||
'touchmove',
|
||||
event => {
|
||||
if (this.touchStartPos && event.touches.length === 1) {
|
||||
this.detectSwipe(event);
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Handle touch end
|
||||
document.addEventListener(
|
||||
'touchend',
|
||||
event => {
|
||||
this.emit('touchEnd', { touches: event.touches.length });
|
||||
this.touchStartPos = null;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Handle pinch gestures
|
||||
document.addEventListener('gesturestart', event => {
|
||||
event.preventDefault();
|
||||
const position = MobileUtils.getEventPosition(event as any);
|
||||
this.emit('gestureDetected', {
|
||||
type: 'pinch',
|
||||
position: { x: position.x, y: position.y },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup orientation change detection
|
||||
*/
|
||||
private setupOrientationDetection(): void {
|
||||
const handleOrientationChange = () => {
|
||||
const orientation = MobileUtils.isPortrait() ? 'portrait' : 'landscape';
|
||||
this.emit('orientationChanged', { orientation });
|
||||
console.log(`📱 Orientation changed to: ${orientation}`);
|
||||
};
|
||||
|
||||
// Listen for orientation changes
|
||||
window.addEventListener('orientationchange', handleOrientationChange);
|
||||
window.addEventListener('resize', handleOrientationChange);
|
||||
|
||||
// Initial orientation
|
||||
setTimeout(handleOrientationChange, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tap gestures
|
||||
*/
|
||||
private detectTap(event: TouchEvent): void {
|
||||
const currentTime = Date.now();
|
||||
const position = MobileUtils.getEventPosition(event);
|
||||
|
||||
if (currentTime - this.lastTap < this.tapTimeout) {
|
||||
// Double tap detected
|
||||
this.emit('gestureDetected', {
|
||||
type: 'tap',
|
||||
position: { x: position.x, y: position.y },
|
||||
});
|
||||
}
|
||||
|
||||
this.lastTap = currentTime;
|
||||
|
||||
// Single tap with delay
|
||||
setTimeout(() => {
|
||||
if (Date.now() - this.lastTap >= this.tapTimeout) {
|
||||
this.emit('gestureDetected', {
|
||||
type: 'tap',
|
||||
position: { x: position.x, y: position.y },
|
||||
});
|
||||
}
|
||||
}, this.tapTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect swipe gestures
|
||||
*/
|
||||
private detectSwipe(event: TouchEvent): void {
|
||||
if (!this.touchStartPos) return;
|
||||
|
||||
const position = MobileUtils.getEventPosition(event);
|
||||
const deltaX = position.x - this.touchStartPos.x;
|
||||
const deltaY = position.y - this.touchStartPos.y;
|
||||
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
if (distance > this.swipeThreshold) {
|
||||
this.emit('gestureDetected', {
|
||||
type: 'swipe',
|
||||
position: { x: position.x, y: position.y },
|
||||
});
|
||||
this.touchStartPos = null; // Reset to prevent multiple swipe events
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mobile-friendly button
|
||||
*/
|
||||
createMobileButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = text;
|
||||
button.className = 'userscript-mobile-button';
|
||||
|
||||
// Add unified event listener for touch and mouse
|
||||
MobileUtils.addUnifiedEventListener(button, 'start', event => {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
});
|
||||
|
||||
// Prevent double-tap zoom
|
||||
MobileUtils.preventDoubleTapZoom(button);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mobile-friendly menu
|
||||
*/
|
||||
createMobileMenu(items: Array<{ text: string; action: () => void }>): HTMLElement {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'userscript-mobile-menu';
|
||||
|
||||
// Add safe area padding for devices with notches
|
||||
const safeArea = MobileUtils.getSafeAreaInsets();
|
||||
menu.style.paddingTop = `${Math.max(16, safeArea.top)}px`;
|
||||
menu.style.paddingBottom = `${Math.max(16, safeArea.bottom)}px`;
|
||||
menu.style.paddingLeft = `${Math.max(16, safeArea.left)}px`;
|
||||
menu.style.paddingRight = `${Math.max(16, safeArea.right)}px`;
|
||||
|
||||
items.forEach(item => {
|
||||
const button = this.createMobileButton(item.text, () => {
|
||||
item.action();
|
||||
this.hideMobileMenu(menu);
|
||||
});
|
||||
menu.appendChild(button);
|
||||
});
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show mobile menu at position
|
||||
*/
|
||||
showMobileMenu(menu: HTMLElement, position?: { x: number; y: number }): void {
|
||||
document.body.appendChild(menu);
|
||||
|
||||
if (position) {
|
||||
const detection = MobileUtils.detect();
|
||||
|
||||
if (detection.isMobile) {
|
||||
// On mobile, show at bottom of screen
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.bottom = '16px';
|
||||
menu.style.left = '16px';
|
||||
menu.style.right = '16px';
|
||||
} else {
|
||||
// On desktop, show at cursor position
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.left = `${position.x}px`;
|
||||
menu.style.top = `${position.y}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add backdrop
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
backdrop.addEventListener('click', () => {
|
||||
this.hideMobileMenu(menu);
|
||||
});
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
menu.dataset.backdrop = 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide mobile menu
|
||||
*/
|
||||
hideMobileMenu(menu: HTMLElement): void {
|
||||
if (menu.dataset.backdrop) {
|
||||
const backdrop = document.querySelector('div[style*="rgba(0, 0, 0, 0.3)"]');
|
||||
if (backdrop) {
|
||||
backdrop.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.parentNode) {
|
||||
menu.parentNode.removeChild(menu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module status
|
||||
*/
|
||||
get initialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
}
|
365
src/utils/mobile.ts
Normal file
365
src/utils/mobile.ts
Normal file
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue