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
|
• 🎨 **DOM Utilities:** Helper functions for element manipulation and waiting
|
||||||
• 🔒 **Error Handling:** Comprehensive error boundary system
|
• 🔒 **Error Handling:** Comprehensive error boundary system
|
||||||
• ⚡ **Event System:** Type-safe event emitter for module communication
|
• ⚡ **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
|
## 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
|
## UserScript Compatibility
|
||||||
|
|
||||||
• **Tampermonkey:** Full support with all GM\_\* APIs
|
• **Tampermonkey:** Full support with all GM\_\* APIs
|
||||||
|
@ -209,6 +245,28 @@ export class MyModule extends EventEmitter<ModuleEvents> {
|
||||||
• **Violentmonkey:** Full compatibility
|
• **Violentmonkey:** Full compatibility
|
||||||
• **Safari:** Works with userscript managers
|
• **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
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
|
|
|
@ -24,10 +24,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"includes": [
|
"includes": ["https://*.josunlp.de/*", "https://josunlp.de/*"],
|
||||||
"https://*.josunlp.de/*",
|
|
||||||
"https://josunlp.de/*"
|
|
||||||
],
|
|
||||||
"excludes": [],
|
"excludes": [],
|
||||||
"grants": [
|
"grants": [
|
||||||
"GM_setValue",
|
"GM_setValue",
|
||||||
|
@ -38,6 +35,17 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mobile": {
|
||||||
|
"supportedBrowsers": [
|
||||||
|
"Kiwi Browser (Android)",
|
||||||
|
"Microsoft Edge Mobile",
|
||||||
|
"Firefox Mobile",
|
||||||
|
"Safari Mobile (iOS)",
|
||||||
|
"Yandex Browser"
|
||||||
|
],
|
||||||
|
"touchOptimized": true,
|
||||||
|
"responsiveDesign": true
|
||||||
|
},
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"resources": [],
|
"resources": [],
|
||||||
"connecters": [],
|
"connecters": [],
|
||||||
|
|
10
package.json
10
package.json
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "userscript-project-template",
|
"name": "userscript-project-template",
|
||||||
"version": "1.0.0",
|
"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",
|
"main": "index.ts",
|
||||||
"module": "node",
|
"module": "node",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -32,7 +32,13 @@
|
||||||
"vite",
|
"vite",
|
||||||
"browser-automation",
|
"browser-automation",
|
||||||
"web-enhancement",
|
"web-enhancement",
|
||||||
"browser-scripting"
|
"browser-scripting",
|
||||||
|
"mobile-support",
|
||||||
|
"touch-gestures",
|
||||||
|
"responsive-design",
|
||||||
|
"mobile-browser",
|
||||||
|
"kiwi-browser",
|
||||||
|
"edge-mobile"
|
||||||
],
|
],
|
||||||
"author": "Jonas Pfalzgraf <info@josunlp.de>",
|
"author": "Jonas Pfalzgraf <info@josunlp.de>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
91
src/index.ts
91
src/index.ts
|
@ -1,6 +1,7 @@
|
||||||
import { ExampleModule } from '@/modules/example';
|
import { ExampleModule } from '@/modules/example';
|
||||||
import { DOMUtils } from '@/utils/dom';
|
import { DOMUtils } from '@/utils/dom';
|
||||||
import { EventEmitter } from '@/utils/events';
|
import { EventEmitter } from '@/utils/events';
|
||||||
|
import { MobileUtils } from '@/utils/mobile';
|
||||||
import { Storage } from '@/utils/storage';
|
import { Storage } from '@/utils/storage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,6 +61,14 @@ class App extends EventEmitter<AppEvents> {
|
||||||
protected async main(): Promise<void> {
|
protected async main(): Promise<void> {
|
||||||
console.log('👋 Hello from UserScript Template!');
|
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
|
// Example: Add some basic functionality
|
||||||
this.addExampleFeatures();
|
this.addExampleFeatures();
|
||||||
|
|
||||||
|
@ -75,11 +84,27 @@ class App extends EventEmitter<AppEvents> {
|
||||||
// Initialize example module
|
// Initialize example module
|
||||||
const exampleModule = new ExampleModule();
|
const exampleModule = new ExampleModule();
|
||||||
await exampleModule.initialize();
|
await exampleModule.initialize();
|
||||||
|
|
||||||
// Register the module
|
|
||||||
this.registerModule('example', exampleModule);
|
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 }) => {
|
exampleModule.on('actionPerformed', ({ action, timestamp }) => {
|
||||||
console.log(
|
console.log(
|
||||||
`📡 Module action received: ${action} at ${new Date(timestamp).toLocaleString()}`
|
`📡 Module action received: ${action} at ${new Date(timestamp).toLocaleString()}`
|
||||||
|
@ -94,28 +119,72 @@ class App extends EventEmitter<AppEvents> {
|
||||||
* Example features to demonstrate the template
|
* Example features to demonstrate the template
|
||||||
*/
|
*/
|
||||||
private addExampleFeatures(): void {
|
private addExampleFeatures(): void {
|
||||||
// Example: Add custom styles
|
// Add mobile-optimized styles
|
||||||
DOMUtils.addStyles(
|
const mobileInfo = MobileUtils.detect();
|
||||||
`
|
const baseCss = `
|
||||||
.userscript-highlight {
|
.userscript-highlight {
|
||||||
background-color: yellow !important;
|
background-color: yellow !important;
|
||||||
border: 2px solid red !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
|
// Example: Storage usage
|
||||||
const visitCount = (Storage.get<number>('visitCount', 0) || 0) + 1;
|
const visitCount = (Storage.get<number>('visitCount', 0) || 0) + 1;
|
||||||
Storage.set('visitCount', visitCount);
|
Storage.set('visitCount', visitCount);
|
||||||
console.log(`📊 This is visit #${visitCount}`);
|
console.log(`📊 This is visit #${visitCount}`);
|
||||||
|
|
||||||
// Example: Add menu command
|
// Example: Add menu command (with mobile detection)
|
||||||
if (typeof GM_registerMenuCommand !== 'undefined') {
|
if (typeof GM_registerMenuCommand !== 'undefined') {
|
||||||
GM_registerMenuCommand('Show Visit Count', () => {
|
GM_registerMenuCommand('Show Visit Count', () => {
|
||||||
const count = Storage.get<number>('visitCount', 0);
|
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