/** * Implements scroll to animation with momentum effect * * @see http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html */ const TIME_CONSTANT = 100; // higher numbers - slower animation const SCROLL_ANCHOR_OFFSET = 80; // 50 + 30 (header height + some spacing) // Первый скролл выполняется сразу после загрузки страницы, так что чтобы снизить // нагрузку на рендеринг мы откладываем первый скрол на 200ms let isFirstScroll = true; let scrollJob: { hasAmplitude: boolean; start: number; y: number; amplitude: number; } | null = null; export function scrollTo(y: number) { if (scrollJob) { // we already scrolling, so simply change the coordinates we are scrolling to if (scrollJob.hasAmplitude) { const delta = y - scrollJob.y; scrollJob.amplitude += delta; } scrollJob.y = y; return; } const start = Date.now(); let scrollWasTouched = false; scrollJob = { // NOTE: we may use some sort of debounce to wait till we catch all the // scroll requests after app state changes, but the way with hasAmplitude // seems to be more reliable hasAmplitude: false, start, y, amplitude: 0, }; requestAnimationFrame(() => { // wrap in requestAnimationFrame to optimize initial reading of scrollTop if (!scrollJob) { return; } const y = normalizeScrollPosition(scrollJob.y); scrollJob.hasAmplitude = true; scrollJob.y = y; scrollJob.amplitude = y - getScrollTop(); (function animateScroll() { if (!scrollJob) { return; } const { start, y, amplitude } = scrollJob; const elapsed = Date.now() - start; let delta = -amplitude * Math.exp(-elapsed / TIME_CONSTANT); if (Math.abs(delta) > 0.5 && !scrollWasTouched) { requestAnimationFrame(animateScroll); } else { // the last animation frame delta = 0; scrollJob = null; document.removeEventListener('mousewheel', markScrollTouched); document.removeEventListener('touchstart', markScrollTouched); } if (scrollWasTouched) { // block any animation visualisation in case, when user touched scroll return; } const newScrollTop = y + delta; window.scrollTo(0, newScrollTop); })(); }); document.addEventListener('mousewheel', markScrollTouched); document.addEventListener('touchstart', markScrollTouched); function markScrollTouched() { scrollWasTouched = true; } } /** * Ensures, that `y` is the coordinate, that can be physically scrolled to * * @param {number} y * * @returns {number} */ function normalizeScrollPosition(y: number): number { const contentHeight = (document.documentElement && document.documentElement.scrollHeight) || 0; const windowHeight: number = window.innerHeight; const maxY = contentHeight - windowHeight; return Math.min(y, maxY); } /** * Scrolls to page's top or #anchor link, if any * * @param {?HTMLElement} targetEl - the element to scroll to */ export function restoreScroll(targetEl: HTMLElement | null = null) { const { hash } = window.location; setTimeout( () => { isFirstScroll = false; if (targetEl === null) { const id = hash.substr(1); if (!id) { return; } targetEl = document.getElementById(id); } const viewPort = document.body; if (!viewPort) { console.log('Can not find viewPort element'); // eslint-disable-line return; } let y = 0; if (targetEl) { const { top } = targetEl.getBoundingClientRect(); y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET; } scrollTo(y); }, isFirstScroll ? 200 : 0, ); } /** * http://stackoverflow.com/a/3464890/5184751 * * @returns {number} */ export function getScrollTop(): number { const doc = document.documentElement; if (doc) { return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); } return 0; }