From debb32d353f421db0caa398e6a3759b455a6feda Mon Sep 17 00:00:00 2001 From: Vben Date: Thu, 15 Aug 2024 21:48:52 +0800 Subject: [PATCH] fix: page spinner is styled incorrectly when scrolling (#4163) * feat: add contributor information to documents * fix: page spinner is styled incorrectly when scrolling --- docs/.vitepress/config.mts | 10 + docs/.vitepress/theme/index.ts | 5 +- docs/package.json | 1 + docs/src/guide/in-depth/theme.md | 6 +- internal/vite-config/src/plugins/index.ts | 2 +- .../plugins/{nitor-mock.ts => nitro-mock.ts} | 0 .../base/shared/src/constants/globals.ts | 1 + .../@core/base/shared/src/utils/dom.test.ts | 231 ++++++------ packages/@core/base/shared/src/utils/dom.ts | 43 ++- packages/@core/composables/src/index.ts | 2 +- .../composables/src/use-content-height.ts | 47 --- .../composables/src/use-content-style.ts | 55 +++ .../src/components/layout-content.vue | 8 +- .../ui-kit/layout-ui/src/vben-layout.vue | 4 + .../src/basic/content/content-spinner.vue | 16 + .../layouts/src/basic/content/content.vue | 10 - .../layouts/src/basic/content/index.ts | 1 + packages/effects/layouts/src/basic/layout.vue | 8 +- .../views/demos/features/clipboard/index.vue | 18 +- .../demos/features/full-screen/index.vue | 2 +- pnpm-lock.yaml | 347 +++++++++++++++--- 21 files changed, 554 insertions(+), 263 deletions(-) rename internal/vite-config/src/plugins/{nitor-mock.ts => nitro-mock.ts} (100%) delete mode 100644 packages/@core/composables/src/use-content-height.ts create mode 100644 packages/@core/composables/src/use-content-style.ts create mode 100644 packages/effects/layouts/src/basic/content/content-spinner.vue diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0ce099d2..6dfb2de9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -2,6 +2,10 @@ import type { DefaultTheme, HeadConfig } from 'vitepress'; import { resolve } from 'node:path'; +import { + GitChangelog, + GitChangelogMarkdownSection, +} from '@nolebase/vitepress-plugin-git-changelog/vite'; import { type PwaOptions, withPwa } from '@vite-pwa/vitepress'; import { defineConfigWithTheme } from 'vitepress'; @@ -98,6 +102,12 @@ export default withPwa( json: { stringify: true, }, + plugins: [ + GitChangelog({ + repoURL: () => 'https://github.com/vbenjs/vue-vben-admin', + }), + GitChangelogMarkdownSection(), + ], server: { fs: { allow: ['../..'], diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 709e2c84..862f06e9 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,6 +1,7 @@ // https://vitepress.dev/guide/custom-theme import type { Theme } from 'vitepress'; +import { NolebaseGitChangelogPlugin } from '@nolebase/vitepress-plugin-git-changelog/client'; import DefaultTheme from 'vitepress/theme'; import SiteLayout from './components/site-layout.vue'; @@ -9,11 +10,13 @@ import { initHmPlugin } from './plugins/hm'; import './styles'; +import '@nolebase/vitepress-plugin-git-changelog/client/style.css'; + export default { enhanceApp({ app }) { // ... app.component('VbenContributors', VbenContributors); - + app.use(NolebaseGitChangelogPlugin); // 百度统计 initHmPlugin(); }, diff --git a/docs/package.json b/docs/package.json index f8ada1d0..e7b2882d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,6 +11,7 @@ "medium-zoom": "^1.1.0" }, "devDependencies": { + "@nolebase/vitepress-plugin-git-changelog": "^2.4.0", "@vite-pwa/vitepress": "^0.5.0", "vitepress": "^1.3.2", "vue": "^3.4.37" diff --git a/docs/src/guide/in-depth/theme.md b/docs/src/guide/in-depth/theme.md index 1976cee6..d96dade7 100644 --- a/docs/src/guide/in-depth/theme.md +++ b/docs/src/guide/in-depth/theme.md @@ -223,7 +223,7 @@ css 变量内的颜色,必须使用 `hsl` 格式,如 `0 0% 100%`,不需要 你只需要在你的项目中覆盖你想要修改的 CSS 变量即可。例如,要更改默认卡片背景色,你可以在你的 CSS 文件中添加以下内容进行覆盖: -### 默认主题下: +### 默认主题下 ```css :root { @@ -1222,7 +1222,7 @@ export const overridesPreferences = defineOverridesPreferences({ 侧边栏颜色通过`--sidebar`变量来配置 -### 默认主题下: +### 默认主题下 ```css :root { @@ -1244,7 +1244,7 @@ export const overridesPreferences = defineOverridesPreferences({ 侧边栏颜色通过`--header`变量来配置 -### 默认主题下: +### 默认主题下 ```css :root { diff --git a/internal/vite-config/src/plugins/index.ts b/internal/vite-config/src/plugins/index.ts index 8d8d6745..c497edd3 100644 --- a/internal/vite-config/src/plugins/index.ts +++ b/internal/vite-config/src/plugins/index.ts @@ -23,7 +23,7 @@ import { viteImportMapPlugin } from './importmap'; import { viteInjectAppLoadingPlugin } from './inject-app-loading'; import { viteMetadataPlugin } from './inject-metadata'; import { viteLicensePlugin } from './license'; -import { viteNitroMockPlugin } from './nitor-mock'; +import { viteNitroMockPlugin } from './nitro-mock'; import { vitePrintPlugin } from './print'; /** diff --git a/internal/vite-config/src/plugins/nitor-mock.ts b/internal/vite-config/src/plugins/nitro-mock.ts similarity index 100% rename from internal/vite-config/src/plugins/nitor-mock.ts rename to internal/vite-config/src/plugins/nitro-mock.ts diff --git a/packages/@core/base/shared/src/constants/globals.ts b/packages/@core/base/shared/src/constants/globals.ts index 0da08ba2..1d9d2f45 100644 --- a/packages/@core/base/shared/src/constants/globals.ts +++ b/packages/@core/base/shared/src/constants/globals.ts @@ -3,6 +3,7 @@ * @en_US Layout content height */ export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`; +export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`; /** * @zh_CN 默认命名空间 diff --git a/packages/@core/base/shared/src/utils/dom.test.ts b/packages/@core/base/shared/src/utils/dom.test.ts index c95ec4c7..66e6466b 100644 --- a/packages/@core/base/shared/src/utils/dom.test.ts +++ b/packages/@core/base/shared/src/utils/dom.test.ts @@ -1,140 +1,127 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getElementVisibleHeight } from './dom'; // 假设函数位于 utils.ts 中 +import { getElementVisibleRect } from './dom'; // 假设函数位于 utils.ts 中 -describe('getElementVisibleHeight', () => { - // Mocking the getBoundingClientRect method - const mockGetBoundingClientRect = vi.fn(); - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - - beforeAll(() => { - // Mock getBoundingClientRect method - Element.prototype.getBoundingClientRect = mockGetBoundingClientRect; +describe('getElementVisibleRect', () => { + // 设置浏览器视口尺寸的 mock + beforeEach(() => { + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue( + 800, + ); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue( + 1000, + ); + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000); }); - afterAll(() => { - // Restore original getBoundingClientRect method - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; - }); - - it('should return 0 if the element is null or undefined', () => { - expect(getElementVisibleHeight(null)).toBe(0); - expect(getElementVisibleHeight()).toBe(0); - }); - - it('should return the visible height of the element', () => { - // Mock the getBoundingClientRect return value - mockGetBoundingClientRect.mockReturnValue({ - bottom: 500, - height: 400, + it('should return default rect if element is undefined', () => { + expect(getElementVisibleRect()).toEqual({ + bottom: 0, + height: 0, left: 0, right: 0, - toJSON: () => ({}), + top: 0, + width: 0, + }); + }); + + it('should return default rect if element is null', () => { + expect(getElementVisibleRect(null)).toEqual({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }); + }); + + it('should return correct visible rect when element is fully visible', () => { + const element = { + getBoundingClientRect: () => ({ + bottom: 400, + height: 300, + left: 200, + right: 600, + top: 100, + width: 400, + }), + } as HTMLElement; + + expect(getElementVisibleRect(element)).toEqual({ + bottom: 400, + height: 300, + left: 200, + right: 600, top: 100, - width: 0, - x: 0, - y: 0, + width: 400, }); - - const mockElement = document.createElement('div'); - document.body.append(mockElement); - - // Mocking window.innerHeight and document.documentElement.clientHeight - const originalInnerHeight = window.innerHeight; - const originalClientHeight = document.documentElement.clientHeight; - - Object.defineProperty(window, 'innerHeight', { - value: 600, - writable: true, - }); - - Object.defineProperty(document.documentElement, 'clientHeight', { - value: 600, - writable: true, - }); - - expect(getElementVisibleHeight(mockElement)).toBe(400); - - // Restore original values - Object.defineProperty(window, 'innerHeight', { - value: originalInnerHeight, - writable: true, - }); - - Object.defineProperty(document.documentElement, 'clientHeight', { - value: originalClientHeight, - writable: true, - }); - - mockElement.remove(); }); - it('should return the visible height when element is partially out of viewport', () => { - // Mock the getBoundingClientRect return value - mockGetBoundingClientRect.mockReturnValue({ - bottom: 300, - height: 400, - left: 0, - right: 0, - toJSON: () => ({}), - top: -100, - width: 0, - x: 0, - y: 0, + it('should return correct visible rect when element is partially off-screen at the top', () => { + const element = { + getBoundingClientRect: () => ({ + bottom: 200, + height: 250, + left: 100, + right: 500, + top: -50, + width: 400, + }), + } as HTMLElement; + + expect(getElementVisibleRect(element)).toEqual({ + bottom: 200, + height: 200, + left: 100, + right: 500, + top: 0, + width: 400, }); - - const mockElement = document.createElement('div'); - document.body.append(mockElement); - - // Mocking window.innerHeight and document.documentElement.clientHeight - const originalInnerHeight = window.innerHeight; - const originalClientHeight = document.documentElement.clientHeight; - - Object.defineProperty(window, 'innerHeight', { - value: 600, - writable: true, - }); - - Object.defineProperty(document.documentElement, 'clientHeight', { - value: 600, - writable: true, - }); - - expect(getElementVisibleHeight(mockElement)).toBe(300); - - // Restore original values - Object.defineProperty(window, 'innerHeight', { - value: originalInnerHeight, - writable: true, - }); - - Object.defineProperty(document.documentElement, 'clientHeight', { - value: originalClientHeight, - writable: true, - }); - - mockElement.remove(); }); - it('should return 0 if the element is completely out of viewport', () => { - // Mock the getBoundingClientRect return value - mockGetBoundingClientRect.mockReturnValue({ - bottom: -100, - height: 400, - left: 0, - right: 0, - toJSON: () => ({}), - top: -500, - width: 0, - x: 0, - y: 0, + it('should return correct visible rect when element is partially off-screen at the right', () => { + const element = { + getBoundingClientRect: () => ({ + bottom: 400, + height: 300, + left: 800, + right: 1200, + top: 100, + width: 400, + }), + } as HTMLElement; + + expect(getElementVisibleRect(element)).toEqual({ + bottom: 400, + height: 300, + left: 800, + right: 1000, + top: 100, + width: 200, }); + }); - const mockElement = document.createElement('div'); - document.body.append(mockElement); + it('should return all zeros when element is completely off-screen', () => { + const element = { + getBoundingClientRect: () => ({ + bottom: 1200, + height: 300, + left: 1100, + right: 1400, + top: 900, + width: 300, + }), + } as HTMLElement; - expect(getElementVisibleHeight(mockElement)).toBe(0); - - mockElement.remove(); + expect(getElementVisibleRect(element)).toEqual({ + bottom: 800, + height: 0, + left: 1100, + right: 1000, + top: 900, + width: 0, + }); }); }); diff --git a/packages/@core/base/shared/src/utils/dom.ts b/packages/@core/base/shared/src/utils/dom.ts index 6a6e69c7..79640d60 100644 --- a/packages/@core/base/shared/src/utils/dom.ts +++ b/packages/@core/base/shared/src/utils/dom.ts @@ -1,12 +1,28 @@ +export interface VisibleDomRect { + bottom: number; + height: number; + left: number; + right: number; + top: number; + width: number; +} + /** - * 获取元素可见高度 + * 获取元素可见信息 * @param element */ -function getElementVisibleHeight( +export function getElementVisibleRect( element?: HTMLElement | null | undefined, -): number { +): VisibleDomRect { if (!element) { - return 0; + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }; } const rect = element.getBoundingClientRect(); const viewHeight = Math.max( @@ -17,7 +33,20 @@ function getElementVisibleHeight( const top = Math.max(rect.top, 0); const bottom = Math.min(rect.bottom, viewHeight); - return Math.max(0, bottom - top); -} + const viewWidth = Math.max( + document.documentElement.clientWidth, + window.innerWidth, + ); -export { getElementVisibleHeight }; + const left = Math.max(rect.left, 0); + const right = Math.min(rect.right, viewWidth); + + return { + bottom, + height: Math.max(0, bottom - top), + left, + right, + top, + width: Math.max(0, right - left), + }; +} diff --git a/packages/@core/composables/src/index.ts b/packages/@core/composables/src/index.ts index f2abfedf..d0015f15 100644 --- a/packages/@core/composables/src/index.ts +++ b/packages/@core/composables/src/index.ts @@ -1,4 +1,4 @@ -export * from './use-content-height'; +export * from './use-content-style'; export * from './use-namespace'; export * from './use-sortable'; export { diff --git a/packages/@core/composables/src/use-content-height.ts b/packages/@core/composables/src/use-content-height.ts deleted file mode 100644 index 701eba83..00000000 --- a/packages/@core/composables/src/use-content-height.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { computed, onMounted, ref, watch } from 'vue'; - -import { - CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT, - getElementVisibleHeight, -} from '@vben-core/shared'; - -import { useCssVar, useDebounceFn, useWindowSize } from '@vueuse/core'; -/** - * @zh_CN 获取内容高度(可视区域,不包含滚动条) - */ -function useContentHeight() { - const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT); - - const contentStyles = computed(() => { - return { - height: `var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT})`, - }; - }); - - return { contentHeight, contentStyles }; -} - -/** - * @zh_CN 创建内容高度监听 - */ -function useContentHeightListener() { - const contentElement = ref(null); - - const { height, width } = useWindowSize(); - const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT); - const debouncedCalcHeight = useDebounceFn(() => { - contentHeight.value = `${getElementVisibleHeight(contentElement.value)}px`; - }, 200); - - watch([height, width], () => { - debouncedCalcHeight(); - }); - - onMounted(() => { - debouncedCalcHeight(); - }); - - return { contentElement }; -} - -export { useContentHeight, useContentHeightListener }; diff --git a/packages/@core/composables/src/use-content-style.ts b/packages/@core/composables/src/use-content-style.ts new file mode 100644 index 00000000..7c9b777c --- /dev/null +++ b/packages/@core/composables/src/use-content-style.ts @@ -0,0 +1,55 @@ +import type { CSSProperties } from 'vue'; +import { computed, nextTick, onMounted, ref } from 'vue'; + +import { + CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT, + CSS_VARIABLE_LAYOUT_CONTENT_WIDTH, + getElementVisibleRect, + type VisibleDomRect, +} from '@vben-core/shared'; + +import { useCssVar, useDebounceFn } from '@vueuse/core'; + +/** + * @zh_CN content style + */ +function useContentStyle() { + const contentElement = ref(null); + const visibleDomRect = ref(null); + const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT); + const contentWidth = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_WIDTH); + + const overlayStyle = computed((): CSSProperties => { + const { height, left, top, width } = visibleDomRect.value ?? {}; + return { + height: `${height}px`, + left: `${left}px`, + position: 'fixed', + top: `${top}px`, + width: `${width}px`, + zIndex: 1000, + }; + }); + + const debouncedCalcHeight = useDebounceFn( + (_entries: ResizeObserverEntry[]) => { + visibleDomRect.value = getElementVisibleRect(contentElement.value); + contentHeight.value = `${visibleDomRect.value.height}px`; + contentWidth.value = `${visibleDomRect.value.width}px`; + }, + 100, + ); + + onMounted(() => { + nextTick(() => { + if (contentElement.value) { + const observer = new ResizeObserver(debouncedCalcHeight); + observer.observe(contentElement.value); + } + }); + }); + + return { contentElement, overlayStyle, visibleDomRect }; +} + +export { useContentStyle }; diff --git a/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue b/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue index 297d3ca0..ab1d4d7f 100644 --- a/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue +++ b/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue @@ -4,7 +4,7 @@ import type { ContentCompactType } from '@vben-core/typings'; import type { CSSProperties } from 'vue'; import { computed } from 'vue'; -import { useContentHeightListener } from '@vben-core/composables'; +import { useContentStyle } from '@vben-core/composables'; interface Props { /** @@ -24,7 +24,7 @@ interface Props { const props = withDefaults(defineProps(), {}); -const { contentElement } = useContentHeightListener(); +const { contentElement, overlayStyle } = useContentStyle(); const style = computed((): CSSProperties => { const { @@ -53,7 +53,9 @@ const style = computed((): CSSProperties => { diff --git a/packages/@core/ui-kit/layout-ui/src/vben-layout.vue b/packages/@core/ui-kit/layout-ui/src/vben-layout.vue index 89297dd8..c0218c78 100644 --- a/packages/@core/ui-kit/layout-ui/src/vben-layout.vue +++ b/packages/@core/ui-kit/layout-ui/src/vben-layout.vue @@ -519,6 +519,10 @@ function handleOpenMenu() { class="transition-[margin-top] duration-200" > + + +import type { CSSProperties } from 'vue'; + +import { VbenSpinner } from '@vben-core/shadcn-ui'; + +import { useContentSpinner } from './use-content-spinner'; + +defineOptions({ name: 'LayoutContentSpinner' }); + +defineProps<{ overlayStyle: CSSProperties }>(); + +const { spinning } = useContentSpinner(); + + diff --git a/packages/effects/layouts/src/basic/content/content.vue b/packages/effects/layouts/src/basic/content/content.vue index 870d46d0..ffeaa666 100644 --- a/packages/effects/layouts/src/basic/content/content.vue +++ b/packages/effects/layouts/src/basic/content/content.vue @@ -7,20 +7,15 @@ import type { import { type VNode } from 'vue'; import { RouterView } from 'vue-router'; -import { useContentHeight } from '@vben/hooks'; import { preferences, usePreferences } from '@vben/preferences'; import { storeToRefs, useTabbarStore } from '@vben/stores'; -import { VbenSpinner } from '@vben-core/shadcn-ui'; import { IFrameRouterView } from '../../iframe'; -import { useContentSpinner } from './use-content-spinner'; defineOptions({ name: 'LayoutContent' }); const tabbarStore = useTabbarStore(); const { keepAlive } = usePreferences(); -const { spinning } = useContentSpinner(); -const { contentStyles } = useContentHeight(); const { getCachedTabs, getExcludeCachedTabs, renderRouteView } = storeToRefs(tabbarStore); @@ -86,11 +81,6 @@ function transformComponent(