安裝
pnpm dlx shadcn-vue add @admin/rich-editorimport { AdminRichEditor } from '@/registry/admin/ui/rich-editor'用法
掛上 v-model 即可編輯。StarterKit 快捷鍵與輸入規則:Ctrl+B 粗體、行首 # 加空格變標題、- 加空格變清單:
<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import { ref } from 'vue'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'const value = ref<JSONContent | string>( '<h2>歡迎使用 admin-rich-editor</h2><p>沒有 toolbar 也能編輯:選取文字按 <strong>Ctrl+B</strong> 加粗,行首打 <code>#</code> 加空格變標題,打 <code>-</code> 加空格變清單。</p><ul><li>項目一</li><li>項目二</li></ul>')</script><template> <admin-rich-editor v-model="value" :ui="{ base: cn(baseProseClass, starterKitProseClass) }" /></template>要按鈕式的工具列,裝 opt-in 的
@admin/editor-toolbar,下一章介紹。
內容樣式
<AdminRichEditor> / <AdminEditor> 本身不套用任何內容外觀。要讓標題、清單這些內容長出樣子,把樣式常數用 cn(...) 組好、經 :ui="{ base: ... }" 注入內容節點:
baseProseClass(@/registry/admin/ui/editor)— block 間距、段落行高starterKitProseClass(@/registry/admin/ui/rich-editor)— 標題 / 清單 / 程式碼 / 引言 / hr / link
<script setup lang="ts">import { cn } from '@/lib/utils'import { baseProseClass } from '@/registry/admin/ui/editor'import { AdminRichEditor, starterKitProseClass } from '@/registry/admin/ui/rich-editor'</script><template> <admin-rich-editor v-model="value" :ui="{ base: cn(baseProseClass, starterKitProseClass) }" /></template>擴充包各自帶自己的樣式常數(如 editorTableClass),裝了就一起放進 cn,細節見各包文件。
前台唯讀 viewer
後台存的 HTML 要在前台展示時,不需要再掛一顆 editor。樣式常數就是一般的 package export,前台 import 同一份、套在 v-html 容器上,兩邊外觀就一致,之後改樣式也只改一處。
前台容器的直接子元素就是 p / h1 這些內容節點,和後台 :ui.base 注入的是同一層,直接用 :class 套即可。v-html 輸出前記得先過一層 sanitizer(例如 DOMPurify),內容出自多人後台或舊資料匯入時尤其重要:
<script setup lang="ts">import DOMPurify from 'dompurify'import { cn } from '@/lib/utils'import { baseProseClass } from '@/registry/admin/ui/editor'import { starterKitProseClass } from '@/registry/admin/ui/rich-editor'const html = ref('') // 後台存下的 HTMLconst safeHtml = computed(() => DOMPurify.sanitize(html.value))</script><template> <div :class="cn(baseProseClass, starterKitProseClass)" v-html="safeHtml"></div></template>內容若存的是 JSON(ProseMirror doc)而不是 HTML,先用 tiptap 的 generateHTML(json, extensions) 轉成 HTML,再照上面套樣式。
場景
客製 starterKit(精簡版)
透過 :starter-kit="{ heading: false, codeBlock: false, ... }" 關閉 sub-extension,自寫 items 收緊功能。適合留言情境:
<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import type { EditorToolbarItem } from '@/components/admin/editor-toolbar'import { ref } from 'vue'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminEditorToolbar } from '@/components/admin/editor-toolbar'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'const value = ref<JSONContent | string>( '<p>只啟用 <strong>bold</strong> / <em>italic</em> / link 的精簡版,適合留言情境。</p>')// 客製 items:只保留留言情境會用到的功能const leanItems: EditorToolbarItem[][] = [ [ { kind: 'mark', mark: 'bold', tooltip: { text: '粗體' } }, { kind: 'mark', mark: 'italic', tooltip: { text: '斜體' } } ], [{ kind: 'link', tooltip: { text: '連結' } }]]</script><template> <admin-rich-editor v-slot="{ editor, handlers }" v-model="value" :starter-kit="{ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }" :ui="{ base: cn(baseProseClass, starterKitProseClass) }" > <admin-editor-toolbar :editor :handlers="handlers" :items="leanItems" /> </admin-rich-editor></template>唯讀 / 預覽模式
:editable="false" 切到唯讀:
- 無編輯游標、無 focus ring、
cursor: default - 文字仍可選取(允許 Ctrl+C 複製)
<AdminEditorToolbar>可以選擇不渲染(純展示)
<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import { ref } from 'vue'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'// 模擬 DB 來的 HTML 內容const value = ref<JSONContent | string>( `<h2>純展示模式(editable=false)</h2><p>這份內容是<strong>唯讀</strong>展示,沒有編輯游標、沒有 focus ring,但<em>文字可以選取</em>用來複製。</p><ul> <li>適用情境:列表預覽、文章詳情頁、hover 預覽。</li> <li>因為 toolbar 不渲染,UI 完全乾淨。</li></ul><blockquote> 唯讀模式只渲染 StarterKit 內建節點;若 DB 內容含 <code><img></code>,需另行注入官方 Image extension。</blockquote>`.trim())</script><template> <admin-rich-editor v-model="value" :editable="false" :ui="{ base: cn(baseProseClass, starterKitProseClass) }" /></template>表單整合(vee-validate)
<AdminRichEditor> 的 v-model 可直接接 vee-validate 的 useField value。用 useField('content', rules) 取得 value / errorMessage,把 value 綁到 editor、errorMessage 顯示錯誤即可:
<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import { useField, useForm } from 'vee-validate'import { cn } from '@/lib/utils'import { baseProseClass } from '@/components/admin/editor'import { AdminEditorToolbar, defaultEditorItems } from '@/components/admin/editor-toolbar'import { AdminRichEditor, starterKitProseClass } from '@/components/admin/rich-editor'const { handleSubmit, resetForm } = useForm<{ content: string | JSONContent }>({ initialValues: { content: '' }})function validateContent(value: unknown) { if (typeof value !== 'string' || value.length === 0) { return '請輸入內容' } // 去掉 HTML tags 之後實際內容長度 const text = value.replace(/<[^>]+>/g, '').trim() if (text.length < 10) { return '內容至少 10 字' } return true}const { value: content, errorMessage } = useField<string | JSONContent>('content', validateContent)const onSubmit = handleSubmit((values) => { // eslint-disable-next-line no-console console.log('submit:', values) resetForm()})</script><template> <form class="flex flex-col gap-3" @submit="onSubmit"> <div class="flex flex-col gap-1"> <label class="text-sm font-medium text-text-80" for="editor-form-content">文章內容</label> <admin-rich-editor v-model="content" :class="errorMessage ? 'border-error-50' : ''" :ui="{ base: cn(baseProseClass, starterKitProseClass) }" > <template #default="{ editor, handlers }"> <admin-editor-toolbar :editor :handlers="handlers" :items="defaultEditorItems" /> </template> </admin-rich-editor> <span v-if="errorMessage" class="text-sm text-error-50">{{ errorMessage }}</span> </div> <div> <button type="submit" :class="cn('rounded-[8px] bg-primary-50 px-4 py-2 text-text-10', 'hover:bg-primary-60')" > 送出 </button> </div> </form></template>要在 error 狀態顯示錯誤邊框,自行透過 :class="{ 'border-error-50': errorMessage }" 處理。
進階使用
加自訂 extension
<script setup lang="ts">import { Underline } from '@tiptap/extension-underline'import { AdminEditorToolbar, defaultEditorItems } from '@/registry/admin/ui/editor-toolbar'import { AdminRichEditor } from '@/registry/admin/ui/rich-editor'const value = ref('<p>支援 underline 的內容</p>')const items = [ ...defaultEditorItems, [{ kind: 'mark', mark: 'underline', tooltip: { text: '底線' } }],]</script><template> <admin-rich-editor v-slot="{ editor }" v-model="value" :extensions="[Underline]"> <admin-editor-toolbar :editor :items /> </admin-rich-editor></template>加自訂 kind handler
透過 handlers prop 新增任意 kind,toolbar items 直接寫 { kind: 'myCustom' }:
<script setup lang="ts">import { AdminEditorToolbar, defaultEditorItems } from '@/registry/admin/ui/editor-toolbar'import { AdminRichEditor } from '@/registry/admin/ui/rich-editor'const handlers = { insertVariable: { canExecute: () => true, execute: (editor: any, cmd: any) => editor.chain().focus().insertContent(`{{ ${cmd.name} }}`), isActive: () => false, },}const items = [ ...defaultEditorItems, [ { kind: 'insertVariable', name: 'username', label: '使用者名稱' }, { kind: 'insertVariable', name: 'orderId', label: '訂單編號' }, ],]</script><template> <admin-rich-editor v-slot="{ editor }" v-model="value" :handlers> <admin-editor-toolbar :editor :items /> </admin-rich-editor></template>Type-safe 封裝
AdminRichEditor 的 modelValue 為 string | JSONContent,不會依 contentType 在 compile-time 與 v-model 型別對齊。這與 tiptap 的 Content 型別一致:序列化格式在執行期才決定,型別層維持寬 union。
開啟 strictVModel 或 strictTemplates 時,若以更嚴的型別(例如 ref<JSONContent>)綁定,容易出現型別錯誤。這屬刻意設計,而非元件缺漏——收窄會讓以 ref<string> 綁 HTML/markdown 的常見用法直接 type error。
若需要讓 contentType 與 v-model 型別對齊,可自行封裝:以 contentType 為 discriminant,'json' 對應 JSONContent、其餘對應 string。
範例:
<script setup lang="ts" generic="TContentType extends EditorContentType = 'html'">import type { JSONContent } from '@tiptap/core'import type { EditorContentType } from '@/components/admin/editor-core/composables/types'import type { AdminRichEditorProps, AdminRichEditorSlots} from '@/components/admin/rich-editor/types'import { reactiveOmit } from '@vueuse/core'import { useForwardPropsEmits } from 'reka-ui'import { AdminRichEditor } from '@/components/admin/rich-editor'// contentType 收斂 v-model 型別:json 存 ProseMirror doc,其餘存字串type TModelValue = [TContentType] extends ['json'] ? JSONContent : stringdefineOptions({ name: 'typed-admin-rich-editor'})const props = defineProps< Omit<AdminRichEditorProps, 'modelValue' | 'contentType'> & { contentType?: TContentType modelValue?: TModelValue }>()const emits = defineEmits<{ 'update:modelValue': [value: TModelValue]}>()const slots = defineSlots<AdminRichEditorSlots>()const delegatedProps = reactiveOmit(props, 'modelValue')const forwardedAll = useForwardPropsEmits(delegatedProps, emits)const forwarded = computed(() => { const { 'onUpdate:modelValue': _, ...rest } = forwardedAll.value return rest})const innerValue = computed<JSONContent | string>({ get() { return props.modelValue ?? '' }, set(value) { emits('update:modelValue', value as TModelValue) }})</script><template> <admin-rich-editor v-bind="forwarded" v-model="innerValue"> <template v-if="slots.default" #default="slotProps"> <slot v-bind="slotProps"></slot> </template> </admin-rich-editor></template>型別檢查需開啟 strictVModel(若已開啟 strictTemplates 則已包含)。設定見 Type-safe。下列同時含合法綁定與刻意錯誤綁定;錯誤處以 @vue-expect-error 標示。
<script setup lang="ts">import type { JSONContent } from '@tiptap/core'import { ref } from 'vue'import TypedAdminRichEditor from './typed-admin-rich-editor-demo.vue'const htmlValue = ref('<p>HTML 內容</p>')const jsonValue = ref<JSONContent>({ type: 'doc', content: [] })</script><template> <div class="flex w-full max-w-md flex-col gap-6"> <typed-admin-rich-editor v-model="jsonValue" content-type="json" /> <!-- @vue-expect-error content-type=json 需 JSONContent,給 string 報錯 --> <typed-admin-rich-editor v-model="htmlValue" content-type="json" /> <!-- html -> string --> <typed-admin-rich-editor v-model="htmlValue" content-type="html" /> <!-- @vue-expect-error content-type=html 需 string,給 JSONContent 報錯 --> <typed-admin-rich-editor v-model="jsonValue" content-type="html" /> </div></template>字數限制
core 不內建 character count extension,要的話透過 :extensions 自帶:
<script setup lang="ts">import CharacterCount from '@tiptap/extension-character-count'import { AdminEditorToolbar, defaultEditorItems } from '@/registry/admin/ui/editor-toolbar'import { AdminRichEditor } from '@/registry/admin/ui/rich-editor'const value = ref('')</script><template> <admin-rich-editor v-slot="{ editor }" v-model="value" :extensions="[CharacterCount.configure({ limit: 500 })]" > <admin-editor-toolbar :editor :items="defaultEditorItems" /> <div class="mt-2 text-xs text-text-50"> {{ editor.storage.characterCount.characters() }} / 500 </div> </admin-rich-editor></template>複製貼上行為
tiptap StarterKit 內建 paste rules,行為:
- 保留支援的 mark / node(粗體、斜體、標題、清單、引言、code、link、
<img>) - 丟棄不在 schema 內的標籤(未安裝對應擴充包時,Mention / Emoji / TextAlign / TaskList 這類內容貼上會被吃掉)
- 從 Word 來源的 HTML(
<o:p>、<span style="...">等垃圾)會被清掉 - 外部圖片 URL 直接顯示,但要注意 CORS / hot-link 問題(建議自行接上 upload Dialog 把圖上傳到自家 CDN)
SSR 行為
useEditor() 在 SSR 階段回 undefined,template 內 <EditorContent v-if="editor"> 守護:
- Server-rendered HTML 不含 editor content
- Client hydration 後實例化,內容才出現(可能短暫跳動)
- 後台場景無 SEO / LCP 嚴格需求,可接受
- 不適合 SEO 場景(若需要,該情境也不該用 editor)
不支援 SSR:tiptap useEditor() 為 browser-only;元件殼不含 <ClientOnly>。Nuxt 請自行以 <ClientOnly> 包覆,其他 SSR 框架用等價機制;純 CSR 不需處理。見 安裝 · SSR。headed/vendor 命名與是否需包覆無關。
API 參考
組成
<template> <admin-rich-editor v-slot="{ editor }" v-model="value"> <admin-editor-toolbar :editor :items="defaultEditorItems" /> </admin-rich-editor></template>admin-rich-editor
Props
| 名稱 | 型別 | 預設值 | 說明 |
|---|---|---|---|
modelValue | string | JSONContent | undefined | — | — |
contentType | EditorContentType | undefined | — | — |
editable | boolean | undefined | true | — |
extensions | AnyExtension[] | undefined | — | — |
handlers | EditorCustomHandlers | undefined | — | — |
editorProps | EditorProps<any> | undefined | — | — |
autofocus | FocusPosition | undefined | — | — |
class | ClassValue | — | — |
ui | AdminEditorUI | undefined | — | — |
as | string | undefined | "div" | — |
starterKit | Partial<StarterKitOptions> | undefined | — | — |
Events
| 名稱 | Payload | 說明 |
|---|---|---|
update:modelValue | string | JSONContent | — |
Slots
| 名稱 | Bindings | 說明 |
|---|---|---|
default | { editor: Editor; handlers: { [x: string]: EditorHandler<unknown> | EditorHandler<unknown>; mark: EditorHandler<unknown>; heading: EditorHandler<unknown>; link: EditorHandler<unknown>; unlink: EditorHandler<unknown>; blockquote: EditorHandler<unknown>; bulletList: EditorHandler<unknown>; orderedList: EditorHandler<unknown>; codeBlock: EditorHandler<unknown>; horizontalRule: EditorHandler<unknown>; paragraph: EditorHandler<unknown>; undo: EditorHandler<unknown>; redo: EditorHandler<unknown>; unsetAllMarks: EditorHandler<unknown>; clearNodes: EditorHandler<unknown>; hardBreak: EditorHandler<unknown>; }; } | — |
Expose
| 名稱 | 簽名 | 說明 |
|---|---|---|
editor | Editor | undefined | — |