使用装饰在原生 JavaScript 中高亮搜索词
本教程将为不使用 React、Vue 或其他框架的 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 badge after each match.
你将使用原生 DOM API 手动构建小部件,无需框架小部件渲染器。
什么是装饰?
装饰是显示在文档之上的视觉标记。它们会改变用户看到的内容,但不会改变文档内容。将编辑器保存为 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.
You create all three with the Decoration class, imported from @tiptap/core.
设置编辑器和搜索输入框
先创建一个包含搜索输入框和编辑器容器的基础 HTML 页面:
<!-- index.html -->
<div class="highlight-toolbar">
<input id="search-input" type="text" placeholder="Type a word to highlight..." />
</div>
<div id="editor"></div>然后在 JavaScript 中设置编辑器。我们导入 StarterKit 提供基础编辑功能,并导入下一步将创建的 Highlight 扩展:
// main.ts
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Highlight } from './highlight'
import './styles.css'
// Create the editor inside the #editor div
const editor = new Editor({
element: document.getElementById('editor')!,
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>
`,
})Highlight 扩展还不存在,下面来创建它。
创建高亮扩展
扩展是 Tiptap 的构建块。从粗体文本到代码块,每项功能都存在于扩展中。我们需要一个读取搜索词并生成装饰的自定义扩展。
先创建一个空扩展:
// highlight.ts
import { Extension } from '@tiptap/core'
export const Highlight = Extension.create({
name: 'highlight',
})Every extension needs a name. The name is how Tiptap identifies your extension when you call commands like setSearchTerm or updateDecorations('highlight').
Right now the extension does nothing. We will add decorations to it step by step.
Store the search term
The search term comes from the input field outside the editor. The extension needs a place to store it so the decoration logic can read it later.
Tiptap extensions have a storage property for this. Storage is a plain object that lives on the extension instance. You initialize it with addStorage:
// highlight.ts
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
addStorage() {
return {
term: '',
}
},
})Writing to storage alone does not trigger a decoration rebuild. The caller would need to write to storage and then call updateDecorations() every time. That is easy to forget. Instead, we add a custom command that does both in one step:
// highlight.ts (complete file so far)
import { Extension } from '@tiptap/core'
// ... HighlightStorage and declare module stay the same ...
export const Highlight = Extension.create<{}, HighlightStorage>({
name: 'highlight',
addStorage() {
return { term: '' }
},
// addCommands defines custom commands you can call via editor.commands.
// We create one command that stores the term and refreshes decorations.
addCommands() {
return {
// setSearchTerm takes a string and returns a command function.
// Tiptap commands always return a function that receives the editor state.
setSearchTerm: (term: string) => ({ editor, commands }) => {
// Write the term into storage so create() can read it
editor.storage.highlight.term = term
// Tell Tiptap to rebuild this extension's decorations.
// This calls create() again with the new search term.
commands.updateDecorations('highlight')
return true
},
}
},
})Now the caller only needs one call: editor.commands.setSearchTerm('tiptap'). The command handles storage and the decoration refresh together. We will use it from the input handler later. First, let us add the decoration logic.
查找并高亮匹配项
This is the core of the extension. We add an addDecorations hook that scans the document for matches and returns inline decorations for each one.
// highlight.ts
import { Decoration, Extension } from '@tiptap/core'
// ... HighlightStorage, declare module, 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().
// It receives { editor, state, view }. We use state to read the document.
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 []
// We collect decorations into this array as we find matches
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.
At this point, if you call editor.commands.setSearchTerm('tiptap'), every occurrence of "tiptap" gets a yellow highlight (once you add the CSS). The command stores the term and triggers a rebuild in one step.
Outline blocks that contain matches
Inline decorations highlight individual words. Node decorations can outline the entire block (paragraph, heading, etc.) that contains a match. This helps the user spot which paragraphs have results.
We add node decorations inside the same create function. To avoid outlining the same block twice, we track which blocks we have already decorated:
// Inside create, before the descendants() call:
// 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 decoration: highlight the match itself
decorations.push(
Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
)
// Node decoration: 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)
}
})Now every paragraph or heading that contains a match gets a CSS class has-match. We will style that with an outline later.
Add a numbered badge after each match
The last decoration type is the widget. A widget decoration inserts a DOM element at a single position in the document. We will render a small numbered badge after each match so the user can see which match is which.
Unlike the React and Vue guides, we build the widget by hand with document.createElement. No framework needed.
First, we need a counter so each match gets a unique number:
// Inside create, before the descendants() call:
// Track the match number across all text nodes
let matchNumber = 0
// 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 decoration: highlight the match
decorations.push(
Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
)
// Node decoration: outline the containing block (once per block)
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 decoration: a numbered badge after the match.
// The render callback creates the DOM element.
// It receives (view, getPos) but we do not need them for a static badge.
// Snapshot the number: the render callback runs after create() returns,
// when matchNumber already holds the final total.
const badgeNumber = ++matchNumber
decorations.push(
Decoration.Widget(
matchTo,
() => {
// Create a span element to show the match number
const badge = document.createElement('span')
badge.className = 'match-badge'
// contentEditable must be false so the user cannot type inside the badge
badge.contentEditable = 'false'
badge.textContent = String(badgeNumber)
return badge
},
{
// The key identifies this widget across rebuilds.
// Position-based keys are fine here because the badge has no state.
// If the widget held state (like a counter the user could click),
// you would use a stable key like `match-${id}` instead.
key: `match-badge-${matchFrom}`,
// side: 1 places the widget after the match (right side)
side: 1,
},
),
)
index = text.indexOf(term, index + term.length)
}
})小部件 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.
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.ts
import { Decoration, Extension } from '@tiptap/core'
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 badge after the match
// Snapshot the number: the render callback runs after
// create() returns, when matchNumber holds the final total.
const badgeNumber = ++matchNumber
decorations.push(
Decoration.Widget(
matchTo,
() => {
const badge = document.createElement('span')
badge.className = 'match-badge'
badge.contentEditable = 'false'
badge.textContent = String(badgeNumber)
return badge
},
{ key: `match-badge-${matchFrom}`, side: 1 },
),
)
index = text.indexOf(term, index + term.length)
}
})
return decorations
},
}
},
})连接搜索输入框
The extension is ready. Now we wire up the input field so that when the user types, we call our setSearchTerm command. The command stores the term and triggers a decoration rebuild in one step.
Add this to main.ts, after the editor is created:
// main.ts (continued)
// Find the search input we added in the HTML
const searchInput = document.getElementById('search-input') as HTMLInputElement
// Listen for every keystroke in the input
searchInput.addEventListener('input', () => {
// Our custom command stores the term and rebuilds decorations.
// No need to touch storage or call updateDecorations separately.
editor.commands.setSearchTerm(searchInput.value)
})That is the full flow: the user types, the command stores the term and triggers a rebuild, and Tiptap calls our create function which scans the document and returns new decorations.
设置装饰样式
装饰会向文档添加 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 React tutorial or Vue 3 tutorial if you want to render framework components as widgets.