建议工具
此实用工具可帮助编辑器中的各种建议功能。查看 Mention 或 Emoji 节点以了解其实际效果。
设置
char
触发自动补全弹出窗口的字符。
默认:'@'
pluginKey
一个 ProseMirror PluginKey。
默认:SuggestionPluginKey
allow
一个返回布尔值的函数,指示建议是否应处于活动状态。
默认:(props: { editor: Editor; state: EditorState; range: Range, isActive?: boolean }) => true
shouldShow
一个返回布尔值的函数,用于指示建议是否应处于激活状态。这对于防止在协作环境中远程用户触发建议弹窗非常有用。
shouldShow: ({ editor, range, query, text, transaction }) => {
return !isChangeOrigin(transaction)
}默认:null
shouldResetDismissed
控制用户通过 Escape 或 exitSuggestion() 关闭后,何时让已关闭的建议再次变为活动状态。
默认情况下,只要用户停留在同一个触发上下文中,该工具会保持已关闭的建议处于关闭状态。使用 allowSpaces: true 时,该已关闭上下文可以跨越空格,直到光标离开它为止。
如果你需要自行决定何时清除该已关闭上下文,请使用 shouldResetDismissed。
该回调不会在每次事务中都被调用。它只会在以下事务上进行评估:建议插件找到有效匹配、匹配通过 allow 和 shouldShow,并且存在可能需要清除的已关闭建议状态。
Suggestion({
// ...
shouldResetDismissed: ({ transaction, allowSpaces, range, match }) => {
if (!allowSpaces) {
return false
}
return transaction.doc.textBetween(range.from, match.range.to, '\n').includes('.')
},
})返回 true 以清除当前事务的已关闭状态,并允许建议再次打开。
默认:null
allowSpaces
允许或不允许建议项中包含空格。与 allowToIncludeChar 不兼容——如果 allowToIncludeChar 为 true,则此选项会被禁用。
默认:false
allowToIncludeChar
允许将触发字符本身包含在建议查询中。与 allowSpaces 不兼容。
默认:false
allowedPrefixes
允许触发建议的前缀字符。设置为 null 以允许任何前缀字符。
默认:[' ']
startOfLine
仅在行首触发自动补全弹出窗口。
默认:false
minQueryLength
用户在触发字符之后必须输入的最少字符数,之后才会调用 items() 函数。
在从 API 获取建议时很有用——它可以防止当查询太短、没有实际意义时发出不必要的网络请求。
默认:0
Suggestion({
minQueryLength: 2,
items: async ({ query, signal }) => {
// 仅在 query.length >= 2 时调用
const response = await fetch(`/api/search?q=${query}`, { signal })
return response.json()
},
})debounce
用户停止输入后,在调用 items() 之前需要等待的毫秒数。这可以防止每次按键都触发代价较高的操作(如 API 请求)。
防抖计时器会在每次按键时重置,因此只有当用户暂停输入达到指定时长后,才会调用 items() 函数。
默认:0(不防抖)
Suggestion({
debounce: 300,
items: async ({ query }) => {
// 用户停止输入 300 毫秒后调用
return searchApi(query)
},
})initialItems
在建议弹窗打开时立即显示的项目数组,在异步 items() 调用完成之前显示。items() 完成后,其结果会替换初始项目。
适用于在真实结果加载时显示最近使用或热门项目。
Suggestion({
initialItems: ['Lea Thompson', 'Cyndi Lauper', 'Tom Cruise'],
items: async ({ query }) => {
// 解析后,这些内容会替换初始项目
return fetchSearchResults(query)
},
})默认:undefined
decorationTag
应为建议呈现的 HTML 标签。
默认:'span'
decorationClass
应添加到建议的 CSS 类。
默认:'suggestion'
decorationContent
应在建议装饰中呈现的内容。
默认:''
decorationEmptyClass
当建议为空时应添加的 CSS 类。
默认:'is-empty'
placement
建议弹出窗口相对于光标或触发装饰的首选位置。使用 Floating UI 的 placement 值。
解析后的 placement 会传递给 SuggestionProps.floatingUi.placement,你可以在渲染回调中读取它。
默认:'bottom-start'
Suggestion({
placement: 'top-start',
})offset
将建议弹出窗口相对于其锚点在各轴上偏移指定像素数。这些值会传递给 Floating UI 的 offset 中间件。
默认:{ mainAxis: 4, crossAxis: 0 }
Suggestion({
offset: { mainAxis: 8, crossAxis: 4 },
})flip
启用后,当首选方向没有足够空间时,弹窗会自动翻转到另一侧。这会切换 Floating UI 的 flip 中间件。
默认:true
Suggestion({
placement: 'top-start',
flip: false,
})container
一个 CSS 选择器字符串或 HTMLElement,用于定义建议弹出窗口的容器。设置它有助于处理滚动包含或 z-index 堆叠上下文。
默认:undefined
Suggestion({
container: '#my-editor-container',
})floatingUi
用于顶层选项无法提供足够控制时的高级 Floating UI 配置。插件会保留锚点和位置的所有权——使用顶层 placement 选项来设置方向——而 floatingUi 允许你切换定位 strategy 并追加额外的 middleware。
你传入的任何 middleware 都会在插件根据顶层 offset 和 flip 选项构建出的 offset 与 flip middleware 之后追加,因此二者是组合而不是相互替换。
import { shift, size } from '@floating-ui/dom'
Suggestion({
placement: 'top-start',
floatingUi: {
strategy: 'fixed',
middleware: [shift({ padding: 8 }), size()],
},
})所有属性都是可选的。完整类型如下:
type SuggestionFloatingUiOptions = {
strategy?: 'absolute' | 'fixed'
middleware?: Middleware[]
}默认:undefined
dismissOnOutsideClick
当为 true 时,弹窗和编辑器之外的指针交互会关闭当前活动的建议。仅当你使用 受控定位(props.mount)渲染弹窗时,此选项才会生效,因为插件需要知道哪个元素是弹窗,才能判断什么算作“外部”。
默认:true
command
选择建议时执行的操作。
默认:() => {}
items
一个函数或异步函数,会根据用户输入返回过滤后的建议。该函数会接收一个 AbortSignal(signal),当用户继续输入时该信号会被中止(例如,新按键使当前请求失效)。可用它来取消正在进行的获取请求。
items: async ({ editor, query, signal }) => {
const response = await fetch(`/api/search?q=${query}`, { signal })
return response.json()
}默认:({ editor, query, signal }) => []
render
用于自动补全弹窗的渲染函数。返回一个包含生命周期钩子(onStart、onBeforeStart、onUpdate、onBeforeUpdate、onExit、onKeyDown)的对象,这些钩子会接收一个 SuggestionProps 对象。
传递给每个钩子的 SuggestionProps 对象除了现有的 editor、range、query、text、items、command、decorationNode 和 clientRect 之外,还包含以下属性:
{
/** 挂载你的弹窗元素并接管定位。推荐的默认建议弹窗渲染方式——见下方“定位”。
* 返回一个 `unmount` 函数,在 `onExit` 中调用。 */
mount: (element: HTMLElement, options?: SuggestionMountOptions) => () => void
/** 当异步 items() 调用进行中时为 true,否则为 false。
* 仅在 items() 返回 Promise 时相关。 */
loading: boolean
/** 解析后的定位选项,会从建议
* 选项中传递下来,因此你可以在渲染回调中读取它们。 */
placement: SuggestionPlacement
offset: { mainAxis: number; crossAxis: number }
flip: boolean
container?: string | HTMLElement
/** 已解析的 Floating UI 配置,可直接传给 `computePosition()`。
* 如果你想自行运行定位循环而不是使用 `mount()`,这是一个可用的备用方案。 */
floatingUi: {
placement: SuggestionPlacement
strategy: 'absolute' | 'fixed'
middleware: Middleware[]
}
}默认:() => ({})
findSuggestionMatch
可选参数,用于替换编辑器内容中触发建议的内置正则匹配。
有关更多详细信息,请参见 源代码。
默认:findSuggestionMatch(config: Trigger): SuggestionMatch
异步行为
当你的 items() 函数返回一个 Promise 时,建议插件会自动处理异步生命周期:
- 初始状态: 弹出窗口打开,
loading为true。如果提供了initialItems,这些项目会立即显示。 - 获取中:
items()函数会带着一个AbortSignal被调用。如果用户继续输入,前一个请求的信号会被中止,并在debounce延迟后发起一个新的请求。 - 解析: 一旦 promise 解析完成,返回的项目会替换当前项目,
loading变为false。onUpdate回调会携带新的SuggestionProps被触发。 - 空状态: 如果没有提供
items()函数,或者它返回一个空数组,弹出窗口会显示空状态。除非正在进行获取,否则loading属性保持为false。 - 关闭: 当弹出窗口关闭时(例如用户按下 Escape),任何进行中的请求都会自动中止。
下面是一个完整示例:
Suggestion({
minQueryLength: 2,
debounce: 300,
initialItems: ['Lea Thompson', 'Cyndi Lauper'],
items: async ({ query, signal }) => {
// 模拟一次 API 调用 —— 如果查询发生变化,这个请求将被中止
const response = await fetch(`/api/users?q=${query}`, { signal })
const data = await response.json()
return data.slice(0, 5)
},
render: () => {
let component
let unmount
return {
onStart(props) {
component = new ReactRenderer(MyList, {
props,
editor: props.editor,
})
// 让插件为你挂载并定位弹出窗口。
unmount = props.mount(component.element)
},
onUpdate(props) {
// `props.loading` 在获取时为 true,否则为 false
// `props.items` 包含解析后的数据(或解析前的 initialItems)
component.updateProps(props)
},
onExit() {
unmount?.()
component.destroy()
},
}
},
})定位
该建议工具与 Floating UI 集成,用于弹出层定位。定位弹出层有两种方式:
- 使用
props.mount的托管定位 — 推荐的默认方式。插件会挂载元素,将其锚定到光标,并持续为你保持定位。 - 手动定位 — 备用方案。你自己挂载元素,并使用
props.floatingUi和props.clientRect运行自己的定位循环。
使用 props.mount 的托管定位
在你的 render 回调中,使用你渲染出来的弹出层元素调用 props.mount(element)(例如 ReactRenderer 或 VueRenderer 的 element)。然后插件会:
- 将该元素追加到配置的
container中(默认是document.body), - 使用你的
placement、offset、flip和floatingUi选项,将其锚定到建议项的光标矩形, - 通过 Floating UI 的
autoUpdate在滚动、调整大小和布局变化时自动重新定位——无需手动监听器,并且 - 当启用
dismissOnOutsideClick时,点击外部会将其关闭。
mount() 会返回一个 unmount 函数。你必须在 onExit 中调用它,以清理自动更新循环和外部点击监听器(如果插件添加了元素,还会移除该元素)。忘记这么做会导致监听器泄漏,并在 DOM 中留下孤立的弹出层。
import { ReactRenderer } from '@tiptap/react'
import DropdownList from './DropdownList.jsx'
Suggestion({
placement: 'top-start',
offset: { mainAxis: 8 },
items: ({ query }) => fetchItems(query),
render: () => {
let component
let unmount = null
return {
onStart(props) {
component = new ReactRenderer(DropdownList, {
props,
editor: props.editor,
})
// 插件会挂载元素、定位它,并持续让它保持锚定。
unmount = props.mount(component.element)
},
onUpdate(props) {
component.updateProps(props)
// 不需要调用重新定位——autoUpdate 会保持它锚定。
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
unmount?.()
component.destroy()
},
}
},
})注意
如果你传入的元素已经附加到 DOM 中,插件不会移动或移除它——它只会对其进行定位。 在这种情况下,你仍然需要自己负责挂载和卸载该元素。如果你希望插件为你管理 DOM, 请传入一个未附加的元素。
配置 placement、offset 和 flip
使用顶层的 placement、offset 和 flip 选项来控制托管弹出层显示的位置:
Suggestion({
placement: 'top-start',
offset: { mainAxis: 8, crossAxis: 0 },
flip: true,
})如果需要更改定位 strategy 或使用自定义中间件(例如 shift 或 size),请添加 floatingUi 选项。其 middleware 会附加在插件自身的 offset 和 flip 中间件之后:
import { shift, size } from '@floating-ui/dom'
Suggestion({
placement: 'bottom-end',
floatingUi: {
strategy: 'fixed',
middleware: [
shift({ padding: 16 }),
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth}px`,
maxHeight: `${availableHeight}px`,
})
},
}),
],
},
})自定义位置的应用方式
默认情况下,托管弹出层的位置是通过向元素的 style 写入 position、left 和 top 来应用的(并且元素会在第一次测量完成前隐藏,以避免在错误坐标处闪现)。
如果你需要以不同方式应用位置——例如使用 CSS transform、动画,或写入框架 ref——请在挂载选项中传入 onPosition 回调。这样做时,插件将不再自行写入样式,而是把计算出的坐标交给你:
onStart(props) {
component = new ReactRenderer(DropdownList, { props, editor: props.editor })
unmount = props.mount(component.element, {
onPosition({ x, y, placement, strategy }) {
Object.assign(component.element.style, {
position: strategy,
transform: `translate(${x}px, ${y}px)`,
})
},
})
}注意
当你提供 onPosition 时,插件在第一次测量前将不再隐藏元素,因此请你自己先应用初始位置,以避免闪烁。
跟随动画或变换中的锚点
autoUpdate 已经会响应滚动、调整大小和布局变化。对于持续移动的锚点——例如位于经过变换或动画的容器中——你可以通过转发 autoUpdate 选项 来启用逐帧更新:
unmount = props.mount(component.element, {
autoUpdate: { animationFrame: true },
})挂载到容器中
要将弹出层渲染到特定的堆叠或滚动上下文中——例如模态框或对话框——请设置 container 选项。托管弹出层会被追加到该处,而不是 document.body:
Suggestion({
container: '#my-dialog',
})字符串会被当作 CSS 选择器处理;你也可以直接传入一个 HTMLElement。如果选择器匹配不到任何元素(或者无效),插件会回退到 document.body。
手动定位(备用方案)
如果你需要对挂载和定位拥有完全控制权,请完全跳过 props.mount。你自己挂载元素,然后结合解析后的 props.floatingUi 配置和 props.clientRect 运行自己的 Floating UI 循环。因为这里的 DOM 由你自己管理,你也需要负责在 onUpdate 时重新定位,并在 onExit 时清理:
import { computePosition, shift } from '@floating-ui/dom'
import { ReactRenderer } from '@tiptap/react'
function reposition(props, element) {
const reference = { getBoundingClientRect: () => props.clientRect() }
computePosition(reference, element, props.floatingUi).then(({ x, y, strategy }) => {
Object.assign(element.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`,
})
})
}
Suggestion({
floatingUi: {
strategy: 'fixed',
middleware: [shift({ padding: 8 })],
},
render: () => {
let component
return {
onStart(props) {
component = new ReactRenderer(DropdownList, { props, editor: props.editor })
if (!props.clientRect) {
return
}
document.body.appendChild(component.element)
reposition(props, component.element)
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect) {
return
}
reposition(props, component.element)
},
onExit() {
component.element.remove()
component.destroy()
},
}
},
})注意
使用手动定位时,你只会在 onUpdate 时重新定位,因此除非你自己绑定监听器(或者使用 Floating UI 的 autoUpdate),
否则弹出层不会跟随滚动、调整大小或布局变化。props.mount 会为你处理所有这些,因此它是推荐方案。
退出打开的建议
有时你希望用户能够在不选择项目的情况下退出打开的建议。
为实现这一点,用户可以按下 Escape 键,这将关闭打开的建议。
如果你想手动触发关闭建议,可以使用 exitSuggestion 工具函数来关闭视图中现有的建议。
import { exitSuggestion } from '@tiptap/suggestion'
import { PluginKey } from 'prosemirror-state' // 可选,如果你需要创建自定义的 key
const MySuggestionPluginKey = new PluginKey('my-suggestions') // 或使用默认的 'suggestion'
exitSuggestion(editor.view, MySuggestionPluginKey)
// 或者,使用默认的插件 key:
// exitSuggestion(editor.view, 'suggestion')协作
当在协作环境中使用 Tiptap 时,你可能会注意到,当某个用户触发建议(如提及)时,建议会对所有用户弹出。这是因为文档更改会同步到所有客户端,每个客户端的建议插件都会对该更改作出反应。
为防止这种情况,可以使用 shouldShow 选项,并结合来自 @tiptap/extension-collaboration 的 isChangeOrigin 辅助函数。
import { isChangeOrigin } from '@tiptap/extension-collaboration'
Suggestion({
// …
shouldShow: ({ transaction }) => {
return !isChangeOrigin(transaction)
},
})通过返回 !isChangeOrigin(props.transaction),只有当前用户发起更改时,建议才会被激活。