本文为开发开源项目的真实开发经历,感兴趣的可以来给我的项目点个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;
}
}
}