Linter 示例

Linting example

浏览器 DOM 很好地完成了它的使命——表示复杂的网页。但它庞大的范围和松散的结构,使得我们很难对它做出假设。一个只表示较小文档集合的文档模型,更容易进行推理。

这个示例实现了一个简单的文档 linter,它能找出文档中的问题,并让你轻松修复它们。

这个示例的第一部分是一个函数:给定一个文档,它产生在该文档中发现的问题数组。我们会使用 descendants 方法方便地遍历文档中的所有节点。根据节点的类型,检查不同类型的问题。

每个问题都表示为一个带有 message、start 和 end 的对象,这样它们就能被显示和高亮。这些对象还可以可选地带有一个 fix 方法,调用它(传入视图)即可修复该问题。

// Words you probably shouldn't use
const badWords = /\b(obviously|clearly|evidently|simply)\b/ig
// Matches punctuation with a space before it
const badPunc = / ([,\.!?:]) ?/g

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

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

  // For each node in the document
  doc.descendants((node, pos) => {
    if (node.isText) {
      // Scan text nodes for suspicious patterns
      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") {
      // Check whether heading levels fit under the current level
      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) {
      // Ensure images have alt text
      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"}, {prob}),
               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
    return icon
  }
}

问题对象被存储在 decoration spec 中,这样事件处理器就能从插件的 decoration set 中取回它。我们让单击图标选中被标注的区域,双击则运行 fix 方法。

每次变化都重新计算整组问题、重新创建整组装饰并不高效,所以对于生产代码,你可能想考虑一种能够增量更新这些内容的方法。那会复杂不少,但绝对可行——事务可以给你所需的信息,让你弄清楚文档的哪一部分发生了变化。

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

function getProb(view, plugin, dom) {
  let pos = view.posAtDOM(dom, 0)
  let found = plugin.getState(view.state)
               .find(pos, pos, spec => spec.prob?.msg == dom.title)
  return found.length ? found[0].type.spec.prob : null
}

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 prob = getProb(view, this, event.target)
        if (prob)
          view.dispatch(view.state.tr
            .setSelection(TextSelection.create(view.state.doc, prob.from, prob.to))
            .scrollIntoView())
        return true
      }
    },
    handleDoubleClick(view, _, event) {
      if (/lint-icon/.test(event.target.className)) {
        let prob = getProb(view, this, event.target)
        if (prob?.fix) {
          prob.fix(view)
          view.focus()
          return true
        }
      }
    }
  }
})