为了实现复杂的文档编辑功能,支持 Markdown 格式以及插入图片等功能,我们可以使用一些现有的开源编辑器组件,比如 TipTap 或 ProseMirror。这里我们将使用 TipTap,因为它基于 ProseMirror,但提供了更友好的 API 和更多的现成扩展。

以下是如何将这些功能集成到我们的 Blog 系统中:

  1. 首先,安装必要的依赖:
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder marked
  1. 创建一个新的 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;
  1. 更新 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>
  );
};
  1. 更新 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}`);
  1. 更新 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 格式和插入图片的功能。主要变化包括:

  1. 添加了一个新的 Editor 组件,使用 TipTap 编辑器。
  2. 更新了 Blog 组件以使用新的 Editor
  3. 在服务器端使用 marked 库来渲染 Markdown 内容。
  4. 更新了 API 处理程序以处理富文本内容。

这个实现还可以进一步改进,例如:

  • 添加更多的编辑器功能,如代码块、表格等。
  • 实现图片上传功能,而不仅仅是插入 URL。
  • 添加预览功能,让用户在发布前可以看到渲染后的效果。
  • 实现草稿保存功能。
  • 添加更多的样式来美化编辑器和博客文章的展示。

记住,在实际部署时,你需要考虑安全问题,例如对用户输入进行净化以防止 XSS 攻击。此外,处理大量富文本内容可能会影响性能,所以可能需要考虑分页或懒加载等优化策略。