拼写检查示例

浏览器的 DOM 用来表示复杂的网页是很棒的--这也是设计它的目的。但是它巨大的页面内容和松散的结构使其很难做一些类似于「TypeScript类型推断」一样的推断(来判断用户是否书写合法的内容)。因此,一个代表了更小文档的文档模型就理所当然的应运而生了。

本示例实现了一个简单的文档 拼写检查 功能,它能够发现文档中的问题,然后方便的修复它:

Remix on Glitch

这个示例的第一个部分就是一个函数,它接受一个文档参数,返回在该文档中发现的错误数组。我们将会使用 descendants 方法去方便的迭代文档中的所有节点。然后根据不同的节点类型,来应用不同的错误检查方式。

每个错误类型被表示为一个对象,它包含有一个错误提示、一个起始位置,以及一个结束位置信息,这样我们就能够展示错误提示然后高亮错误内容。对象也可选的有一个 fix 方法,可以修复错误(传 view 作为参数):

// 定义一些你可能不想让用户使用的值
const badWords = /\b(obviously|clearly|evidently|simply)\b/ig
// 将标点符号与前面的空格匹配
const badPunc = / ([,\.!?:]) ?/g

function lint(doc) {
  let result = [], lastHeadLevel = null

  function record(msg, from, to, fix) {
    result.push({msg, from, to, fix})
  }

  // 遍历在文档中的每个节点
  doc.descendants((node, pos) => {
    if (node.isText) {
      // 扫描文本节点中的可疑的匹配内容
      let m
      while (m = badWords.exec(node.text))
        record(`Try not to say '${m[0]}'`,
               pos + m.index, pos + m.index + m[0].length)
      while (m = badPunc.exec(node.text))
        record("Suspicious spacing around punctuation",
               pos + m.index, pos + m.index + m[0].length,
               fixPunc(m[1] + " "))
    } else if (node.type.name == "heading") {
      // 检查标题的等级是否与当前的等级匹配
      let level = node.attrs.level
      if (lastHeadLevel != null && level > lastHeadLevel + 1)
        record(`Heading too small (${level} under ${lastHeadLevel})`,
               pos + 1, pos + 1 + node.content.size,
               fixHeader(lastHeadLevel + 1))
      lastHeadLevel = level
    } else if (node.type.name == "image" && !node.attrs.alt) {
      // 确保图片都有一个 alt 属性
      record("Image without alt text", pos, pos + 1, addAlt)
    }
  })

  return result
}

用来提供修复命令的工具函数大致长这样:

function fixPunc(replacement) {
  return function({state, dispatch}) {
    dispatch(state.tr.replaceWith(this.from, this.to,
                                  state.schema.text(replacement)))
  }
}

function fixHeader(level) {
  return function({state, dispatch}) {
    dispatch(state.tr.setNodeMarkup(this.from - 1, null, {level}))
  }
}

function addAlt({state, dispatch}) {
  let alt = prompt("Alt text", "")
  if (alt) {
    let attrs = Object.assign({}, state.doc.nodeAt(this.from).attrs, {alt})
    dispatch(state.tr.setNodeMarkup(this.from, null, attrs))
  }
}

插件通过维护一个 decorations 集合来高亮错误同时插入一个紧挨着错误的 icon。CSS 用来将这个 icon 定位到编辑器的右侧,这样它就脱离了文档流而不会影响内容:

import {Decoration, DecorationSet} from "prosemirror-view"

function lintDeco(doc) {
  let decos = []
  lint(doc).forEach(prob => {
    decos.push(Decoration.inline(prob.from, prob.to, {class: "problem"}),
               Decoration.widget(prob.from, lintIcon(prob)))
  })
  return DecorationSet.create(doc, decos)
}

function lintIcon(prob) {
  let icon = document.createElement("div")
  icon.className = "lint-icon"
  icon.title = prob.msg
  icon.problem = prob
  return icon
}

错误对象被存储在 icon 的 DOM 节点上,这样当点击 icon 的时候,事件处理函数能够访问到相应的信息。我们将单击设计成选中错误的区域,双击设计成执行 fix 方法。

重新计算所有的错误,然后重新创建 decorations 的集合不是一种非常高效方式,因此对于生产环境的代码你可能想要考虑一种增量更新这些 decorations 的方式。想实现这个确实有点复杂,不过却是可行的--transaction 可以为你提供文档的哪部分更新了的信息:

import {Plugin, TextSelection} from "prosemirror-state"

let lintPlugin = new Plugin({
  state: {
    init(_, {doc}) { return lintDeco(doc) },
    apply(tr, old) { return tr.docChanged ? lintDeco(tr.doc) : old }
  },
  props: {
    decorations(state) { return this.getState(state) },
    handleClick(view, _, event) {
      if (/lint-icon/.test(event.target.className)) {
        let {from, to} = event.target.problem
        view.dispatch(
          view.state.tr
            .setSelection(TextSelection.create(view.state.doc, from, to))
            .scrollIntoView())
        return true
      }
    },
    handleDoubleClick(view, _, event) {
      if (/lint-icon/.test(event.target.className)) {
        let prob = event.target.problem
        if (prob.fix) {
          prob.fix(view)
          view.focus()
          return true
        }
      }
    }
  }
})