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:
Generated
+1542
-2
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<textarea
|
||||
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`}
|
||||
/>
|
||||
</label>
|
||||
<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)}
|
||||
spellCheck={false}
|
||||
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"
|
||||
/>
|
||||
<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]">
|
||||
|
||||
Reference in New Issue
Block a user