好久不见,有没有人想我(狗头保命🐶)?在之前的一系列Prosemirror文章中,带大家认识了 Prosemirror(未完结),本文带大家认识一下 Tiptap。
1. Tiptap 与 Prosemirror 的关系
正如 prosemirror 官网所说,prosemirror 并不是一个开箱即用的编辑器,它仅仅提供了一组用于构建富文本编辑器的概念与工具,它的目标不是给你提供一辆车,而是提供造车的所有零件配置,让你自己组装。Tiptap 就是基于 Prosemirror 建立起来的一套整车供应商,它提供了开箱即用的编辑器,并且不失扩展性,不仅能让有基础需求的人可以快速接入,也可以让有高级需求的人进行定制。但在使用过程中,其难点还是在 prosemirror,Tiptap 只是基于 Prosemirror 提供了一套机制,可以让我们快速入手搭建一套编辑器,并不是一个全新的东西。就像你用 next.js 或 nuxt.js,限制你的最大部分还是在 react 跟 vue 层面,不懂基础,很难玩转框架。
ProseMirror 官方文档:
The core library is not an easy drop-in component—we are prioritizing modularity and customizability over simplicity, with the hope that, in the future, people will distribute drop-in editors based on ProseMirror. As such, this is more of a Lego set than a Matchbox car.
核心库不是简单开箱即用的组件 ——我们优先考虑的是模块化与自定义,而不是简单化。基于此,希望未来有人能提供基于 ProseMirror 开箱即用的编辑器。因此,当前只提供一套可以自由组合拼装的乐高集,而不是已经预制好、无法改变的火柴盒汽车。
2. 基于 Tiptap 快速搭建富文本编辑器
Tiptap 除了提供对 Prosemirror 的封装,还对当前各种流行的框架提供了开箱即用的版本,为了更好地了解它,我们还是以 vanilla JS 原生方式使用,只要会原生,接入各种框架也就是手拿把掐了,并且如果我们希望自己开发的编辑器可以跨框架接入任何项目,这种方式也是最好的,在 UI 层面可以选择打包后体积更小的 Svelte 或者其他 webComponents 库。
接下来我们创建一个 react 项目,并使用 vanilla 方式开发一个编辑器,同事演示如何在 react 中集成。
2.1 初始化项目
- 使用 vite 创建项目,选择 react + typescript
npm create vite
✔ Project name: … tiptap-editor-demo
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
Scaffolding project in /Users/yishuai/develop/editor/tiptap-editor-demo...
Done. Now run:
cd tiptap-editor-demo
npm install
npm run dev
- 安装相关依赖
npm install @tiptap/core @tiptap/pm @tiptap/starter-kit
npm install sass -D
依赖包解释:
@tiptap/core
: tiptap 核心包,tiptap 核心实现都在这个包里面@tiptap/pm
: tiptap 依赖的 ProseMirror,tiptap 依赖 posemirror 进行开发,这个包将散落的 posemirror 各个包做了一个统一集合,后续在 tiptap 中想要导入 prosemirror 的官方包,至于要在@tiptap/pm
导出即可,可以有效避免 tiptap 与依赖的 prosemirror 版本不一致导致的各种 bug.@tiptap/starter-kit
: 这个包整合了常用的 tiptap 插件,让我们的编辑器可以开箱即用,如果自定义程度比较高的编辑器需求,这个包就可以废弃,自己封装各种插件。
2.2 二次封装 tiptap Editor
对 tiptap 的 editor 进行二次封装,可以将一些默认插件集成到自己的编辑器中,不用每次创建都重新传入。
// src/editor/index.ts
import { Editor, type EditorOptions } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
export interface MEditorOptions extends EditorOptions {
// 排除哪些插件
excludeExtensions: string[];
}
export class MEditor {
editor: Editor;
defaultOptions: Partial<MEditorOptions> = {
extensions: [StarterKit],
};
constructor(public options: Partial<MEditorOptions>) {
const defaultExtensions = this.defaultOptions.extensions || [];
const excludeExtensions = this.defaultOptions.excludeExtensions || [];
const currentExtensions = options.extensions || [];
const extensions = defaultExtensions.filter(extension => !excludeExtensions.includes(extension.name));
if (currentExtensions.length) {
extensions.push(...currentExtensions)
}
this.options = {
...this.defaultOptions,
...options,
extensions
}
this.editor = new Editor(this.options);
}
}
2.3 使用编辑器
// src/App.tsx
import { useEffect, useRef } from 'react'
import { MEditor } from './editor'
import MenuBar from './components/MenuBar';
function App() {
const editorRootRef = useRef<HTMLDivElement|null>(null);
const editorRef = useRef<MEditor>();
useEffect(() => {
// 判断 !editorRef.current 是避免 react 开发模式下 useEffect 执行两遍导致的重复创建 editor 实例
if (editorRootRef.current && !editorRef.current) {
editorRef.current = new MEditor({
element: editorRootRef.current,
content: `<h1>标题</h1><p>内容</>`
})
}
() => {
// 销毁 editor 实例
if (editorRef.current) {
editorRef.current.editor.destroy();
}
}
}, [])
return (
<main>
<MenuBar />
<div className='editorRoot' ref={editorRootRef} />
</main>
)
}
export default App
然后就可以看到编辑器已经出现了,虽然丑一点但已经可以使用了
2.4 增加 MenuBar 方便操作文档
首先将将 editor 封装为一个 hooks 方便再其他组件中使用,先创建一个 EditorContext 用于向下传递编辑器对象
import { Editor } from "@tiptap/core";
import { createContext } from "react";
export const EditorContext = createContext<Editor|null>(null);
在 App.tsx 中使用
import { useEffect, useRef, useState } from 'react'
import { MEditor } from './editor'
import { EditorContext } from './components/EditorProvider';
import { Editor } from '@tiptap/core';
function App() {
const editorRootRef = useRef<HTMLDivElement|null>(null);
const editorRef = useRef<MEditor>();
const [editor, setEditor] = useState<Editor|null>(null)
useEffect(() => {
// 判断 !editorRef.current 是避免 react 开发模式下 useEffect 执行两遍导致的重复创建 editor 实例
if (editorRootRef.current && !editorRef.current) {
editorRef.current = new MEditor({
element: editorRootRef.current,
content: `<h1>标题</h1><p>内容</>`
})
setEditor(editorRef.current.editor);
}
() => {
// 销毁 editor 实例
if (editorRef.current) {
editorRef.current.editor.destroy();
}
}
}, [])
return (
<EditorContext.Provider value={editor || null}>
<main>
<div className='editorRoot' ref={editorRootRef} />
</main>
</EditorContext.Provider>
)
}
export default App
创建 useCurrentEditor
,方便使用 editor, useEffect
中监听了编辑器的 transaction
事件,然后强制更新,可以确保当前编辑器内部有变化时候 React UI 可以保持最新状态
import { useContext, useEffect, useState } from "react";
import { EditorContext } from "../components/EditorProvider";
export function useCurrentEditor() {
const editorInstance = useContext(EditorContext);
const [, forceUpdate] = useState({});
useEffect(() => {
if (editorInstance) {
editorInstance.on('transaction', () => {
forceUpdate({})
})
}
}, [editorInstance])
return {
editor: editorInstance
};
}
创建 MenuBar
组件,MenuBar 中 Button 点击后,通过 editor.chain()
调用命令,设置对应的操作,editor.isActive("bold") ? "is-active" : ""
可以检测当前编辑器是否处于某种状态(如 bold
是加粗状态)
// src/components/MenuBar.tsx
import { useEffect } from "react";
import { useCurrentEditor } from "../hooks/useCurrentEditor";
const MenuBar = () => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
<div className="menubar">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "is-active" : ""}
>
italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "is-active" : ""}
>
strike
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "is-active" : ""}
>
code
</button>
<button onClick={() => editor.chain().focus().unsetAllMarks().run()}>
clear marks
</button>
<button onClick={() => editor.chain().focus().clearNodes().run()}>
clear nodes
</button>
<button
onClick={() => editor.chain().focus().setParagraph().run()}
className={editor.isActive("paragraph") ? "is-active" : ""}
>
paragraph
</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().toggleHeading({ level: 3 }).run()}
className={editor.isActive("heading", { level: 3 }) ? "is-active" : ""}
>
h3
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
className={editor.isActive("heading", { level: 4 }) ? "is-active" : ""}
>
h4
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
className={editor.isActive("heading", { level: 5 }) ? "is-active" : ""}
>
h5
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
className={editor.isActive("heading", { level: 6 }) ? "is-active" : ""}
>
h6
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "is-active" : ""}
>
bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "is-active" : ""}
>
ordered list
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "is-active" : ""}
>
code block
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "is-active" : ""}
>
blockquote
</button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()}>
horizontal rule
</button>
<button onClick={() => editor.chain().focus().setHardBreak().run()}>
hard break
</button>
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
>
undo
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
>
redo
</button>
</div>
);
};
export default MenuBar;
更新 App.tsx
<EditorContext.Provider value={editor || null}>
<main>
<MenuBar />
<div className='editorRoot' ref={editorRootRef} />
</main>
</EditorContext.Provider>
添加一些样式,你就可以看到一个还不错的编辑器
/* Basic document styles */
:root {
color-scheme: dark;
}
body {
box-sizing: border-box;
margin: 0;
background: #1d1d1d;
font-family: sans-serif;
line-height: 1.4;
color: #e6e6e6;
padding: 1rem;
min-height: 100vh;
}
button {
background-color: rgba(#ffffff, 0.2);
color: rgba(#ffffff, 0.8);
border: 2px solid #1d1d1d;
border-radius: 0.4em;
cursor: pointer;
}
button:hover {
background-color: rgba(#ffffff, 0.3);
}
button:disabled {
background-color: rgba(#ffffff, 0.1);
color: rgba(#ffffff, 0.2);
}
button.is-active {
background-color: rgba(royalblue, 1);
color: #fff;
}
button.is-active:hover {
background-color: rgba(royalblue, 0.8);
}
button:disabled {
background-color: rgba(royalblue, 0.1);
color: rgba(#ffffff, 0.2);
}
.menubar {
border-bottom: 1px solid rgba(#ffffff, 0.1);
padding-bottom: 20px;
}
/* Basic editor styles */
.tiptap {
min-height: calc(100vh - 2rem);
&:focus-visible {
outline: none;
}
> * + * {
margin-top: 0.75em;
}
ul,
ol {
padding: 0 1rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}
code {
background: rgba(#ffffff, 0.1);
color: rgba(#ffffff, 0.6);
border: 1px solid rgba(#ffffff, 0.1);
border-radius: 0.5rem;
padding: 0.2rem;
}
pre {
background: rgba(#ffffff, 0.1);
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
border: none;
}
}
img {
max-width: 100%;
height: auto;
}
blockquote {
margin-left: 0;
padding-left: 1rem;
border-left: 2px solid rgba(#ffffff, 0.4);
}
hr {
border: none;
border-top: 2px solid rgba(#ffffff, 0.1);
margin: 2rem 0;
}
}
3. 小结
到目前体验完 Tiptap 之后,可以深切感受到它与 ProseMirror 的最大不同在于它的使用方式,基本上是一个比较完善的产品了,可以直接使用,非常方便快捷,而不像之前使用 ProseMirror 时候什么都要自己搭建。
除了开箱即用的特点,相比于 ProseMirror 还有非常多属于自己的特色,例如 Tiptap 的插件机制、插件继承、Node/Mark 的 定义、Storage 存储器、命令机制等,这些在后续的篇章中进行讨论。
其实 Tiptap 的核心还是围绕 Prosemirror 的,在逐渐深入后,还是需要重新翻阅 ProseMirror,tiptap 只是提供了更简单的使用方式,没有改变编辑器仍为 Prosemirror 的特点,后续我们的核心还是要回到 Prosemirror 中。
原文链接:https://juejin.cn/post/7343753562516520996 作者:king王一帅