184 lines
5.3 KiB
TypeScript
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];
|
|
}
|