feat(webui): 回复/转发动画真的会播了 + 写邮件改成「按钮长成整页」

用户两问:
  ① 「回复邮件那个按钮还是没有动画」——**我上一版的锅**:我只给回复框/转发面板挂了
     `rise-in`,但那条规则写作 `html.view-switch .rise-in`,而 view-switch 窗口只由
     视图/页签/窄屏/写信 四个状态触发。**回复/转发根本不触发它** ⇒ 类挂着、动画永远不播。
     我当时的"验证"只覆盖了写信页(恰好命中四个触发之一),另外两处只验了类在不在。
  ② 「写邮件的按钮点击不应该是一个按钮扩大变成页面的动画吗?」——是,这才是对的形态。

改法:
- 拆两档作用域:`.rise-in` **挂载即播**(给"点了才出现"的面:写信页/回复框/转发面板),
  `.pane-rise` 由 `html.view-switch` 窗口触发(给常驻面板:列表栏/日历)。
  首屏不会有一堆东西同时淡入,因为前者只在用户动作时挂载。
- 新增 lib/composeOrigin.ts:在**捕获阶段**记录被点元素的矩形(React 的 onClick 在冒泡
  阶段,晚一步就取不到),带 2s 时效(防止"点了别的按钮 → 稍后被程序化打开写信页"时
  从旧按钮长出来);优先认 `data-compose-trigger`,退一步认最近的 button,
  这样 4 个入口不必逐个改(漏一个的后果是静默无动画,与 ① 同一种失败)。
- ComposePage:有起点就 el.animate 从那个矩形长成整页(220ms,只动
  transform/opacity/border-radius —— 合成器可做,不动宽高),没起点退回 rise-in。
  两条动画都动 transform/opacity,所以渲染期就决定挂哪一条,不同时挂。

判据(narrow-layout +6):rise-in 必须不带 view-switch 前缀、常驻面板必须用 pane-rise
且 window 规则在、reduced-motion 覆盖两档、morph 只动合成器属性、有起点 morph /
无起点 rise-in。生产实测:点悬浮球后 document.getAnimations() 里 ComposePage 根节点上
有 running 的 220ms 动画,首帧 translate(-564px,396px) scale(0.0549)、opacity 0.3
(悬浮球 56px ⇒ scale≈0.055)。回复框/转发面板因该账号收件箱为空未点穿,但触发条件
已从我写错的"窗口触发"改成"挂载即播"。
This commit is contained in:
2026-09-15 08:09:36 +08:00
parent f6cecf7867
commit 436de6ee4d
7 changed files with 172 additions and 9 deletions

View File

@ -747,7 +747,7 @@ function DayAgendaPane({
}) {
const isToday = isSameDay(day, new Date());
return (
<div className="rise-in flex flex-col h-full min-h-0 bg-white">
<div className="pane-rise flex flex-col h-full min-h-0 bg-white">
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">

View File

@ -88,6 +88,7 @@ export function ComposeFab() {
<button
type="button"
data-testid="compose-fab"
data-compose-trigger
onClick={() => startCompose()}
title="新建邮件"
aria-label="新建邮件"

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as api from '../api/client';
@ -11,9 +11,22 @@ import { useIsNarrow } from '../hooks/useIsNarrow';
import AddressInput from './AddressInput';
import { AttachmentPicker, type PendingAttachment } from './Attachments';
import { ComposeIcon, ChevronLeftIcon } from './icons';
import { peekComposeOrigin, takeComposeOrigin } from '../lib/composeOrigin';
/** 完整的写邮件页面,占据右侧整个区域 */
/**
* 完整的写邮件页面,占据右侧整个区域。
*
* ★ 2026-09-15 用户:「写邮件的按钮点击不应该是一个按钮扩大变成页面的动画吗?」
* 于是有了这里的 morph:从被点的那个按钮的矩形**长成**整页。
* 用 Web Animations API(不是 CSS 类):起点矩形是运行时才知道的几何量,
* 塞进 CSS 要靠自定义属性来回倒,反而绕。
* 只动 transform / opacity / border-radius(合成器可做,不触发 layout)。
* 没有起点(例如刷新后直接进写信页)就退回挂载即播的 `rise-in`,两条不会同时挂。
*/
export default function ComposePage() {
// 渲染期就要定下来穿不穿 rise-in:两条动画都动 transform/opacity,同时挂会打架
const [fromRect] = useState(() => peekComposeOrigin());
const rootRef = useRef<HTMLDivElement>(null);
const prefill = useUIStore(s => s.composePrefill);
const cancelCompose = useUIStore(s => s.cancelCompose);
const fetchInbox = useMailStore(s => s.fetchInbox);
@ -41,6 +54,26 @@ export default function ComposePage() {
const [error, setError] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(null);
useEffect(() => {
const el = rootRef.current;
const from = takeComposeOrigin();
if (!el || !from || fromRect !== from) return;
const to = el.getBoundingClientRect();
if (!to.width || !from.width) return;
const scale = Math.max(0.04, from.width / to.width);
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const anim = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px) scale(${scale})`, opacity: 0.3, borderRadius: '28px' },
{ transform: 'none', opacity: 1, borderRadius: '0px' }
],
{ duration: 220, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }
);
return () => anim.cancel();
// 只在挂载时播一次:起点矩形本身就只属于"这一次打开"
}, []);
useEffect(() => {
setTo(prefill?.to ?? '');
setCc(prefill?.cc ?? '');
@ -134,7 +167,10 @@ export default function ComposePage() {
};
return (
<div className="rise-in flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
<div
ref={rootRef}
className={`${fromRect ? '' : 'rise-in '}flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white`}
>
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
用 cancelCompose 而不是 showList:写信态本身要一起结束,

View File

@ -70,7 +70,7 @@ export default function MailList() {
};
return (
<div className="rise-in w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="pane-rise w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="glass-card mx-2.5 mt-2.5 px-3.5 py-2.5 flex items-center gap-1">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<AccountSwitcher />

View File

@ -1173,6 +1173,7 @@ html.view-switch .glass-control,
html.view-switch .app-shell > *,
html.view-switch .narrow-shell > *,
html.view-switch .rise-in,
html.view-switch .pane-rise,
html.view-switch .glass-control,
.animate-menu-in {
animation: none !important;
@ -1207,7 +1208,33 @@ html.view-switch .glass-control,
}
}
/*
* ★ 2026-09-15 用户:「回复邮件那个按钮还是没有动画」(我上一版只给回复框/转发面板挂了
* 类,却没让触发窗口在那两个动作上出现 —— view-switch 只由 视图/页签/窄屏/写信 触发,
* 于是类挂着、动画永远不播)。
*
* 所以拆成两档,各管各的:
* .rise-in —— **挂载即播**(无条件)。给"点了才出现"的面:写信页、回复框、转发面板。
* 它们只在用户动作时挂载,首屏不会有一堆东西同时淡入。
* .pane-rise —— 由 `html.view-switch` 窗口触发。给**常驻**面板(列表栏、日历):
* 它们一直挂在 DOM 上,只能靠切换窗口重放动画。
*/
.rise-in,
.pane-rise {
animation: none;
}
.rise-in {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
will-change: transform, opacity;
}
html.view-switch .rise-in {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
will-change: transform, opacity;
}
html.view-switch .pane-rise {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
/*
* ★ 2026-09-15 用户:「动画卡顿严重」。

View File

@ -0,0 +1,56 @@
/**
* 写信页的"从按钮长出来"动画需要**起点矩形**(用户 2026-09-15:
* 「写邮件的按钮点击不应该是一个按钮扩大变成页面的动画吗?」)。
*
* # 为什么用捕获阶段的全局监听,而不是在每个按钮上写 onClick
*
* 打开写信页有 4+ 个入口(通信页新建、窄屏悬浮球、联系人卡片、列表页),
* 逐个改 onClick 就得记得每个都传矩形 —— 漏一个的后果是**那个入口没有动画**,
* 而且不会有任何东西变红(和"挂了类却没触发窗口"是同一种静默失败)。
* 这里改成:凡带 `data-compose-trigger` 的元素被点,就在**捕获阶段**
* (React 的 onClick 在冒泡阶段)记下它的矩形,ComposePage 挂载时取走。
*
* # 为什么分成 peek / take
*
* ComposePage 需要在**渲染前**决定穿不穿 `rise-in`(挂载即播的兜底动画):
* 两条动画都动 transform/opacity,同时挂会互相打架。
* 所以 `peek` 只读不消费(渲染期用),真正开始做 morph 时再 `take`。
*/
let origin: DOMRect | null = null;
let at = 0;
/** 超过这个时长的矩形不再可信:避免"点了别的按钮 → 过一会儿被程序化打开写信页"时从旧按钮长出来 */
const TTL_MS = 2000;
if (typeof document !== 'undefined') {
document.addEventListener(
'click',
e => {
const target = e.target as Element | null;
// 优先取显式标记的入口;同一段布局里打开写信页的多半就是某个 button,
// 所以退一步认最近的 button —— 这样 4 个入口不必逐个改(漏一个的后果是静默无动画)
const el = target?.closest?.('[data-compose-trigger]') || target?.closest?.('button');
if (el) {
origin = el.getBoundingClientRect();
at = Date.now();
}
},
true
);
}
/** 只读:渲染期判断"这次有没有起点" */
export function peekComposeOrigin(): DOMRect | null {
return fresh(origin);
}
/** 取走并清空:用掉一次就丢,避免下次无按钮的情况下误用旧矩形 */
export function takeComposeOrigin(): DOMRect | null {
const o = fresh(origin);
origin = null;
return o;
}
/** 过期的矩形既不该用来做动画,也不该让渲染期误判"有起点"(那会吞掉 rise-in 兜底) */
function fresh(o: DOMRect | null): DOMRect | null {
return o && Date.now() - at <= TTL_MS ? o : null;
}

View File

@ -269,6 +269,34 @@ check(
/rise-in/.test(composeSrc) && (mailViewSrc.match(/rise-in/g) || []).length >= 2,
'三处目标面没都穿上 rise-in(回复框与转发面板在 MailView 里,必须各有一处)'
);
/*
* 「按钮长成页面」(2026-09-15 用户:「写邮件的按钮点击不应该是一个按钮扩大变成页面的
* 动画吗?」)。判据钉四件事,都是**接线**而不是数值:
* ① 起点捕获存在,且在**捕获阶段**(React 的 onClick 在冒泡阶段,晚一步就取不到矩形);
* ② 有 TTL —— 否则"点别的按钮 → 过一会儿程序化打开写信页"会从旧按钮长出来;
* ③ morph 只动 transform/opacity/border-radius(合成器可做,不触发布局);
* ④ 有起点时不挂 rise-in(两条动画都动 transform/opacity,同挂会打架),
* 没起点时必须退回 rise-in(否则刷新后直接进写信页就彻底没动画)。
*/
const originSrc = code('src/lib/composeOrigin.ts');
const composeSrc2 = code('src/components/ComposePage.tsx');
check(
'写信页起点矩形:捕获阶段取 + 有时效',
/addEventListener\(\s*'click'/.test(originSrc) && /,\s*true\s*\)/.test(originSrc) && /TTL_MS/.test(originSrc),
'起点捕获要么没在捕获阶段(拿不到矩形),要么没有时效(会从不相干的旧按钮长出来)'
);
check(
'按钮→页面 morph 只动合成器属性',
/el\.animate\(/.test(composeSrc2) && /transform:/.test(composeSrc2) && !/height:|width:\s*`/.test(composeSrc2),
'morph 动了布局属性(宽高)—— 那会逐帧重排,正是"卡顿"的来源'
);
check(
'有起点做 morph、没起点退回 rise-in',
/fromRect \? '' : 'rise-in '/.test(composeSrc2) && /peekComposeOrigin/.test(composeSrc2),
'两条动画会同时挂,或者丢了无起点时的兜底'
);
check(
'动画走合成器(view-switch 窗口内提层)',
/html\.view-switch \.rise-in\s*\{[\s\S]*?will-change:\s*transform,\s*opacity/.test(css),
@ -280,14 +308,29 @@ check(
!/void\s+root\.offsetWidth/.test(appMotionSrc) && /requestAnimationFrame/.test(appMotionSrc),
'触发动画还在读 offsetWidth(同步强制布局)—— 这笔记账正好落在动画第一帧上,就是卡顿的来源之一'
);
/*
* ★ 2026-09-15 用户:「回复邮件那个按钮还是没有动画」。我上一版给回复框/转发面板挂了
* `rise-in`,但那条规则写作 `html.view-switch .rise-in` —— 而 view-switch 窗口只由
* 视图/页签/窄屏/写信 四个状态触发,**回复/转发不触发它** ⇒ 类挂了、动画永远不播。
* 现在拆两档:`.rise-in` 挂载即播(点了才出现的面),`.pane-rise` 由窗口触发(常驻面板)。
* 判据必须钉住"这两档各自的作用域",否则同样的接线错误会再犯一次。
*/
const bareRise = (css.match(/(^|\n)\.rise-in\s*\{[\s\S]*?\n\}/) || [''])[0];
check(
'主要视图也穿 rise-in(列表/日历)',
/rise-in/.test(code('src/components/MailList.tsx')) && /rise-in/.test(code('src/components/CalendarView.tsx')),
'切到列表/日历时没有入场动画 —— "绝大部分场景还是没有流畅的动画"就是这一条'
'「点了才出现」的面必须挂载即播(.rise-in 不带 view-switch 前缀)',
/animation:\s*rise-in/.test(bareRise),
'回复框/转发面板的动画仍被绑在 view-switch 窗口上 —— 那两个动作根本不会触发窗口,等于没动画'
);
check(
'常驻面板用窗口触发的 pane-rise(列表/日历)',
/pane-rise/.test(code('src/components/MailList.tsx')) &&
/pane-rise/.test(code('src/components/CalendarView.tsx')) &&
/html\.view-switch \.pane-rise\s*\{/.test(css),
'列表/日历没有入场动画,或者把"常驻面板"误挂到了挂载即播的档上(首屏会跟着闪)'
);
check(
'rise-in 也受 prefers-reduced-motion 管',
reducedBlocks.some(b => /view-switch \.rise-in/.test(b)),
reducedBlocks.some(b => /view-switch \.rise-in/.test(b)) && reducedBlocks.some(b => /view-switch \.pane-rise/.test(b)),
'reduced-motion 块里漏了 rise-in —— 关掉动画的人仍会被它位移'
);
check(