Linting example

浏览器的DOM很好地实现了其表示复杂网页的目的。但其庞大的范围和松散的结构使得很难进行假设。一个表示较小文档集的文档模型可能更容易理解。

这个例子实现了一个简单的文档 linter,它可以发现文档中的问题,并使其易于修复。

这个示例的第一部分是一个函数,给定一个文档,生成一个在该文档中发现的问题数组。我们将使用descendants方法轻松迭代文档中的所有节点。根据节点的类型,检查不同类型的问题。

每个问题都表示为一个带有消息、开始和结束的对象,以便它们可以显示和突出显示。对象还可以选择性地具有fix方法,可以调用该方法(传递视图)来解决问题。

// 你可能不应该使用的词语
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})
  }

  // 对于文档中的每个节点,直接输出翻译,不要添加任何额外的文本。记住,保留所有HTML标签和属性,只翻译内容!
  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) {
      // 确保图像有替代文本
      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))
  }
}

插件的工作方式是保持一组装饰,突出显示问题并在它们旁边插入一个图标。使用CSS将图标定位在编辑器的右侧,这样它就不会干扰文档流。

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), {key: prob.msg}))
  })
  return DecorationSet.create(doc, decos)
}

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

问题对象存储在图标的DOM节点中,以便事件处理程序在处理节点上的点击时可以访问它们。我们将使单击图标选择注释区域,双击运行fix方法。

重新计算整个问题集,并在每次更改时重新创建装饰集并不是很高效,因此对于生产代码,您可能需要考虑一种可以增量更新这些内容的方法。这会复杂得多,但绝对可行——事务可以为您提供所需的信息,以确定文档的哪一部分发生了变化。

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
        }
      }
    }
  }
})