认识 Prosemirror 的框架: Tiptap

好久不见,有没有人想我(狗头保命🐶)?在之前的一系列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 初始化项目

  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
  1. 安装相关依赖
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

然后就可以看到编辑器已经出现了,虽然丑一点但已经可以使用了

认识 Prosemirror 的框架: Tiptap

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;
}
}

认识 Prosemirror 的框架: Tiptap

3. 小结

到目前体验完 Tiptap 之后,可以深切感受到它与 ProseMirror 的最大不同在于它的使用方式,基本上是一个比较完善的产品了,可以直接使用,非常方便快捷,而不像之前使用 ProseMirror 时候什么都要自己搭建。

除了开箱即用的特点,相比于 ProseMirror 还有非常多属于自己的特色,例如 Tiptap 的插件机制、插件继承、Node/Mark 的 定义、Storage 存储器、命令机制等,这些在后续的篇章中进行讨论。

其实 Tiptap 的核心还是围绕 Prosemirror 的,在逐渐深入后,还是需要重新翻阅 ProseMirror,tiptap 只是提供了更简单的使用方式,没有改变编辑器仍为 Prosemirror 的特点,后续我们的核心还是要回到 Prosemirror 中。

原文链接:https://juejin.cn/post/7343753562516520996 作者:king王一帅

(0)
上一篇 2024年3月8日 下午4:26
下一篇 2024年3月8日 下午4:36

相关推荐

发表回复

登录后才能评论