---
title: "建议工具"
description: "使用提及和表情符号等节点自定义自动补全建议。在我们的文档中探索设置和配置。"
canonical_url: "https://tiptap.zhcndoc.com/editor/api/utilities/suggestion"
---

# 建议工具

使用提及和表情符号等节点自定义自动补全建议。在我们的文档中探索设置和配置。

此实用工具可帮助编辑器中的各种建议功能。查看 [`Mention`](https://tiptap.zhcndoc.com/editor/extensions/nodes/mention.md) 或 [`Emoji`](https://tiptap.zhcndoc.com/editor/extensions/nodes/emoji.md) 节点以了解其实际效果。

## 设置

### char

触发自动补全弹出窗口的字符。

默认：`'@'`

### pluginKey

一个 ProseMirror PluginKey。

默认：`SuggestionPluginKey`

### allow

一个返回布尔值的函数，指示建议是否应处于活动状态。

默认：`(props: { editor: Editor; state: EditorState; range: Range, isActive?: boolean }) => true`

### shouldShow

一个返回布尔值的函数，用于指示建议是否应处于激活状态。这对于防止在协作环境中远程用户触发建议弹窗非常有用。

```js
shouldShow: ({ editor, range, query, text, transaction }) => {
  return !isChangeOrigin(transaction)
}
```

默认：`null`

### shouldResetDismissed

控制用户通过 Escape 或 `exitSuggestion()` 关闭后，何时让已关闭的建议再次变为活动状态。

默认情况下，只要用户停留在同一个触发上下文中，该工具会保持已关闭的建议处于关闭状态。使用 `allowSpaces: true` 时，该已关闭上下文可以跨越空格，直到光标离开它为止。

如果你需要自行决定何时清除该已关闭上下文，请使用 `shouldResetDismissed`。

该回调不会在每次事务中都被调用。它只会在以下事务上进行评估：建议插件找到有效匹配、匹配通过 `allow` 和 `shouldShow`，并且存在可能需要清除的已关闭建议状态。

```js
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`

```js
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`（不防抖）

```js
Suggestion({
  debounce: 300,
  items: async ({ query }) => {
    // 用户停止输入 300 毫秒后调用
    return searchApi(query)
  },
})
```

### initialItems

在建议弹窗打开时立即显示的项目数组，在异步 `items()` 调用完成之前显示。`items()` 完成后，其结果会替换初始项目。

适用于在真实结果加载时显示最近使用或热门项目。

```js
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 值](https://floating-ui.com/docs/computePosition#placement)。

解析后的 placement 会传递给 `SuggestionProps.floatingUi.placement`，你可以在渲染回调中读取它。

默认：`'bottom-start'`

```js
Suggestion({
  placement: 'top-start',
})
```

### offset

将建议弹出窗口相对于其锚点在各轴上偏移指定像素数。这些值会传递给 Floating UI 的 [`offset` 中间件](https://floating-ui.com/docs/offset)。

默认：`{ mainAxis: 4, crossAxis: 0 }`

```js
Suggestion({
  offset: { mainAxis: 8, crossAxis: 4 },
})
```

### flip

启用后，当首选方向没有足够空间时，弹窗会自动翻转到另一侧。这会切换 Floating UI 的 [`flip` 中间件](https://floating-ui.com/docs/flip)。

默认：`true`

```js
Suggestion({
  placement: 'top-start',
  flip: false,
})
```

### container

一个 CSS 选择器字符串或 `HTMLElement`，用于定义建议弹出窗口的容器。设置它有助于处理滚动包含或 z-index 堆叠上下文。

默认：`undefined`

```js
Suggestion({
  container: '#my-editor-container',
})
```

### floatingUi

用于顶层选项无法提供足够控制时的高级 Floating UI 配置。插件会保留锚点和位置的所有权——使用顶层 [`placement`](#placement) 选项来设置方向——而 `floatingUi` 允许你切换定位 `strategy` 并追加额外的 `middleware`。

你传入的任何 middleware 都会在插件根据顶层 `offset` 和 `flip` 选项构建出的 `offset` 与 `flip` middleware 之后追加，因此二者是组合而不是相互替换。

```js
import { shift, size } from '@floating-ui/dom'

Suggestion({
  placement: 'top-start',
  floatingUi: {
    strategy: 'fixed',
    middleware: [shift({ padding: 8 }), size()],
  },
})
```

所有属性都是可选的。完整类型如下：

```ts
type SuggestionFloatingUiOptions = {
  strategy?: 'absolute' | 'fixed'
  middleware?: Middleware[]
}
```

默认：`undefined`

### dismissOnOutsideClick

当为 `true` 时，弹窗和编辑器之外的指针交互会关闭当前活动的建议。仅当你使用 [受控定位](#managed-positioning-with-propsmount)（`props.mount`）渲染弹窗时，此选项才会生效，因为插件需要知道哪个元素是弹窗，才能判断什么算作“外部”。

默认：`true`

### command

选择建议时执行的操作。

默认：`() => {}`

### items

一个函数或异步函数，会根据用户输入返回过滤后的建议。该函数会接收一个 `AbortSignal`（`signal`），当用户继续输入时该信号会被中止（例如，新按键使当前请求失效）。可用它来取消正在进行的获取请求。

```js
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` 之外，还包含以下属性：

```ts
{
  /** 挂载你的弹窗元素并接管定位。推荐的默认建议弹窗渲染方式——见下方“定位”。 
   *  返回一个 `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

可选参数，用于替换编辑器内容中触发建议的内置正则匹配。\
有关更多详细信息，请参见 [源代码](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/src/findSuggestionMatch.ts#L18)。

默认：`findSuggestionMatch(config: Trigger): SuggestionMatch`

## 异步行为

当你的 `items()` 函数返回一个 `Promise` 时，建议插件会自动处理异步生命周期：

1. **初始状态：** 弹出窗口打开，`loading` 为 `true`。如果提供了 `initialItems`，这些项目会立即显示。
2. **获取中：** `items()` 函数会带着一个 `AbortSignal` 被调用。如果用户继续输入，前一个请求的信号会被中止，并在 `debounce` 延迟后发起一个新的请求。
3. **解析：** 一旦 promise 解析完成，返回的项目会替换当前项目，`loading` 变为 `false`。`onUpdate` 回调会携带新的 `SuggestionProps` 被触发。
4. **空状态：** 如果没有提供 `items()` 函数，或者它返回一个空数组，弹出窗口会显示空状态。除非正在进行获取，否则 `loading` 属性保持为 `false`。
5. **关闭：** 当弹出窗口关闭时（例如用户按下 Escape），任何进行中的请求都会自动中止。

下面是一个完整示例：

```js
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](https://floating-ui.com/) 集成，用于弹出层定位。定位弹出层有两种方式：

- **使用 `props.mount` 的托管定位** — 推荐的默认方式。插件会挂载元素，将其锚定到光标，并持续为你保持定位。
- **手动定位** — 备用方案。你自己挂载元素，并使用 `props.floatingUi` 和 `props.clientRect` 运行自己的定位循环。

### 使用 `props.mount` 的托管定位

在你的 `render` 回调中，使用你渲染出来的弹出层元素调用 `props.mount(element)`（例如 `ReactRenderer` 或 `VueRenderer` 的 `element`）。然后插件会：

- 将该元素追加到配置的 [`container`](#container) 中（默认是 `document.body`），
- 使用你的 [`placement`](#placement)、[`offset`](#offset)、[`flip`](#flip) 和 [`floatingUi`](#floatingui) 选项，将其锚定到建议项的光标矩形，
- 通过 Floating UI 的 [`autoUpdate`](https://floating-ui.com/docs/autoUpdate) 在滚动、调整大小和布局变化时自动重新定位——无需手动监听器，并且
- 当启用 [`dismissOnOutsideClick`](#dismissonoutsideclick) 时，点击外部会将其关闭。

`mount()` 会返回一个 `unmount` 函数。**你必须在 `onExit` 中调用它**，以清理自动更新循环和外部点击监听器（如果插件添加了元素，还会移除该元素）。忘记这么做会导致监听器泄漏，并在 DOM 中留下孤立的弹出层。

```js
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`](#placement)、[`offset`](#offset) 和 [`flip`](#flip) 选项来控制托管弹出层显示的位置：

```js
Suggestion({
  placement: 'top-start',
  offset: { mainAxis: 8, crossAxis: 0 },
  flip: true,
})
```

如果需要更改定位 `strategy` 或使用自定义中间件（例如 `shift` 或 `size`），请添加 [`floatingUi`](#floatingui) 选项。其 `middleware` 会附加在插件自身的 `offset` 和 `flip` 中间件之后：

```js
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` 回调。这样做时，插件将不再自行写入样式，而是把计算出的坐标交给你：

```js
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` 选项](https://floating-ui.com/docs/autoUpdate#options) 来启用逐帧更新：

```js
unmount = props.mount(component.element, {
  autoUpdate: { animationFrame: true },
})
```

### 挂载到容器中

要将弹出层渲染到特定的堆叠或滚动上下文中——例如模态框或对话框——请设置 [`container`](#container) 选项。托管弹出层会被追加到该处，而不是 `document.body`：

```js
Suggestion({
  container: '#my-dialog',
})
```

字符串会被当作 CSS 选择器处理；你也可以直接传入一个 `HTMLElement`。如果选择器匹配不到任何元素（或者无效），插件会回退到 `document.body`。

### 手动定位（备用方案）

如果你需要对挂载和定位拥有完全控制权，请完全跳过 `props.mount`。你自己挂载元素，然后结合解析后的 `props.floatingUi` 配置和 `props.clientRect` 运行自己的 Floating UI 循环。因为这里的 DOM 由你自己管理，你也需要负责在 `onUpdate` 时重新定位，并在 `onExit` 时清理：

```js
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` 会为你处理所有这些，因此它是推荐方案。

> **Interactive demo:** [SuggestionPositioning](https://embed.tiptap.dev/preview/Examples/SuggestionPositioning)

## 退出打开的建议

有时你希望用户能够在不选择项目的情况下退出打开的建议。\
为实现这一点，用户可以按下 Escape 键，这将关闭打开的建议。\
如果你想手动触发关闭建议，可以使用 `exitSuggestion` 工具函数来关闭视图中现有的建议。

```js
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` 辅助函数。

```js
import { isChangeOrigin } from '@tiptap/extension-collaboration'

Suggestion({
  // …
  shouldShow: ({ transaction }) => {
    return !isChangeOrigin(transaction)
  },
})
```

通过返回 `!isChangeOrigin(props.transaction)`，只有当前用户发起更改时，建议才会被激活。

## 源代码

[packages/suggestion/](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/)
