編輯器

Editor Mention Menu

參考
輸入 @ 觸發浮動選單;挑選並插入提及對象。

安裝

Terminal
pnpm dlx shadcn-vue add @admin/editor-mention-menu
import type { MentionMenuItem } from '@/registry/admin/ui/editor-mention-menu'import { AdminEditorMentionMenu } from '@/registry/admin/ui/editor-mention-menu'

admin-editor-mention-menu 依賴 @admin/rich-editor + @tiptap/extension-mention。

裝完必須在 :extensions 注入 Mention

@tiptap/extension-mention 是 Node extension,跟 suggestion / emoji 這類純 plugin 不同,它會動到 schema,而 schema 在編輯器建立時就固定、無法後補。<AdminEditorMentionMenu> mount 時會註冊 Suggestion plugin,popup 照常浮現,但 schema 沒有 mention node 時,選定後只能退回插入純文字 @John,data-id / data-label 等 attrs 不會持久化。

import Mention from '@tiptap/extension-mention'const extensions = [  Mention.configure({    HTMLAttributes: { class: 'mention' },    renderText: ({ node }) => `${node.attrs.mentionSuggestionChar ?? '@'}${node.attrs.label ?? node.attrs.id}`,  }),]​// <AdminRichEditor :extensions="extensions">

兩個設定都是建議值,不是必填。renderText 決定序列化與複製貼上的文字輸出,未設定時沿用 tiptap 預設的 @label,設定後可改成以觸發字元開頭;class: 'mention' 讓慣用的 .mention CSS 有地方掛(本文件 demo 的 chip 樣式改以 data-type 屬性選取,不依賴它)。

用法

<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import type { MentionMenuItem } from '@/components/admin/editor-mention-menu'import Mention from '@tiptap/extension-mention'import { ref } from 'vue'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminEditorMentionMenu } from '@/components/admin/editor-mention-menu'import { AdminEditorToolbar, defaultEditorItems } from '@/components/admin/editor-toolbar'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'​const value = ref<JSONContent | string>('<p>輸入 @ 觸發 mention menu(例如 @john)</p>')​const users: MentionMenuItem[] = [  { id: 'u1', label: 'John', avatar: 'https://i.pravatar.cc/24?u=u1' },  { id: 'u2', label: 'Jane', avatar: 'https://i.pravatar.cc/24?u=u2' },  { id: 'u3', label: 'Alex', avatar: 'https://i.pravatar.cc/24?u=u3' },  { id: 'u4', label: 'Sam', avatar: 'https://i.pravatar.cc/24?u=u4' }]​// 必須自行注入配置好的 Mention extension 讓 schema 認識 mention node(完整持久化)。// `renderText` 與 `HTMLAttributes.class` 為建議配置://   - renderText 讓序列化 / 複製貼上沿用觸發字元(未設定時 Tiptap 預設輸出 `@label`)//   - class: 'mention' 供慣用 .mention CSS 掛載(chip 樣式本 demo 以 data-type 屬性選取,不依賴它)const extensions = [  Mention.configure({    HTMLAttributes: { class: 'mention' },    renderText: ({ node }) =>      `${node.attrs.mentionSuggestionChar ?? '@'}${node.attrs.label ?? node.attrs.id}`  })]​// mention chip 樣式不內含於 registry,由使用者依需求自套const mentionChipClass = `  **:data-[type=mention]:inline-block **:data-[type=mention]:cursor-default  **:data-[type=mention]:rounded-[4px] **:data-[type=mention]:bg-info-10  **:data-[type=mention]:px-1.5 **:data-[type=mention]:py-0.5 **:data-[type=mention]:text-sm  **:data-[type=mention]:font-medium **:data-[type=mention]:text-info-70  **:data-[type=mention]:select-none`</script>​<template>  <admin-rich-editor    v-slot="{ editor, handlers }"    v-model="value"    :extensions="extensions"    :ui="{ base: cn(baseProseClass, starterKitProseClass, mentionChipClass) }"  >    <admin-editor-toolbar :editor :handlers="handlers" :items="defaultEditorItems" />    <admin-editor-mention-menu :editor :items="users" />  </admin-rich-editor></template>
<script setup lang="ts">import Mention from '@tiptap/extension-mention'import { ref } from 'vue'import { AdminEditorMentionMenu } from '@/registry/admin/ui/editor-mention-menu'import { AdminRichEditor } from '@/registry/admin/ui/rich-editor'​const value = ref('')const users = [  { id: 'u1', label: 'John' },  { id: 'u2', label: 'Jane' }]​// mention node 要能持久化,務必在 :extensions 加配置好的 Mentionconst extensions = [  Mention.configure({    HTMLAttributes: { class: 'mention' },    renderText: ({ node }) => `${node.attrs.mentionSuggestionChar ?? '@'}${node.attrs.label ?? node.attrs.id}`,  }),]</script>​<template>  <admin-rich-editor v-slot="{ editor }" v-model="value" :extensions="extensions">    <admin-editor-mention-menu :editor :items="users" />  </admin-rich-editor></template>

MentionMenuItem 介面

interface MentionMenuItem {  id: string | number  label: string  avatar?: string  [key: string]: unknown // 任意 metadata}

選定後,mention node 的 attrs 會展開整個 item 物件(外加 mentionSuggestionChar: '@'),讓 renderText / renderHTML 讀得到任意欄位。

attrs 持久化注意

存進 DB 的 HTML 形如:

<span data-type="mention" data-id="u1" data-label="John">@John</span>

item 建議只放 id、label 和很小的 metadata(部門代號、role 等);avatar URL、長描述這類欄位改用 id 在顯示時二次查詢補上,避免內容膨脹、拖慢序列化。

非同步搜尋

<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import type { MentionMenuItem } from '@/components/admin/editor-mention-menu'import Mention from '@tiptap/extension-mention'import { ref, watch } from 'vue'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminEditorMentionMenu } from '@/components/admin/editor-mention-menu'import { AdminEditorToolbar, defaultEditorItems } from '@/components/admin/editor-toolbar'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'​const value = ref<JSONContent | string>('<p>輸入 @ 觸發 mention menu(非同步搜尋 — 模擬 API)</p>')const searchTerm = ref('')​const ALL_USERS: MentionMenuItem[] = [  { id: 'u1', label: 'John' },  { id: 'u2', label: 'Jane' },  { id: 'u3', label: 'Alex' },  { id: 'u4', label: 'Sam' },  { id: 'u5', label: 'Emily' },  { id: 'u6', label: 'Michael' }]​// 初始給全部 users(模擬 API 預載清單),讓 `@` 一觸發 popup 就有東西const results = ref<MentionMenuItem[]>(ALL_USERS)​// 模擬非同步 API(延遲 200ms)let fetchTimer: ReturnType<typeof setTimeout> | null = nullwatch(searchTerm, (q) => {  if (fetchTimer) {    clearTimeout(fetchTimer)  }  fetchTimer = setTimeout(() => {    const ql = q.toLowerCase()    results.value = ql ? ALL_USERS.filter((u) => u.label.toLowerCase().includes(ql)) : ALL_USERS  }, 200)})​const extensions = [Mention]​// mention chip 樣式不內含於 registry,由使用者依需求自套const mentionChipClass = `  **:data-[type=mention]:inline-block **:data-[type=mention]:cursor-default  **:data-[type=mention]:rounded-[4px] **:data-[type=mention]:bg-info-10  **:data-[type=mention]:px-1.5 **:data-[type=mention]:py-0.5 **:data-[type=mention]:text-sm  **:data-[type=mention]:font-medium **:data-[type=mention]:text-info-70  **:data-[type=mention]:select-none`</script>​<template>  <admin-rich-editor    v-slot="{ editor, handlers }"    v-model="value"    :extensions="extensions"    :ui="{ base: cn(baseProseClass, starterKitProseClass, mentionChipClass) }"  >    <admin-editor-toolbar :editor :handlers="handlers" :items="defaultEditorItems" />    <admin-editor-mention-menu      v-model:search-term="searchTerm"      ignore-filter      :editor      :items="results"    />  </admin-rich-editor></template>

users 來自後端 API、前端不適合全載時,用 v-model:search-term 雙向綁定搜尋字串,並加 ignore-filter 跳過內建的 fuzzy filter:

<script setup lang="ts">import Mention from '@tiptap/extension-mention'import { ref } from 'vue'​const search = ref('')const { data: users } = await useFetch('/api/users', {  query: { q: search }})const extensions = [  Mention.configure({    HTMLAttributes: { class: 'mention' },    renderText: ({ node }) => `${node.attrs.mentionSuggestionChar ?? '@'}${node.attrs.label ?? node.attrs.id}`,  }),]</script>​<template>  <admin-rich-editor v-slot="{ editor }" v-model="value" :extensions="extensions">    <admin-editor-mention-menu      v-model:search-term="search"      ignore-filter      :editor      :items="users ?? []"    />  </admin-rich-editor></template>

search 變動時 useFetch 會自動重打 API。

鍵盤導航

同 Editor Suggestion Menu。

樣式

mention chip 樣式不內含於 registry,由使用者依需求自套。demo 以 **:data-[type=mention]:* utility class 注入編輯器容器(見「用法」demo 原始碼的 mentionChipClass 常數,色票對齊 admin design tokens),也可以改用 CSS 選取 [data-type='mention'] 自寫:

:deep([data-type='mention']) {  /* 自訂樣式 */}

API 參考

admin-editor-mention-menu

Props

名稱型別預設值說明
editor*Editor—tiptap Editor instance(必填)
items*MentionMenuItem[] | MentionMenuItem[][]—候選清單(可為扁平或分組陣列)
charstring | undefined"@"觸發字元,預設 `@`
pluginKeystring | undefined"editorMentionMenu"PM plugin key,預設 `editorMentionMenu`
limitnumber | undefined10顯示筆數上限,預設 10
filterFieldsstring[] | undefined["label"]filter 比對欄位,預設 `['label']`
ignoreFilterboolean | undefinedfalsetrue 時跳過內建 fuzzy filter(外部已 filter — 典型搭配 `v-model:searchTerm` + useFetch)
classClassValue——
searchTermstring | undefined""—

* 代表必填

Events

名稱Payload說明
update:searchTermstring—

更新紀錄

  • feat: 新增 tiptap editor 元件系列273a0d0