使用 Tiptap 在 Next.js 中构建简洁的富文本编辑器(真实项目设置)
Source: Dev.to
当你构建仪表盘或 SaaS 应用时,某个时刻你需要一个 真正的 文本编辑器——而不是普通的 textarea,也不是伪 markdown 框。
你需要:
- 标题
- 列表
- 格式化
- 干净的 HTML 输出
- 对 UI 的完全控制
这正是 Tiptap 的用武之地。
下面的博文展示了我如何在 Next.js + React 仪表盘中使用 Tiptap 编辑器,配合自定义工具栏、Tailwind 样式,并将 HTML 输出准备好保存到数据库中。
注意: 这段代码是 真实的仪表盘代码,而非演示。
1️⃣ 仪表板页面 (ResumesPage)
"use client";
import DashboardLayout from "@/components/(user-dashboard)/layout/DashboardLayout";
import DiagonalDivider from "@/components/(user-dashboard)/components/DiagonalDivider";
import { useState } from "react";
import TipTapEditorMinimal from "@/components/(editors)/RichTextEditor/TipTapEditorMinimal";
import PrimaryButton from "@/components/Common/ui/PrimaryButton";
import { FaFile } from "react-icons/fa";
import { FileChartColumn } from "lucide-react";
export default function ResumesPage() {
const [resumeHtml, setResumeHtml] = useState("");
const [resumeJson, setResumeJson] = useState(null);
const handleResumeChange = (html: string, json: any) => {
setResumeHtml(html);
setResumeJson(json);
// Save to your Database via API route
};
const [post, setPost] = useState("");
const onChange = (content: string) => {
setPost(content);
};
const initialTemplate = `
专业概述
全栈开发者,擅长 MERN 技术栈、Next.js 和 Tailwind CSS。
经验
- Software Engineer at XYZ Corp (2023‑Present)
import { PrimaryButton, TipTapEditorMinimal } from "some-ui-library";
export default function ResumePage() {
const [resumeHtml, setResumeHtml] = useState("");
const [resumeJson, setResumeJson] = useState("");
const handleResumeChange = (html: string, json: any) => {
setResumeHtml(html);
setResumeJson(JSON.stringify(json));
};
const initialTemplate = `
## Experience
- Software Engineer at XYZ Corp (2023-Present)
`;
return (
<>
{/* Header */}
<div>
<h2>Resumes</h2>
<p>Track all your resume download activities</p>
</div>
{/* Resume Builder */}
<section>
<h2>Resume Builder</h2>
<p>{resumeHtml.length} characters</p>
<PrimaryButton
onClick={() => {
console.log("Save resume:", {
html: resumeHtml,
json: resumeJson,
});
}}
>
Save Resume
</PrimaryButton>
<TipTapEditorMinimal
content={initialTemplate}
onChange={handleResumeChange}
/>
</section>
</>
);
}
该组件的功能
- 编辑器设置 – 挂载最小化的 Tiptap 编辑器。
- 扩展 – 添加 starter kit、text‑align 和 highlight 扩展。
- HTML 输出 –
onChange返回编辑器的 HTML(准备存入数据库)。 - Tailwind 样式 – 所有内容均使用 Tailwind 类进行样式化。
2️⃣ Minimal Tiptap Editor (TipTapEditorMinimal)
"use client";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import MenuBar from "./MenuBar";
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
}
export default function TipTapEditorMinimal({
content,
onChange,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc ml-4 my-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal ml-4 my-2",
},
},
}),
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Highlight,
],
content,
editorProps: {
attributes: {
class:
"h-[230px] overflow-y-auto p-4 border rounded-b-md outline-none prose",
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
immediatelyRender: false,
});
return (
<>
{editor && <MenuBar editor={editor} />}
{editor && <EditorContent editor={editor} />}
</>
);
}
此工具栏完全自定义 — 不使用默认的 Tiptap UI。
它支持:
- 标题
- 粗体 / 斜体 / 删除线
- 对齐
- 列表
- 高亮
Source:
3️⃣ 自定义工具栏(MenuBar)
import React, { useEffect, useState } from "react";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
List,
ListOrdered,
Strikethrough,
} from "lucide-react";
import { Editor } from "@tiptap/react";
import { Toggle } from "../../Common/ui/Toggle";
export default function MenuBar({ editor }: { editor: Editor | null }) {
const [, forceUpdate] = useState({});
// Re‑render on selection/transaction changes so button states stay in sync
useEffect(() => {
if (!editor) return;
const updateHandler = () => forceUpdate({});
editor.on("selectionUpdate", updateHandler);
editor.on("transaction", updateHandler);
return () => {
editor.off("selectionUpdate", updateHandler);
editor.off("transaction", updateHandler);
};
}, [editor]);
if (!editor) return null;
return (
<>
{/* Bold */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={editor.isActive("bold")}
>
<Bold />
</Toggle>
{/* Italic */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={editor.isActive("italic")}
>
<Italic />
</Toggle>
{/* Strikethrough */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={editor.isActive("strike")}
>
<Strikethrough />
</Toggle>
{/* Heading 1 */}
<Toggle
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
pressed={editor.isActive("heading", { level: 1 })}
>
<Heading1 />
</Toggle>
{/* Heading 2 */}
<Toggle
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
pressed={editor.isActive("heading", { level: 2 })}
>
<Heading2 />
</Toggle>
{/* Heading 3 */}
<Toggle
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
pressed={editor.isActive("heading", { level: 3 })}
>
<Heading3 />
</Toggle>
{/* Unordered List */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
pressed={editor.isActive("bulletList")}
>
<List />
</Toggle>
{/* Ordered List */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
pressed={editor.isActive("orderedList")}
>
<ListOrdered />
</Toggle>
{/* Text Align Left */}
<Toggle
onPressedChange={() => editor.chain().focus().setTextAlign("left").run()}
pressed={editor.isActive({ textAlign: "left" })}
>
<AlignLeft />
</Toggle>
{/* Text Align Center */}
<Toggle
onPressedChange={() =>
editor.chain().focus().setTextAlign("center").run()
}
pressed={editor.isActive({ textAlign: "center" })}
>
<AlignCenter />
</Toggle>
{/* Text Align Right */}
<Toggle
onPressedChange={() => editor.chain().focus().setTextAlign("right").run()}
pressed={editor.isActive({ textAlign: "right" })}
>
<AlignRight />
</Toggle>
{/* Highlight */}
<Toggle
onPressedChange={() => editor.chain().focus().toggleHighlight().run()}
pressed={editor.isActive("highlight")}
>
<Highlighter />
</Toggle>
</>
);
}
工具栏工作原理
Toggle组件 – 可复用的按钮,接收onPressedChange和pressed状态。editor.chain().focus().().run()– 在保持编辑器聚焦的同时执行 Tiptap 命令。editor.isActive(...)– 根据当前选区同步 UI(例如,加粗状态)。
Source: (请在此处提供原始来源链接,以便我保持原样并放在翻译内容的顶部)
按钮在光标位于粗体文本内部时仍保持高亮状态。
📦 摘要
- Tiptap 为您提供功能完整、可扩展的富文本编辑器。
- 通过将其集成到 Next.js 客户端组件 中,您可以捕获干净的 HTML(或 JSON),并直接存入数据库。
- 使用 Tailwind 和 Lucide 图标构建的 自定义工具栏 提供您所需的精准 UI——没有多余的臃肿。
随意复制代码、调整样式,并在产品发展时通过更多扩展(表格、图片、提及等)来扩展编辑器。祝编码愉快!
组件使用
editor.chain().focus().toggleItalic().run();
editor.chain().focus().toggleStrike().run();
editor.chain().focus().toggleBulletList().run();
editor.chain().focus().toggleOrderedList().run();
editor.chain().focus().toggleHighlight().run();
切换组件 (Toggle.tsx)
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva } from "class-variance-authority";
import { cn } from "@/utils/editorUtils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md transition",
{
variants: {
size: {
default: "h-9 w-9",
},
},
defaultVariants: {
size: "default",
},
}
);
function Toggle({
className,
...props
}: React.ComponentProps) {
return (
<TogglePrimitive.Root
className={cn(toggleVariants({ size: "default" }), className)}
{...props}
/>
);
}
export { Toggle };
实用函数 (editorUtils.ts)
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}
为什么选择 Tiptap?
- Freedom – 没有强制的 UI。
- Control – 由你决定标记、工具栏、输出和性能。
- Versatility – 适用于仪表盘、SaaS 编辑器和内容构建器。
- Reliability – 在真实项目中运行流畅——没有魔法,只有可靠的 React 代码。