From 110d457535bc3b5bae1336494f091b346874dbb1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 08:15:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E5=9B=9E=E5=A4=8D=E7=90=83?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=AE=B9=E5=99=A8=E5=8F=98=E5=BD=A2=20+=20?= =?UTF-8?q?=E6=91=98=E6=8E=89=E9=82=AE=E4=BB=B6=E5=88=97=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E5=85=A5=E5=9C=BA=E5=8A=A8=E7=94=BB=20+=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E8=BF=87=E6=B8=A1=E4=B8=8D=E5=86=8D=E8=A1=A5?= =?UTF-8?q?=E9=97=B4=E9=98=B4=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户连着报了三件(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 → 红)。 --- .../electron/src/components/ComposePage.tsx | 2 + client/electron/src/components/MailList.tsx | 2 +- client/electron/src/components/MailView.tsx | 49 ++++++++++++++++-- client/electron/src/index.css | 15 +++++- client/electron/src/lib/composeOrigin.ts | 13 ++++- client/electron/test/narrow-layout.test.mjs | 51 ++++++++++++++++--- 6 files changed, 119 insertions(+), 13 deletions(-) diff --git a/client/electron/src/components/ComposePage.tsx b/client/electron/src/components/ComposePage.tsx index 3840a61..fbb72b9 100644 --- a/client/electron/src/components/ComposePage.tsx +++ b/client/electron/src/components/ComposePage.tsx @@ -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); diff --git a/client/electron/src/components/MailList.tsx b/client/electron/src/components/MailList.tsx index c21343d..d47f668 100644 --- a/client/electron/src/components/MailList.tsx +++ b/client/electron/src/components/MailList.tsx @@ -70,7 +70,7 @@ export default function MailList() { }; return ( -
+

{isSent ? '发件箱' : '收件箱'}

diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index f67ea9b..f363d52 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -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(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({