Files
Portfolio/src/composables/useScrollDrive.ts

122 lines
4.1 KiB
TypeScript

import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue';
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
import type { ScrollDriveSample, ScrollDriveSnapshot } from '@/journey/types';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
const initialSnapshot = (): ScrollDriveSnapshot => ({
scrollY: 0,
routeProgress: 0,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
});
export interface ScrollDriveBinding {
snapshot: Readonly<Ref<ScrollDriveSnapshot>>;
requestFrame(): void;
subscribeFrame(
listener: (now: number, snapshot: ScrollDriveSnapshot) => boolean,
): () => void;
}
export function useScrollDrive(): ScrollDriveBinding {
const controller = createScrollDriveController();
const snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot());
const subscribers = new Set<(
now: number,
snapshot: ScrollDriveSnapshot,
) => boolean>();
let requestFrame = (): void => {};
let disposed = false;
let teardown = (): void => {};
onMounted(() => {
let frameId: number | undefined;
let resetOnNextFrame = false;
const visualViewport = window.visualViewport;
const readSample = (now: number): ScrollDriveSample => {
const scrollingElement = document.scrollingElement ?? document.documentElement;
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
return {
scrollY: scrollingElement.scrollTop,
now,
maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight),
};
};
let scheduleFrame = (): void => {};
const runFrame = (now: number): void => {
frameId = undefined;
const input = readSample(now);
const nextSnapshot = resetOnNextFrame
? controller.reset(input)
: controller.sample(input);
snapshot.value = nextSnapshot;
resetOnNextFrame = false;
let keepRunning = false;
for (const subscriber of [...subscribers]) {
keepRunning = subscriber(now, nextSnapshot) || keepRunning;
}
if (nextSnapshot.acceleration > 0 || keepRunning) scheduleFrame();
};
scheduleFrame = (): void => {
if (frameId === undefined && document.visibilityState !== 'hidden') {
frameId = requestAnimationFrame(runFrame);
}
};
requestFrame = scheduleFrame;
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
resetOnNextFrame = true;
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
snapshot.value = controller.reset();
return;
}
resetOnNextFrame = true;
scheduleFrame();
};
snapshot.value = controller.reset(readSample(performance.now()));
window.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
window.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
teardown = (): void => {
disposed = true;
requestFrame = (): void => {};
window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
document.removeEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
subscribers.clear();
};
});
onUnmounted(() => teardown());
return {
snapshot: readonly(snapshot),
requestFrame: () => requestFrame(),
subscribeFrame(listener) {
if (disposed) return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
},
};
}