认识 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
shell 复制代码
npm create vite
shell 复制代码
✔ 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. 安装相关依赖
shell 复制代码
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 进行二次封装,可以将一些默认插件集成到自己的编辑器中,不用每次创建都重新传入。

ts 复制代码
// 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 使用编辑器

ts 复制代码
// 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

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

首先将将 editor 封装为一个 hooks 方便再其他组件中使用,先创建一个 EditorContext 用于向下传递编辑器对象

ts 复制代码
import { Editor } from "@tiptap/core";
import { createContext } from "react";

export const EditorContext = createContext<Editor|null>(null);

在 App.tsx 中使用

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 可以保持最新状态

ts 复制代码
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 是加粗状态)

tsx 复制代码
// 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

tsx 复制代码
<EditorContext.Provider value={editor || null}>
  <main>
    <MenuBar />
    <div className='editorRoot' ref={editorRootRef} />
  </main>
</EditorContext.Provider>

添加一些样式,你就可以看到一个还不错的编辑器

css 复制代码
/* 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 中。

相关推荐
剑亦未配妥1 小时前
移动端触摸事件与鼠标事件的触发机制详解
前端·javascript
人工智能训练师7 小时前
Ubuntu22.04如何安装新版本的Node.js和npm
linux·运维·前端·人工智能·ubuntu·npm·node.js
Seveny077 小时前
pnpm相对于npm,yarn的优势
前端·npm·node.js
yddddddy8 小时前
css的基本知识
前端·css
昔人'8 小时前
css `lh`单位
前端·css
前端君9 小时前
实现最大异步并发执行队列
javascript
Nan_Shu_61410 小时前
Web前端面试题(2)
前端
知识分享小能手10 小时前
React学习教程,从入门到精通,React 组件核心语法知识点详解(类组件体系)(19)
前端·javascript·vue.js·学习·react.js·react·anti-design-vue
蚂蚁RichLab前端团队11 小时前
🚀🚀🚀 RichLab - 花呗前端团队招贤纳士 - 【转岗/内推/社招】
前端·javascript·人工智能
孩子 你要相信光11 小时前
css之一个元素可以同时应用多个动画效果
前端·css