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" />
|
||||
|
||||
Reference in New Issue
Block a user