认识 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 中。

相关推荐
非著名架构师4 分钟前
js混淆的方式方法
开发语言·javascript·ecmascript
多多米100538 分钟前
初学Vue(2)
前端·javascript·vue.js
敏编程1 小时前
网页前端开发之Javascript入门篇(5/9):函数
开发语言·javascript
柏箱1 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑1 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8561 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习1 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript
blaizeer2 小时前
深入理解 CSS 浮动(Float):详尽指南
前端·css
编程老船长2 小时前
网页设计基础 第一讲:软件分类介绍、工具选择与课程概览
前端
编程老船长2 小时前
网页设计基础 第二讲:安装与配置 VSCode 开发工具,创建第一个 HTML 页面
前端