使用tiptap快速搭建markdown/富文本编辑器

本文为开发开源项目的真实开发经历,感兴趣的可以来给我的项目点个star,谢谢啦~

具体博文介绍: 开源|Documind协同文档(接入deepseek-r1、支持实时聊天)Documind 🚀 一个支持实时聊天和接入 - 掘金

技术栈

NextJS+ Tiptap+ TS+ Zustand + tailwind

为什么我选择tiptap

模块化架构 + 协同编辑能力 + 现代技术栈

tiptap通过插件化设计实现了极致的可定制性(除了官方插件外还支持社区插件);并且tiptap具有react的优良传统:强大的生态联动,可以和liveblocks搭配实现协同文档。

观看本文前请先阅读tiptap官网文档,对tiptap的使用方法有初步的认知。

让我们分块讲解

首先让我们忽略zustand部分和className

extension及为tiptap的扩展功能,configure为每个插件的配置项,需要什么功能就在这里加入扩展名并且:下载依赖包、在global.css中导入相应样式(具体参考tiptap官方文档),具体扩展名在下面已加上注释。

<EditorContent/>则是tiptap的核心组件,接收一个名为editor的props,通过将useEditor创建的编辑器实例传入即可完成一个基础富文本编辑器(支持markdown语法)

typescript 复制代码
export const Editor = ({ initialContent }: EditorProps) => {
const editor = useEditor({
    immediatelyRender: false,
    extensions: [
      // StarterKit包含了基础功能:加粗、斜体、标题、引用、代码块等
      StarterKit.configure({
        history: false, // 禁用历史记录,通常用于协同编辑场景
      }),
      // 下划线功能
      Underline,
      // 文本高亮功能
      Highlight.configure({
        multicolor: true,//支持多种颜色
      }),
      // 文本颜色功能
      Color,
      // 超链接功能
      Link.configure({
        autolink: true,        // 自动识别URL并转换为链接
        defaultProtocol: "https", // 默认使用https协议
      }),
      // 文本样式基础支持
      TextStyle,
      // 表格相关功能
      Table,
      // 图片插入功能
      Image,
      // 图片缩放功能
      ImageResize,
      // 表格行
      TableRow,
      // 表格单元格
      TableCell,
      // 表格表头
      TableHeader,
      // 字体族设置
      FontFamily,
      // 文本对齐功能
      TextAlign.configure({
        types: ["heading", "paragraph"], // 仅标题和段落支持对齐
      }),
      // 任务列表项
      TaskItem.configure({
        nested: true, // 支持嵌套任务列表
      }),
      // 任务列表容器
      TaskList,
    ],
  });
  
  return (
    <div>
      <div>
        {/* EditorContent 是 Tiptap 的核心渲染组件,用于显示编辑器内容 */}
        <EditorContent editor={editor} />
      </div>
    </div>
  );
};

添加zustand部分

为什么要使用zustand来对editor进行状态管理?

  • 及时对editor进行存储
  • 跨组件操作editor
  • 支持协同编辑

通过以下代码我们可以看到我们在tiptap的不同生命周期都及时存储了editor状态

详细源码会在最后一个版块给出

javascript 复制代码
    const { setEditor } = useEditorStore();
    
    onCreate({ editor }) {
      setEditor(editor);
    },
    onDestroy() {
      setEditor(null);
    },
    onUpdate({ editor }) {
      setEditor(editor);
    },
    onSelectionUpdate({ editor }) {
      setEditor(editor);
    },
    onTransaction({ editor }) {
      //事务更新时,将编辑器设置到全局状态中
      setEditor(editor);
    },
    onFocus({ editor }) {
      //聚焦时,将编辑器设置到全局状态中
      setEditor(editor);
    },
    onBlur({ editor }) {
      //失去焦点时,将编辑器设置到全局状态中
      setEditor(editor);
    },
    onContentError({ editor }) {
      setEditor(editor);
    }

成果

源码和注释

zustand源码

typescript 复制代码
import {create} from "zustand";
import {type Editor} from "@tiptap/react"

interface EditorState{
  editor:Editor | null;
  setEditor:(editor:Editor|null)=>void;
}

export const useEditorStore = create<EditorState>()((set)=>({
  editor:null,
  setEditor:(editor)=>set({editor}),
}))

tiptap源码

typescript 复制代码
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";
import ImageResize from "tiptap-extension-resize-image";
import { useEditorStore } from "@/store/use-editor-store";
import Underline from "@tiptap/extension-underline";
import FontFamily from "@tiptap/extension-font-family";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Link from "@tiptap/extension-link";
import { Highlight } from "@tiptap/extension-highlight";
import TextAlign from "@tiptap/extension-text-align";

interface EditorProps {
  initialContent?: string | undefined;
}

export const Editor = ({ initialContent }: EditorProps) => {
  const { setEditor } = useEditorStore();
  const editor = useEditor({
    immediatelyRender: false,
    onCreate({ editor }) {
      setEditor(editor);
    },
    onDestroy() {
      setEditor(null);
    },
    onUpdate({ editor }) {
      setEditor(editor);
    },
    onSelectionUpdate({ editor }) {
      setEditor(editor);
    },
    onTransaction({ editor }) {
      setEditor(editor);
    },
    onFocus({ editor }) {
      setEditor(editor);
    },
    onBlur({ editor }) {
      setEditor(editor);
    },
    onContentError({ editor }) {
      setEditor(editor);
    },
    editorProps: {
      attributes: {
        class:
          "focus:outline-none print:border-0 bg-white shadow-lg flex flex-col min-h-[1054px] w-[816px] pt-10 pr-14 pb-10 cursor-text",
        style: `padding-left: 56px; padding-right: 56px;`,
      },
    },
    extensions: [
      StarterKit.configure({
        history: false,
      }),
      Underline,
      Highlight.configure({
        multicolor: true,
      }),
      Color,
      Link.configure({
        autolink: true,
        defaultProtocol: "https",
      }),
      TextStyle,
      Table,
      Image,
      ImageResize,
      TableRow,
      TableCell,
      TableHeader,
      FontFamily,
      TextAlign.configure({
        types: ["heading", "paragraph"],
      }),
      TaskItem.configure({
        nested: true,
      }),
      TaskList,
    ],
  });

  return (
    <div className="size-full overflow-x-auto bg-[#F9FBFD] px-4 print:p-0 print:bg-white print:overflow-visible">
      <div className="min-w-max flex justify-center w-[816px] py-4 print:py-0 mx-auto print:w-full print:min-w-0">
        <EditorContent editor={editor} />
      </div>
    </div>
  );
};

global.css中tiptap部分源码

sass 复制代码
.tiptap {
  /* Link styles */
  a {
    @apply text-blue-600;
    cursor: pointer;
    &:hover {
      @apply underline;
    }
  }
  /* Image-specific styling */
  img {
    display: block;
    height: auto;
    margin: 1.5rem 0;
    max-width: 100%;

    &.ProseMirror-selectednode {
      outline: 3px solid var(--purple);
    }
  }
  table {
    border-collapse: collapse;
    margin: 0;
    overflow: hidden;
    table-layout: fixed;
    width: 100%;

    td,
    th {
      border: 1px solid black;
      box-sizing: border-box;
      min-width: 1em;
      padding: 6px 8px;
      position: relative;
      vertical-align: top;

      > * {
        margin-bottom: 0;
      }
    }

    th {
      /* 表格表头 */
      /* background-color: #7c7c7c; */
      font-weight: bold;
      text-align: left;
    }

    .selectedCell:after {
      background: #959596;
      content: "";
      left: 0;
      right: 0;
      top: 0;
      bottom: 0;
      pointer-events: none;
      position: absolute;
      z-index: 2;
    }

    .column-resize-handle {
      background-color: var(--primary);
      bottom: -2px;
      pointer-events: none;
      position: absolute;
      right: -2px;
      top: 0;
      width: 4px;
    }
  }

  .tableWrapper {
    margin: 1.5rem 0;
    overflow-x: auto;
  }

  &.resize-cursor {
    cursor: ew-resize;
    cursor: col-resize;
  }

  /* Heading styles */
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    line-height: 1.1;
    margin-top: 2.5rem;
    text-wrap: pretty;
  }

  h1,
  h2 {
    margin-top: 3.5rem;
    margin-bottom: 1.5rem;
  }

  h1 {
    font-size: 1.4rem;
  }

  h2 {
    font-size: 1.2rem;
  }

  h3 {
    font-size: 1.1rem;
  }

  h4,
  h5,
  h6 {
    font-size: 1rem;
  }

  ul,
  ol {
    padding: 0 1rem;
    margin:
      1.25rem 1rem 1.25rem,
      0.4rem;
  }
  ul li {
    /* 设置列表项前面的项目符号为实心圆点。 */
    list-style-type: disc;

    p {
      margin-top: 0.25em;
      margin-bottom: 0.25em;
    }
  }
  ol li {
    /* 设置列表项前面的项目符号为数字。 */
    list-style-type: decimal;

    p {
      margin-top: 0.25em;
      margin-bottom: 0.25em;
    }
  }
  /* Task list specific styles */
  ul[data-type="taskList"] {
    list-style: none;
    margin-left: 0;
    padding: 0;

    li {
      align-items: flex-start;
      display: flex;

      > label {
        flex: 0 0 auto;
        margin-right: 0.5rem;
        user-select: none;
        -webkit-user-select: none;
      }

      > div {
        flex: 1 1 auto;
      }
    }

    input[type="checkbox"] {
      cursor: pointer;
    }

    ul[data-type="taskList"] {
      margin: 0;
    }
  }
  /* For mobile */
  .floating-threads {
    display: none;
  }

  /* For desktop */
  .anchored-threads {
    display: block;
    max-width: 300px;
    width: 100%;
    position: absolute;
    right: 12px;
  }

  @media (max-width: 640px) {
    .floating-threads {
      display: block;
    }

    .anchored-threads {
      display: none;
    }
  }
}
相关推荐
2603_965896627 分钟前
Grid 网格布局|CSS 二维页面排版体系
前端·css
有味道的男人22 分钟前
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
服务器·前端·javascript
lovingsoft1 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly1 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝1 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦1 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡2 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI2 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95272 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人2 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring