feat(admin): Markdown 编辑器分栏实时预览 + 工具栏

- 左写右预览,react-markdown + remark-gfm(表格/GFM 生效)
- 工具栏一键插入标题/加粗/斜体/代码/链接/列表/引用(按光标选区)
- 预览接 prose-site 排版,编辑页返回链接指向 /posts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-17 16:34:36 +08:00
parent 6334dccf4a
commit eee51ddf0a
4 changed files with 1670 additions and 22 deletions
+1542 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -10,12 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3"
},
+30
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -82,3 +83,32 @@ body {
height: 0;
display: none;
}
/* Markdown 预览排版 — 对齐设计 token */
.prose-site {
--tw-prose-body: var(--ink-2);
--tw-prose-headings: var(--ink);
--tw-prose-links: var(--accent-ink);
--tw-prose-bold: var(--ink);
--tw-prose-counters: var(--ink-3);
--tw-prose-bullets: var(--hairline-strong);
--tw-prose-hr: var(--hairline);
--tw-prose-quotes: var(--ink-2);
--tw-prose-quote-borders: var(--accent);
--tw-prose-code: var(--accent-ink);
--tw-prose-pre-bg: var(--term-bg);
--tw-prose-pre-code: var(--term-ink);
--tw-prose-th-borders: var(--hairline-strong);
--tw-prose-td-borders: var(--hairline);
--tw-prose-captions: var(--ink-3);
}
.prose-site :where(code):not(:where(pre code))::before,
.prose-site :where(code):not(:where(pre code))::after {
content: none;
}
.prose-site :where(code):not(:where(pre code)) {
background: var(--code-bg);
border-radius: 4px;
padding: 2px 6px;
font-weight: 500;
}
+90 -15
View File
@@ -1,12 +1,18 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
createPost,
fetchPost,
updatePost,
type PostForm,
} from '@/api/posts'
ArrowLeft,
Bold,
Code,
Heading2,
Italic,
Link2,
List,
Quote,
} from 'lucide-react'
import { createPost, fetchPost, updatePost, type PostForm } from '@/api/posts'
const EMPTY: PostForm = {
slug: '',
@@ -26,6 +32,7 @@ export default function PostEditPage() {
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const contentRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!id) return
@@ -56,6 +63,44 @@ export default function PostEditPage() {
const set = <K extends keyof PostForm>(key: K, value: PostForm[K]) =>
setForm((f) => ({ ...f, [key]: value }))
// 在光标选区两侧插入标记(加粗/斜体/代码/链接)
const wrap = (before: string, after = before) => {
const ta = contentRef.current
if (!ta) return
const { selectionStart: s, selectionEnd: e, value } = ta
const selected = value.slice(s, e)
const next = value.slice(0, s) + before + selected + after + value.slice(e)
set('content', next)
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(s + before.length, e + before.length)
})
}
// 在当前行首插入前缀(标题/列表/引用)
const prefix = (pre: string) => {
const ta = contentRef.current
if (!ta) return
const { selectionStart: s, value } = ta
const lineStart = value.lastIndexOf('\n', s - 1) + 1
const next = value.slice(0, lineStart) + pre + value.slice(lineStart)
set('content', next)
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(s + pre.length, s + pre.length)
})
}
const tools = [
{ icon: Heading2, label: '标题', fn: () => prefix('## ') },
{ icon: Bold, label: '加粗', fn: () => wrap('**') },
{ icon: Italic, label: '斜体', fn: () => wrap('*') },
{ icon: Code, label: '行内代码', fn: () => wrap('`') },
{ icon: Link2, label: '链接', fn: () => wrap('[', '](https://)') },
{ icon: List, label: '列表', fn: () => prefix('- ') },
{ icon: Quote, label: '引用', fn: () => prefix('> ') },
]
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -66,7 +111,7 @@ export default function PostEditPage() {
} else {
await createPost(form)
}
navigate('/')
navigate('/posts')
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败')
} finally {
@@ -80,7 +125,7 @@ export default function PostEditPage() {
return (
<div>
<Link
to="/"
to="/posts"
className="mb-6 inline-flex items-center gap-1.5 font-mono text-[12.5px] tracking-[0.08em] text-ink-3 transition-colors hover:text-accent-ink"
>
<ArrowLeft className="size-3.5" />
@@ -93,7 +138,7 @@ export default function PostEditPage() {
{loading ? (
<div className="space-y-4">
<div className="h-10 animate-pulse rounded-lg bg-code" />
<div className="h-64 animate-pulse rounded-lg bg-code" />
<div className="h-96 animate-pulse rounded-lg bg-code" />
</div>
) : (
<form onSubmit={onSubmit} className="space-y-5">
@@ -144,18 +189,48 @@ export default function PostEditPage() {
</label>
</div>
<label className="block">
<div>
<span className="mb-1.5 block text-[13px] text-ink-2">
Markdown
Markdown ·
</span>
<div className="overflow-hidden rounded-lg border border-hairline-strong">
{/* 工具栏 */}
<div className="flex items-center gap-0.5 border-b border-hairline bg-ground px-2 py-1.5">
{tools.map((t) => (
<button
key={t.label}
type="button"
title={t.label}
aria-label={t.label}
onClick={t.fn}
className="flex size-8 items-center justify-center rounded-md text-ink-2 transition-colors hover:bg-surface hover:text-accent-ink"
>
<t.icon className="size-[17px]" />
</button>
))}
</div>
{/* 分栏:左写右预览 */}
<div className="grid grid-cols-1 md:grid-cols-2">
<textarea
ref={contentRef}
value={form.content}
onChange={(e) => set('content', e.target.value)}
rows={18}
spellCheck={false}
className={`${inputCls} resize-y font-mono text-[13.5px] leading-relaxed`}
placeholder="# 从这里开始写…"
className="min-h-[440px] resize-y bg-surface p-4 font-mono text-[13.5px] leading-relaxed outline-none placeholder:text-ink-3 md:border-r md:border-hairline"
/>
</label>
<div className="prose prose-site min-h-[440px] max-w-none overflow-auto bg-ground p-4 text-[14px]">
{form.content.trim() ? (
<Markdown remarkPlugins={[remarkGfm]}>
{form.content}
</Markdown>
) : (
<p className="text-ink-3"></p>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-hairline pt-5">
<label className="flex cursor-pointer items-center gap-2.5 text-[14px]">