mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
feat(glass): anchor expressive navbar refraction POC
This commit is contained in:
@@ -6,7 +6,20 @@ const DEFAULT_NAVBAR_GEOMETRY = {
|
||||
radius: 16,
|
||||
width: 1200,
|
||||
}
|
||||
const MAP_RESIZE_SETTLE_MS = 180
|
||||
const MAP_RESIZE_SETTLE_MS = 60
|
||||
const OBSERVED_SIZE_STYLE_PROPERTIES = [
|
||||
'--shell-floating-navbar-radius',
|
||||
'--shell-floating-navbar-inset',
|
||||
'--layout-navbar-block-size',
|
||||
'--layout-navbar-safe-area-top',
|
||||
'--navbar-tab-height',
|
||||
'border-radius',
|
||||
'border-start-start-radius',
|
||||
'width',
|
||||
'height',
|
||||
'inline-size',
|
||||
'block-size',
|
||||
]
|
||||
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||
const displacementMapSize = reactive({
|
||||
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
||||
@@ -14,46 +27,167 @@ const displacementMapSize = reactive({
|
||||
})
|
||||
|
||||
let observedNavbar: HTMLElement | null = null
|
||||
let observedShell: HTMLElement | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let stateObserver: MutationObserver | null = null
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let transparencyQuery: MediaQueryList | null = null
|
||||
let mapRevision = 0
|
||||
let cachedGeometry = ''
|
||||
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
|
||||
let failedGeometry = ''
|
||||
let lastObservedGeometry = ''
|
||||
const geometryTransitions = new Set<string>()
|
||||
|
||||
function syncDisplacementMap() {
|
||||
if (!observedNavbar) return
|
||||
/** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */
|
||||
function isRefractionActive() {
|
||||
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
|
||||
|
||||
return (
|
||||
theme === 'glass' &&
|
||||
(glassAppearance === 'clear' || glassAppearance === 'tinted') &&
|
||||
(glassQuality === 'balanced' || glassQuality === 'high') &&
|
||||
observedShell?.classList.contains('layout-navbar-floating-eligible') &&
|
||||
observedShell.classList.contains('layout-navbar-away-from-top') &&
|
||||
!transparencyQuery?.matches
|
||||
)
|
||||
}
|
||||
|
||||
/** 几何或状态变化后立即撤销旧 map,避免在另一个尺寸中采样。 */
|
||||
function invalidateDisplacementMap() {
|
||||
mapRevision += 1
|
||||
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'false')
|
||||
}
|
||||
|
||||
function getInlineStyleValue(styleText: string | null, property: string) {
|
||||
const declarations = document.createElement('div').style
|
||||
declarations.cssText = styleText ?? ''
|
||||
return declarations.getPropertyValue(property).trim()
|
||||
}
|
||||
|
||||
/** 滚动缩放变量不改变真实采样几何,只有尺寸声明变化才撤销当前 map。 */
|
||||
function hasObservedSizeStyleChange(record: MutationRecord) {
|
||||
if (record.attributeName !== 'style') return true
|
||||
|
||||
const target = record.target as Element
|
||||
const currentStyle = target.getAttribute('style')
|
||||
|
||||
return OBSERVED_SIZE_STYLE_PROPERTIES.some(
|
||||
property => getInlineStyleValue(record.oldValue, property) !== getInlineStyleValue(currentStyle, property),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const bounds = observedNavbar.getBoundingClientRect()
|
||||
const styles = getComputedStyle(observedNavbar)
|
||||
const floatingRadius = Number.parseFloat(styles.getPropertyValue('--shell-floating-navbar-radius'))
|
||||
// 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
|
||||
const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
|
||||
const height = Math.max(1, Math.round(bounds.height))
|
||||
const width = Math.max(1, Math.round(bounds.width))
|
||||
|
||||
displacementMapSize.height = height
|
||||
displacementMapSize.width = width
|
||||
displacementMapUrl.value = createGlassNavbarDisplacementMap({
|
||||
height,
|
||||
radius: Number.isFinite(floatingRadius)
|
||||
? floatingRadius
|
||||
: Number.isFinite(borderRadius)
|
||||
? borderRadius
|
||||
: DEFAULT_NAVBAR_GEOMETRY.radius,
|
||||
width,
|
||||
})
|
||||
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
|
||||
const geometryKey = `${width}:${height}:${radius}`
|
||||
if (lastObservedGeometry !== geometryKey) {
|
||||
lastObservedGeometry = geometryKey
|
||||
failedGeometry = ''
|
||||
}
|
||||
|
||||
// 几何动画期间沿用上一张位移图,尺寸稳定后再重建,避免逐帧生成并上传位移纹理。
|
||||
try {
|
||||
if (cachedGeometry !== geometryKey) {
|
||||
if (failedGeometry === geometryKey) return
|
||||
const map = createGlassNavbarDisplacementMap({ height, radius, width })
|
||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||
failedGeometry = geometryKey
|
||||
invalidateDisplacementMap()
|
||||
return
|
||||
}
|
||||
const decoded = new Image()
|
||||
decoded.src = map
|
||||
await decoded.decode()
|
||||
if (revision !== mapRevision || !isRefractionActive()) return
|
||||
cachedGeometry = geometryKey
|
||||
cachedMap = map
|
||||
failedGeometry = ''
|
||||
}
|
||||
displacementMapSize.height = height
|
||||
displacementMapSize.width = width
|
||||
displacementMapUrl.value = cachedMap
|
||||
await nextTick()
|
||||
if (revision === mapRevision && isRefractionActive()) {
|
||||
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
|
||||
}
|
||||
} catch {
|
||||
// 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
|
||||
if (revision === mapRevision) {
|
||||
failedGeometry = geometryKey
|
||||
invalidateDisplacementMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画期间使用同族 CSS 材质;尺寸稳定后只重建一次,不逐帧生成或拉伸旧图。
|
||||
function scheduleDisplacementMapSync() {
|
||||
invalidateDisplacementMap()
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
if (!isRefractionActive()) return
|
||||
resizeTimer = setTimeout(() => {
|
||||
resizeTimer = null
|
||||
syncDisplacementMap()
|
||||
void syncDisplacementMap()
|
||||
}, MAP_RESIZE_SETTLE_MS)
|
||||
}
|
||||
|
||||
function handleGeometryTransition(event: TransitionEvent) {
|
||||
if (
|
||||
event.target !== observedNavbar ||
|
||||
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
|
||||
)
|
||||
return
|
||||
if (event.type === 'transitionrun') {
|
||||
geometryTransitions.add(event.propertyName)
|
||||
invalidateDisplacementMap()
|
||||
} else {
|
||||
geometryTransitions.delete(event.propertyName)
|
||||
if (geometryTransitions.size === 0) scheduleDisplacementMapSync()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
||||
if (!observedNavbar) return
|
||||
observedShell = observedNavbar.closest('.layout-wrapper')
|
||||
transparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)')
|
||||
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
|
||||
stateObserver = new MutationObserver(handleStateMutations)
|
||||
stateObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
|
||||
})
|
||||
if (observedShell) {
|
||||
stateObserver.observe(observedShell, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
}
|
||||
stateObserver.observe(observedNavbar, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
observedNavbar.addEventListener('transitionrun', handleGeometryTransition)
|
||||
observedNavbar.addEventListener('transitionend', handleGeometryTransition)
|
||||
observedNavbar.addEventListener('transitioncancel', handleGeometryTransition)
|
||||
|
||||
syncDisplacementMap()
|
||||
scheduleDisplacementMapSync()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
|
||||
|
||||
@@ -65,12 +199,23 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
invalidateDisplacementMap()
|
||||
geometryTransitions.clear()
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
stateObserver?.disconnect()
|
||||
stateObserver = null
|
||||
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
|
||||
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)
|
||||
observedNavbar = null
|
||||
observedShell = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -79,10 +224,10 @@ onBeforeUnmount(() => {
|
||||
<defs>
|
||||
<filter
|
||||
id="glass-navbar-live-refraction-balanced"
|
||||
x="-8%"
|
||||
y="-80%"
|
||||
width="116%"
|
||||
height="260%"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
@@ -99,10 +244,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<filter
|
||||
id="glass-navbar-live-refraction-high"
|
||||
x="-12%"
|
||||
y="-100%"
|
||||
width="124%"
|
||||
height="300%"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
||||
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', () => ({
|
||||
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
|
||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
|
||||
}))
|
||||
|
||||
describe('GlassNavbarRefractionDefs', () => {
|
||||
let shell: HTMLDivElement
|
||||
let navbar: HTMLElement
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
let wrapper: ReturnType<typeof mount> | undefined
|
||||
let width: number
|
||||
let radius: number
|
||||
let transparencyReduced: boolean
|
||||
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
|
||||
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
|
||||
const disconnect = vi.fn()
|
||||
const observe = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
width = 1423
|
||||
radius = 16
|
||||
transparencyReduced = false
|
||||
transparencyChange = undefined
|
||||
decodePending = []
|
||||
shell = document.createElement('div')
|
||||
shell.className = 'layout-wrapper layout-navbar-floating-eligible layout-navbar-away-from-top'
|
||||
shell.dataset.glassNavbarRefraction = 'chromium'
|
||||
shell.innerHTML = '<header class="layout-navbar"></header>'
|
||||
document.body.append(shell)
|
||||
navbar = shell.querySelector('.layout-navbar') as HTMLElement
|
||||
Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' })
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
x: 16,
|
||||
y: 16,
|
||||
left: 16,
|
||||
top: 16,
|
||||
width,
|
||||
height: 64,
|
||||
right: width + 16,
|
||||
bottom: 80,
|
||||
toJSON: () => ({}),
|
||||
}))
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
borderStartStartRadius: `${radius}px`,
|
||||
getPropertyValue: () => '1rem',
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resize = callback
|
||||
}
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
get matches() {
|
||||
return transparencyReduced
|
||||
},
|
||||
addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
|
||||
transparencyChange = listener
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
decode = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
decodePending.push({ resolve, reject })
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
shell.remove()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function expectReadyForWidth(expectedWidth: number) {
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('true')
|
||||
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth))
|
||||
}
|
||||
|
||||
function completePendingDecode() {
|
||||
for (const pending of decodePending.splice(0)) pending.resolve()
|
||||
}
|
||||
|
||||
function dispatchTransition(type: 'transitionrun' | 'transitionend' | 'transitioncancel', propertyName: string) {
|
||||
const event = new Event(type, { bubbles: true }) as TransitionEvent
|
||||
Object.defineProperty(event, 'propertyName', { value: propertyName })
|
||||
navbar.dispatchEvent(event)
|
||||
}
|
||||
|
||||
it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith({ width: 1423, height: 64, radius: 16 })
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('disables old sampling during resize and caches unchanged geometry', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
resize?.([], {} as ResizeObserver)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
width = 1200
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
||||
expectReadyForWidth(1200)
|
||||
})
|
||||
|
||||
it('does not generate a map in CSS quality', async () => {
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
})
|
||||
|
||||
it('does not activate a pending map after switching to CSS quality', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
await flushPromises()
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
})
|
||||
|
||||
it('does not generate or activate a map while reduced transparency is enabled', async () => {
|
||||
transparencyReduced = true
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
|
||||
transparencyReduced = false
|
||||
transparencyChange?.({ matches: false } as MediaQueryListEvent)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('regenerates after a geometry transition ends or is cancelled', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
|
||||
width = 1200
|
||||
dispatchTransition('transitionrun', 'width')
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
dispatchTransition('transitionend', 'width')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
||||
expectReadyForWidth(1200)
|
||||
|
||||
radius = 20
|
||||
dispatchTransition('transitionrun', 'border-radius')
|
||||
dispatchTransition('transitioncancel', 'border-radius')
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 20 })
|
||||
expectReadyForWidth(1200)
|
||||
})
|
||||
|
||||
it('waits for every geometry transition before restoring the map', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
|
||||
dispatchTransition('transitionrun', 'width')
|
||||
dispatchTransition('transitionrun', 'border-radius')
|
||||
dispatchTransition('transitionend', 'width')
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||
|
||||
dispatchTransition('transitioncancel', 'border-radius')
|
||||
await settle()
|
||||
expectReadyForWidth(1423)
|
||||
})
|
||||
|
||||
it('only regenerates for radius or observed theme size changes', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
shell.style.setProperty('--shell-floating-navbar-scale-x', '0.9')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
radius = 20
|
||||
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1423, height: 64, radius: 20 })
|
||||
})
|
||||
|
||||
it('does not retry a failed geometry in a feedback loop', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
const [pending] = decodePending.splice(0)
|
||||
pending.reject(new Error('decode failed'))
|
||||
await flushPromises()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
resize?.([], {} as ResizeObserver)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the window resize fallback and removes it on unmount', async () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await settle()
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
|
||||
width = 1200
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
|
||||
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
width = 1100
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settle()
|
||||
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops pending work on unmount', async () => {
|
||||
wrapper = mount(GlassNavbarRefractionDefs)
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
wrapper = undefined
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -393,10 +393,17 @@ describe('glass overlay material styles', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => {
|
||||
it('keeps floating clear and tinted navbars on CSS material until Chromium SVG is ready', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
||||
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
|
||||
const baseMaterialStart = styles.lastIndexOf('// 基础材质由单一真实表面承载')
|
||||
const svgEnhancementStart = styles.indexOf('// 只有已确认的 Chromium SVG 能力')
|
||||
const reducedTransparencyStart = styles.lastIndexOf('@media (prefers-reduced-transparency: reduce)')
|
||||
const reducedMotionStart = styles.lastIndexOf('@media (prefers-reduced-motion: reduce)')
|
||||
const baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
|
||||
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
|
||||
const reducedMotionRule = styles.slice(reducedMotionStart)
|
||||
|
||||
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
|
||||
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||
@@ -419,27 +426,46 @@ describe('glass overlay material styles', () => {
|
||||
expect(styles).toContain(
|
||||
'inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
|
||||
)
|
||||
const highQualityStart = styles.indexOf("html[data-theme='glass'][data-glass-quality='high']")
|
||||
const liveRefractionStart = styles.lastIndexOf(
|
||||
"html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])",
|
||||
highQualityStart,
|
||||
expect(baseMaterialStart).toBeGreaterThanOrEqual(0)
|
||||
expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
|
||||
expect(baseMaterialRule).toContain('.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top')
|
||||
expect(baseMaterialRule).not.toContain("data-glass-navbar-refraction='chromium'")
|
||||
expect(styles).toContain('--glass-navbar-live-filter: none;')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(1.5px) saturate(118%)')
|
||||
expect(baseMaterialRule).toContain('-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important')
|
||||
expect(baseMaterialRule).toContain('backdrop-filter: var(--glass-navbar-live-filter) !important')
|
||||
expect(baseMaterialRule).toContain('border: 0 !important')
|
||||
expect(baseMaterialRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%)')
|
||||
expect(baseMaterialRule).toContain('inset 0 1px 2px rgba(255, 255, 255, 0.4)')
|
||||
expect(baseMaterialRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
||||
expect(baseMaterialRule).not.toContain("url('#glass-navbar-live-refraction-")
|
||||
expect(styles).toMatch(
|
||||
/html\[data-theme='glass'\]\[data-glass-quality='balanced'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-balanced'\) blur\(1\.5px\) saturate\(118%\);/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/html\[data-theme='glass'\]\[data-glass-quality='high'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-high'\) blur\(1px\) saturate\(122%\);/,
|
||||
)
|
||||
expect(styles).not.toMatch(
|
||||
/data-glass-navbar-refraction='chromium'\](?!\[data-glass-navbar-refraction-ready='true'\])[^{]*\{[\s\S]*?url\('#glass-navbar-live-refraction-/u,
|
||||
)
|
||||
const liveRefractionRule = styles.slice(liveRefractionStart, highQualityStart)
|
||||
|
||||
expect(liveRefractionStart).toBeGreaterThanOrEqual(0)
|
||||
expect(highQualityStart).toBeGreaterThan(liveRefractionStart)
|
||||
expect(liveRefractionRule).toContain("backdrop-filter: url('#glass-navbar-live-refraction-balanced')")
|
||||
expect(liveRefractionRule).toContain('border: 0 !important')
|
||||
expect(liveRefractionRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%)')
|
||||
expect(liveRefractionRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
||||
expect(styles).toContain('inline-size: min(100vw, variables.$layout-boxed-content-width)')
|
||||
expect(styles).toContain('transform: translateX(-50%) !important')
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
|
||||
)
|
||||
expect(liveRefractionRule).not.toContain('&::before')
|
||||
expect(baseMaterialRule).not.toContain('&::before')
|
||||
expect(styles).not.toContain('&::after')
|
||||
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
||||
expect(reducedTransparencyRule).toContain('--glass-navbar-live-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('backdrop-filter: none !important')
|
||||
expect(reducedTransparencyRule).toContain('background: rgb(11, 19, 34) !important')
|
||||
expect(reducedTransparencyRule).toContain('background-image: none !important')
|
||||
expect(reducedMotionRule).toContain('.layout-navbar')
|
||||
expect(reducedMotionRule).toContain('.navbar-content-container')
|
||||
expect(reducedMotionRule).toContain('transition: none !important')
|
||||
expect(reducedMotionRule).not.toContain('inset-block-start: 0 !important')
|
||||
expect(reducedMotionRule).not.toContain('inset-inline: 0 !important')
|
||||
expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
|
||||
expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
|
||||
expect(filterDefinitions).toContain('getBoundingClientRect()')
|
||||
@@ -447,20 +473,24 @@ describe('glass overlay material styles', () => {
|
||||
expect(filterDefinitions).toContain('in2="map"')
|
||||
expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
|
||||
expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
|
||||
expect(filterDefinitions).not.toContain('width="100%"')
|
||||
expect(filterDefinitions).not.toContain('height="100%"')
|
||||
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bwidth="100%"/u)
|
||||
expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bheight="100%"/u)
|
||||
expect(filterDefinitions).toContain('scale="-22"')
|
||||
expect(filterDefinitions).toContain('scale="-34"')
|
||||
expect(filterDefinitions).toContain('x="-8%"')
|
||||
expect(filterDefinitions).toContain('width="116%"')
|
||||
expect(filterDefinitions).toContain('x="-12%"')
|
||||
expect(filterDefinitions).toContain('width="124%"')
|
||||
expect(filterDefinitions).toContain('x="0%"')
|
||||
expect(filterDefinitions).toContain('y="0%"')
|
||||
expect(filterDefinitions).toContain('width="100%"')
|
||||
expect(filterDefinitions).toContain('height="100%"')
|
||||
expect(filterDefinitions).not.toContain('x="-8%"')
|
||||
expect(filterDefinitions).not.toContain('width="116%"')
|
||||
expect(filterDefinitions).not.toContain('x="-12%"')
|
||||
expect(filterDefinitions).not.toContain('width="124%"')
|
||||
expect(filterDefinitions).not.toContain('horizontal-continuity')
|
||||
expect(filterDefinitions).not.toContain('<feComposite')
|
||||
expect(filterDefinitions).not.toContain('<feGaussianBlur')
|
||||
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
|
||||
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
|
||||
expect(refractionUtilities).toContain('Math.sin(Math.PI * normalizedDistance)')
|
||||
expect(refractionUtilities).toContain('refractionProfile(distanceInside, bandWidth, outerGuard)')
|
||||
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ html[data-theme='glass'] {
|
||||
--glass-control-prominent-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
||||
--glass-navbar-live-filter: none;
|
||||
--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-saturate: 115%;
|
||||
@@ -1652,10 +1653,10 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
|
||||
}
|
||||
}
|
||||
|
||||
// 实时折射表面在贴顶与浮动状态使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
||||
// 浮动顶栏使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.layout-navbar {
|
||||
inline-size: auto !important;
|
||||
inset-block-start: 0 !important;
|
||||
@@ -1680,42 +1681,82 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
|
||||
}
|
||||
}
|
||||
|
||||
// 位移、材质和轮廓由同一个真实尺寸的表面完成;顶部不叠加硬高光线。
|
||||
// 基础材质由单一真实表面承载;柔和迎光表达厚度,不叠加父子两层硬高光。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: blur(1.5px) saturate(118%);
|
||||
|
||||
border: 0 !important;
|
||||
-webkit-backdrop-filter: blur(1.5px) saturate(118%) !important;
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%) !important;
|
||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
||||
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important;
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.2),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.16),
|
||||
inset 0 -1px 0 rgba(4, 10, 20, 0.22),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.24),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1px 2px rgba(4, 10, 20, 0.22),
|
||||
0 12px 32px rgba(3, 7, 18, 0.16) !important;
|
||||
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
||||
inset-inline: var(--shell-floating-navbar-inset) !important;
|
||||
}
|
||||
|
||||
// 只有已确认的 Chromium SVG 能力才替换基础 CSS 滤镜;未就绪时保持同族材质与真实几何。
|
||||
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-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%) !important;
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
|
||||
}
|
||||
|
||||
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-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%);
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-appearance='tinted']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
|
||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: none !important;
|
||||
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background: rgb(11, 19, 34) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.layout-navbar,
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper.layout-navbar-floating-eligible
|
||||
.navbar-content-container {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,50 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
expect(field.height).toBe(41)
|
||||
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(64)
|
||||
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64)
|
||||
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192)
|
||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192)
|
||||
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(120)
|
||||
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(120)
|
||||
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(136)
|
||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(136)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ width: 1423, height: 64, radius: 16 },
|
||||
{ width: 401, height: 72, radius: 16 },
|
||||
{ width: 127, height: 64, radius: 8 },
|
||||
{ width: 127, height: 64, radius: 32 },
|
||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||
const field = createGlassNavbarDisplacementField(geometry)
|
||||
for (const scale of [-22, -34]) {
|
||||
const source = (x: number, y: number) => {
|
||||
const pixel = pixelAt(field, x, y)
|
||||
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
|
||||
}
|
||||
let minimumDeterminant = Number.POSITIVE_INFINITY
|
||||
let minimumX = Number.POSITIVE_INFINITY
|
||||
let minimumY = Number.POSITIVE_INFINITY
|
||||
let maximumX = 0
|
||||
let maximumY = 0
|
||||
for (let y = 0; y < field.height; y += 1) {
|
||||
for (let x = 0; x < field.width; x += 1) {
|
||||
const point = source(x, y)
|
||||
minimumX = Math.min(minimumX, point[0])
|
||||
minimumY = Math.min(minimumY, point[1])
|
||||
maximumX = Math.max(maximumX, point[0])
|
||||
maximumY = Math.max(maximumY, point[1])
|
||||
if (x === field.width - 1 || y === field.height - 1) continue
|
||||
const nextX = source(x + 1, y)
|
||||
const nextY = source(x, y + 1)
|
||||
const determinant =
|
||||
(nextX[0] - point[0]) * (nextY[1] - point[1]) - (nextY[0] - point[0]) * (nextX[1] - point[1])
|
||||
minimumDeterminant = Math.min(minimumDeterminant, determinant)
|
||||
}
|
||||
}
|
||||
expect(minimumDeterminant).toBeGreaterThan(0.05)
|
||||
expect(minimumX).toBeGreaterThanOrEqual(0)
|
||||
expect(minimumY).toBeGreaterThanOrEqual(0)
|
||||
expect(maximumX).toBeLessThanOrEqual(field.width)
|
||||
expect(maximumY).toBeLessThanOrEqual(field.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||
|
||||
@@ -31,10 +31,12 @@ export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
|
||||
|
||||
const DISPLACEMENT_NEUTRAL_CHANNEL = 128
|
||||
const DISPLACEMENT_CHANNEL_AMPLITUDE = 127
|
||||
const OUTER_NEUTRAL_GUARD_PX = 2
|
||||
const REFRACTION_BAND_PX = 12
|
||||
const REFRACTION_PROFILE_POWER = 2
|
||||
const HIGH_REFRACTION_SCALE_PX = 34
|
||||
const OUTER_NEUTRAL_GUARD_PX = 0.5
|
||||
const REFRACTION_BAND_PX = 24
|
||||
// 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
|
||||
const MAX_DISPLACEMENT_BAND_RATIO = 0.42
|
||||
const PEAK_DEPTH_RATIO = 0.16
|
||||
|
||||
function normalizePixelSize(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
||||
@@ -53,6 +55,17 @@ function clampChannel(value: number) {
|
||||
return Math.max(0, Math.min(255, Math.round(value)))
|
||||
}
|
||||
|
||||
function smoothstep(value: number) {
|
||||
return value * value * (3 - 2 * value)
|
||||
}
|
||||
|
||||
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
|
||||
function refractionProfile(depth: number, band: number, guard: number) {
|
||||
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
|
||||
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard))
|
||||
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成圆角表面的法线位移场。
|
||||
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
|
||||
@@ -66,8 +79,7 @@ export function createGlassNavbarDisplacementField({
|
||||
const pixelHeight = normalizePixelSize(height)
|
||||
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
|
||||
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
||||
const bandWidth = Math.min(REFRACTION_BAND_PX, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||
const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
@@ -83,11 +95,18 @@ export function createGlassNavbarDisplacementField({
|
||||
const sampleY = y + 0.5
|
||||
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
const distanceInside = -signedDistance
|
||||
// 长直边允许更厚的透镜;向圆角与法线交汇轴渐缩,避免高曲率区产生聚焦尖点。
|
||||
const edgeX = Math.min(sampleX, pixelWidth - sampleX)
|
||||
const edgeY = Math.min(sampleY, pixelHeight - sampleY)
|
||||
const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1)))
|
||||
const cornerBand = Math.min(maximumBand, pixelRadius)
|
||||
const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight
|
||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||
const channelAmplitude = (bandWidth * MAX_DISPLACEMENT_BAND_RATIO * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
|
||||
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
|
||||
|
||||
const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard)
|
||||
const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER
|
||||
const profile = refractionProfile(distanceInside, bandWidth, outerGuard)
|
||||
const gradientX =
|
||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
@@ -98,10 +117,10 @@ export function createGlassNavbarDisplacementField({
|
||||
const offset = (y * pixelWidth + x) * 4
|
||||
|
||||
pixels[offset] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength,
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength,
|
||||
)
|
||||
pixels[offset + 2] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength,
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientY * profile) / gradientLength,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user