From c6dddbb66e5de5e0cd18eb0fe667c41aada764ba Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 25 Jun 2025 14:12:49 +0200 Subject: [PATCH] fix: Prevent content scrolling behind main menu (part 1) (#35173) --- .../mastodon/containers/mastodon.jsx | 2 + .../mastodon/containers/media_container.jsx | 10 --- .../ui/components/body_scroll_lock.tsx | 71 +++++++++++++++++++ .../features/ui/components/modal_root.jsx | 15 ---- app/javascript/mastodon/utils/scrollbar.ts | 20 ++---- app/javascript/styles/mastodon/basics.scss | 1 + .../styles/mastodon/components.scss | 5 ++ 7 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 0b1255c33..8dcda3b0a 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -18,6 +18,7 @@ import initialState, { title as siteTitle } from 'mastodon/initial_state'; import { IntlProvider } from 'mastodon/locales'; import { store } from 'mastodon/store'; import { isProduction } from 'mastodon/utils/environment'; +import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock'; const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; @@ -58,6 +59,7 @@ export default class Mastodon extends PureComponent { + diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index e826dbfa9..a4f79fcf9 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -14,7 +14,6 @@ import MediaModal from 'mastodon/features/ui/components/media_modal'; import { Video } from 'mastodon/features/video'; import { IntlProvider } from 'mastodon/locales'; import { createPollFromServerJSON } from 'mastodon/models/poll'; -import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; @@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent { }; handleOpenMedia = (media, index, lang) => { - document.body.classList.add('with-modals--active'); - document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; - this.setState({ media, index, lang }); }; @@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent { const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props')); const mediaList = fromJS(media); - document.body.classList.add('with-modals--active'); - document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; - this.setState({ media: mediaList, lang, options }); }; handleCloseMedia = () => { - document.body.classList.remove('with-modals--active'); - document.documentElement.style.marginRight = '0'; - this.setState({ media: null, index: null, diff --git a/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx b/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx new file mode 100644 index 000000000..289c295aa --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx @@ -0,0 +1,71 @@ +import { useLayoutEffect, useEffect, useState } from 'react'; + +import { createAppSelector, useAppSelector } from 'mastodon/store'; +import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; + +const getShouldLockBodyScroll = createAppSelector( + [ + (state) => state.navigation.open, + (state) => state.modal.get('stack').size > 0, + ], + (isMobileMenuOpen: boolean, isModalOpen: boolean) => { + return isMobileMenuOpen || isModalOpen; + }, +); + +/** + * This component locks scrolling on the `body` element when + * `getShouldLockBodyScroll` returns true. + * + * The scrollbar width is taken into account and written to + * a CSS custom property `--root-scrollbar-width` + */ + +export const BodyScrollLock: React.FC = () => { + const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll); + + useLayoutEffect(() => { + document.body.classList.toggle('with-modals--active', shouldLockBodyScroll); + }, [shouldLockBodyScroll]); + + const [scrollbarWidth, setScrollbarWidth] = useState(() => + getScrollbarWidth(), + ); + + useEffect(() => { + const handleResize = () => { + setScrollbarWidth(getScrollbarWidth()); + }; + window.addEventListener('resize', handleResize, { passive: true }); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + // Inject style element to make scrollbar width available + // as CSS custom property + useLayoutEffect(() => { + const nonce = document + .querySelector('meta[name=style-nonce]') + ?.getAttribute('content'); + + if (nonce) { + const styleEl = document.createElement('style'); + styleEl.nonce = nonce; + styleEl.innerHTML = ` + :root { + --root-scrollbar-width: ${scrollbarWidth}px; + } + `; + document.head.appendChild(styleEl); + + return () => { + document.head.removeChild(styleEl); + }; + } + + return () => ''; + }, [scrollbarWidth]); + + return null; +}; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index ff1337517..4a98de0a3 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -20,7 +20,6 @@ import { IgnoreNotificationsModal, AnnualReportModal, } from 'mastodon/features/ui/util/async-components'; -import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import BundleContainer from '../containers/bundle_container'; @@ -90,20 +89,6 @@ export default class ModalRoot extends PureComponent { backgroundColor: null, }; - getSnapshotBeforeUpdate () { - return { visible: !!this.props.type }; - } - - componentDidUpdate (prevProps, prevState, { visible }) { - if (visible) { - document.body.classList.add('with-modals--active'); - document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; - } else { - document.body.classList.remove('with-modals--active'); - document.documentElement.style.marginRight = '0'; - } - } - setBackgroundColor = color => { this.setState({ backgroundColor: color }); }; diff --git a/app/javascript/mastodon/utils/scrollbar.ts b/app/javascript/mastodon/utils/scrollbar.ts index d505df124..268236c21 100644 --- a/app/javascript/mastodon/utils/scrollbar.ts +++ b/app/javascript/mastodon/utils/scrollbar.ts @@ -1,8 +1,9 @@ import { isMobile } from '../is_mobile'; -let cachedScrollbarWidth: number | null = null; - -const getActualScrollbarWidth = () => { +export const getScrollbarWidth = () => { + if (isMobile(window.innerWidth)) { + return 0; + } const outer = document.createElement('div'); outer.style.visibility = 'hidden'; outer.style.overflow = 'scroll'; @@ -16,16 +17,3 @@ const getActualScrollbarWidth = () => { return scrollbarWidth; }; - -export const getScrollbarWidth = () => { - if (cachedScrollbarWidth !== null) { - return cachedScrollbarWidth; - } - - const scrollbarWidth = isMobile(window.innerWidth) - ? 0 - : getActualScrollbarWidth(); - cachedScrollbarWidth = scrollbarWidth; - - return scrollbarWidth; -}; diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index f7dd06e7b..e6820c58f 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -68,6 +68,7 @@ body { &.with-modals--active { overflow-y: hidden; overscroll-behavior: none; + margin-right: var(--root-scrollbar-width, 0); } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c32110be8..3bd65c8c1 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2894,6 +2894,11 @@ a.account__display-name { background: var(--background-color); backdrop-filter: var(--background-filter); border-top: 1px solid var(--background-border-color); + box-sizing: border-box; + + .with-modals--active & { + padding-right: var(--root-scrollbar-width); + } .layout-multiple-columns & { display: none;