为了实现复杂的文档编辑功能,支持 Markdown 格式以及插入图片等功能,我们可以使用一些现有的开源编辑器组件,比如 TipTap 或 ProseMirror。这里我们将使用 TipTap,因为它基于 ProseMirror,但提供了更友好的 API 和更多的现成扩展。
以下是如何将这些功能集成到我们的 Blog 系统中:
- 首先,安装必要的依赖:
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder marked- 创建一个新的 React 组件
src/components/Editor.tsx用于富文本编辑:
import React from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
interface EditorProps {
onChange: (content: string) => void;
initialContent?: string;
}
const Editor: React.FC<EditorProps> = ({ onChange, initialContent = '' }) => {
const editor = useEditor({
extensions: [
StarterKit,
Image,
Link,
Placeholder.configure({
placeholder: 'Write your blog post here...',
}),
],
content: initialContent,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
if (!editor) {
return null;
}
return (
<div>
<div>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
<button onClick={() => editor.chain().focus().toggleBulletList().run()}>
Bullet List
</button>
<button
onClick={() => {
const url = window.prompt('Enter the URL of the image:');
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
>
Insert Image
</button>
<button
onClick={() => {
const url = window.prompt('Enter the URL:');
if (url) {
editor.chain().focus().toggleLink({ href: url }).run();
}
}}
>
Add Link
</button>
</div>
<EditorContent editor={editor} />
</div>
);
};
export default Editor;- 更新
src/components/Blog.tsx以使用新的 Editor 组件:
import React, { useState } from 'react';
import Editor from './Editor';
import { marked } from 'marked';
interface Post {
id: number;
title: string;
content: string;
createdAt: number;
}
interface BlogProps {
posts: Post[];
}
export const Blog: React.FC<BlogProps> = ({ posts }) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 使用 HTMX 发送 POST 请求
// @ts-ignore
htmx.trigger('#new-post-form', 'submit');
};
return (
<div>
<h1>My Blog</h1>
<form id="new-post-form" hx-post="/api/post" hx-swap="beforeend" hx-target="#posts" onSubmit={handleSubmit}>
<input
type="text"
name="title"
placeholder="Title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Editor onChange={setContent} />
<input type="hidden" name="content" value={content} />
<button type="submit">Create Post</button>
</form>
<div id="posts">
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
<small>Created at: {new Date(post.createdAt).toLocaleString()}</small>
<button hx-get={`/api/post/${post.id}`} hx-target="closest article" hx-swap="outerHTML">
Load Full Post
</button>
<button hx-delete={`/api/post/${post.id}`} hx-target="closest article" hx-swap="outerHTML">
Delete Post
</button>
</article>
))}
</div>
</div>
);
};- 更新
src/index.ts以处理新的富文本内容:
import { Elysia } from 'elysia';
import { html } from '@elysiajs/html';
import { staticPlugin } from '@elysiajs/static';
import { renderToString } from 'react-dom/server';
import { Blog } from './components/Blog';
import { db } from './db';
import { posts } from './db/schema';
import { eq } from 'drizzle-orm';
import { marked } from 'marked';
const app = new Elysia()
.use(html())
.use(staticPlugin())
.get('/', async ({ html }) => {
const allPosts = await db.select().from(posts).all();
const blogHtml = renderToString(Blog({ posts: allPosts }));
return html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Blog</title>
<link rel="stylesheet" href="/public/styles.css">
<script src="https://unpkg.com/htmx.org@1.9.0"></script>
<script src="https://unpkg.com/marked@4.0.16/marked.min.js"></script>
</head>
<body>
<div id="root">${blogHtml}</div>
</body>
</html>
`);
})
.get('/api/post/:id', async ({ params }) => {
const post = await db.select().from(posts).where(eq(posts.id, parseInt(params.id))).get();
if (!post) {
return new Response('Post not found', { status: 404 });
}
return `
<article>
<h2>${post.title}</h2>
<div>${marked(post.content)}</div>
<small>Created at: ${new Date(post.createdAt).toLocaleString()}</small>
</article>
`;
})
.post('/api/post', async ({ body }) => {
const { title, content } = body as { title: string; content: string };
const newPost = await db.insert(posts).values({ title, content }).returning().get();
return `
<article>
<h2>${newPost.title}</h2>
<div>${marked(newPost.content)}</div>
<small>Created at: ${new Date(newPost.createdAt).toLocaleString()}</small>
<button hx-get="/api/post/${newPost.id}" hx-target="closest article" hx-swap="outerHTML">
Load Full Post
</button>
<button hx-delete="/api/post/${newPost.id}" hx-target="closest article" hx-swap="outerHTML">
Delete Post
</button>
</article>
`;
})
.put('/api/post/:id', async ({ params, body }) => {
const { title, content } = body as { title: string; content: string };
const updatedPost = await db.update(posts)
.set({ title, content })
.where(eq(posts.id, parseInt(params.id)))
.returning()
.get();
return updatedPost;
})
.delete('/api/post/:id', async ({ params }) => {
await db.delete(posts).where(eq(posts.id, parseInt(params.id))).run();
return { success: true };
})
.listen(3000);
console.log(`Server is running at http://localhost:${app.server?.port}`);- 更新
src/public/styles.css以添加一些编辑器样式:
.ProseMirror {
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
min-height: 200px;
}
.ProseMirror:focus {
outline: none;
border-color: #4CAF50;
}
button {
margin-right: 5px;
margin-bottom: 5px;
}
.is-active {
background-color: #4CAF50;
color: white;
}这个更新后的 Blog 系统现在支持富文本编辑,包括 Markdown 格式和插入图片的功能。主要变化包括:
- 添加了一个新的
Editor组件,使用 TipTap 编辑器。 - 更新了
Blog组件以使用新的Editor。 - 在服务器端使用
marked库来渲染 Markdown 内容。 - 更新了 API 处理程序以处理富文本内容。
这个实现还可以进一步改进,例如:
- 添加更多的编辑器功能,如代码块、表格等。
- 实现图片上传功能,而不仅仅是插入 URL。
- 添加预览功能,让用户在发布前可以看到渲染后的效果。
- 实现草稿保存功能。
- 添加更多的样式来美化编辑器和博客文章的展示。
记住,在实际部署时,你需要考虑安全问题,例如对用户输入进行净化以防止 XSS 攻击。此外,处理大量富文本内容可能会影响性能,所以可能需要考虑分页或懒加载等优化策略。