在 Tiptap 中启用拼写检查

使用浏览器拼写检查为拼写错误的单词添加下划线,并在浏览器的上下文菜单中提供更正建议。只需设置两个编辑器属性,无需额外扩展。

如果需要自定义建议菜单、自定义词典,或希望在不同浏览器中获得一致的结果,可以集成自定义拼写检查器。

以下示例假设你已经安装了 Tiptap。

启用浏览器拼写检查

在现有编辑器中,将 spellcheck 和 lang 添加到 editorProps.attributes。由于这些 DOM 属性使用字符串,请使用字符串值 'true'。

对于新的 JavaScript 编辑器,先在 HTML 中添加一个容器:

<div id="editor"></div>

然后创建编辑器:

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit],
  content: '<p>This sentense has a spelling mistake.</p>',
  editorProps: {
    // Apply these attributes to the editable element.
    attributes: {
      // Ask the browser to check spelling.
      spellcheck: 'true',
      // Declare the text's language. Browser dictionary settings still apply.
      lang: 'en-US',
    },
  },
})

在 React 或 Vue 中,将相同的 editorProps 选项传给 useEditor。这样会把属性添加到 EditorContent 内部的可编辑元素上。

在编辑器中点击,输入 sentense,然后按空格键。如果已启用拼写检查,浏览器会为该单词添加下划线。打开该单词的上下文菜单即可查看建议。

HTML 的 spellcheck 属性会请求浏览器检查拼写,但用户仍可以关闭检查。将 lang 设置为文本使用的语言,例如德语使用 de-DE。浏览器还需要匹配的词典。

在运行时更改拼写检查

使用 setOptions() 开启或关闭浏览器拼写检查。复制现有的 props 和属性,以保留事件处理器及其他设置。

在上面的编辑器设置之后添加以下函数:

function setSpellcheck(enabled) {
  const editorProps = editor.options.editorProps

  editor.setOptions({
    editorProps: {
      // Keep existing event handlers and other editor props.
      ...editorProps,
      attributes: {
        // Keep attributes such as class, role, and lang.
        ...editorProps.attributes,
        // DOM attribute values are strings.
        spellcheck: enabled ? 'true' : 'false',
      },
    },
  })
}

setSpellcheck(false)

如果使用的是 attributes 函数,请更新该函数,而不是将它作为对象展开。

在原生 ProseMirror 中启用拼写检查

在 ProseMirror 中,将属性直接传给 EditorView:

import { EditorView } from 'prosemirror-view'

// state is your existing EditorState, including its schema and plugins.
const view = new EditorView(document.querySelector('#editor'), {
  state,
  // ProseMirror applies these attributes to its editable element.
  attributes: {
    // Request native spelling checks for English text.
    spellcheck: 'true',
    lang: 'en-US',
  },
})

排查缺失的拼写建议

如果看不到拼写下划线,请检查以下设置:

  1. 检查可编辑的 .tiptap 或 .ProseMirror 元素,确认它包含 contenteditable="true" 和 spellcheck="true"。
  2. 在浏览器或操作系统中启用拼写检查,并安装或选择与你的文本语言匹配的词典。
  3. 在编辑器中点击,输入拼写错误的单词,然后按空格键。有些浏览器在你编辑之前不会检查已有文本。
  4. 检查自定义 NodeView 和嵌套元素是否设置了 spellcheck="false" 或 contenteditable="false"。这些设置可能会将文本排除在检查之外。
  5. 如果出现下划线但没有建议,请检查应用是否替换了浏览器的上下文菜单,或阻止了 contextmenu 事件。

请在应用支持的浏览器和设备上测试。下划线和建议菜单由浏览器控制。如果需要在应用中读取错误或设置下划线样式,请使用自定义检查器。

集成自定义拼写检查器

下面的示例使用 nspell 检查英文文本,将错误存储在扩展中,并使用 addDecorations() 添加下划线。装饰只改变文本的显示方式,不会改变保存的文档。

更多示例请阅读装饰指南,并通过装饰 API 参考查找方法和更新设置。

选择检查引擎

  • nspell 根据词典检查单词。correct(word) 检查拼写,suggest(word) 返回可能的更正结果。你可以像下面的示例一样使用它在本地检查拼写。
  • LanguageTool 通过 HTTP 服务检查拼写和语法。如果要在用户输入时检查,请使用自己的服务器或允许自动请求的 API 方案。它的免费公共端点禁止自动请求。

请按以下顺序,将 JavaScript 代码片段放入同一个浏览器模块中。这是独立于浏览器示例的另一套编辑器设置,使用相同的 <div id="editor"></div> 容器。

1. 存储错误并添加下划线

创建一个 Spellcheck 扩展。它的存储包含错误以及 checkedDoc,也就是检查器读取的文档版本。如果文档发生变化,扩展会隐藏旧的下划线。

import { Decoration, Extension } from '@tiptap/core'

const Spellcheck = Extension.create({
  name: 'spellcheck',

  addStorage() {
    // Keep checker results outside the saved document.
    return { checkedDoc: null, errors: [] }
  },

  addDecorations() {
    return {
      // Rebuild after edits so results for the old document disappear.
      update: 'document',
      create: ({ state }) => {
        // Show results only for the document snapshot the checker used.
        if (this.storage.checkedDoc !== state.doc) return []

        // Underline each error's document range without changing its text.
        return this.storage.errors.map(({ from, to }) =>
          Decoration.Inline(from, to, { class: 'spellcheck-error' }),
        )
      },
    }
  },
})

2. 创建编辑器

将 Spellcheck 与 StarterKit 一起注册。关闭浏览器拼写检查,让下划线只由自定义检查器绘制:

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit, Spellcheck],
  content: '<p>This sentense has a spelling mistake.</p>',
  editorProps: {
    attributes: { spellcheck: 'false', lang: 'en-US' },
  },
})

添加一个保存结果并刷新下划线的函数。每个错误都需要 from 和 to,分别表示单词在文档中的起始和结束位置:

function showSpellcheckResults(checkedDoc, errors) {
  // Ignore results if the editor closed or the document changed during the check.
  if (editor.isDestroyed || editor.state.doc !== checkedDoc) return

  // Store the checked snapshot and the errors that belong to it.
  editor.storage.spellcheck.checkedDoc = checkedDoc
  editor.storage.spellcheck.errors = errors
  // Storage changes need an explicit decoration refresh.
  editor.commands.updateDecorations('spellcheck')
}

将以下规则添加到应用的样式表中:

.spellcheck-error {
  /* Draw a red, wavy underline beneath each error. */
  text-decoration-line: underline;
  text-decoration-style: wavy;
  text-decoration-color: #c62828;
}

仅修改存储不会重新绘制下划线。updateDecorations() 会让 Tiptap 根据已存储的错误重新构建装饰。

3. 加载英文词典

安装 nspell 和英文 Hunspell 词典:

npm install nspell dictionary-en

dictionary-en 使用 Node.js 读取文件。要在浏览器中使用词典,请将文件复制到应用的公共资源目录。在应用根目录运行以下命令:

mkdir -p public/dictionaries
cp node_modules/dictionary-en/index.aff public/dictionaries/en.aff
cp node_modules/dictionary-en/index.dic public/dictionaries/en.dic

这假设应用会将 public/dictionaries/en.aff 作为 /dictionaries/en.aff 提供。请根据使用的框架或基础 URL 调整路径。

在启动时加载一次这些文件。将以下代码放入与编辑器相同的浏览器模块中:

import nspell from 'nspell'

async function createEnglishSpellchecker() {
  // Fetch the affix rules and word list from your app's static assets.
  const [aff, dic] = await Promise.all(
    ['/dictionaries/en.aff', '/dictionaries/en.dic'].map(async (url) => {
      const response = await fetch(url)
      if (!response.ok) throw new Error(`Failed to load dictionary: ${url}`)
      return response.text()
    }),
  )

  // nspell accepts dictionary data as strings.
  return nspell({ aff, dic })
}

const spell = await createEnglishSpellchecker()

4. 查找并保存错误

分别检查每个段落或标题。跨越格式标记连接文本,使 sen<strong>ten</strong>se 成为一个单词。跳过代码和图片等非文本内联节点。

文档位置会计算文本和节点边界,这与 editor.getText() 中的偏移量不同。下面的函数用相同长度的空格替换跳过的内容,以保持位置对齐:

function findSpellingErrors(doc, spell) {
  const errors = []

  doc.descendants((block, blockPos) => {
    // Skip code blocks, including their descendants.
    if (block.type.spec.code) return false
    if (!block.isTextblock) return

    let text = ''

    block.forEach((child) => {
      const isCode = child.marks.some((mark) => mark.type.spec.code)

      // Text node sizes equal their JavaScript string lengths.
      // Spaces keep offsets aligned for code, hard breaks, and inline nodes.
      text += child.isText && !isCode ? child.text : ' '.repeat(child.nodeSize)
    })

    // nspell checks words. This tokenizer includes letters and contractions.
    for (const match of text.matchAll(/[\p{L}\p{M}]+(?:['’][\p{L}\p{M}]+)*/gu)) {
      const checkedWord = match[0]
      // Normalize curly apostrophes only for the dictionary lookup.
      const word = checkedWord.replaceAll('’', "'")
      if (spell.correct(word)) continue

      // Add one for the text block's opening token.
      const from = blockPos + 1 + match.index
      errors.push({
        from,
        to: from + checkedWord.length,
        checkedWord,
        suggestions: spell.suggest(word),
      })
    }

    // We already checked this block. Don't visit its children again.
    return false
  })

  return errors
}

function checkEnglishDocument() {
  if (editor.isDestroyed) return

  // Check one snapshot, then save results and refresh its decorations.
  const checkedDoc = editor.state.doc
  const errors = findSpellingErrors(checkedDoc, spell)
  showSpellcheckResults(checkedDoc, errors)
}

checkEnglishDocument()

对于示例文档,editor.storage.spellcheck.errors 会包含 sentense 的错误,位置为 from: 6、to: 14。该单词应显示红色波浪下划线。可以使用错误对象的 suggestions 数组构建更正菜单。

如果要在用户输入时重新检查,请等待输入暂停 300 毫秒。将以下代码添加到第一次检查之后:

let spellcheckTimer

editor.on('update', () => {
  // Restart the timer on each document change.
  clearTimeout(spellcheckTimer)
  spellcheckTimer = setTimeout(checkEnglishDocument, 300)
})

editor.on('destroy', () => {
  // Cancel a scheduled check when the editor closes.
  clearTimeout(spellcheckTimer)
})

此示例在主线程上检查整个文档。对于大型文档,请使用 Web Worker,或只检查发生变化的块。单词匹配模式会处理字母和缩写词,请根据应用对数字和 URL 的规则进行调整。

5. 应用更正

当用户选择建议时,将已存储的错误和替换文本传给以下函数:

function applySpellingSuggestion(error, replacement) {
  if (editor.isDestroyed) return
  const { state, view } = editor
  const { from, to, checkedWord } = error

  // Ignore suggestions from an older version of the document.
  if (editor.storage.spellcheck.checkedDoc !== state.doc) return

  // Confirm that the range still contains the checked word.
  if (state.doc.textBetween(from, to, '') === checkedWord) {
    // A transaction lets history and collaboration track the replacement.
    view.dispatch(state.tr.insertText(replacement, from, to))
  }
}

请使用 insertText() 进行替换,让已配置的历史记录和协作插件能够跟踪这些变更。不要替换 innerHTML 或编辑 DOM 文本节点。

让建议菜单支持键盘操作。添加接受更正、忽略单词或将单词加入应用词典的控件。如果单词跨越粗体或斜体文本,请决定替换后应保留哪种格式。

使用其他检查器

要使用 LanguageTool 或其他服务,请将 findSpellingErrors() 替换为对该检查器的调用。保留相同的错误字段,并将结果传给 showSpellcheckResults()。

进行异步检查时,请在发送请求前保存 editor.state.doc,并在结果中带回该文档版本。如果请求期间文档发生变化(包括协作者的编辑),showSpellcheckResults() 会丢弃这些结果。每次文档变化后都安排一次新的检查。

如果服务返回的是纯文本偏移量,请在保存错误前将其转换为文档位置。当你为发送的文本添加分隔符或移除内容时,请保留位置映射。

更多装饰示例请参考 Vanilla JavaScript、React 和 Vue 教程。

处理文档隐私

如果使用远程检查服务,请只发送需要检查的文本,并将服务凭据保存在服务器上。对于必须留在设备上的内容,请使用本地检查引擎。

浏览器拼写检查也可能根据用户的浏览器设置将文本发送到远程服务。在敏感内容上启用拼写检查前,请阅读 MDN 的拼写检查隐私说明。