Merge pull request #505 from Open-Less/fix/saved-toast-position
fix(ui): 统一 SavedToast 弹框位置与进出方向
This commit is contained in:
3 files changed
+69
-38
No files matched your search
@@ -1,56 +1,88 @@
|
||||
// SavedToast.tsx — 控制台卡右上角的"正在保存 / 已保存 / 失败"小 pill。
|
||||
// 父级 scroll wrapper(FloatingShell main 区)已设 position:relative,
|
||||
// 此 pill 用 absolute 锚到右上角,避免在页面顶部撑成一条难看的长横幅。
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
// 弹框入场 / 退场方向 —— 始终"从哪来,回哪去"(同一方向进出)。
|
||||
// 'right':从屏幕右侧滑入、再滑回右侧 —— 页面级 toast(风格包 / 翻译 / 划词…)。
|
||||
// 'top' :从屏幕上方滑入、再滑回上方 —— 设置弹窗内的 toast。
|
||||
export type ToastSlideFrom = 'right' | 'top';
|
||||
|
||||
interface SavedToastProps {
|
||||
saveState: SaveToastState;
|
||||
message: string;
|
||||
/** 覆盖默认 position:absolute、top:16 right:16 偏移。
|
||||
* Style 页传 position:'fixed' 把 toast 锚到视口右上角,编辑器展开后向下滚也能看见;
|
||||
* SettingsModal 用默认 absolute 锚在模态内容右上角。 */
|
||||
offsetStyle?: Pick<CSSProperties, 'top' | 'right' | 'left' | 'bottom' | 'position'>;
|
||||
slideFrom?: ToastSlideFrom;
|
||||
}
|
||||
|
||||
export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) {
|
||||
if (saveState === 'idle') return null;
|
||||
export function SavedToast({ saveState, message, offsetStyle, slideFrom = 'right' }: SavedToastProps) {
|
||||
// 维护内部状态,使通知可以自己倒计时关闭(即使用户父组件的 timer 长于 0.8s)
|
||||
const [internalVisible, setInternalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState !== 'idle') {
|
||||
setInternalVisible(true);
|
||||
// 满足用户要求:弹出后约 0.8 秒自动收回
|
||||
const timer = window.setTimeout(() => setInternalVisible(false), 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
setInternalVisible(false);
|
||||
}, [saveState, message]);
|
||||
|
||||
const failed = saveState === 'failed';
|
||||
|
||||
// 统一停靠右上角 —— 跟「风格市场 / 刷新 / 导入 ZIP」这排页头按钮同区。
|
||||
// position:fixed 锚视口:滑入 / 滑出都贴着屏幕边走,不会在页面里撑出滚动条。
|
||||
// 设置弹窗自行传 offsetStyle 覆盖成 absolute(锚到弹窗内容区右上角)。
|
||||
const style: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
right: 28,
|
||||
...offsetStyle,
|
||||
// 必须高于所有 modal(backdrop zIndex 50);失败 toast 决不能被 modal 盖住,否则用户看不到错因。
|
||||
zIndex: 9999,
|
||||
padding: '5px 12px',
|
||||
zIndex: 99999,
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: failed
|
||||
? '0.5px solid rgba(239,68,68,0.22)'
|
||||
: '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: failed ? 'rgba(239,68,68,0.10)' : 'rgba(37,99,235,0.10)',
|
||||
color: failed ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
|
||||
background: failed ? 'rgba(254,242,242,0.92)' : 'rgba(239,244,255,0.92)',
|
||||
color: failed ? '#dc2626' : '#2563eb',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 4px 12px -4px rgba(15,17,22,0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
boxShadow: failed
|
||||
? '0 4px 12px -8px rgba(239,68,68,.28)'
|
||||
: '0 4px 12px -8px rgba(37,99,235,.26)',
|
||||
backdropFilter: 'blur(12px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(160%)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'ol-toast-pop 0.22s var(--ol-motion-spring)',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
// "从哪来,回哪去":入场起点 == 退场终点,方向由 slideFrom 决定。
|
||||
// 两个分支都写全 x / y —— motion variant 保持完整,避免另一轴落到隐式默认值。
|
||||
const offscreen = slideFrom === 'top'
|
||||
? { opacity: 0, x: 0, y: '-220%' }
|
||||
: { opacity: 0, x: '120%', y: 0 };
|
||||
|
||||
return (
|
||||
<div role={failed ? 'alert' : 'status'} style={style}>
|
||||
{message}
|
||||
<style>{`
|
||||
@keyframes ol-toast-pop {
|
||||
from { opacity: 0; transform: translateY(-6px) scale(.96); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{internalVisible && (
|
||||
<motion.div
|
||||
role={failed ? 'alert' : 'status'}
|
||||
initial={{ ...offscreen, filter: 'blur(8px)' }}
|
||||
animate={{ opacity: 1, x: 0, y: 0, filter: 'blur(0px)' }}
|
||||
exit={{ ...offscreen, filter: 'blur(4px)' }}
|
||||
transition={{ type: 'spring', damping: 20, stiffness: 260 }}
|
||||
style={style}
|
||||
>
|
||||
{failed ? '⚠️' : '✓'} {message}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -204,11 +204,14 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
section 标题都不会跟着内容一起飘。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
|
||||
弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden
|
||||
会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。
|
||||
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
|
||||
<SavedToast
|
||||
saveState={savedToast.state}
|
||||
message={savedToast.message}
|
||||
offsetStyle={{ top: 16, right: 54 }}
|
||||
slideFrom="top"
|
||||
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -536,13 +536,9 @@ export function Style() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 视口锚定(position: fixed)—— 编辑器展开后滚动到下方时仍可见。
|
||||
放在 bottom-right 避免压在「导入 ZIP」按钮上挡文字。 */}
|
||||
<SavedToast
|
||||
saveState={saveState}
|
||||
message={saveMessage}
|
||||
offsetStyle={{ position: 'fixed', bottom: 32, right: 32, top: 'auto' }}
|
||||
/>
|
||||
{/* 控制台卡右上角锚定 —— 与「风格市场 / 刷新 / 导入 ZIP」按钮同区;
|
||||
淡蓝 pill 只闪现 0.8s,不长期遮挡按钮。 */}
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
{marketplaceOpen && (
|
||||
<MarketplaceModal
|
||||
|
||||
Reference in new issue
Block a user