From ad6cd93b103bcbbc1179ca70299cfa959763177c Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 17:20:09 +0800 Subject: [PATCH 1/5] feat(capsule): center-grow entry + contract-fade exit animation --- openless-all/app/src/components/Capsule.tsx | 65 +++++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 76ce96db..373df648 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -294,6 +294,13 @@ export function Capsule() { const [insertedChars, setInsertedChars] = useState(0); const [message, setMessage] = useState(); const [translation, setTranslation] = useState(false); + // `leaving` 与 `lastVisibleState` 协同实现「退出动画」: + // - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留 + // 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。 + // - 动画结束(~240ms)后再把 leaving 置回 false,组件回到「真正未挂载」分支。 + // - 若期间 state 又切回非 idle(例如用户连按热键),立刻中止 leaving 并恢复显示。 + const [leaving, setLeaving] = useState(false); + const [lastVisibleState, setLastVisibleState] = useState(isTauri ? 'idle' : 'recording'); // Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。 const hostMetrics = getCapsuleHostMetrics(os, translation); @@ -320,6 +327,31 @@ export function Capsule() { }; }, []); + // 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 ~240ms,再卸载。 + // 设计要点: + // 1. 进入非 idle:清掉 leaving,记录最新可见 state; + // 2. 进入 idle 且之前可见:开启 leaving 并启动定时器; + // 3. 期间又被打回非 idle:定时器仍会触发,但 effect 内会读到 state !== idle, + // 所以 cleanup 时直接 clearTimeout,避免错误地把可见状态切到 idle。 + useEffect(() => { + if (state !== 'idle') { + // 立即恢复可见,并取消上一轮可能挂着的离场。 + if (leaving) setLeaving(false); + setLastVisibleState(state); + return undefined; + } + // state === 'idle':判断是不是从可见态过渡过来。 + if (lastVisibleState === 'idle') return undefined; + setLeaving(true); + const timer = setTimeout(() => { + setLeaving(false); + setLastVisibleState('idle'); + }, 240); + return () => clearTimeout(timer); + // 故意只依赖 state —— lastVisibleState / leaving 是内部派生量, + // 把它们加进依赖会让定时器被反复重建。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); const onCancel = () => { void invokeOrMock('cancel_dictation', undefined, () => undefined); @@ -329,10 +361,14 @@ export function Capsule() { void invokeOrMock('stop_dictation', undefined, () => undefined); }; - if (state === 'idle') { + // 真正卸载:state 已是 idle,且不在离场动画中。 + if (state === 'idle' && !leaving) { return
; } + // 离场时用 lastVisibleState 渲染最后一帧内容,避免把 idle 当作 fallback 走到 AudioBars(0)。 + const renderedState: CapsuleState = state === 'idle' ? lastVisibleState : state; + return (
{/* "正在翻译" 徽章 — 嵌套两层: @@ -401,17 +445,26 @@ export function Capsule() {