feat: replace hammerjs with custom swipe and pan directives

This commit is contained in:
Johannes Millan 2025-08-10 11:41:08 +02:00
parent 4b05f21285
commit 973e17afc2
11 changed files with 355 additions and 33 deletions

View file

@ -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",

View file

@ -39,6 +39,7 @@
<div class="box"></div>
<div
panGesture
(longPressIOS)="openContextMenu($event)"
(contextmenu)="openContextMenu($event)"
(panend)="onPanEnd()"

View file

@ -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 {

View file

@ -14,6 +14,7 @@
}
<div
swipeGesture
(swiperight)="IS_TOUCH_PRIMARY && close()"
[style]="sideStyle()"
class="side"

View file

@ -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);

View file

@ -6,6 +6,7 @@
</div>
<div
swipeGesture
(swiperight)="IS_TOUCH_PRIMARY && close()"
[style]="sideStyle"
class="side"

View file

@ -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,

View 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(),
};
}
}

View 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;
}
}

View file

@ -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 },
};
}

View file

@ -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,