Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 101 additions & 40 deletions src/renderer/src/lib/chatInputHero.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
const HERO_DURATION_MS = 320
const HERO_EASING = 'cubic-bezier(0.22, 0.86, 0.24, 1)'
const HERO_DURATION_MS = 380
const HERO_EASING = 'cubic-bezier(0.16, 1, 0.3, 1)'
const HERO_SETTLE_OFFSET = 0.88
const HERO_SETTLE_SCALE = 1.012
const HERO_TARGET_REVEAL_OFFSET = 0.58
const HERO_OVERLAY_FADE_OFFSET = 0.8
const HERO_CLEANUP_GRACE_MS = 240

type PendingChatInputHeroFlight = {
clone: HTMLElement
sourceElement: HTMLElement
sourceOpacity: string
cleanupTimer: number | null
}

let pendingFlight: PendingChatInputHeroFlight | null = null
Expand All @@ -20,10 +26,22 @@ const clearPendingFlight = () => {
}

pendingFlight.sourceElement.style.opacity = pendingFlight.sourceOpacity
if (pendingFlight.cleanupTimer !== null) {
window.clearTimeout(pendingFlight.cleanupTimer)
}
pendingFlight.clone.remove()
pendingFlight = null
}

const waitForAnimationCompletion = async (animation: Animation, durationMs: number) => {
await Promise.race([
animation.finished.catch(() => undefined),
new Promise<void>((resolve) => {
window.setTimeout(resolve, durationMs + HERO_CLEANUP_GRACE_MS)
})
])
}

const createHeroClone = (sourceElement: HTMLElement, sourceRect: DOMRect) => {
const clone = sourceElement.cloneNode(true) as HTMLElement
const sourceStyle = window.getComputedStyle(sourceElement)
Expand Down Expand Up @@ -72,10 +90,15 @@ export const prepareChatInputHeroFlight = (sourceElement: HTMLElement | null): b
const clone = createHeroClone(sourceElement, sourceRect)
document.body.appendChild(clone)

const cleanupTimer = window.setTimeout(() => {
clearPendingFlight()
}, HERO_DURATION_MS + HERO_CLEANUP_GRACE_MS)

pendingFlight = {
clone,
sourceElement,
sourceOpacity: sourceElement.style.opacity
sourceOpacity: sourceElement.style.opacity,
cleanupTimer
}

sourceElement.style.opacity = '0'
Expand Down Expand Up @@ -105,6 +128,9 @@ export const playChatInputHeroFlight = async (

const flight = pendingFlight
pendingFlight = null
if (flight.cleanupTimer !== null) {
window.clearTimeout(flight.cleanupTimer)
}

const targetRect = targetElement.getBoundingClientRect()
if (targetRect.width === 0 || targetRect.height === 0) {
Expand All @@ -119,48 +145,83 @@ export const playChatInputHeroFlight = async (
const deltaY = targetRect.top - sourceRect.top
const scaleX = targetRect.width / sourceRect.width
const scaleY = targetRect.height / sourceRect.height
const settleTranslateX = deltaX * HERO_SETTLE_OFFSET
const settleTranslateY = deltaY * HERO_SETTLE_OFFSET
const settleScaleX = scaleX + (1 - scaleX) * (1 - HERO_SETTLE_OFFSET) + (HERO_SETTLE_SCALE - 1)
const settleScaleY = scaleY + (1 - scaleY) * (1 - HERO_SETTLE_OFFSET) + (HERO_SETTLE_SCALE - 1)

targetElement.style.opacity = '0'

const overlayAnimation = flight.clone.animate(
[
let overlayAnimation: Animation | null = null
let targetAnimation: Animation | null = null
let animationsCompleted = false

try {
overlayAnimation = flight.clone.animate(
[
{
transform: 'translate3d(0, 0, 0) scale(1, 1)',
borderRadius: flight.clone.style.borderRadius,
opacity: 1,
offset: 0
},
{
transform: `translate3d(${deltaX * HERO_OVERLAY_FADE_OFFSET}px, ${deltaY * HERO_OVERLAY_FADE_OFFSET}px, 0) scale(${1 + (scaleX - 1) * HERO_OVERLAY_FADE_OFFSET}, ${1 + (scaleY - 1) * HERO_OVERLAY_FADE_OFFSET})`,
borderRadius: targetStyle.borderRadius,
opacity: 1,
offset: HERO_OVERLAY_FADE_OFFSET
},
{
transform: `translate3d(${settleTranslateX}px, ${settleTranslateY}px, 0) scale(${settleScaleX}, ${settleScaleY})`,
borderRadius: targetStyle.borderRadius,
opacity: 0.42,
offset: HERO_SETTLE_OFFSET
},
{
transform: `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scaleX}, ${scaleY})`,
borderRadius: targetStyle.borderRadius,
opacity: 0,
offset: 1
}
],
{
transform: 'translate3d(0, 0, 0) scale(1, 1)',
borderRadius: flight.clone.style.borderRadius,
opacity: 1,
offset: 0
},
duration: HERO_DURATION_MS,
easing: HERO_EASING,
fill: 'forwards'
}
)

targetAnimation = targetElement.animate(
[
{ opacity: 0, transform: 'translate3d(0, 10px, 0) scale(0.992)', offset: 0 },
{
opacity: 0,
transform: 'translate3d(0, 10px, 0) scale(0.992)',
offset: HERO_TARGET_REVEAL_OFFSET
},
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', offset: 1 }
],
{
transform: `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scaleX}, ${scaleY})`,
borderRadius: targetStyle.borderRadius,
opacity: 1,
offset: 1
duration: HERO_DURATION_MS,
easing: HERO_EASING,
fill: 'forwards'
}
],
{
duration: HERO_DURATION_MS,
easing: HERO_EASING,
fill: 'forwards'
)

await Promise.all([
waitForAnimationCompletion(overlayAnimation, HERO_DURATION_MS),
waitForAnimationCompletion(targetAnimation, HERO_DURATION_MS)
])
animationsCompleted = true

return true
} finally {
if (!animationsCompleted) {
overlayAnimation?.cancel()
targetAnimation?.cancel()
}
)

const targetAnimation = targetElement.animate(
[
{ opacity: 0, offset: 0 },
{ opacity: 0, offset: 0.6 },
{ opacity: 1, offset: 1 }
],
{
duration: HERO_DURATION_MS,
easing: HERO_EASING,
fill: 'forwards'
}
)

await Promise.allSettled([overlayAnimation.finished, targetAnimation.finished])

targetElement.style.opacity = ''
flight.sourceElement.style.opacity = flight.sourceOpacity
flight.clone.remove()
return true
targetElement.style.opacity = ''
flight.sourceElement.style.opacity = flight.sourceOpacity
flight.clone.remove()
}
}