mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat: replace hammerjs with custom swipe and pan directives
This commit is contained in:
parent
4b05f21285
commit
973e17afc2
11 changed files with 355 additions and 33 deletions
|
|
@ -188,7 +188,6 @@
|
|||
"@schematics/angular": "^20.1.4",
|
||||
"@types/electron-localshortcut": "^3.1.3",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/hammerjs": "^2.0.45",
|
||||
"@types/jasmine": "^3.10.2",
|
||||
"@types/jasminewd2": "~2.0.13",
|
||||
"@types/node": "20.12.4",
|
||||
|
|
@ -220,7 +219,6 @@
|
|||
"fflate": "^0.8.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"glob": "^9.3.5",
|
||||
"hammerjs": "^2.0.8",
|
||||
"husky": "^4.2.5",
|
||||
"ical.js": "^2.1.0",
|
||||
"idb": "^8.0.3",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
<div class="box"></div>
|
||||
|
||||
<div
|
||||
panGesture
|
||||
(longPressIOS)="openContextMenu($event)"
|
||||
(contextmenu)="openContextMenu($event)"
|
||||
(panend)="onPanEnd()"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
tap,
|
||||
} from 'rxjs/operators';
|
||||
import { fadeAnimation } from '../../../ui/animations/fade.ani';
|
||||
import { PanDirective } from '../../../ui/swipe-gesture/pan.directive';
|
||||
import { TaskAttachmentService } from '../task-attachment/task-attachment.service';
|
||||
import { DialogEditTaskAttachmentComponent } from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component';
|
||||
import { swirlAnimation } from '../../../ui/animations/swirl-in-out.ani';
|
||||
|
|
@ -126,6 +127,7 @@ import { TaskLog } from '../../../core/log';
|
|||
TagListComponent,
|
||||
ShortPlannedAtPipe,
|
||||
TagToggleMenuListComponent,
|
||||
PanDirective,
|
||||
],
|
||||
})
|
||||
export class TaskComponent implements OnDestroy, AfterViewInit {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
}
|
||||
|
||||
<div
|
||||
swipeGesture
|
||||
(swiperight)="IS_TOUCH_PRIMARY && close()"
|
||||
[style]="sideStyle()"
|
||||
class="side"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
|||
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import { filter, map, switchMap, startWith } from 'rxjs/operators';
|
||||
import { of, timer } from 'rxjs';
|
||||
import { SwipeDirective } from '../../swipe-gesture/swipe.directive';
|
||||
|
||||
const SMALL_CONTAINER_WIDTH = 620;
|
||||
const VERY_SMALL_CONTAINER_WIDTH = 450;
|
||||
|
|
@ -36,6 +37,8 @@ const VERY_SMALL_CONTAINER_WIDTH = 450;
|
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'[class.isOver]': 'isOver()',
|
||||
},
|
||||
imports: [SwipeDirective],
|
||||
standalone: true,
|
||||
})
|
||||
export class BetterDrawerContainerComponent implements OnDestroy {
|
||||
private _elementRef = inject(ElementRef);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
</div>
|
||||
|
||||
<div
|
||||
swipeGesture
|
||||
(swiperight)="IS_TOUCH_PRIMARY && close()"
|
||||
[style]="sideStyle"
|
||||
class="side"
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import {
|
|||
import { ReplaySubject, Subscription } from 'rxjs';
|
||||
import { DomSanitizer, SafeStyle } from '@angular/platform-browser';
|
||||
import { IS_TOUCH_PRIMARY } from '../../util/is-mouse-primary';
|
||||
import { SwipeDirective } from '../swipe-gesture/swipe.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'better-simple-drawer',
|
||||
imports: [],
|
||||
imports: [SwipeDirective],
|
||||
templateUrl: './better-simple-drawer.component.html',
|
||||
styleUrl: './better-simple-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
|
|
|||
247
src/app/ui/swipe-gesture/pan.directive.ts
Normal file
247
src/app/ui/swipe-gesture/pan.directive.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import {
|
||||
Directive,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Output,
|
||||
Input,
|
||||
ElementRef,
|
||||
Renderer2,
|
||||
NgZone,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface PanEvent {
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
deltaTime: number;
|
||||
isFinal: boolean;
|
||||
eventType: number;
|
||||
target: EventTarget | null;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan gesture directive for touch interactions
|
||||
* Detects pan movements in all directions
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[panGesture]',
|
||||
standalone: true,
|
||||
})
|
||||
export class PanDirective {
|
||||
@Output() panstart = new EventEmitter<PanEvent>();
|
||||
@Output() panmove = new EventEmitter<PanEvent>();
|
||||
@Output() panend = new EventEmitter<PanEvent>();
|
||||
@Output() panleft = new EventEmitter<PanEvent>();
|
||||
@Output() panright = new EventEmitter<PanEvent>();
|
||||
@Output() panup = new EventEmitter<PanEvent>();
|
||||
@Output() pandown = new EventEmitter<PanEvent>();
|
||||
@Output() pancancel = new EventEmitter<PanEvent>();
|
||||
|
||||
// Configuration
|
||||
@Input() panThreshold = 10; // Minimum distance to start panning
|
||||
@Input() panEnabled = true;
|
||||
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private startTime = 0;
|
||||
private lastX = 0;
|
||||
private lastY = 0;
|
||||
private isPanning = false;
|
||||
private touchIdentifier: number | null = null;
|
||||
private lastDirection: 'left' | 'right' | 'up' | 'down' | null = null;
|
||||
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private renderer: Renderer2,
|
||||
private ngZone: NgZone,
|
||||
) {}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(event: TouchEvent): void {
|
||||
if (!this.panEnabled || this.touchIdentifier !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
this.touchIdentifier = touch.identifier;
|
||||
this.startX = touch.clientX;
|
||||
this.startY = touch.clientY;
|
||||
this.lastX = touch.clientX;
|
||||
this.lastY = touch.clientY;
|
||||
this.startTime = Date.now();
|
||||
this.isPanning = false;
|
||||
this.lastDirection = null;
|
||||
|
||||
// Create pan event
|
||||
const panEvent = this.createPanEvent(event, 0, 0, false, 1); // eventType: 1 = start
|
||||
|
||||
// Run outside Angular zone for performance
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.panstart.emit(panEvent);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
onTouchMove(event: TouchEvent): void {
|
||||
if (!this.panEnabled || this.touchIdentifier === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching touch
|
||||
let touch: Touch | null = null;
|
||||
for (let i = 0; i < event.changedTouches.length; i++) {
|
||||
if (event.changedTouches[i].identifier === this.touchIdentifier) {
|
||||
touch = event.changedTouches[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = touch.clientX;
|
||||
const currentY = touch.clientY;
|
||||
const deltaX = currentX - this.startX;
|
||||
const deltaY = currentY - this.startY;
|
||||
const xSquared = deltaX * deltaX;
|
||||
const ySquared = deltaY * deltaY;
|
||||
const distance = Math.sqrt(xSquared + ySquared);
|
||||
|
||||
// Check if we should start panning
|
||||
if (!this.isPanning && distance >= this.panThreshold) {
|
||||
this.isPanning = true;
|
||||
}
|
||||
|
||||
if (this.isPanning) {
|
||||
// Prevent default to avoid scrolling
|
||||
event.preventDefault();
|
||||
|
||||
const panEvent = this.createPanEvent(event, deltaX, deltaY, false, 2); // eventType: 2 = move
|
||||
|
||||
// Determine direction
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
let currentDirection: 'left' | 'right' | 'up' | 'down' | null = null;
|
||||
|
||||
if (absX > absY) {
|
||||
currentDirection = deltaX > 0 ? 'right' : 'left';
|
||||
} else if (absY > absX) {
|
||||
currentDirection = deltaY > 0 ? 'down' : 'up';
|
||||
}
|
||||
|
||||
// Run outside Angular zone for performance
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
// Emit direction-specific events
|
||||
if (currentDirection !== this.lastDirection) {
|
||||
this.lastDirection = currentDirection;
|
||||
|
||||
switch (currentDirection) {
|
||||
case 'left':
|
||||
this.panleft.emit(panEvent);
|
||||
break;
|
||||
case 'right':
|
||||
this.panright.emit(panEvent);
|
||||
break;
|
||||
case 'up':
|
||||
this.panup.emit(panEvent);
|
||||
break;
|
||||
case 'down':
|
||||
this.pandown.emit(panEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.panmove.emit(panEvent);
|
||||
});
|
||||
}
|
||||
|
||||
this.lastX = currentX;
|
||||
this.lastY = currentY;
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(event: TouchEvent): void {
|
||||
if (!this.panEnabled || this.touchIdentifier === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching touch
|
||||
let touch: Touch | null = null;
|
||||
for (let i = 0; i < event.changedTouches.length; i++) {
|
||||
if (event.changedTouches[i].identifier === this.touchIdentifier) {
|
||||
touch = event.changedTouches[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - this.startX;
|
||||
const deltaY = touch.clientY - this.startY;
|
||||
|
||||
if (this.isPanning) {
|
||||
const panEvent = this.createPanEvent(event, deltaX, deltaY, true, 4); // eventType: 4 = end
|
||||
|
||||
// Run in Angular zone for final event
|
||||
this.ngZone.run(() => {
|
||||
this.panend.emit(panEvent);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.reset();
|
||||
}
|
||||
|
||||
@HostListener('touchcancel', ['$event'])
|
||||
onTouchCancel(event: TouchEvent): void {
|
||||
if (!this.panEnabled || this.touchIdentifier === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPanning) {
|
||||
const deltaX = this.lastX - this.startX;
|
||||
const deltaY = this.lastY - this.startY;
|
||||
const panEvent = this.createPanEvent(event, deltaX, deltaY, true, 8); // eventType: 8 = cancel
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.pancancel.emit(panEvent);
|
||||
});
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.startX = 0;
|
||||
this.startY = 0;
|
||||
this.startTime = 0;
|
||||
this.lastX = 0;
|
||||
this.lastY = 0;
|
||||
this.isPanning = false;
|
||||
this.touchIdentifier = null;
|
||||
this.lastDirection = null;
|
||||
}
|
||||
|
||||
private createPanEvent(
|
||||
originalEvent: TouchEvent,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
isFinal: boolean,
|
||||
eventType: number,
|
||||
): PanEvent {
|
||||
const deltaTime = Date.now() - this.startTime;
|
||||
|
||||
return {
|
||||
deltaX,
|
||||
deltaY,
|
||||
deltaTime,
|
||||
isFinal,
|
||||
eventType,
|
||||
target: originalEvent.target,
|
||||
preventDefault: () => originalEvent.preventDefault(),
|
||||
};
|
||||
}
|
||||
}
|
||||
97
src/app/ui/swipe-gesture/swipe.directive.ts
Normal file
97
src/app/ui/swipe-gesture/swipe.directive.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Directive, EventEmitter, HostListener, Output, Input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Simple swipe directive for touch gestures
|
||||
* Detects swipe left and swipe right gestures
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[swipeGesture]',
|
||||
standalone: true,
|
||||
})
|
||||
export class SwipeDirective {
|
||||
@Output() swiperight = new EventEmitter<void>();
|
||||
@Output() swipeleft = new EventEmitter<void>();
|
||||
|
||||
// Configuration options
|
||||
@Input() swipeThreshold = 50; // Minimum distance in pixels
|
||||
@Input() swipeVelocityThreshold = 0.3; // Minimum velocity in pixels/ms
|
||||
@Input() swipeMaxTime = 1000; // Maximum time in ms for a swipe
|
||||
@Input() swipeEnabled = true; // Enable/disable swipe detection
|
||||
|
||||
private swipeStartX = 0;
|
||||
private swipeStartY = 0;
|
||||
private swipeStartTime = 0;
|
||||
private touchIdentifier: number | null = null;
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(event: TouchEvent): void {
|
||||
if (!this.swipeEnabled || this.touchIdentifier !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
this.touchIdentifier = touch.identifier;
|
||||
this.swipeStartX = touch.clientX;
|
||||
this.swipeStartY = touch.clientY;
|
||||
this.swipeStartTime = Date.now();
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(event: TouchEvent): void {
|
||||
if (!this.swipeEnabled || this.touchIdentifier === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the touch that matches our identifier
|
||||
let touch: Touch | null = null;
|
||||
for (let i = 0; i < event.changedTouches.length; i++) {
|
||||
if (event.changedTouches[i].identifier === this.touchIdentifier) {
|
||||
touch = event.changedTouches[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - this.swipeStartX;
|
||||
const deltaY = touch.clientY - this.swipeStartY;
|
||||
const deltaTime = Date.now() - this.swipeStartTime;
|
||||
|
||||
// Reset state
|
||||
this.touchIdentifier = null;
|
||||
|
||||
// Check if this qualifies as a swipe
|
||||
if (deltaTime > this.swipeMaxTime) {
|
||||
return; // Too slow
|
||||
}
|
||||
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
|
||||
// Check if horizontal movement is dominant
|
||||
if (absX < this.swipeThreshold || absX < absY * 1.5) {
|
||||
return; // Not enough horizontal movement or too much vertical movement
|
||||
}
|
||||
|
||||
// Calculate velocity
|
||||
const velocity = absX / deltaTime;
|
||||
if (velocity < this.swipeVelocityThreshold) {
|
||||
return; // Too slow
|
||||
}
|
||||
|
||||
// Emit swipe event
|
||||
if (deltaX > 0) {
|
||||
this.swiperight.emit();
|
||||
} else {
|
||||
this.swipeleft.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('touchcancel', ['$event'])
|
||||
onTouchCancel(event: TouchEvent): void {
|
||||
// Reset state on cancel
|
||||
this.touchIdentifier = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { HammerGestureConfig } from '@angular/platform-browser';
|
||||
import { Injectable } from '@angular/core';
|
||||
// Required for custom touch gestures (swipe, pan) to work
|
||||
import 'hammerjs';
|
||||
|
||||
const DIRECTION_LEFT = 2;
|
||||
const DIRECTION_RIGHT = 4;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const DIRECTION_HORIZONTAL = DIRECTION_LEFT | DIRECTION_RIGHT;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MyHammerConfig extends HammerGestureConfig {
|
||||
override overrides: {
|
||||
[key: string]: Record<string, unknown>;
|
||||
} = {
|
||||
swipe: { direction: DIRECTION_HORIZONTAL },
|
||||
pan: { direction: 6 },
|
||||
pinch: { enable: false },
|
||||
rotate: { enable: false },
|
||||
};
|
||||
}
|
||||
10
src/main.ts
10
src/main.ts
|
|
@ -15,13 +15,7 @@ import { androidInterface } from './app/features/android/android-interface';
|
|||
// Type definitions for window.ea are in ./app/core/window-ea.d.ts
|
||||
import { App as CapacitorApp } from '@capacitor/app';
|
||||
import { GlobalErrorHandler } from './app/core/error-handler/global-error-handler.class';
|
||||
import {
|
||||
bootstrapApplication,
|
||||
BrowserModule,
|
||||
HAMMER_GESTURE_CONFIG,
|
||||
HammerModule,
|
||||
} from '@angular/platform-browser';
|
||||
import { MyHammerConfig } from './hammer-config.class';
|
||||
import { bootstrapApplication, BrowserModule } from '@angular/platform-browser';
|
||||
import {
|
||||
HttpClient,
|
||||
provideHttpClient,
|
||||
|
|
@ -102,7 +96,6 @@ bootstrapApplication(AppComponent, {
|
|||
MaterialCssVarsModule.forRoot(),
|
||||
// External
|
||||
BrowserModule,
|
||||
HammerModule,
|
||||
// NOTE: both need to be present to use forFeature stores
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers: [
|
||||
|
|
@ -168,7 +161,6 @@ bootstrapApplication(AppComponent, {
|
|||
: 'en-US',
|
||||
},
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
{ provide: HAMMER_GESTURE_CONFIG, useClass: MyHammerConfig },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
DatePipe,
|
||||
ShortTimeHtmlPipe,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue