---
title: "使用装饰在原生 JavaScript 中高亮搜索词"
description: "Build a term highlighter for a vanilla Tiptap editor with inline, node, and widget decorations."
canonical_url: "https://tiptap.zhcndoc.com/guides/decorations-vanilla"
---

# 使用装饰在原生 JavaScript 中高亮搜索词

Build a term highlighter for a vanilla Tiptap editor with inline, node, and widget decorations.

本教程将为不使用 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 页面：

```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` 扩展：

```ts
// 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 的构建块。从粗体文本到代码块，每项功能都存在于扩展中。我们需要一个读取搜索词并生成装饰的自定义扩展。

先创建一个空扩展：

```ts
// 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`:

```ts
// 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:

```ts
// 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.

```ts
// 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:

```ts
// 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:

```ts
// 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](https://tiptap.zhcndoc.com/editor/core-concepts/decorations.md#widget-keys) in the core concepts guide.

## 完整扩展

下面是包含三种装饰类型的完整扩展：

```ts
// 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:

```ts
// 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 类。添加以下样式使其可见：

```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](https://tiptap.zhcndoc.com/editor/core-concepts/decorations.md) in the core concepts guide.
- Read the full [Decorations API reference](https://tiptap.zhcndoc.com/editor/api/decorations.md) for every signature and option.
- See the [React tutorial](https://tiptap.zhcndoc.com/guides/decorations-react.md) or [Vue 3 tutorial](https://tiptap.zhcndoc.com/guides/decorations-vue.md) if you want to render framework components as widgets.
