編輯器

Rich Editor

參考API
admin-editor 加上 StarterKit;粗體、標題、清單等基本格式。

安裝

Terminal
pnpm dlx shadcn-vue add @admin/rich-editor
import { 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>&lt;img&gt;</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。

範例:

TypedAdminRichEditor.vue
<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 : string​defineOptions({  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

型別檢查需開啟 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

名稱型別預設值說明
modelValuestring | JSONContent | undefined——
contentTypeEditorContentType | undefined——
editableboolean | undefinedtrue—
extensionsAnyExtension[] | undefined——
handlersEditorCustomHandlers | undefined——
editorPropsEditorProps<any> | undefined——
autofocusFocusPosition | undefined——
classClassValue——
uiAdminEditorUI | undefined——
asstring | undefined"div"—
starterKitPartial<StarterKitOptions> | undefined——

Events

名稱Payload說明
update:modelValuestring | 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

名稱簽名說明
editorEditor | undefined—

更新紀錄

  • refactor: v-model emit 型別由 any 收斂為 string | JSONContentd391ee8
  • refactor: 移除多餘 model generic,改由 wrapper 對齊型別1bf76d9
  • feat: 新增 tiptap editor 元件系列273a0d0