-
{
- // Prevent multiple initializations
- if (window.ScrollLock) {
- return;
- }
-
- class ScrollLock {
- constructor() {
- this.locked = false;
- this.scrollableElements = [];
- this.scrollPositions = new Map();
- this.originalStyles = new Map();
- this.fixedElements = [];
- }
-
- /**
- * Find all scrollable elements in the DOM (optimized)
- * Uses more targeted selectors instead of querying all elements
- */
- findScrollableElements() {
- const scrollables = [];
-
- // More targeted query - only look for elements with overflow properties
- const candidates = document.querySelectorAll(
- '[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
- );
-
- // Batch all style reads first to minimize reflows
- const elementsToCheck = [];
- for (const el of candidates) {
- // Skip the element itself or if it's inside these containers
- const dataName = el.getAttribute("data-name");
- const isExcludedElement =
- dataName === "ScrollArea" ||
- dataName === "CommandList" ||
- dataName === "SelectContent" ||
- dataName === "MultiSelectContent" ||
- dataName === "DropdownMenuContent" ||
- dataName === "ContextMenuContent";
-
- if (
- el !== document.body &&
- el !== document.documentElement &&
- !isExcludedElement &&
- !el.closest('[data-name="ScrollArea"]') &&
- !el.closest('[data-name="CommandList"]') &&
- !el.closest('[data-name="SelectContent"]') &&
- !el.closest('[data-name="MultiSelectContent"]') &&
- !el.closest('[data-name="DropdownMenuContent"]') &&
- !el.closest('[data-name="ContextMenuContent"]')
- ) {
- elementsToCheck.push(el);
- }
- }
-
- // Now batch read all computed styles and dimensions
- elementsToCheck.forEach((el) => {
- const style = window.getComputedStyle(el);
- const hasOverflow =
- style.overflow === "auto" ||
- style.overflow === "scroll" ||
- style.overflowY === "auto" ||
- style.overflowY === "scroll";
-
- // Only check scrollHeight if overflow is set
- if (hasOverflow && el.scrollHeight > el.clientHeight) {
- scrollables.push(el);
- }
- });
-
- return scrollables;
- }
-
- /**
- * Lock scrolling on all scrollable elements (optimized)
- * Batches all DOM reads before DOM writes to prevent forced reflows
- */
- lock() {
- if (this.locked) return;
-
- this.locked = true;
-
- // Find all scrollable elements
- this.scrollableElements = this.findScrollableElements();
-
- // ===== BATCH 1: READ PHASE - Read all layout properties first =====
- const windowScrollY = window.scrollY;
- const scrollbarWidth = window.innerWidth - document.body.clientWidth;
-
- // Store window scroll position
- this.scrollPositions.set("window", windowScrollY);
-
- // Store original body styles
- this.originalStyles.set("body", {
- position: document.body.style.position,
- top: document.body.style.top,
- width: document.body.style.width,
- overflow: document.body.style.overflow,
- paddingRight: document.body.style.paddingRight,
- });
-
- // Read all fixed-position elements and their padding (only if we have scrollbar)
- if (scrollbarWidth > 0) {
- // Use more targeted query for fixed elements
- const fixedCandidates = document.querySelectorAll(
- '[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
- );
-
- this.fixedElements = Array.from(fixedCandidates).filter((el) => {
- const style = window.getComputedStyle(el);
- return (
- style.position === "fixed" &&
- !el.closest('[data-name="DropdownMenuContent"]') &&
- !el.closest('[data-name="MultiSelectContent"]') &&
- !el.closest('[data-name="ContextMenuContent"]')
- );
- });
-
- // Batch read all padding values
- this.fixedElements.forEach((el) => {
- const computedStyle = window.getComputedStyle(el);
- const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
-
- this.originalStyles.set(el, {
- paddingRight: el.style.paddingRight,
- computedPadding: currentPadding,
- });
- });
- }
-
- // Read scrollable elements info
- const scrollableInfo = this.scrollableElements.map((el) => {
- const scrollTop = el.scrollTop;
- const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
- const computedStyle = window.getComputedStyle(el);
- const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
-
- this.scrollPositions.set(el, scrollTop);
- this.originalStyles.set(el, {
- overflow: el.style.overflow,
- overflowY: el.style.overflowY,
- paddingRight: el.style.paddingRight,
- });
-
- return { el, elementScrollbarWidth, currentPadding };
- });
-
- // ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
-
- // Apply body lock
- document.body.style.position = "fixed";
- document.body.style.top = `-${windowScrollY}px`;
- document.body.style.width = "100%";
- document.body.style.overflow = "hidden";
-
- if (scrollbarWidth > 0) {
- document.body.style.paddingRight = `${scrollbarWidth}px`;
-
- // Apply padding compensation to fixed elements
- this.fixedElements.forEach((el) => {
- const stored = this.originalStyles.get(el);
- if (stored) {
- el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
- }
- });
- }
-
- // Lock all scrollable containers
- scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
- el.style.overflow = "hidden";
-
- if (elementScrollbarWidth > 0) {
- el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
- }
- });
- }
-
- /**
- * Unlock scrolling on all elements (optimized)
- * @param {number} delay - Delay in milliseconds before unlocking (for animations)
- */
- unlock(delay = 0) {
- if (!this.locked) return;
-
- const performUnlock = () => {
- // Restore body scroll
- const bodyStyles = this.originalStyles.get("body");
- if (bodyStyles) {
- document.body.style.position = bodyStyles.position;
- document.body.style.top = bodyStyles.top;
- document.body.style.width = bodyStyles.width;
- document.body.style.overflow = bodyStyles.overflow;
- document.body.style.paddingRight = bodyStyles.paddingRight;
- }
-
- // Restore window scroll position
- const windowScrollY = this.scrollPositions.get("window") || 0;
- window.scrollTo(0, windowScrollY);
-
- // Restore all scrollable containers
- this.scrollableElements.forEach((el) => {
- const originalStyles = this.originalStyles.get(el);
- if (originalStyles) {
- el.style.overflow = originalStyles.overflow;
- el.style.overflowY = originalStyles.overflowY;
- el.style.paddingRight = originalStyles.paddingRight;
- }
-
- // Restore scroll position
- const scrollPosition = this.scrollPositions.get(el) || 0;
- el.scrollTop = scrollPosition;
- });
-
- // Restore fixed-position elements padding
- this.fixedElements.forEach((el) => {
- const styles = this.originalStyles.get(el);
- if (styles && styles.paddingRight !== undefined) {
- el.style.paddingRight = styles.paddingRight;
- }
- });
-
- // Clear storage
- this.scrollableElements = [];
- this.fixedElements = [];
- this.scrollPositions.clear();
- this.originalStyles.clear();
- this.locked = false;
- };
-
- if (delay > 0) {
- setTimeout(performUnlock, delay);
- } else {
- performUnlock();
- }
- }
-
- /**
- * Check if scrolling is currently locked
- */
- isLocked() {
- return this.locked;
- }
- }
-
- // Export as singleton
- window.ScrollLock = new ScrollLock();
-})();