mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): extend readable optics and frost to desktop navigation
This commit is contained in:
@@ -211,6 +211,7 @@ export default defineComponent({
|
|||||||
? 'theme-qualified'
|
? 'theme-qualified'
|
||||||
: 'connected',
|
: 'connected',
|
||||||
'data-shell-scroll-direction': shellScroll.direction.value,
|
'data-shell-scroll-direction': shellScroll.direction.value,
|
||||||
|
'data-glass-navigation-refraction': navbarRefractionMode,
|
||||||
'data-glass-navbar-refraction': navbarRefractionMode,
|
'data-glass-navbar-refraction': navbarRefractionMode,
|
||||||
style: {
|
style: {
|
||||||
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
'--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { Ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
createGlassNavbarDisplacementMap,
|
createGlassNavbarDisplacementMap,
|
||||||
getGlassNavbarOpticalResponse,
|
getGlassNavbarOpticalResponse,
|
||||||
@@ -6,6 +7,42 @@ import {
|
|||||||
} from '@/utils/glassNavbarRefraction'
|
} from '@/utils/glassNavbarRefraction'
|
||||||
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||||
|
|
||||||
|
/** 独立持有几何、位移图和就绪状态的导航表面。 */
|
||||||
|
type NavigationSurface = 'navbar' | 'sidebar'
|
||||||
|
|
||||||
|
interface NavigationSurfaceState {
|
||||||
|
/** 已解码缓存对应的完整几何与光学参数键。 */
|
||||||
|
cachedGeometry: string
|
||||||
|
/** 已成功解码、允许重新激活的 PNG。 */
|
||||||
|
cachedMap: string
|
||||||
|
/** 初始图像尺寸与计算样式不可用时的半径回退。 */
|
||||||
|
defaultGeometry: {
|
||||||
|
height: number
|
||||||
|
radius: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
/** 被观察的真实导航元素,前景控件不参与位移。 */
|
||||||
|
element: HTMLElement | null
|
||||||
|
/** 避免相同失败输入反复解码的键。 */
|
||||||
|
failedGeometry: string
|
||||||
|
/** 必须全部结束后才允许激活最终几何的过渡属性。 */
|
||||||
|
geometryTransitions: Set<string>
|
||||||
|
/** 输入改变时解除失败重试抑制的上一份几何键。 */
|
||||||
|
lastObservedGeometry: string
|
||||||
|
/** 使过时解码结果失效的单调版本。 */
|
||||||
|
mapRevision: number
|
||||||
|
/** 与 feImage 同批提交的 CSS 像素尺寸。 */
|
||||||
|
mapSize: { height: number; width: number }
|
||||||
|
/** 当前绑定到 feImage 的位移图。 */
|
||||||
|
mapUrl: Ref<string>
|
||||||
|
/** 正在解码的输入,重复尺寸通知不会将它取消。 */
|
||||||
|
pendingGeometry: string
|
||||||
|
/** CSS 只在该属性为 true 时激活对应 SVG。 */
|
||||||
|
readyAttribute: string
|
||||||
|
/** 承载布局资格与就绪属性的外壳。 */
|
||||||
|
shell: HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
const settings = useEffectiveGlassSettings()
|
const settings = useEffectiveGlassSettings()
|
||||||
const opticalResponse = computed(() =>
|
const opticalResponse = computed(() =>
|
||||||
getGlassNavbarOpticalResponse({
|
getGlassNavbarOpticalResponse({
|
||||||
@@ -19,6 +56,11 @@ const DEFAULT_NAVBAR_GEOMETRY = {
|
|||||||
radius: 16,
|
radius: 16,
|
||||||
width: 1200,
|
width: 1200,
|
||||||
}
|
}
|
||||||
|
const DEFAULT_SIDEBAR_GEOMETRY = {
|
||||||
|
height: 800,
|
||||||
|
radius: 0,
|
||||||
|
width: 260,
|
||||||
|
}
|
||||||
const MAP_RESIZE_SETTLE_MS = 60
|
const MAP_RESIZE_SETTLE_MS = 60
|
||||||
const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
||||||
'--shell-floating-navbar-radius',
|
'--shell-floating-navbar-radius',
|
||||||
@@ -26,6 +68,8 @@ const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
|||||||
'--layout-navbar-block-size',
|
'--layout-navbar-block-size',
|
||||||
'--layout-navbar-safe-area-top',
|
'--layout-navbar-safe-area-top',
|
||||||
'--navbar-tab-height',
|
'--navbar-tab-height',
|
||||||
|
'--layout-vertical-nav-width',
|
||||||
|
'--layout-vertical-nav-collapsed-width',
|
||||||
'border-radius',
|
'border-radius',
|
||||||
'border-start-start-radius',
|
'border-start-start-radius',
|
||||||
'width',
|
'width',
|
||||||
@@ -33,45 +77,104 @@ const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
|||||||
'inline-size',
|
'inline-size',
|
||||||
'block-size',
|
'block-size',
|
||||||
]
|
]
|
||||||
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
const navbarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||||
const displacementMapSize = reactive({
|
const sidebarMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||||
|
const navbarMapSize = reactive({
|
||||||
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
||||||
width: DEFAULT_NAVBAR_GEOMETRY.width,
|
width: DEFAULT_NAVBAR_GEOMETRY.width,
|
||||||
})
|
})
|
||||||
|
const sidebarMapSize = reactive({
|
||||||
|
height: DEFAULT_SIDEBAR_GEOMETRY.height,
|
||||||
|
width: DEFAULT_SIDEBAR_GEOMETRY.width,
|
||||||
|
})
|
||||||
|
|
||||||
let observedNavbar: HTMLElement | null = null
|
|
||||||
let observedShell: HTMLElement | null = null
|
let observedShell: HTMLElement | null = null
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
let stateObserver: MutationObserver | null = null
|
let stateObserver: MutationObserver | null = null
|
||||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let transparencyQuery: MediaQueryList | null = null
|
let transparencyQuery: MediaQueryList | null = null
|
||||||
let mapRevision = 0
|
const navbarState: NavigationSurfaceState = {
|
||||||
let cachedGeometry = ''
|
cachedGeometry: '',
|
||||||
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
|
cachedMap: NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||||
let pendingGeometry = ''
|
defaultGeometry: DEFAULT_NAVBAR_GEOMETRY,
|
||||||
let failedGeometry = ''
|
element: null,
|
||||||
let lastObservedGeometry = ''
|
failedGeometry: '',
|
||||||
const geometryTransitions = new Set<string>()
|
geometryTransitions: new Set(),
|
||||||
|
lastObservedGeometry: '',
|
||||||
|
mapRevision: 0,
|
||||||
|
mapSize: navbarMapSize,
|
||||||
|
mapUrl: navbarMapUrl,
|
||||||
|
pendingGeometry: '',
|
||||||
|
readyAttribute: 'data-glass-navbar-refraction-ready',
|
||||||
|
shell: null,
|
||||||
|
}
|
||||||
|
const sidebarState: NavigationSurfaceState = {
|
||||||
|
cachedGeometry: '',
|
||||||
|
cachedMap: NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||||
|
defaultGeometry: DEFAULT_SIDEBAR_GEOMETRY,
|
||||||
|
element: null,
|
||||||
|
failedGeometry: '',
|
||||||
|
geometryTransitions: new Set(),
|
||||||
|
lastObservedGeometry: '',
|
||||||
|
mapRevision: 0,
|
||||||
|
mapSize: sidebarMapSize,
|
||||||
|
mapUrl: sidebarMapUrl,
|
||||||
|
pendingGeometry: '',
|
||||||
|
readyAttribute: 'data-glass-sidebar-refraction-ready',
|
||||||
|
shell: null,
|
||||||
|
}
|
||||||
|
const surfaceStates: Record<NavigationSurface, NavigationSurfaceState> = {
|
||||||
|
navbar: navbarState,
|
||||||
|
sidebar: sidebarState,
|
||||||
|
}
|
||||||
|
const SURFACE_KEYS: readonly NavigationSurface[] = ['navbar', 'sidebar']
|
||||||
|
const transitionHandlers: Record<NavigationSurface, EventListener | null> = {
|
||||||
|
navbar: null,
|
||||||
|
sidebar: null,
|
||||||
|
}
|
||||||
|
|
||||||
/** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */
|
function getSurfaceState(surface: NavigationSurface) {
|
||||||
function isRefractionActive() {
|
return surfaceStates[surface]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有桌面清透/色调导航进入 SVG 位移增强;磨砂固定层沿用稳定背板或原生 CSS。 */
|
||||||
|
function isRefractionActive(surface: NavigationSurface) {
|
||||||
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
|
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
|
||||||
|
const state = getSurfaceState(surface)
|
||||||
|
const shell = state.shell
|
||||||
|
const element = state.element
|
||||||
|
|
||||||
|
if (
|
||||||
|
!element ||
|
||||||
|
!shell ||
|
||||||
|
theme !== 'glass' ||
|
||||||
|
(glassAppearance !== 'clear' && glassAppearance !== 'tinted') ||
|
||||||
|
(glassQuality !== 'balanced' && glassQuality !== 'high') ||
|
||||||
|
transparencyQuery?.matches
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (surface === 'navbar') {
|
||||||
|
return (
|
||||||
|
shell.classList.contains('layout-navbar-floating-eligible') &&
|
||||||
|
shell.classList.contains('layout-navbar-away-from-top')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
theme === 'glass' &&
|
!element.classList.contains('overlay-nav') &&
|
||||||
(glassAppearance === 'clear' || glassAppearance === 'tinted') &&
|
!shell.classList.contains('layout-overlay-nav') &&
|
||||||
(glassQuality === 'balanced' || glassQuality === 'high') &&
|
!shell.classList.contains('layout-app-shell') &&
|
||||||
observedShell?.classList.contains('layout-navbar-floating-eligible') &&
|
!shell.classList.contains('layout-horizontal-nav-active')
|
||||||
observedShell.classList.contains('layout-navbar-away-from-top') &&
|
|
||||||
!transparencyQuery?.matches
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 几何或状态变化后立即撤销旧 map,避免在另一个尺寸中采样。 */
|
/** 几何或状态变化后立即撤销对应 map,避免另一个尺寸继续采样旧位移场。 */
|
||||||
function invalidateDisplacementMap() {
|
function invalidateDisplacementMap(surface: NavigationSurface) {
|
||||||
mapRevision += 1
|
const state = getSurfaceState(surface)
|
||||||
pendingGeometry = ''
|
state.mapRevision += 1
|
||||||
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'false')
|
state.pendingGeometry = ''
|
||||||
|
state.shell?.setAttribute(state.readyAttribute, 'false')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInlineStyleValue(styleText: string | null, property: string) {
|
function getInlineStyleValue(styleText: string | null, property: string) {
|
||||||
@@ -96,17 +199,18 @@ function handleStateMutations(records: MutationRecord[]) {
|
|||||||
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
|
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以真实边界和计算后的圆角统一比较已解码、待解码与当前采样几何。 */
|
/** 读取真实表面边界;圆角使用计算后的 CSS 像素,矩形侧栏明确传入 0。 */
|
||||||
function readDisplacementGeometry() {
|
function readDisplacementGeometry(surface: NavigationSurface) {
|
||||||
if (!observedNavbar) return null
|
const state = getSurfaceState(surface)
|
||||||
const bounds = observedNavbar.getBoundingClientRect()
|
if (!state.element) return null
|
||||||
const styles = getComputedStyle(observedNavbar)
|
const bounds = state.element.getBoundingClientRect()
|
||||||
|
const styles = getComputedStyle(state.element)
|
||||||
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
|
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
|
||||||
const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
|
const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
|
||||||
const height = Math.max(1, Math.round(bounds.height))
|
const height = Math.max(1, Math.round(bounds.height))
|
||||||
const width = Math.max(1, Math.round(bounds.width))
|
const width = Math.max(1, Math.round(bounds.width))
|
||||||
|
|
||||||
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
|
const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
|
||||||
const optics = opticalResponse.value
|
const optics = opticalResponse.value
|
||||||
return {
|
return {
|
||||||
height,
|
height,
|
||||||
@@ -118,92 +222,107 @@ function readDisplacementGeometry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
|
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
|
||||||
async function syncDisplacementMap() {
|
async function syncDisplacementMap(surface: NavigationSurface) {
|
||||||
if (!isRefractionActive() || geometryTransitions.size > 0) return
|
const state = getSurfaceState(surface)
|
||||||
const geometry = readDisplacementGeometry()
|
if (!isRefractionActive(surface) || state.geometryTransitions.size > 0) return
|
||||||
if (!geometry || pendingGeometry === geometry.key) return
|
const geometry = readDisplacementGeometry(surface)
|
||||||
|
if (!geometry || state.pendingGeometry === geometry.key) return
|
||||||
const { height, radius, width, optics, key: geometryKey } = geometry
|
const { height, radius, width, optics, key: geometryKey } = geometry
|
||||||
const revision = ++mapRevision
|
const revision = ++state.mapRevision
|
||||||
pendingGeometry = geometryKey
|
state.pendingGeometry = geometryKey
|
||||||
if (lastObservedGeometry !== geometryKey) {
|
if (state.lastObservedGeometry !== geometryKey) {
|
||||||
lastObservedGeometry = geometryKey
|
state.lastObservedGeometry = geometryKey
|
||||||
failedGeometry = ''
|
state.failedGeometry = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cachedGeometry !== geometryKey) {
|
if (state.cachedGeometry !== geometryKey) {
|
||||||
if (failedGeometry === geometryKey) return
|
if (state.failedGeometry === geometryKey) return
|
||||||
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
||||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||||
failedGeometry = geometryKey
|
state.failedGeometry = geometryKey
|
||||||
invalidateDisplacementMap()
|
invalidateDisplacementMap(surface)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const decoded = new Image()
|
const decoded = new Image()
|
||||||
decoded.src = map
|
decoded.src = map
|
||||||
await decoded.decode()
|
await decoded.decode()
|
||||||
if (revision !== mapRevision || !isRefractionActive()) return
|
if (revision !== state.mapRevision || !isRefractionActive(surface)) return
|
||||||
cachedGeometry = geometryKey
|
state.cachedGeometry = geometryKey
|
||||||
cachedMap = map
|
state.cachedMap = map
|
||||||
failedGeometry = ''
|
state.failedGeometry = ''
|
||||||
}
|
}
|
||||||
displacementMapSize.height = height
|
state.mapSize.height = height
|
||||||
displacementMapSize.width = width
|
state.mapSize.width = width
|
||||||
displacementMapUrl.value = cachedMap
|
state.mapUrl.value = state.cachedMap
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (revision === mapRevision && isRefractionActive()) {
|
if (revision === state.mapRevision && isRefractionActive(surface)) {
|
||||||
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
|
state.shell?.setAttribute(state.readyAttribute, 'true')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
|
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
|
||||||
if (revision === mapRevision) {
|
if (revision === state.mapRevision) {
|
||||||
failedGeometry = geometryKey
|
state.failedGeometry = geometryKey
|
||||||
invalidateDisplacementMap()
|
invalidateDisplacementMap(surface)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (revision === mapRevision) pendingGeometry = ''
|
if (revision === state.mapRevision) state.pendingGeometry = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDisplacementMaps() {
|
||||||
|
for (const surface of SURFACE_KEYS) void syncDisplacementMap(surface)
|
||||||
|
}
|
||||||
|
|
||||||
// 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。
|
// 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。
|
||||||
function scheduleDisplacementMapSync() {
|
function scheduleDisplacementMapSync() {
|
||||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
if (!isRefractionActive() || geometryTransitions.size > 0) {
|
|
||||||
invalidateDisplacementMap()
|
let shouldSync = false
|
||||||
return
|
for (const surface of SURFACE_KEYS) {
|
||||||
|
const state = getSurfaceState(surface)
|
||||||
|
if (!isRefractionActive(surface) || state.geometryTransitions.size > 0) {
|
||||||
|
if (state.element) invalidateDisplacementMap(surface)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
const geometry = readDisplacementGeometry()
|
|
||||||
if (geometry?.key === pendingGeometry) return
|
const geometry = readDisplacementGeometry(surface)
|
||||||
if (geometry?.key === cachedGeometry && displacementMapUrl.value === cachedMap) {
|
if (!geometry || geometry.key === state.pendingGeometry) continue
|
||||||
|
if (geometry.key === state.cachedGeometry && state.mapUrl.value === state.cachedMap) {
|
||||||
// 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。
|
// 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。
|
||||||
if (pendingGeometry) invalidateDisplacementMap()
|
if (state.pendingGeometry) invalidateDisplacementMap(surface)
|
||||||
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
|
state.shell?.setAttribute(state.readyAttribute, 'true')
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
invalidateDisplacementMap()
|
invalidateDisplacementMap(surface)
|
||||||
|
shouldSync = true
|
||||||
|
}
|
||||||
|
if (!shouldSync) return
|
||||||
|
|
||||||
resizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
void syncDisplacementMap()
|
syncDisplacementMaps()
|
||||||
}, MAP_RESIZE_SETTLE_MS)
|
}, MAP_RESIZE_SETTLE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGeometryTransition(event: TransitionEvent) {
|
function handleGeometryTransition(surface: NavigationSurface, event: TransitionEvent) {
|
||||||
|
const state = getSurfaceState(surface)
|
||||||
if (
|
if (
|
||||||
event.target !== observedNavbar ||
|
event.target !== state.element ||
|
||||||
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
|
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if (event.type === 'transitionrun') {
|
if (event.type === 'transitionrun') {
|
||||||
geometryTransitions.add(event.propertyName)
|
state.geometryTransitions.add(event.propertyName)
|
||||||
invalidateDisplacementMap()
|
invalidateDisplacementMap(surface)
|
||||||
} else {
|
} else {
|
||||||
geometryTransitions.delete(event.propertyName)
|
state.geometryTransitions.delete(event.propertyName)
|
||||||
// transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。
|
// transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。
|
||||||
if (geometryTransitions.size === 0) {
|
if (state.geometryTransitions.size === 0) {
|
||||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
void syncDisplacementMap()
|
syncDisplacementMaps()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,9 +331,16 @@ function handleGeometryTransition(event: TransitionEvent) {
|
|||||||
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
|
watch(opticalResponse, scheduleDisplacementMapSync, { flush: 'sync' })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
observedShell =
|
||||||
if (!observedNavbar) return
|
document.querySelector<HTMLElement>('.layout-wrapper[data-glass-navigation-refraction="chromium"]') ??
|
||||||
observedShell = observedNavbar.closest('.layout-wrapper')
|
document.querySelector<HTMLElement>('.layout-wrapper[data-glass-navbar-refraction="chromium"]')
|
||||||
|
if (!observedShell) return
|
||||||
|
|
||||||
|
navbarState.element = observedShell.querySelector<HTMLElement>('.layout-navbar')
|
||||||
|
// Drawer 与桌面共用此元素;资格随断点判断,不能在挂载时永久排除 overlay 状态。
|
||||||
|
sidebarState.element = observedShell.querySelector<HTMLElement>('.layout-vertical-nav')
|
||||||
|
for (const surface of SURFACE_KEYS) getSurfaceState(surface).shell = observedShell
|
||||||
|
|
||||||
transparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)')
|
transparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)')
|
||||||
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
|
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
|
||||||
stateObserver = new MutationObserver(handleStateMutations)
|
stateObserver = new MutationObserver(handleStateMutations)
|
||||||
@@ -223,21 +349,26 @@ onMounted(() => {
|
|||||||
attributeOldValue: true,
|
attributeOldValue: true,
|
||||||
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
|
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
|
||||||
})
|
})
|
||||||
if (observedShell) {
|
|
||||||
stateObserver.observe(observedShell, {
|
stateObserver.observe(observedShell, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeOldValue: true,
|
attributeOldValue: true,
|
||||||
attributeFilter: ['class', 'style'],
|
attributeFilter: ['class', 'style'],
|
||||||
})
|
})
|
||||||
}
|
for (const surface of SURFACE_KEYS) {
|
||||||
stateObserver.observe(observedNavbar, {
|
const state = getSurfaceState(surface)
|
||||||
|
const element = state.element
|
||||||
|
if (!element) continue
|
||||||
|
stateObserver.observe(element, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeOldValue: true,
|
attributeOldValue: true,
|
||||||
attributeFilter: ['class', 'style'],
|
attributeFilter: ['class', 'style'],
|
||||||
})
|
})
|
||||||
observedNavbar.addEventListener('transitionrun', handleGeometryTransition)
|
const transitionHandler: EventListener = event => handleGeometryTransition(surface, event as TransitionEvent)
|
||||||
observedNavbar.addEventListener('transitionend', handleGeometryTransition)
|
transitionHandlers[surface] = transitionHandler
|
||||||
observedNavbar.addEventListener('transitioncancel', handleGeometryTransition)
|
element.addEventListener('transitionrun', transitionHandler)
|
||||||
|
element.addEventListener('transitionend', transitionHandler)
|
||||||
|
element.addEventListener('transitioncancel', transitionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
scheduleDisplacementMapSync()
|
scheduleDisplacementMapSync()
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
@@ -247,12 +378,27 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(scheduleDisplacementMapSync)
|
resizeObserver = new ResizeObserver(scheduleDisplacementMapSync)
|
||||||
resizeObserver.observe(observedNavbar)
|
for (const surface of SURFACE_KEYS) {
|
||||||
|
const element = getSurfaceState(surface).element
|
||||||
|
if (element) resizeObserver.observe(element)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
invalidateDisplacementMap()
|
for (const surface of SURFACE_KEYS) {
|
||||||
geometryTransitions.clear()
|
const state = getSurfaceState(surface)
|
||||||
|
invalidateDisplacementMap(surface)
|
||||||
|
state.geometryTransitions.clear()
|
||||||
|
if (state.element && transitionHandlers[surface]) {
|
||||||
|
state.element.removeEventListener('transitionrun', transitionHandlers[surface])
|
||||||
|
state.element.removeEventListener('transitionend', transitionHandlers[surface])
|
||||||
|
state.element.removeEventListener('transitioncancel', transitionHandlers[surface])
|
||||||
|
}
|
||||||
|
transitionHandlers[surface] = null
|
||||||
|
state.shell?.removeAttribute(state.readyAttribute)
|
||||||
|
state.element = null
|
||||||
|
state.shell = null
|
||||||
|
}
|
||||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
resizeTimer = null
|
resizeTimer = null
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
@@ -261,12 +407,7 @@ onBeforeUnmount(() => {
|
|||||||
stateObserver = null
|
stateObserver = null
|
||||||
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
|
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
|
||||||
transparencyQuery = null
|
transparencyQuery = null
|
||||||
observedNavbar?.removeEventListener('transitionrun', handleGeometryTransition)
|
|
||||||
observedNavbar?.removeEventListener('transitionend', handleGeometryTransition)
|
|
||||||
observedNavbar?.removeEventListener('transitioncancel', handleGeometryTransition)
|
|
||||||
observedShell?.removeAttribute('data-glass-navbar-refraction-ready')
|
|
||||||
window.removeEventListener('resize', scheduleDisplacementMapSync)
|
window.removeEventListener('resize', scheduleDisplacementMapSync)
|
||||||
observedNavbar = null
|
|
||||||
observedShell = null
|
observedShell = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -285,10 +426,10 @@ onBeforeUnmount(() => {
|
|||||||
<feImage
|
<feImage
|
||||||
x="0"
|
x="0"
|
||||||
y="0"
|
y="0"
|
||||||
:width="displacementMapSize.width"
|
:width="navbarMapSize.width"
|
||||||
:height="displacementMapSize.height"
|
:height="navbarMapSize.height"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
:href="displacementMapUrl"
|
:href="navbarMapUrl"
|
||||||
result="map"
|
result="map"
|
||||||
/>
|
/>
|
||||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
||||||
@@ -305,10 +446,50 @@ onBeforeUnmount(() => {
|
|||||||
<feImage
|
<feImage
|
||||||
x="0"
|
x="0"
|
||||||
y="0"
|
y="0"
|
||||||
:width="displacementMapSize.width"
|
:width="navbarMapSize.width"
|
||||||
:height="displacementMapSize.height"
|
:height="navbarMapSize.height"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
:href="displacementMapUrl"
|
:href="navbarMapUrl"
|
||||||
|
result="map"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter
|
||||||
|
id="glass-sidebar-live-refraction-balanced"
|
||||||
|
x="0%"
|
||||||
|
y="0%"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
>
|
||||||
|
<feImage
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
:width="sidebarMapSize.width"
|
||||||
|
:height="sidebarMapSize.height"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
:href="sidebarMapUrl"
|
||||||
|
result="map"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-22" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter
|
||||||
|
id="glass-sidebar-live-refraction-high"
|
||||||
|
x="0%"
|
||||||
|
y="0%"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
>
|
||||||
|
<feImage
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
:width="sidebarMapSize.width"
|
||||||
|
:height="sidebarMapSize.height"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
:href="sidebarMapUrl"
|
||||||
result="map"
|
result="map"
|
||||||
/>
|
/>
|
||||||
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
|
<feDisplacementMap in="SourceGraphic" in2="map" xChannelSelector="R" yChannelSelector="B" scale="-34" />
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
let resize: ResizeObserverCallback | undefined
|
let resize: ResizeObserverCallback | undefined
|
||||||
let wrapper: ReturnType<typeof mount> | undefined
|
let wrapper: ReturnType<typeof mount> | undefined
|
||||||
let width: number
|
let width: number
|
||||||
|
let sidebarWidth: number
|
||||||
let radius: number
|
let radius: number
|
||||||
|
let sidebar: HTMLElement | undefined
|
||||||
let transparencyReduced: boolean
|
let transparencyReduced: boolean
|
||||||
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
|
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
|
||||||
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
|
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
|
||||||
@@ -30,32 +32,41 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
|
||||||
width = 1423
|
width = 1423
|
||||||
|
sidebarWidth = 260
|
||||||
radius = 16
|
radius = 16
|
||||||
transparencyReduced = false
|
transparencyReduced = false
|
||||||
transparencyChange = undefined
|
transparencyChange = undefined
|
||||||
decodePending = []
|
decodePending = []
|
||||||
|
sidebar = undefined
|
||||||
shell = document.createElement('div')
|
shell = document.createElement('div')
|
||||||
shell.className = 'layout-wrapper layout-navbar-floating-eligible layout-navbar-away-from-top'
|
shell.className =
|
||||||
|
'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top'
|
||||||
shell.dataset.glassNavbarRefraction = 'chromium'
|
shell.dataset.glassNavbarRefraction = 'chromium'
|
||||||
shell.innerHTML = '<header class="layout-navbar"></header>'
|
shell.innerHTML = '<header class="layout-navbar"></header>'
|
||||||
document.body.append(shell)
|
document.body.append(shell)
|
||||||
navbar = shell.querySelector('.layout-navbar') as HTMLElement
|
navbar = shell.querySelector('.layout-navbar') as HTMLElement
|
||||||
Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' })
|
Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' })
|
||||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||||
|
const isSidebar = this.classList.contains('layout-vertical-nav')
|
||||||
|
const elementWidth = isSidebar ? sidebarWidth : width
|
||||||
|
const elementHeight = isSidebar ? 800 : 64
|
||||||
|
|
||||||
|
return {
|
||||||
x: 16,
|
x: 16,
|
||||||
y: 16,
|
y: 16,
|
||||||
left: 16,
|
left: 16,
|
||||||
top: 16,
|
top: 16,
|
||||||
width,
|
width: elementWidth,
|
||||||
height: 64,
|
height: elementHeight,
|
||||||
right: width + 16,
|
right: elementWidth + 16,
|
||||||
bottom: 80,
|
bottom: elementHeight + 16,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||||
() =>
|
element =>
|
||||||
({
|
({
|
||||||
borderStartStartRadius: `${radius}px`,
|
borderStartStartRadius: element.classList.contains('layout-vertical-nav') ? '0px' : `${radius}px`,
|
||||||
getPropertyValue: () => '1rem',
|
getPropertyValue: () => '1rem',
|
||||||
}) as unknown as CSSStyleDeclaration,
|
}) as unknown as CSSStyleDeclaration,
|
||||||
)
|
)
|
||||||
@@ -112,6 +123,12 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth))
|
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectReadyForSidebar(expectedWidth: number) {
|
||||||
|
expect(shell.dataset.glassSidebarRefractionReady).toBe('true')
|
||||||
|
expect(wrapper?.findAll('feImage')[2].attributes('width')).toBe(String(expectedWidth))
|
||||||
|
expect(wrapper?.findAll('feImage')[2].attributes('height')).toBe('800')
|
||||||
|
}
|
||||||
|
|
||||||
function completePendingDecode() {
|
function completePendingDecode() {
|
||||||
for (const pending of decodePending.splice(0)) pending.resolve()
|
for (const pending of decodePending.splice(0)) pending.resolve()
|
||||||
}
|
}
|
||||||
@@ -122,6 +139,13 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
navbar.dispatchEvent(event)
|
navbar.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountWithSidebar(overlay = false) {
|
||||||
|
sidebar = document.createElement('aside')
|
||||||
|
sidebar.className = `layout-vertical-nav${overlay ? ' overlay-nav' : ''}`
|
||||||
|
shell.prepend(sidebar)
|
||||||
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
|
}
|
||||||
|
|
||||||
it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => {
|
it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => {
|
||||||
wrapper = mount(GlassNavbarRefractionDefs)
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
@@ -132,6 +156,87 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
expectReadyForWidth(1423)
|
expectReadyForWidth(1423)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps independent geometry caches when switching between horizontal and vertical navigation', async () => {
|
||||||
|
mountWithSidebar()
|
||||||
|
await settle()
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||||
|
expectReadyForWidth(1423)
|
||||||
|
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||||
|
shell.className = 'layout-wrapper'
|
||||||
|
await flushPromises()
|
||||||
|
await settle()
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||||
|
)
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ width: 260, height: 800, radius: 0 }),
|
||||||
|
)
|
||||||
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
|
expectReadyForSidebar(260)
|
||||||
|
|
||||||
|
shell.className =
|
||||||
|
'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top'
|
||||||
|
await flushPromises()
|
||||||
|
await settle()
|
||||||
|
expectReadyForWidth(1423)
|
||||||
|
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebuilds only the sidebar map when its collapsed width changes', async () => {
|
||||||
|
shell.className = 'layout-wrapper'
|
||||||
|
mountWithSidebar()
|
||||||
|
await settle()
|
||||||
|
const initialCallCount = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length
|
||||||
|
|
||||||
|
sidebarWidth = 80
|
||||||
|
resize?.([], {} as ResizeObserver)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(initialCallCount + 1)
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ width: 80, height: 800, radius: 0 }),
|
||||||
|
)
|
||||||
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
|
expectReadyForSidebar(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not generate a live map for a mobile Drawer surface', async () => {
|
||||||
|
shell.className = 'layout-wrapper layout-overlay-nav'
|
||||||
|
mountWithSidebar(true)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||||
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
|
expect(shell.dataset.glassSidebarRefractionReady).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates the existing sidebar when a Drawer viewport returns to desktop', async () => {
|
||||||
|
shell.className = 'layout-wrapper layout-overlay-nav'
|
||||||
|
mountWithSidebar(true)
|
||||||
|
await settle()
|
||||||
|
shell.classList.remove('layout-overlay-nav')
|
||||||
|
sidebar?.classList.remove('overlay-nav')
|
||||||
|
await flushPromises()
|
||||||
|
await settle()
|
||||||
|
expectReadyForSidebar(260)
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not strand a sidebar update when an unrelated fixed navbar transition ends', async () => {
|
||||||
|
shell.className = 'layout-wrapper'
|
||||||
|
mountWithSidebar()
|
||||||
|
await settle()
|
||||||
|
dispatchTransition('transitionrun', 'height')
|
||||||
|
effectiveSettings.value = { glassDeformationStrength: 99, glassTranslationStrength: 99 }
|
||||||
|
dispatchTransition('transitionend', 'height')
|
||||||
|
completePendingDecode()
|
||||||
|
await flushPromises()
|
||||||
|
expectReadyForSidebar(260)
|
||||||
|
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => {
|
it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => {
|
||||||
wrapper = mount(GlassNavbarRefractionDefs)
|
wrapper = mount(GlassNavbarRefractionDefs)
|
||||||
await settle()
|
await settle()
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ describe('glass overlay material styles', () => {
|
|||||||
|
|
||||||
expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))')
|
expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))')
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/,
|
/&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\]\s+body\[data-theme='glass'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/,
|
||||||
)
|
)
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\[data-glass-appearance='frosted'\]\[data-glass-quality='balanced'\]\s*\{[\s\S]*?--glass-fixed-shell-backplate-filter:\s*var\(--glass-native-surface-backdrop-filter\);/,
|
/\[data-glass-appearance='frosted'\]\[data-glass-quality='balanced'\]\s*\{[\s\S]*?--glass-fixed-shell-backplate-filter:\s*var\(--glass-native-surface-backdrop-filter\);/,
|
||||||
@@ -226,6 +226,42 @@ describe('glass overlay material styles', () => {
|
|||||||
expect(overlayBackplateRule).toMatch(/transition:\s*clip-path 0\.25s ease-in-out/u)
|
expect(overlayBackplateRule).toMatch(/transition:\s*clip-path 0\.25s ease-in-out/u)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps desktop sidebar refraction isolated from the attached navbar and mobile Drawer', () => {
|
||||||
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
const defs = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
||||||
|
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(styles).toContain('--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter)')
|
||||||
|
expect(styles).toContain('--glass-sidebar-diffusion-blur: clamp(')
|
||||||
|
expect(styles).toContain('--glass-sidebar-absorption-start: clamp(')
|
||||||
|
expect(styles).toContain('--glass-sidebar-absorption-end: clamp(')
|
||||||
|
expect(styles).toContain('--glass-sidebar-edge-opacity: clamp(')
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.layout-vertical-nav\s*\{[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-sidebar-live-filter\);[\s\S]*?background-image:\s*var\(--glass-sheen\)/,
|
||||||
|
)
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/,
|
||||||
|
)
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/,
|
||||||
|
)
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/&\[data-glass-appearance='frosted'\]\s*\{[\s\S]*?\.layout-vertical-nav::before\s*\{[\s\S]*?var\(--glass-sidebar-absorption-start\)[\s\S]*?var\(--glass-sidebar-absorption-end\)[\s\S]*?var\(--glass-sidebar-edge-opacity\)/,
|
||||||
|
)
|
||||||
|
expect(defs).toContain('id="glass-sidebar-live-refraction-balanced"')
|
||||||
|
expect(defs).toContain('id="glass-sidebar-live-refraction-high"')
|
||||||
|
expect(styles).not.toContain("url('#glass-sidebar-live-refraction-high') var(--glass-fixed-shell-backdrop-filter)")
|
||||||
|
expect(styles).toContain('--glass-sidebar-live-filter: none !important')
|
||||||
|
expect(styles).toContain('--glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter)')
|
||||||
|
expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: none')
|
||||||
|
expect(defs).toContain("readyAttribute: 'data-glass-sidebar-refraction-ready'")
|
||||||
|
expect(layout).toContain("'data-glass-navigation-refraction': navbarRefractionMode")
|
||||||
|
expect(layout).toContain("'data-glass-navbar-refraction': navbarRefractionMode")
|
||||||
|
expect(styles).not.toContain(
|
||||||
|
".layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']\n .layout-navbar",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('limits detached navbar geometry to eligible Transparent and Glass horizontal shells', () => {
|
it('limits detached navbar geometry to eligible Transparent and Glass horizontal shells', () => {
|
||||||
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
|
const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ html[data-theme='glass'] {
|
|||||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
||||||
--glass-navbar-live-filter: none;
|
--glass-navbar-live-filter: none;
|
||||||
|
--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
||||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||||
--glass-overlay-saturate: 115%;
|
--glass-overlay-saturate: 115%;
|
||||||
@@ -228,6 +229,35 @@ html[data-theme='glass'] {
|
|||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
|
--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
// 固定大表面用受控扩散与吸收表达厚度,避免直接沿用卡片的高频玻璃参数。
|
||||||
|
--glass-sidebar-diffusion-blur: clamp(
|
||||||
|
14px,
|
||||||
|
calc(6px + var(--glass-surface-density, 0.86) * 16px + (1 - var(--glass-background-visibility, 0.58)) * 6px),
|
||||||
|
28px
|
||||||
|
);
|
||||||
|
--glass-sidebar-diffusion-saturation: clamp(
|
||||||
|
126%,
|
||||||
|
calc(112% + var(--glass-background-visibility, 0.58) * 30% + var(--glass-surface-density, 0.86) * 8%),
|
||||||
|
154%
|
||||||
|
);
|
||||||
|
--glass-sidebar-absorption-start: clamp(
|
||||||
|
0.18,
|
||||||
|
calc(0.08 + var(--glass-surface-density, 0.86) * 0.2 + (1 - var(--glass-background-visibility, 0.58)) * 0.12),
|
||||||
|
0.42
|
||||||
|
);
|
||||||
|
--glass-sidebar-absorption-end: clamp(
|
||||||
|
0.28,
|
||||||
|
calc(var(--glass-sidebar-absorption-start) + 0.12 + (1 - var(--glass-background-visibility, 0.58)) * 0.08),
|
||||||
|
0.54
|
||||||
|
);
|
||||||
|
--glass-sidebar-edge-opacity: clamp(
|
||||||
|
0.12,
|
||||||
|
calc(0.08 + var(--glass-reflection, 0.5) * 0.22 + (1 - var(--glass-background-visibility, 0.58)) * 0.06),
|
||||||
|
0.4
|
||||||
|
);
|
||||||
|
--glass-sidebar-backdrop-filter: blur(var(--glass-sidebar-diffusion-blur))
|
||||||
|
saturate(var(--glass-sidebar-diffusion-saturation)) brightness(var(--glass-transmission-brightness));
|
||||||
|
--glass-sidebar-live-filter: var(--glass-sidebar-backdrop-filter);
|
||||||
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
||||||
--glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
|
--glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
|
||||||
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
|
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
|
||||||
@@ -490,8 +520,8 @@ html[data-theme='glass'] {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
border-inline-end: 1px solid var(--glass-border-raised);
|
border-inline-end: 1px solid var(--glass-border-raised);
|
||||||
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
-webkit-backdrop-filter: var(--glass-sidebar-live-filter);
|
||||||
backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
|
backdrop-filter: var(--glass-sidebar-live-filter);
|
||||||
background-color: var(--glass-surface-raised);
|
background-color: var(--glass-surface-raised);
|
||||||
background-image: var(--glass-sheen);
|
background-image: var(--glass-sheen);
|
||||||
box-shadow: var(--glass-shadow-raised);
|
box-shadow: var(--glass-shadow-raised);
|
||||||
@@ -501,6 +531,59 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-glass-appearance='frosted'] {
|
||||||
|
.layout-wrapper[data-shell-mode='desktop'] {
|
||||||
|
--glass-navbar-backdrop-filter: var(--glass-sidebar-backdrop-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frosted 的大表面先扩散再吸收背景,边缘高光只保留在真实 DOM 背板的外轮廓。
|
||||||
|
.layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before {
|
||||||
|
border-inline-end: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image:
|
||||||
|
var(--glass-sheen),
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(7, 14, 25, var(--glass-sidebar-absorption-start)),
|
||||||
|
rgba(7, 14, 25, var(--glass-sidebar-absorption-end))
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset -1px 0 0 rgba(255, 255, 255, var(--glass-sidebar-edge-opacity)),
|
||||||
|
0 8px 24px rgba(3, 7, 18, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper[data-shell-mode='desktop'] > .glass-fixed-shell-backplate--main {
|
||||||
|
// 固定顶栏与侧栏共用同一 L 形稳定背板,扩散参数必须作用于真实承载层。
|
||||||
|
--glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper[data-shell-mode='desktop'].layout-fixed-shell-backplate-active {
|
||||||
|
--glass-navbar-scrolled-backdrop-filter: none;
|
||||||
|
|
||||||
|
.layout-navbar,
|
||||||
|
.layout-vertical-nav::before {
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-vertical-nav::before,
|
||||||
|
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar {
|
||||||
|
-webkit-backdrop-filter: var(--glass-sidebar-backdrop-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-sidebar-backdrop-filter) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar {
|
||||||
|
// 原生回退没有壁纸背板的吸收层,独立保证繁忙页面下的文字对比。
|
||||||
|
background:
|
||||||
|
var(--glass-sheen),
|
||||||
|
linear-gradient(
|
||||||
|
rgba(7, 14, 25, var(--glass-sidebar-absorption-start)),
|
||||||
|
rgba(7, 14, 25, var(--glass-sidebar-absorption-end))
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chromium 磨砂 fixed 层直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。
|
// Chromium 磨砂 fixed 层直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。
|
||||||
&[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] {
|
&[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] {
|
||||||
.layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before,
|
.layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before,
|
||||||
@@ -1764,6 +1847,45 @@ html[data-theme='glass'][data-glass-quality='balanced']:is(
|
|||||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。
|
||||||
|
html[data-theme='glass'][data-glass-quality='high']:is(
|
||||||
|
[data-glass-appearance='clear'],
|
||||||
|
[data-glass-appearance='tinted']
|
||||||
|
)
|
||||||
|
body[data-theme='glass']
|
||||||
|
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||||
|
.layout-vertical-nav:not(.overlay-nav) {
|
||||||
|
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') blur(1px) saturate(115%)
|
||||||
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
|
||||||
|
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||||
|
[data-glass-appearance='clear'],
|
||||||
|
[data-glass-appearance='tinted']
|
||||||
|
)
|
||||||
|
body[data-theme='glass']
|
||||||
|
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||||
|
.layout-vertical-nav:not(.overlay-nav) {
|
||||||
|
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') blur(1px) saturate(115%)
|
||||||
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
|
||||||
|
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme='glass'][data-glass-appearance='tinted']
|
html[data-theme='glass'][data-glass-appearance='tinted']
|
||||||
body[data-theme='glass']
|
body[data-theme='glass']
|
||||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
@@ -1778,6 +1900,10 @@ html[data-theme='glass'][data-glass-appearance='tinted']
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
html[data-theme='glass'] body[data-theme='glass'] .layout-vertical-nav {
|
||||||
|
--glass-sidebar-live-filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||||
body[data-theme='glass']
|
body[data-theme='glass']
|
||||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
{ width: 401, height: 72, radius: 16 },
|
{ width: 401, height: 72, radius: 16 },
|
||||||
{ width: 127, height: 64, radius: 8 },
|
{ width: 127, height: 64, radius: 8 },
|
||||||
{ width: 127, height: 64, radius: 32 },
|
{ width: 127, height: 64, radius: 32 },
|
||||||
|
{ width: 260, height: 800, radius: 0 },
|
||||||
])('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])
|
||||||
@@ -115,6 +116,21 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a fixed rectangle optically active without substituting a rounded corner', () => {
|
||||||
|
const field = createGlassNavbarDisplacementField({
|
||||||
|
height: 800,
|
||||||
|
radius: 0,
|
||||||
|
width: 260,
|
||||||
|
optics: getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(pixelAt(field, 130, 0)).toEqual([128, 128, 128, 255])
|
||||||
|
expect(pixelAt(field, 130, 8)[2]).toBeLessThan(128)
|
||||||
|
expect(pixelAt(field, 8, 400)[0]).toBeLessThan(128)
|
||||||
|
expect(pixelAt(field, 8, 8)[0]).toBeLessThan(128)
|
||||||
|
expect(pixelAt(field, 8, 8)[2]).toBeLessThan(128)
|
||||||
|
})
|
||||||
|
|
||||||
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||||
const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
|
const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,10 @@ function smoothstep(value: number) {
|
|||||||
|
|
||||||
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
|
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
|
||||||
function refractionProfile(depth: number, band: number, guard: number) {
|
function refractionProfile(depth: number, band: number, guard: number) {
|
||||||
|
if (depth <= guard || depth >= band) return 0
|
||||||
|
|
||||||
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
|
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
|
||||||
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard))
|
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard || 1))
|
||||||
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
|
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,9 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const pixelHeight = normalizePixelSize(height)
|
const pixelHeight = normalizePixelSize(height)
|
||||||
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
|
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
|
||||||
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
||||||
const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2)
|
// 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。
|
||||||
|
const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX
|
||||||
|
const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||||
|
|
||||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||||
@@ -133,7 +137,8 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const edgeY = Math.min(sampleY, pixelHeight - sampleY)
|
const edgeY = Math.min(sampleY, pixelHeight - sampleY)
|
||||||
const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1)))
|
const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1)))
|
||||||
const cornerBand = Math.min(maximumBand, pixelRadius)
|
const cornerBand = Math.min(maximumBand, pixelRadius)
|
||||||
const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight
|
// 矩形角点没有圆弧法线;固定带宽交给四条直边的轴向剖面处理,避免角点成为采样断点。
|
||||||
|
const bandWidth = pixelRadius === 0 ? maximumBand : cornerBand + (maximumBand - cornerBand) * straightWeight
|
||||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||||
const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
@@ -145,6 +150,29 @@ export function createGlassNavbarDisplacementField({
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||||
|
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||||
|
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||||
|
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
|
if (pixelRadius === 0) {
|
||||||
|
// 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。
|
||||||
|
const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard)
|
||||||
|
const rightProfile = refractionProfile(pixelWidth - sampleX, maximumBand, outerGuard)
|
||||||
|
const topProfile = refractionProfile(sampleY, maximumBand, outerGuard)
|
||||||
|
const bottomProfile = refractionProfile(pixelHeight - sampleY, maximumBand, outerGuard)
|
||||||
|
const verticalAmplitude = (maximumBand * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
const offset = (y * pixelWidth + x) * 4
|
||||||
|
|
||||||
|
pixels[offset] = clampChannel(
|
||||||
|
DISPLACEMENT_NEUTRAL_CHANNEL + channelAmplitude * (rightProfile - leftProfile) + translationChannel,
|
||||||
|
)
|
||||||
|
pixels[offset + 2] = clampChannel(
|
||||||
|
DISPLACEMENT_NEUTRAL_CHANNEL + verticalAmplitude * (bottomProfile - topProfile),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||||
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
||||||
@@ -157,11 +185,6 @@ export function createGlassNavbarDisplacementField({
|
|||||||
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
||||||
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
||||||
const offset = (y * pixelWidth + x) * 4
|
const offset = (y * pixelWidth + x) * 4
|
||||||
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
|
||||||
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
|
||||||
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
|
||||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
|
||||||
|
|
||||||
pixels[offset] = clampChannel(
|
pixels[offset] = clampChannel(
|
||||||
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
|
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user