🎁 100 free AI Toolkit licenses – apply by August 15.Learn more

构建自定义查找和替换界面

Editor

查找和替换扩展是无头的。它可以搜索、高亮、导航和替换文本,但不会渲染界面。你可以自行决定控件的位置:工具栏、模态框、侧边栏、浮动面板,或由键盘驱动的工作流。

本指南将构建一个小型界面,其中包含搜索和替换输入框、匹配选项、结果导航以及替换操作。

UI 如何连接到扩展

该扩展提供了用于更新搜索内容和执行操作的命令。其当前状态可通过 editor.storage.findAndReplace 获取。

使用命令处理输入和按钮事件:

editor.commands.setSearchTerm('Tiptap')
editor.commands.goToNextResult()
editor.commands.replaceAll()

读取存储以显示选中的结果,并在没有匹配项时禁用操作:

const { currentIndex, results } = editor.storage.findAndReplace
const resultLabel =
  results.length === 0 ? 'No results' : `${(currentIndex ?? 0) + 1} of ${results.length}`

React

使用 useEditorState 仅订阅控件所需的查找和替换存储值。这样可以让 UI 保持同步,同时避免因无关的编辑器更改而重新渲染。

import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
import FindAndReplace from '@tiptap/extension-find-and-replace'
import StarterKit from '@tiptap/starter-kit'

function FindAndReplaceControls({ editor }) {
  const state = useEditorState({
    editor,
    selector: (context) => ({
      searchTerm: context.editor.storage.findAndReplace.searchTerm,
      replaceTerm: context.editor.storage.findAndReplace.replaceTerm,
      caseSensitive: context.editor.storage.findAndReplace.caseSensitive,
      useRegex: context.editor.storage.findAndReplace.useRegex,
      wholeWord: context.editor.storage.findAndReplace.wholeWord,
      resultCount: context.editor.storage.findAndReplace.results.length,
      currentIndex: context.editor.storage.findAndReplace.currentIndex,
    }),
  })

  const resultLabel =
    state.resultCount === 0
      ? '无结果'
      : `${(state.currentIndex ?? 0) + 1} / ${state.resultCount}`

  return (
    <div className="find-and-replace">
      <input
        aria-label="搜索"
        placeholder="搜索"
        value={state.searchTerm}
        onChange={(event) => editor.commands.setSearchTerm(event.currentTarget.value)}
        onKeyDown={(event) => {
          if (event.key !== 'Enter') return

          if (event.shiftKey) {
            editor.commands.goToPreviousResult()
          } else {
            editor.commands.goToNextResult()
          }
        }}
      />
      <input
        aria-label="替换"
        placeholder="替换"
        value={state.replaceTerm}
        onChange={(event) => editor.commands.setReplaceTerm(event.currentTarget.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={state.caseSensitive}
          onChange={(event) => editor.commands.setCaseSensitive(event.currentTarget.checked)}
        />
        匹配大小写
      </label>
      <label>
        <input
          type="checkbox"
          checked={state.wholeWord}
          disabled={state.useRegex}
          onChange={(event) => editor.commands.setWholeWord(event.currentTarget.checked)}
        />
        全字匹配
      </label>
      <label>
        <input
          type="checkbox"
          checked={state.useRegex}
          onChange={(event) => editor.commands.setUseRegex(event.currentTarget.checked)}
        />
        正则表达式
      </label>
      <button
        disabled={state.resultCount === 0}
        onClick={() => editor.commands.goToPreviousResult()}
      >
        上一个
      </button>
      <button disabled={state.resultCount === 0} onClick={() => editor.commands.goToNextResult()}>
        下一个
      </button>
      <button disabled={state.resultCount === 0} onClick={() => editor.commands.replace()}>
        替换
      </button>
      <button disabled={state.resultCount === 0} onClick={() => editor.commands.replaceAll()}>
        全部替换
      </button>
      <button onClick={() => editor.commands.clearSearch()}>清除</button>
      <span>{resultLabel}</span>
    </div>
  )
}

export default function FindAndReplaceEditor() {
  const editor = useEditor({
    extensions: [StarterKit, FindAndReplace],
    content: '<p>在本文档中搜索 Tiptap。</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <>
      <FindAndReplaceControls editor={editor} />
      <EditorContent editor={editor} />
    </>
  )
}

Vue

同一个接口可以在模板中读取编辑器存储,并在事件处理器中调用命令。此示例使用 Vue 的 Options API,但命令和存储在 Composition API 中的工作方式相同。

<template>
  <div v-if="editor">
    <div class="find-and-replace">
      <input
        aria-label="搜索"
        placeholder="搜索"
        :value="editor.storage.findAndReplace.searchTerm"
        @input="(event) => editor.commands.setSearchTerm(event.target.value)"
        @keydown.enter.exact="editor.commands.goToNextResult()"
        @keydown.shift.enter="editor.commands.goToPreviousResult()"
      />
      <input
        aria-label="替换"
        placeholder="替换"
        :value="editor.storage.findAndReplace.replaceTerm"
        @input="(event) => editor.commands.setReplaceTerm(event.target.value)"
      />
      <label>
        <input
          type="checkbox"
          :checked="editor.storage.findAndReplace.caseSensitive"
          @change="(event) => editor.commands.setCaseSensitive(event.target.checked)"
        />
        区分大小写
      </label>
      <label>
        <input
          type="checkbox"
          :checked="editor.storage.findAndReplace.wholeWord"
          :disabled="editor.storage.findAndReplace.useRegex"
          @change="(event) => editor.commands.setWholeWord(event.target.checked)"
        />
        全字匹配
      </label>
      <label>
        <input
          type="checkbox"
          :checked="editor.storage.findAndReplace.useRegex"
          @change="(event) => editor.commands.setUseRegex(event.target.checked)"
        />
        正则表达式
      </label>
      <button :disabled="hasNoResults" @click="editor.commands.goToPreviousResult()">
        上一个
      </button>
      <button :disabled="hasNoResults" @click="editor.commands.goToNextResult()">下一个</button>
      <button :disabled="hasNoResults" @click="editor.commands.replace()">替换</button>
      <button :disabled="hasNoResults" @click="editor.commands.replaceAll()">全部替换</button>
      <button @click="editor.commands.clearSearch()">清除</button>
      <span>{{ resultLabel }}</span>
    </div>
    <editor-content :editor="editor" />
  </div>
</template>

<script>
import FindAndReplace from '@tiptap/extension-find-and-replace'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'

export default {
  components: { EditorContent },

  data() {
    return { editor: null }
  },

  computed: {
    hasNoResults() {
      return this.editor.storage.findAndReplace.results.length === 0
    },

    resultLabel() {
      const { currentIndex, results } = this.editor.storage.findAndReplace

      if (results.length === 0) {
        return 'No results'
      }

      return `${(currentIndex ?? 0) + 1} of ${results.length}`
    },
  },

  mounted() {
    this.editor = new Editor({
      extensions: [StarterKit, FindAndReplace],
      content: '<p>Search this document for Tiptap.</p>',
    })
  },

  beforeUnmount() {
    this.editor.destroy()
  },
}
</script>

适配界面

这些示例中的控件只是其中一种选择。你可以将相同的命令和存储功能呈现在通过 Cmd/Ctrl+F 打开的对话框、持久侧边栏或紧凑工具栏中。

构建自己的 UI 时,启用正则表达式模式后,请禁用或隐藏全词匹配功能。正则表达式会改变匹配项的查找方式,但替换文本始终会按字面插入。