fix(webui): 回复球改成容器变形 + 摘掉邮件列表的入场动画 + 全局控件过渡不再补间阴影

用户连着报了三件(2026-09-15):

① 「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」
   上一版我只给了回复框一个 150ms 的 opacity 淡入 —— 观感上等于没有。用户要的形态与
   写信页一致:**球自己长成回复框**。现在蓝球带 data-morph-trigger、onClick 里就地记下
   自己的矩形(currentTarget 最准),回复框挂载时用 useLayoutEffect 在**首帧之前**把
   CSS 淡入关掉并接上 morph(220ms,只动 transform/opacity/border-radius)。

② 「邮件列表会闪」——**我上一版引入的回归**。
   我把 pane-rise 挂到了邮件列表上,而 view-switch 窗口在**选中一封邮件**时也会触发
   (narrowPane 是依赖之一)⇒ 每点一封信整个列表重放一次入场。已摘掉:列表这类
   "选中即变"的面不许挂入场动画,只有日历(真的换视图)保留 pane-rise。
   判据改成反向的"列表必须不动",把这个回归钉死。

③ 「主页点击按钮有明显的卡顿」。
   全局 `button,a,input,textarea,select,[role=button]` 的 transition 里带了一条
   `box-shadow var(--dur-base)` —— 阴影是**绘制**属性,每次 hover/active 都要把元素
   连同阴影覆盖区重绘,且它挂在所有交互元素上。已去掉该补间(阴影瞬切),
   判据钉"全局控件过渡不得含 box-shadow",个别表面仍可自己写。

另补一个真缺口:prefers-reduced-motion 原先只覆盖"窗口触发"那档,**没覆盖"挂载即播"
那档(裸 .rise-in)**,两处 morph 也没走这个开关。现已全部纳入;判据加严到"必须行首
就是 .rise-in"(第一版被 `html.view-switch .rise-in,` 满足掉,变异测试才发现假绿)。

判据合计 84 通过 0 失败(变异都验过:删裸 .rise-in → 红;把 box-shadow 加回 → 红;
列表挂回 pane-rise → 红;useLayoutEffect 换回 useEffect → 红)。
This commit is contained in:
2026-09-15 08:15:22 +08:00
parent 436de6ee4d
commit 110d457535
6 changed files with 119 additions and 13 deletions

View File

@ -58,6 +58,8 @@ export default function ComposePage() {
const el = rootRef.current;
const from = takeComposeOrigin();
if (!el || !from || fromRect !== from) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // 关动画的人不该被位移动到
const to = el.getBoundingClientRect();
if (!to.width || !from.width) return;
const scale = Math.max(0.04, from.width / to.width);

View File

@ -70,7 +70,7 @@ export default function MailList() {
};
return (
<div className="pane-rise w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="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

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useLayoutEffect, useRef } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMailStore } from '../stores/mailStore';
@ -17,6 +17,7 @@ import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { Composer, ComposerChip } from './Composer';
import { useIsNarrow } from '../hooks/useIsNarrow';
import { rememberOrigin, takeComposeOrigin } from '../lib/composeOrigin';
import { useUIStore } from '../stores/uiStore';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
@ -902,8 +903,42 @@ function ReplyBar({
// 默认收起(只留悬浮球),宽窄同一套 —— 理由见 CollapsibleHeader 的说明。
// 跨断点回到默认,否则“自动隐藏”只在首次生效。
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => setOpen(false), [narrow]);
/*
* ★ 2026-09-15 用户:「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」。
*
* 上一版这里只有一个 150ms 的 opacity 淡入 —— 观感上等于没有。用户要的形态与
* 写信页一致:**这个球自己长成回复框**(容器变形)。
*
* 用 useLayoutEffect 而不是 useEffect:要在浏览器**首次绘制之前**把 CSS 那个
* 挂载淡入关掉并接上 morph,否则会先闪一下淡入再跳一次。
* 只动 transform / opacity / border-radius(合成器可做,不触发布局)。
*/
useLayoutEffect(() => {
const el = boxRef.current;
if (!open || !el) return;
const from = takeComposeOrigin();
if (!from || !from.width) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // 关动画的人不该被位移动到
const to = el.getBoundingClientRect();
if (!to.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);
el.style.animation = 'none'; // 让位给 morph:两条动画都动 transform/opacity
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();
}, [open]);
if (!replyTo) return null;
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
@ -925,7 +960,12 @@ function ReplyBar({
<button
type="button"
data-testid="reply-fab"
onClick={() => setOpen(true)}
data-morph-trigger
onClick={e => {
// 起点就在手边:把这个球自己的矩形记下来,回复框挂载时从它长出来
rememberOrigin(e.currentTarget);
setOpen(true);
}}
title={`回复 ${target}`}
aria-label={`回复 ${target}`}
className="absolute bottom-4 right-4 z-20 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center hover:bg-blue-700 active:bg-blue-800 transition-colors"
@ -978,7 +1018,10 @@ function ReplyBar({
};
return (
<div className="rise-in shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 px-4 md:px-6 py-3">
<div
ref={boxRef}
className="rise-in shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 px-4 md:px-6 py-3"
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="text-3xs text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
<div className="flex-1" />

View File

@ -1051,7 +1051,16 @@ html[data-bg='on'] .nav-rail {
border-radius: 0.25rem;
}
/* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。 */
/*
* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。
*
* ★ 2026-09-15 用户:「主页点击按钮有明显的卡顿」。
* 原先这里还带一条 `box-shadow var(--dur-base)` —— **阴影是绘制属性,不是合成器属性**:
* 每次 hover/active 都要把元素连同阴影覆盖区重绘一遍,而且它挂在**所有** button/a/input 上。
* 列表里几十个按钮 + 玻璃卡片背景时,这笔重绘正好落在点击那一帧上,就是"点按钮卡一下"。
* 阴影现在直接跟随状态切换(瞬切),不再补间;观感差别很小,代价差很多。
* 真要动画阴影的个别表面,自己显式写 transition。
*/
button,
a,
input,
@ -1060,7 +1069,7 @@ select,
[role='button'] {
transition: background-color var(--dur-fast) var(--ease-out-soft),
border-color var(--dur-fast) var(--ease-out-soft),
color var(--dur-fast) var(--ease-out-soft), box-shadow var(--dur-base) var(--ease-out-soft);
color var(--dur-fast) var(--ease-out-soft);
}
/* 尊重系统的「减弱动态效果」:前庭功能障碍者会因动画不适。 */
@ -1170,6 +1179,8 @@ html.view-switch .glass-control,
/* 尊重系统设置:关了动画就一点都不动(前庭功能敏感的人会被位移动画伤到) */
@media (prefers-reduced-motion: reduce) {
/* 2026-09-15:`.rise-in`(挂载即播那档)原先漏在这儿 —— 关掉动画的人照样会看到它 */
.rise-in,
html.view-switch .app-shell > *,
html.view-switch .narrow-shell > *,
html.view-switch .rise-in,

View File

@ -28,7 +28,8 @@ if (typeof document !== 'undefined') {
const target = e.target as Element | null;
// 优先取显式标记的入口;同一段布局里打开写信页的多半就是某个 button,
// 所以退一步认最近的 button —— 这样 4 个入口不必逐个改(漏一个的后果是静默无动画)
const el = target?.closest?.('[data-compose-trigger]') || target?.closest?.('button');
const el =
target?.closest?.('[data-compose-trigger], [data-morph-trigger]') || target?.closest?.('button');
if (el) {
origin = el.getBoundingClientRect();
at = Date.now();
@ -38,6 +39,16 @@ if (typeof document !== 'undefined') {
);
}
/**
* 就地记下一个起点(给"点击处已经握着 currentTarget"的场景用,例如回复球:
* 那儿不需要全局监听,onClick 里直接记下自己的矩形最准)。
*/
export function rememberOrigin(el: Element | null): void {
if (!el) return;
origin = el.getBoundingClientRect();
at = Date.now();
}
/** 只读:渲染期判断"这次有没有起点" */
export function peekComposeOrigin(): DOMRect | null {
return fresh(origin);

View File

@ -281,6 +281,26 @@ check(
*/
const originSrc = code('src/lib/composeOrigin.ts');
const composeSrc2 = code('src/components/ComposePage.tsx');
/*
* 回复球也要 morph(2026-09-15 用户:「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」)。
* 上一版只给了它 150ms 淡入 —— 观感上等于没有;要的形态与写信页一致:球自己长成回复框。
* 判据钉:① 球带 morph 起点标记;② 起点就地取(onClick 里 currentTarget 最准);
* ③ 用 useLayoutEffect(首帧前换掉,否则先闪淡入再跳);④ 让位给 morph 时把 CSS 动画关掉
* (两条动画都动 transform/opacity,同挂会打架);⑤ 只动合成器属性。
*/
const mvSrc = code('src/components/MailView.tsx');
check(
'回复球带 morph 起点标记且就地取矩形',
/data-morph-trigger/.test(mvSrc) && /rememberOrigin\(e\.currentTarget\)/.test(mvSrc),
'蓝球没接 morph —— 点了只有一层几乎看不见的淡入'
);
check(
'回复框 morph 在首帧前接管(useLayoutEffect + 关掉 CSS 动画)',
/useLayoutEffect\(\(\) => \{[\s\S]{0,900}?el\.style\.animation = 'none'/.test(mvSrc),
'用 useEffect 或没关 CSS 动画 —— 会先闪一下淡入再跳一次'
);
check(
'写信页起点矩形:捕获阶段取 + 有时效',
/addEventListener\(\s*'click'/.test(originSrc) && /,\s*true\s*\)/.test(originSrc) && /TTL_MS/.test(originSrc),
@ -321,17 +341,36 @@ check(
/animation:\s*rise-in/.test(bareRise),
'回复框/转发面板的动画仍被绑在 view-switch 窗口上 —— 那两个动作根本不会触发窗口,等于没动画'
);
/*
* 点击卡顿(2026-09-15 用户:「主页点击按钮有明显的卡顿」)。
* 阴影是**绘制**属性:把它挂在"所有交互元素"的 transition 上,每次 hover/active 都要
* 重绘元素连同阴影区。判据钉"全局控件过渡不得含 box-shadow",
* 并留出旁路(个别表面自己写 transition 仍可以动画阴影)。
*/
const ctrlRule = (css.match(/button,\s*a,\s*input,\s*textarea,\s*select,\s*\[role='button'\]\s*\{[\s\S]*?\n\}/) || [''])[0];
check(
'常驻面板用窗口触发的 pane-rise(列表/日历)',
/pane-rise/.test(code('src/components/MailList.tsx')) &&
/pane-rise/.test(code('src/components/CalendarView.tsx')) &&
'全局控件过渡不含 box-shadow(绘制属性)',
ctrlRule.length > 0 && !/box-shadow/.test(ctrlRule),
'全局 button/a/input 的 transition 里又出现了 box-shadow —— 每次 hover/active 都重绘阴影区,点击会卡'
);
check(
'日历用窗口触发的 pane-rise,**邮件列表不许挂**',
/pane-rise/.test(code('src/components/CalendarView.tsx')) &&
!/pane-rise/.test(code('src/components/MailList.tsx')) &&
/html\.view-switch \.pane-rise\s*\{/.test(css),
'列表/日历没有入场动画,或者把"常驻面板"误挂到了挂载即播的档上(首屏会跟着闪)'
'邮件列表挂上了入场动画 —— view-switch 窗口在**选中邮件**时也会触发,' +
'于是每点一封信整个列表重播一次,观感就是"邮件列表会闪"(2026-09-15 用户报的就是这个)'
);
check(
'rise-in 也受 prefers-reduced-motion 管',
reducedBlocks.some(b => /view-switch \.rise-in/.test(b)) && reducedBlocks.some(b => /view-switch \.pane-rise/.test(b)),
'reduced-motion 块里漏了 rise-in —— 关掉动画的人仍会被它位移'
// 必须行首就是 .rise-in(否则 `html.view-switch .rise-in,` 会把这个判断满足掉 ——
// 第一版就是这么假绿的,变异测试抓到了)
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.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(
'淡出规则同时带 -webkit- 前缀(Electron 里以哪个为准不由我们定)',