使用装饰在 React 中高亮搜索词
本教程将为 React Tiptap 编辑器构建词语高亮功能。用户在输入框中输入搜索词,文档中的每个匹配项都会被高亮。文档本身不会发生变化,高亮效果纯粹影响视觉呈现。
完成的功能会使用三种装饰类型:
- Inline decorations highlight every match with a yellow background.
- Node decorations outline every block that contains at least one match.
- Widget decorations render a small numbered React badge after each match.
什么是装饰?
装饰是显示在文档之上的视觉标记。它们会改变用户看到的内容,但不会改变文档内容。将编辑器保存为 JSON 或 HTML 时,装饰不会被包含在其中。
Tiptap 有三种装饰类型:
- Inline wraps a range of text in a styled span. Use it for highlights.
- Node adds attributes (like a CSS class) to the DOM wrapper of a whole block. Use it to outline paragraphs or headings.
- Widget inserts a DOM element at a single position. Use it for badges, markers, or small UI elements.
三种装饰都使用从 @tiptap/core 导入的 Decoration 类创建。对于渲染 React 组件的小部件,请使用 @tiptap/react 中的 ReactWidgetRenderer。
设置编辑器和搜索输入框
先创建一个同时渲染搜索输入框和编辑器的基础 React 组件:
// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'
import { Highlight } from './Highlight'
import './styles.css'
export function TiptapEditor() {
// We store the search term in React state so the input stays controlled
const [searchTerm, setSearchTerm] = useState('')
const editor = useEditor({
extensions: [StarterKit, Highlight],
content: `
<h2>Tiptap decorations tutorial</h2>
<p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
<p>You can highlight words without changing the document.</p>
<p>Try typing "tiptap" in the search box above.</p>
`,
})
if (!editor) {
return null
}
return (
<div>
<div className="highlight-toolbar">
<input
aria-label="Search"
placeholder="Type a word to highlight..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<EditorContent editor={editor} />
</div>
)
}Highlight 扩展还不存在,我们会在下一步创建它。输入框已经连接到 React state,但还没有连接到编辑器;扩展准备好后再完成连接。
创建高亮扩展
扩展是 Tiptap 的构建块。从粗体文本到代码块,每项功能都存在于扩展中。我们需要一个读取搜索词并生成装饰的自定义扩展。
先创建一个空扩展,并为搜索词添加 storage 字段:
// Highlight.tsx
import { Extension } from '@tiptap/core'
// This tells TypeScript what our storage looks like
interface HighlightStorage {
term: string
}
// Tiptap keeps every extension's storage in one shared object: editor.storage.
// TypeScript does not know about our "highlight" key by default, so we use
// declare module to add it. This gives us type checking on editor.storage.highlight.
declare module '@tiptap/core' {
interface Storage {
highlight: HighlightStorage
}
}
export const Highlight = Extension.create<{}, HighlightStorage>({
name: 'highlight',
// addStorage returns the initial values for this extension's storage.
// We store the search term here so the decoration logic can read it.
addStorage() {
return {
term: '',
}
},
// addCommands defines custom commands callable via editor.commands.
// Writing to storage alone does not trigger a decoration rebuild, so we
// create one command that stores the term and refreshes decorations together.
addCommands() {
return {
setSearchTerm: (term: string) => ({ editor, commands }) => {
editor.storage.highlight.term = term
commands.updateDecorations('highlight')
return true
},
}
},
})Storage 是位于扩展实例上的普通对象。单独写入 storage 不会触发装饰重建,因此我们添加了 setSearchTerm 命令,在一步操作中存储搜索词并调用 updateDecorations。稍后会从 React 组件中调用它。在 addDecorations 内,我们从 storage 读取搜索词。
创建徽章小部件组件
在添加装饰之前,先创建小部件要渲染的 React 组件。它是一个显示在每个匹配项后的编号小徽章。
ReactWidgetRenderer automatically passes editor and getPos to your component as props, alongside any props you provide. For this badge we only need the match number:
// MatchBadge.tsx
import type { ReactWidgetDecorationProps } from '@tiptap/react'
// ReactWidgetDecorationProps gives us editor and getPos.
// We add our own prop: the match number to display.
interface MatchBadgeProps extends ReactWidgetDecorationProps {
matchNumber: number
}
export function MatchBadge({ matchNumber }: MatchBadgeProps) {
return (
<span className="match-badge" contentEditable={false}>
{matchNumber}
</span>
)
}contentEditable={false} 属性很重要。没有它,用户可能在徽章内输入内容,从而干扰 ProseMirror 的编辑逻辑。
什么是 editor 和 getPos?
Every widget component receives editor (the Tiptap editor instance) and getPos (a function
that returns the widget's current document position). We do not need them for a static badge, but
they are essential for interactive widgets. For example, a "replace" button would use getPos()
to know which part of the document to replace. Always call getPos() when you need the position,
never store its result, because the position changes as the user edits.
查找并高亮匹配项
现在为扩展添加 addDecorations 钩子。在这里扫描文档中的匹配项并返回装饰。
我们分三步完成:先添加行内装饰,再添加节点装饰,最后添加小部件装饰。
第一步:使用行内装饰进行高亮
// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'
// ... HighlightStorage, declare module, addStorage, and addCommands stay the same ...
export const Highlight = Extension.create<{}, HighlightStorage>({
name: 'highlight',
addStorage() {
return { term: '' }
},
addDecorations() {
return {
// 'manual' means Tiptap will not rebuild decorations on every keystroke.
// We decide when to rebuild by calling editor.commands.updateDecorations().
update: 'manual',
// create runs on init and every time we call updateDecorations().
create: ({ editor, state }) => {
// Read the search term from storage and clean it up
const term = editor.storage.highlight.term.trim().toLowerCase()
// If there is no search term, return an empty array (no decorations)
if (!term) return []
const decorations: Decoration[] = []
// descendants() walks every node in the document.
// Each node comes with its starting position (pos).
state.doc.descendants((node, pos) => {
// We only care about text nodes. Skip headings, paragraphs, etc.
if (!node.isText || !node.text) return
// Search inside this text node (case-insensitive)
const text = node.text.toLowerCase()
let index = text.indexOf(term)
// Keep searching until we run out of matches in this text node
while (index !== -1) {
// pos is where the text node starts.
// index is where the match starts inside the text node.
// So the match's document position is pos + index.
const matchFrom = pos + index
const matchTo = matchFrom + term.length
// Create an inline decoration that wraps the match in a span
// with the CSS class "highlight-match"
decorations.push(
Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
)
// Move past this match and look for the next one
index = text.indexOf(term, index + term.length)
}
})
return decorations
},
}
},
})为什么使用手动更新策略?
The search term comes from outside the document (an input field). When the user types in the
editor, the document changes but the search term does not. There is no reason to rebuild
decorations on every keystroke. With update: 'manual', Tiptap only maps existing decorations
to their new positions (which is fast) and waits for us to call updateDecorations() when the
search term actually changes.
第二步:使用节点装饰描边块
行内装饰会高亮单个词语。节点装饰可以描边包含匹配项的整个块(段落、标题等)。我们会记录已经装饰过的块,以避免重复:
// Inside create, before the descendants() call, add a Set to track decorated blocks:
create: ({ editor, state }) => {
const term = editor.storage.highlight.term.trim().toLowerCase()
if (!term) return []
const decorations: Decoration[] = []
// Track block start positions we have already outlined
const decoratedBlocks = new Set<number>()
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return
const text = node.text.toLowerCase()
let index = text.indexOf(term)
while (index !== -1) {
const matchFrom = pos + index
const matchTo = matchFrom + term.length
// Inline: highlight the match
decorations.push(
Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
)
// Node: outline the block that contains this match.
// resolve() gives us info about the position, including which block it sits in.
const $match = state.doc.resolve(matchFrom)
// depth is how deeply the match is nested: 1 is a top-level block,
// 2 is a block inside another block. The calculation selects the
// innermost block around the match.
const depth = Math.max(1, $match.depth)
// before() and after() give us the start and end of the containing block
const blockStart = $match.before(depth)
const blockEnd = $match.after(depth)
// Only outline each block once, even if it has multiple matches
if (!decoratedBlocks.has(blockStart)) {
decoratedBlocks.add(blockStart)
decorations.push(
Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
)
}
index = text.indexOf(term, index + term.length)
}
})
return decorations
},第三步:使用小部件装饰显示编号徽章
现在添加小部件。我们使用 ReactWidgetRenderer 渲染之前创建的 MatchBadge 组件。需要一个计数器为所有文本节点中的匹配项编号:
// Add the import at the top of Highlight.tsx:
import { ReactWidgetRenderer } from '@tiptap/react'
import { MatchBadge } from './MatchBadge'
// Inside create, add a counter before the descendants() call:
let matchNumber = 0
// Then inside the while loop, after the node decoration:
// Widget: render a numbered React badge after the match.
// ReactWidgetRenderer takes the component and an options object.
// The component receives the props we pass plus editor and getPos.
matchNumber++
decorations.push(
ReactWidgetRenderer(MatchBadge, {
editor,
// Place the widget right after the match
pos: matchTo,
// The key identifies this widget across rebuilds.
// Position-based keys are fine here because the badge has no state.
// For stateful widgets, use a stable key like `match-${id}` instead.
key: `match-badge-${matchFrom}`,
// These props are passed to the MatchBadge component
props: { matchNumber },
// side: 1 places the widget after the match (right side)
side: 1,
}),
)小部件 key 说明
Every widget needs a key. ProseMirror uses the key to decide whether to reuse the widget's DOM
across redraws or destroy and recreate it. If the key stays the same, the widget stays mounted and
React preserves its state. If the key changes, the widget is recreated.
For stateless widgets like this badge, a position-based key (match-badge-${matchFrom}) is fine.
For stateful widgets (anything that holds data the user changed), use a stable key tied to the
item's identity, like comment-${id}. See the
widget keys section in the core concepts guide.
完整扩展
下面是包含三种装饰类型的完整扩展:
// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'
import { MatchBadge } from './MatchBadge'
interface HighlightStorage {
term: string
}
// Augment Tiptap's Storage interface so editor.storage.highlight is typed
declare module '@tiptap/core' {
interface Storage {
highlight: HighlightStorage
}
}
export const Highlight = Extension.create<{}, HighlightStorage>({
name: 'highlight',
addStorage() {
return { term: '' }
},
addCommands() {
return {
setSearchTerm: (term: string) => ({ editor, commands }) => {
editor.storage.highlight.term = term
commands.updateDecorations('highlight')
return true
},
}
},
addDecorations() {
return {
update: 'manual',
create: ({ editor, state }) => {
const term = editor.storage.highlight.term.trim().toLowerCase()
if (!term) return []
const decorations: Decoration[] = []
let matchNumber = 0
const decoratedBlocks = new Set<number>()
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return
const text = node.text.toLowerCase()
let index = text.indexOf(term)
while (index !== -1) {
const matchFrom = pos + index
const matchTo = matchFrom + term.length
// Inline: highlight the match
decorations.push(
Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
)
// Node: outline the containing block once
const $match = state.doc.resolve(matchFrom)
const depth = Math.max(1, $match.depth)
const blockStart = $match.before(depth)
const blockEnd = $match.after(depth)
if (!decoratedBlocks.has(blockStart)) {
decoratedBlocks.add(blockStart)
decorations.push(
Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
)
}
// Widget: numbered React badge after the match
matchNumber++
decorations.push(
ReactWidgetRenderer(MatchBadge, {
editor,
pos: matchTo,
key: `match-badge-${matchFrom}`,
props: { matchNumber },
side: 1,
}),
)
index = text.indexOf(term, index + term.length)
}
})
return decorations
},
}
},
})连接搜索输入框
现在将输入框连接到扩展。当搜索词变化时,将其写入扩展的 storage,并通知 Tiptap 重建装饰:
// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'
import { Highlight } from './Highlight'
import './styles.css'
export function TiptapEditor() {
const [searchTerm, setSearchTerm] = useState('')
const editor = useEditor({
extensions: [StarterKit, Highlight],
content: `
<h2>Tiptap decorations tutorial</h2>
<p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
<p>You can highlight words without changing the document.</p>
<p>Try typing "tiptap" in the search box above.</p>
`,
})
// Called every time the input value changes
const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchTerm(value)
if (!editor) return
// Our custom command stores the term and rebuilds decorations.
// No need to touch storage or call updateDecorations separately.
editor.commands.setSearchTerm(value)
}
if (!editor) {
return null
}
return (
<div>
<div className="highlight-toolbar">
<input
aria-label="Search"
placeholder="Type a word to highlight..."
value={searchTerm}
onChange={onSearchChange}
/>
</div>
<EditorContent editor={editor} />
</div>
)
}流程是:用户输入,React state 更新,setSearchTerm 命令存储搜索词并触发重建,Tiptap 再调用我们的 create 函数扫描文档并返回新装饰。
设置装饰样式
装饰会向文档添加 CSS 类。添加以下样式使其可见:
/* styles.css */
/* Inline decoration: yellow highlight on each match */
.highlight-match {
background: #fff3a3;
border-radius: 2px;
}
/* Node decoration: outline blocks that contain matches */
.has-match {
outline: 2px solid #ffc857;
outline-offset: 2px;
border-radius: 2px;
}
/* Widget decoration: numbered badge after each match */
.match-badge {
margin-inline-start: 0.25rem;
padding: 0.1rem 0.4rem;
background: #1f2937;
color: white;
font-size: 0.75rem;
border-radius: 0.25rem;
vertical-align: middle;
user-select: none;
}下一步
- Review decoration types and update strategies in the core concepts guide.
- Read the full Decorations API reference for every signature and option.
- See the Vanilla JS tutorial if you want to build widgets by hand without a framework, or the Vue 3 tutorial for Vue.