fix(webui): 窄屏两处 —— 收起补反向 morph、编辑态不再隐藏底部导航
用户:「收起没有动画,且输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」
① 收起没动画:打开有 morph、收起是瞬间卸载(不对称)。补反向 morph:
打开时把球的矩形存进 originRef(球在框打开时不在 DOM 上,收起草稿时取不到),
收起时从当前形状缩回球的位置/大小(160ms),**播完再卸载**(先卸载就没得播)。
finished 被取消时也会 reject,两种情况都落到"收起"。
② 底部导航消失:测量结果是 nav 的 rect = 0,0,0,0,即 **display:none**,不是被挤出去——
根因是 `.form-editing .narrow-nav { display: none }` 撞上"回复框展开即自动聚焦 textarea"
(form-editing 由 main.tsx 在任何输入框获焦时挂上)⇒ 一弹框导航就消失,点到别处才回来。
而这条隐藏已经没必要:viewport 声明了 `interactive-widget=resizes-content`,键盘弹出时
浏览器会重排布局,导航自然落在键盘上方。已删掉该隐藏。
顺带:详情/列表两个窄屏根节点补 min-h-0(flex 子项默认不可压缩,回复框一撑会把导航顶出可视区)。
判据 +4(编辑态不隐藏导航、viewport 声明 resizes-content、两个根节点带 min-h-0、收起有反向
morph 且先播完再卸载)。变异:抽掉 min-h-0 → 红;收起改回瞬间卸载 → 红;把 display:none
放回去 → 红。
★ 一处判据自身的坑:判断"还有没有那条隐藏规则"必须用**剥注释版**——我第一版用原文判,
被我刚写的那段解释性注释(里面原样引用了那条规则)判红,与 read.mjs 里记的同一形态。
窄屏(420x820,真实邮件)实测:初始/开信/回复框弹出并聚焦(form-editing)/收起后 ——
底部导航高度都是 51px 且在视口内;收起时框上有 160ms 动画。
This commit is contained in:
@ -73,7 +73,7 @@ export default function MailView() {
|
|||||||
// relative 是给收起态的回复悬浮球定位用的(见 ReplyBar 的说明):
|
// relative 是给收起态的回复悬浮球定位用的(见 ReplyBar 的说明):
|
||||||
// 宽屏下没有它,球会挂到视口右下角,而不是这块详情栏的右下角。
|
// 宽屏下没有它,球会挂到视口右下角,而不是这块详情栏的右下角。
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 min-w-0 flex flex-col bg-gray-50">
|
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-gray-50">
|
||||||
<CollapsibleHeader
|
<CollapsibleHeader
|
||||||
narrow={narrow}
|
narrow={narrow}
|
||||||
lead={<BackButton label="会话" />}
|
lead={<BackButton label="会话" />}
|
||||||
@ -134,7 +134,7 @@ export default function MailView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 min-w-0 flex flex-col bg-white">
|
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">
|
||||||
<Header
|
<Header
|
||||||
mail={currentMail}
|
mail={currentMail}
|
||||||
onRead={() => markRead(currentMail.mail_id)}
|
onRead={() => markRead(currentMail.mail_id)}
|
||||||
@ -904,6 +904,8 @@ function ReplyBar({
|
|||||||
// 跨断点回到默认,否则“自动隐藏”只在首次生效。
|
// 跨断点回到默认,否则“自动隐藏”只在首次生效。
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** 球的矩形:打开时存下来,收起时用它做反向 morph(球那时不在 DOM 上) */
|
||||||
|
const originRef = useRef<DOMRect | null>(null);
|
||||||
useEffect(() => setOpen(false), [narrow]);
|
useEffect(() => setOpen(false), [narrow]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -939,6 +941,36 @@ function ReplyBar({
|
|||||||
return () => anim.cancel();
|
return () => anim.cancel();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ★ 2026-09-15 用户:「收起没有动画」。打开有 morph、收起是瞬间消失 —— 不对称。
|
||||||
|
* 这里补反向:从当前形状缩回球的位置与大小,播完再卸载(先卸载就没得可播)。
|
||||||
|
*/
|
||||||
|
const collapse = () => {
|
||||||
|
const el = boxRef.current;
|
||||||
|
const from = originRef.current;
|
||||||
|
if (!el || !from || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const to = el.getBoundingClientRect();
|
||||||
|
if (!to.width) {
|
||||||
|
setOpen(false);
|
||||||
|
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: 'none', opacity: 1, borderRadius: '0px' },
|
||||||
|
{ transform: `translate(${dx}px, ${dy}px) scale(${scale})`, opacity: 0.15, borderRadius: '28px' }
|
||||||
|
],
|
||||||
|
{ duration: 160, easing: 'cubic-bezier(0.4, 0, 0.6, 1)' }
|
||||||
|
);
|
||||||
|
// finished 在被取消时会 reject(组件卸载等),两种都得落到"收起"
|
||||||
|
anim.finished.then(() => setOpen(false)).catch(() => setOpen(false));
|
||||||
|
};
|
||||||
|
|
||||||
if (!replyTo) return null;
|
if (!replyTo) return null;
|
||||||
|
|
||||||
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
||||||
@ -962,7 +994,9 @@ function ReplyBar({
|
|||||||
data-testid="reply-fab"
|
data-testid="reply-fab"
|
||||||
data-morph-trigger
|
data-morph-trigger
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
// 起点就在手边:把这个球自己的矩形记下来,回复框挂载时从它长出来
|
// 起点就在手边:把这个球自己的矩形记下来 —— 打开时用它做 morph,
|
||||||
|
// 收起时还要用它把框"缩回球里"(那时球已经不在 DOM 上,取不到)
|
||||||
|
originRef.current = e.currentTarget.getBoundingClientRect();
|
||||||
rememberOrigin(e.currentTarget);
|
rememberOrigin(e.currentTarget);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
@ -1040,7 +1074,7 @@ function ReplyBar({
|
|||||||
{ccOpen ? '收起抄送' : '抄送'}
|
{ccOpen ? '收起抄送' : '抄送'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={collapse}
|
||||||
className="tap inline-flex items-center gap-0.5 text-3xs text-gray-500 hover:text-blue-600"
|
className="tap inline-flex items-center gap-0.5 text-3xs text-gray-500 hover:text-blue-600"
|
||||||
aria-label="收起回复框"
|
aria-label="收起回复框"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -583,9 +583,18 @@
|
|||||||
padding-left: env(safe-area-inset-left);
|
padding-left: env(safe-area-inset-left);
|
||||||
padding-right: env(safe-area-inset-right);
|
padding-right: env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
.form-editing .narrow-nav {
|
/*
|
||||||
display: none;
|
* ★ 2026-09-15 用户:「输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」。
|
||||||
}
|
*
|
||||||
|
* 原先这里是 `.form-editing .narrow-nav { display: none }` —— `form-editing` 由
|
||||||
|
* main.tsx 在**任何输入框获得焦点**时挂上,而回复框一展开就会自动聚焦它的 textarea
|
||||||
|
* ⇒ 导航立刻消失;只有点到别处(失焦)才回来。
|
||||||
|
*
|
||||||
|
* 但它已经没必要了:viewport 里声明了 `interactive-widget=resizes-content`,
|
||||||
|
* 键盘弹出时浏览器会重排布局(导航自然落在键盘上方),不需要靠隐藏来腾地方。
|
||||||
|
* 而"弹个回复框导航就没了、还要再点一下才回来"是纯损失。
|
||||||
|
*/
|
||||||
|
/* 编辑态不再隐藏底部导航:见上 */
|
||||||
.tap {
|
.tap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -290,6 +290,46 @@ const composeSrc2 = code('src/components/ComposePage.tsx');
|
|||||||
* (两条动画都动 transform/opacity,同挂会打架);⑤ 只动合成器属性。
|
* (两条动画都动 transform/opacity,同挂会打架);⑤ 只动合成器属性。
|
||||||
*/
|
*/
|
||||||
const mvSrc = code('src/components/MailView.tsx');
|
const mvSrc = code('src/components/MailView.tsx');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 2026-09-15 用户两条窄屏反馈,各钉一条接线:
|
||||||
|
* ① 「输入框弹出后,底部导航栏会消失」—— 详情/列表根节点缺 min-h-0(flex 子项默认
|
||||||
|
* min-height:auto ⇒ 压不下去),回复框一撑就把底部导航顶出可视区。导航自己有
|
||||||
|
* shrink-0,所以问题一定在**它上面那层能不能被压缩**。
|
||||||
|
* ② 「收起没有动画」—— 打开有 morph、收起瞬间消失。收起必须先反向播完再卸载。
|
||||||
|
*/
|
||||||
|
const mvSrc2 = code('src/components/MailView.tsx');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 2026-09-15 用户:「输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」。
|
||||||
|
* 根因是 `.form-editing .narrow-nav { display: none }` 撞上"回复框展开即聚焦输入框"。
|
||||||
|
* 判据钉住:窄屏导航不得因编辑态被隐藏(键盘由 interactive-widget=resizes-content 处理)。
|
||||||
|
*/
|
||||||
|
check(
|
||||||
|
'编辑态不隐藏底部导航',
|
||||||
|
// 判"代码里还有没有这条规则"必须用**剥注释**版:解释性注释里会写到这条规则的原样
|
||||||
|
// (我第一版用原文判,被自己刚写的那段注释判红了 —— 与 read.mjs 里记的同一形态)
|
||||||
|
!/\.form-editing\s+\.narrow-nav\s*\{[^}]*display:\s*none/.test(code('src/index.css')),
|
||||||
|
'又出现"编辑态隐藏底部导航"——回复框一展开导航就消失,还得再点一下才回来'
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'viewport 声明 resizes-content(键盘靠重排让位,不靠隐藏)',
|
||||||
|
/interactive-widget=resizes-content/.test(prose('index.html')),
|
||||||
|
'index.html 里没有 interactive-widget=resizes-content —— 键盘会盖住底部内容,才逼着去隐藏导航'
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'窄屏可压缩:详情/列表根节点带 min-h-0',
|
||||||
|
(mvSrc2.match(/relative flex-1 min-w-0 min-h-0 flex flex-col/g) || []).length >= 2,
|
||||||
|
'根节点缺 min-h-0 —— 回复框弹出会把底部导航栏顶出可视区("点了才又出现")'
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'收起有反向 morph(先播完再卸载)',
|
||||||
|
/const collapse = \(\) => \{/.test(mvSrc2) && /anim\.finished\.then\(\(\) => setOpen\(false\)\)/.test(mvSrc2) &&
|
||||||
|
/onClick=\{collapse\}/.test(mvSrc2),
|
||||||
|
'收起仍是瞬间卸载 —— 打开有动画、收起没动画,不对称'
|
||||||
|
);
|
||||||
|
|
||||||
check(
|
check(
|
||||||
'回复球带 morph 起点标记且就地取矩形',
|
'回复球带 morph 起点标记且就地取矩形',
|
||||||
/data-morph-trigger/.test(mvSrc) && /rememberOrigin\(e\.currentTarget\)/.test(mvSrc),
|
/data-morph-trigger/.test(mvSrc) && /rememberOrigin\(e\.currentTarget\)/.test(mvSrc),
|
||||||
@ -542,7 +582,9 @@ check(
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'回复悬浮球锁定在**详情栏**内(两个根节点带 relative)',
|
'回复悬浮球锁定在**详情栏**内(两个根节点带 relative)',
|
||||||
(mv.match(/className="relative flex-1 min-w-0 flex flex-col bg-/g) || []).length === 2,
|
// 2026-09-15:这两个根节点后来又补了 min-h-0(窄屏可压缩),类名顺序变了 ——
|
||||||
|
// 判据钉的是"两个根节点、都带 relative",不该被中间多出来的一个类名判红。
|
||||||
|
(mv.match(/className="relative flex-1 min-w-0[^"]*flex flex-col bg-/g) || []).length === 2,
|
||||||
'少了 relative:宽屏下 absolute 会一路找到视口,球挂到窗口右下角而不是详情栏'
|
'少了 relative:宽屏下 absolute 会一路找到视口,球挂到窗口右下角而不是详情栏'
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
Reference in New Issue
Block a user