feat(theme): optimize view transition (#725)

This commit is contained in:
pengzhanbo 2025-10-15 12:34:16 +08:00 committed by GitHub
parent a2d52602d3
commit 1503a20fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 50 deletions

View File

@ -15,6 +15,8 @@ export default defineThemeConfig({
organization: 'pengzhanbo',
},
transition: { appearance: 'circle-clip' },
social: [
{ icon: 'github', link: 'https://github.com/pengzhanbo/vuepress-theme-plume' },
{ icon: 'qq', link: 'https://qm.qq.com/q/FbPPoOIscE' },

View File

@ -686,7 +686,7 @@ interface SidebarItem {
* 或配置过渡动画类型
* @default 'fade'
*/
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip'
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip' | 'blinds-vertical' | 'blinds-horizontal' | 'soft-blur-fade' | 'diamond-reveal'
}
```

View File

@ -691,7 +691,7 @@ interface SidebarItem {
* or configure the transition animation type.
* @default 'fade'
*/
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip'
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip' | 'blinds-vertical' | 'blinds-horizontal' | 'soft-blur-fade' | 'diamond-reveal'
}
```

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import VPSwitch from '@theme/VPSwitch.vue'
import { computed, inject, nextTick, ref, watchPostEffect } from 'vue'
import { enableTransitions, useData } from '../composables/index.js'
import { enableTransitions, resolveTransitionKeyframes, useData } from '../composables/index.js'
const checked = ref(false)
const { theme, isDark } = useData()
@ -15,7 +15,7 @@ const transitionMode = computed(() => {
return typeof options.appearance === 'string' ? options.appearance : 'fade'
})
const toggleAppearance = inject('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
const toggleAppearance = inject('toggle-appearance', async ({ clientX, clientY }: MouseEvent) => {
if (!enableTransitions() || transitionMode.value === false) {
isDark.value = !isDark.value
return
@ -26,45 +26,12 @@ const toggleAppearance = inject('toggle-appearance', async ({ clientX: x, client
await nextTick()
}).ready
const keyframes: PropertyIndexedKeyframes = {}
const mode = transitionMode.value
let duration = 400
if (mode === 'circle-clip') {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)}px at ${x}px ${y}px)`,
]
keyframes.clipPath = isDark.value ? clipPath.reverse() : clipPath
}
else if (mode === 'horizontal-clip') {
const clipPath = [
`inset(0px ${innerWidth}px 0px 0px)`,
`inset(0px 0px 0px 0px)`,
]
keyframes.clipPath = isDark.value ? clipPath.reverse() : clipPath
}
else if (mode === 'vertical-clip') {
const clipPath = [
`inset(0px 0px ${innerHeight}px 0px)`,
`inset(0px 0px 0px 0px)`,
]
keyframes.clipPath = isDark.value ? clipPath.reverse() : clipPath
}
else if (mode === 'skew-clip') {
const clipPath = [
'polygon(0px 0px, 0px 0px, 0px 0px)',
`polygon(0px 0px, ${innerWidth * 2}px 0px, 0px ${innerHeight * 2}px)`,
]
keyframes.clipPath = isDark.value ? clipPath.reverse() : clipPath
}
else {
keyframes.opacity = isDark.value ? [1, 0] : [0, 1]
duration = 300
}
const { keyframes, duration } = resolveTransitionKeyframes(
clientX,
clientY,
transitionMode.value,
isDark.value,
)
document.documentElement.animate(
keyframes,
@ -86,12 +53,7 @@ watchPostEffect(() => {
</script>
<template>
<VPSwitch
class="vp-switch-appearance"
:title="switchTitle"
:aria-checked="checked"
@click="toggleAppearance"
>
<VPSwitch class="vp-switch-appearance" :title="switchTitle" :aria-checked="checked" @click="toggleAppearance">
<span class="vpi-sun sun" />
<span class="vpi-moon moon" />
</VPSwitch>
@ -121,13 +83,23 @@ watchPostEffect(() => {
</style>
<style>
[data-theme] {
will-change: clip-path, filter, opacity;
}
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-group(root) {
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root),
::view-transition-new(root) {
clip-path: none;
mix-blend-mode: normal;
mask: none;
transition: none !important;
animation: none !important;
}

View File

@ -33,4 +33,5 @@ export * from './sidebar-data.js'
export * from './sidebar.js'
export * from './tag-colors.js'
export * from './theme-data.js'
export * from './view-transition.js'
export * from './watermark.js'

View File

@ -0,0 +1,99 @@
import type { TransitionOptions } from '../../shared/index.js'
type TransitionMode = Exclude<TransitionOptions['appearance'], boolean>
interface TransitionResult extends PropertyIndexedKeyframes {
duration?: number
}
type TransitionStrategy = {
[key in NonNullable<TransitionMode>]: (
reverse: (effect: string[]) => string[],
context: { x: number, y: number, isDark: boolean }
) => TransitionResult
}
const strategy: TransitionStrategy = {
// 淡入淡出
'fade': reverse => ({ opacity: reverse(['0', '1']), duration: 300 }),
// 圆形裁剪
'circle-clip': (reverse, { x, y }) => ({
clipPath: reverse([
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)}px at ${x}px ${y}px)`,
]),
duration: 650,
}),
// 横向裁剪
'horizontal-clip': reverse => ({
clipPath: reverse([
`inset(0px ${innerWidth}px 0px 0px)`,
`inset(0px 0px 0px 0px)`,
]),
}),
// 纵向裁剪
'vertical-clip': reverse => ({
clipPath: reverse([
`inset(0px 0px ${innerHeight}px 0px)`,
`inset(0px 0px 0px 0px)`,
]),
}),
// 倾斜裁剪
'skew-clip': reverse => ({
clipPath: reverse([
'polygon(0px 0px, 0px 0px, 0px 0px)',
`polygon(0px 0px, ${innerWidth * 2}px 0px, 0px ${innerHeight * 2}px)`,
]),
}),
// 百叶窗效果 上下展开
'blinds-vertical': reverse => ({
clipPath: reverse([
'inset(50% 0% 50% 0%)',
'inset(0 0 0 0)',
]),
}),
// 百叶窗效果 左右展开
'blinds-horizontal': reverse => ({
clipPath: reverse([
'polygon(50% 0, 50% 100%, 50% 100%, 50% 0)',
'polygon(0 0, 0 100%, 100% 100%, 100% 0)',
]),
}),
// 模糊淡出
'soft-blur-fade': reverse => ({
opacity: reverse(['0', '1']),
filter: reverse(['blur(10px)', 'blur(0px)']),
duration: 380,
}),
// 菱形裁剪展开
'diamond-reveal': reverse => ({
clipPath: reverse([
`polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)`,
`polygon(50% -50%, 150% 50%, 50% 150%, -50% 50%)`,
]),
duration: 500,
}),
}
export function resolveTransitionKeyframes(
x: number,
y: number,
mode: TransitionMode,
isDark: boolean,
): {
keyframes: PropertyIndexedKeyframes
duration: number
} {
if (!mode || !strategy[mode])
mode = 'fade'
const reverse = (effect: string[]): string[] => {
return isDark ? effect.reverse() : effect
}
const { duration = 400, ...keyframes } = strategy[mode](reverse, { x, y, isDark })
return { keyframes, duration }
}

View File

@ -13,5 +13,5 @@ export interface TransitionOptions {
* /
* @default 'fade'
*/
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip'
appearance?: boolean | 'fade' | 'circle-clip' | 'horizontal-clip' | 'vertical-clip' | 'skew-clip' | 'blinds-vertical' | 'blinds-horizontal' | 'soft-blur-fade' | 'diamond-reveal'
}