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>; requestFrame(): void; subscribeFrame( listener: (now: number, snapshot: ScrollDriveSnapshot) => boolean, ): () => void; } export function useScrollDrive(): ScrollDriveBinding { const controller = createScrollDriveController(); const snapshot = shallowRef(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), 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); }, }; }