fix(glass): restore refraction when navbar motion settles

This commit is contained in:
InfinityPacer
2026-09-06 03:13:34 +08:00
parent a9c804e724
commit 791b20a303
2 changed files with 88 additions and 12 deletions
@@ -48,6 +48,7 @@ let transparencyQuery: MediaQueryList | null = null
let mapRevision = 0
let cachedGeometry = ''
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
let pendingGeometry = ''
let failedGeometry = ''
let lastObservedGeometry = ''
const geometryTransitions = new Set<string>()
@@ -69,6 +70,7 @@ function isRefractionActive() {
/** 几何或状态变化后立即撤销旧 map,避免在另一个尺寸中采样。 */
function invalidateDisplacementMap() {
mapRevision += 1
pendingGeometry = ''
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'false')
}
@@ -94,11 +96,9 @@ function handleStateMutations(records: MutationRecord[]) {
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
}
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
async function syncDisplacementMap() {
if (!observedNavbar || !isRefractionActive() || geometryTransitions.size > 0) return
const revision = ++mapRevision
/** 以真实边界和计算后的圆角统一比较已解码、待解码与当前采样几何。 */
function readDisplacementGeometry() {
if (!observedNavbar) return null
const bounds = observedNavbar.getBoundingClientRect()
const styles = getComputedStyle(observedNavbar)
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
@@ -108,7 +108,23 @@ async function syncDisplacementMap() {
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
const optics = opticalResponse.value
const geometryKey = `${width}:${height}:${radius}:${optics.horizontalRatio}:${optics.verticalRatio}:${optics.translationPx}`
return {
height,
radius,
width,
optics,
key: `${width}:${height}:${radius}:${optics.horizontalRatio}:${optics.verticalRatio}:${optics.translationPx}`,
}
}
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
async function syncDisplacementMap() {
if (!isRefractionActive() || geometryTransitions.size > 0) return
const geometry = readDisplacementGeometry()
if (!geometry || pendingGeometry === geometry.key) return
const { height, radius, width, optics, key: geometryKey } = geometry
const revision = ++mapRevision
pendingGeometry = geometryKey
if (lastObservedGeometry !== geometryKey) {
lastObservedGeometry = geometryKey
failedGeometry = ''
@@ -144,14 +160,28 @@ async function syncDisplacementMap() {
failedGeometry = geometryKey
invalidateDisplacementMap()
}
} finally {
if (revision === mapRevision) pendingGeometry = ''
}
}
// 动画期间使用同族 CSS 材质;尺寸稳定后只重建一次,不逐帧生成或拉伸旧图。
// 连续 resize 需要合并;相同几何的通知不撤销已就绪或正在解码的位移图。
function scheduleDisplacementMapSync() {
invalidateDisplacementMap()
if (resizeTimer !== null) clearTimeout(resizeTimer)
if (!isRefractionActive()) return
resizeTimer = null
if (!isRefractionActive() || geometryTransitions.size > 0) {
invalidateDisplacementMap()
return
}
const geometry = readDisplacementGeometry()
if (geometry?.key === pendingGeometry) return
if (geometry?.key === cachedGeometry && displacementMapUrl.value === cachedMap) {
// 取消草稿可能命中旧缓存,同时还有另一参数的解码;先使该异步结果失效。
if (pendingGeometry) invalidateDisplacementMap()
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
return
}
invalidateDisplacementMap()
resizeTimer = setTimeout(() => {
resizeTimer = null
void syncDisplacementMap()
@@ -169,7 +199,12 @@ function handleGeometryTransition(event: TransitionEvent) {
invalidateDisplacementMap()
} else {
geometryTransitions.delete(event.propertyName)
if (geometryTransitions.size === 0) scheduleDisplacementMapSync()
// transitionend/cancel 已给出稳定尺寸,无需再附加 resize 防抖等待。
if (geometryTransitions.size === 0) {
if (resizeTimer !== null) clearTimeout(resizeTimer)
resizeTimer = null
void syncDisplacementMap()
}
}
}
@@ -132,15 +132,16 @@ describe('GlassNavbarRefractionDefs', () => {
expectReadyForWidth(1423)
})
it('disables old sampling during resize and caches unchanged geometry', async () => {
it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
resize?.([], {} as ResizeObserver)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
expect(shell.dataset.glassNavbarRefractionReady).toBe('true')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
width = 1200
resize?.([], {} as ResizeObserver)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
expect.objectContaining({ width: 1200, height: 64, radius: 16 }),
@@ -148,6 +149,46 @@ describe('GlassNavbarRefractionDefs', () => {
expectReadyForWidth(1200)
})
it('restores a cached map immediately when the final geometry transition ends', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
dispatchTransition('transitionrun', 'inset-inline-start')
dispatchTransition('transitionrun', 'border-radius')
dispatchTransition('transitionend', 'inset-inline-start')
await flushPromises()
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
dispatchTransition('transitionend', 'border-radius')
await flushPromises()
expectReadyForWidth(1423)
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
})
it('does not cancel a pending decode on a duplicate resize notification', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await vi.advanceTimersByTimeAsync(65)
resize?.([], {} as ResizeObserver)
completePendingDecode()
await flushPromises()
expectReadyForWidth(1423)
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
})
it('rejects a late draft decode when cancellation already restored the cached map', async () => {
vi.mocked(createGlassNavbarDisplacementMap)
.mockReturnValueOnce('data:image/png;base64,saved')
.mockReturnValueOnce('data:image/png;base64,draft')
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
effectiveSettings.value = { glassDeformationStrength: 99, glassTranslationStrength: 99 }
await vi.advanceTimersByTimeAsync(65)
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2)
effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 }
expectReadyForWidth(1423)
completePendingDecode()
await flushPromises()
expect(wrapper.get('feImage').attributes('href')).toBe('data:image/png;base64,saved')
})
it('does not generate a map in CSS quality', async () => {
document.documentElement.dataset.glassQuality = 'css'
wrapper = mount(GlassNavbarRefractionDefs)