Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 62 additions & 30 deletions openless-all/app/src/components/SavedToast.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
5 changes: 4 additions & 1 deletion openless-all/app/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
10 changes: 3 additions & 7 deletions openless-all/app/src/pages/Style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading