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:
@ -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);
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 里以哪个为准不由我们定)',
|
||||
|
||||
Reference in New Issue
Block a user