TelegramPWA/src/util/swipeController.ts
2023-10-27 12:52:06 +02:00

184 lines
5.3 KiB
TypeScript

import type { MoveOffsets } from './captureEvents';
import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom';
import { animateNumber, timingFunctions } from './animation';
import { captureEvents, SwipeDirection } from './captureEvents';
import { waitForAnimationEnd } from './cssAnimationEndListeners';
import { clamp } from './math';
import { IS_IOS } from './windowEnvironment';
const INERTIA_DURATION = 300;
const INERTIA_EASING = timingFunctions.easeOutCubic;
let isSwipeActive = false;
let swipeOffsets: MoveOffsets | undefined;
let onDrag: ((offsets: MoveOffsets) => void) | undefined;
let onRelease: ((onCancel: NoneToVoidFunction) => void) | undefined;
export function captureControlledSwipe(
element: HTMLElement, options: {
excludedClosestSelector?: string;
onSwipeLeftStart?: NoneToVoidFunction;
onSwipeRightStart?: NoneToVoidFunction;
onCancel: NoneToVoidFunction;
},
) {
return captureEvents(element, {
excludedClosestSelector: options.excludedClosestSelector,
swipeThreshold: 10,
onSwipe(e, direction, offsets) {
if (direction === SwipeDirection.Left) {
options.onSwipeLeftStart?.();
} else if (direction === SwipeDirection.Right) {
options.onSwipeRightStart?.();
} else {
return false;
}
if (IS_IOS) {
isSwipeActive = true;
swipeOffsets = offsets;
}
return true;
},
onDrag(e, captureEvent, offsets) {
if (!isSwipeActive) return;
onDrag?.(offsets);
},
onRelease() {
if (!isSwipeActive) return;
isSwipeActive = false;
onRelease?.(options.onCancel);
onDrag = undefined;
onRelease = undefined;
},
});
}
export function allowSwipeControlForTransition(
currentSlide: HTMLElement,
nextSlide: HTMLElement,
onCancelForTransition: NoneToVoidFunction,
) {
if (!isSwipeActive) return;
const targetPosition = extractAnimationEndPosition(currentSlide);
if (!targetPosition) return;
currentSlide.getAnimations().forEach((a) => a.pause());
nextSlide.getAnimations().forEach((a) => a.pause());
currentSlide.style.animationTimingFunction = 'linear';
nextSlide.style.animationTimingFunction = 'linear';
let currentDirection: 1 | -1 | undefined;
requestMeasure(() => {
const computedStyle = getComputedStyle(currentSlide);
const initialPositionPx = extractPositionFromMatrix(computedStyle.transform, targetPosition.axis);
const targetPositionPx = targetPosition.units === 'px'
? targetPosition.value
: ((targetPosition.value / 100) * (
targetPosition.axis === 'X' ? currentSlide.offsetWidth : currentSlide.offsetHeight
));
const distance = targetPositionPx - initialPositionPx;
let progress = 0;
onDrag = ({ dragOffsetX, dragOffsetY }) => {
const dragOffset = targetPosition.axis === 'X'
? dragOffsetX - swipeOffsets!.dragOffsetX
: dragOffsetY - swipeOffsets!.dragOffsetY;
const newProgress = clamp(dragOffset / distance, 0, 1);
currentDirection = newProgress > progress ? 1 : -1;
progress = newProgress;
updateAnimationProgress([currentSlide, nextSlide], progress);
};
onRelease = (onCancelForClient: NoneToVoidFunction) => {
const isCanceled = currentDirection === -1;
function cleanup() {
currentSlide.getAnimations().forEach((a) => a.cancel());
nextSlide.getAnimations().forEach((a) => a.cancel());
requestMutation(() => {
currentSlide.style.animationTimingFunction = '';
nextSlide.style.animationTimingFunction = '';
});
}
if (!isCanceled) {
// For some reason animations are not cleared when CSS class is removed
waitForAnimationEnd(currentSlide, cleanup);
}
animateNumber({
from: progress,
to: isCanceled ? 0 : 1,
duration: INERTIA_DURATION,
timing: INERTIA_EASING,
onUpdate(releaseProgress) {
updateAnimationProgress([currentSlide, nextSlide], releaseProgress);
},
onEnd() {
if (isCanceled) {
cleanup();
onCancelForTransition();
onCancelForClient();
}
},
});
};
});
}
function updateAnimationProgress(elements: HTMLElement[], progress: number) {
elements.map((e) => e.getAnimations()).flat().forEach((animation) => {
animation.currentTime = (animation.effect!.getTiming().duration as number) * progress;
});
}
function extractAnimationEndPosition(element: HTMLElement) {
for (const animation of element.getAnimations()) {
if (!(animation.effect instanceof KeyframeEffect)) continue;
for (const keyframe of animation.effect.getKeyframes()) {
if (keyframe.offset !== 1 || !keyframe.transform) continue;
const position = extractPositionFromTransform(keyframe.transform as string);
if (position) {
return position;
}
}
}
return undefined;
}
function extractPositionFromTransform(transformRule: string) {
const match = transformRule.match(/([XY])\((-?\d+)(%|px)\)/);
if (!match) return undefined;
return {
axis: match[1] as 'X' | 'Y',
value: Number(match[2]),
units: match[3],
};
}
function extractPositionFromMatrix(transform: string, axis: 'X' | 'Y') {
const matrix = transform.slice(7, -1).split(',').map(Number);
return matrix[axis === 'X' ? 4 : 5];
}