feat: 为所有弹出元素添加关闭过渡动画 + 页面路由切换动画
- SearchBar:关闭时遮罩淡出 + 面板上滑收起 - 图片插入 Popover:关闭时缩放淡出 - 删除确认:window.confirm 替换为自定义毛玻璃模态框(含淡入/淡出) - 页面路由切换:离开时向上淡出,进入时向下淡入(含编辑器返回管理页) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
||||
"Bash(git init *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)"
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git commit -m 'docs: 重写 README 为中文,添加英文版跳转链接 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import styles from './MarkdownToolbar.module.css';
|
||||
|
||||
const TOOLS = [
|
||||
@@ -9,13 +9,32 @@ const TOOLS = [
|
||||
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
|
||||
];
|
||||
|
||||
const CLOSE_DURATION = 160;
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef }) {
|
||||
const [imgPopover, setImgPopover] = useState(false);
|
||||
const [popoverClosing, setPopoverClosing] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState('');
|
||||
const [imgAlt, setImgAlt] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setPopoverClosing(true);
|
||||
setTimeout(() => {
|
||||
setImgPopover(false);
|
||||
setPopoverClosing(false);
|
||||
}, CLOSE_DURATION);
|
||||
}, []);
|
||||
|
||||
const togglePopover = useCallback(() => {
|
||||
if (imgPopover) {
|
||||
closePopover();
|
||||
} else {
|
||||
setImgPopover(true);
|
||||
}
|
||||
}, [imgPopover, closePopover]);
|
||||
|
||||
const insertAt = (html) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
@@ -52,7 +71,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
||||
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
|
||||
setImgUrl('');
|
||||
setImgAlt('');
|
||||
setImgPopover(false);
|
||||
closePopover();
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
@@ -69,7 +88,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
||||
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
|
||||
insertAt(`<img src="${src}" alt="${alt}" />`);
|
||||
setUploading(false);
|
||||
setImgPopover(false);
|
||||
closePopover();
|
||||
e.target.value = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
@@ -94,16 +113,16 @@ export default function MarkdownToolbar({ textareaRef }) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
className={`${styles.tool} ${imgPopover ? styles.toolActive : ''}`}
|
||||
title="插入图片"
|
||||
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }}
|
||||
onMouseDown={(e) => { e.preventDefault(); togglePopover(); }}
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{imgPopover && (
|
||||
<div className={`${styles.popover} glass-card`}>
|
||||
<div className={`${styles.popover} ${popoverClosing ? styles.popoverOut : ''} glass-card`}>
|
||||
<p className={styles.popoverTitle}>插入图片</p>
|
||||
|
||||
<div className={styles.popoverSection}>
|
||||
@@ -162,7 +181,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.popoverClose}
|
||||
onClick={() => setImgPopover(false)}
|
||||
onClick={closePopover}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.toolActive {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
@@ -51,7 +57,17 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.popoverOut {
|
||||
animation: popoverCollapse 160ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
@keyframes popoverCollapse {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-8px) scale(0.96); }
|
||||
}
|
||||
|
||||
.popoverTitle {
|
||||
|
||||
@@ -3,26 +3,46 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import PetalCanvas from '../ui/PetalCanvas';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
export default function AppShell() {
|
||||
const location = useLocation();
|
||||
const prevPath = useRef(location.pathname);
|
||||
const [petalTrigger, setPetalTrigger] = useState(0);
|
||||
|
||||
// Track location key to re-trigger enter animation on every navigation
|
||||
const [displayKey, setDisplayKey] = useState(location.key);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const exitTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevPath.current !== location.pathname) {
|
||||
prevPath.current = location.pathname;
|
||||
setPetalTrigger(t => t + 1);
|
||||
if (prevPath.current === location.pathname) return;
|
||||
prevPath.current = location.pathname;
|
||||
setPetalTrigger(t => t + 1);
|
||||
|
||||
// Play exit on old content, then swap to new
|
||||
setExiting(true);
|
||||
clearTimeout(exitTimer.current);
|
||||
exitTimer.current = setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
}, [location.pathname]);
|
||||
setDisplayKey(location.key);
|
||||
setExiting(false);
|
||||
}, 160);
|
||||
|
||||
return () => clearTimeout(exitTimer.current);
|
||||
}, [location.pathname, location.key]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PetalCanvas trigger={petalTrigger} />
|
||||
<Header />
|
||||
<main className="page-main" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Outlet />
|
||||
<div
|
||||
key={displayKey}
|
||||
className={`${styles.pageContent} ${exiting ? styles.exit : styles.enter}`}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
22
src/components/layout/AppShell.module.css
Normal file
22
src/components/layout/AppShell.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.pageContent {
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.enter {
|
||||
animation: pageEnter 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.exit {
|
||||
animation: pageExit 160ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pageEnter {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pageExit {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
57
src/components/ui/ConfirmModal.jsx
Normal file
57
src/components/ui/ConfirmModal.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import styles from './ConfirmModal.module.css';
|
||||
|
||||
const CLOSE_DURATION = 180;
|
||||
|
||||
export default function ConfirmModal({ open, title, message, confirmLabel = '确认', danger = false, onConfirm, onCancel }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setClosing(false);
|
||||
setVisible(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const triggerClose = useCallback((cb) => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
setClosing(false);
|
||||
cb?.();
|
||||
}, CLOSE_DURATION);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') triggerClose(onCancel); };
|
||||
if (visible) window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [visible, triggerClose, onCancel]);
|
||||
|
||||
if (!visible && !open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : ''}`}
|
||||
onClick={() => triggerClose(onCancel)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.modal} glass-card ${closing ? styles.modalOut : ''}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{title && <h3 className={styles.title}>{title}</h3>}
|
||||
{message && <p className={styles.message}>{message}</p>}
|
||||
<div className={styles.actions}>
|
||||
<button className="btn" onClick={() => triggerClose(onCancel)}>取消</button>
|
||||
<button
|
||||
className={`btn ${danger ? styles.btnDanger : 'btn--primary'}`}
|
||||
onClick={() => triggerClose(onConfirm)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/ui/ConfirmModal.module.css
Normal file
77
src/components/ui/ConfirmModal.module.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
animation: fadeIn 180ms ease both;
|
||||
}
|
||||
|
||||
.overlayOut {
|
||||
animation: fadeOut 180ms ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
animation: modalIn 220ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.modalOut {
|
||||
animation: modalOut 180ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-ink-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
background: #d94f4f;
|
||||
color: #fff;
|
||||
border-color: #d94f4f;
|
||||
}
|
||||
|
||||
.btnDanger:hover {
|
||||
background: #c03e3e;
|
||||
border-color: #c03e3e;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes modalOut {
|
||||
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||
to { opacity: 0; transform: scale(0.94) translateY(8px); }
|
||||
}
|
||||
@@ -5,21 +5,36 @@ import { search } from '../../utils/searchIndex';
|
||||
import { formatDateShort } from '../../utils/dateFormat';
|
||||
import styles from './SearchBar.module.css';
|
||||
|
||||
const CLOSE_DURATION = 180;
|
||||
|
||||
export default function SearchBar({ open, onClose }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const { articles } = useArticles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setClosing(false);
|
||||
setVisible(true);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const triggerClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
setClosing(false);
|
||||
onClose();
|
||||
}, CLOSE_DURATION);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setResults(query.trim() ? search(query, articles) : []);
|
||||
@@ -28,19 +43,26 @@ export default function SearchBar({ open, onClose }) {
|
||||
}, [query, articles]);
|
||||
|
||||
const handleKey = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
if (e.key === 'Escape') triggerClose();
|
||||
}, [triggerClose]);
|
||||
|
||||
const goToArticle = (slug) => {
|
||||
onClose();
|
||||
navigate(`/article/${slug}`);
|
||||
triggerClose();
|
||||
setTimeout(() => navigate(`/article/${slug}`), CLOSE_DURATION);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!visible && !open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose} onKeyDown={handleKey}>
|
||||
<div className={`${styles.panel} glass`} onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : ''}`}
|
||||
onClick={triggerClose}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
<div
|
||||
className={`${styles.panel} ${closing ? styles.panelOut : ''} glass`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.inputRow}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}>
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
|
||||
@@ -53,7 +75,7 @@ export default function SearchBar({ open, onClose }) {
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索文章…"
|
||||
/>
|
||||
<button className={styles.close} onClick={onClose}>✕</button>
|
||||
<button className={styles.close} onClick={triggerClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 100px;
|
||||
animation: fadeIn 150ms ease;
|
||||
animation: fadeIn 150ms ease both;
|
||||
}
|
||||
|
||||
.overlayOut {
|
||||
animation: fadeOut 180ms ease both;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -16,7 +20,21 @@
|
||||
max-width: 560px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.panelOut {
|
||||
animation: slideUp 180ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-12px) scale(0.97); }
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AdminGate from '../components/admin/AdminGate';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
import { useArticles } from '../contexts/ArticleContext';
|
||||
import { formatDateShort } from '../utils/dateFormat';
|
||||
import styles from './AdminPage.module.css';
|
||||
@@ -8,11 +10,7 @@ function AdminContent() {
|
||||
const { articles, deleteArticle } = useArticles();
|
||||
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const handleDelete = (slug, title) => {
|
||||
if (window.confirm(`确认删除「${title}」?此操作不可撤销。`)) {
|
||||
deleteArticle(slug);
|
||||
}
|
||||
};
|
||||
const [pendingDelete, setPendingDelete] = useState(null); // { slug, title }
|
||||
|
||||
return (
|
||||
<div className={`container ${styles.page}`}>
|
||||
@@ -69,7 +67,7 @@ function AdminContent() {
|
||||
<button
|
||||
className="btn btn--ghost"
|
||||
style={{ color: 'var(--color-accent-strong)' }}
|
||||
onClick={() => handleDelete(a.slug, a.title)}
|
||||
onClick={() => setPendingDelete({ slug: a.slug, title: a.title })}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@@ -82,6 +80,16 @@ function AdminContent() {
|
||||
<p className={styles.empty}>还没有文章,<Link to="/admin/new">点击新建</Link></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!pendingDelete}
|
||||
title={`删除「${pendingDelete?.title}」`}
|
||||
message="此操作不可撤销,文章将被永久删除。"
|
||||
confirmLabel="确认删除"
|
||||
danger
|
||||
onConfirm={() => { deleteArticle(pendingDelete.slug); setPendingDelete(null); }}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user