schema
初识 schema
schema 其实就是我们定义扩展(extension)、节点(node)和标记(mark)用的语法规范!
当您只使用我们给您提供的时这些东西时,您不必太在意 schema,但您要自定义的时候,了解其的工作原理可能会有所帮助。
让我们看一下典型 ProseMirror 编辑器的最简单 schema 长什么样:
// ProseMirror里的 schema
{
nodes: {
// 定义了一个doc的节点类型
// 每个schema (在tiptap中 为 每个editor实例)必须至少定义一个顶级节点类型(默认为"doc")和"text"文本内容的类型。
doc: {
content: 'block+',
},
paragraph: {
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
},
text: {
group: 'inline',
},
},
}
我们在这里注册了三个节点 doc,paragraph 和 text。
- doc 是文档节点,亦是根节点,它允许一个或多个块节点作为子节点
content: 'block+'
。 - paragraph 是段落节点,它隶属于块节点组
group: 'block'
,它允许零个或多个内联节点作为子节点content: 'inline*'
,因此只能 text 节点在其中。 - text 是文本节点,它隶属于行节点组
group: 'inline'
。
再来看 Tiptap 的约束长什么样。
在 Tiptap 中,每个节点、标记和扩展名都存在于自己的文件中。这允许我们拆分逻辑,最后整个模式将合并在 一起
// Tiptap里的 schema
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",
});
节点 schema
节点就像内容块,例如段落、标题、代码块、引用等等。
与许多其他编辑器不同,Tiptap 基于定义内容结构。这使您能够定义文档中可能出现的节点类型、其属性以及它们的嵌套方式。这个模式非常严格。您不能使用任何未在您的架构中定义的 HTML 元素或属性。 这就要求我们必须熟练 节点 schema。
比如:这是 <strong>非常重要的</strong>
放到 Tiptap 中,但没有任何处理 strong 标签的扩展,你只会看到这是非常重要的
渲染成功,却没有被 strong。
一个简单的文档可能是一个"doc"包含两个"paragraph" 的节点,每个 paragraph 节点包含一个"text"节点。
content
content 属性准确定义了节点可以拥有的内容类型。ProseMirror 对此非常严格。这意味着,不符合模式的内容将被丢弃。 如果不指定 content 则任何内容都会被丢弃!
content 的值来源于某一个节点类型的 group、目前tiptap 内置一些常用的节点如:block、inline 等。
Node.create({
// 内容只允许有一个block
content: 'block',
// 内容至少有1个 block类型
content: 'block+',
//内容为0或多个 block类型
content: 'block*',
// 所有的内容必须是 'inline'类型 (text 或 hard break)
content: 'inline*',
// 内容只允许 'text'类型
content: 'text*',
// 可以有一个或多个 【paragraph 或 lists】 (如果存在list)
content: '(paragraph|list?)+',
// 必须顶部有一个标题 在下边有更多的block
content: 'heading block+'
// 当然 也可以是自定义的节点类型(前提您得定义好group为custom的节点)
content: 'custom'
})
group
将此节点添加到某一分组中。然后在别的节点的content会使用。
Node.create({
// 添加到 'block' 组
group: "block",
// 添加到 'inline' 组
group: "inline",
// 添加到 'block' 和 'list' 组
group: "block list",
// 当然 也可以是自定义的节点分组名
group: "custom",
});
如果您想在 编辑器内容的(doc)根节点中 使用自定义的节点分组名,则需要重写默认根节点定义 schema的 content 属性
import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
// 重写根节点 使其的支持custom组的所有节点 作为子节点
const CustomDocument = Node.create({
name: "doc",
topNode: true,
content: "(block|custom)+",
});
const CustomNodes = Node.create({
name: "custom-node-myspan",
group: "custom",
content: "text*",
parseHTML() {
return [{ tag: "myspan" }];
},
renderHTML({ HTMLAttributes }) {
return ["myspan", HTMLAttributes, 0];
},
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomDocument, CustomNodes],
content: "<myspan>你好世界</myspan>",
});
return <EditorContent editor={editor} />;
};
inline
会换行
当父节点 schema 配置content:block
,
你的 editor 初始内容为content: '你好<myspan>世界</myspan>'
则会被渲染成
<!-- 即text会被p标签包裹起来 -->
<p>你好</p>
<p><myspan>世界</myspan></p>
这个时候子节点 schema 配置 inline 必须配置为 false,否则报错
不会换行
当父节点 schema 配置content:text*
或者content:inline*
,
你的 editor 初始内容为content: '<myspan>你好世界</myspan>'
则会被渲染成
你好<myspan>世界</myspan>
这个时候子节点 schema 配置 inline 必须配置为 true,否则报错
inline 存在的意义
看起来节点的 inline 只是被动设置,由父节点 content 的值决定。
在上边的例子中,如content:(block|custom)+
则在父节点内部内容的 render 表现为:
当前节点 myspan 即会和文本 Text 一行。此时的节点就像个标记一样,但具备节点的功能。
mark
您可以使用定义节点内允许哪些标记 marks。
Node.create({
// 此节点只允许 'bold' 标记
marks: "bold",
// 此节点允许 'bold'和 'italic' 标记
marks: "bold italic",
// 此节点允许所有标记
marks: "_",
// 此节点禁止所有标记
marks: "",
});
atom
节点不可直接编辑,应将其视为一个单元。
Node.create({
atom: true,
});

import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-refer",
group: "inline",
content: "text*",
inline: true,
atom: true, // 将此标签内容作为一个整体
parseHTML() { // 遇到什么标签会自动解析
return [{ tag: "refer" }];
},
renderHTML({ HTMLAttributes }) { // 渲染成什么样子
return ["refer", HTMLAttributes, 0];
},
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: '具体参与人员有:<refer>小张</refer><refer>小李</refer>等人员',
});
return <EditorContent editor={editor} />;
};
当然我们也内置了一个提及 Mention节点 就是用了这个。
selectable
配置节点是否可选择,一般默认是可选择的。
Node.create({
selectable: true,
});
译者尚未发现有什么作用,不知道使用场景
draggable
使用此设置可以将节点配置为可拖动

import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-draggable",
group: "inline",
content: "text*",
inline: true,
draggable: true,
parseHTML() {
return [{ tag: "draggable" }];
},
renderHTML({ HTMLAttributes }) {
return ["draggable", HTMLAttributes, 0];
},
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: '拖拽右边的小方块-><draggable/>',
});
return <EditorContent editor={editor} />;
};
code
你的内容中包含代码,但是渲染的不如你的预期(如保留空格与换行、拥有 defining 特性),可以尝试开启此配置
Node.create({
code: true,
});

- Code为true
- Code为false
import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-mycode",
group: "inline",
content: "text*",
inline: true,
code: true,
parseHTML() {
return [{ tag: "mycode" }];
},
renderHTML({ HTMLAttributes }) {
return ["mycode", HTMLAttributes, 0];
}
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: `<mycode>
const json = {
name: '张三',
age: 20
}
</mycode>`,
});
return <>
<EditorContent editor={editor} />
</>;
};

import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-mycode",
group: "inline",
content: "text*",
inline: true,
code: false,
parseHTML() {
return [{ tag: "mycode" }];
},
renderHTML({ HTMLAttributes }) {
return ["mycode", HTMLAttributes, 0];
}
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: `<mycode>
const json = {
name: '张三',
age: 20
}
</mycode>`,
});
return <>
<EditorContent editor={editor} />
</>;
};
whitespace
控制节点中的空格的处理方式
Node.create({
whitespace: "pre",
});
译者尚未发现有什么作用,不知道使用场景
defining
默认情况下,当节点的全部内容被替换时(例如,粘贴新内容时),当前节点本身就会被保留。
开启此选项,则会保证替换节点所有内容 包含节点本身。
通常,节点 Blockquote、CodeBlock、Heading 和 ListItem 会用到此项。
Node.create({
defining: true,
});
- Defining为true
- Defining为false

import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-mycode",
group: "inline",
content: "text*",
inline: true,
defining: false,
parseHTML() {
return [{ tag: "mycode" }];
},
renderHTML({ HTMLAttributes }) {
return ["mycode", HTMLAttributes, 0];
}
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: `尝试替换右侧内容:<mycode>
const json = {
name: '张三',
age: 20
}
</mycode>`,
});
return <>
<EditorContent editor={editor} />
</>;
};

import { Node } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomNode = Node.create({
name: "custom-node-mycode",
group: "inline",
content: "text*",
inline: true,
defining: true,
parseHTML() {
return [{ tag: "mycode" }];
},
renderHTML({ HTMLAttributes }) {
return ["mycode", HTMLAttributes, 0];
}
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomNode],
content: `尝试替换右侧内容:<mycode>
const json = {
name: '张三',
age: 20
}
</mycode>`,
});
return <>
<EditorContent editor={editor} />
</>;
};
isolating
将删除和编辑等光标控制在一定范围内的节点,比如单元格 TableCell会用到
Node.create({
isolating: true,
});
译者尚未发现有什么作用,除了表格的单元格会用到,其它不知道使用场景
allowGapCursor
为Gapcursor 扩展提供的一个属性,用来决定光标是否可以出现在该节点的任意间隙的地方
Node.create({
allowGapCursor: false,
});
译者尚未发现有什么作用,不知道 使用场景
tableRole
为Table 扩展提供的一个属性,来配置节点具有的角色,如表头、表行、单元格
Node.create({
// 允许的值为table、row、cell和header_cell
tableRole: "cell",
});
译者尚未发现有什么作用,不知道使用场景
标记 schema
标记可以应用于节点的特定部分 使其特殊显示,标记有:粗体、斜体、删除线、链接等
先来个例子 自己扩充加一个 【重点项】的标记

- index.jsx
- mark-dot.js
- 全局样式
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import MarkDot from './mark-dot';
export default () => {
const editor = useEditor({
extensions: [StarterKit, MarkDot],
content: "我是<text-dot>最棒</text-dot>的",
});
if (!editor) { return null;}
return (
<>
<button className={editor.isActive("dot") ? "is-active" : ""} onClick={()=>editor.commands.setDot()}>
划重点
</button>
<button onClick={()=>editor.commands.toggleDot()}>
重点切换
</button>
<button disabled={!editor.isActive("dot")} onClick={()=>editor.commands.unsetDot()}>
移除重点
</button>
<EditorContent editor={editor} />
</>
);
};
import { Mark } from "@tiptap/core";
export default Mark.create({
name: "dot",
// 反显的时候是否解析此标签(还是直接扔掉)
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
// 控制扩展如何呈现为 HTML
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
// 标记对外提供的命令
addCommands() {
return {
setDot:
() =>
({ commands }) => {
return commands.setMark(this.name);
},
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
unsetDot:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
});
text-dot {
text-emphasis: dot;
text-emphasis-position: under left;
}
inclusive
配置标记在光标结束时是否需要处于活动状态。
Mark.create({
inclusive: false,
});
这里有对比的例子
- inclusive为true
- inclusive为false

import { Mark } from "@tiptap/core";
export default Mark.create({
name: "dot",
// 光标结束时标记是否需要处于活动状态(即决定editor.isActive("dot")的值)
inclusive: true,
// 反显的时候是否解析此标签(还是直接扔掉)
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
// 控制扩展如何呈现为 HTML
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
// 标记对外提供的命令
addCommands() {
return {
setDot:
() =>
({ commands }) => {
return commands.setMark(this.name);
},
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
unsetDot:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
});
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import MarkDot from './mark-dot';
export default () => {
const editor = useEditor({
extensions: [StarterKit, MarkDot],
content: "我是<text-dot>最棒</text-dot>的",
});
if (!editor) { return null;}
return (
<>
<button className={editor.isActive("dot") ? "is-active" : ""} onClick={()=>editor.commands.setDot()}>
划重点
</button>
<button onClick={()=>editor.commands.toggleDot()}>
重点切换
</button>
<button disabled={!editor.isActive("dot")} onClick={()=>editor.commands.unsetDot()}>
移除重点
</button>
<EditorContent editor={editor} />
</>
);
};

import { Mark } from "@tiptap/core";
export default Mark.create({
name: "dot",
// 光标结束时标记是否需要处于活动状态(即决定editor.isActive("dot")的值)
inclusive: true,
// 反显的时候是否解析此标签(还是直接扔掉)
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
// 控制扩展如何呈现为 HTML
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
// 标记对外提供的命令
addCommands() {
return {
setDot:
() =>
({ commands }) => {
return commands.setMark(this.name);
},
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
unsetDot:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
});
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import MarkDot from './mark-dot';
export default () => {
const editor = useEditor({
extensions: [StarterKit, MarkDot.extend({
inclusive: false
})],
content: "我是<text-dot>最棒</text-dot>的",
});
if (!editor) { return null;}
return (
<>
<button className={editor.isActive("dot") ? "is-active" : ""} onClick={()=>editor.commands.setDot()}>
划重点
</button>
<button onClick={()=>editor.commands.toggleDot()}>
重点切换
</button>
<button disabled={!editor.isActive("dot")} onClick={()=>editor.commands.unsetDot()}>
移除重点
</button>
<EditorContent editor={editor} />
</>
);
};
excludes
默认情况下,节点可以被标记同时应用。使用 excludes 属性,您可以定义哪些标记不能与标记共存。
Mark.create({
// 不能和粗体标记同时使用
excludes: 'bold'
// 不能和其它任何标记同时使用
excludes: '_',
})
举个例子,定一个不能和粗体同时使用的标记

import { Mark } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
const CustomMark = Mark.create({
name: "dot",
// 不能和粗体标记同时使用
excludes: "bold",
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
addCommands() {
return {
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
};
},
});
export default () => {
const editor = useEditor({
extensions: [StarterKit, CustomMark],
content: "我是<text-dot>最棒</text-dot>的",
});
if (!editor) {
return null;
}
return (
<>
<button
className={editor.isActive("bold") ? "is-active" : ""}
onClick={() => editor.commands.toggleBold()}
>
粗体
</button>
<button
className={editor.isActive("dot") ? "is-active" : ""}
onClick={() => editor.commands.toggleDot()}
>
重点
</button>
<EditorContent editor={editor} />
</>
);
};
exitable
默认情况下,标记会“捕获”光标,这意味着光标无法离开标 记(比如在文末执行标记,那么您的光标向右永远也移动不出去,标记的active一直处于激活状态)。
如果设置为 true,当标记位于节点末尾时,标记将退出。
这在某些场景下很使用,比如说code标记。
Mark.create({
// 标记的光标是否可以在头尾离开 -默认为false(不离开)
exitable: true,
})
这里有个对比的例子,您将光标移动到末尾处 并使用右方向键向右移动
- Exitable为false
- Exitable为true

import { Mark } from "@tiptap/core";
export default Mark.create({
name: "dot",
// 标记的光标是否可以在头尾离开 -默认为false(不离开)
exitable: false,
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
addCommands() {
return {
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
};
},
});
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import MarkDot from "./mark-dot";
export default () => {
const editor = useEditor({
extensions: [StarterKit, MarkDot],
content: "我是<text-dot>最棒的</text-dot>",
});
if (!editor) {
return null;
}
return (
<>
<button
className={editor.isActive("dot") ? "is-active" : ""}
onClick={() => editor.chain().focus().toggleDot().run()}
>
重点
</button>
<EditorContent editor={editor} />
</>
);
};

import { Mark } from "@tiptap/core";
export default Mark.create({
name: "dot",
// 标记的光标是否可以在头尾离开 -默认为false(不离开)
exitable: false,
parseHTML() {
return [
{
tag: "text-dot",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["text-dot", HTMLAttributes, 0];
},
addCommands() {
return {
toggleDot:
() =>
({ commands }) => {
return commands.toggleMark(this.name);
},
};
},
});
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
import MarkDot from "./mark-dot";
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
MarkDot.extend({
exitable: true,
}),
],
content: "我是<text-dot>最棒的</text-dot>",
});
if (!editor) {
return null;
}
return (
<>
<button
className={editor.isActive("dot") ? "is-active" : ""}
onClick={() => editor.chain().focus().toggleDot().run()}
>
重点
</button>
<EditorContent editor={editor} />
</>
);
};
group
将您的标记 添加(或归类)到一组中。 这样后续可以在节点属性中引用。
Mark.create({
// 将此标记归类到 'basic' 组
group: 'basic',
// 将此标记归类到 'basic' 和 'foobar' 组
group: 'basic foobar',
})
code
你的内容中包含代码,但是渲染的不如你的预期(如保留空格与换行、拥有 defining 特性),可以尝试开启此配置
Mark.create({
code: true,
})
使用场景和 节点的配置项code 一样。
spanning
默认情况下,标记可以使用是跨节点的。
设置spanning: false则说明标记不得跨越多个节点。
Mark.create({
spanning: false,
})
获取底层 ProseMirror schema
在某些场景下,您可能需要使用底层Schema。
比如协作文本编辑功能,或您想要手动将您的内容呈现为 HTML。
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
如果您只想拥有架构而不初始化实际的编辑器,则可以使用getSchema辅助函数。
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,
// 下边或还有更多其它
])