从零开始定义 Schema
Schemas from scratch
ProseMirror 的 schema 为文档提供了一种类似语法的东西——它们规定了哪些结构是合法的。
ProseMirror schemas provide something like a syntax for documents—they set down which structures are valid.
最简单的 schema 允许文档只由文本组成。
The most simple schema possible allows the document to be composed just of text.
import {Schema} from "prosemirror-model"
const textSchema = new Schema({
nodes: {
text: {},
doc: {content: "text*"}
}
})
你可以用它来编辑行内内容。(ProseMirror 视图可以被挂载到任何节点上,包括行内节点。)
You can use it to edit inline content. (A ProseMirror view can be mounted on any node, including inline nodes.)
块(Blocks)
Blocks
为了增加更多结构,你通常需要添加某种块节点。例如,这个 schema 由 note(便签)组成,note 可以进一步被 group 节点分组。
To add more structure, you'll usually want to add some kind of block nodes. For example, this schema consists of notes that can optionally be grouped with group nodes.
const noteSchema = new Schema({
nodes: {
text: {},
note: {
content: "text*",
toDOM() { return ["note", 0] },
parseDOM: [{tag: "note"}]
},
notegroup: {
content: "note+",
toDOM() { return ["notegroup", 0] },
parseDOM: [{tag: "notegroup"}]
},
doc: {
content: "(note | notegroup)+"
}
}
})
对于不是文本、也不是顶层节点的节点,必须提供 toDOM 方法,这样编辑器才能渲染它们;还要提供 parseDOM 值,这样它们才能被解析。这个 schema 使用自定义的 DOM 节点 <note> 和 <notegroup> 来表示它的节点。
For nodes that aren't text or top-level nodes, it is
necessary to provide
toDOM methods, so that the editor can
render them, and parseDOM values, so
that they can be parsed. This schema uses custom DOM nodes <note>
and <notegroup> to represent its nodes.
你可以按 ctrl-space 给选中的 note 添加一个分组。要实现这个功能,你首先得实现一个自定义的编辑命令。大致像这样:
You can press ctrl-space to add a group around the selected notes. To get that functionality, you first have to implement a custom editing command. Something like this:
import {findWrapping} from "prosemirror-transform"
function makeNoteGroup(state, dispatch) {
// Get a range around the selected blocks
let range = state.selection.$from.blockRange(state.selection.$to)
// See if it is possible to wrap that range in a note group
let wrapping = findWrapping(range, noteSchema.nodes.notegroup)
// If not, the command doesn't apply
if (!wrapping) return false
// Otherwise, dispatch a transaction, using the `wrap` method to
// create the step that does the actual wrapping.
if (dispatch) dispatch(state.tr.wrap(range, wrapping).scrollIntoView())
return true
}
像 keymap({"Ctrl-Space": makeNoteGroup}) 这样的 keymap 可以用来启用它。
A keymap like keymap({"Ctrl-Space": makeNoteGroup}) can
be used to enable it.
通用的按键绑定(enter 和 backspace)在这个 schema 下工作得很好——enter 会拆分光标所在的 textblock,如果它为空,就尝试把它从父节点中提升出来,因此可以用来创建新的 note 以及从 note group 中脱离。在 textblock 开头按 backspace 会把该 textblock 从父节点中提升出来,这可以用来把 note 从 group 中移除。
The generic bindings for enter and backspace work just fine in this schema—enter will split the textblock around the cursor, or if that's empty, try to lift it out of its parent node, and thus can be used to create new notes and escape from a note group. Backspace at the start of a textblock will lift that textblock out of its parent, which can be used to remove notes from a group.
分组与标记(Groups and marks)
Groups and marks
让我们再来一个,加上星星和「喊叫」。
Let's do one more, with stars and shouting.
这个 schema 的行内内容不仅有文本,还有 star,它只是行内节点。为了能方便地引用我们的两种行内节点,它们被标记为一个组(也叫 "inline")。这个 schema 对两种块节点也做了同样的事:一种 paragraph 类型允许任意行内内容,另一种只允许不带标记的文本。
This schema has not just text as inline content, but also stars,
which are just inline nodes. To be able to easily refer to both our
inline nodes, they are tagged as a group (also called "inline"). The
schema does the same for the two types of block nodes, one paragraph
type that allows any inline content, and one that only allows unmarked
text.
let starSchema = new Schema({
nodes: {
text: {
group: "inline",
},
star: {
inline: true,
group: "inline",
toDOM() { return ["star", "🟊"] },
parseDOM: [{tag: "star"}]
},
paragraph: {
group: "block",
content: "inline*",
toDOM() { return ["p", 0] },
parseDOM: [{tag: "p"}]
},
boring_paragraph: {
group: "block",
content: "text*",
marks: "",
toDOM() { return ["p", {class: "boring"}, 0] },
parseDOM: [{tag: "p.boring", priority: 60}]
},
doc: {
content: "block+"
}
},
由于 textblock 默认允许标记,boring_paragraph 类型把 marks 设为空字符串,以明确禁止标记。
Since textblocks allow marks by default, the boring_paragraph type
sets marks to the empty string to
explicitly forbid them.
这个 schema 定义了两种标记:喊叫文本和链接。第一种类似于常见的 strong 或 emphasis 标记,它只是给所标记的内容添加一个位的信息,而不带任何属性。它指定自己应该渲染为 <shouting> 标签(该标签被样式化为行内、加粗和大写),并且同一个标签应该被解析为这个标记。
The schema defines two types of marks, shouted text and links. The
first is like the common strong or emphasis marks, in that it just
adds a single bit of information to the content it marks, and doesn't
have any attributes. It specifies that it should be rendered as a
<shouting> tag (which is styled to be inline, bold, and uppercase),
and that that same tag should be parsed as this mark.
marks: {
shouting: {
toDOM() { return ["shouting", 0] },
parseDOM: [{tag: "shouting"}]
},
link: {
attrs: {href: {}},
toDOM(node) { return ["a", {href: node.attrs.href}, 0] },
parseDOM: [{tag: "a", getAttrs(dom) { return {href: dom.href} }}],
inclusive: false
}
}
})
链接确实有一个属性——它们的目标 URL,所以它们的 DOM 序列化方法必须输出它(toDOM 返回的数组中的第二个元素,如果它是一个普通对象,就提供了一组 DOM 属性),而它们的 DOM 解析器则必须读取它。
Links do have an attribute—their target URL, so their DOM serializing
method has to output that (the second element in an array returned
from toDOM, if it's a plain object, provides a set of DOM
attributes), and their DOM parser has to read it.
默认情况下,标记是 inclusive(包容性)的,意思是它们会应用到在它们末尾插入的内容上(如果它们从其父节点的开头开始,也会应用到开头插入的内容上)。对于链接类型的标记,这通常不是期望的行为,可以把 mark spec 上的 inclusive 属性设为 false 来禁用该行为。
By default, marks are inclusive, meaning that they get applied to
content inserted at their end (as well as at their start when they
start at the start of their parent node). For link-type marks, this is
usually not the expected behavior, and the
inclusive property on the mark spec
can be set to false to disable that behavior.
为了能够与这些元素交互,我们又需要添加一个自定义 keymap。有一个用于切换标记的命令辅助函数,我们可以直接把它用在喊叫标记上。
To make it possible to interact with these elements we again have to add a custom keymap. There's a command helper for toggling marks, which we can use directly for the shouting mark.
import {toggleMark} from "prosemirror-commands"
import {keymap} from "prosemirror-keymap"
let starKeymap = keymap({
"Ctrl-b": toggleMark(starSchema.marks.shouting),
"Ctrl-q": toggleLink,
"Ctrl-Space": insertStar
})
切换链接要稍微复杂一些。当没有选中任何内容时,启用或禁用非包容性标记是没有意义的,因为你不能像包容性标记那样「输入进去」。而且我们需要向用户询问一个 URL——但只有在要添加链接时才问。所以这个命令在提示输入 URL 之前,先用 rangeHasMark 检查它是要添加还是移除。
Toggling a link is a little more involved. En- or disabling
non-inclusive marks when nothing is selected isn't meaningful, since
you can't “type into’ them like you can with inclusive marks. And we
need to ask the user for a URL—but only if a link is being added. So
the command uses rangeHasMark to check
whether it will be adding or removing, before prompting for a URL.
(prompt 很可能不是你在真实系统中想用的东西。当你用异步方法向用户询问某些内容时,应用命令效果时务必使用当前的状态,而不是命令最初被调用时的状态。)
(prompt is probably not what you'd want to use in a real system.
When using an asynchronous method to query the user for something,
make sure to use the current state, not the state when the command
was originally called, when applying the command's effect.)
function toggleLink(state, dispatch) {
let {doc, selection} = state
if (selection.empty) return false
let attrs = null
if (!doc.rangeHasMark(selection.from, selection.to, starSchema.marks.link)) {
attrs = {href: prompt("Link to where?", "")}
if (!attrs.href) return false
}
return toggleMark(starSchema.marks.link, attrs)(state, dispatch)
}
插入星星的命令首先检查 schema 是否允许在光标位置插入一个星星(使用 canReplaceWith),如果可以,就用新建的 star 节点替换选区。
The command that inserts a star first checks whether the schema allows
one to be inserted at the cursor position (using
canReplaceWith), and if so, replaces
the selection with a newly created star node.
function insertStar(state, dispatch) {
let type = starSchema.nodes.star
let {$from} = state.selection
if (!$from.parent.canReplaceWith($from.index(), $from.index(), type))
return false
dispatch(state.tr.replaceSelectionWith(type.create()))
return true
}