使用 Tiptap 在 Next.js 中构建简洁的富文本编辑器(真实项目设置)

发布: (2026年1月1日 GMT+8 12:42)
7 min read
原文: Dev.to

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 组件 – 可复用的按钮,接收 onPressedChangepressed 状态。
  • 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 代码。
Back to Blog

相关文章

阅读更多 »

修复 Next.js 中的水合错误

Hydration 错误的常见原因 浏览器/环境问题 - 浏览器扩展注入属性、密码管理器、广告拦截器、辅助功能工具 - Br...