From 791b20a3032f18b9e9a96c3764ffc1da08fc143a Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 6 Sep 2026 03:13:34 +0800 Subject: [PATCH] fix(glass): restore refraction when navbar motion settles --- .../theme/GlassNavbarRefractionDefs.vue | 55 +++++++++++++++---- .../GlassNavbarRefractionDefs.spec.ts | 45 ++++++++++++++- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index 31cef070..752f6203 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -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() @@ -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() + } } } diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index 31558636..b10092ad 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -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)