mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 00:54:42 +08:00
feat(glass): improve optical quality and controls (#585)
This commit is contained in:
55
src/App.vue
55
src/App.vue
@@ -27,7 +27,8 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
|
||||
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
@@ -74,6 +75,7 @@ const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
const backgroundCrossfadeStartedAt = ref(0)
|
||||
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
|
||||
@@ -84,16 +86,37 @@ const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||
const isInitialRouteReady = ref(false)
|
||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
// 登录专题保持固定光学参数,避免全局用户偏好改变其独立视觉合成。
|
||||
const opticalDeformationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassDeformationStrength,
|
||||
)
|
||||
const opticalFlowStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassFlowStrength,
|
||||
)
|
||||
const opticalReflectionStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassReflectionStrength,
|
||||
)
|
||||
const opticalTransparencyStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTransparencyStrength,
|
||||
)
|
||||
const opticalTranslationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTranslationStrength,
|
||||
)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||
)
|
||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
|
||||
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。
|
||||
const activeOpticalBackgroundImage = computed(() =>
|
||||
getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)),
|
||||
)
|
||||
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
|
||||
const previousOpticalBackgroundImage = computed(() => {
|
||||
const previousIndex = previousImageIndex.value
|
||||
if (previousIndex === null) return ''
|
||||
|
||||
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||
})
|
||||
const appWrapperStyle = computed(() => ({
|
||||
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
|
||||
}))
|
||||
@@ -102,7 +125,6 @@ const shouldRenderGlassOpticalLayer = computed(
|
||||
isGlassTheme.value &&
|
||||
effectiveGlassSettings.value.glassQuality !== 'css' &&
|
||||
isInitialRouteReady.value &&
|
||||
!isRenderThrottled.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||
@@ -350,6 +372,7 @@ function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundCrossfadeStartedAt.value = 0
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
@@ -359,6 +382,7 @@ function activateBackgroundImage(nextIndex: number) {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
isBackgroundCrossfading.value = true
|
||||
backgroundCrossfadeStartedAt.value = performance.now()
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
@@ -390,11 +414,21 @@ function rotateBackgroundImage() {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
const requestVersion = ++backgroundRotationVersion
|
||||
const nextImage = backgroundImages.value[nextIndex]
|
||||
const opticalImage =
|
||||
shouldRenderGlassOpticalLayer.value && !isLoginWallpaperRoute.value
|
||||
? getOpticalBackgroundImage(nextImage)
|
||||
: undefined
|
||||
|
||||
void commitPreloadedBackgroundRotation({
|
||||
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
|
||||
commit: () => activateBackgroundImage(nextIndex),
|
||||
preload: () => preloadImage(backgroundImages.value[nextIndex]),
|
||||
preload: () =>
|
||||
preloadBackgroundRotationImages({
|
||||
displayUrl: nextImage,
|
||||
opticalUrl: opticalImage,
|
||||
preload: preloadImage,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -679,11 +713,18 @@ onUnmounted(() => {
|
||||
<GlassOpticalLayer
|
||||
v-if="shouldRenderGlassOpticalLayer"
|
||||
:appearance="effectiveGlassSettings.glassAppearance"
|
||||
:class="{ 'glass-optical-layer--background-transition': isBackgroundCrossfading }"
|
||||
:deformation-strength="opticalDeformationStrength"
|
||||
:flow-strength="opticalFlowStrength"
|
||||
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
|
||||
:reflection-strength="opticalReflectionStrength"
|
||||
:transparency-strength="opticalTransparencyStrength"
|
||||
:translation-strength="opticalTranslationStrength"
|
||||
:route-key="route.fullPath"
|
||||
:tint-color="globalTheme.current.value.colors.primary"
|
||||
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
|
||||
:transition-started-at="backgroundCrossfadeStartedAt"
|
||||
:wallpaper-url="activeOpticalBackgroundImage"
|
||||
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
||||
/>
|
||||
<!-- 页面内容 -->
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
type ThemeCustomizerGlassAppearance,
|
||||
type ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
GLASS_OPTICAL_STRENGTH_MIN,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassOpticalPresetParameters,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalPreset,
|
||||
} from '@/utils/glassOptics'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -26,8 +34,20 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||
const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||
const isSaving = ref(false)
|
||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
||||
const showsDynamicTuning = computed(() => usesRealtimeOptics.value)
|
||||
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||
)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -44,7 +64,13 @@ watch(
|
||||
(value, previous) => {
|
||||
if (value) {
|
||||
draftAppearance.value = settings.value.glassAppearance
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftQuality.value = settings.value.glassQuality
|
||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||
draftTranslationStrength.value = settings.value.glassTranslationStrength
|
||||
draftTransparencyStrength.value = settings.value.glassTransparencyStrength
|
||||
} else if (previous) {
|
||||
cancelGlassPreview()
|
||||
}
|
||||
@@ -61,13 +87,23 @@ const appearanceOptions: Array<{
|
||||
]
|
||||
|
||||
const qualityOptions: Array<{
|
||||
hint: string
|
||||
label: string
|
||||
value: ThemeCustomizerGlassQuality
|
||||
}> = [
|
||||
{ label: 'theme.glassQualityCss', value: 'css' },
|
||||
{ label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||
{ label: 'theme.glassQualityHigh', value: 'high' },
|
||||
{ hint: 'theme.glassQualityCssHint', label: 'theme.glassQualityCss', value: 'css' },
|
||||
{ hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||
{ hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' },
|
||||
]
|
||||
const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '')
|
||||
const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
||||
{ label: 'theme.glassPresetNatural', value: 'natural' },
|
||||
{ label: 'theme.glassPresetGlide', value: 'glide' },
|
||||
{ label: 'theme.glassPresetLiquid', value: 'liquid' },
|
||||
]
|
||||
const visiblePresetOptions = computed(() =>
|
||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
||||
)
|
||||
|
||||
/** 仅允许已实现的材质进入待保存设置。 */
|
||||
function updateAppearance(value: unknown) {
|
||||
@@ -86,16 +122,68 @@ function updateQuality(value: unknown) {
|
||||
previewGlassSettings({ glassQuality: option.value })
|
||||
}
|
||||
|
||||
/** 将当前草稿恢复为玻璃主题默认值并立即预览。 */
|
||||
function resetSettings() {
|
||||
draftAppearance.value = 'clear'
|
||||
draftQuality.value = 'balanced'
|
||||
/** 将五个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
|
||||
function applyPreset(value: unknown) {
|
||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||
if (!availablePresets.value.includes(value)) return
|
||||
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
||||
draftPreset.value = value
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
draftTranslationStrength.value = parameters.translation
|
||||
draftTransparencyStrength.value = parameters.transparency
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||
function updateTranslationStrength(value: unknown) {
|
||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
||||
}
|
||||
|
||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||
function updateDeformationStrength(value: unknown) {
|
||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
||||
}
|
||||
|
||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||
function updateFlowStrength(value: unknown) {
|
||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
||||
}
|
||||
|
||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||
function updateReflectionStrength(value: unknown) {
|
||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
||||
}
|
||||
|
||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||
function updateTransparencyStrength(value: unknown) {
|
||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
||||
}
|
||||
|
||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
||||
function resetSettings() {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||
async function saveSettings() {
|
||||
if (isSaving.value) return
|
||||
@@ -105,7 +193,13 @@ async function saveSettings() {
|
||||
try {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
commitGlassPreview()
|
||||
visible.value = false
|
||||
@@ -171,6 +265,122 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t(qualityHint) }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="usesRealtimeOptics">
|
||||
<div class="glass-settings-dialog__preset-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassPreset') }}</h3>
|
||||
</div>
|
||||
<VBtnToggle
|
||||
:model-value="activePreset"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__preset"
|
||||
@update:model-value="applyPreset"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in visiblePresetOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__preset-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</section>
|
||||
|
||||
<section class="glass-settings-dialog__tuning">
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransparencyStrength') }}</h3>
|
||||
<output>{{ draftTransparencyStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTransparencyStrength"
|
||||
:aria-label="t('theme.glassTransparencyStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTransparencyStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassReflectionStrength') }}</h3>
|
||||
<output>{{ draftReflectionStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftReflectionStrength"
|
||||
:aria-label="t('theme.glassReflectionStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateReflectionStrength"
|
||||
/>
|
||||
|
||||
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTranslationStrength') }}</h3>
|
||||
<output>{{ draftTranslationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTranslationStrength"
|
||||
:aria-label="t('theme.glassTranslationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTranslationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDeformationStrength') }}</h3>
|
||||
<output>{{ draftDeformationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftDeformationStrength"
|
||||
:aria-label="t('theme.glassDeformationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateDeformationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassFlowStrength') }}</h3>
|
||||
<output>{{ draftFlowStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftFlowStrength"
|
||||
:aria-label="t('theme.glassFlowStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateFlowStrength"
|
||||
/>
|
||||
</div>
|
||||
<p class="glass-settings-dialog__hint">
|
||||
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }}
|
||||
</p>
|
||||
</section>
|
||||
</VCardText>
|
||||
|
||||
@@ -204,8 +414,57 @@ onScopeDispose(cancelGlassPreview)
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__hint {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
output {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header--spaced,
|
||||
.glass-settings-dialog__slider-header + .glass-settings-dialog__slider-header {
|
||||
margin-block-start: 18px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__live-controls {
|
||||
margin-block-start: 18px;
|
||||
|
||||
:deep(.v-slider) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance,
|
||||
.glass-settings-dialog__quality {
|
||||
.glass-settings-dialog__quality,
|
||||
.glass-settings-dialog__preset {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
@@ -226,6 +485,12 @@ onScopeDispose(cancelGlassPreview)
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-block-start: 10px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option {
|
||||
block-size: 32px !important;
|
||||
inline-size: 100%;
|
||||
@@ -233,7 +498,8 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option,
|
||||
.glass-settings-dialog__quality-option {
|
||||
.glass-settings-dialog__quality-option,
|
||||
.glass-settings-dialog__preset-option {
|
||||
border: 0 !important;
|
||||
border-radius: 7px !important;
|
||||
box-shadow: none !important;
|
||||
@@ -247,8 +513,13 @@ onScopeDispose(cancelGlassPreview)
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active) {
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const toggleStub = {
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
}
|
||||
const sliderStub = {
|
||||
emits: ['update:modelValue'],
|
||||
name: 'VSlider',
|
||||
props: ['disabled', 'modelValue'],
|
||||
template:
|
||||
'<input class="slider-stub" type="range" :data-disabled="String(disabled)" :value="modelValue" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelGlassPreview: vi.fn(),
|
||||
commitGlassPreview: vi.fn(),
|
||||
previewGlassSettings: vi.fn(),
|
||||
settings: {
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 50,
|
||||
glassTranslationStrength: 50,
|
||||
glassTransparencyStrength: 50,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
@@ -15,12 +38,7 @@ vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
commitGlassPreview: mocks.commitGlassPreview,
|
||||
previewGlassSettings: mocks.previewGlassSettings,
|
||||
useThemeCustomizer: () => ({
|
||||
settings: {
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'css',
|
||||
},
|
||||
},
|
||||
settings: mocks.settings,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -28,31 +46,53 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mdAndUp: { value: true } }),
|
||||
}))
|
||||
|
||||
describe('GlassSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cancelGlassPreview.mockClear()
|
||||
mocks.commitGlassPreview.mockClear()
|
||||
mocks.previewGlassSettings.mockClear()
|
||||
mocks.settings.value.glassAppearance = 'clear'
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassQuality = 'css'
|
||||
mocks.settings.value.glassReflectionStrength = 50
|
||||
mocks.settings.value.glassTranslationStrength = 50
|
||||
mocks.settings.value.glassTransparencyStrength = 50
|
||||
})
|
||||
|
||||
it('cancels an active preview when the parent closes the dialog', async () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: { VDialogCloseBtn: true },
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(2)
|
||||
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
||||
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
|
||||
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resets the draft to the default glass settings without committing', async () => {
|
||||
it('resets parameters to the current material, quality, and preset without committing', async () => {
|
||||
mocks.settings.value.glassAppearance = 'frosted'
|
||||
mocks.settings.value.glassPreset = 'liquid'
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
mocks.settings.value.glassDeformationStrength = 65
|
||||
mocks.settings.value.glassFlowStrength = 61
|
||||
mocks.settings.value.glassReflectionStrength = 58
|
||||
mocks.settings.value.glassTranslationStrength = 57
|
||||
mocks.settings.value.glassTransparencyStrength = 55
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -66,6 +106,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VDivider: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
@@ -75,9 +116,143 @@ describe('GlassSettingsDialog', () => {
|
||||
await resetButton.trigger('click')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'balanced',
|
||||
glassDeformationStrength: 82,
|
||||
glassFlowStrength: 80,
|
||||
glassPreset: 'liquid',
|
||||
glassReflectionStrength: 44,
|
||||
glassTranslationStrength: 54,
|
||||
glassTransparencyStrength: 46,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps static reflection adjustable in standard quality', async () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(2)
|
||||
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
|
||||
await sliders[1].setValue('86')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
|
||||
})
|
||||
|
||||
it('hides preset choices in standard quality', () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted after slider adjustments', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassPreset = 'glide'
|
||||
mocks.settings.value.glassTransparencyStrength = 61
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
|
||||
const preset = wrapper.find('.glass-settings-dialog__preset')
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
expect(wrapper.findAll('.glass-settings-dialog__preset-option')).toHaveLength(3)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
|
||||
await sliders[0].setValue('77')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
})
|
||||
|
||||
it('normalizes and previews all five independent slider values', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(5)
|
||||
expect(sliders.every(slider => slider.attributes('data-disabled') !== 'true')).toBe(true)
|
||||
|
||||
await sliders[0].setValue('91.4')
|
||||
await sliders[1].setValue('73.6')
|
||||
await sliders[2].setValue('84.2')
|
||||
await sliders[3].setValue('68.7')
|
||||
await sliders[4].setValue('62.2')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassReflectionStrength: 74 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassTranslationStrength: 84 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassDeformationStrength: 69 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassFlowStrength: 62 })
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps motion tuning available in high quality', () => {
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(5)
|
||||
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
|
||||
'theme.glassTransparencyStrength',
|
||||
'theme.glassReflectionStrength',
|
||||
'theme.glassTranslationStrength',
|
||||
'theme.glassDeformationStrength',
|
||||
'theme.glassFlowStrength',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { useGlassOpticalRenderer } from '@/composables/useGlassOpticalRenderer'
|
||||
import {
|
||||
setGlassRendererState,
|
||||
useGlassOpticalInteractionSource,
|
||||
useGlassOpticalRenderer,
|
||||
type GlassRendererState,
|
||||
} from '@/composables/useGlassOpticalRenderer'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前玻璃材质,用于选择透明、色调或磨砂的光学参数。 */
|
||||
appearance: ThemeCustomizerGlassAppearance
|
||||
/** 用户选择的局部非均匀形变强度。 */
|
||||
deformationStrength: number
|
||||
/** 用户选择的轨迹、尾波与惯性强度。 */
|
||||
flowStrength: number
|
||||
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
||||
reflectionStrength: number
|
||||
/** 用户选择的真实壁纸可见度。 */
|
||||
transparencyStrength: number
|
||||
/** 用户选择的共享壁纸采样平移强度。 */
|
||||
translationStrength: number
|
||||
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
|
||||
routeKey: string
|
||||
/** 当前主题主色,用于同步色调材质的光学高光。 */
|
||||
tintColor: string
|
||||
/** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */
|
||||
transitionDuration: number
|
||||
/** 外层壁纸交叉淡化的 performance timeline 起点。 */
|
||||
transitionStartedAt: number
|
||||
/** 与 CSS 背景保持一致的活动壁纸。 */
|
||||
wallpaperUrl: string
|
||||
/** 切换期保留的上一张壁纸;空值表示当前没有交叉淡化。 */
|
||||
previousWallpaperUrl: string
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const { state } = useGlassOpticalRenderer({
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const interactionSource = useGlassOpticalInteractionSource()
|
||||
const fixedRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
canvas,
|
||||
canvas: fixedCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
quality: () => props.quality,
|
||||
reflectionStrength: () => props.reflectionStrength,
|
||||
transparencyStrength: () => props.transparencyStrength,
|
||||
translationStrength: () => props.translationStrength,
|
||||
routeKey: () => props.routeKey,
|
||||
tintColor: () => props.tintColor,
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
surfaceSpace: 'fixed',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
const scrollRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
canvas: scrollCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
quality: () => props.quality,
|
||||
reflectionStrength: () => props.reflectionStrength,
|
||||
transparencyStrength: () => props.transparencyStrength,
|
||||
translationStrength: () => props.translationStrength,
|
||||
routeKey: () => props.routeKey,
|
||||
tintColor: () => props.tintColor,
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
surfaceSpace: 'scroll',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
|
||||
const rendererState = ref<GlassRendererState>('loading')
|
||||
|
||||
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
||||
watchEffect(() => {
|
||||
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
||||
const state: GlassRendererState = states.every(value => value === 'ready')
|
||||
? 'ready'
|
||||
: states.some(value => value === 'loading')
|
||||
? 'loading'
|
||||
: 'fallback'
|
||||
|
||||
setGlassRendererState(rendererState, state)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="glass-optical-layer" aria-hidden="true" :data-state="state" />
|
||||
<canvas
|
||||
ref="fixedCanvas"
|
||||
class="glass-optical-layer glass-optical-layer--fixed"
|
||||
aria-hidden="true"
|
||||
data-presentation-space="fixed"
|
||||
:data-state="fixedRenderer.state.value"
|
||||
/>
|
||||
<canvas
|
||||
ref="scrollCanvas"
|
||||
class="glass-optical-layer glass-optical-layer--scroll"
|
||||
aria-hidden="true"
|
||||
data-presentation-space="scroll"
|
||||
:data-state="scrollRenderer.state.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
@@ -140,7 +141,12 @@ const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== 'clear' ||
|
||||
settings.value.glassDeformationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassFlowStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassQuality !== 'css' ||
|
||||
settings.value.glassReflectionStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
|
||||
52
src/components/theme/__tests__/GlassOpticalLayer.spec.ts
Normal file
52
src/components/theme/__tests__/GlassOpticalLayer.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||
|
||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
|
||||
|
||||
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
||||
setGlassRendererState: vi.fn((state: { value: string }, value: string) => {
|
||||
state.value = value
|
||||
}),
|
||||
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
|
||||
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
|
||||
rendererCalls.push(options)
|
||||
|
||||
return {
|
||||
renderedFrames: ref(0),
|
||||
state: ref('ready'),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('GlassOpticalLayer', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||
rendererCalls.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
const canvases = wrapper.findAll('canvas')
|
||||
expect(canvases).toHaveLength(2)
|
||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/composables/useGlassOpticalRenderer'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
||||
|
||||
vi.mock('three', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('three')>()
|
||||
@@ -26,11 +27,15 @@ vi.mock('three', async importOriginal => {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
clear() {}
|
||||
dispose() {}
|
||||
forceContextLoss() {}
|
||||
render() {}
|
||||
setClearColor() {}
|
||||
setPixelRatio() {}
|
||||
setRenderTarget() {}
|
||||
setScissor() {}
|
||||
setScissorTest() {}
|
||||
setSize() {}
|
||||
},
|
||||
}
|
||||
@@ -63,6 +68,59 @@ function appendOpticalSurface(className: string, bounds: Pick<DOMRect, 'height'
|
||||
return surface
|
||||
}
|
||||
|
||||
/** 为元素提供可由 renderer 读取的稳定视口边界。 */
|
||||
function setOpticalSurfaceBounds(element: HTMLElement, bounds: Pick<DOMRect, 'height' | 'width' | 'x' | 'y'>) {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
...bounds,
|
||||
bottom: bounds.y + bounds.height,
|
||||
left: bounds.x,
|
||||
right: bounds.x + bounds.width,
|
||||
top: bounds.y,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
}
|
||||
|
||||
function stubMediaPreferences({ coarsePointer = false, reducedMotion = false, reducedTransparency = false } = {}) {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches:
|
||||
(query === '(pointer: coarse)' && coarsePointer) ||
|
||||
(query === '(prefers-reduced-motion: reduce)' && reducedMotion) ||
|
||||
(query === '(prefers-reduced-transparency: reduce)' && reducedTransparency),
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function createTouchList(points: Array<{ clientX: number; clientY: number; identifier: number }>) {
|
||||
const touches = points.map(point => point as Touch)
|
||||
|
||||
return Object.assign(touches, {
|
||||
item(index: number) {
|
||||
return touches[index] ?? null
|
||||
},
|
||||
}) as unknown as TouchList
|
||||
}
|
||||
|
||||
function dispatchTouchEvent(
|
||||
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
|
||||
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
|
||||
changedTouches = touches,
|
||||
) {
|
||||
const event = new Event(type) as TouchEvent
|
||||
Object.defineProperties(event, {
|
||||
changedTouches: { value: createTouchList(changedTouches) },
|
||||
touches: { value: createTouchList(touches) },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
vi.stubGlobal('WebGLRenderingContext', class {})
|
||||
@@ -70,6 +128,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.replaceChildren()
|
||||
@@ -79,14 +138,14 @@ afterEach(() => {
|
||||
describe('glass optical surface discovery', () => {
|
||||
it('detects a target surface added directly', () => {
|
||||
const surface = document.createElement('section')
|
||||
surface.className = 'dashboard-grid-item-content'
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
|
||||
expect(containsGlassOpticalSurface(surface)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a target surface inside an asynchronously added subtree', () => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.innerHTML = '<div><section class="dashboard-grid-item-content"></section></div>'
|
||||
wrapper.innerHTML = '<div><section data-glass-optical-surface></section></div>'
|
||||
|
||||
expect(containsGlassOpticalSurface(wrapper)).toBe(true)
|
||||
expect(containsGlassOpticalSurface(document.createElement('div'))).toBe(false)
|
||||
@@ -115,7 +174,8 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
it('excludes the navbar from mobile clear optical surfaces', () => {
|
||||
appendOpticalSurface('layout-navbar', { height: 64, width: 390, x: 0, y: 0 })
|
||||
appendOpticalSurface('dashboard-grid-item-content', { height: 200, width: 350, x: 20, y: 120 })
|
||||
const content = appendOpticalSurface('dashboard-content', { height: 200, width: 350, x: 20, y: 120 })
|
||||
content.dataset.glassOpticalSurface = ''
|
||||
|
||||
const rects = collectGlassOpticalRects(390, 800, 'clear')
|
||||
|
||||
@@ -139,6 +199,61 @@ describe('glass optical surface discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the actual dashboard card boundary and all four corner radii', () => {
|
||||
const shell = document.createElement('section')
|
||||
shell.className = 'dashboard-grid-item-content'
|
||||
shell.innerHTML = `
|
||||
<div class="dashboard-grid-auto-size">
|
||||
<div class="dashboard-grid-content-measure">
|
||||
<article class="v-card"></article>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const card = shell.querySelector<HTMLElement>('.v-card')!
|
||||
card.style.borderTopLeftRadius = '24px'
|
||||
card.style.borderTopRightRadius = '20px'
|
||||
card.style.borderBottomRightRadius = '16px'
|
||||
card.style.borderBottomLeftRadius = '12px'
|
||||
setOpticalSurfaceBounds(shell, { height: 220, width: 360, x: 20, y: 100 })
|
||||
setOpticalSurfaceBounds(card, { height: 200, width: 340, x: 30, y: 110 })
|
||||
document.body.append(shell)
|
||||
|
||||
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
|
||||
expect.objectContaining({
|
||||
height: 200,
|
||||
radii: [24, 20, 16, 12],
|
||||
width: 340,
|
||||
x: 30,
|
||||
y: 110,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('discovers explicit optical surfaces without component-specific selectors', () => {
|
||||
const surface = appendOpticalSurface('custom-surface', { height: 180, width: 300, x: 40, y: 120 })
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
surface.style.borderTopLeftRadius = '18px'
|
||||
surface.style.borderTopRightRadius = '18px'
|
||||
surface.style.borderBottomRightRadius = '18px'
|
||||
surface.style.borderBottomLeftRadius = '18px'
|
||||
|
||||
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
|
||||
expect.objectContaining({ height: 180, radii: [18, 18, 18, 18], width: 300, x: 40, y: 120 }),
|
||||
])
|
||||
})
|
||||
|
||||
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 })
|
||||
surface.style.borderTopLeftRadius = '20px'
|
||||
surface.style.borderTopRightRadius = '20px'
|
||||
surface.style.borderBottomRightRadius = '20px'
|
||||
surface.style.borderBottomLeftRadius = '20px'
|
||||
|
||||
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
|
||||
expect.objectContaining({ height: 220, radii: [20, 20, 20, 20], width: 150, x: 24, y: 96 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('recovers after consecutive WebGL context loss cycles', async () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
@@ -177,6 +292,7 @@ describe('glass optical surface discovery', () => {
|
||||
const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => ++frameId)
|
||||
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame')
|
||||
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
const resizeDisconnect = vi.spyOn(ResizeObserverMock.prototype, 'disconnect')
|
||||
const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
|
||||
@@ -200,16 +316,24 @@ describe('glass optical surface discovery', () => {
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(requestFrame).toHaveBeenCalled()
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(1)
|
||||
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
|
||||
|
||||
const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(rendererDispose).toHaveBeenCalledTimes(1)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2)
|
||||
expect(contextLoss).toHaveBeenCalledTimes(1)
|
||||
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
|
||||
@@ -224,16 +348,24 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
|
||||
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(0)
|
||||
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
|
||||
|
||||
const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(rendererDispose).toHaveBeenCalledTimes(2)
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2)
|
||||
expect(contextLoss).toHaveBeenCalledTimes(2)
|
||||
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
|
||||
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
|
||||
@@ -241,4 +373,680 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('reuses the renderer context while switching quality profiles', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const quality = ref<'balanced' | 'high'>('high')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality,
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
|
||||
quality.value = 'balanced'
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(rendererDispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(2)
|
||||
|
||||
quality.value = 'high'
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(rendererDispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(renderTargetDispose).toHaveBeenCalledTimes(4)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps old and new wallpaper textures in the same renderer during the shared transition', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg')
|
||||
const previousWallpaperUrl = ref('')
|
||||
const transitionStartedAt = ref(0)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('frosted'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('high'),
|
||||
previousWallpaperUrl,
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
transitionDuration: ref(1500),
|
||||
transitionStartedAt,
|
||||
wallpaperUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
const replacementTexture = new three.Texture<HTMLImageElement>()
|
||||
replacementTexture.image = {
|
||||
height: 1,
|
||||
naturalHeight: 1,
|
||||
naturalWidth: 1,
|
||||
width: 1,
|
||||
} as HTMLImageElement
|
||||
let resolveTexture!: (texture: typeof replacementTexture) => void
|
||||
vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockReturnValueOnce(
|
||||
new Promise<typeof replacementTexture>(resolve => {
|
||||
resolveTexture = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
previousWallpaperUrl.value = wallpaperUrl.value
|
||||
transitionStartedAt.value = performance.now()
|
||||
wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg'
|
||||
await nextTick()
|
||||
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBe('true'))
|
||||
|
||||
resolveTexture(replacementTexture)
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined())
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uPreviousTexture: { value: unknown }
|
||||
uTexture: { value: unknown }
|
||||
uTextureMix: { value: number }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
|
||||
expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0)
|
||||
expect(uniforms.uTextureMix.value).toBeLessThan(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps the active texture and ready state when a replacement wallpaper fails', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockRejectedValueOnce(new Error('replacement failed'))
|
||||
|
||||
wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg'
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined())
|
||||
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
expect(rendererDispose).not.toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('pauses briefly hidden renderers and resumes a stable frame without disposing resources', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
vi.useFakeTimers()
|
||||
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1)
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
visibilityState = 'visible'
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expect(render).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('releases renderer resources after the long background timeout', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
vi.useFakeTimers()
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('uses the CSS fallback when reduced transparency is active', async () => {
|
||||
stubMediaPreferences({ reducedTransparency: true })
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback'))
|
||||
expect(document.documentElement.dataset.glassRendererState).toBe('fallback')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('tracks the actual passive touch coordinate without restoring press magnification', async () => {
|
||||
stubMediaPreferences({ coarsePointer: true })
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 500, width: 350, x: 20, y: 100 })
|
||||
surface.style.borderRadius = '20px'
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('frosted'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/recommend'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now()))
|
||||
}
|
||||
render.mockClear()
|
||||
|
||||
dispatchTouchEvent('touchstart', [{ clientX: 80, clientY: 180, identifier: 7 }])
|
||||
const startFrames = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
startFrames.forEach(callback => callback(performance.now()))
|
||||
render.mockClear()
|
||||
|
||||
const moveEvent = dispatchTouchEvent('touchmove', [{ clientX: 180, clientY: 320, identifier: 7 }])
|
||||
const interactionFrames = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
interactionFrames.forEach(callback => callback(moveEvent.timeStamp + 16))
|
||||
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uPointer: { value: { x: number; y: number } }
|
||||
uTrail: { value: Array<{ x: number; y: number; z: number }> }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const pointer = scene.children[0].material.uniforms.uPointer.value
|
||||
const trail = scene.children[0].material.uniforms.uTrail.value
|
||||
expect(pointer.x).toBeCloseTo(180 / 390)
|
||||
expect(pointer.y).toBeCloseTo(1 - 320 / 844)
|
||||
expect(trail[0]).toMatchObject({ z: 1 })
|
||||
expect(trail[0].x).toBeCloseTo(180 / 390)
|
||||
expect(trail[0].y).toBeCloseTo(1 - 320 / 844)
|
||||
expect(trail[1]).toMatchObject({ z: 0.72 })
|
||||
expect(trail[1].x).toBeCloseTo(80 / 390)
|
||||
expect(trail[1].y).toBeCloseTo(1 - 180 / 844)
|
||||
|
||||
dispatchTouchEvent('touchend', [], [{ clientX: 180, clientY: 320, identifier: 7 }])
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
|
||||
stubMediaPreferences({ coarsePointer: true })
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
const surface = appendOpticalSurface('test-surface', { height: 500, width: 350, x: 20, y: 100 })
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('frosted'),
|
||||
canvas: ref(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'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now()))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120)
|
||||
setOpticalSurfaceBounds(surface, { height: 500, width: 350, x: 20, y: -20 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + 160 + pass * 16))
|
||||
}
|
||||
|
||||
const settledScene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uMotion: { value: number }
|
||||
uRects: { value: Array<{ y: number }> }
|
||||
uScrollOffset: { value: { y: number } }
|
||||
uTrail: { value: Array<{ z: number }> }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const settledUniforms = settledScene.children[0].material.uniforms
|
||||
expect(settledUniforms.uMotion.value).toBe(0)
|
||||
expect(settledUniforms.uTrail.value.every(trail => trail.z === 0)).toBe(true)
|
||||
expect(settledUniforms.uRects.value[0].y).toBeCloseTo(1 - (100 + 500) / 844)
|
||||
expect(settledUniforms.uScrollOffset.value.y).toBe(120)
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps local material response stable while moving from card A through a gap to card B', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(700)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
const surfaces = [
|
||||
{ x: 100, name: 'A' },
|
||||
{ x: 400, name: 'B' },
|
||||
]
|
||||
surfaces.forEach(({ name, x }) => {
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 180,
|
||||
width: 180,
|
||||
x,
|
||||
y: 100,
|
||||
})
|
||||
surface.dataset.testSurface = name
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
const deformationStrength = ref(50)
|
||||
const flowStrength = ref(50)
|
||||
const reflectionStrength = ref(50)
|
||||
const translationStrength = ref(50)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
deformationStrength,
|
||||
flowStrength,
|
||||
quality: ref('balanced'),
|
||||
reflectionStrength,
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength,
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now()))
|
||||
}
|
||||
render.mockClear()
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
const [cardAFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
cardAFrame(performance.now() + 16)
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uDeformationStrength: { value: number }
|
||||
uFlowStrength: { value: number }
|
||||
uMaxRefractionPixels: { value: number }
|
||||
uMotionExpansion: { value: number }
|
||||
uRects: { value: Array<{ x: number }> }
|
||||
uReflectionStrength: { value: number }
|
||||
uTransparency: { value: number }
|
||||
uTranslationStrength: { value: number }
|
||||
uSurfaceWeights: { value: number[] }
|
||||
}
|
||||
fragmentShader: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
const cardAX = 100 / 1200
|
||||
const cardBX = 400 / 1200
|
||||
expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 330, clientY: 160 }))
|
||||
expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 460, clientY: 160 }))
|
||||
const cardAIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardAX) < 0.001)
|
||||
const cardBIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardBX) < 0.001)
|
||||
expect(cardAIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cardBIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(uniforms.uSurfaceWeights.value[cardAIndex]).toBe(1)
|
||||
expect(uniforms.uSurfaceWeights.value[cardBIndex]).toBe(1)
|
||||
expect(uniforms.uTranslationStrength.value).toBe(1)
|
||||
expect(uniforms.uDeformationStrength.value).toBe(1)
|
||||
expect(uniforms.uFlowStrength.value).toBe(1)
|
||||
expect(uniforms.uMotionExpansion.value).toBe(0)
|
||||
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
|
||||
expect(uniforms.uReflectionStrength.value).toBe(1)
|
||||
expect(uniforms.uTransparency.value).toBeGreaterThan(0.6)
|
||||
|
||||
translationStrength.value = 100
|
||||
deformationStrength.value = 100
|
||||
flowStrength.value = 100
|
||||
reflectionStrength.value = 80
|
||||
await nextTick()
|
||||
expect(uniforms.uTranslationStrength.value).toBeCloseTo(1.7)
|
||||
expect(uniforms.uDeformationStrength.value).toBeCloseTo(1.55)
|
||||
expect(uniforms.uFlowStrength.value).toBeCloseTo(1.45)
|
||||
expect(uniforms.uMotionExpansion.value).toBe(1)
|
||||
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
|
||||
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'materialEnergy = max(materialEnergy, liquidEnergy * rectMask)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('getContentProtection')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('sampleHighQualityDiffuse')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('singleSpecular')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('stableSample')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('broadReflection')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('flowSurfaceDetail * 0.38')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('pointerCurvature')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('wakeCurvature')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uMotionExpansion')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uFlowStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uMotion *')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float highlightBudget')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float absorption')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'causticHighlightMix * uReflectionStrength * highlightBudget',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('mix(0.78, 0.94, uTransparency)')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('mix(0.035, 0.4')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('settles pointer feedback without leaving a continuous animation frame', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
const surface = appendOpticalSurface('test-surface', { height: 240, width: 320, x: 20, y: 80 })
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
const canvas = document.createElement('canvas')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(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'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now()))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
render.mockClear()
|
||||
|
||||
const pointerMove = new MouseEvent('pointermove', { clientX: 160, clientY: 160 })
|
||||
window.dispatchEvent(pointerMove)
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
const [firstInteractionFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
firstInteractionFrame(pointerMove.timeStamp + 100)
|
||||
const firstScene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
|
||||
}>
|
||||
}
|
||||
const firstVelocity = firstScene.children[0].material.uniforms.uPointerVelocity.value
|
||||
const stableDirection = { x: firstVelocity.x, y: firstVelocity.y }
|
||||
|
||||
const [secondInteractionFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
secondInteractionFrame(pointerMove.timeStamp + 300)
|
||||
const secondScene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
|
||||
}>
|
||||
}
|
||||
expect(secondScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject(stableDirection)
|
||||
|
||||
const [finalInteractionFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
finalInteractionFrame(pointerMove.timeStamp + 500)
|
||||
const finalScene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
|
||||
}>
|
||||
}
|
||||
|
||||
expect(callbacks.size).toBe(0)
|
||||
expect(finalScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 })
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps immediate translation and deformation at zero flow without scheduling inertia', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength: ref(70),
|
||||
flowStrength: ref(0),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength: ref(70),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now()))
|
||||
}
|
||||
render.mockClear()
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
expect(callbacks.size).toBe(1)
|
||||
const [frame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
frame(performance.now() + 16)
|
||||
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uDeformationStrength: { value: number }
|
||||
uFlowStrength: { value: number }
|
||||
uMotion: { value: number }
|
||||
uTranslationStrength: { value: number }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
expect(uniforms.uMotion.value).toBe(1)
|
||||
expect(uniforms.uTranslationStrength.value).toBeGreaterThan(1)
|
||||
expect(uniforms.uDeformationStrength.value).toBeGreaterThan(1)
|
||||
expect(uniforms.uFlowStrength.value).toBe(0)
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
previewGlassSettings,
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
useEffectiveGlassSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -17,11 +18,17 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses clear appearance and balanced quality by default', () => {
|
||||
it('uses the checkpoint appearance, quality, and strength values by default', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(50)
|
||||
expect(settings.glassFlowStrength).toBe(50)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
expect(settings.glassReflectionStrength).toBe(50)
|
||||
expect(settings.glassTranslationStrength).toBe(50)
|
||||
expect(settings.glassTransparencyStrength).toBe(50)
|
||||
})
|
||||
|
||||
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
||||
@@ -39,15 +46,47 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('falls back when stored glass settings are invalid', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }),
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }),
|
||||
)
|
||||
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
})
|
||||
|
||||
it('rounds and clamps persisted optical strength values', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
glassDeformationStrength: -12,
|
||||
glassFlowStrength: 42.6,
|
||||
glassReflectionStrength: 140.6,
|
||||
glassTranslationStrength: 101,
|
||||
glassTransparencyStrength: 83.7,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassDeformationStrength: 0,
|
||||
glassFlowStrength: 43,
|
||||
glassReflectionStrength: 100,
|
||||
glassTranslationStrength: 100,
|
||||
glassTransparencyStrength: 84,
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the legacy motion value into translation, deformation, and flow', () => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassMotionStrength: 67 }))
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassDeformationStrength: 67,
|
||||
glassFlowStrength: 67,
|
||||
glassTranslationStrength: 67,
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs glass settings to the document roots', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
@@ -61,6 +100,10 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.documentElement.dataset.glassQuality).toBe('high')
|
||||
expect(document.body.dataset.glassAppearance).toBe('tinted')
|
||||
expect(document.body.dataset.glassQuality).toBe('high')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.5')
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.5')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-transparency')).toBe('0.5')
|
||||
expect(document.body.style.getPropertyValue('--glass-transparency')).toBe('0.5')
|
||||
})
|
||||
|
||||
it('previews glass settings without persisting them', () => {
|
||||
@@ -76,11 +119,29 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('commits the latest glass preview as one persisted state', () => {
|
||||
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'clear' })
|
||||
previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTranslationStrength: 69,
|
||||
glassTransparencyStrength: 80,
|
||||
})
|
||||
|
||||
commitGlassPreview()
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTranslationStrength: 69,
|
||||
glassTransparencyStrength: 80,
|
||||
})
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
})
|
||||
|
||||
@@ -96,12 +157,42 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
})
|
||||
|
||||
it('restores persisted glass settings when a preview is cancelled', () => {
|
||||
persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'clear' })
|
||||
const effective = useEffectiveGlassSettings()
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassReflectionStrength: 66,
|
||||
glassTranslationStrength: 46,
|
||||
glassTransparencyStrength: 72,
|
||||
})
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTranslationStrength: 86,
|
||||
glassTransparencyStrength: 94,
|
||||
})
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTranslationStrength: 86,
|
||||
glassTransparencyStrength: 94,
|
||||
})
|
||||
|
||||
cancelGlassPreview()
|
||||
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted')
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassReflectionStrength: 66,
|
||||
glassTranslationStrength: 46,
|
||||
glassTransparencyStrength: 72,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,13 @@ import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { saveLocalTheme } from '@/@core/utils/theme'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
getGlassOpticalPresetParameters,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalPreset,
|
||||
} from '@/utils/glassOptics'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||
|
||||
@@ -64,8 +71,20 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
|
||||
export interface ThemeCustomizerSettings {
|
||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||
glassAppearance: ThemeCustomizerGlassAppearance
|
||||
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
||||
glassDeformationStrength: number
|
||||
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
||||
glassFlowStrength: number
|
||||
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
|
||||
glassPreset: GlassOpticalPreset
|
||||
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||
glassQuality: ThemeCustomizerGlassQuality
|
||||
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
|
||||
glassReflectionStrength: number
|
||||
/** 共享壁纸在表面内的统一采样平移强度,范围 0 到 100。 */
|
||||
glassTranslationStrength: number
|
||||
/** 玻璃材质释放真实壁纸的程度,范围 0 到 100。 */
|
||||
glassTransparencyStrength: number
|
||||
/** 桌面导航布局。 */
|
||||
layout: ThemeCustomizerLayout
|
||||
/** 主题强调色,也是色调玻璃的颜色来源。 */
|
||||
@@ -86,6 +105,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||
@@ -123,9 +143,17 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
||||
|
||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTranslationStrength: glassParameters.translation,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -136,6 +164,19 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizableThemeCustomizerSettings = Partial<ThemeCustomizerSettings> & {
|
||||
/** 旧版单一动态强度仅用于一次性迁移到三个独立维度。 */
|
||||
glassMotionStrength?: unknown
|
||||
}
|
||||
|
||||
/** 新字段缺失时继承旧版动态强度,避免升级后无声重置用户手感。 */
|
||||
function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fallback: number) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return normalizeGlassOpticalStrength(value)
|
||||
if (typeof legacyValue === 'number' && Number.isFinite(legacyValue)) return normalizeGlassOpticalStrength(legacyValue)
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
|
||||
@@ -145,7 +186,7 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
|
||||
}
|
||||
|
||||
/** 规范化持久化的主题定制设置并迁移旧值。 */
|
||||
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||
function normalizeThemeCustomizerSettings(settings: NormalizableThemeCustomizerSettings): ThemeCustomizerSettings {
|
||||
const fallback = getDefaultThemeCustomizerSettings()
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
@@ -155,9 +196,29 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
||||
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
: fallback.glassAppearance,
|
||||
glassDeformationStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassDeformationStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassDeformationStrength,
|
||||
),
|
||||
glassFlowStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassFlowStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassFlowStrength,
|
||||
),
|
||||
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
|
||||
? (settings.glassPreset as GlassOpticalPreset)
|
||||
: fallback.glassPreset,
|
||||
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
: fallback.glassQuality,
|
||||
glassReflectionStrength: normalizeGlassOpticalStrength(settings.glassReflectionStrength),
|
||||
glassTranslationStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassTranslationStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassTranslationStrength,
|
||||
),
|
||||
glassTransparencyStrength: normalizeGlassOpticalStrength(settings.glassTransparencyStrength),
|
||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
@@ -183,11 +244,7 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||
const parsed = stored ? JSON.parse(stored) : {}
|
||||
return normalizeThemeCustomizerSettings({
|
||||
...fallback,
|
||||
...parsed,
|
||||
theme: readStoredThemePreference(),
|
||||
})
|
||||
return normalizeThemeCustomizerSettings({ ...parsed, theme: readStoredThemePreference() })
|
||||
} catch (error) {
|
||||
console.warn('读取主题定制设置失败,已使用默认设置:', error)
|
||||
|
||||
@@ -197,13 +254,34 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
|
||||
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
||||
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
||||
const glassPreviewState = ref<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'> | null>(null)
|
||||
type ThemeCustomizerGlassSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTranslationStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
>
|
||||
const glassPreviewState = ref<ThemeCustomizerGlassSettings | null>(null)
|
||||
const effectiveGlassSettings = computed(() => ({
|
||||
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||
glassDeformationStrength:
|
||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
||||
glassReflectionStrength:
|
||||
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
|
||||
glassTranslationStrength:
|
||||
glassPreviewState.value?.glassTranslationStrength ?? settingsState.value.glassTranslationStrength,
|
||||
glassTransparencyStrength:
|
||||
glassPreviewState.value?.glassTransparencyStrength ?? settingsState.value.glassTransparencyStrength,
|
||||
}))
|
||||
|
||||
/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */
|
||||
/** 提供当前实际生效的玻璃设置;临时预览优先于已持久化设置。 */
|
||||
export function useEffectiveGlassSettings() {
|
||||
return readonly(effectiveGlassSettings)
|
||||
}
|
||||
@@ -259,13 +337,29 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
|
||||
export function applyThemeCustomizerRootSettings(
|
||||
settings: Pick<
|
||||
ThemeCustomizerSettings,
|
||||
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
|
||||
| 'glassAppearance'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
| 'layout'
|
||||
| 'radius'
|
||||
| 'semiDarkMenu'
|
||||
| 'shadow'
|
||||
| 'skin'
|
||||
>,
|
||||
) {
|
||||
if (!isBrowser()) return
|
||||
|
||||
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-reflection',
|
||||
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
||||
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -273,6 +367,14 @@ export function applyThemeCustomizerRootSettings(
|
||||
document.documentElement.setAttribute('data-theme-skin', settings.skin)
|
||||
document.body.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
document.body.setAttribute('data-glass-quality', settings.glassQuality)
|
||||
document.body.style.setProperty(
|
||||
'--glass-reflection',
|
||||
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.body.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.body.setAttribute('data-theme-layout', settings.layout)
|
||||
document.body.setAttribute('data-theme-radius', settings.radius)
|
||||
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -340,10 +442,8 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
||||
return nextSettings
|
||||
}
|
||||
|
||||
/** 临时应用玻璃外观,不写入存储或广播持久化变更。 */
|
||||
export function previewGlassSettings(
|
||||
patch: Partial<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'>>,
|
||||
) {
|
||||
/** 临时应用玻璃设置,不写入存储或广播持久化变更。 */
|
||||
export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings>) {
|
||||
const previewSettings = normalizeThemeCustomizerSettings({
|
||||
...settingsState.value,
|
||||
...glassPreviewState.value,
|
||||
@@ -352,7 +452,13 @@ export function previewGlassSettings(
|
||||
|
||||
glassPreviewState.value = {
|
||||
glassAppearance: previewSettings.glassAppearance,
|
||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||
glassPreset: previewSettings.glassPreset,
|
||||
glassQuality: previewSettings.glassQuality,
|
||||
glassReflectionStrength: previewSettings.glassReflectionStrength,
|
||||
glassTranslationStrength: previewSettings.glassTranslationStrength,
|
||||
glassTransparencyStrength: previewSettings.glassTransparencyStrength,
|
||||
}
|
||||
applyThemeCustomizerRootSettings({
|
||||
...settingsState.value,
|
||||
@@ -387,7 +493,13 @@ export function cancelGlassPreview() {
|
||||
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||
const defaults = normalizeThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -399,7 +511,13 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
|
||||
return (
|
||||
settings.glassAppearance === defaults.glassAppearance &&
|
||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||
settings.glassPreset === defaults.glassPreset &&
|
||||
settings.glassQuality === defaults.glassQuality &&
|
||||
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
|
||||
settings.glassTranslationStrength === defaults.glassTranslationStrength &&
|
||||
settings.glassTransparencyStrength === defaults.glassTransparencyStrength &&
|
||||
settings.layout === defaults.layout &&
|
||||
settings.primaryColor === defaults.primaryColor &&
|
||||
settings.radius === defaults.radius &&
|
||||
@@ -449,11 +567,41 @@ export function useThemeCustomizer() {
|
||||
return updateSettings({ glassAppearance })
|
||||
}
|
||||
|
||||
/** 更新玻璃局部非均匀形变强度。 */
|
||||
function setGlassDeformationStrength(glassDeformationStrength: number) {
|
||||
return updateSettings({ glassDeformationStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||
return updateSettings({ glassFlowStrength })
|
||||
}
|
||||
|
||||
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
|
||||
function setGlassPreset(glassPreset: GlassOpticalPreset) {
|
||||
return updateSettings({ glassPreset })
|
||||
}
|
||||
|
||||
/** 更新玻璃主题渲染质量档位。 */
|
||||
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
|
||||
return updateSettings({ glassQuality })
|
||||
}
|
||||
|
||||
/** 更新玻璃表面反射亮度。 */
|
||||
function setGlassReflectionStrength(glassReflectionStrength: number) {
|
||||
return updateSettings({ glassReflectionStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃统一采样平移强度。 */
|
||||
function setGlassTranslationStrength(glassTranslationStrength: number) {
|
||||
return updateSettings({ glassTranslationStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃材质的真实壁纸可见度。 */
|
||||
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
|
||||
return updateSettings({ glassTransparencyStrength })
|
||||
}
|
||||
|
||||
/** 更新全局圆角档位。 */
|
||||
function setRadius(radius: ThemeCustomizerRadius) {
|
||||
return updateSettings({ radius })
|
||||
@@ -488,7 +636,13 @@ export function useThemeCustomizer() {
|
||||
async function resetSettings() {
|
||||
await updateSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -525,7 +679,13 @@ export function useThemeCustomizer() {
|
||||
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassDeformationStrength,
|
||||
setGlassFlowStrength,
|
||||
setGlassPreset,
|
||||
setGlassQuality,
|
||||
setGlassReflectionStrength,
|
||||
setGlassTranslationStrength,
|
||||
setGlassTransparencyStrength,
|
||||
setLayout,
|
||||
setPrimaryColor,
|
||||
setRadius,
|
||||
|
||||
@@ -156,6 +156,22 @@ export default {
|
||||
glassQualityCss: 'Standard',
|
||||
glassQualityBalanced: 'Balanced',
|
||||
glassQualityHigh: 'High',
|
||||
glassQualityCssHint: 'Static CSS material with the lowest resource use; clarity and reflection remain available.',
|
||||
glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.',
|
||||
glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.',
|
||||
glassPreset: 'Preset',
|
||||
glassPresetNatural: 'Natural',
|
||||
glassPresetGlide: 'Glide',
|
||||
glassPresetLiquid: 'Liquid',
|
||||
glassTranslationStrength: 'Sample Translation',
|
||||
glassDeformationStrength: 'Deformation',
|
||||
glassFlowStrength: 'Flow Strength',
|
||||
glassReflectionStrength: 'Reflection Brightness',
|
||||
glassTransparencyStrength: 'Clarity',
|
||||
glassOpticalStrengthHint:
|
||||
'Sample translation, local deformation, and flow memory stay independent; clarity and reflection do not replace motion.',
|
||||
glassOpticalStrengthUnavailableHint:
|
||||
'This environment keeps clarity and static reflection; the three dynamic controls return with desktop live quality.',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
transparency: 'Transparency',
|
||||
|
||||
@@ -154,6 +154,20 @@ export default {
|
||||
glassQualityCss: '标准',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高质量',
|
||||
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透与反射,不启用实时流动。',
|
||||
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
|
||||
glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。',
|
||||
glassPreset: '方案',
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液态',
|
||||
glassTranslationStrength: '采样平移',
|
||||
glassDeformationStrength: '形变强度',
|
||||
glassFlowStrength: '流动强度',
|
||||
glassReflectionStrength: '反射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '采样平移、局部形变与流动记忆互相独立;通透度和反射不代替动态参数。',
|
||||
glassOpticalStrengthUnavailableHint: '当前环境保留通透度和静态反射;三个动态参数会在桌面实时质量下恢复。',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
transparency: '透明度',
|
||||
|
||||
@@ -154,6 +154,20 @@ export default {
|
||||
glassQualityCss: '標準',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高品質',
|
||||
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透與反射,不啟用即時流動。',
|
||||
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
|
||||
glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。',
|
||||
glassPreset: '方案',
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液態',
|
||||
glassTranslationStrength: '採樣平移',
|
||||
glassDeformationStrength: '形變強度',
|
||||
glassFlowStrength: '流動強度',
|
||||
glassReflectionStrength: '反射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '採樣平移、局部形變與流動記憶互相獨立;通透度和反射不代替動態參數。',
|
||||
glassOpticalStrengthUnavailableHint: '目前環境保留通透度和靜態反射;三個動態參數會在桌面即時品質下恢復。',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
transparency: '透明度',
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
|
||||
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||
html[data-theme='glass'] {
|
||||
--glass-surface: rgba(11, 19, 34, 22%);
|
||||
--glass-surface-soft: rgba(11, 19, 34, 28%);
|
||||
--glass-surface-raised: rgba(11, 19, 34, 36%);
|
||||
--glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-surface-soft: rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-surface-raised: rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24));
|
||||
--glass-control: rgba(11, 19, 34, 52%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
||||
--glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
|
||||
--glass-border: rgba(255, 255, 255, 10%);
|
||||
--glass-border-raised: rgba(255, 255, 255, 14%);
|
||||
--glass-border-hover: rgba(255, 255, 255, 20%);
|
||||
--glass-border: rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12));
|
||||
--glass-border-raised: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16));
|
||||
--glass-border-hover: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2));
|
||||
--glass-control-prominent-border: rgba(255, 255, 255, 14%);
|
||||
--glass-highlight: rgba(255, 255, 255, 14%);
|
||||
--glass-highlight: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16));
|
||||
--glass-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 9%),
|
||||
@@ -23,6 +23,11 @@ html[data-theme='glass'] {
|
||||
);
|
||||
--glass-shadow:
|
||||
0 10px 28px rgba(3, 7, 18, 24%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
||||
--glass-dashboard-shadow: inset 0 1px 0 var(--glass-highlight);
|
||||
--glass-dashboard-shadow-hover:
|
||||
inset 0 1px 0 rgba(255, 255, 255, calc(0.16 + var(--glass-reflection, 0.5) * 0.24)),
|
||||
inset -1px 0 0 rgba(2, 6, 16, calc(0.12 + var(--glass-reflection, 0.5) * 0.1)),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, calc(0.14 + var(--glass-reflection, 0.5) * 0.1));
|
||||
--glass-shadow-raised:
|
||||
0 16px 40px rgba(3, 7, 18, 32%), inset 0 1px 0 rgba(255, 255, 255, 24%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||
--glass-shadow-hover:
|
||||
@@ -104,9 +109,21 @@ html[data-theme='glass'] {
|
||||
|
||||
// 色调保持与透明材质接近的透光度,只叠加主色语义。
|
||||
&[data-glass-appearance='tinted'] {
|
||||
--glass-surface: color-mix(in srgb, rgba(11, 19, 34, 24%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||
--glass-surface-soft: color-mix(in srgb, rgba(11, 19, 34, 30%) 89%, rgba(var(--v-theme-primary), 26%));
|
||||
--glass-surface-raised: color-mix(in srgb, rgba(11, 19, 34, 38%) 84%, rgba(var(--v-theme-primary), 32%));
|
||||
--glass-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%,
|
||||
rgba(var(--v-theme-primary), 28%)
|
||||
);
|
||||
--glass-surface-soft: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%,
|
||||
rgba(var(--v-theme-primary), 26%)
|
||||
);
|
||||
--glass-surface-raised: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%,
|
||||
rgba(var(--v-theme-primary), 32%)
|
||||
);
|
||||
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
|
||||
--glass-control-prominent-focus: color-mix(
|
||||
@@ -114,15 +131,31 @@ html[data-theme='glass'] {
|
||||
rgba(255, 255, 255, 10%) 76%,
|
||||
rgba(var(--v-theme-primary), 32%)
|
||||
);
|
||||
--glass-border: color-mix(in srgb, rgba(255, 255, 255, 10%) 82%, rgba(var(--v-theme-primary), 26%));
|
||||
--glass-border-raised: color-mix(in srgb, rgba(255, 255, 255, 14%) 80%, rgba(var(--v-theme-primary), 30%));
|
||||
--glass-border-hover: color-mix(in srgb, rgba(255, 255, 255, 20%) 78%, rgba(var(--v-theme-primary), 36%));
|
||||
--glass-border: color-mix(
|
||||
in srgb,
|
||||
rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12)) 82%,
|
||||
rgba(var(--v-theme-primary), 26%)
|
||||
);
|
||||
--glass-border-raised: color-mix(
|
||||
in srgb,
|
||||
rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 80%,
|
||||
rgba(var(--v-theme-primary), 30%)
|
||||
);
|
||||
--glass-border-hover: color-mix(
|
||||
in srgb,
|
||||
rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2)) 78%,
|
||||
rgba(var(--v-theme-primary), 36%)
|
||||
);
|
||||
--glass-control-prominent-border: color-mix(
|
||||
in srgb,
|
||||
rgba(255, 255, 255, 14%) 78%,
|
||||
rgba(var(--v-theme-primary), 32%)
|
||||
);
|
||||
--glass-highlight: color-mix(in srgb, rgba(255, 255, 255, 14%) 86%, rgba(var(--v-theme-primary), 18%));
|
||||
--glass-highlight: color-mix(
|
||||
in srgb,
|
||||
rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 86%,
|
||||
rgba(var(--v-theme-primary), 18%)
|
||||
);
|
||||
--glass-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 9%),
|
||||
@@ -134,17 +167,17 @@ html[data-theme='glass'] {
|
||||
|
||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||
&[data-glass-appearance='frosted'] {
|
||||
--glass-surface: rgba(255, 255, 255, 8%);
|
||||
--glass-surface-soft: rgba(255, 255, 255, 7%);
|
||||
--glass-surface-raised: rgba(255, 255, 255, 12%);
|
||||
--glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07));
|
||||
--glass-surface-soft: rgba(255, 255, 255, calc(0.11 - var(--glass-transparency, 0.5) * 0.06));
|
||||
--glass-surface-raised: rgba(255, 255, 255, calc(0.16 - var(--glass-transparency, 0.5) * 0.08));
|
||||
--glass-control: rgba(255, 255, 255, 9%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
||||
--glass-border: rgba(255, 255, 255, 15%);
|
||||
--glass-border-raised: rgba(255, 255, 255, 18%);
|
||||
--glass-border-hover: rgba(255, 255, 255, 28%);
|
||||
--glass-border: rgba(255, 255, 255, calc(0.07 + var(--glass-reflection, 0.5) * 0.16));
|
||||
--glass-border-raised: rgba(255, 255, 255, calc(0.08 + var(--glass-reflection, 0.5) * 0.2));
|
||||
--glass-border-hover: rgba(255, 255, 255, calc(0.14 + var(--glass-reflection, 0.5) * 0.28));
|
||||
--glass-control-prominent-border: rgba(255, 255, 255, 18%);
|
||||
--glass-highlight: rgba(255, 255, 255, 22%);
|
||||
--glass-highlight: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.24));
|
||||
--glass-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 7%),
|
||||
@@ -255,11 +288,8 @@ html[data-theme='glass'] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 页面容器不承担玻璃材质,避免内容高度结束处与透明 footer 形成整宽明暗边界。
|
||||
.layout-page-content {
|
||||
background-color: rgba(255, 255, 255, 2.5%);
|
||||
}
|
||||
|
||||
&[data-glass-appearance='frosted'] .layout-page-content {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -350,6 +380,19 @@ html[data-theme='glass'] {
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||
-webkit-backdrop-filter: var(--glass-dashboard-backdrop-filter);
|
||||
backdrop-filter: var(--glass-dashboard-backdrop-filter);
|
||||
box-shadow: var(--glass-dashboard-shadow) !important;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
// Dashboard 悬停沿左上来光增强顶部高光,并在右下背光面保留轻吸收。
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card:hover,
|
||||
.dashboard-grid-item-content
|
||||
> .dashboard-grid-auto-size
|
||||
> .dashboard-grid-content-measure
|
||||
> :first-child
|
||||
> .v-card:hover {
|
||||
box-shadow: var(--glass-dashboard-shadow-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:where(.layout-navbar, .Vue-Toastification__toast) {
|
||||
@@ -532,8 +575,8 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
.v-overlay__scrim {
|
||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
background: rgba(3, 7, 18, 48%);
|
||||
}
|
||||
|
||||
@@ -950,7 +993,6 @@ html[data-theme='glass'] {
|
||||
|
||||
// 光学档位只在 renderer ready 后接管背景采样,加载与失败状态继续使用标准 CSS 材质。
|
||||
.glass-optical-layer {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
@@ -960,9 +1002,13 @@ html[data-theme='glass'] {
|
||||
transition: opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
// 壁纸交叉淡入期间停用折射输出,避免新纹理采样仍在混合的背景。
|
||||
.glass-optical-layer--background-transition {
|
||||
opacity: 0 !important;
|
||||
.glass-optical-layer--fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
// 文档表面与该呈现层共享滚动坐标,canvas backing buffer 仍由 renderer 的质量预算约束。
|
||||
.glass-optical-layer--scroll {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// 登录卡片的壁纸副本与外层背景共享切换时序,避免卡片内部提前显示下一张图片。
|
||||
@@ -980,6 +1026,8 @@ html[data-theme='glass']:is(
|
||||
[data-glass-quality='balanced'],
|
||||
[data-glass-quality='high']
|
||||
)[data-glass-renderer-state='ready'] {
|
||||
--glass-surface-backdrop-filter: none;
|
||||
--glass-raised-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
}
|
||||
|
||||
@@ -987,8 +1035,8 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is(
|
||||
[data-glass-quality='balanced'],
|
||||
[data-glass-quality='high']
|
||||
)[data-glass-renderer-state='ready'] {
|
||||
--glass-navbar-backdrop-filter: blur(18px) saturate(145%);
|
||||
--glass-overlay-blur: 18px;
|
||||
--glass-navbar-backdrop-filter: none;
|
||||
--glass-overlay-blur: 8px;
|
||||
--glass-overlay-saturate: 145%;
|
||||
}
|
||||
|
||||
@@ -1012,7 +1060,7 @@ html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass
|
||||
}
|
||||
}
|
||||
|
||||
// 磨砂材质的散射由 renderer 与有限的局部扩散共同构成。
|
||||
// 磨砂材质的散射由 renderer 统一完成,避免 CSS 再次模糊折射结果。
|
||||
html[data-glass-appearance='frosted']:is(
|
||||
[data-glass-quality='balanced'],
|
||||
[data-glass-quality='high']
|
||||
@@ -1020,14 +1068,14 @@ html[data-glass-appearance='frosted']:is(
|
||||
body[data-theme='glass'] {
|
||||
.layout-vertical-nav::before,
|
||||
.layout-navbar {
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%) !important;
|
||||
backdrop-filter: blur(18px) saturate(145%) !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||
-webkit-backdrop-filter: blur(24px) saturate(150%) !important;
|
||||
backdrop-filter: blur(24px) saturate(150%) !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -59,3 +59,31 @@ describe('commitPreloadedBackgroundRotation', () => {
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preloadBackgroundRotationImages', () => {
|
||||
it('does not let an unused optical texture block the visible wallpaper', async () => {
|
||||
const preload = vi.fn(async (url: string) => url === 'display.jpg')
|
||||
|
||||
await expect(
|
||||
preloadBackgroundRotationImages({
|
||||
displayUrl: 'display.jpg',
|
||||
preload,
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
expect(preload).toHaveBeenCalledOnce()
|
||||
expect(preload).toHaveBeenCalledWith('display.jpg')
|
||||
})
|
||||
|
||||
it('requires both textures when the optical renderer consumes the derived wallpaper', async () => {
|
||||
const preload = vi.fn(async (url: string) => url === 'display.jpg')
|
||||
|
||||
await expect(
|
||||
preloadBackgroundRotationImages({
|
||||
displayUrl: 'display.jpg',
|
||||
opticalUrl: 'optical.jpg',
|
||||
preload,
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
expect(preload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import {
|
||||
canUseGlassWallpaperTexture,
|
||||
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
||||
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassCoverScale,
|
||||
getGlassOpticalDecay,
|
||||
getGlassOpticalBufferSize,
|
||||
getGlassOpticalMaxRefractionPixels,
|
||||
getGlassOpticalMotionEnergy,
|
||||
getGlassOpticalMotionExpansion,
|
||||
getGlassOpticalMotionStrengthScale,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalReflectionStrengthScale,
|
||||
getGlassOpticalRenderProfile,
|
||||
getGlassOpticalTransparency,
|
||||
getGlassOpticalSurfaceTransitionWeights,
|
||||
getGlassOpticalWakeDirection,
|
||||
getGlassOpticalWakeSample,
|
||||
getGlassScrollBufferSize,
|
||||
getGlassWallpaperTransitionProgress,
|
||||
normalizeGlassOpticalRect,
|
||||
normalizeGlassOpticalStrength,
|
||||
reconcileGlassOpticalSurfaceSlots,
|
||||
selectGlassOpticalRects,
|
||||
stepGlassOpticalSpring,
|
||||
type GlassOpticalRect,
|
||||
} from '@/utils/glassOptics'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass optics geometry', () => {
|
||||
it('caps the renderer buffer independently from device pixel ratio', () => {
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 })
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 })
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 931, width: 1440 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 844, width: 390 })
|
||||
expect(getGlassOpticalBufferSize(1920, 1080, false, 'high', 2)).toEqual({ height: 1080, width: 1920 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true, 'high', 3)).toEqual({ height: 1266, width: 585 })
|
||||
expect(getGlassScrollBufferSize(1440, 4200, 'balanced', 2)).toEqual({ height: 3072, width: 1440 })
|
||||
expect(getGlassScrollBufferSize(1440, 4200, 'high', 2)).toEqual({ height: 4096, width: 1920 })
|
||||
})
|
||||
|
||||
it('matches cover cropping on wide and tall images', () => {
|
||||
@@ -22,34 +43,305 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
|
||||
})
|
||||
|
||||
it('maps user strength sliders to accelerated high-range optical response', () => {
|
||||
expect(normalizeGlassOpticalStrength(Number.NaN)).toBe(50)
|
||||
expect(normalizeGlassOpticalStrength(-12)).toBe(0)
|
||||
expect(normalizeGlassOpticalStrength(44.6)).toBe(45)
|
||||
expect(normalizeGlassOpticalStrength(160)).toBe(100)
|
||||
expect(getGlassOpticalMotionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalMotionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalMotionStrengthScale(80)).toBeGreaterThan(2)
|
||||
expect(getGlassOpticalMotionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_MOTION_MAX_SCALE)
|
||||
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
||||
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
|
||||
)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 80)).toBe(9)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 100)).toBe(9)
|
||||
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
|
||||
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
|
||||
expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86)
|
||||
expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96)
|
||||
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan(
|
||||
getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps presets as concrete five-parameter values', () => {
|
||||
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
|
||||
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
|
||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||
|
||||
expect(natural).toEqual({
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 50,
|
||||
translation: 50,
|
||||
transparency: 50,
|
||||
})
|
||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.flow).toBeGreaterThan(glide.flow)
|
||||
expect(getAvailableGlassOpticalPresets('css')).toEqual(['natural'])
|
||||
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
|
||||
})
|
||||
|
||||
it('returns preset copies so previews cannot mutate the shared matrix', () => {
|
||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||
first.translation = 0
|
||||
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
|
||||
})
|
||||
|
||||
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
|
||||
const samples = [0, 250, 750, 1250, 1500].map(elapsed => getGlassWallpaperTransitionProgress(elapsed, 1500))
|
||||
|
||||
expect(samples[0]).toBe(0)
|
||||
expect(samples.at(-1)).toBe(1)
|
||||
expect(samples[2]).toBeGreaterThan(0.5)
|
||||
expect(samples.every((sample, index) => index === 0 || sample > samples[index - 1])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the selected optical quality on every route', () => {
|
||||
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
|
||||
bufferQuality: 'balanced',
|
||||
textureLimit: 2048,
|
||||
contentProtection: false,
|
||||
diffusionSamples: 5,
|
||||
flowField: false,
|
||||
flowHalfLife: 0,
|
||||
maxRefractionPixels: 6,
|
||||
motionDuration: 360,
|
||||
motionHalfLife: 82,
|
||||
pixelRatioCap: 1,
|
||||
pointerImmediateResponse: 0.7,
|
||||
springDamping: 0.9,
|
||||
springFrequency: 24,
|
||||
textureLimit: 3072,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 2,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'auto',
|
||||
trailCount: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('spends high-quality cost on visible detail and content protection', () => {
|
||||
const balanced = getGlassOpticalRenderProfile('balanced', '/dashboard')
|
||||
const high = getGlassOpticalRenderProfile('high', '/dashboard')
|
||||
|
||||
expect(balanced).toMatchObject({
|
||||
contentProtection: false,
|
||||
diffusionSamples: 5,
|
||||
flowField: false,
|
||||
maxRefractionPixels: 6,
|
||||
trailCount: 2,
|
||||
})
|
||||
expect(high).toMatchObject({
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
maxRefractionPixels: 9,
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(high.motionDuration).toBeGreaterThan(balanced.motionDuration)
|
||||
expect(high.pixelRatioCap).toBeGreaterThan(balanced.pixelRatioCap)
|
||||
})
|
||||
|
||||
it('uses refresh-rate independent decay and reaches a deterministic static state', () => {
|
||||
expect(getGlassOpticalDecay(100, 100)).toBeCloseTo(0.5)
|
||||
expect(getGlassOpticalDecay(100, 50) ** 2).toBeCloseTo(getGlassOpticalDecay(100, 100))
|
||||
expect(getGlassOpticalMotionEnergy(0, 420, 90)).toBe(1)
|
||||
expect(getGlassOpticalMotionEnergy(90, 420, 90)).toBeCloseTo(0.5)
|
||||
expect(getGlassOpticalMotionEnergy(400, 420, 90)).toBeLessThan(0.005)
|
||||
expect(getGlassOpticalMotionEnergy(410, 420, 90)).toBeLessThan(getGlassOpticalMotionEnergy(400, 420, 90))
|
||||
expect(getGlassOpticalMotionEnergy(420, 420, 90)).toBe(0)
|
||||
|
||||
const samples = Array.from({ length: 29 }, (_, index) => getGlassOpticalMotionEnergy(index * 15, 420, 90))
|
||||
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps one crest and one trough in the event-relative liquid wake', () => {
|
||||
const samples = Array.from({ length: 161 }, (_, index) => getGlassOpticalWakeSample(-4 + index * 0.05))
|
||||
const extrema = samples
|
||||
.slice(1, -1)
|
||||
.filter(
|
||||
(sample, index) =>
|
||||
(sample > samples[index] && sample > samples[index + 2]) ||
|
||||
(sample < samples[index] && sample < samples[index + 2]),
|
||||
)
|
||||
|
||||
expect(extrema).toHaveLength(2)
|
||||
expect(Math.min(...samples)).toBeLessThan(0)
|
||||
expect(Math.max(...samples)).toBeGreaterThan(0)
|
||||
expect(getGlassOpticalWakeSample(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('locks wake direction until a deliberate turn starts a new impulse', () => {
|
||||
expect(
|
||||
getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: Math.cos(Math.PI / 8), y: Math.sin(Math.PI / 8) }, 0.04, false),
|
||||
).toEqual({ x: 1, y: 0 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.04, false)).toEqual({ x: 0, y: 1 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.002, false)).toEqual({ x: 1, y: 0 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 2 }, 0.04, true)).toEqual({ x: 0, y: 1 })
|
||||
})
|
||||
|
||||
it('uses a frame-rate independent spring with at most one visible overshoot', () => {
|
||||
const profile = getGlassOpticalRenderProfile('high', '/dashboard')
|
||||
const simulate = (deltaMs: number) => {
|
||||
let state = { position: 0, velocity: 0 }
|
||||
const samples: number[] = []
|
||||
|
||||
for (let elapsed = 0; elapsed < profile.motionDuration; elapsed += deltaMs) {
|
||||
state = stepGlassOpticalSpring(state, 1, deltaMs, profile.springFrequency, profile.springDamping)
|
||||
samples.push(state.position)
|
||||
}
|
||||
|
||||
return { samples, state }
|
||||
}
|
||||
const sixtyHertz = simulate(1000 / 60)
|
||||
const oneTwentyHertz = simulate(1000 / 120)
|
||||
const visibleCrossings = sixtyHertz.samples.slice(1).filter((sample, index) => {
|
||||
const previousOffset = sixtyHertz.samples[index] - 1
|
||||
const currentOffset = sample - 1
|
||||
|
||||
return previousOffset * currentOffset < 0 && Math.max(Math.abs(previousOffset), Math.abs(currentOffset)) > 0.002
|
||||
})
|
||||
|
||||
expect(visibleCrossings.length).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...sixtyHertz.samples)).toBeLessThan(1.04)
|
||||
expect(sixtyHertz.state.position).toBeCloseTo(1, 2)
|
||||
expect(sixtyHertz.state.position).toBeCloseTo(oneTwentyHertz.state.position, 3)
|
||||
})
|
||||
|
||||
it('reconciles capped surface slots without reshuffling stable cards', () => {
|
||||
const candidates = Array.from({ length: 10 }, (_, index) => ({
|
||||
key: `card-${index}`,
|
||||
rect: {
|
||||
height: 80,
|
||||
radii: [12, 12, 12, 12] as [number, number, number, number],
|
||||
rank: index + 1,
|
||||
width: 90,
|
||||
x: index * 100,
|
||||
y: 20,
|
||||
},
|
||||
}))
|
||||
const initial = reconcileGlassOpticalSurfaceSlots([], candidates, 8)
|
||||
const onCardA = reconcileGlassOpticalSurfaceSlots(initial, candidates, 8, 'card-8')
|
||||
const throughGap = reconcileGlassOpticalSurfaceSlots(onCardA, candidates, 8, 'card-8')
|
||||
const onCardB = reconcileGlassOpticalSurfaceSlots(throughGap, candidates, 8, 'card-9', 'card-8')
|
||||
|
||||
expect(initial.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-6',
|
||||
'card-7',
|
||||
])
|
||||
expect(onCardA.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-6',
|
||||
'card-8',
|
||||
])
|
||||
expect(throughGap.map(slot => slot.key)).toEqual(onCardA.map(slot => slot.key))
|
||||
expect(onCardB.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-8',
|
||||
'card-9',
|
||||
])
|
||||
expect(onCardB.at(-2)?.role).toBe('outgoing')
|
||||
expect(onCardB.at(-1)?.role).toBe('active')
|
||||
})
|
||||
|
||||
it('crossfades active surface weights monotonically', () => {
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
|
||||
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
||||
})
|
||||
|
||||
it('only uploads browser-readable login wallpapers to WebGL', () => {
|
||||
const documentUrl = 'https://moviepilot.example/login'
|
||||
|
||||
@@ -63,10 +355,10 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('keeps high-value outer surfaces and removes nested repeats', () => {
|
||||
const candidates: GlassOpticalRect[] = [
|
||||
{ height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 },
|
||||
{ height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 },
|
||||
{ height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 },
|
||||
{ height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 },
|
||||
{ height: 900, radii: [0, 0, 0, 0], rank: 2, width: 260, x: 0, y: 0 },
|
||||
{ height: 300, radii: [16, 16, 16, 16], rank: 4, width: 500, x: 300, y: 100 },
|
||||
{ height: 100, radii: [12, 12, 12, 12], rank: 5, width: 200, x: 320, y: 120 },
|
||||
{ height: 120, radii: [12, 12, 12, 12], rank: 1, width: 400, x: 500, y: 700 },
|
||||
]
|
||||
|
||||
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
|
||||
@@ -77,20 +369,20 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('keeps original geometry while using the viewport intersection for visibility', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }],
|
||||
[{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }],
|
||||
320,
|
||||
240,
|
||||
false,
|
||||
)
|
||||
|
||||
expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }])
|
||||
expect(selected).toEqual([{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }])
|
||||
})
|
||||
|
||||
it('budgets partially visible surfaces by their visible pixels', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[
|
||||
{ height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 },
|
||||
{ height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 },
|
||||
{ height: 1000, radii: [20, 20, 20, 20], rank: 1, width: 1000, x: -950, y: -900 },
|
||||
{ height: 120, radii: [16, 16, 16, 16], rank: 2, width: 200, x: 80, y: 80 },
|
||||
],
|
||||
320,
|
||||
240,
|
||||
@@ -102,11 +394,58 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500),
|
||||
).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] })
|
||||
normalizeGlassOpticalRect(
|
||||
{ height: 100, radii: [20, 18, 16, 14], rank: 1, width: 200, x: 100, y: 50 },
|
||||
1000,
|
||||
500,
|
||||
),
|
||||
).toEqual({ radii: [20, 18, 16, 14], rect: [0.1, 0.7, 0.2, 0.2] })
|
||||
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500),
|
||||
).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] })
|
||||
normalizeGlassOpticalRect(
|
||||
{ height: 100, radii: [80, 70, 60, 40], rank: 1, width: 200, x: -20, y: 50 },
|
||||
1000,
|
||||
500,
|
||||
),
|
||||
).toEqual({
|
||||
radii: [80 * (10 / 13), 70 * (10 / 13), 60 * (10 / 13), 40 * (10 / 13)],
|
||||
rect: [-0.02, 0.7, 0.2, 0.2],
|
||||
})
|
||||
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radii: [80, 30, 0, 0], rank: 1, width: 100, x: 0, y: 0 }, 100, 100)
|
||||
.radii,
|
||||
).toEqual([80 * (10 / 11), 30 * (10 / 11), 0, 0])
|
||||
})
|
||||
|
||||
it('keeps visible surfaces without an area-based hard cutoff', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[
|
||||
{ height: 400, radii: [20, 20, 20, 20], rank: 1, width: 700, x: 0, y: 0 },
|
||||
{ height: 400, radii: [20, 20, 20, 20], rank: 2, width: 700, x: 0, y: 400 },
|
||||
],
|
||||
700,
|
||||
800,
|
||||
false,
|
||||
)
|
||||
|
||||
expect(selected).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('prioritizes the surface under the current interaction when the count is capped', () => {
|
||||
const candidates = Array.from({ length: 9 }, (_, index): GlassOpticalRect => ({
|
||||
height: 80,
|
||||
radii: [12, 12, 12, 12],
|
||||
rank: index + 1,
|
||||
width: 100,
|
||||
x: index * 110,
|
||||
y: 20,
|
||||
}))
|
||||
|
||||
const selected = selectGlassOpticalRects(candidates, 1000, 200, false, { x: 930, y: 60 })
|
||||
|
||||
expect(selected).toHaveLength(8)
|
||||
expect(selected).toContain(candidates[8])
|
||||
expect(selected).not.toContain(candidates[7])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,15 @@ interface PreloadedBackgroundRotationOptions {
|
||||
preload: () => Promise<boolean>
|
||||
}
|
||||
|
||||
interface BackgroundRotationImagePreloadOptions {
|
||||
/** 外层背景实际显示的壁纸地址。 */
|
||||
displayUrl: string
|
||||
/** 实时光学 renderer 确实会消费时才提供的同源纹理地址。 */
|
||||
opticalUrl?: string
|
||||
/** 执行单张图片预加载并返回可用状态。 */
|
||||
preload: (url: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
|
||||
*/
|
||||
@@ -18,3 +27,16 @@ export async function commitPreloadedBackgroundRotation(options: PreloadedBackgr
|
||||
options.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载一次轮换真正依赖的壁纸;未启用光学采样时不让派生纹理阻断外层背景切换。
|
||||
*/
|
||||
export async function preloadBackgroundRotationImages(options: BackgroundRotationImagePreloadOptions) {
|
||||
const urls =
|
||||
options.opticalUrl && options.opticalUrl !== options.displayUrl
|
||||
? [options.displayUrl, options.opticalUrl]
|
||||
: [options.displayUrl]
|
||||
const results = await Promise.all(urls.map(url => options.preload(url)))
|
||||
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8
|
||||
export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5
|
||||
export const GLASS_OPTICAL_MOTION_MAX_SCALE = 3.2
|
||||
export const GLASS_OPTICAL_DEFORMATION_MAX_SCALE = 1.55
|
||||
export const GLASS_OPTICAL_FLOW_MAX_SCALE = 1.45
|
||||
export const GLASS_OPTICAL_REFLECTION_MAX_SCALE = 1.8
|
||||
export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7
|
||||
export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50
|
||||
export const GLASS_OPTICAL_STRENGTH_MAX = 100
|
||||
export const GLASS_OPTICAL_STRENGTH_MIN = 0
|
||||
|
||||
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
||||
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
|
||||
export type GlassOpticalQuality = 'balanced' | 'high'
|
||||
export type GlassCornerRadii = [number, number, number, number]
|
||||
|
||||
export interface GlassOpticalParameters {
|
||||
/** 局部非均匀折射与内容弯曲强度。 */
|
||||
deformation: number
|
||||
/** 轨迹范围、尾波、惯性与收敛强度。 */
|
||||
flow: number
|
||||
/** 方向高光、迎光棱镜与背光吸收强度。 */
|
||||
reflection: number
|
||||
/** 共享壁纸采样在表面内的统一坐标平移强度。 */
|
||||
translation: number
|
||||
/** 壁纸可见度与材质遮罩强度。 */
|
||||
transparency: number
|
||||
}
|
||||
|
||||
export interface GlassInteractionPoint {
|
||||
/** 指针或触点相对视口的横坐标。 */
|
||||
x: number
|
||||
/** 指针或触点相对视口的纵坐标。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalRect {
|
||||
/** 元素的实际高度,元素可部分位于视口外。 */
|
||||
height: number
|
||||
/** 元素圆角的 CSS 像素值。 */
|
||||
radius: number
|
||||
/** 按左上、右上、右下、左下排列的 CSS 圆角像素值。 */
|
||||
radii: GlassCornerRadii
|
||||
/** 表面的场景优先级,数值越小越优先。 */
|
||||
rank: number
|
||||
/** 元素的实际宽度,元素可部分位于视口外。 */
|
||||
@@ -23,13 +55,240 @@ export interface GlassOpticalBufferSize {
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalPoint {
|
||||
/** 归一化或视口坐标系中的横向分量。 */
|
||||
x: number
|
||||
/** 归一化或视口坐标系中的纵向分量。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalSpringState {
|
||||
/** 当前跟随位置。 */
|
||||
position: number
|
||||
/** 当前每秒位移速度。 */
|
||||
velocity: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalSurfaceCandidate<TKey> {
|
||||
/** renderer 生命周期内稳定的表面身份。 */
|
||||
key: TKey
|
||||
/** 表面的当前视口几何。 */
|
||||
rect: GlassOpticalRect
|
||||
}
|
||||
|
||||
export interface GlassOpticalSurfaceSlot<TKey> extends GlassOpticalSurfaceCandidate<TKey> {
|
||||
/** 槽位在本次交互中的职责。 */
|
||||
role: 'active' | 'outgoing' | 'stable'
|
||||
}
|
||||
|
||||
export interface GlassOpticalRenderProfile {
|
||||
/** 光学层内部缓冲使用的质量档位。 */
|
||||
bufferQuality: GlassOpticalQuality
|
||||
/** 是否使用额外壁纸采样保护人物和文字等高梯度内容。 */
|
||||
contentProtection: boolean
|
||||
/** 磨砂扩散使用的纹理采样数。 */
|
||||
diffusionSamples: 5 | 9
|
||||
/** 是否使用带时序记忆的液态位移场。 */
|
||||
flowField: boolean
|
||||
/** 时序位移场能量衰减到一半所需的时间。 */
|
||||
flowHalfLife: number
|
||||
/** 动态折射在视口像素空间中的软上限。 */
|
||||
maxRefractionPixels: number
|
||||
/** 输入停止后液态形态收敛到静态所需的时间。 */
|
||||
motionDuration: number
|
||||
/** 主液态反馈能量衰减到一半所需的时间。 */
|
||||
motionHalfLife: number
|
||||
/** 高质量缓冲允许使用的设备像素比上限。 */
|
||||
pixelRatioCap: number
|
||||
/** 新输入立即作用于镜片位置的比例。 */
|
||||
pointerImmediateResponse: number
|
||||
/** 镜片跟随弹簧的阻尼比。 */
|
||||
springDamping: number
|
||||
/** 镜片跟随弹簧的角频率,单位弧度每秒。 */
|
||||
springFrequency: number
|
||||
/** 活动壁纸进入 GPU 前的最长边限制。 */
|
||||
textureLimit: number
|
||||
/** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */
|
||||
textureSource: 'auto' | 'procedural' | 'wallpaper'
|
||||
/** 参与液态方向计算的最近输入采样数量。 */
|
||||
trailCount: number
|
||||
}
|
||||
|
||||
/** 将用户滑杆输入收敛到 renderer 支持的整数范围,非法存量值回落到默认视觉。 */
|
||||
export function normalizeGlassOpticalStrength(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<
|
||||
GlassAppearance,
|
||||
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
|
||||
> = {
|
||||
clear: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 },
|
||||
liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 },
|
||||
liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 },
|
||||
glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 },
|
||||
liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 },
|
||||
glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 },
|
||||
liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 },
|
||||
glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 },
|
||||
liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 },
|
||||
glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 },
|
||||
liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 返回材质、质量与预置共同确定的五个具体参数,调用方可以安全修改返回值。 */
|
||||
export function getGlassOpticalPresetParameters(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalParameters {
|
||||
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
|
||||
}
|
||||
|
||||
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
|
||||
export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability): GlassOpticalPreset[] {
|
||||
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
|
||||
}
|
||||
|
||||
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
if (elapsed <= 0) return 0
|
||||
|
||||
const target = elapsed / duration
|
||||
const sample = (time: number, start: number, end: number) => {
|
||||
const inverse = 1 - time
|
||||
|
||||
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
|
||||
}
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
let parameter = target
|
||||
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
parameter = (lower + upper) * 0.5
|
||||
if (sample(parameter, 0.25, 0.25) < target) lower = parameter
|
||||
else upper = parameter
|
||||
}
|
||||
|
||||
return sample(parameter, 0.1, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 流动强度使用感知曲线:中点保持既有视觉,高区间同时释放更大的形变幅度与空间范围。
|
||||
* 最大值仍受质量档和内容保护共同约束,避免高梯度海报出现无界拉伸。
|
||||
*/
|
||||
export function getGlassOpticalMotionStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_MOTION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 采样平移保持中点等于既有即时位移,并在高区间受控增长。 */
|
||||
export function getGlassOpticalTranslationStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_TRANSLATION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 非均匀形变独立缩放局部折射,最终像素位移仍受质量档软上限限制。 */
|
||||
export function getGlassOpticalDeformationStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_DEFORMATION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 流动维度只延展轨迹、尾波与惯性,中点保持当前时序手感。 */
|
||||
export function getGlassOpticalFlowStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
|
||||
export function getGlassOpticalMotionExpansion(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const highRangeProgress = Math.max(
|
||||
0,
|
||||
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
|
||||
)
|
||||
|
||||
return highRangeProgress ** 1.55
|
||||
}
|
||||
|
||||
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
|
||||
export function getGlassOpticalMaxRefractionPixels(basePixels: number, _value: unknown) {
|
||||
void _value
|
||||
|
||||
return basePixels
|
||||
}
|
||||
|
||||
/** 反射强度使用独立感知曲线,只控制光学亮度,不放大背景位移。 */
|
||||
export function getGlassOpticalReflectionStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */
|
||||
export function getGlassOpticalTransparency(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
if (normalized <= 80) {
|
||||
const progress = normalized / 80
|
||||
|
||||
return 0.28 + 0.58 * progress ** 1.25
|
||||
}
|
||||
|
||||
const highRangeProgress = (normalized - 80) / 20
|
||||
|
||||
return 0.86 + 0.1 * highRangeProgress ** 1.5
|
||||
}
|
||||
|
||||
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
|
||||
@@ -37,13 +296,174 @@ export function getGlassOpticalRenderProfile(
|
||||
quality: GlassOpticalQuality,
|
||||
routeKey: string,
|
||||
): GlassOpticalRenderProfile {
|
||||
const highQuality = quality === 'high'
|
||||
|
||||
return {
|
||||
bufferQuality: quality,
|
||||
textureLimit: quality === 'high' ? 3072 : 2048,
|
||||
contentProtection: highQuality,
|
||||
diffusionSamples: highQuality ? 9 : 5,
|
||||
flowField: highQuality,
|
||||
flowHalfLife: highQuality ? 130 : 0,
|
||||
maxRefractionPixels: highQuality ? 9 : 6,
|
||||
motionDuration: highQuality ? 540 : 360,
|
||||
motionHalfLife: highQuality ? 125 : 82,
|
||||
pixelRatioCap: highQuality ? 1.5 : 1,
|
||||
pointerImmediateResponse: highQuality ? 0.58 : 0.7,
|
||||
springDamping: highQuality ? 0.78 : 0.9,
|
||||
springFrequency: highQuality ? 18 : 24,
|
||||
textureLimit: highQuality ? 4096 : 3072,
|
||||
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
|
||||
trailCount: highQuality ? 4 : 2,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将时间常数转换为与刷新率无关的指数衰减。 */
|
||||
export function getGlassOpticalDecay(halfLife: number, delta: number) {
|
||||
if (halfLife <= 0) return 0
|
||||
if (delta <= 0) return 1
|
||||
|
||||
return 2 ** (-delta / halfLife)
|
||||
}
|
||||
|
||||
/** 输入停止后按时间收敛,尾段平滑归零以恢复事件驱动静止状态。 */
|
||||
export function getGlassOpticalMotionEnergy(elapsed: number, duration: number, halfLife: number) {
|
||||
if (elapsed >= duration) return 0
|
||||
|
||||
const safeElapsed = Math.max(0, elapsed)
|
||||
const tailDuration = Math.max(1, duration * 0.25)
|
||||
const tailProgress = Math.min(1, Math.max(0, (duration - safeElapsed) / tailDuration))
|
||||
const tailTaper = tailProgress * tailProgress * (3 - 2 * tailProgress)
|
||||
|
||||
return getGlassOpticalDecay(halfLife, safeElapsed) * tailTaper
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析阻尼弹簧的精确时间步,避免刷新率变化改变跟随手感。
|
||||
* 欠阻尼参数只允许一次可见回摆,生命周期结束后由 renderer 归零。
|
||||
*/
|
||||
export function stepGlassOpticalSpring(
|
||||
state: GlassOpticalSpringState,
|
||||
target: number,
|
||||
deltaMs: number,
|
||||
frequency: number,
|
||||
damping: number,
|
||||
): GlassOpticalSpringState {
|
||||
const deltaSeconds = Math.min(0.064, Math.max(0, deltaMs / 1000))
|
||||
const safeFrequency = Math.max(0.001, frequency)
|
||||
const safeDamping = Math.max(0, damping)
|
||||
const offset = state.position - target
|
||||
if (deltaSeconds <= 0) return state
|
||||
|
||||
if (safeDamping >= 1) {
|
||||
const decay = Math.exp(-safeFrequency * deltaSeconds)
|
||||
const coefficient = state.velocity + safeFrequency * offset
|
||||
|
||||
return {
|
||||
position: target + (offset + coefficient * deltaSeconds) * decay,
|
||||
velocity: (state.velocity - safeFrequency * coefficient * deltaSeconds) * decay,
|
||||
}
|
||||
}
|
||||
|
||||
const dampedFrequency = safeFrequency * Math.sqrt(1 - safeDamping * safeDamping)
|
||||
const decay = Math.exp(-safeDamping * safeFrequency * deltaSeconds)
|
||||
const angle = dampedFrequency * deltaSeconds
|
||||
const cosine = Math.cos(angle)
|
||||
const sine = Math.sin(angle)
|
||||
const sineCoefficient = (state.velocity + safeDamping * safeFrequency * offset) / dampedFrequency
|
||||
const oscillation = offset * cosine + sineCoefficient * sine
|
||||
|
||||
return {
|
||||
position: target + decay * oscillation,
|
||||
velocity:
|
||||
decay *
|
||||
(-safeDamping * safeFrequency * oscillation -
|
||||
offset * dampedFrequency * sine +
|
||||
sineCoefficient * dampedFrequency * cosine),
|
||||
}
|
||||
}
|
||||
|
||||
/** 单个双相尾波只有一个波峰和一个波谷,不形成连续周期波列。 */
|
||||
export function getGlassOpticalWakeSample(position: number) {
|
||||
return position * Math.exp(-0.5 * position * position)
|
||||
}
|
||||
|
||||
/** 低速噪声和小角度移动沿用既有方向,显著转向才开始新的尾波。 */
|
||||
export function getGlassOpticalWakeDirection(
|
||||
current: GlassOpticalPoint,
|
||||
next: GlassOpticalPoint,
|
||||
speed: number,
|
||||
restart: boolean,
|
||||
): GlassOpticalPoint {
|
||||
const normalize = (point: GlassOpticalPoint) => {
|
||||
const length = Math.hypot(point.x, point.y)
|
||||
|
||||
return length > 0.0001 ? { x: point.x / length, y: point.y / length } : { x: 0, y: -1 }
|
||||
}
|
||||
const currentDirection = normalize(current)
|
||||
const nextDirection = normalize(next)
|
||||
if (restart || Math.hypot(current.x, current.y) <= 0.0001) return nextDirection
|
||||
if (speed < 0.006) return currentDirection
|
||||
|
||||
const directionDot = currentDirection.x * nextDirection.x + currentDirection.y * nextDirection.y
|
||||
|
||||
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
|
||||
}
|
||||
|
||||
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
|
||||
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
|
||||
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
|
||||
const eased = progress * progress * (3 - 2 * progress)
|
||||
|
||||
return {
|
||||
incoming: 0.35 + eased * 0.65,
|
||||
outgoing: 1 - eased,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用仍可见的稳定槽位,并为当前与离场表面保留确定位置。
|
||||
* 候选顺序只用于补充空位,指针移动不会重排已经占用的稳定槽位。
|
||||
*/
|
||||
export function reconcileGlassOpticalSurfaceSlots<TKey>(
|
||||
previous: GlassOpticalSurfaceSlot<TKey>[],
|
||||
candidates: GlassOpticalSurfaceCandidate<TKey>[],
|
||||
maxCount: number,
|
||||
activeKey?: TKey,
|
||||
outgoingKey?: TKey,
|
||||
): GlassOpticalSurfaceSlot<TKey>[] {
|
||||
if (maxCount <= 0) return []
|
||||
|
||||
const candidateByKey = new Map(candidates.map(candidate => [candidate.key, candidate]))
|
||||
const reserved: GlassOpticalSurfaceSlot<TKey>[] = []
|
||||
if (outgoingKey !== undefined && outgoingKey !== activeKey) {
|
||||
const outgoing = candidateByKey.get(outgoingKey)
|
||||
if (outgoing) reserved.push({ ...outgoing, role: 'outgoing' })
|
||||
}
|
||||
if (activeKey !== undefined) {
|
||||
const active = candidateByKey.get(activeKey)
|
||||
if (active) reserved.push({ ...active, role: 'active' })
|
||||
}
|
||||
|
||||
const reservedKeys = new Set(reserved.map(slot => slot.key))
|
||||
const stableCount = Math.max(0, maxCount - reserved.length)
|
||||
const stable: GlassOpticalSurfaceSlot<TKey>[] = []
|
||||
const stableKeys = new Set<TKey>()
|
||||
const appendStable = (key: TKey) => {
|
||||
if (stable.length >= stableCount || reservedKeys.has(key) || stableKeys.has(key)) return
|
||||
|
||||
const candidate = candidateByKey.get(key)
|
||||
if (!candidate) return
|
||||
|
||||
stable.push({ ...candidate, role: 'stable' })
|
||||
stableKeys.add(key)
|
||||
}
|
||||
|
||||
for (const slot of previous) appendStable(slot.key)
|
||||
for (const candidate of candidates) appendStable(candidate.key)
|
||||
|
||||
return [...stable, ...reserved].slice(0, maxCount)
|
||||
}
|
||||
|
||||
/** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */
|
||||
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
|
||||
if (!url || !documentUrl) return false
|
||||
@@ -58,18 +478,20 @@ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): b
|
||||
}
|
||||
}
|
||||
|
||||
/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */
|
||||
/** 按质量档位的像素预算计算内部缓冲,高质量只使用受控的设备像素比增益。 */
|
||||
export function getGlassOpticalBufferSize(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
mobile: boolean,
|
||||
quality: GlassOpticalQuality = 'balanced',
|
||||
devicePixelRatio = 1,
|
||||
): GlassOpticalBufferSize {
|
||||
const safeWidth = Math.max(1, viewportWidth)
|
||||
const safeHeight = Math.max(1, viewportHeight)
|
||||
const highQuality = quality === 'high'
|
||||
const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960
|
||||
const maxHeight = highQuality ? 960 : 720
|
||||
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
|
||||
const safeWidth = Math.max(1, viewportWidth) * pixelRatio
|
||||
const safeHeight = Math.max(1, viewportHeight) * pixelRatio
|
||||
const maxWidth = mobile ? (highQuality ? 1280 : 960) : highQuality ? 1920 : 1440
|
||||
const maxHeight = mobile ? (highQuality ? 1440 : 960) : highQuality ? 1200 : 960
|
||||
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight)
|
||||
|
||||
return {
|
||||
@@ -78,6 +500,24 @@ export function getGlassOpticalBufferSize(
|
||||
}
|
||||
}
|
||||
|
||||
/** 文档空间画布独立限制横纵分辨率,避免长页面按纵横比连带降低横向清晰度。 */
|
||||
export function getGlassScrollBufferSize(
|
||||
presentationWidth: number,
|
||||
presentationHeight: number,
|
||||
quality: GlassOpticalQuality,
|
||||
devicePixelRatio = 1,
|
||||
): GlassOpticalBufferSize {
|
||||
const highQuality = quality === 'high'
|
||||
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
|
||||
const maxWidth = highQuality ? 1920 : 1440
|
||||
const maxHeight = highQuality ? 4096 : 3072
|
||||
|
||||
return {
|
||||
height: Math.max(1, Math.round(Math.min(presentationHeight * pixelRatio, maxHeight))),
|
||||
width: Math.max(1, Math.round(Math.min(presentationWidth * pixelRatio, maxWidth))),
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */
|
||||
export function getGlassCoverScale(
|
||||
viewportWidth: number,
|
||||
@@ -99,10 +539,9 @@ export function selectGlassOpticalRects(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
mobile: boolean,
|
||||
interactionPoint?: GlassInteractionPoint,
|
||||
): GlassOpticalRect[] {
|
||||
const viewportArea = Math.max(1, viewportWidth * viewportHeight)
|
||||
const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||
const maxArea = viewportArea * (mobile ? 0.68 : 0.82)
|
||||
const visible = candidates
|
||||
.map(rect => {
|
||||
const left = Math.max(0, rect.x)
|
||||
@@ -120,10 +559,26 @@ export function selectGlassOpticalRects(
|
||||
}
|
||||
})
|
||||
.filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24)
|
||||
.sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea)
|
||||
.sort((left, right) => {
|
||||
if (interactionPoint) {
|
||||
const leftContainsInteraction =
|
||||
interactionPoint.x >= left.rect.x &&
|
||||
interactionPoint.x <= left.rect.x + left.rect.width &&
|
||||
interactionPoint.y >= left.rect.y &&
|
||||
interactionPoint.y <= left.rect.y + left.rect.height
|
||||
const rightContainsInteraction =
|
||||
interactionPoint.x >= right.rect.x &&
|
||||
interactionPoint.x <= right.rect.x + right.rect.width &&
|
||||
interactionPoint.y >= right.rect.y &&
|
||||
interactionPoint.y <= right.rect.y + right.rect.height
|
||||
|
||||
if (leftContainsInteraction !== rightContainsInteraction) return leftContainsInteraction ? -1 : 1
|
||||
}
|
||||
|
||||
return left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea
|
||||
})
|
||||
|
||||
const selected: typeof visible = []
|
||||
let selectedArea = 0
|
||||
|
||||
for (const candidate of visible) {
|
||||
if (selected.length >= maxCount) break
|
||||
@@ -138,10 +593,7 @@ export function selectGlassOpticalRects(
|
||||
)
|
||||
if (nested) continue
|
||||
|
||||
if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue
|
||||
|
||||
selected.push(candidate)
|
||||
selectedArea += candidate.visibleArea
|
||||
}
|
||||
|
||||
return selected.map(candidate => candidate.rect)
|
||||
@@ -151,9 +603,20 @@ export function selectGlassOpticalRects(
|
||||
export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) {
|
||||
const safeWidth = Math.max(1, viewportWidth)
|
||||
const safeHeight = Math.max(1, viewportHeight)
|
||||
const radii = rect.radii.map(radius => Math.max(0, radius)) as GlassCornerRadii
|
||||
const [topLeft, topRight, bottomRight, bottomLeft] = radii
|
||||
const fitScale = (side: number, radiusSum: number) => (radiusSum > 0 ? Math.max(0, side) / radiusSum : 1)
|
||||
// CSS 会用同一个比例缩小全部圆角,保证任一边的相邻半径之和不超过该边长度。
|
||||
const radiusScale = Math.min(
|
||||
1,
|
||||
fitScale(rect.width, topLeft + topRight),
|
||||
fitScale(rect.width, bottomLeft + bottomRight),
|
||||
fitScale(rect.height, topLeft + bottomLeft),
|
||||
fitScale(rect.height, topRight + bottomRight),
|
||||
)
|
||||
|
||||
return {
|
||||
radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)),
|
||||
radii: radii.map(radius => radius * radiusScale) as GlassCornerRadii,
|
||||
rect: [
|
||||
rect.x / safeWidth,
|
||||
1 - (rect.y + rect.height) / safeHeight,
|
||||
|
||||
Reference in New Issue
Block a user