mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
perf(glass): reduce long-list interaction overhead (#600)
This commit is contained in:
@@ -1041,7 +1041,7 @@
|
|||||||
},
|
},
|
||||||
"src/views/setting/AccountSettingDirectory.vue": {
|
"src/views/setting/AccountSettingDirectory.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 10
|
"count": 9
|
||||||
},
|
},
|
||||||
"no-prototype-builtins": {
|
"no-prototype-builtins": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@@ -1858,6 +1858,108 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the active and nearby visible clips when a long list exceeds the shader budget', async () => {
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
const three = await import('three')
|
||||||
|
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||||
|
const outerSurface = appendOpticalSurface('v-card', { height: 2200, width: 900, x: 80, y: -1200 })
|
||||||
|
outerSurface.dataset.glassOpticalSurface = ''
|
||||||
|
const cards = Array.from({ length: 12 }, (_, index) => {
|
||||||
|
const card = document.createElement('article')
|
||||||
|
card.className = 'app-hover-lift-card'
|
||||||
|
setOpticalSurfaceBounds(card, {
|
||||||
|
height: 120,
|
||||||
|
width: 360,
|
||||||
|
x: index % 2 === 0 ? 100 : 500,
|
||||||
|
y: -1100 + Math.floor(index / 2) * 180,
|
||||||
|
})
|
||||||
|
outerSurface.append(card)
|
||||||
|
|
||||||
|
return card
|
||||||
|
})
|
||||||
|
setOpticalSurfaceBounds(cards[10], { height: 120, width: 360, x: 100, y: 620 })
|
||||||
|
setOpticalSurfaceBounds(cards[11], { height: 120, width: 360, x: 500, y: 620 })
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/dashboard'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 560, clientY: 680 }))
|
||||||
|
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const rects = scene.children[0].material.uniforms.uInteractionRects.value
|
||||||
|
.slice(0, scene.children[0].material.uniforms.uInteractionRectCount.value)
|
||||||
|
.map(rect => rect.toArray())
|
||||||
|
|
||||||
|
expect(rects[0][0]).toBeCloseTo(500 / 1200)
|
||||||
|
expect(rects.some(rect => Math.abs(rect[0] - 100 / 1200) < 0.001 && Math.abs(rect[1] - 60 / 800) < 0.001)).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(rects.every(rect => rect[1] >= -96 / 800)).toBe(true)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hits cached interaction clips without rescanning nested cards on pointermove', async () => {
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
const outerSurface = appendOpticalSurface('v-card', { height: 500, width: 900, x: 80, y: 100 })
|
||||||
|
outerSurface.dataset.glassOpticalSurface = ''
|
||||||
|
const card = document.createElement('article')
|
||||||
|
card.className = 'app-hover-lift-card'
|
||||||
|
setOpticalSurfaceBounds(card, { height: 180, width: 360, x: 100, y: 140 })
|
||||||
|
outerSurface.append(card)
|
||||||
|
const querySelectorAll = vi.spyOn(outerSurface, 'querySelectorAll')
|
||||||
|
const measureCard = vi.spyOn(card, 'getBoundingClientRect')
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/dashboard'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
querySelectorAll.mockClear()
|
||||||
|
measureCard.mockClear()
|
||||||
|
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 180, clientY: 200 }))
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 200, clientY: 220 }))
|
||||||
|
|
||||||
|
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||||
|
expect(measureCard).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
ResizeObserverMock.instances.forEach(observer => observer.trigger())
|
||||||
|
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the active texture and ready state when a replacement wallpaper fails', async () => {
|
it('keeps the active texture and ready state when a replacement wallpaper fails', async () => {
|
||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -2350,7 +2452,7 @@ describe('glass optical surface discovery', () => {
|
|||||||
}
|
}
|
||||||
expect(wallpaperSampling.at(-1)).toBe(1)
|
expect(wallpaperSampling.at(-1)).toBe(1)
|
||||||
|
|
||||||
window.dispatchEvent(new WheelEvent('wheel'))
|
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 }))
|
||||||
|
|
||||||
expect(wallpaperSampling.at(-1)).toBe(0)
|
expect(wallpaperSampling.at(-1)).toBe(0)
|
||||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||||
@@ -2370,6 +2472,84 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ignores scroll intent and movement that cannot move a managed glass surface', async () => {
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
|
||||||
|
const unrelatedScroller = document.createElement('div')
|
||||||
|
unrelatedScroller.style.overflowY = 'auto'
|
||||||
|
Object.defineProperties(unrelatedScroller, {
|
||||||
|
clientHeight: { configurable: true, value: 200 },
|
||||||
|
scrollHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, value: 0, writable: true },
|
||||||
|
})
|
||||||
|
document.body.append(unrelatedScroller)
|
||||||
|
const unrelatedContent = document.createElement('div')
|
||||||
|
unrelatedScroller.append(unrelatedContent)
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'v-overlay'
|
||||||
|
const overlayScroller = unrelatedScroller.cloneNode() as HTMLElement
|
||||||
|
overlay.append(overlayScroller)
|
||||||
|
document.body.append(overlay)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('high'),
|
||||||
|
routeKey: ref('/dashboard'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
unrelatedContent.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
|
||||||
|
unrelatedContent.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'PageDown' }))
|
||||||
|
unrelatedScroller.dispatchEvent(new Event('scroll', { bubbles: false }))
|
||||||
|
overlayScroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
|
||||||
|
overlayScroller.dispatchEvent(new Event('scroll', { bubbles: false }))
|
||||||
|
|
||||||
|
expect(document.documentElement).not.toHaveAttribute('data-glass-scroll-presentation')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts native scroll presentation for a nested scroller that moves managed surfaces', async () => {
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
const scroller = document.createElement('div')
|
||||||
|
scroller.style.overflowY = 'auto'
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollHeight: { configurable: true, value: 900 },
|
||||||
|
scrollTop: { configurable: true, value: 0, writable: true },
|
||||||
|
})
|
||||||
|
document.body.append(scroller)
|
||||||
|
const surface = appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
|
||||||
|
scroller.append(surface)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('high'),
|
||||||
|
routeKey: ref('/dashboard'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
surface.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('redraws a high-quality scroll layer without advancing its flow targets', async () => {
|
it('redraws a high-quality scroll layer without advancing its flow targets', async () => {
|
||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
|||||||
@@ -359,6 +359,11 @@ type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> &
|
|||||||
mode: GlassOpticalSurfaceMode
|
mode: GlassOpticalSurfaceMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GlassInteractionClipDescriptor extends GlassOpticalSurfaceDescriptor {
|
||||||
|
/** 拥有该局部裁剪的顶层材质面。 */
|
||||||
|
owner: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
interface PreparedWallpaperTexture {
|
interface PreparedWallpaperTexture {
|
||||||
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
|
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
|
||||||
frostedTarget: WebGLRenderTarget | null
|
frostedTarget: WebGLRenderTarget | null
|
||||||
@@ -400,6 +405,7 @@ const SURFACE_SELECTORS = [
|
|||||||
] as const
|
] as const
|
||||||
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
|
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
|
||||||
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
|
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
|
||||||
|
const INTERACTION_CLIP_OVERSCAN_PX = 96
|
||||||
|
|
||||||
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
|
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
|
||||||
function getSurfacePresentationSpace(
|
function getSurfacePresentationSpace(
|
||||||
@@ -1377,6 +1383,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
||||||
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
||||||
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
|
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
|
||||||
|
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
|
||||||
|
let interactionClipMembershipDirty = true
|
||||||
let activeSurface: HTMLElement | null = null
|
let activeSurface: HTMLElement | null = null
|
||||||
let activeInteractionClip: HTMLElement | null = null
|
let activeInteractionClip: HTMLElement | null = null
|
||||||
let outgoingSurface: HTMLElement | null = null
|
let outgoingSurface: HTMLElement | null = null
|
||||||
@@ -1430,7 +1438,47 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
renderFrame(performance.now(), false)
|
renderFrame(performance.now(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 找到本次输入实际可能推动的最近滚动容器。 */
|
||||||
|
function resolveScrollIntentTarget(event: Event): Element | null {
|
||||||
|
const documentScroller = document.scrollingElement ?? document.documentElement
|
||||||
|
const eventTarget = event.target
|
||||||
|
if (!(eventTarget instanceof Element)) return documentScroller
|
||||||
|
if (eventTarget.closest('.v-overlay')) return null
|
||||||
|
|
||||||
|
const wheel = event instanceof WheelEvent ? event : null
|
||||||
|
for (
|
||||||
|
let candidate: Element | null = eventTarget;
|
||||||
|
candidate && candidate !== document.body;
|
||||||
|
candidate = candidate.parentElement
|
||||||
|
) {
|
||||||
|
const style = getComputedStyle(candidate)
|
||||||
|
const canScrollY =
|
||||||
|
/(auto|overlay|scroll)/.test(style.overflowY) &&
|
||||||
|
candidate.scrollHeight > candidate.clientHeight &&
|
||||||
|
(!wheel ||
|
||||||
|
(wheel.deltaY < 0 && candidate.scrollTop > 0) ||
|
||||||
|
(wheel.deltaY > 0 && candidate.scrollTop + candidate.clientHeight < candidate.scrollHeight))
|
||||||
|
const canScrollX =
|
||||||
|
/(auto|overlay|scroll)/.test(style.overflowX) &&
|
||||||
|
candidate.scrollWidth > candidate.clientWidth &&
|
||||||
|
(!wheel ||
|
||||||
|
(wheel.deltaX < 0 && candidate.scrollLeft > 0) ||
|
||||||
|
(wheel.deltaX > 0 && candidate.scrollLeft + candidate.clientWidth < candidate.scrollWidth))
|
||||||
|
if (canScrollX || canScrollY) return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentScroller
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有会移动文档或已管理玻璃表面的滚动才需要切换 scroll 材质路径。 */
|
||||||
|
function isRelevantScrollTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof Element) || target === document.documentElement || target === document.body) return true
|
||||||
|
|
||||||
|
return observedSurfaces.some(surface => target.contains(surface))
|
||||||
|
}
|
||||||
|
|
||||||
function handleScrollIntent(event: Event) {
|
function handleScrollIntent(event: Event) {
|
||||||
|
if (event instanceof WheelEvent && event.deltaX === 0 && event.deltaY === 0) return
|
||||||
if (event instanceof KeyboardEvent) {
|
if (event instanceof KeyboardEvent) {
|
||||||
const target = event.target
|
const target = event.target
|
||||||
if (
|
if (
|
||||||
@@ -1442,6 +1490,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
if (!['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) return
|
if (!['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scrollTarget = resolveScrollIntentTarget(event)
|
||||||
|
if (!scrollTarget || !isRelevantScrollTarget(scrollTarget)) return
|
||||||
beginNativeScrollPresentation()
|
beginNativeScrollPresentation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1815,12 +1865,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const presentation = getCommittedPresentationSize()
|
const presentation = getCommittedPresentationSize()
|
||||||
const normalizedInteractionClips = interactionClips.map(clip => {
|
const normalizedInteractionClips = interactionClips.map(clip =>
|
||||||
const rect =
|
normalizeGlassOpticalRect(clip.rect, presentation.width, presentation.height),
|
||||||
clip.key === activeInteractionClip && clip.key.isConnected ? getElementPresentationRect(clip.key) : clip.rect
|
)
|
||||||
|
|
||||||
return normalizeGlassOpticalRect(rect, presentation.width, presentation.height)
|
|
||||||
})
|
|
||||||
const uniformInteractionRects = resources.uniforms.uInteractionRects.value
|
const uniformInteractionRects = resources.uniforms.uInteractionRects.value
|
||||||
const uniformInteractionRadii = resources.uniforms.uInteractionRadii.value
|
const uniformInteractionRadii = resources.uniforms.uInteractionRadii.value
|
||||||
for (let index = 0; index < 8; index += 1) {
|
for (let index = 0; index < 8; index += 1) {
|
||||||
@@ -1871,38 +1918,102 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
* 材质父表面可折叠多个交互卡片;共享动态场只在最终输出时按真实卡片边界裁剪。
|
* 材质父表面可折叠多个交互卡片;共享动态场只在最终输出时按真实卡片边界裁剪。
|
||||||
* 活动卡优先占用固定预算,其余可见卡片继续消费同一时序场。
|
* 活动卡优先占用固定预算,其余可见卡片继续消费同一时序场。
|
||||||
*/
|
*/
|
||||||
function updateInteractionClips() {
|
function refreshInteractionClipRegistry() {
|
||||||
const seen = new Set<HTMLElement>()
|
const seen = new Set<HTMLElement>()
|
||||||
const candidates: GlassOpticalSurfaceDescriptor[] = []
|
const candidates: GlassInteractionClipDescriptor[] = []
|
||||||
const append = (element: HTMLElement, mode: GlassOpticalSurfaceMode) => {
|
const append = (
|
||||||
|
element: HTMLElement,
|
||||||
|
owner: HTMLElement,
|
||||||
|
mode: GlassOpticalSurfaceMode,
|
||||||
|
committedRect?: GlassOpticalRect,
|
||||||
|
) => {
|
||||||
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
|
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
|
||||||
|
|
||||||
const rect = getElementPresentationRect(element)
|
const rect = committedRect ?? getElementPresentationRect(element)
|
||||||
if (rect.width < 24 || rect.height < 24) return
|
if (rect.width < 24 || rect.height < 24) return
|
||||||
|
|
||||||
seen.add(element)
|
seen.add(element)
|
||||||
candidates.push({ key: element, mode, rect })
|
candidates.push({ key: element, mode, owner, rect })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slot of surfaceSlots) {
|
for (const surface of surfaceRegistry) {
|
||||||
const mode = slot.mode ?? 'dynamic'
|
const mode = surface.mode ?? 'dynamic'
|
||||||
if (mode === 'static-material') continue
|
if (mode === 'static-material') continue
|
||||||
|
|
||||||
const nestedClips = [
|
const surfaceIsClip = surface.key.matches(INTERACTION_CLIP_SELECTOR)
|
||||||
...(slot.key.matches(INTERACTION_CLIP_SELECTOR) ? [slot.key] : []),
|
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
|
||||||
...slot.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR),
|
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
|
||||||
]
|
|
||||||
if (nestedClips.length > 0) {
|
if (nestedClips.length > 0) {
|
||||||
nestedClips.forEach(clip => append(clip, mode))
|
nestedClips.forEach(clip => append(clip, surface.key, mode))
|
||||||
} else {
|
} else if (!surfaceIsClip) {
|
||||||
append(slot.key, mode)
|
append(surface.key, surface.key, mode, surface.rect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeIndex = activeInteractionClip
|
interactionClipRegistry = candidates
|
||||||
? candidates.findIndex(candidate => candidate.key === activeInteractionClip)
|
interactionClipMembershipDirty = false
|
||||||
: -1
|
}
|
||||||
if (activeIndex > 0) candidates.unshift(...candidates.splice(activeIndex, 1))
|
|
||||||
|
/** 稳定帧复用成员集合;顶层 clip 直接复用 surface collector 已提交的矩形。 */
|
||||||
|
function refreshInteractionClipGeometry() {
|
||||||
|
const committedSurfaces = new Map(surfaceRegistry.map(surface => [surface.key, surface]))
|
||||||
|
interactionClipRegistry = interactionClipRegistry.flatMap(clip => {
|
||||||
|
if (
|
||||||
|
!clip.key.isConnected ||
|
||||||
|
!clip.owner.isConnected ||
|
||||||
|
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
|
||||||
|
!committedSurfaces.has(clip.owner)
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = committedSurfaces.get(clip.key)?.rect ?? getElementPresentationRect(clip.key)
|
||||||
|
if (rect.width < 24 || rect.height < 24) return []
|
||||||
|
|
||||||
|
return [{ ...clip, rect }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInteractionClips() {
|
||||||
|
const slotKeys = new Set(surfaceSlots.map(slot => slot.key))
|
||||||
|
const viewportX = presentationSpace === 'scroll' ? window.scrollX : 0
|
||||||
|
const viewportY = presentationSpace === 'scroll' ? window.scrollY : 0
|
||||||
|
const interactionPoint = getPresentationPoint(lastPointerX, lastPointerY)
|
||||||
|
const candidates = interactionClipRegistry
|
||||||
|
.filter(candidate => slotKeys.has(candidate.owner))
|
||||||
|
.map(candidate => {
|
||||||
|
const { rect } = candidate
|
||||||
|
const visibleWidth = Math.min(viewportX + window.innerWidth, rect.x + rect.width) - Math.max(viewportX, rect.x)
|
||||||
|
const visibleHeight =
|
||||||
|
Math.min(viewportY + window.innerHeight, rect.y + rect.height) - Math.max(viewportY, rect.y)
|
||||||
|
const inOverscan =
|
||||||
|
rect.x + rect.width >= viewportX - INTERACTION_CLIP_OVERSCAN_PX &&
|
||||||
|
rect.x <= viewportX + window.innerWidth + INTERACTION_CLIP_OVERSCAN_PX &&
|
||||||
|
rect.y + rect.height >= viewportY - INTERACTION_CLIP_OVERSCAN_PX &&
|
||||||
|
rect.y <= viewportY + window.innerHeight + INTERACTION_CLIP_OVERSCAN_PX
|
||||||
|
const distance = Math.hypot(
|
||||||
|
rect.x + rect.width * 0.5 - interactionPoint.x,
|
||||||
|
rect.y + rect.height * 0.5 - interactionPoint.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidate,
|
||||||
|
distance,
|
||||||
|
visible: visibleWidth >= 24 && visibleHeight >= 24,
|
||||||
|
inOverscan,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(entry => entry.candidate.key === activeInteractionClip || entry.inOverscan)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftActive = left.candidate.key === activeInteractionClip
|
||||||
|
const rightActive = right.candidate.key === activeInteractionClip
|
||||||
|
if (leftActive !== rightActive) return leftActive ? -1 : 1
|
||||||
|
if (left.visible !== right.visible) return left.visible ? -1 : 1
|
||||||
|
|
||||||
|
return left.distance - right.distance
|
||||||
|
})
|
||||||
|
.map(entry => entry.candidate)
|
||||||
|
|
||||||
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||||
interactionClips = candidates.slice(0, maxCount)
|
interactionClips = candidates.slice(0, maxCount)
|
||||||
}
|
}
|
||||||
@@ -1947,6 +2058,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
true,
|
true,
|
||||||
nextObservedSurfaces,
|
nextObservedSurfaces,
|
||||||
)
|
)
|
||||||
|
if (interactionClipMembershipDirty) refreshInteractionClipRegistry()
|
||||||
|
else refreshInteractionClipGeometry()
|
||||||
updateVisibleSurfaceUniforms(timestamp)
|
updateVisibleSurfaceUniforms(timestamp)
|
||||||
|
|
||||||
const observedSurfacesChanged =
|
const observedSurfacesChanged =
|
||||||
@@ -2331,17 +2444,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
|
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
|
||||||
if (!surface) return null
|
if (!surface) return null
|
||||||
|
|
||||||
const interactionClips = [
|
const matchingClips = interactionClipRegistry
|
||||||
...(surface.key.matches(INTERACTION_CLIP_SELECTOR) ? [surface.key] : []),
|
.filter(candidate => candidate.owner === surface.key && rectContainsPoint(candidate.rect, x, y))
|
||||||
...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR),
|
|
||||||
]
|
|
||||||
.filter(element => resolveGlassOpticalSurfaceMode(element) === surface.mode)
|
|
||||||
.map(element => ({ element, rect: getElementPresentationRect(element) }))
|
|
||||||
.filter(candidate => rectContainsPoint(candidate.rect, x, y))
|
|
||||||
.sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height)
|
.sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clip: interactionClips[0]?.element ?? surface.key,
|
clip: matchingClips[0]?.key ?? surface.key,
|
||||||
surface,
|
surface,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2352,8 +2460,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
*/
|
*/
|
||||||
function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) {
|
function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) {
|
||||||
if (activeSurface === surface) {
|
if (activeSurface === surface) {
|
||||||
updateInteractionClips()
|
|
||||||
writeSurfaceUniforms(timestamp)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2732,10 +2838,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
function handleScroll(event: Event) {
|
function handleScroll(event: Event) {
|
||||||
if (presentationSpace !== 'scroll' || !resources) return
|
if (presentationSpace !== 'scroll' || !resources) return
|
||||||
|
|
||||||
|
const target = event.target
|
||||||
|
if (!isRelevantScrollTarget(target)) return
|
||||||
|
|
||||||
beginNativeScrollPresentation()
|
beginNativeScrollPresentation()
|
||||||
scrollFrameCommitted = false
|
scrollFrameCommitted = false
|
||||||
scrollLateGeometryCommitted = false
|
scrollLateGeometryCommitted = false
|
||||||
const target = event.target
|
|
||||||
const isDocumentScroll =
|
const isDocumentScroll =
|
||||||
!(target instanceof Element) || target === document.documentElement || target === document.body
|
!(target instanceof Element) || target === document.documentElement || target === document.body
|
||||||
if (!isDocumentScroll) {
|
if (!isDocumentScroll) {
|
||||||
@@ -2909,6 +3017,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
||||||
if (!mutationTouchesOpticalSurface(mutations)) return
|
if (!mutationTouchesOpticalSurface(mutations)) return
|
||||||
|
|
||||||
|
interactionClipMembershipDirty = true
|
||||||
commitActivePagePresentation()
|
commitActivePagePresentation()
|
||||||
scheduleSurfaceStabilityUpdate()
|
scheduleSurfaceStabilityUpdate()
|
||||||
})
|
})
|
||||||
@@ -3006,6 +3115,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
availableSurfaces = []
|
availableSurfaces = []
|
||||||
surfaceSlots = []
|
surfaceSlots = []
|
||||||
interactionClips = []
|
interactionClips = []
|
||||||
|
interactionClipRegistry = []
|
||||||
|
interactionClipMembershipDirty = true
|
||||||
activeSurface = null
|
activeSurface = null
|
||||||
activeInteractionClip = null
|
activeInteractionClip = null
|
||||||
outgoingSurface = null
|
outgoingSurface = null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { TransferDirectoryConf, StorageConf } from '@/api/types'
|
import type { ApiResponse, StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||||
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||||
import StorageCard from '@/components/cards/StorageCard.vue'
|
import StorageCard from '@/components/cards/StorageCard.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -272,9 +272,13 @@ function removeStorage(storage: StorageConf) {
|
|||||||
// 保存设置
|
// 保存设置
|
||||||
async function saveSystemSettings(value: any) {
|
async function saveSystemSettings(value: any) {
|
||||||
try {
|
try {
|
||||||
|
// 响应拦截器返回业务响应体,并行组合前需收窄 Axios 的静态返回类型。
|
||||||
const [envResult, mountedDiskResult] = await Promise.all([
|
const [envResult, mountedDiskResult] = await Promise.all([
|
||||||
api.post('system/env', value),
|
api.post('system/env', value) as unknown as Promise<ApiResponse>,
|
||||||
api.post(`system/setting/${mountedLocalDiskDeleteEmptyDirsKey}`, mountedLocalDiskDeleteEmptyDirs.value),
|
api.post(
|
||||||
|
`system/setting/${mountedLocalDiskDeleteEmptyDirsKey}`,
|
||||||
|
mountedLocalDiskDeleteEmptyDirs.value,
|
||||||
|
) as unknown as Promise<ApiResponse>,
|
||||||
])
|
])
|
||||||
if (envResult.success && mountedDiskResult.success) {
|
if (envResult.success && mountedDiskResult.success) {
|
||||||
$toast.success(t('setting.directory.organizeSaveSuccess'))
|
$toast.success(t('setting.directory.organizeSaveSuccess'))
|
||||||
|
|||||||
Reference in New Issue
Block a user