Help us learn about your current experience with the documentation. Take the survey.

富文本编辑器开发指南

富文本编辑器是一个 UI 组件,为 GitLab 应用中的 GitLab Flavored Markdown 提供 WYSIWYG(所见即所得)编辑体验。它也是实现专注于 Markdown 的编辑器的基础,这些编辑器可以针对其他引擎,如静态站点生成器。

我们使用 Tiptap 2.0ProseMirror 来构建富文本编辑器。这些框架在原生的 contenteditable Web 技术之上提供了一层抽象。

使用指南

按照以下说明将富文本编辑器包含在功能中。

  1. 包含富文本编辑器组件
  2. 设置和获取 Markdown
  3. 监听变化

包含富文本编辑器组件

导入 ContentEditor Vue 组件。我们建议使用异步命名导入来利用缓存,因为 ContentEditor 是一个较大的依赖项。

<script>
export default {
  components: {
    ContentEditor: () =>
      import(
        /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
      ),
  },
  // 组件定义的其余部分
}
</script>

富文本编辑器需要两个属性:

  • renderMarkdown 是一个异步函数,返回调用 Markdown API 的响应(字符串)。
  • uploadsPath 是一个指向支持 multipart/form-dataGitLab 上传服务 的 URL。

查看 WikiForm.vue 组件,了解这两个属性的生产环境示例。

设置和获取 Markdown

ContentEditor Vue 组件没有实现 Vue 数据绑定流(v-model),因为设置和获取 Markdown 是昂贵的操作。数据绑定会在每次用户与组件交互时触发这些操作。

相反,你应该通过监听 initialized 事件来获取 ContentEditor 类的实例:

<script>
import { createAlert } from '~/alert';
import { __ } from '~/locale';

export default {
  methods: {
    async loadInitialContent(contentEditor) {
      this.contentEditor = contentEditor;

      try {
        await this.contentEditor.setSerializedContent(this.content);
      } catch (e) {
        createAlert({ message: __('无法加载初始文档') });
      }
    },
    submitChanges() {
      const markdown = this.contentEditor.getSerializedContent();
    },
  },
};
</script>
<template>
  <content-editor
    :render-markdown="renderMarkdown"
    :uploads-path="pageInfo.uploadsPath"
    @initialized="loadInitialContent"
  />
</template>

监听变化

你仍然可以对富文本编辑器中的变化做出反应。响应变化可以帮助你了解文档是否为空或已修改。使用 @change 事件处理程序来实现此目的。

<script>
export default {
  data() {
    return {
      empty: false,
    };
  },
  methods: {
    handleContentEditorChange({ empty }) {
      this.empty = empty;
    }
  },
};
</script>
<template>
  <div>
    <content-editor
      :render-markdown="renderMarkdown"
      :uploads-path="pageInfo.uploadsPath"
      @initialized="loadInitialContent"
      @change="handleContentEditorChange"
    />
    <gl-button :disabled="empty" @click="submitChanges">
      {{ __('提交更改') }}
    </gl-button>
  </div>
</template>

实现指南

富文本编辑器由三个主要层组成:

  • 编辑工具 UI,如工具栏和表格结构编辑器。它们显示编辑器的状态并通过分发命令来修改它。
  • Tiptap Editor 对象管理编辑器的状态,并将业务逻辑作为由编辑工具 UI 执行的命令暴露出来。
  • Markdown 序列化器将 Markdown 源字符串转换为 ProseMirror 文档,反之亦然。

编辑工具 UI

编辑工具 UI 是 Vue 组件,它们显示编辑器的状态并分发 命令 来修改它。它们位于 ~/content_editor/components 目录中。例如,粗体工具栏按钮通过在用户选择粗体文本时变为活动状态来显示编辑器的状态。此按钮还分发 toggleBold 命令将文本格式化为粗体:

sequenceDiagram
    participant A as 编辑工具 UI
    participant B as Tiptap 对象
    A->>B: 查询状态/分发命令
    B--)A: 通知状态变化

节点视图

我们实现 节点视图 为某些内容类型(如表和图像)提供内联编辑工具。节点视图允许将内容类型的表示与其 模型 分离。在表示层中使用 Vue 组件可以在富文本编辑器中实现复杂的编辑体验。节点视图位于 ~/content_editor/components/wrappers

分发命令

你可以将 Tiptap Editor 对象注入到 Vue 组件中以分发命令。

不要在 Vue 组件中实现修改编辑器状态的逻辑。将此逻辑封装在命令中,并从组件的方法中分发该命令。

<script>
export default {
  inject: ['tiptapEditor'],
  methods: {
    execute() {
      // 错误做法
      const { state, view } = this.tiptapEditor.state;
      const { tr, schema } = state;
      tr.addMark(state.selection.from, state.selection.to, null, null, schema.mark('bold'));

      // 正确做法
      this.tiptapEditor.chain().toggleBold().focus().run();
    },
  }
};
</script>
<template>

查询编辑器状态

使用 EditorStateObserver 无渲染组件来响应编辑器状态的变化,例如文档或选择发生变化时。你可以监听以下事件:

  • docUpdate
  • selectionUpdate
  • transaction
  • focus
  • blur
  • error

Tiptap 事件指南 中了解有关这些事件的更多信息。

<script>
// 为效率考虑,部分代码已被隐藏
import EditorStateObserver from './editor_state_observer.vue';

export default {
  components: {
    EditorStateObserver,
  },
  data() {
    return {
      error: null,
    };
  },
  methods: {
    displayError({ message }) {
      this.error = message;
    },
    dismissError() {
      this.error = null;
    },
  },
};
</script>
<template>
  <editor-state-observer @error="displayError">
    <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError">
      {{ error }}
    </gl-alert>
  </editor-state-observer>
</template>

Tiptap 编辑器对象

Tiptap Editor 类管理编辑器的状态,并封装了驱动富文本编辑器的所有业务逻辑。富文本编辑器构建此类的实例,并提供所有必要的扩展来支持 GitLab Flavored Markdown

实现新扩展

扩展是富文本编辑器的构建块。你可以通过阅读 Tiptap 指南 来学习如何实现新的扩展。我们建议在从头实现新扩展之前,先检查内置的 节点标记 列表。

将富文本编辑器扩展存储在 ~/content_editor/extensions 目录中。使用 Tiptap 内置扩展时,在此目录内的 ES6 模块中包装它:

export { Bold as default } from '@tiptap/extension-bold';

使用 extend 方法来自定义扩展的行为:

import { HardBreak } from '@tiptap/extension-hard-break';

export default HardBreak.extend({
  addKeyboardShortcuts() {
    return {
      'Shift-Enter': () => this.editor.commands.setHardBreak(),
    };
  },
});

注册扩展

~/content_editor/services/create_content_editor.js 中注册新扩展。导入扩展模块并将其添加到 builtInContentEditorExtensions 数组中:

import Emoji from '../extensions/emoji';

const builtInContentEditorExtensions = [
  Code,
  CodeBlockHighlight,
  Document,
  Dropcursor,
  Emoji,
  // 其他扩展
]

Markdown 序列化器

Markdown 序列化器将 Markdown 字符串转换为 ProseMirror 文档,反之亦然。

反序列化

反序列化是将 Markdown 转换为 ProseMirror 文档的过程。我们首先使用 Markdown API 端点 将 Markdown 渲染为 HTML,从而利用 ProseMirror 的 HTML 解析和序列化功能

sequenceDiagram
    participant A as 富文本编辑器
    participant E as Tiptap 对象
    participant B as Markdown 序列化器
    participant C as Markdown API
    participant D as ProseMirror 解析器
    A->>B: 反序列化(markdown)
    B->>C: 渲染(markdown)
    C-->>B: html
    B->>D: 转换为文档(html)
    D-->>A: 文档
    A->>E: 设置内容(文档)

反序列化器位于扩展模块中。阅读有关 parseHTMLaddAttributes 的 Tiptap 文档,以了解如何实现它们。Tiptap API 是 ProseMirror schema spec API 的包装器。

序列化

序列化是将 ProseMirror 文档转换为 Markdown 的过程。Content Editor 使用 prosemirror-markdown 来序列化文档。我们建议在实现序列化器之前阅读 MarkdownSerializerMarkdownSerializerState 类的文档:

sequenceDiagram
    participant A as 富文本编辑器
    participant B as Markdown 序列化器
    participant C as ProseMirror Markdown
    A->>B: 序列化(文档)
    B->>C: 序列化(文档, 序列化器)
    C-->>A: Markdown 字符串

prosemirror-markdown 需要为富文本编辑器支持的每种内容类型实现序列化器函数。我们在 ~/content_editor/services/markdown_serializer.js 中实现序列化器。