AI-News/frontend/app/composables/useSharedTransition.ts
2025-12-04 10:04:21 +08:00

268 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Shared Element Transition Composable
* 实现卡片到详情页的共享元素转场动画FLIP 技术)
*/
import { reactive, readonly } from 'vue'
interface TransitionElement {
el: HTMLElement
rect: DOMRect
}
interface TransitionState {
from: TransitionElement | null
to: TransitionElement | null
isAnimating: boolean
slug: string | null
}
const state = reactive<TransitionState>({
from: null,
to: null,
isAnimating: false,
slug: null,
})
// 存储离开页面时的元素位置
const exitPositions = new Map<string, DOMRect>()
// 创建全局遮罩层用于转场
let overlayEl: HTMLElement | null = null
function createOverlay() {
if (overlayEl) return overlayEl
overlayEl = document.createElement('div')
overlayEl.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
`
document.body.appendChild(overlayEl)
return overlayEl
}
function showOverlay() {
const overlay = createOverlay()
requestAnimationFrame(() => {
overlay.style.opacity = '1'
})
}
function hideOverlay() {
if (!overlayEl) return
overlayEl.style.opacity = '0'
setTimeout(() => {
if (overlayEl && overlayEl.parentNode) {
overlayEl.parentNode.removeChild(overlayEl)
overlayEl = null
}
}, 300)
}
export function useSharedTransition() {
/**
* 标记源元素(列表卡片)
*/
function markSource(slug: string, el: HTMLElement) {
if (!el || !slug) return
const rect = el.getBoundingClientRect()
state.from = { el, rect }
state.slug = slug
exitPositions.set(slug, rect)
console.log('📌 标记源元素:', {
slug,
rect: { x: rect.left, y: rect.top, w: rect.width, h: rect.height }
})
}
/**
* 标记目标元素(详情页)
*/
function markTarget(slug: string, el: HTMLElement) {
if (!el || !slug) return
const rect = el.getBoundingClientRect()
state.to = { el, rect }
}
/**
* 执行进入动画(列表 → 详情)
*/
function animateEnter(slug: string, targetEl: HTMLElement) {
if (!targetEl) return
console.log('🎬 animateEnter 被调用:', { slug, targetEl, isAnimating: state.isAnimating })
const fromRect = exitPositions.get(slug)
console.log('📍 源位置信息:', fromRect)
// 显示遮罩层
showOverlay()
if (!fromRect) {
// 没有源位置,使用简单淡入
console.log('⚠️ 没有找到源位置,使用降级动画')
targetEl.style.opacity = '0'
targetEl.style.transform = 'scale(0.9)'
targetEl.style.position = 'relative'
targetEl.style.zIndex = '10000'
requestAnimationFrame(() => {
requestAnimationFrame(() => {
targetEl.style.transition = 'opacity 1s ease, transform 1s ease'
targetEl.style.opacity = '1'
targetEl.style.transform = 'scale(1)'
setTimeout(() => {
targetEl.style.transition = ''
targetEl.style.transform = ''
targetEl.style.opacity = ''
targetEl.style.position = ''
targetEl.style.zIndex = ''
hideOverlay()
}, 1000)
})
})
return
}
state.isAnimating = true
const toRect = targetEl.getBoundingClientRect()
// 计算 FLIP 变换
const scaleX = fromRect.width / toRect.width
const scaleY = fromRect.height / toRect.height
const translateX = fromRect.left - toRect.left
const translateY = fromRect.top - toRect.top
console.log('🎬 共享元素转场开始:', {
slug,
from: { x: fromRect.left, y: fromRect.top, w: fromRect.width, h: fromRect.height },
to: { x: toRect.left, y: toRect.top, w: toRect.width, h: toRect.height },
transform: { translateX, translateY, scaleX, scaleY }
})
// First: 设置初始状态(从源位置)
targetEl.style.position = 'relative'
targetEl.style.zIndex = '10000'
targetEl.style.transformOrigin = 'top left'
targetEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`
targetEl.style.opacity = '0.3'
targetEl.style.willChange = 'transform, opacity'
// Last: 动画到最终位置(使用双重 RAF 确保初始状态被应用)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
targetEl.style.transition = 'transform 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 1.5s ease'
targetEl.style.transform = 'translate(0, 0) scale(1, 1)'
targetEl.style.opacity = '1'
setTimeout(() => {
targetEl.style.transition = ''
targetEl.style.transform = ''
targetEl.style.transformOrigin = ''
targetEl.style.opacity = ''
targetEl.style.willChange = ''
targetEl.style.position = ''
targetEl.style.zIndex = ''
state.isAnimating = false
hideOverlay()
console.log('✅ 共享元素转场完成')
}, 1500)
})
})
}
/**
* 执行退出动画(详情 → 列表)
*/
function animateExit(slug: string, sourceEl: HTMLElement, onComplete?: () => void) {
if (!sourceEl || state.isAnimating) {
onComplete?.()
return
}
const toRect = exitPositions.get(slug)
if (!toRect) {
// 没有目标位置,使用简单淡出
sourceEl.style.transition = 'opacity 0.6s ease, transform 0.6s ease'
sourceEl.style.opacity = '0'
sourceEl.style.transform = 'scale(0.95)'
setTimeout(() => {
onComplete?.()
}, 600)
return
}
state.isAnimating = true
const fromRect = sourceEl.getBoundingClientRect()
// 计算反向 FLIP 变换
const scaleX = toRect.width / fromRect.width
const scaleY = toRect.height / fromRect.height
const translateX = toRect.left - fromRect.left
const translateY = toRect.top - fromRect.top
console.log('🔙 共享元素退出动画开始:', {
slug,
from: { x: fromRect.left, y: fromRect.top, w: fromRect.width, h: fromRect.height },
to: { x: toRect.left, y: toRect.top, w: toRect.width, h: toRect.height },
transform: { translateX, translateY, scaleX, scaleY }
})
// 动画到目标位置(加长到 800ms
sourceEl.style.transformOrigin = 'top left'
sourceEl.style.transition = 'transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.8s ease'
sourceEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`
sourceEl.style.opacity = '0.7'
setTimeout(() => {
state.isAnimating = false
console.log('✅ 共享元素退出动画完成')
onComplete?.()
}, 800)
}
/**
* 清理状态
*/
function cleanup() {
state.from = null
state.to = null
state.slug = null
state.isAnimating = false
}
/**
* 清理过期的位置缓存(保留最近 20 个)
*/
function cleanupOldPositions() {
if (exitPositions.size > 20) {
const keys = Array.from(exitPositions.keys())
const toDelete = keys.slice(0, keys.length - 20)
toDelete.forEach(key => exitPositions.delete(key))
}
}
return {
state: readonly(state),
markSource,
markTarget,
animateEnter,
animateExit,
cleanup,
cleanupOldPositions,
}
}