mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
fix(glass): unify content surfaces and renderer eligibility (#653)
This commit is contained in:
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
|
|||||||
// 图片加载失败
|
// 图片加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
|
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
|
||||||
|
const imageRequestRevision = ref(0)
|
||||||
|
|
||||||
|
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
|
||||||
|
const hasCompletedPosterReveal = ref(false)
|
||||||
|
const POSTER_REVEAL_FALLBACK_MS = 400
|
||||||
|
let posterRevealFallbackTimer: number | null = null
|
||||||
|
|
||||||
|
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
|
||||||
|
function resetPosterRevealState() {
|
||||||
|
hasCompletedPosterReveal.value = false
|
||||||
|
if (posterRevealFallbackTimer === null) return
|
||||||
|
|
||||||
|
window.clearTimeout(posterRevealFallbackTimer)
|
||||||
|
posterRevealFallbackTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
|
||||||
|
function completePosterReveal(revision: number, usesFallback: boolean) {
|
||||||
|
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||||
|
|
||||||
|
if (posterRevealFallbackTimer !== null) {
|
||||||
|
window.clearTimeout(posterRevealFallbackTimer)
|
||||||
|
posterRevealFallbackTimer = null
|
||||||
|
}
|
||||||
|
hasCompletedPosterReveal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 当前订阅状态
|
// 当前订阅状态
|
||||||
const isSubscribed = ref(false)
|
const isSubscribed = ref(false)
|
||||||
|
|
||||||
@@ -399,6 +427,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
|||||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasLoadedRealPoster = computed(
|
||||||
|
() =>
|
||||||
|
Boolean(props.media?.poster_path) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
|
||||||
|
const imageRequest = computed(() => {
|
||||||
|
const revision = imageRequestRevision.value
|
||||||
|
const src = getImgUrl.value
|
||||||
|
const usesFallback = imageLoadError.value || !props.media?.poster_path
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleError: () => {
|
||||||
|
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||||
|
|
||||||
|
resetPosterRevealState()
|
||||||
|
isImageLoaded.value = false
|
||||||
|
imageLoadError.value = true
|
||||||
|
imageRequestRevision.value += 1
|
||||||
|
},
|
||||||
|
handleLoad: () => {
|
||||||
|
if (revision !== imageRequestRevision.value) return
|
||||||
|
|
||||||
|
resetPosterRevealState()
|
||||||
|
isImageLoaded.value = true
|
||||||
|
if (!usesFallback) {
|
||||||
|
posterRevealFallbackTimer = window.setTimeout(
|
||||||
|
() => completePosterReveal(revision, usesFallback),
|
||||||
|
POSTER_REVEAL_FALLBACK_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleReveal: (event: TransitionEvent) => {
|
||||||
|
if (
|
||||||
|
event.propertyName !== 'opacity' ||
|
||||||
|
!(event.target instanceof Element) ||
|
||||||
|
!event.target.matches('.v-img__img')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completePosterReveal(revision, usesFallback)
|
||||||
|
},
|
||||||
|
key: revision,
|
||||||
|
src,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取媒体类型文本
|
// 获取媒体类型文本
|
||||||
function getMediaTypeText(type: string | undefined) {
|
function getMediaTypeText(type: string | undefined) {
|
||||||
if (!type) return ''
|
if (!type) return ''
|
||||||
@@ -425,13 +501,21 @@ watch(isSubscribed, subscribed => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.media,
|
[() => props.media, () => props.media?.poster_path],
|
||||||
() => {
|
([media], [previousMedia]) => {
|
||||||
|
imageRequestRevision.value += 1
|
||||||
|
resetPosterRevealState()
|
||||||
|
isImageLoaded.value = false
|
||||||
|
imageLoadError.value = false
|
||||||
|
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
|
||||||
|
if (media === previousMedia) return
|
||||||
|
|
||||||
resetMediaCardDetailState()
|
resetMediaCardDetailState()
|
||||||
subscribedSeasons.value = []
|
subscribedSeasons.value = []
|
||||||
subscribedSeasonModes.value = {}
|
subscribedSeasonModes.value = {}
|
||||||
subscribedSeasonsLoaded.value = false
|
subscribedSeasonsLoaded.value = false
|
||||||
},
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -445,6 +529,7 @@ onActivated(resetMediaCardDetailState)
|
|||||||
onDeactivated(resetMediaCardDetailState)
|
onDeactivated(resetMediaCardDetailState)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
resetPosterRevealState()
|
||||||
resetMediaCardDetailState()
|
resetMediaCardDetailState()
|
||||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
observer.value?.disconnect()
|
observer.value?.disconnect()
|
||||||
@@ -461,6 +546,7 @@ onBeforeUnmount(() => {
|
|||||||
:height="props.height"
|
:height="props.height"
|
||||||
:width="props.width"
|
:width="props.width"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
|
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
|
||||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||||
:class="{
|
:class="{
|
||||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||||
@@ -470,12 +556,14 @@ onBeforeUnmount(() => {
|
|||||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||||
>
|
>
|
||||||
<VImg
|
<VImg
|
||||||
|
:key="imageRequest.key"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
:src="getImgUrl"
|
:src="imageRequest.src"
|
||||||
class="object-cover aspect-w-2 aspect-h-3"
|
class="object-cover aspect-w-2 aspect-h-3"
|
||||||
cover
|
cover
|
||||||
@load="isImageLoaded = true"
|
@load="imageRequest.handleLoad"
|
||||||
@error="imageLoadError = true"
|
@error="imageRequest.handleError"
|
||||||
|
@transitionend="imageRequest.handleReveal"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
|
|||||||
import { server } from '@tests/support/msw/server'
|
import { server } from '@tests/support/msw/server'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { HttpResponse, http } from 'msw'
|
import { HttpResponse, http } from 'msw'
|
||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h, reactive, ref } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -89,6 +89,50 @@ interface RenderCardOptions {
|
|||||||
superUser?: boolean
|
superUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ControlledImageRequest {
|
||||||
|
/** 模拟当前 VImg 请求失败。 */
|
||||||
|
fail: () => void
|
||||||
|
/** 模拟当前 VImg 请求成功。 */
|
||||||
|
load: () => void
|
||||||
|
/** 模拟当前 VImg 的 opacity 淡入完成。 */
|
||||||
|
reveal: () => void
|
||||||
|
/** 当前 VImg 实例发起的图片地址。 */
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
|
||||||
|
function createControlledImageStub(requests: ControlledImageRequest[]) {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
emits: ['error', 'load'],
|
||||||
|
props: { src: String },
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
const src = props.src ?? ''
|
||||||
|
const imageElement = ref<HTMLImageElement | null>(null)
|
||||||
|
const request = {
|
||||||
|
fail: () => emit('error', src),
|
||||||
|
load: () => emit('load', src),
|
||||||
|
reveal: () => {
|
||||||
|
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
|
||||||
|
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
|
||||||
|
imageElement.value?.dispatchEvent(event)
|
||||||
|
},
|
||||||
|
src,
|
||||||
|
}
|
||||||
|
requests.push(request)
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('div', { 'data-src': src }, [
|
||||||
|
h('img', {
|
||||||
|
ref: imageElement,
|
||||||
|
class: 'v-img__img',
|
||||||
|
}),
|
||||||
|
slots.default?.(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
|
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
|
||||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||||
return renderWithProviders(MediaCard, {
|
return renderWithProviders(MediaCard, {
|
||||||
@@ -414,17 +458,22 @@ describe('MediaCard', () => {
|
|||||||
expect(dialogProps.selected).toEqual([])
|
expect(dialogProps.selected).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads matching TV seasons before opening the subscription dialog', async () => {
|
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
|
||||||
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
|
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
|
||||||
|
const subscribeListRequest = vi.fn<(url: URL) => void>()
|
||||||
server.use(
|
server.use(
|
||||||
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
||||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||||
subscribeListHandler([
|
subscribeListHandler(
|
||||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
[
|
||||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||||
]),
|
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
subscribeListRequest,
|
||||||
|
),
|
||||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||||
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
||||||
),
|
),
|
||||||
@@ -443,6 +492,19 @@ describe('MediaCard', () => {
|
|||||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||||
subscribedSeasons: [1, 3],
|
subscribedSeasons: [1, 3],
|
||||||
})
|
})
|
||||||
|
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
media.poster_path = '/original/updated.jpg'
|
||||||
|
mocks.openSharedDialog.mockClear()
|
||||||
|
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||||
|
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||||
|
expect(updatedDialogProps).toMatchObject({
|
||||||
|
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||||
|
subscribedSeasons: [1, 3],
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||||
@@ -533,35 +595,84 @@ describe('MediaCard', () => {
|
|||||||
type: '电视剧',
|
type: '电视剧',
|
||||||
vote_average: 8.6,
|
vote_average: 8.6,
|
||||||
})
|
})
|
||||||
const VImgStub = defineComponent({
|
const requests: ControlledImageRequest[] = []
|
||||||
name: 'VImg',
|
const VImgStub = createControlledImageStub(requests)
|
||||||
emits: ['error', 'load'],
|
|
||||||
props: { src: String },
|
|
||||||
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
|
|
||||||
setup(props, { emit, slots }) {
|
|
||||||
return () =>
|
|
||||||
h('div', { 'data-src': props.src }, [
|
|
||||||
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
|
|
||||||
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
|
|
||||||
slots.default?.(),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { container } = await renderWithProviders(MediaCard, {
|
const { container } = await renderWithProviders(MediaCard, {
|
||||||
props: { media, width: '9rem' },
|
props: { media, width: '9rem' },
|
||||||
initialState: { user: { superUser: true } },
|
initialState: { user: { superUser: true } },
|
||||||
global: { stubs: { VImg: VImgStub } },
|
global: { stubs: { VImg: VImgStub } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
requests[0].load()
|
||||||
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
requests[0].reveal()
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||||
expect(container).toHaveTextContent('TV')
|
expect(container).toHaveTextContent('TV')
|
||||||
expect(container).toHaveTextContent('8.6')
|
expect(container).toHaveTextContent('8.6')
|
||||||
|
|
||||||
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
|
requests[0].fail()
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
||||||
)
|
)
|
||||||
|
await waitFor(() => expect(requests.some(request => request.src.includes('no-image'))).toBe(true))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
const fallbackRequest = requests.find(request => request.src.includes('no-image'))
|
||||||
|
fallbackRequest?.load()
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
fallbackRequest?.reveal()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps placeholder-only cards inside the renderer after the image loads', async () => {
|
||||||
|
const requests: ControlledImageRequest[] = []
|
||||||
|
const { container } = await renderWithProviders(MediaCard, {
|
||||||
|
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
|
||||||
|
initialState: { user: { superUser: true } },
|
||||||
|
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||||
|
})
|
||||||
|
|
||||||
|
requests[0].load()
|
||||||
|
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a previous poster load after the card is reused for another media item', async () => {
|
||||||
|
const requests: ControlledImageRequest[] = []
|
||||||
|
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
|
||||||
|
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
|
||||||
|
const { container, rerender } = await renderWithProviders(MediaCard, {
|
||||||
|
props: { media: mediaA, width: '9rem' },
|
||||||
|
initialState: { user: { superUser: true } },
|
||||||
|
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||||
|
})
|
||||||
|
|
||||||
|
requests[0].load()
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
await rerender({ media: mediaB, width: '9rem' })
|
||||||
|
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
requests[0].reveal()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
|
||||||
|
currentRequest?.load()
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||||
|
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||||
|
|
||||||
|
currentRequest?.reveal()
|
||||||
|
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the AniList source badge after the poster loads', async () => {
|
it('renders the AniList source badge after the poster loads', async () => {
|
||||||
|
|||||||
@@ -148,6 +148,28 @@ function createTouchList(points: Array<{ clientX: number; clientY: number; ident
|
|||||||
}) as unknown as TouchList
|
}) as unknown as TouchList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造浏览器在 detached 子树交付前保留的 child-list 移除记录。 */
|
||||||
|
function createRemovalRecord(target: Element, removedNodes: Element[]): MutationRecord {
|
||||||
|
const toNodeList = (nodes: Node[]) =>
|
||||||
|
Object.assign(nodes, {
|
||||||
|
item(index: number) {
|
||||||
|
return nodes[index] ?? null
|
||||||
|
},
|
||||||
|
}) as unknown as NodeList
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedNodes: toNodeList([]),
|
||||||
|
attributeName: null,
|
||||||
|
attributeNamespace: null,
|
||||||
|
nextSibling: null,
|
||||||
|
oldValue: null,
|
||||||
|
previousSibling: null,
|
||||||
|
removedNodes: toNodeList(removedNodes),
|
||||||
|
target,
|
||||||
|
type: 'childList',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchTouchEvent(
|
function dispatchTouchEvent(
|
||||||
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
|
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
|
||||||
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
|
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
|
||||||
@@ -454,6 +476,24 @@ describe('glass optical surface discovery', () => {
|
|||||||
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
|
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('excludes direct surfaces and descendants even when a child requests dynamic mode', () => {
|
||||||
|
const direct = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
|
||||||
|
direct.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const excludedContainer = document.createElement('section')
|
||||||
|
excludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const overridden = document.createElement('article')
|
||||||
|
overridden.className = 'app-hover-lift-card'
|
||||||
|
overridden.dataset.glassOpticalMode = 'dynamic'
|
||||||
|
setOpticalSurfaceBounds(overridden, { height: 220, width: 150, x: 200, y: 96 })
|
||||||
|
excludedContainer.append(overridden)
|
||||||
|
document.body.append(excludedContainer)
|
||||||
|
|
||||||
|
expect(containsGlassOpticalSurface(direct)).toBe(false)
|
||||||
|
expect(containsGlassOpticalSurface(excludedContainer)).toBe(false)
|
||||||
|
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
|
||||||
|
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('discovers the shared interactive card contract used across routes', () => {
|
it('discovers the shared interactive card contract used across routes', () => {
|
||||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
|
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
|
||||||
surface.style.borderTopLeftRadius = '20px'
|
surface.style.borderTopLeftRadius = '20px'
|
||||||
@@ -1998,6 +2038,458 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a parent material static when all nested interaction clips are excluded', 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 pageContent = document.createElement('main')
|
||||||
|
pageContent.className = 'app-wrapper layout-page-content'
|
||||||
|
const outerSurface = document.createElement('section')
|
||||||
|
outerSurface.className = 'v-card'
|
||||||
|
setOpticalSurfaceBounds(outerSurface, { height: 420, width: 900, x: 40, y: 80 })
|
||||||
|
const nestedCards = [80, 400].map(x => {
|
||||||
|
const nestedCard = document.createElement('article')
|
||||||
|
nestedCard.className = 'app-hover-lift-card'
|
||||||
|
nestedCard.dataset.glassOpticalMode = 'excluded'
|
||||||
|
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x, y: 140 })
|
||||||
|
outerSurface.append(nestedCard)
|
||||||
|
|
||||||
|
return nestedCard
|
||||||
|
})
|
||||||
|
pageContent.append(outerSurface)
|
||||||
|
document.body.append(pageContent)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||||
|
const getUniforms = () => {
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uRectCount: { value: number }
|
||||||
|
uSurfaceDynamics: { value: number[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
return scene.children[0].material.uniforms
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getUniforms().uRectCount.value).toBe(1)
|
||||||
|
expect(getUniforms().uInteractionRectCount.value).toBe(0)
|
||||||
|
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(0)
|
||||||
|
|
||||||
|
nestedCards[0].removeAttribute('data-glass-optical-mode')
|
||||||
|
await vi.waitFor(() => expect(getUniforms().uInteractionRectCount.value).toBe(1))
|
||||||
|
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes excluded interaction clip membership across direct and nested mutations', 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 pageContent = document.createElement('main')
|
||||||
|
pageContent.className = 'app-wrapper layout-page-content'
|
||||||
|
const surfaces = [40, 520].map(x => {
|
||||||
|
const surface = document.createElement('section')
|
||||||
|
surface.className = 'v-card'
|
||||||
|
setOpticalSurfaceBounds(surface, { height: 420, width: 400, x, y: 80 })
|
||||||
|
pageContent.append(surface)
|
||||||
|
|
||||||
|
return surface
|
||||||
|
})
|
||||||
|
document.body.append(pageContent)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||||
|
const getInteractionState = () => {
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
|
||||||
|
uRectCount: { value: number }
|
||||||
|
uSurfaceDynamics: { value: number[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const uniforms = scene.children[0].material.uniforms
|
||||||
|
|
||||||
|
return {
|
||||||
|
interactionCount: uniforms.uInteractionRectCount.value,
|
||||||
|
interactionXs: uniforms.uInteractionRects.value
|
||||||
|
.slice(0, uniforms.uInteractionRectCount.value)
|
||||||
|
.map(rect => rect.toArray()[0])
|
||||||
|
.sort((left, right) => left - right),
|
||||||
|
surfaceCount: uniforms.uRectCount.value,
|
||||||
|
surfaceDynamics: uniforms.uSurfaceDynamics.value.slice(0, 2).sort((left, right) => left - right),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 2,
|
||||||
|
interactionXs: [40 / 1200, 520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [1, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const excludedClip = document.createElement('article')
|
||||||
|
excludedClip.className = 'app-hover-lift-card'
|
||||||
|
excludedClip.dataset.glassOpticalMode = 'excluded'
|
||||||
|
surfaces[0].append(excludedClip)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 1,
|
||||||
|
interactionXs: [520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [0, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
surfaces[1].append(excludedClip)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 1,
|
||||||
|
interactionXs: [40 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [0, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
excludedClip.remove()
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 2,
|
||||||
|
interactionXs: [40 / 1200, 520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [1, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const excludedContainer = document.createElement('div')
|
||||||
|
excludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||||
|
surfaces[0].append(excludedContainer)
|
||||||
|
const nestedExcludedClip = document.createElement('article')
|
||||||
|
nestedExcludedClip.className = 'app-hover-lift-card'
|
||||||
|
excludedContainer.append(nestedExcludedClip)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 1,
|
||||||
|
interactionXs: [520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [0, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
nestedExcludedClip.remove()
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 2,
|
||||||
|
interactionXs: [40 / 1200, 520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [1, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
excludedContainer.append(nestedExcludedClip)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 1,
|
||||||
|
interactionXs: [520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [0, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
nestedExcludedClip.remove()
|
||||||
|
excludedContainer.remove()
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(getInteractionState()).toEqual({
|
||||||
|
interactionCount: 2,
|
||||||
|
interactionXs: [40 / 1200, 520 / 1200],
|
||||||
|
surfaceCount: 2,
|
||||||
|
surfaceDynamics: [1, 1],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates a managed owner through deeply nested removals in one observer batch', async () => {
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
const mutationObservers: Array<MutationObserver & { trigger: (records: MutationRecord[]) => void }> = []
|
||||||
|
class MutationObserverMock implements MutationObserver {
|
||||||
|
constructor(private readonly callback: MutationCallback) {
|
||||||
|
mutationObservers.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
observe() {}
|
||||||
|
takeRecords() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
trigger(records: MutationRecord[]) {
|
||||||
|
this.callback(records, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('MutationObserver', MutationObserverMock)
|
||||||
|
const three = await import('three')
|
||||||
|
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||||
|
const pageContent = document.createElement('main')
|
||||||
|
pageContent.className = 'app-wrapper layout-page-content'
|
||||||
|
const surface = document.createElement('section')
|
||||||
|
surface.className = 'v-card'
|
||||||
|
setOpticalSurfaceBounds(surface, { height: 420, width: 900, x: 40, y: 80 })
|
||||||
|
const outerExcludedContainer = document.createElement('div')
|
||||||
|
outerExcludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const innerExcludedContainer = document.createElement('div')
|
||||||
|
innerExcludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const deeplyNestedClip = document.createElement('article')
|
||||||
|
deeplyNestedClip.className = 'app-hover-lift-card'
|
||||||
|
innerExcludedContainer.append(deeplyNestedClip)
|
||||||
|
outerExcludedContainer.append(innerExcludedContainer)
|
||||||
|
surface.append(outerExcludedContainer)
|
||||||
|
pageContent.append(surface)
|
||||||
|
document.body.append(pageContent)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||||
|
const getInteractionState = () => {
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uSurfaceDynamics: { value: number[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const uniforms = scene.children[0].material.uniforms
|
||||||
|
|
||||||
|
return {
|
||||||
|
interactionCount: uniforms.uInteractionRectCount.value,
|
||||||
|
surfaceDynamics: uniforms.uSurfaceDynamics.value[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||||
|
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 0, surfaceDynamics: 0 }))
|
||||||
|
|
||||||
|
outerExcludedContainer.remove()
|
||||||
|
innerExcludedContainer.remove()
|
||||||
|
deeplyNestedClip.remove()
|
||||||
|
mutationObservers[0].trigger([
|
||||||
|
createRemovalRecord(surface, [outerExcludedContainer]),
|
||||||
|
createRemovalRecord(outerExcludedContainer, [innerExcludedContainer]),
|
||||||
|
createRemovalRecord(innerExcludedContainer, [deeplyNestedClip]),
|
||||||
|
])
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 1, surfaceDynamics: 1 }))
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes and restores an active surface and interaction clip when exclusion changes', 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 appWrapper = document.createElement('main')
|
||||||
|
appWrapper.className = 'app-wrapper'
|
||||||
|
const surface = document.createElement('article')
|
||||||
|
surface.className = 'app-hover-lift-card'
|
||||||
|
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
|
||||||
|
appWrapper.append(surface)
|
||||||
|
document.body.append(appWrapper)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
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: 160, clientY: 180 }))
|
||||||
|
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||||
|
const getCounts = () => {
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uRectCount: { value: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const uniforms = scene.children[0].material.uniforms
|
||||||
|
|
||||||
|
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getCounts()).toEqual([1, 1])
|
||||||
|
|
||||||
|
surface.dataset.glassOpticalMode = 'excluded'
|
||||||
|
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
|
||||||
|
|
||||||
|
surface.removeAttribute('data-glass-optical-mode')
|
||||||
|
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores child-list churn inside an excluded surface after removed nodes lose their ancestor', async () => {
|
||||||
|
const appWrapper = document.createElement('main')
|
||||||
|
appWrapper.className = 'app-wrapper'
|
||||||
|
const surface = document.createElement('article')
|
||||||
|
surface.className = 'app-hover-lift-card'
|
||||||
|
surface.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const imageContent = document.createElement('div')
|
||||||
|
surface.append(imageContent)
|
||||||
|
appWrapper.append(surface)
|
||||||
|
document.body.append(appWrapper)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
dynamicsActive: ref(false),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||||
|
|
||||||
|
imageContent.remove()
|
||||||
|
await nextTick()
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||||
|
|
||||||
|
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes and restores a managed surface when it moves across an excluded boundary', 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 appWrapper = document.createElement('main')
|
||||||
|
appWrapper.className = 'app-wrapper'
|
||||||
|
const eligibleParent = document.createElement('section')
|
||||||
|
const excludedParent = document.createElement('section')
|
||||||
|
excludedParent.dataset.glassOpticalMode = 'excluded'
|
||||||
|
const surface = document.createElement('article')
|
||||||
|
surface.className = 'app-hover-lift-card'
|
||||||
|
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
|
||||||
|
eligibleParent.append(surface)
|
||||||
|
appWrapper.append(eligibleParent, excludedParent)
|
||||||
|
document.body.append(appWrapper)
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(document.createElement('canvas')),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/search'),
|
||||||
|
surfaceSpace: 'scroll',
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
const getCounts = () => {
|
||||||
|
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||||
|
children: Array<{
|
||||||
|
material: {
|
||||||
|
uniforms: {
|
||||||
|
uInteractionRectCount: { value: number }
|
||||||
|
uRectCount: { value: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const uniforms = scene.children[0].material.uniforms
|
||||||
|
|
||||||
|
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||||
|
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||||
|
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||||
|
excludedParent.append(surface)
|
||||||
|
expect(surface.closest('[data-glass-optical-mode="excluded"]')).toBe(excludedParent)
|
||||||
|
await vi.waitFor(() => expect(querySelectorAll).toHaveBeenCalled())
|
||||||
|
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
|
||||||
|
|
||||||
|
eligibleParent.append(surface)
|
||||||
|
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps an explicit optical boundary as the interaction clip without allocating nested slots', async () => {
|
it('keeps an explicit optical boundary as the interaction clip without allocating nested slots', 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)
|
||||||
|
|||||||
@@ -442,8 +442,19 @@ const SURFACE_SELECTORS = [
|
|||||||
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 OPTICAL_BOUNDARY_SELECTOR = '[data-glass-optical-boundary]'
|
const OPTICAL_BOUNDARY_SELECTOR = '[data-glass-optical-boundary]'
|
||||||
|
const OPTICAL_EXCLUSION_SELECTOR = '[data-glass-optical-mode="excluded"]'
|
||||||
const INTERACTION_CLIP_OVERSCAN_PX = 96
|
const INTERACTION_CLIP_OVERSCAN_PX = 96
|
||||||
|
|
||||||
|
/** 排除合同覆盖整个子树;后代不能用 dynamic 声明重新加入 renderer。 */
|
||||||
|
function isGlassOpticalElementExcluded(element: Element) {
|
||||||
|
return Boolean(element.closest(OPTICAL_EXCLUSION_SELECTOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** overlay 与显式排除子树都不参与壁纸光学表面或交互裁剪。 */
|
||||||
|
function isGlassOpticalElementEligible(element: Element) {
|
||||||
|
return !element.closest('.v-overlay') && !isGlassOpticalElementExcluded(element)
|
||||||
|
}
|
||||||
|
|
||||||
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
|
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
|
||||||
function getSurfacePresentationSpace(
|
function getSurfacePresentationSpace(
|
||||||
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
|
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
|
||||||
@@ -455,9 +466,16 @@ function getSurfacePresentationSpace(
|
|||||||
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
|
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
|
||||||
export function containsGlassOpticalSurface(node: Node) {
|
export function containsGlassOpticalSurface(node: Node) {
|
||||||
if (!(node instanceof Element)) return false
|
if (!(node instanceof Element)) return false
|
||||||
if (node.matches(SURFACE_SELECTOR_QUERY) && !node.closest('.v-overlay')) return true
|
if (node.matches(SURFACE_SELECTOR_QUERY) && isGlassOpticalElementEligible(node)) return true
|
||||||
|
|
||||||
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(element => !element.closest('.v-overlay'))
|
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(isGlassOpticalElementEligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 DOM 子树是否包含会约束父表面动态输出的交互裁剪。 */
|
||||||
|
function containsGlassInteractionClip(node: Node) {
|
||||||
|
if (!(node instanceof Element)) return false
|
||||||
|
|
||||||
|
return node.matches(INTERACTION_CLIP_SELECTOR) || Boolean(node.querySelector(INTERACTION_CLIP_SELECTOR))
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERTEX_SHADER = `
|
const VERTEX_SHADER = `
|
||||||
@@ -1100,7 +1118,7 @@ function collectGlassOpticalSurfaceDescriptors(
|
|||||||
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
|
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
|
||||||
if (seen.has(element)) continue
|
if (seen.has(element)) continue
|
||||||
seen.add(element)
|
seen.add(element)
|
||||||
if (element.closest('.v-overlay')) continue
|
if (!isGlassOpticalElementEligible(element)) continue
|
||||||
collectedElements?.push(element)
|
collectedElements?.push(element)
|
||||||
|
|
||||||
const bounds = element.getBoundingClientRect()
|
const bounds = element.getBoundingClientRect()
|
||||||
@@ -1291,8 +1309,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
|
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
|
||||||
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
||||||
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
||||||
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
|
let interactionClips: GlassInteractionClipDescriptor[] = []
|
||||||
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
|
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
|
||||||
|
let interactionClipConstrainedOwners = new Set<HTMLElement>()
|
||||||
let interactionClipMembershipDirty = true
|
let interactionClipMembershipDirty = true
|
||||||
let activeSurface: HTMLElement | null = null
|
let activeSurface: HTMLElement | null = null
|
||||||
let activeInteractionClip: HTMLElement | null = null
|
let activeInteractionClip: HTMLElement | null = null
|
||||||
@@ -1869,6 +1888,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const uniformRadii = resources.uniforms.uRadii.value
|
const uniformRadii = resources.uniforms.uRadii.value
|
||||||
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
||||||
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
||||||
|
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
|
||||||
const transitionWeights = outgoingSurface
|
const transitionWeights = outgoingSurface
|
||||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||||
: { incoming: 1, outgoing: 0 }
|
: { incoming: 1, outgoing: 0 }
|
||||||
@@ -1894,7 +1914,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
uniformWeights[index] = surfaceWeight * pagePresentationWeight
|
uniformWeights[index] = surfaceWeight * pagePresentationWeight
|
||||||
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
|
const nestedInteractionAvailable =
|
||||||
|
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
|
||||||
|
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
resources.uniforms.uRectCount.value = normalized.length
|
resources.uniforms.uRectCount.value = normalized.length
|
||||||
@@ -1907,13 +1929,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
function refreshInteractionClipRegistry() {
|
function refreshInteractionClipRegistry() {
|
||||||
const seen = new Set<HTMLElement>()
|
const seen = new Set<HTMLElement>()
|
||||||
const candidates: GlassInteractionClipDescriptor[] = []
|
const candidates: GlassInteractionClipDescriptor[] = []
|
||||||
|
const constrainedOwners = new Set<HTMLElement>()
|
||||||
const append = (
|
const append = (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
owner: HTMLElement,
|
owner: HTMLElement,
|
||||||
mode: GlassOpticalSurfaceMode,
|
mode: GlassOpticalSurfaceMode,
|
||||||
committedRect?: GlassOpticalRect,
|
committedRect?: GlassOpticalRect,
|
||||||
) => {
|
) => {
|
||||||
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
|
if (
|
||||||
|
seen.has(element) ||
|
||||||
|
!element.isConnected ||
|
||||||
|
!isGlassOpticalElementEligible(element) ||
|
||||||
|
resolveGlassOpticalSurfaceMode(element) !== mode
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rect = committedRect ?? getElementPresentationRect(element)
|
const rect = committedRect ?? getElementPresentationRect(element)
|
||||||
|
|
||||||
@@ -1935,6 +1965,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
|
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
|
||||||
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
|
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
|
||||||
if (nestedClips.length > 0) {
|
if (nestedClips.length > 0) {
|
||||||
|
constrainedOwners.add(surface.key)
|
||||||
nestedClips.forEach(clip => append(clip, surface.key, mode))
|
nestedClips.forEach(clip => append(clip, surface.key, mode))
|
||||||
} else if (!surfaceIsClip) {
|
} else if (!surfaceIsClip) {
|
||||||
append(surface.key, surface.key, mode, surface.rect)
|
append(surface.key, surface.key, mode, surface.rect)
|
||||||
@@ -1942,6 +1973,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
interactionClipRegistry = candidates
|
interactionClipRegistry = candidates
|
||||||
|
interactionClipConstrainedOwners = constrainedOwners
|
||||||
interactionClipMembershipDirty = false
|
interactionClipMembershipDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1952,6 +1984,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
if (
|
if (
|
||||||
!clip.key.isConnected ||
|
!clip.key.isConnected ||
|
||||||
!clip.owner.isConnected ||
|
!clip.owner.isConnected ||
|
||||||
|
!isGlassOpticalElementEligible(clip.key) ||
|
||||||
|
!isGlassOpticalElementEligible(clip.owner) ||
|
||||||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
|
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
|
||||||
!committedSurfaces.has(clip.owner)
|
!committedSurfaces.has(clip.owner)
|
||||||
) {
|
) {
|
||||||
@@ -2030,6 +2064,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
activeInteractionClip = null
|
activeInteractionClip = null
|
||||||
}
|
}
|
||||||
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
|
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
|
||||||
|
const interactionClipKeys = new Set(interactionClipRegistry.map(clip => clip.key))
|
||||||
|
if (activeInteractionClip && !interactionClipKeys.has(activeInteractionClip)) activeInteractionClip = null
|
||||||
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||||
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
|
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
|
||||||
surfaceSlots,
|
surfaceSlots,
|
||||||
@@ -2242,7 +2278,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const surface = target.matches(SURFACE_SELECTOR_QUERY)
|
const surface = target.matches(SURFACE_SELECTOR_QUERY)
|
||||||
? target
|
? target
|
||||||
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
|
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
|
||||||
if (!surface) return null
|
if (!surface || !isGlassOpticalElementEligible(surface)) return null
|
||||||
|
|
||||||
return SURFACE_SELECTORS.some(
|
return SURFACE_SELECTORS.some(
|
||||||
({ selector, space }) =>
|
({ selector, space }) =>
|
||||||
@@ -2482,13 +2518,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
function findInteractionTarget(x: number, y: number) {
|
function findInteractionTarget(x: number, y: number) {
|
||||||
if (availableSurfaces.length === 0) updateSurfaceUniforms()
|
if (availableSurfaces.length === 0) updateSurfaceUniforms()
|
||||||
|
|
||||||
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
|
const surface = availableSurfaces.find(
|
||||||
|
candidate => isGlassOpticalElementEligible(candidate.key) && rectContainsPoint(candidate.rect, x, y),
|
||||||
|
)
|
||||||
if (!surface) return null
|
if (!surface) return null
|
||||||
|
|
||||||
const matchingClips = interactionClipRegistry
|
const matchingClips = interactionClipRegistry
|
||||||
.filter(
|
.filter(
|
||||||
candidate =>
|
candidate =>
|
||||||
candidate.owner === surface.key &&
|
candidate.owner === surface.key &&
|
||||||
|
isGlassOpticalElementEligible(candidate.key) &&
|
||||||
isInteractionClipRenderable(candidate.rect) &&
|
isInteractionClipRenderable(candidate.rect) &&
|
||||||
rectContainsPoint(candidate.rect, x, y),
|
rectContainsPoint(candidate.rect, x, y),
|
||||||
)
|
)
|
||||||
@@ -2689,6 +2728,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
clientY: number,
|
clientY: number,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
velocityOverride?: { x: number; y: number },
|
velocityOverride?: { x: number; y: number },
|
||||||
|
target?: EventTarget | null,
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
!hasDynamicCapability() ||
|
!hasDynamicCapability() ||
|
||||||
@@ -2696,6 +2736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (target instanceof Element && isGlassOpticalElementExcluded(target)) return
|
||||||
|
|
||||||
const viewportWidth = Math.max(window.innerWidth, 1)
|
const viewportWidth = Math.max(window.innerWidth, 1)
|
||||||
const viewportHeight = Math.max(window.innerHeight, 1)
|
const viewportHeight = Math.max(window.innerHeight, 1)
|
||||||
@@ -2803,7 +2844,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
function handlePointerMove(event: PointerEvent) {
|
function handlePointerMove(event: PointerEvent) {
|
||||||
if (event.pointerType === 'touch') return
|
if (event.pointerType === 'touch') return
|
||||||
|
|
||||||
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now())
|
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now(), undefined, event.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTouch(touches: TouchList, identifier: number | null) {
|
function findTouch(touches: TouchList, identifier: number | null) {
|
||||||
@@ -2818,6 +2859,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
|
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
|
||||||
function handleTouchStart(event: TouchEvent) {
|
function handleTouchStart(event: TouchEvent) {
|
||||||
if (activeTouchIdentifier !== null) return
|
if (activeTouchIdentifier !== null) return
|
||||||
|
if (event.target instanceof Element && isGlassOpticalElementExcluded(event.target)) return
|
||||||
|
|
||||||
const touch = findTouch(event.changedTouches, null)
|
const touch = findTouch(event.changedTouches, null)
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
@@ -2854,7 +2896,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
if (!touch) return
|
if (!touch) return
|
||||||
|
|
||||||
const timestamp = event.timeStamp || performance.now()
|
const timestamp = event.timeStamp || performance.now()
|
||||||
applyInteraction(touch.clientX, touch.clientY, timestamp)
|
applyInteraction(touch.clientX, touch.clientY, timestamp, undefined, event.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd(event: TouchEvent) {
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
@@ -3105,11 +3147,72 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
||||||
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
||||||
return mutations.some(
|
const removalRecords = mutations.flatMap(mutation => {
|
||||||
mutation =>
|
if (mutation.type !== 'childList' || mutation.removedNodes.length === 0 || !(mutation.target instanceof Element))
|
||||||
mutation.type === 'attributes' ||
|
return []
|
||||||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
|
|
||||||
)
|
return [
|
||||||
|
{
|
||||||
|
removedElements: [...mutation.removedNodes].filter((node): node is Element => node instanceof Element),
|
||||||
|
target: mutation.target,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const managedRemovalTargets = new Set<Element>()
|
||||||
|
const removedElementsFromManagedSurfaces = new Set<Element>()
|
||||||
|
let removalGraphExpanded = removalRecords.length > 0
|
||||||
|
|
||||||
|
// MutationRecord 保留节点身份,但回调时的最终 DOM 已丢失中间祖先关系,需要沿同批次移除边传递 owner。
|
||||||
|
while (removalGraphExpanded) {
|
||||||
|
removalGraphExpanded = false
|
||||||
|
|
||||||
|
for (const record of removalRecords) {
|
||||||
|
const belongsToManagedSurface =
|
||||||
|
managedRemovalTargets.has(record.target) ||
|
||||||
|
surfaceRegistry.some(surface => surface.key === record.target || surface.key.contains(record.target)) ||
|
||||||
|
[...removedElementsFromManagedSurfaces].some(
|
||||||
|
element => element === record.target || element.contains(record.target),
|
||||||
|
)
|
||||||
|
if (!belongsToManagedSurface) continue
|
||||||
|
|
||||||
|
if (!managedRemovalTargets.has(record.target)) {
|
||||||
|
managedRemovalTargets.add(record.target)
|
||||||
|
removalGraphExpanded = true
|
||||||
|
}
|
||||||
|
for (const element of record.removedElements) {
|
||||||
|
if (removedElementsFromManagedSurfaces.has(element)) continue
|
||||||
|
|
||||||
|
removedElementsFromManagedSurfaces.add(element)
|
||||||
|
removalGraphExpanded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutations.some(mutation => {
|
||||||
|
if (mutation.type === 'attributes') return true
|
||||||
|
|
||||||
|
const removedManagedSurface = [...mutation.removedNodes].some(
|
||||||
|
node =>
|
||||||
|
node instanceof Element &&
|
||||||
|
(surfaceRegistry.some(surface => surface.key === node || node.contains(surface.key)) ||
|
||||||
|
interactionClipRegistry.some(clip => clip.key === node || node.contains(clip.key))),
|
||||||
|
)
|
||||||
|
if (removedManagedSurface) return true
|
||||||
|
|
||||||
|
const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]
|
||||||
|
|
||||||
|
// 新增节点按提交后的祖先资格判断;排除子树内部的图片 DOM 变化不需要重扫。
|
||||||
|
if (mutation.target instanceof Element && !isGlassOpticalElementEligible(mutation.target)) {
|
||||||
|
const belongsToManagedSurface = surfaceRegistry.some(
|
||||||
|
surface => surface.key === mutation.target || surface.key.contains(mutation.target),
|
||||||
|
)
|
||||||
|
const removedFromManagedSurface = managedRemovalTargets.has(mutation.target)
|
||||||
|
|
||||||
|
return (belongsToManagedSurface || removedFromManagedSurface) && changedNodes.some(containsGlassInteractionClip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedNodes.some(node => containsGlassOpticalSurface(node) || containsGlassInteractionClip(node))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupObservers() {
|
function setupObservers() {
|
||||||
@@ -3262,6 +3365,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
surfaceSlots = []
|
surfaceSlots = []
|
||||||
interactionClips = []
|
interactionClips = []
|
||||||
interactionClipRegistry = []
|
interactionClipRegistry = []
|
||||||
|
interactionClipConstrainedOwners = new Set<HTMLElement>()
|
||||||
interactionClipMembershipDirty = true
|
interactionClipMembershipDirty = true
|
||||||
activeSurface = null
|
activeSurface = null
|
||||||
activeInteractionClip = null
|
activeInteractionClip = null
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.settings-section-card {
|
.settings-section-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: var(--app-surface-border);
|
border: var(--app-grouped-list-border);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
background-color: rgb(var(--v-theme-surface));
|
background-color: var(--app-grouped-list-background);
|
||||||
box-shadow: var(--app-surface-shadow);
|
box-shadow: var(--app-surface-shadow);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
|
|||||||
@@ -1686,8 +1686,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.search-progress-card {
|
.search-progress-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
backdrop-filter: blur(10px);
|
border: var(--app-grouped-list-border);
|
||||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
|
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), var(--app-grouped-list-background);
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ describe('glass overlay material styles', () => {
|
|||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
|
||||||
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
|
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
|
||||||
expect(styles).toContain('--glass-overlay-blur: 3px')
|
expect(styles.match(/--glass-overlay-blur:\s*8px/g)).toHaveLength(2)
|
||||||
expect(styles).toContain('--glass-overlay-saturate: 115%')
|
expect(styles).toContain('--glass-overlay-saturate: 115%')
|
||||||
expect(styles).toContain('--glass-overlay-blur: 12px')
|
|
||||||
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
||||||
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
||||||
expect(styles).toContain('--glass-overlay-saturate: 135%')
|
expect(styles).toContain('--glass-overlay-saturate: 135%')
|
||||||
@@ -216,14 +215,12 @@ describe('glass overlay material styles', () => {
|
|||||||
/\[data-glass-scroll-presentation='native'\][\s\S]*?\.glass-optical-layer--scroll\s*\{\s*opacity:\s*0\s*!important;/,
|
/\[data-glass-scroll-presentation='native'\][\s\S]*?\.glass-optical-layer--scroll\s*\{\s*opacity:\s*0\s*!important;/,
|
||||||
)
|
)
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\[data-glass-renderer-state='ready'\][\s\S]*?\.app-hover-lift-card:not\(\.media-card--image-loaded\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
/\[data-glass-renderer-state='ready'\][\s\S]*?:is\([\s\S]*?\.app-hover-lift-card[\s\S]*?\):not\(\[data-glass-optical-mode='excluded'\]\):not\(\[data-glass-optical-mode='excluded'\] \*\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||||
)
|
)
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\.layout-wrapper:not\(\.layout-fixed-shell-backplate-active\) \.layout-vertical-nav::before,[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
/\.layout-wrapper:not\(\.layout-fixed-shell-backplate-active\) \.layout-vertical-nav::before,[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||||
)
|
)
|
||||||
expect(styles).toMatch(
|
expect(styles).not.toContain('.settings-section-card.app-grouped-list')
|
||||||
/\.settings-section-card\.app-grouped-list\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
|
||||||
)
|
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\.file-browser-toolbar\.v-toolbar\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-surface-backdrop-filter\)\s*!important;/,
|
/\.file-browser-toolbar\.v-toolbar\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-surface-backdrop-filter\)\s*!important;/,
|
||||||
)
|
)
|
||||||
@@ -232,6 +229,26 @@ describe('glass overlay material styles', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the audited content surfaces on the shared grouped-list material contract', () => {
|
||||||
|
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||||
|
const transparentStyles = readFileSync(resolve(cwd(), 'src/styles/themes/transparent.scss'), 'utf8')
|
||||||
|
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
const resourcePage = readFileSync(resolve(cwd(), 'src/pages/resource.vue'), 'utf8')
|
||||||
|
const appCenterPage = readFileSync(resolve(cwd(), 'src/pages/appcenter.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(commonStyles).toContain('--app-grouped-list-backdrop-filter: none')
|
||||||
|
expect(transparentStyles).toContain('--app-grouped-list-backdrop-filter: blur(var(--transparent-blur))')
|
||||||
|
expect(transparentStyles.match(/--app-grouped-list-backdrop-filter:\s*none/g)).toHaveLength(3)
|
||||||
|
expect(glassStyles).toContain('--app-grouped-list-backdrop-filter: var(--glass-surface-backdrop-filter)')
|
||||||
|
|
||||||
|
for (const page of [resourcePage, appCenterPage]) {
|
||||||
|
expect(page).toContain('border: var(--app-grouped-list-border)')
|
||||||
|
expect(page).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
|
||||||
|
expect(page).toContain('var(--app-grouped-list-background)')
|
||||||
|
expect(page).not.toContain('backdrop-filter: blur(10px)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps frosted route opacity static while preserving its short movement', () => {
|
it('keeps frosted route opacity static while preserving its short movement', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +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-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: 3px;
|
--glass-overlay-blur: 8px;
|
||||||
--glass-overlay-saturate: 115%;
|
--glass-overlay-saturate: 115%;
|
||||||
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
||||||
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
||||||
@@ -187,7 +187,7 @@ html[data-theme='glass'] {
|
|||||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
||||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22))
|
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22))
|
||||||
);
|
);
|
||||||
--glass-overlay-blur: 12px;
|
--glass-overlay-blur: 8px;
|
||||||
--glass-overlay-saturate: 120%;
|
--glass-overlay-saturate: 120%;
|
||||||
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
||||||
--glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness));
|
--glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness));
|
||||||
@@ -386,8 +386,8 @@ html[data-theme='glass'] {
|
|||||||
background-color: var(--glass-surface) !important;
|
background-color: var(--glass-surface) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 海报完成绘制后已完全遮住卡片底面,释放不可见的实时背景采样。
|
// 只有成功覆盖的真实海报才释放底面采样;占位图与失败态仍保留完整材料。
|
||||||
.media-card.media-card--image-loaded {
|
.media-card[data-glass-optical-mode='excluded'] {
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
@@ -1012,23 +1012,17 @@ html[data-theme='glass'] {
|
|||||||
.workflow-share-card {
|
.workflow-share-card {
|
||||||
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
|
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
|
||||||
--workflow-share-glass-end-opacity: calc(0.24 + var(--glass-tint-density, 0.65) * 0.42);
|
--workflow-share-glass-end-opacity: calc(0.24 + var(--glass-tint-density, 0.65) * 0.42);
|
||||||
--workflow-share-glass-scrim:
|
--workflow-share-glass-scrim: linear-gradient(
|
||||||
linear-gradient(
|
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
|
||||||
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
|
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
|
||||||
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
|
);
|
||||||
);
|
|
||||||
|
|
||||||
background-color: var(--glass-surface) !important;
|
background-color: var(--glass-surface) !important;
|
||||||
background-image:
|
background-image:
|
||||||
var(--glass-sheen),
|
var(--glass-sheen), var(--workflow-share-glass-scrim),
|
||||||
var(--workflow-share-glass-scrim),
|
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(
|
rgba(var(--workflow-share-gradient-start-rgb, 74, 85, 104), var(--workflow-share-glass-start-opacity)) 0%,
|
||||||
var(--workflow-share-gradient-start-rgb, 74, 85, 104),
|
|
||||||
var(--workflow-share-glass-start-opacity)
|
|
||||||
)
|
|
||||||
0%,
|
|
||||||
rgba(var(--workflow-share-gradient-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
|
rgba(var(--workflow-share-gradient-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
|
||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
@@ -1095,11 +1089,10 @@ html[data-theme='glass'] {
|
|||||||
&[data-glass-appearance='frosted'] .workflow-share-card {
|
&[data-glass-appearance='frosted'] .workflow-share-card {
|
||||||
--workflow-share-glass-start-opacity: calc(0.12 + var(--glass-tint-density, 0.65) * 0.28);
|
--workflow-share-glass-start-opacity: calc(0.12 + var(--glass-tint-density, 0.65) * 0.28);
|
||||||
--workflow-share-glass-end-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.34);
|
--workflow-share-glass-end-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.34);
|
||||||
--workflow-share-glass-scrim:
|
--workflow-share-glass-scrim: linear-gradient(
|
||||||
linear-gradient(
|
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
|
||||||
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
|
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
|
||||||
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
||||||
@@ -1150,11 +1143,10 @@ html[data-theme='glass'] {
|
|||||||
&[data-glass-appearance='tinted'] .workflow-share-card {
|
&[data-glass-appearance='tinted'] .workflow-share-card {
|
||||||
--workflow-share-glass-start-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.32);
|
--workflow-share-glass-start-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.32);
|
||||||
--workflow-share-glass-end-opacity: calc(0.21 + var(--glass-tint-density, 0.65) * 0.38);
|
--workflow-share-glass-end-opacity: calc(0.21 + var(--glass-tint-density, 0.65) * 0.38);
|
||||||
--workflow-share-glass-scrim:
|
--workflow-share-glass-scrim: linear-gradient(
|
||||||
linear-gradient(
|
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
|
||||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
|
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
|
||||||
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
||||||
@@ -1195,12 +1187,6 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// “更多”应用列表的分组卡片存在局部固定模糊,需要在完整小屏区间跟随玻璃材质。
|
|
||||||
.settings-section-card.app-grouped-list {
|
|
||||||
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
|
||||||
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件地址栏与下方内容卡属于同一工作表面,不使用更强的 raised 材质。
|
// 文件地址栏与下方内容卡属于同一工作表面,不使用更强的 raised 材质。
|
||||||
.file-browser-toolbar.v-toolbar {
|
.file-browser-toolbar.v-toolbar {
|
||||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter) !important;
|
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter) !important;
|
||||||
@@ -1514,13 +1500,19 @@ html[data-glass-appearance='frosted']:is(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动表面始终由原生 backdrop 持有壁纸基底;GPU 层只叠加局部动态折射。
|
// 滚动表面由原生 backdrop 持有壁纸基底;完整内容覆盖的排除子树不再采样。
|
||||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||||
body[data-theme='glass'] {
|
body[data-theme='glass'] {
|
||||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
:is(
|
||||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card,
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||||
[data-glass-optical-surface],
|
.dashboard-grid-item-content
|
||||||
.app-hover-lift-card:not(.media-card--image-loaded) {
|
> .dashboard-grid-auto-size
|
||||||
|
> .dashboard-grid-content-measure
|
||||||
|
> :first-child
|
||||||
|
> .v-card,
|
||||||
|
[data-glass-optical-surface],
|
||||||
|
.app-hover-lift-card
|
||||||
|
):not([data-glass-optical-mode='excluded']):not([data-glass-optical-mode='excluded'] *) {
|
||||||
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||||
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user