AI-News/frontend/app/composables/useSharedTransition.ts

268 lines
7.3 KiB
TypeScript
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
/**
* 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,
}
}