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:
@ -747,7 +747,7 @@ function DayAgendaPane({
|
||||
}) {
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div className="rise-in flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="pane-rise flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -88,6 +88,7 @@ export function ComposeFab() {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compose-fab"
|
||||
data-compose-trigger
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
aria-label="新建邮件"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import * as api from '../api/client';
|
||||
@ -11,9 +11,22 @@ import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||||
import { peekComposeOrigin, takeComposeOrigin } from '../lib/composeOrigin';
|
||||
|
||||
/** 完整的写邮件页面,占据右侧整个区域 */
|
||||
/**
|
||||
* 完整的写邮件页面,占据右侧整个区域。
|
||||
*
|
||||
* ★ 2026-09-15 用户:「写邮件的按钮点击不应该是一个按钮扩大变成页面的动画吗?」
|
||||
* 于是有了这里的 morph:从被点的那个按钮的矩形**长成**整页。
|
||||
* 用 Web Animations API(不是 CSS 类):起点矩形是运行时才知道的几何量,
|
||||
* 塞进 CSS 要靠自定义属性来回倒,反而绕。
|
||||
* 只动 transform / opacity / border-radius(合成器可做,不触发 layout)。
|
||||
* 没有起点(例如刷新后直接进写信页)就退回挂载即播的 `rise-in`,两条不会同时挂。
|
||||
*/
|
||||
export default function ComposePage() {
|
||||
// 渲染期就要定下来穿不穿 rise-in:两条动画都动 transform/opacity,同时挂会打架
|
||||
const [fromRect] = useState(() => peekComposeOrigin());
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const prefill = useUIStore(s => s.composePrefill);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
@ -41,6 +54,26 @@ export default function ComposePage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
const from = takeComposeOrigin();
|
||||
if (!el || !from || fromRect !== from) return;
|
||||
const to = el.getBoundingClientRect();
|
||||
if (!to.width || !from.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);
|
||||
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();
|
||||
// 只在挂载时播一次:起点矩形本身就只属于"这一次打开"
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTo(prefill?.to ?? '');
|
||||
setCc(prefill?.cc ?? '');
|
||||
@ -134,7 +167,10 @@ export default function ComposePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rise-in flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`${fromRect ? '' : 'rise-in '}flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white`}
|
||||
>
|
||||
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||||
|
||||
@ -70,7 +70,7 @@ export default function MailList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rise-in w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
|
||||
<div className="pane-rise 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 />
|
||||
|
||||
Reference in New Issue
Block a user