mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): unify inset navigation contours with the frosted backplate
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,17 +13,292 @@ interface Props {
|
|||||||
transitionDurationMs: number
|
transitionDurationMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 共用稳定壁纸背板、但拥有独立外轮廓的导航表面。 */
|
||||||
|
type GeometrySurface = 'sidebar' | 'navbar'
|
||||||
|
|
||||||
|
/** SVG objectBoundingBox 坐标,分别以背板的实际宽和高归一化。 */
|
||||||
|
interface NormalizedClipRect {
|
||||||
|
/** 可见高度占背板高度的比例。 */
|
||||||
|
height: number
|
||||||
|
/** 水平方向圆角半径占背板宽度的比例。 */
|
||||||
|
rx: number
|
||||||
|
/** 垂直方向圆角半径占背板高度的比例。 */
|
||||||
|
ry: number
|
||||||
|
/** 可见宽度占背板宽度的比例。 */
|
||||||
|
width: number
|
||||||
|
/** 相对背板左边缘的位置。 */
|
||||||
|
x: number
|
||||||
|
/** 相对背板上边缘的位置。 */
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 背板实际CSS像素边界,不使用可能包含滚动条的window.innerWidth。 */
|
||||||
|
interface BackplateBounds {
|
||||||
|
/** 真实高度。 */
|
||||||
|
height: number
|
||||||
|
/** 实际左侧视口坐标。 */
|
||||||
|
left: number
|
||||||
|
/** 实际顶部视口坐标。 */
|
||||||
|
top: number
|
||||||
|
/** 排除滚动条后的实际宽度。 */
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEOMETRY_SURFACES: readonly GeometrySurface[] = ['sidebar', 'navbar']
|
||||||
|
const GEOMETRY_ATTRIBUTE_FILTER = [
|
||||||
|
'class',
|
||||||
|
'data-glass-appearance',
|
||||||
|
'data-glass-quality',
|
||||||
|
'data-shell-display-environment',
|
||||||
|
'data-shell-mode',
|
||||||
|
'data-shell-navbar-attachment',
|
||||||
|
'data-theme',
|
||||||
|
'style',
|
||||||
|
]
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
const mainBackplateRef = ref<HTMLElement | null>(null)
|
||||||
|
const clipRects = ref<NormalizedClipRect[]>([])
|
||||||
|
const clipPathId = `glass-fixed-shell-clip-${useId().replace(/[^a-zA-Z0-9_-]/gu, '-')}`
|
||||||
const transitionStyle = computed(() => ({
|
const transitionStyle = computed(() => ({
|
||||||
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
|
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
|
||||||
}))
|
}))
|
||||||
|
const mainBackplateStyle = computed(() => ({
|
||||||
|
...transitionStyle.value,
|
||||||
|
clipPath: clipRects.value.length === GEOMETRY_SURFACES.length ? `url(#${clipPathId})` : undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const observedElements: Record<GeometrySurface, HTMLElement | null> = {
|
||||||
|
navbar: null,
|
||||||
|
sidebar: null,
|
||||||
|
}
|
||||||
|
const transitionHandlers: Record<GeometrySurface, EventListener | null> = {
|
||||||
|
navbar: null,
|
||||||
|
sidebar: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = false
|
||||||
|
let observedShell: HTMLElement | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let stateObserver: MutationObserver | null = null
|
||||||
|
let geometrySyncQueued = false
|
||||||
|
let usesResizeFallback = false
|
||||||
|
let lastGeometryKey = ''
|
||||||
|
|
||||||
|
function getLayoutShell() {
|
||||||
|
return mainBackplateRef.value?.closest('.layout-wrapper') as HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅为桌面连接式导航启用几何裁剪,其他壳层继续使用主题原有 CSS 裁剪。 */
|
||||||
|
function isConnectedDesktopShell(shell: HTMLElement) {
|
||||||
|
return (
|
||||||
|
document.documentElement.dataset.theme === 'glass' &&
|
||||||
|
!props.isOverlayNav &&
|
||||||
|
shell.dataset.shellMode === 'desktop' &&
|
||||||
|
shell.dataset.shellNavbarAttachment === 'connected' &&
|
||||||
|
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||||
|
!shell.classList.contains('layout-overlay-nav') &&
|
||||||
|
!shell.classList.contains('layout-app-shell') &&
|
||||||
|
!shell.classList.contains('layout-window-controls-overlay-shell')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBackplateBounds(): BackplateBounds | null {
|
||||||
|
const backplate = mainBackplateRef.value
|
||||||
|
if (!backplate) return null
|
||||||
|
|
||||||
|
const bounds = backplate.getBoundingClientRect()
|
||||||
|
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||||
|
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||||
|
if (width <= 0 || height <= 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
left: Number.isFinite(bounds.left) ? bounds.left : 0,
|
||||||
|
top: Number.isFinite(bounds.top) ? bounds.top : 0,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定导航四角共用等半径;读取计算后的像素值与CSS轮廓保持一致。 */
|
||||||
|
function readComputedRadius(element: HTMLElement) {
|
||||||
|
const radius = Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius)
|
||||||
|
return Number.isFinite(radius) ? Math.max(0, radius) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNormalizedClipRect(element: HTMLElement, backplate: BackplateBounds) {
|
||||||
|
const bounds = element.getBoundingClientRect()
|
||||||
|
const left = Number.isFinite(bounds.left) ? bounds.left : 0
|
||||||
|
const top = Number.isFinite(bounds.top) ? bounds.top : 0
|
||||||
|
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||||
|
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||||
|
const right = Number.isFinite(bounds.right) ? bounds.right : left + width
|
||||||
|
const bottom = Number.isFinite(bounds.bottom) ? bounds.bottom : top + height
|
||||||
|
const visibleLeft = clamp(Math.min(left, right) - backplate.left, 0, backplate.width)
|
||||||
|
const visibleTop = clamp(Math.min(top, bottom) - backplate.top, 0, backplate.height)
|
||||||
|
const visibleRight = clamp(Math.max(left, right) - backplate.left, 0, backplate.width)
|
||||||
|
const visibleBottom = clamp(Math.max(top, bottom) - backplate.top, 0, backplate.height)
|
||||||
|
const visibleWidth = visibleRight - visibleLeft
|
||||||
|
const visibleHeight = visibleBottom - visibleTop
|
||||||
|
|
||||||
|
if (visibleWidth <= 0 || visibleHeight <= 0) return null
|
||||||
|
|
||||||
|
const radius = readComputedRadius(element)
|
||||||
|
const normalizedWidth = visibleWidth / backplate.width
|
||||||
|
const normalizedHeight = visibleHeight / backplate.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: normalizedHeight,
|
||||||
|
rx: clamp(radius / backplate.width, 0, normalizedWidth / 2),
|
||||||
|
ry: clamp(radius / backplate.height, 0, normalizedHeight / 2),
|
||||||
|
width: normalizedWidth,
|
||||||
|
x: visibleLeft / backplate.width,
|
||||||
|
y: visibleTop / backplate.height,
|
||||||
|
} satisfies NormalizedClipRect
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGeometrySurface(surface: GeometrySurface, element: HTMLElement | null) {
|
||||||
|
const previousElement = observedElements[surface]
|
||||||
|
if (previousElement === element) return
|
||||||
|
|
||||||
|
if (resizeObserver && previousElement) resizeObserver.unobserve(previousElement)
|
||||||
|
const previousHandler = transitionHandlers[surface]
|
||||||
|
if (previousElement && previousHandler) {
|
||||||
|
previousElement.removeEventListener('transitionrun', previousHandler)
|
||||||
|
previousElement.removeEventListener('transitionend', previousHandler)
|
||||||
|
previousElement.removeEventListener('transitioncancel', previousHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElements[surface] = element
|
||||||
|
transitionHandlers[surface] = null
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
resizeObserver?.observe(element)
|
||||||
|
const transitionHandler: EventListener = () => scheduleGeometrySync()
|
||||||
|
transitionHandlers[surface] = transitionHandler
|
||||||
|
element.addEventListener('transitionrun', transitionHandler)
|
||||||
|
element.addEventListener('transitionend', transitionHandler)
|
||||||
|
element.addEventListener('transitioncancel', transitionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeStateSources() {
|
||||||
|
if (!stateObserver) return
|
||||||
|
|
||||||
|
stateObserver.disconnect()
|
||||||
|
stateObserver.observe(document.documentElement, {
|
||||||
|
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||||
|
attributes: true,
|
||||||
|
})
|
||||||
|
if (document.body) {
|
||||||
|
stateObserver.observe(document.body, {
|
||||||
|
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||||
|
attributes: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (observedShell) {
|
||||||
|
stateObserver.observe(observedShell, {
|
||||||
|
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||||
|
attributes: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshObservedElements() {
|
||||||
|
const shell = getLayoutShell()
|
||||||
|
if (shell !== observedShell) {
|
||||||
|
observedShell = shell
|
||||||
|
observeStateSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
bindGeometrySurface('sidebar', shell?.querySelector<HTMLElement>('.layout-vertical-nav:not(.overlay-nav)') ?? null)
|
||||||
|
bindGeometrySurface('navbar', shell?.querySelector<HTMLElement>('.layout-navbar') ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGeometry(nextRects: NormalizedClipRect[]) {
|
||||||
|
const nextKey = JSON.stringify(nextRects)
|
||||||
|
if (nextKey === lastGeometryKey) return
|
||||||
|
|
||||||
|
lastGeometryKey = nextKey
|
||||||
|
clipRects.value = nextRects
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGeometry() {
|
||||||
|
if (!isMounted) return
|
||||||
|
|
||||||
|
refreshObservedElements()
|
||||||
|
const shell = observedShell
|
||||||
|
const backplate = readBackplateBounds()
|
||||||
|
const sidebar = observedElements.sidebar
|
||||||
|
const navbar = observedElements.navbar
|
||||||
|
|
||||||
|
if (!shell || !backplate || !isConnectedDesktopShell(shell) || !sidebar || !navbar) {
|
||||||
|
applyGeometry([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRects = GEOMETRY_SURFACES.map(surface =>
|
||||||
|
readNormalizedClipRect(observedElements[surface] as HTMLElement, backplate),
|
||||||
|
)
|
||||||
|
if (nextRects.some(rect => rect === null)) {
|
||||||
|
applyGeometry([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGeometry(nextRects as NormalizedClipRect[])
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleGeometrySync() {
|
||||||
|
if (geometrySyncQueued) return
|
||||||
|
|
||||||
|
geometrySyncQueued = true
|
||||||
|
queueMicrotask(() => {
|
||||||
|
geometrySyncQueued = false
|
||||||
|
syncGeometry()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.isOverlayNav, scheduleGeometrySync, { flush: 'sync' })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMounted = true
|
||||||
|
stateObserver = new MutationObserver(scheduleGeometrySync)
|
||||||
|
resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => scheduleGeometrySync())
|
||||||
|
usesResizeFallback = resizeObserver === null
|
||||||
|
if (usesResizeFallback) window.addEventListener('resize', scheduleGeometrySync, { passive: true })
|
||||||
|
else if (mainBackplateRef.value) resizeObserver?.observe(mainBackplateRef.value)
|
||||||
|
|
||||||
|
refreshObservedElements()
|
||||||
|
observeStateSources()
|
||||||
|
syncGeometry()
|
||||||
|
void nextTick(syncGeometry)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isMounted = false
|
||||||
|
stateObserver?.disconnect()
|
||||||
|
stateObserver = null
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
if (usesResizeFallback) window.removeEventListener('resize', scheduleGeometrySync)
|
||||||
|
usesResizeFallback = false
|
||||||
|
|
||||||
|
for (const surface of GEOMETRY_SURFACES) bindGeometrySurface(surface, null)
|
||||||
|
observedShell = null
|
||||||
|
geometrySyncQueued = false
|
||||||
|
lastGeometryKey = ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="mainBackplateRef"
|
||||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
|
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
|
||||||
data-backplate-surface="main"
|
data-backplate-surface="main"
|
||||||
:style="transitionStyle"
|
:style="mainBackplateStyle"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -46,6 +322,24 @@ const transitionStyle = computed(() => ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<svg class="glass-fixed-shell-backplate__geometry" width="0" height="0" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<clipPath :id="clipPathId" clipPathUnits="objectBoundingBox">
|
||||||
|
<rect
|
||||||
|
v-for="(rect, index) in clipRects"
|
||||||
|
:key="GEOMETRY_SURFACES[index]"
|
||||||
|
:data-clip-surface="GEOMETRY_SURFACES[index]"
|
||||||
|
:height="rect.height"
|
||||||
|
:rx="rect.rx"
|
||||||
|
:ry="rect.ry"
|
||||||
|
:width="rect.width"
|
||||||
|
:x="rect.x"
|
||||||
|
:y="rect.y"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isOverlayNav"
|
v-if="isOverlayNav"
|
||||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
|
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
|
||||||
@@ -89,6 +383,12 @@ const transitionStyle = computed(() => ({
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glass-fixed-shell-backplate__geometry {
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.glass-fixed-shell-backplate--main {
|
.glass-fixed-shell-backplate--main {
|
||||||
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
|
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ function isRefractionActive(surface: NavigationSurface) {
|
|||||||
|
|
||||||
if (surface === 'navbar') {
|
if (surface === 'navbar') {
|
||||||
return (
|
return (
|
||||||
(shell.dataset.shellMode === 'desktop' && !shell.classList.contains('layout-horizontal-nav-active')) ||
|
(shell.dataset.shellMode === 'desktop' &&
|
||||||
|
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||||
|
!shell.classList.contains('layout-window-controls-overlay-shell')) ||
|
||||||
(shell.classList.contains('layout-navbar-floating-eligible') &&
|
(shell.classList.contains('layout-navbar-floating-eligible') &&
|
||||||
shell.classList.contains('layout-navbar-away-from-top'))
|
shell.classList.contains('layout-navbar-away-from-top'))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||||
|
|
||||||
@@ -26,9 +27,146 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
interface TestRect {
|
||||||
|
bottom: number
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
toJSON: () => Record<string, never>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackplateMountOptions {
|
||||||
|
[key: string]: unknown
|
||||||
|
attachTo?: Element
|
||||||
|
props: {
|
||||||
|
isOverlayNav: boolean
|
||||||
|
isOverlayNavActive: boolean
|
||||||
|
layers: readonly GlassFixedShellBackplateLayer[]
|
||||||
|
transitionDurationMs: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedWrappers: Array<ReturnType<typeof mount>> = []
|
||||||
|
let resizeCallback: ResizeObserverCallback | undefined
|
||||||
|
let resizeDisconnect: ReturnType<typeof vi.fn> | undefined
|
||||||
|
let backplateRect: TestRect
|
||||||
|
let sidebarRect: TestRect
|
||||||
|
let navbarRect: TestRect
|
||||||
|
|
||||||
|
function createRect(left: number, top: number, width: number, height: number): TestRect {
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountBackplate(options: BackplateMountOptions) {
|
||||||
|
const wrapper = mount(GlassFixedShellBackplate, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountConnectedShell(horizontal = false) {
|
||||||
|
const shell = document.createElement('div')
|
||||||
|
shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}`
|
||||||
|
shell.dataset.shellMode = 'desktop'
|
||||||
|
shell.dataset.shellNavbarAttachment = horizontal ? 'theme-qualified' : 'connected'
|
||||||
|
|
||||||
|
const sidebar = document.createElement('aside')
|
||||||
|
sidebar.className = 'layout-vertical-nav'
|
||||||
|
const navbar = document.createElement('header')
|
||||||
|
navbar.className = 'layout-navbar'
|
||||||
|
shell.append(sidebar, navbar)
|
||||||
|
document.body.append(shell)
|
||||||
|
|
||||||
|
return {
|
||||||
|
navbar,
|
||||||
|
shell,
|
||||||
|
sidebar,
|
||||||
|
wrapper: mountBackplate({
|
||||||
|
attachTo: shell,
|
||||||
|
props: {
|
||||||
|
isOverlayNav: false,
|
||||||
|
isOverlayNavActive: false,
|
||||||
|
layers: initialLayers,
|
||||||
|
transitionDurationMs: 1500,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleGeometry() {
|
||||||
|
await nextTick()
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
describe('GlassFixedShellBackplate', () => {
|
describe('GlassFixedShellBackplate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resizeCallback = undefined
|
||||||
|
resizeDisconnect = vi.fn()
|
||||||
|
backplateRect = createRect(5, 3, 1185, 790)
|
||||||
|
sidebarRect = createRect(13, 11, 252, 774)
|
||||||
|
navbarRect = createRect(273, 11, 909, 72)
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'ResizeObserver',
|
||||||
|
class {
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
resizeCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
observe() {}
|
||||||
|
|
||||||
|
unobserve() {}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
resizeDisconnect?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||||
|
if (this.classList.contains('glass-fixed-shell-backplate--main')) return backplateRect as DOMRect
|
||||||
|
if (this.classList.contains('layout-vertical-nav')) return sidebarRect as DOMRect
|
||||||
|
if (this.classList.contains('layout-navbar')) return navbarRect as DOMRect
|
||||||
|
return createRect(0, 0, 0, 0) as DOMRect
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||||
|
element =>
|
||||||
|
({
|
||||||
|
borderBottomLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderBottomRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderEndEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderEndStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderStartEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderStartStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderTopLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
borderTopRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||||
|
getPropertyValue: (property: string) => (property.includes('radius') ? '16px' : ''),
|
||||||
|
}) as unknown as CSSStyleDeclaration,
|
||||||
|
)
|
||||||
|
document.documentElement.dataset.theme = 'glass'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers.splice(0)) wrapper.unmount()
|
||||||
|
document.querySelectorAll('.layout-wrapper').forEach(element => element.remove())
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the App-owned slots once for the shared desktop shell', () => {
|
it('renders the App-owned slots once for the shared desktop shell', () => {
|
||||||
const wrapper = mount(GlassFixedShellBackplate, {
|
const wrapper = mountBackplate({
|
||||||
props: {
|
props: {
|
||||||
isOverlayNav: false,
|
isOverlayNav: false,
|
||||||
isOverlayNavActive: false,
|
isOverlayNavActive: false,
|
||||||
@@ -51,7 +189,7 @@ describe('GlassFixedShellBackplate', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('preserves slot nodes while their active and previous roles swap', async () => {
|
it('preserves slot nodes while their active and previous roles swap', async () => {
|
||||||
const wrapper = mount(GlassFixedShellBackplate, {
|
const wrapper = mountBackplate({
|
||||||
props: {
|
props: {
|
||||||
isOverlayNav: false,
|
isOverlayNav: false,
|
||||||
isOverlayNavActive: false,
|
isOverlayNavActive: false,
|
||||||
@@ -76,7 +214,7 @@ describe('GlassFixedShellBackplate', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds a separately clipped surface only for mobile overlay navigation', () => {
|
it('adds a separately clipped surface only for mobile overlay navigation', () => {
|
||||||
const wrapper = mount(GlassFixedShellBackplate, {
|
const wrapper = mountBackplate({
|
||||||
props: {
|
props: {
|
||||||
isOverlayNav: true,
|
isOverlayNav: true,
|
||||||
isOverlayNavActive: true,
|
isOverlayNavActive: true,
|
||||||
@@ -90,4 +228,71 @@ describe('GlassFixedShellBackplate', () => {
|
|||||||
expect(overlay.classes()).toContain('is-visible')
|
expect(overlay.classes()).toContain('is-visible')
|
||||||
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
|
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps both connected surfaces to backplate-relative objectBoundingBox geometry', async () => {
|
||||||
|
const { wrapper } = mountConnectedShell()
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
const main = wrapper.get('[data-backplate-surface="main"]')
|
||||||
|
const mainElement = main.element as HTMLElement
|
||||||
|
const clipPath = wrapper.get('clipPath')
|
||||||
|
const rects = clipPath.findAll('rect')
|
||||||
|
|
||||||
|
expect(clipPath.attributes('clipPathUnits')).toBe('objectBoundingBox')
|
||||||
|
expect(rects).toHaveLength(2)
|
||||||
|
expect(mainElement.style.clipPath).toMatch(/^url\(#glass-fixed-shell-clip-/u)
|
||||||
|
expect(Number(rects[0].attributes('x'))).toBeCloseTo(8 / 1185, 8)
|
||||||
|
expect(Number(rects[0].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||||
|
expect(Number(rects[0].attributes('width'))).toBeCloseTo(252 / 1185, 8)
|
||||||
|
expect(Number(rects[0].attributes('height'))).toBeCloseTo(774 / 790, 8)
|
||||||
|
expect(Number(rects[0].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||||
|
expect(Number(rects[0].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||||
|
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1185, 8)
|
||||||
|
expect(Number(rects[1].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||||
|
expect(Number(rects[1].attributes('width'))).toBeCloseTo(909 / 1185, 8)
|
||||||
|
expect(Number(rects[1].attributes('height'))).toBeCloseTo(72 / 790, 8)
|
||||||
|
expect(Number(rects[1].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||||
|
expect(Number(rects[1].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||||
|
expect(wrapper.findAll('[data-backplate-surface="main"]')).toHaveLength(1)
|
||||||
|
expect(wrapper.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not activate the connected clip for horizontal navigation', async () => {
|
||||||
|
const { wrapper } = mountConnectedShell(true)
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
expect((wrapper.get('[data-backplate-surface="main"]').element as HTMLElement).style.clipPath).toBe('')
|
||||||
|
expect(wrapper.findAll('clipPath rect')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes dimensions and disconnects the resize observer on unmount', async () => {
|
||||||
|
const { wrapper } = mountConnectedShell()
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
backplateRect = createRect(5, 3, 1180, 790)
|
||||||
|
sidebarRect = createRect(13, 11, 60, 774)
|
||||||
|
navbarRect = createRect(273, 11, 904, 96)
|
||||||
|
resizeCallback?.([], {} as ResizeObserver)
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
const rects = wrapper.findAll('clipPath rect')
|
||||||
|
expect(Number(rects[0].attributes('width'))).toBeCloseTo(60 / 1180, 8)
|
||||||
|
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1180, 8)
|
||||||
|
expect(Number(rects[1].attributes('height'))).toBeCloseTo(96 / 790, 8)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
expect(resizeDisconnect).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses window resize when ResizeObserver is unavailable', async () => {
|
||||||
|
vi.stubGlobal('ResizeObserver', undefined)
|
||||||
|
const { wrapper } = mountConnectedShell()
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
sidebarRect = createRect(13, 11, 60, 774)
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
await settleGeometry()
|
||||||
|
|
||||||
|
expect(Number(wrapper.find('clipPath rect').attributes('width'))).toBeCloseTo(60 / 1185, 8)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@use '@configured-variables' as variables;
|
||||||
|
|
||||||
// V3 表面按信息角色统一材料;只改变承载层,不改变图表、业务数据与交互命中区。
|
// V3 表面按信息角色统一材料;只改变承载层,不改变图表、业务数据与交互命中区。
|
||||||
@mixin surfaces {
|
@mixin surfaces {
|
||||||
html[data-theme='glass'] {
|
html[data-theme='glass'] {
|
||||||
@@ -106,6 +108,19 @@
|
|||||||
border-inline-start: 1px solid rgba(255, 255, 255, 0.12);
|
border-inline-start: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-page-content .site-card .border-t .text-medium-emphasis {
|
||||||
|
// 上传/下载是核心读数,信息权重高于网址和能力标识。
|
||||||
|
color: rgb(var(--v-theme-on-surface)) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-page-content .site-card h3,
|
||||||
|
.layout-page-content .plugin-card__banner .v-card-title {
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-page-content .plugin-card__banner {
|
.layout-page-content .plugin-card__banner {
|
||||||
--plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1));
|
--plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1));
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
@@ -140,6 +155,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 固定导航保留布局占位,玻璃实体在占位内留出空气,采样几何仍由真实元素提供。
|
||||||
|
html[data-theme='glass']
|
||||||
|
body[data-theme='glass']
|
||||||
|
.layout-wrapper[data-shell-mode='desktop']:not(
|
||||||
|
.layout-horizontal-nav-active,
|
||||||
|
.layout-window-controls-overlay-shell
|
||||||
|
) {
|
||||||
|
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-width};
|
||||||
|
|
||||||
|
&.layout-vertical-nav-collapsed {
|
||||||
|
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-collapsed-width};
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav {
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--glass-v3-shadow) !important;
|
||||||
|
block-size: calc(100% - 16px);
|
||||||
|
inline-size: calc(var(--glass-v3-nav-reserved-width) - 8px) !important;
|
||||||
|
inset-block-start: 8px;
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
||||||
|
inline-size: calc(#{variables.$layout-vertical-nav-width} - 8px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-navbar {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 16px) !important;
|
||||||
|
inset-block-start: 8px !important;
|
||||||
|
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + 8px) !important;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
html[data-theme='glass'] body[data-theme='glass'] {
|
html[data-theme='glass'] body[data-theme='glass'] {
|
||||||
.layout-page-content
|
.layout-page-content
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
{ width: 127, height: 64, radius: 32 },
|
{ width: 127, height: 64, radius: 32 },
|
||||||
{ width: 260, height: 800, radius: 0 },
|
{ width: 260, height: 800, radius: 0 },
|
||||||
{ width: 68, height: 862, radius: 0 },
|
{ width: 68, height: 862, radius: 0 },
|
||||||
|
{ width: 252, height: 846, radius: 16 },
|
||||||
|
{ width: 60, height: 846, radius: 16 },
|
||||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||||
for (const deformation of [0, 48, 100])
|
for (const deformation of [0, 48, 100])
|
||||||
for (const translation of [0, 48, 100])
|
for (const translation of [0, 48, 100])
|
||||||
|
|||||||
Reference in New Issue
Block a user