Add open/close transition animations to SearchBar, image popover, delete confirm modal, and page routing

- SearchBar: overlay fade + panel slide-down with spring enter / ease-in exit (200ms)
- MarkdownToolbar image popover: scale+slide with same pattern (180ms)
- ConfirmModal: new component replacing window.confirm, scale+translate animations
- AdminPage: uses ConfirmModal for article deletion
- AppShell: enter-only page transition (key={location.key}) avoids double-load issue

All @keyframes defined locally in each CSS module to ensure reliable scoping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:48:43 +08:00
parent ba385c119a
commit 7f98e191bb
10 changed files with 262 additions and 43 deletions

View File

@@ -6,7 +6,11 @@
"mcp__Claude_in_Chrome__tabs_context_mcp", "mcp__Claude_in_Chrome__tabs_context_mcp",
"Bash(git init *)", "Bash(git init *)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit -m ' *)" "Bash(git commit -m ' *)",
"Bash(git remote *)",
"Bash(git push *)",
"Bash(git commit -m 'docs: 重写 README 为中文,添加英文版跳转链接 *)",
"Bash(git commit -m 'Add open/close transition animations to SearchBar, image popover, delete confirm modal, and page routing *)"
] ]
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useState, useRef, useCallback } from 'react';
import styles from './MarkdownToolbar.module.css'; import styles from './MarkdownToolbar.module.css';
const TOOLS = [ const TOOLS = [
@@ -9,12 +9,30 @@ const TOOLS = [
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` }, { label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
]; ];
const CLOSE_MS = 180;
export default function MarkdownToolbar({ textareaRef }) { export default function MarkdownToolbar({ textareaRef }) {
const [imgPopover, setImgPopover] = useState(false); const [imgPopover, setImgPopover] = useState(false);
const [popoverClosing, setPopoverClosing] = useState(false);
const [imgUrl, setImgUrl] = useState(''); const [imgUrl, setImgUrl] = useState('');
const [imgAlt, setImgAlt] = useState(''); const [imgAlt, setImgAlt] = useState('');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const closeTimer = useRef(null);
const closePopover = useCallback(() => {
setPopoverClosing(true);
clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => {
setImgPopover(false);
setPopoverClosing(false);
}, CLOSE_MS);
}, []);
const togglePopover = useCallback(() => {
if (imgPopover) closePopover();
else { setImgPopover(true); setPopoverClosing(false); }
}, [imgPopover, closePopover]);
const insertAt = (html) => { const insertAt = (html) => {
const ta = textareaRef.current; const ta = textareaRef.current;
@@ -52,7 +70,7 @@ export default function MarkdownToolbar({ textareaRef }) {
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`); insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
setImgUrl(''); setImgUrl('');
setImgAlt(''); setImgAlt('');
setImgPopover(false); closePopover();
}; };
const handleFileChange = (e) => { const handleFileChange = (e) => {
@@ -69,7 +87,7 @@ export default function MarkdownToolbar({ textareaRef }) {
const alt = file.name.replace(/\.[^.]+$/, '') || '图片'; const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
insertAt(`<img src="${src}" alt="${alt}" />`); insertAt(`<img src="${src}" alt="${alt}" />`);
setUploading(false); setUploading(false);
setImgPopover(false); closePopover();
e.target.value = ''; e.target.value = '';
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -94,16 +112,16 @@ export default function MarkdownToolbar({ textareaRef }) {
<button <button
type="button" type="button"
className={styles.tool} className={`${styles.tool} ${imgPopover ? styles.toolActive : ''}`}
title="插入图片" title="插入图片"
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }} onMouseDown={(e) => { e.preventDefault(); togglePopover(); }}
> >
🖼 🖼
</button> </button>
</div> </div>
{imgPopover && ( {imgPopover && (
<div className={`${styles.popover} glass-card`}> <div className={`${styles.popover} ${popoverClosing ? styles.popoverOut : styles.popoverIn} glass-card`}>
<p className={styles.popoverTitle}>插入图片</p> <p className={styles.popoverTitle}>插入图片</p>
<div className={styles.popoverSection}> <div className={styles.popoverSection}>
@@ -162,7 +180,7 @@ export default function MarkdownToolbar({ textareaRef }) {
<button <button
type="button" type="button"
className={styles.popoverClose} className={styles.popoverClose}
onClick={() => setImgPopover(false)} onClick={closePopover}
> >
取消 取消
</button> </button>

View File

@@ -1,3 +1,12 @@
@keyframes popoverIn {
from { opacity: 0; transform: translateY(-6px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes popoverOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(0.96); }
}
.toolbarWrap { .toolbarWrap {
position: relative; position: relative;
} }
@@ -32,6 +41,12 @@
border-color: var(--color-muted); border-color: var(--color-muted);
} }
.toolActive {
background: var(--color-surface-2);
color: var(--color-ink);
border-color: var(--color-muted);
}
.separator { .separator {
width: 1px; width: 1px;
height: 20px; height: 20px;
@@ -51,9 +66,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1); transform-origin: top left;
} }
.popoverIn { animation: popoverIn 220ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
.popoverOut { animation: popoverOut 180ms cubic-bezier(0.4, 0, 0.8, 0) both; }
.popoverTitle { .popoverTitle {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
@@ -93,14 +111,8 @@
width: 100%; width: 100%;
} }
.popoverInput:focus { .popoverInput:focus { border-color: var(--color-accent-strong); }
border-color: var(--color-accent-strong); .popoverInput::placeholder { color: var(--color-muted); font-size: 0.75rem; }
}
.popoverInput::placeholder {
color: var(--color-muted);
font-size: 0.75rem;
}
.divider { .divider {
display: flex; display: flex;
@@ -130,6 +142,4 @@
transition: color var(--transition-fast); transition: color var(--transition-fast);
} }
.popoverClose:hover { .popoverClose:hover { color: var(--color-ink); }
color: var(--color-ink);
}

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import PetalCanvas from '../ui/PetalCanvas'; import PetalCanvas from '../ui/PetalCanvas';
import styles from './AppShell.module.css';
export default function AppShell() { export default function AppShell() {
const location = useLocation(); const location = useLocation();
@@ -22,7 +23,9 @@ export default function AppShell() {
<PetalCanvas trigger={petalTrigger} /> <PetalCanvas trigger={petalTrigger} />
<Header /> <Header />
<main className="page-main" style={{ position: 'relative', zIndex: 1 }}> <main className="page-main" style={{ position: 'relative', zIndex: 1 }}>
<div key={location.key} className={styles.page}>
<Outlet /> <Outlet />
</div>
</main> </main>
<Footer /> <Footer />
</> </>

View File

@@ -0,0 +1,8 @@
@keyframes pageEnter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.page {
animation: pageEnter 240ms cubic-bezier(0.34, 1.2, 0.64, 1) both;
}

View File

@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import styles from './ConfirmModal.module.css';
const CLOSE_MS = 200;
export default function ConfirmModal({ open, title, message, confirmLabel = '确认', danger = false, onConfirm, onCancel }) {
const [phase, setPhase] = useState('closed'); // 'closed' | 'open' | 'closing'
const timer = useRef(null);
useEffect(() => {
if (open && phase === 'closed') setPhase('open');
}, [open, phase]);
const triggerClose = useCallback((cb) => {
setPhase('closing');
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setPhase('closed');
cb?.();
}, CLOSE_MS);
}, []);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape' && phase === 'open') triggerClose(onCancel); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [phase, triggerClose, onCancel]);
if (phase === 'closed') return null;
const closing = phase === 'closing';
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => triggerClose(onCancel)}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn} glass-card`}
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>
);
}

View File

@@ -0,0 +1,68 @@
@keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes overlayOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.93) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes modalOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.93) translateY(10px); }
}
.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);
}
.overlayIn { animation: overlayIn 180ms ease both; }
.overlayOut { animation: overlayOut 200ms ease both; }
.modal {
width: 100%;
max-width: 360px;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.modalIn { animation: modalIn 240ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
.modalOut { animation: modalOut 200ms cubic-bezier(0.4, 0, 0.8, 0) 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;
}

View File

@@ -5,20 +5,40 @@ import { search } from '../../utils/searchIndex';
import { formatDateShort } from '../../utils/dateFormat'; import { formatDateShort } from '../../utils/dateFormat';
import styles from './SearchBar.module.css'; import styles from './SearchBar.module.css';
const CLOSE_MS = 200;
export default function SearchBar({ open, onClose }) { export default function SearchBar({ open, onClose }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [phase, setPhase] = useState('closed'); // 'closed' | 'opening' | 'open' | 'closing'
const inputRef = useRef(null); const inputRef = useRef(null);
const { articles } = useArticles(); const { articles } = useArticles();
const navigate = useNavigate(); const navigate = useNavigate();
const timer = useRef(null);
useEffect(() => { useEffect(() => {
if (open) { if (open && phase === 'closed') {
setQuery(''); setQuery('');
setResults([]); setResults([]);
setTimeout(() => inputRef.current?.focus(), 50); setPhase('open');
setTimeout(() => inputRef.current?.focus(), 30);
} }
}, [open]); }, [open, phase]);
const triggerClose = useCallback(() => {
setPhase('closing');
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setPhase('closed');
onClose();
}, CLOSE_MS);
}, [onClose]);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape' && phase === 'open') triggerClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [phase, triggerClose]);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -27,20 +47,24 @@ export default function SearchBar({ open, onClose }) {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [query, articles]); }, [query, articles]);
const handleKey = useCallback((e) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
const goToArticle = (slug) => { const goToArticle = (slug) => {
onClose(); triggerClose();
navigate(`/article/${slug}`); setTimeout(() => navigate(`/article/${slug}`), CLOSE_MS);
}; };
if (!open) return null; if (phase === 'closed') return null;
const closing = phase === 'closing';
return ( return (
<div className={styles.overlay} onClick={onClose} onKeyDown={handleKey}> <div
<div className={`${styles.panel} glass`} onClick={e => e.stopPropagation()}> className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={triggerClose}
>
<div
className={`${styles.panel} ${closing ? styles.panelOut : styles.panelIn} glass`}
onClick={e => e.stopPropagation()}
>
<div className={styles.inputRow}> <div className={styles.inputRow}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}> <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"/> <circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
@@ -53,7 +77,7 @@ export default function SearchBar({ open, onClose }) {
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
placeholder="搜索文章…" placeholder="搜索文章…"
/> />
<button className={styles.close} onClick={onClose}></button> <button className={styles.close} onClick={triggerClose}></button>
</div> </div>
{results.length > 0 && ( {results.length > 0 && (

View File

@@ -1,3 +1,21 @@
/* ── Keyframes defined locally so they are always resolved ── */
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes panelIn {
from { opacity: 0; transform: translateY(-10px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes panelOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-10px) scale(0.97); }
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -8,17 +26,21 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
padding-top: 100px; padding-top: 100px;
animation: fadeIn 150ms ease;
} }
.overlayIn { animation: overlayIn 180ms ease both; }
.overlayOut { animation: overlayOut 200ms ease both; }
.panel { .panel {
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.panelIn { animation: panelIn 220ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
.panelOut { animation: panelOut 200ms cubic-bezier(0.4, 0, 0.8, 0) both; }
.inputRow { .inputRow {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import AdminGate from '../components/admin/AdminGate'; import AdminGate from '../components/admin/AdminGate';
import ConfirmModal from '../components/ui/ConfirmModal';
import { useArticles } from '../contexts/ArticleContext'; import { useArticles } from '../contexts/ArticleContext';
import { formatDateShort } from '../utils/dateFormat'; import { formatDateShort } from '../utils/dateFormat';
import styles from './AdminPage.module.css'; import styles from './AdminPage.module.css';
@@ -7,12 +9,7 @@ import styles from './AdminPage.module.css';
function AdminContent() { function AdminContent() {
const { articles, deleteArticle } = useArticles(); const { articles, deleteArticle } = useArticles();
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
const [pendingDelete, setPendingDelete] = useState(null);
const handleDelete = (slug, title) => {
if (window.confirm(`确认删除「${title}」?此操作不可撤销。`)) {
deleteArticle(slug);
}
};
return ( return (
<div className={`container ${styles.page}`}> <div className={`container ${styles.page}`}>
@@ -69,7 +66,7 @@ function AdminContent() {
<button <button
className="btn btn--ghost" className="btn btn--ghost"
style={{ color: 'var(--color-accent-strong)' }} style={{ color: 'var(--color-accent-strong)' }}
onClick={() => handleDelete(a.slug, a.title)} onClick={() => setPendingDelete({ slug: a.slug, title: a.title })}
> >
删除 删除
</button> </button>
@@ -82,6 +79,16 @@ function AdminContent() {
<p className={styles.empty}>还没有文章<Link to="/admin/new">点击新建</Link></p> <p className={styles.empty}>还没有文章<Link to="/admin/new">点击新建</Link></p>
)} )}
</div> </div>
<ConfirmModal
open={!!pendingDelete}
title={`删除「${pendingDelete?.title}`}
message="此操作不可撤销,文章将被永久删除。"
confirmLabel="确认删除"
danger
onConfirm={() => { deleteArticle(pendingDelete.slug); setPendingDelete(null); }}
onCancel={() => setPendingDelete(null)}
/>
</div> </div>
); );
} }