From 894c23ac84c1c62b1de5bc1aa56119376595b9fc Mon Sep 17 00:00:00 2001 From: HooperTrs Date: Thu, 16 Jul 2026 14:18:05 +0800 Subject: [PATCH] fix(web): stop dialog scroll-lock from shifting the layout Naive UI locks page scroll by setting overflow:hidden on , which drops the classic scrollbar width and still applies margin-right compensation. That reintroduces horizontal jitter when n-dialog / n-modal open or close, on top of the earlier short-to-long content shift. Keep the vertical scrollbar track reserved with overflow-y:scroll !important + scrollbar-gutter, ignore Naive's margin compensation, and freeze background scroll via body position:fixed while the lock is active. --- web/src/assets/css/main.less | 19 ++++++-- web/src/main.ts | 3 ++ web/src/utils/stableScrollbar.ts | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 web/src/utils/stableScrollbar.ts diff --git a/web/src/assets/css/main.less b/web/src/assets/css/main.less index 94a74a06..affca2c5 100644 --- a/web/src/assets/css/main.less +++ b/web/src/assets/css/main.less @@ -3,12 +3,23 @@ --content-main: 620px; } -// Reserve vertical scrollbar space so content doesn't jump horizontally -// when pages go from short (no bar) to tall (bar appears). +// Keep the document width stable when the vertical scrollbar appears/disappears. +// +// Two cases that used to shift the flex-centered layout + fixed sidebars: +// 1) short route → content loads and the classic bar appears +// 2) n-dialog / n-modal opens: Naive UI sets overflow:hidden on , +// which removes the bar (and its own margin-right compensation still jitters +// with our centered/fixed layout) +// +// `overflow-y: scroll !important` keeps the scrollbar track always reserved so +// Naive's inline overflow:hidden cannot free that width. Background scrolling +// while a modal is open is blocked in utils/stableScrollbar.ts instead. +// `scrollbar-gutter: stable` covers engines that honor gutter without a forced bar. +// `margin-right: 0 !important` disables Naive's extra lock compensation. html { scrollbar-gutter: stable; - // Fallback for browsers without scrollbar-gutter support - overflow-y: scroll; + overflow-y: scroll !important; + margin-right: 0 !important; } .app-container { diff --git a/web/src/main.ts b/web/src/main.ts index 2960d06e..917f6fb1 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia'; import router from './router'; import App from './App.vue'; import '@/assets/css/main.less'; +import { installStableScrollbar } from '@/utils/stableScrollbar'; import type { MessageApiInjection } from 'naive-ui/lib/message/src/MessageProvider'; @@ -11,6 +12,8 @@ import 'vfonts/Lato.css'; // 等宽字体 import 'vfonts/FiraCode.css'; +installStableScrollbar(); + const pinia = createPinia(); createApp(App).use(router).use(pinia).mount('#app'); diff --git a/web/src/utils/stableScrollbar.ts b/web/src/utils/stableScrollbar.ts new file mode 100644 index 00000000..fbe8e60e --- /dev/null +++ b/web/src/utils/stableScrollbar.ts @@ -0,0 +1,82 @@ +/** + * Keep document width stable across: + * 1) short page → long content (scrollbar appears) + * 2) n-dialog / n-modal scroll-lock (Naive UI sets overflow:hidden on ) + * + * Naive UI's useLockHtmlScroll removes the classic scrollbar and optionally + * adds margin-right compensation. That fights `scrollbar-gutter` / always-on + * overflow-y:scroll and shifts the flex-centered layout plus fixed sidebars + * (`left/right: calc(50% + ...)`). + * + * Strategy: + * - CSS keeps the vertical scrollbar track always present (see main.less) + * - When Naive marks as locked, freeze body scroll with position:fixed + * without ever releasing the scrollbar width + */ + +let installed = false; + +export function installStableScrollbar() { + if (installed || typeof document === 'undefined') { + return; + } + installed = true; + + const html = document.documentElement; + let frozen = false; + let scrollY = 0; + + const isHtmlScrollLocked = () => { + const { overflow, overflowY } = html.style; + return overflow === 'hidden' || overflowY === 'hidden'; + }; + + const freezeBackgroundScroll = () => { + if (frozen) { + return; + } + frozen = true; + scrollY = window.scrollY; + const body = document.body; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + }; + + const unfreezeBackgroundScroll = () => { + if (!frozen) { + return; + } + frozen = false; + const body = document.body; + body.style.position = ''; + body.style.top = ''; + body.style.left = ''; + body.style.right = ''; + body.style.width = ''; + window.scrollTo(0, scrollY); + }; + + const sync = () => { + // Drop Naive's margin compensation — scrollbar track already reserves width. + if (html.style.marginRight && html.style.marginRight !== '0px') { + html.style.marginRight = '0px'; + } + + if (isHtmlScrollLocked()) { + freezeBackgroundScroll(); + } else { + unfreezeBackgroundScroll(); + } + }; + + new MutationObserver(sync).observe(html, { + attributes: true, + attributeFilter: ['style'], + }); + + // In case lock styles are already present + sync(); +}