如何实现所见即所得编辑器?tiptap的实现原理(二)

2023-11-14 20:29:38 浏览数 (3)

Tiptap 是一个基于 ProseMirror 构建的富文本编辑器,它是一个灵活、可扩展的富文本编辑器,同时适用于 Vue.js 和 React。所以,无论你的技术栈是Vue,还是React,使用Tiptap都不用太过于在选型上纠结。Tiptap 的核心思路是通过插件系统提供丰富的功能,使得开发者可以根据需求定制编辑器的功能和样式

Tiptap 的主要有5大部分组成:

  1. Core:Tiptap 的核心模块,负责处理编辑器的基本功能,如文本输入、选择、撤销和重做等。
  2. Extensions:扩展模块,提供丰富的编辑功能,如加粗、斜体、列表、链接等。开发者可以根据需求选择需要的功能,并通过插件系统轻松地添加到编辑器中,下面我们会展开说说如何自定义一个插件,例如如何将AI能力加持到编辑器上来
  3. Commands:命令模块,用于执行编辑操作,如插入、删除、修改等。开发者可以通过命令 API 对编辑器进行操作,实现自定义的功能。
  4. Schema:定义编辑器的文档结构,包括节点、标记和规则。通过自定义 Schema,可以实现特定的文档结构和约束。
  5. Vue/React components:Tiptap 提供了 Vue 和 React 的组件,使得编辑器可以轻松地集成到这两个框架中。

首先,为了了解这些模块之间的关联关系,我们可以看看下面这系统架构幅图。

Tiptap 作为主要的入口,连接了 Core、Extensions、Commands、Schema 和 Vue/React components。Extensions 又包括了多个功能模块,如 Bold、Italic、List 和 Link。这样的架构使得 Tiptap 可以根据需求灵活地扩展功能和样式。

Tiptap 的 Core模块原理简介

Tiptap 的 Core 模块是基于 ProseMirror 构建的,它负责处理编辑器的基本功能,如文本输入、选择、撤销和重做等。ProseMirror 是一个用于构建富文本编辑器的 JavaScript 库,提供了强大的文档模型和编辑功能,我们在上篇文章中有简单的介绍过,Tiptap实际上就是扩展了ProseMirror的 Nodes,Marks等等,所有的包装相关的源码,我们可以参考 https://github.com/ueberdosis/tiptap/tree/develop/packages/pm。正如这个库的readme文件所说,

那么,整个Core 实际上层对 ProseMirror 的更加方便的使用的封装。说是封装其实就是间接导出,并没有做什么实质性的导出,所以,完完全全可以理解为 TipTap的底层就是 ProseMirror,那么为何不直接依赖 ProseMirror呢?这里极有可能是为了后续更好做扩展或者解耦。

整个Tiptap的架构图,我们可以参考如下

  1. Document Model:ProseMirror 提供了一个灵活的文档模型,用于表示富文本编辑器中的内容。文档模型由节点(Node)和标记(Mark)组成,节点表示文档的结构元素,如段落、标题和列表等;标记表示文本的样式,如加粗、斜体和链接等。Tiptap 的 Core 模块使用 ProseMirror 的文档模型来表示和操作编辑器中的内容。
  2. Transactions:ProseMirror 中的所有编辑操作都是通过事务(Transaction)来完成的。事务是一系列对文档模型的修改操作,如插入、删除和修改等。Tiptap 的 Core 模块使用 ProseMirror 的事务系统来处理编辑操作,确保文档模型的一致性和可撤销性。
  3. View:ProseMirror 提供了一个视图系统,用于将文档模型渲染到 DOM 中,并处理用户的输入和交互。Tiptap 的 Core 模块使用 ProseMirror 的视图系统来实现编辑器的显示和交互功能。
  4. Plugins:ProseMirror 支持插件系统,允许开发者为编辑器添加自定义的功能和行为。Tiptap 的 Core 模块使用 ProseMirror 的插件系统来实现扩展功能,如撤销和重做、拖放和粘贴等。

基本上,可以理解为 是 ProseMirror的那套把戏。

我们如何在TipTap 上去实现一个扩展(Extension),以及扩展的实现原理

在 Tiptap 中,插件的各种能力(如快捷键、命令等)是通过扩展(Extension)的 API 实现的。当你将扩展添加到编辑器时,编辑器会自动加载和应用这些 API。以下是一些主要的 API 和它们的原理:

  1. 快捷键:在扩展中定义 inputRuleskeymap 属性,可以添加快捷键。inputRules 是一种基于输入模式的快捷键,例如在输入 * 和空格时自动创建一个列表。keymap 是一种基于按键组合的快捷键,例如按 Ctrl B 时切换加粗样式。当用户输入或按下快捷键时,编辑器会自动调用相应的命令。
  2. 命令:在扩展中定义 commands 方法,可以添加命令。命令是一个函数,接受一个参数 params,并返回一个处理函数。处理函数接受两个参数:statedispatchstate 是当前的编辑器状态,dispatch 是一个用于分发事务的函数。你可以在处理函数中执行一些操作,如修改文档模型、更新视图和触发事件等。
  3. 菜单项:在扩展中定义 menuItems 属性,可以添加菜单项。菜单项是一个对象,包含一些属性,如 commandicontitle 等。当用户点击菜单项时,编辑器会自动调用相应的命令。
  4. 插件:在扩展中定义 plugins 属性,可以添加 ProseMirror 插件。ProseMirror 插件是一个对象,通常包含一个或多个处理函数,如 handleDOMEventsappendTransactionfilterTransaction 等。这些处理函数用于处理编辑器的事件和事务。

以下是用户操作时,扩展Extension于编辑器Editor的交互序列图,当然隐藏了诸多细节,但是不妨碍我们理解一个扩展在整个编辑过程中扮演的角色。

一个简单的扩展的实现

在Tiptap上实现一个扩展最简单的办法莫过于基于他的模板了:npm init tiptap-extension@latest

为了极大的降低理解难度,我选择直接使用加粗扩展来了解下一个扩展的主要逻辑。

代码语言:typescript复制
import {
  Mark,
  markInputRule,
  markPasteRule,
  mergeAttributes,
} from '@tiptap/core'

export interface BoldOptions {
  HTMLAttributes: Record<string, any>,
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    bold: {
      /**
       * Set a bold mark
       */
      setBold: () => ReturnType,
      /**
       * Toggle a bold mark
       */
      toggleBold: () => ReturnType,
      /**
       * Unset a bold mark
       */
      unsetBold: () => ReturnType,
    }
  }
}

export const starInputRegex = /(?:^|s)((?:**)((?:[^*] ))(?:**))$/
export const starPasteRegex = /(?:^|s)((?:**)((?:[^*] ))(?:**))/g
export const underscoreInputRegex = /(?:^|s)((?:__)((?:[^__] ))(?:__))$/
export const underscorePasteRegex = /(?:^|s)((?:__)((?:[^__] ))(?:__))/g

export const Bold = Mark.create<BoldOptions>({
  name: 'bold',

  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },

  parseHTML() {
    return [
      {
        tag: 'strong',
      },
      {
        tag: 'b',
        getAttrs: node => (node as HTMLElement).style.fontWeight !== 'normal' && null,
      },
      {
        style: 'font-weight',
        getAttrs: value => /^(bold(er)?|[5-9]d{2,})$/.test(value as string) && null,
      },
    ]
  },

  renderHTML({ HTMLAttributes }) {
    return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },

  addCommands() {
    return {
      setBold: () => ({ commands }) => {
        return commands.setMark(this.name)
      },
      toggleBold: () => ({ commands }) => {
        return commands.toggleMark(this.name)
      },
      unsetBold: () => ({ commands }) => {
        return commands.unsetMark(this.name)
      },
    }
  },

  addKeyboardShortcuts() {
    return {
      'Mod-b': () => this.editor.commands.toggleBold(),
      'Mod-B': () => this.editor.commands.toggleBold(),
    }
  },

  addInputRules() {
    return [
      markInputRule({
        find: starInputRegex,
        type: this.type,
      }),
      markInputRule({
        find: underscoreInputRegex,
        type: this.type,
      }),
    ]
  },

  addPasteRules() {
    return [
      markPasteRule({
        find: starPasteRegex,
        type: this.type,
      }),
      markPasteRule({
        find: underscorePasteRegex,
        type: this.type,
      }),
    ]
  },
})

他的代码不多,也就100行左右,也就实现了加粗的功能,我们可以看到,这里面,定义了一些正则,还定义了一些命令,以及快捷键。

可以看到主要的逻辑是,当触发快捷键,时,会给选择的文本增加 ** **,再次触发,会去掉选中。实际上渲染的样式是会表现为 html结构插入到dom中,而renderHtml 就是干这个事情的,但是,加粗的方式不一,所以,多种形式都可以被解析为是 文本加粗。

那么,如何实现一个类似于NotionAI的方式的扩展插件呢?

想必,大家都基本上体验过Notion那种Ai赋能的写作之爽了吧,总之开始用的时候是惊艳到我了,那么,像NotionAI那种输入 / ,就呼出菜单的扩展,该如何实现呢?实际上,这种就就需要用到addProseMirrorPlugins的方式。

代码语言:javascript复制
const SlashExtensions = Extension.create({
    name: 'slash-command',
    addOptions() {
      return {
        suggestion
      };
    },

    addProseMirrorPlugins() {
      return [
        Suggestion({
          editor: this.editor,
          ...this.options.suggestion
        })
      ];
    }
  });

然后具体的逻辑无非就是通过editor读取上下文,然后调用 ChatGPT 接口,拿到响应,在通过editor的接口写到到文档即可。当然我有实现一个成品的NotionAi 的平替,在utools上搜索 aition即可体验。

示例示例
示例示例

我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!

0 人点赞