Tiptap 中的装饰
装饰会改变内容在编辑器中的外观,但不会改变文档本身。将编辑器保存为 JSON 或 HTML 时,装饰不会被包含在其中。
扩展通过 addDecorations() 添加装饰。使用 Decoration 类创建装饰,Tiptap 会负责将其添加到编辑器中。
何时使用装饰而不是节点视图
当标记纯粹是视觉效果且位于文档之外时,请使用装饰。当内容属于文档本身时,请使用节点视图。
装饰与节点视图
对于不会改变文档的高亮、搜索结果、评论、注释和临时 UI 标记,请使用装饰。当你需要持久化的自定义文档内容、可编辑的自定义块或拥有文档结构的复杂嵌入式 UI 时,请使用**节点视图**。
三种装饰类型
装饰有三种类型,全部通过 Decoration 类创建:
- node 为一个节点添加属性,例如 CSS 类。
- inline 为一段文本或其他行内内容设置样式。
- widget 在文档中的某个位置添加 DOM 元素或框架组件。
声明装饰
扩展通过 addDecorations() 生命周期钩子声明装饰,该钩子返回 DecorationSpec(没有装饰时返回 null)。使用从 @tiptap/core 导入的 Decoration 类创建装饰。
import { Decoration, Extension } from '@tiptap/core'
const MyExtension = Extension.create({
name: 'myExtension',
addDecorations() {
return {
create: ({ editor, state, view }) => [Decoration.Inline(1, 5, { class: 'highlight' })],
}
},
})和其他扩展钩子一样,addDecorations() 绑定到 this({ name, options, storage, editor, type, parent }),因此你可以在声明装饰时读取选项和 storage。
从 state 参数读取状态,而不是 editor.state
在 create() 和 createInRange() 内始终从 state 参数读取文档,而不是读取 editor.state。事务执行期间,编辑器视图状态尚未更新,因此 editor.state 指向事务前的文档。state 参数才是当前正在构建的正确状态。
Decoration 类
Decoration 类提供静态方法,返回描述 Tiptap 应显示内容的装饰实例。请从 @tiptap/core 导入它:
import { Decoration } from '@tiptap/core'
// Decorate a single node (attrs go on the node's DOM wrapper)
Decoration.Node(from, to, attrs, spec)
// Decorate a range of inline content (wraps it in a styled span)
Decoration.Inline(from, to, attrs, spec)
// Insert a widget (a DOM node) at a position; `key` is REQUIRED
Decoration.Widget(pos, render, options)attrs accepts class, style, nodeName, and any other HTML attribute (ProseMirror's DecorationAttrs).
节点装饰
节点装饰会为单个节点的 DOM 包裹元素添加属性。例如,可以用它以较低成本为每个标题添加描边类:
addDecorations() {
return {
create: ({ state }) => {
const decorations = []
state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
}
})
return decorations
},
}
}行内装饰
行内装饰会将一段行内内容包裹在带样式的 span 中,适合高亮和搜索结果:
Decoration.Inline(matchFrom, matchTo, { class: 'is-match' })小部件装饰
小部件装饰会在一个位置插入 DOM 节点。与节点装饰和行内装饰不同,小部件需要 render 回调以及必需且稳定的 key:
Decoration.Widget(
pos,
(view, getPos) => {
const el = document.createElement('span')
el.textContent = '★'
return el
},
{ key: `marker-${pos}`, side: -1 },
)side controls which side of the position the widget belongs to. See ProseMirror's Decoration.widget. Every widget also needs a key.
Widget keys
Every widget decoration requires a stable, position-independent key. This is the most common source of bugs when working with widgets.
Choose stable, globally unique keys
ProseMirror reuses a widget's DOM across redraws only when the key matches the previous render. Without a stable key the widget is destroyed and recreated on every update, causing flicker and lost component state. Keys must also be globally unique across all widget decorations in the editor. Duplicate keys cause ProseMirror to misplace the widget DOM and crash.
Tiptap logs a development warning when it detects two widget decorations with the same key in a single build, naming the offending extension:
[tiptap warn]: Duplicate widget decoration key "<key>" in extension "<name>". Widget decoration keys must be globally unique…
The warning helps you find the problem. You must still make every key unique. Otherwise, ProseMirror misplaces the widget DOM.
Good keys use an ID that belongs to the item: comment-${id}, paragraph-${node.attrs.id}, suggestion-${id}.
Bad keys are unstable or position-dependent: a loop or paragraph index, a document position (marker-${from}), or any position-derived value.
If you need two widgets for one entity, suffix them: comment-${id}-start and comment-${id}-end.
Exception for stateless widgets
For stateless widgets in demos and simple examples (such as a static marker), index- or position-based keys are acceptable. Widgets that hold state must use a stable item ID instead.
Default behavior
- On editor init, every extension's
createruns once to build the initial decorations. - When the document changes, Tiptap builds the decorations again by default.
- When only the selection changes, Tiptap keeps the current decorations and updates their positions.
性能
Decorations use the document update strategy by default. This builds them again after every document change. For a large document, you can choose a different strategy to reduce the amount of work.
Use shouldUpdate to skip updates
Return false from shouldUpdate when an edit cannot affect your decorations. Tiptap will keep them and update their positions instead of building them again.
addDecorations() {
return {
create: ({ state }) => buildHeadingOutline(state),
shouldUpdate: ({ tr, oldState, newState }) => {
// Only rebuild when the number of headings changed
return countHeadings(oldState.doc) !== countHeadings(newState.doc)
},
}
}By default, shouldUpdate runs after every document change.
Use changedRanges for edited blocks
With update: 'changedRanges' and a createInRange callback, on a document change the manager:
- Updates the positions of the current decorations.
- Computes the changed range(s) of the transaction and expands them to the enclosing top-level block boundaries, so a match that overlaps the raw edit (for example typing in the middle of a word) is still fully contained.
- Removes the now-stale decorations anchored in those blocks and rebuilds only those blocks by calling
createInRange({ state, from, to }).
During normal editing, Tiptap does not call create. It only calls create when the editor starts or when you call updateDecorations(). This avoids scanning the full document after every key press.
createInRange receives a block-aligned from/to and must return only decorations within that range. It typically shares a scan helper with create:
addDecorations() {
const scan = (editor, state, from, to) => {
const decorations = []
state.doc.nodesBetween(from, to, (node, pos) => {
// …build decorations for nodes/text within [from, to]…
})
return decorations
}
return {
update: 'changedRanges',
create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
}
}createInRange contract
createInRange must return only decorations whose start position lies within [from, to).
from for inline and node decorations, pos for widget decorations. to is exclusive: it is
the next block's start, and that block rebuilds it, so decorations anchored at to are ignored.
The last block is the exception, because it owns the end of the document. Decorations anchored
before from belong to a neighbouring block that the manager did not remove, so returning them
leaks duplicate decorations that are never cleaned up. If a decoration depends on content outside
the supplied range, use the document strategy instead.
TypeScript enforces the pairing: update: 'changedRanges' requires createInRange, while the other strategies do not accept it.
Incremental mode is only correct for block-local decorations
Incremental mode is only correct when each decoration depends solely on the content within its own block or range. Tiptap only scans edited blocks again. It does not check decorations in other blocks.
Safe (local): "highlight every occurrence of a word", "outline every heading", "underline misspelled words". Each decoration depends only on its own block.
Not safe (depends on the whole document): use the default document strategy and create, or force a
full rebuild with updateDecorations():
- Ordinal/counting logic: "highlight only the first match", "color every 3rd occurrence".
- Cross-document relationships: "mark duplicate words across the document".
- Structural relations: "the first heading gets a special class".
- Anything depending on the selection or external state. Changed-range updates react to content changes only;
trigger
updateDecorations()on selection or state changes instead.
Use manual for external state
Use update: 'manual' when document transactions should only map existing decorations to their new positions. Manual decorations are rebuilt only during initialization and when you call updateDecorations().
Use the manual strategy when decorations depend on a value outside the document, such as a search query or filter. You cannot use it with shouldUpdate or createInRange.
Build decorations again
Sometimes decorations depend on something outside the document, such as a search term or filter. Call updateDecorations when that value changes:
editor.commands.updateDecorations() // Update every extension.
editor.commands.updateDecorations('myExtension') // Update one extension.updateDecorations always calls create for the full document. It ignores the update strategy and shouldUpdate for this update.
Runtime parameters via storage
Store changing values in the extension's storage. Read them inside create, then call updateDecorations after changing them:
editor.storage.myExtension.term = 'foo'
editor.commands.updateDecorations('myExtension')Rendering framework components as widgets
Widget decorations can render React or Vue components. The component stays inside your editor's React or Vue app, so hooks, context, and provide/inject continue to work.
React: ReactWidgetRenderer
Import ReactWidgetRenderer from @tiptap/react. It returns a WidgetDecoration you return from create or createInRange, alongside Decoration.Node and Decoration.Inline. Your component additionally receives editor and getPos as props.
import { Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/core'
function CommentMarker({
editor,
getPos,
label,
}: {
editor: Editor
getPos: () => number | undefined
label: string
}) {
return <button onClick={() => console.log(getPos())}>{label}</button>
}
const Comments = Extension.create({
name: 'comments',
addDecorations() {
return {
create: ({ editor, state }) =>
findComments(state.doc).map((c) =>
ReactWidgetRenderer(CommentMarker, {
editor,
pos: c.pos,
key: `comment-${c.id}`, // stable domain key → component state preserved
props: { label: c.label },
}),
),
}
},
})A few things worth knowing:
- Tiptap sends new
propswhencreateorcreateInRangeruns again. It reuses the renderer for the same key, so the component keeps its state. - The cache is swept when the editor is destroyed.
Vue: VueWidgetRenderer
Import VueWidgetRenderer from @tiptap/vue-3 (or @tiptap/vue-2). It reuses Tiptap's VueRenderer, so the component shares the editor's app context (provide/inject work). The component receives editor and getPos in addition to your props.
import { Extension } from '@tiptap/core'
import { VueWidgetRenderer } from '@tiptap/vue-3'
import CommentMarker from './CommentMarker.vue'
const Comments = Extension.create({
name: 'comments',
addDecorations() {
return {
create: ({ editor, state }) =>
findComments(state.doc).map((c) =>
VueWidgetRenderer(CommentMarker, {
editor,
pos: c.pos,
key: `comment-${c.id}`,
props: { label: c.label },
}),
),
}
},
})Vue-specific notes
The component must render a single root element. The editor is passed raw (with markRaw)
on purpose. Do not wrap it in reactivity.
Worked example: a search-term highlighter
This example uses all three kinds of decoration and incremental mode. It highlights every occurrence of a term (inline), renders a star marker before each match (widget), and outlines every heading (node). The term lives in storage and is changed at runtime via updateDecorations.
Each match and each heading depends only on its own block, so update: 'changedRanges' is safe here.
import { Decoration, Extension } from '@tiptap/core'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
export interface HighlightOptions {
term: string
}
export interface HighlightStorage {
term: string
}
declare module '@tiptap/core' {
interface Storage {
highlight: HighlightStorage
}
}
export const Highlight = Extension.create<HighlightOptions, HighlightStorage>({
name: 'highlight',
addOptions: () => ({ term: 'tiptap' }),
addStorage() {
return { term: this.options.term }
},
addDecorations() {
const scan = (editor: Editor, state: EditorState, from: number, to: number) => {
const decorations: Decoration[] = []
const term = editor.storage.highlight.term.trim().toLowerCase()
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name === 'heading') {
decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
}
if (!term || !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
decorations.push(Decoration.Inline(matchFrom, matchTo, { class: 'is-match' }))
decorations.push(
Decoration.Widget(
matchFrom,
() => {
const el = document.createElement('span')
el.textContent = '★'
return el
},
{ key: `marker-${matchFrom}`, side: -1 }, // stateless → position key OK
),
)
index = text.indexOf(term, index + term.length)
}
})
return decorations
}
return {
update: 'changedRanges', // each match/heading is block-local → safe
create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
}
},
})Change the term by updating storage and rebuilding the decorations:
editor.storage.highlight.term = 'editor'
editor.commands.updateDecorations('highlight')下一步
- Read the full Decorations API reference for every signature and option.
- Build a term highlighter with the Vanilla JS tutorial, React tutorial, or Vue 3 tutorial.
- Learn how the
addDecorationshook fits into the extension lifecycle. - Compare with node views when you need persisted, editable custom content.