从零开始定义 Schema

Schemas from scratch

ProseMirror 的 schema 为文档提供了一种类似语法的东西——它们规定了哪些结构是合法的。

最简单的 schema 允许文档只由文本组成。

import {Schema} from "prosemirror-model"

const textSchema = new Schema({
  nodes: {
    text: {},
    doc: {content: "text*"}
  }
})

你可以用它来编辑行内内容。(ProseMirror 视图可以被挂载到任何节点上,包括行内节点。)

块(Blocks)

为了增加更多结构,你通常需要添加某种块节点。例如,这个 schema 由 note(便签)组成,note 可以进一步被 group 节点分组。

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> 来表示它的节点。

你可以按 ctrl-space 给选中的 note 添加一个分组。要实现这个功能,你首先得实现一个自定义的编辑命令。大致像这样:

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 可以用来启用它。

通用的按键绑定(enter 和 backspace)在这个 schema 下工作得很好——enter 会拆分光标所在的 textblock,如果它为空,就尝试把它从父节点中提升出来,因此可以用来创建新的 note 以及从 note group 中脱离。在 textblock 开头按 backspace 会把该 textblock 从父节点中提升出来,这可以用来把 note 从 group 中移除。

分组与标记(Groups and marks)

让我们再来一个,加上星星和「喊叫」。

这个 schema 的行内内容不仅有文本,还有 star,它只是行内节点。为了能方便地引用我们的两种行内节点,它们被标记为一个组(也叫 "inline")。这个 schema 对两种块节点也做了同样的事:一种 paragraph 类型允许任意行内内容,另一种只允许不带标记的文本。

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 设为空字符串,以明确禁止标记。

这个 schema 定义了两种标记:喊叫文本和链接。第一种类似于常见的 strong 或 emphasis 标记,它只是给所标记的内容添加一个位的信息,而不带任何属性。它指定自己应该渲染为 <shouting> 标签(该标签被样式化为行内、加粗和大写),并且同一个标签应该被解析为这个标记。

  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 解析器则必须读取它。

默认情况下,标记是 inclusive(包容性)的,意思是它们会应用到在它们末尾插入的内容上(如果它们从其父节点的开头开始,也会应用到开头插入的内容上)。对于链接类型的标记,这通常不是期望的行为,可以把 mark spec 上的 inclusive 属性设为 false 来禁用该行为。

Such as this sentence.
Do laundry Water the tomatoes Buy flour Get toilet paper

This is a nice paragraph, it can have anything in it.

This paragraph is boring, it can't have anything.

Press ctrl/cmd-space to insert a star, ctrl/cmd-b to toggle shouting, and ctrl/cmd-q to add or remove a link.

为了能够与这些元素交互,我们又需要添加一个自定义 keymap。有一个用于切换标记的命令辅助函数,我们可以直接把它用在喊叫标记上。

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 检查它是要添加还是移除。

prompt 很可能不是你在真实系统中想用的东西。当你用异步方法向用户询问某些内容时,应用命令效果时务必使用当前的状态,而不是命令最初被调用时的状态。)

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 节点替换选区。

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
}