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

@ -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;
}