项目

数据模型

引言

与许多其他编辑器不同,Tiptap 是基于一个 数据模型 来构建的,它定义了内容的结构。这样,你可以定义文档中可能出现的节点类型、它们的属性以及它们如何嵌套。

这个数据模型非常严格。你不能使用数据模型中未定义的任何 HTML 元素或属性。

让我举个例子:如果你在 Tiptap 中粘贴像 This is <strong>important</strong> 这样的内容,但没有处理 strong 标签的扩展,你只会看到 This is important,而不会看到强调标签。如果你想了解这种情况何时发生,可以在启用 enableContentCheck 选项后监听 contentError 事件。

数据模型的样子

当你只使用提供的扩展时,你不需要太关注数据模型。如果你正在自建扩展,理解数据模型的工作原理可能会有所帮助。让我们看看一个典型 ProseMirror 编辑器的最简单数据模型:

// 基础的 ProseMirror 数据模型
{
  nodes: {
    doc: {
      content: 'block+',
    },
    paragraph: {
      content: 'inline*',
      group: 'block',
      parseDOM: [{ tag: 'p' }],
      toDOM: () => ['p', 0],
    },
    text: {
      group: 'inline',
    },
  },
}

这里注册了三个节点:docparagraphtextdoc 是根节点,允许一个或多个块级节点作为子节点( content: 'block+' )。因为 paragraph 属于块级节点组( group: 'block' ),所以文档只能包含段落。段落允许零个或多个内联节点作为子节点( content: 'inline*' ),因此只能有 textparseDOM 定义了如何从粘贴的 HTML 中解析节点,toDOM 则定义了它在 DOM 中如何渲染。

在 Tiptap 中,每个节点、标记和扩展都存在于自己的文件中,这使得逻辑分离开来。在底层,整个数据模型会被合并:

// Tiptap 数据模型 API
import { Node } from "@tiptap/core";

const Document = Node.create({
  name: "doc",
  topNode: true,
  content: "block+",
});

const Paragraph = Node.create({
  name: "paragraph",
  group: "block",
  content: "inline*",
  parseHTML() {
    return [{ tag: "p" }];
  },
  renderHTML({ HTMLAttributes }) {
    return ["p", HTMLAttributes, 0];
  },
});

const Text = Node.create({
  name: "text",
  group: "inline",
});

节点和标记

区别

节点就像内容块,比如段落、标题、代码块、引言等。

标记可以应用于节点的特定部分,如粗体斜体 或~~删除线~~ 文本。链接 也是标记。

节点数据模型

内容

content 属性精确地定义了节点可以包含的特定内容。ProseMirror 对此非常严格,意味着不符合数据模型的内容将被丢弃。它期望一个名称或组名作为字符串。这里有一些例子:

Node.create({
  // 必须有一个或多个块
  content: "block+",

  // 必须有零个或多个块
  content: "block*",

  // 允许所有类型的 'inline' 内容(文本或硬换行)
  content: "inline*",

  // 只能有 'text' 的内容
  content: "text*",

  // 可以有一个或多个段落,或者如果有列表的话,也可以有列表
  content: "(paragraph|list?)+",

  // 必须有一个顶级标题,下面有一个或多个块
  content: "heading block+",
});

标记

你可以使用 marks 属性在数据模型中定义哪些标记可以在节点内部使用。添加一个或多个名称或标记组,允许所有、不允许所有标记,如下所示:

Node.create({
  // 只允许 'bold' 标记
  marks: "bold",

  // 只允许 'bold' 和 'italic' 标记
  marks: "bold italic",

  // 允许所有标记
  marks: "_",

  // 不允许任何标记
  marks: "",
});

将该节点添加到一组扩展中,该组可以在数据模型的 content 属性中引用。

Node.create({
  // 添加到 'block' 组
  group: "block",

  // 添加到 'inline' 组
  group: "inline",

  // 添加到 'block' 和 'list' 组
  group: "block list",
});

内联

节点也可以作为内联渲染。设置 inline: true 会使节点与文本一起排版。例如,提到就是这种情况。结果更像一个标记,但具有节点的功能。一个区别是生成的 JSON 文档。多个标记会同时应用,而内联节点会导致嵌套结构。

Node.create({
  // 将节点内联渲染,例如
  inline: true,
});

对于某些情况下,你想要标记不具备的功能,比如节点视图,尝试使用内联节点是否可行:

Node.create({
  name: "customInlineNode",
  group: "inline",
  inline: true,
  content: "text*",
});

原生不可编辑

节点带有 atom: true 时,它们不是直接可编辑的,应被视为单个单元。在编辑器上下文中不太可能使用这种方式,但它看起来像这样:

Node.create({
  atom: true,
});

一个例子是 Mention 扩展,它看起来像文本,但实际上更像是一个整体单元。由于它没有可编辑的文本内容,复制这样的节点时会为空。不过,好消息是你可以控制这一点。这是 Mention 扩展的一个示例:

// 将原子节点转换为纯文本
renderText({ node }) {
  return `@${node.attrs.id}`
},

可选选择

除了可见的文本选择外,还有一个不可见的节点选择。如果你想让你的节点可选中,可以这样配置:

Node.create({
  selectable: true,
});

可拖动

所有节点都可以通过 draggable 设置为可拖动(默认不拖动):

Node.create({
  draggable: true,
});

代码

用户期望代码的行为非常特殊。对于包含代码的所有节点,你可以设置 code: true 来考虑这一点。

Node.create({
  code: true,
});

空白处理

控制节点中的空白如何解析。

Node.create({
  whitespace: "pre",
});

替代

当节点的内容被完全替换(例如,粘贴新内容)时,节点会被替换掉。如果一个节点在这种替换操作中应该保留,可以将其配置为 defining

通常适用于 BlockquoteCodeBlockHeadingListItem

Node.create({
  defining: true,
});

隔离

对于希望在常规编辑操作(如退格)时隔离光标的节点,例如表格单元格,设置 isolating: true

Node.create({
  isolating: true,
});

允许间隙光标

Gapcursor 扩展注册了一个新的数据模型属性,用于控制该节点内是否允许使用间隙光标。

Node.create({
  allowGapCursor: false,
});

表格角色

Table 扩展注册了一个新的数据模型属性,用于配置节点的角色。允许的值为 tablerowcellheader_cell

Node.create({
  tableRole: "cell",
});

标记数据模型

包含性

如果你想让标记在光标到达其末尾时不激活,设置 inclusivefalse。例如,Link 标记就是这样配置的:

Mark.create({
  inclusive: false,
});

排除

默认情况下,所有标记可以同时应用。通过 excludes 属性,你可以定义哪些标记不能与当前标记共存。例如,内联代码标记排除任何其他标记(粗体、斜体和其他标记)。

Mark.create({
  // 不得与 'bold' 标记共存
  excludes: "bold",

  // 排除所有其他标记
  excludes: "_",
});

可退出

默认情况下,标记会 “捕获” 光标,意味着光标不能离开标记,除非将光标从左向右移动到没有标记的文本中。如果设置为 true,则当标记位于节点末尾时,标记将变为可退出的。这对于使用代码标记尤其方便。

Mark.create({
  // 将此标记设置为可退出 - 默认为 false
  exitable: true,
});

分组

将此标记添加到一组扩展中,该组可以在内容属性的 schema 中引用。

Mark.create({
  // 将此标记添加到 '基本' 组
  group: "基本",
  // 将此标记添加到 '基本' 和 'foobar' 组
  group: "基本 foobar",
});

代码模式

用户期望代码的行为有所不同。对于包含代码的所有标记,可以设置 code: true 来考虑这一点。

Mark.create({
  code: true,
});

跨节点

默认情况下,渲染为 HTML 时,标记可以跨越多个节点。设置 spanning: false 表示标记不应跨越多个节点。

Mark.create({
  spanning: false,
});

获取底层的 ProseMirror 协议

在某些情况下,您需要与底层协议进行交互。如果您正在使用 Tiptap 的协作文本编辑功能,或者想手动将内容渲染为 HTML,则需要这个。

方案 1:通过 Editor

如果您需要在客户端使用,并且已经有一个 Editor 实例,可以通过 Editor 获取:

import { Editor } from "@tiptap/core";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";

const editor = new Editor({
  extensions: [
    Document,
    Paragraph,
    Text,
    // 这里添加更多扩展
  ],
});

const schema = editor.schema;

方案 2:不使用 Editor

如果您只想获得 schema,但不需要初始化实际的编辑器,可以使用 getSchema 辅助函数。它需要可用扩展的数组,并为您方便地生成 ProseMirror 协议:

import { getSchema } from "@tiptap/core";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";

const schema = getSchema([
  Document,
  Paragraph,
  Text,
  // 这里添加更多扩展
]);
在本文档中