import { LightningElement } from "lwc"; import { throttle } from "throttle-debounce"; import { track } from "dxUtils/analytics"; const RESTORE_SCROLL_EVENT_NAME = "restore-scroll"; const SCROLL_EVENT_NAME = "scroll"; const GLOBAL_NAV_TOGGLE_EVENT_NAME = "toggle_global_nav"; const FULLSCREEN_CHANGE_EVENT_NAME = "fullscreenchange"; const LOAD_TIME_SCROLL_RESTORE_DELAY = 750; const REDUNDANT_INSTANCE_ERROR_MESSAGE = "Multiple s detected, this should never be the case."; const HTML_ELEMENT = document.querySelector("html"); declare module globalThis { let singletonScrollManagerRendered: boolean; let singletonScrollManagerConnected: boolean; } // mostly components shouldn't be using this, but there are a few exceptions such as amfReference let scrollUnlocked: boolean = false; export const restoreScroll = () => { if (scrollUnlocked) { return; } const value = window.history.state?.scroll?.value; if (value) { document.body.scrollTop = document.documentElement.scrollTop = value; } }; export default class ScrollManager extends LightningElement { /* WARNING: Dark Magic(TM) follows. This code is likely to be unreliable if: - Our load times significantly change (in this case, LOAD_TIME_SCROLL_RESTORE_DELAY may need to be adjusted) - Any other components attempt to manipulate the body scroll (including #a links) before LOAD_TIME_SCROLL_RESTORE_DELAY has expired This Dark Magic(TM) was required because of the following super annoying race condition occuring while trying to restore scroll position: 1. Load a page on our site (reproducible consistently at time of writing at https://developer.salesforce.com) 2. Scroll down somewhere (remember where) 3. Navigate to another page 4. Navigate back/forward/back, using the browser forward/back buttons (sometimes it takes a few tries to reproduce the race condition) 5. If the page you're on takes long enough for its components to finish rendering asynchronously, sometimes your scroll position will be incorrect (until the second scroll restore is called. To see the really bad behavior, simply remove the window.setTimeout bit) This is because basically it scrolls you down a bit, then a component gets bigger above you as it finishes rendering This pushes your whole view down, so unless we do this Dark Magic(TM) where we attempt to restore scroll _again_ after some reasonable interval, your window will be in the wrong spot when everything finishes rendering */ protected scrollCount = 0; // this is for dark magic, basically we lock the user out of scrolling in the first quarter second, unless they really mean it. We do this because load timings mean that the scroll can get messed up in that period private scrollPosition = 0; private scrolledTwentyFivePercent = false; private scrolledFiftyPercent = false; private scrolledSevenFivePercent = false; private scrolledOneHundredPercent = false; private scrollUnlocked = false; private lastKnownScrollTop = 0; renderedCallback() { scrollUnlocked = window.location.hash !== ""; // if we have anchor links, skip the entire scroll restore if (!globalThis.singletonScrollManagerRendered && !scrollUnlocked) { globalThis.singletonScrollManagerRendered = true; if ( window.history.state?.scroll?.docSize === document.body.scrollHeight ) { // only do this if loading is complete and the scrollHeight matches expectations // otherwise, we're likely still loading, so just chill to avoid jumping around so much restoreScroll(); this.setScrolledPercentagesFromRestore( this.calculateScrollPercentage( +window.history.state?.scroll?.value ) ); } else { window.setTimeout(() => { // sometimes loading is slow, so we may want to reset the scroll to // the correct position after loading is complete, to avoid weird behavior // but only if the user hasn't scrolled around in the meantime restoreScroll(); this.setScrolledPercentagesFromRestore( this.calculateScrollPercentage( +window.history.state?.scroll?.value ) ); scrollUnlocked = true; }, LOAD_TIME_SCROLL_RESTORE_DELAY); } } else { console.error(REDUNDANT_INSTANCE_ERROR_MESSAGE); } } connectedCallback(): void { if (!globalThis.singletonScrollManagerConnected) { globalThis.singletonScrollManagerConnected = true; window.addEventListener(RESTORE_SCROLL_EVENT_NAME, restoreScroll); window.addEventListener(SCROLL_EVENT_NAME, this.onWindowScroll); /* this global nav emits this event when it is shown or hidden their full logic for when this occurs can be found here https://github.com/sfdc-www/hgf-www/blob/main/packages/lwc-components/src/modules/hgf/c360nav/c360nav.ts#L1316 */ window.addEventListener( GLOBAL_NAV_TOGGLE_EVENT_NAME, this.onGlobalNavToggle ); document.addEventListener( FULLSCREEN_CHANGE_EVENT_NAME, this.onFullscreenChange ); } else { console.error(REDUNDANT_INSTANCE_ERROR_MESSAGE); } } onWindowScroll = () => { if (!document.fullscreenElement) { this.lastKnownScrollTop = ( document.scrollingElement || document.body ).scrollTop; } this.updateScrollState(); }; /* Preserve the last valid scroll position, as fullscreen temporarily resets it to 0. Used to restore the original position on exit. */ onFullscreenChange = () => { const isExitingFullscreen = !document.fullscreenElement; if (isExitingFullscreen && this.lastKnownScrollTop > 0) { const target = this.lastKnownScrollTop; requestAnimationFrame(() => { document.body.scrollTop = document.documentElement.scrollTop = target; }); } }; onGlobalNavToggle = (event: Event) => { const isGlobalNavShowing: boolean = (event as CustomEvent).detail; HTML_ELEMENT?.setAttribute( "style", `--dx-g-global-header-nav-row-count: ${isGlobalNavShowing ? 2 : 1}` ); }; sendGtmScrollThresholdEvent(threshold: "25" | "50" | "75" | "100") { track(document.body, "custEv_scroll", { scrollDepth: threshold }); } calculateScrollPercentage(scrollTopOverride?: number) { const scrollingElement = document.scrollingElement || document.body; return ( ((scrollTopOverride || scrollingElement.scrollTop) / (scrollingElement.scrollHeight - window.innerHeight)) * 100 ); } // do not re-send scrolled events when scroll position is restored and user scrolls up setScrolledPercentagesFromRestore(scrollPercentage: number) { if (scrollPercentage > 25) { this.scrolledTwentyFivePercent = true; } if (scrollPercentage > 50) { this.scrolledFiftyPercent = true; } if (scrollPercentage > 75) { this.scrolledSevenFivePercent = true; } if (scrollPercentage > 100) { this.scrolledOneHundredPercent = true; } } scrollThresholdHandler() { this.scrollPosition = this.calculateScrollPercentage(); if (this.scrollPosition > 25 && !this.scrolledTwentyFivePercent) { this.scrolledTwentyFivePercent = true; this.sendGtmScrollThresholdEvent("25"); } else if (this.scrollPosition > 50 && !this.scrolledFiftyPercent) { this.scrolledFiftyPercent = true; this.sendGtmScrollThresholdEvent("50"); } else if (this.scrollPosition > 75 && !this.scrolledSevenFivePercent) { this.scrolledSevenFivePercent = true; this.sendGtmScrollThresholdEvent("75"); } else if ( this.scrollPosition === 100 && !this.scrolledOneHundredPercent ) { this.scrolledOneHundredPercent = true; this.sendGtmScrollThresholdEvent("100"); } } saveScroll = throttle(500, () => { const scrollingElement = document.scrollingElement || document.body; window.history.replaceState( { ...window.history.state, scroll: { value: scrollingElement.scrollTop, docSize: scrollingElement.scrollHeight } }, "", window.location.href ); }); updateScrollState = () => { if (this.scrollCount < 5) { this.scrollCount++; } else { this.scrollUnlocked = true; } if (this.scrollUnlocked) { this.saveScroll(); this.scrollThresholdHandler(); } }; disconnectedCallback(): void { window.removeEventListener(RESTORE_SCROLL_EVENT_NAME, restoreScroll); window.removeEventListener(SCROLL_EVENT_NAME, this.onWindowScroll); window.removeEventListener( GLOBAL_NAV_TOGGLE_EVENT_NAME, this.onGlobalNavToggle ); document.removeEventListener( FULLSCREEN_CHANGE_EVENT_NAME, this.onFullscreenChange ); } }