@norio-office/rich-text
@norio-office/rich-text
一款支持AI局部更新的富文本编辑器;问题反馈:norio-office@qq.com。
中文 | English
中文
@norio-office/rich-text 是一个基于 Vue 3 和 Tiptap 3 的富文本编辑器组件,内置文档式编辑界面、图片/视频/文件块、表格、公式、倒计时、高亮块、大纲、预览模式、导出能力,以及可选的 Yjs 协同编辑。
本文按当前仓库实现说明。编辑器负责编辑界面和内容结构;保存接口、文件服务器、评论数据库、协同服务器由你的项目提供。下面的 /api/... 和 example.com 都是示例地址,不是组件自带的服务。
从哪里开始
- 第一次接入:快速开始,然后看保存、加载与清空。
- 上传附件:上传并插入。
- 控制显示的功能:可用 Key;限制用户编辑:预览模式。
- 自己做按钮:实例 API;自己放目录:外置目录组件。
- 接多人协作:协同编辑;接评论:评论功能对接。
- 接入 AI 局部编辑、目标标记、流式预览和
.nordox文件:先看AI Skill 接入与使用,再看AI 接入指南。 - 遇到问题:先看常见问题。
带完整 <script setup> 和 <template> 的示例可以作为一个 Vue 组件使用;只有 <RichTextEditor ... /> 或函数的片段,需要合并到前面的组件中。yourUploadApi()、初始化权判断等占位逻辑必须替换成业务实现。
安装
npm install @norio-office/rich-text
组件样式会作为独立 CSS 文件发布。建议在应用入口显式引入:
import '@norio-office/rich-text/style.css'
运行环境需要 Vue 3.5+,Tiptap 使用本包声明的 3.x 兼容版本。@tiptap/core、@tiptap/pm、@tiptap/vue-3、starter-kit 和各扩展应解析到同一兼容的 3.x 版本,不要混装不同小版本(例如 core 3.32 与 vue-3 3.22)。若包管理器未自动安装 peer dependencies(宿主需要提供的依赖),请按 package.json 的版本范围安装,不要混用 Tiptap 2.x 或随意升级到下一主版本:
npm install vue@^3.5.0 @tiptap/core@^3.22.1 @tiptap/vue-3@^3.22.1 @tiptap/pm@^3.22.1 @tiptap/starter-kit@^3.22.1 @tiptap/extension-placeholder@^3.22.1 @tiptap/extension-code-block@^3.22.1 @tiptap/extension-code-block-lowlight@^3.22.1 @tiptap/extension-collaboration@^3.22.2 @tiptap/extension-collaboration-caret@^3.22.2 @tiptap/extension-font-family@^3.22.1 @tiptap/extension-horizontal-rule@^3.22.1 @tiptap/extension-subscript@^3.22.1 @tiptap/extension-superscript@^3.22.1 @tiptap/extension-table@^3.22.1 @tiptap/extension-table-cell@^3.22.1 @tiptap/extension-table-header@^3.22.1 @tiptap/extension-table-row@^3.22.1 @tiptap/extension-task-item@^3.22.1 @tiptap/extension-task-list@^3.22.1 @tiptap/extension-text-style@^3.22.1 @tiptap/extension-underline@^3.22.1 @tiptap/y-tiptap@^3.0.2 lowlight@^3.3.0 y-prosemirror@^1.2.6 yjs@^13.6.30
fflate、html2canvas、jspdf、katex、marked、plyr 已作为普通 dependencies 随包安装。
快速开始
<script setup lang="ts">
import { ref } from 'vue'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const content = ref<JSONContent>({
type: 'doc',
content: [{ type: 'paragraph' }],
})
</script>
<template>
<RichTextEditor v-model="content" />
</template>
也可以使用默认导出:
import RichTextEditor from '@norio-office/rich-text'
这个例子从空文档开始。content 是 Tiptap JSON 对象,不是 HTML 字符串;编辑后 v-model 会更新这个对象,但不会自动保存到服务器。组件依赖浏览器 DOM,SSR 项目应在客户端挂载它。
AI Skill 接入与使用
**Skill 在哪里:**源码位于本仓库的 packages/skills/norio-rich-text/,独立发布地址为 norio-office-rich-text-skill。入口是 SKILL.md;references/ 提供协议和操作说明,examples/ 提供输入样例,schemas/ 提供校验规则,scripts/ 是可选的离线 CLI。Skill 不随 @norio-office/rich-text npm 包自动安装;请把整个 norio-rich-text/ 目录放到所用 AI 平台支持的 Skill 位置,并按该平台的方式启用它,不要只复制 SKILL.md。
**接入在线编辑器:**宿主项目仍需安装上面的编辑器包,并完成以下绑定;单独安装 Skill 不会连接到浏览器里的文档。
- 在编辑模式渲染
<RichTextEditor ref="editorRef" :show-ai-target-button="true" />。按钮默认不显示;预览模式下始终隐藏。 - 组件挂载后,用真实编辑器实例和稳定的
documentId创建createRichTextAiBridge,监听ai-target-change。用户点按钮会锁定当前光标/选区;点正文、点标记关闭按钮或宿主调用clearAiTarget()会清除标记,并通过事件通知宿主。再次点工具栏按钮不会取消标记。 - 将
getRichTextToolDefinitions()的定义和createRichTextToolHandlers(bridge, host)的同名处理函数注册到自己的 AI 运行时。宿主负责模型请求、文档权限、外部资源授权、文件存储和请求取消;编辑器不自带这些服务。 - 发送编辑指令前读取
bridge.getAiTarget(),用它的targetId创建bridge.createEditContext(...),把返回的contextId、targetRefs等交给工具调用。不要自行构造targetRef或直接替换整个v-model来做局部编辑。
下面是浏览器侧的关键绑定(假设已在组件挂载后取得 editorRef,并准备好 hostDocumentId、activeTarget;保存函数和 AI 工具注册由你的项目实现):
import { createRichTextAiBridge, createRichTextToolHandlers } from '@norio-office/rich-text/ai'
import { getRichTextToolDefinitions } from '@norio-office/rich-text/ai-protocol'
const bridge = createRichTextAiBridge(editorRef.value!, { documentId: hostDocumentId })
const stop = bridge.onEvent(event => {
if (event.type === 'ai-target-change') {
activeTarget.value = event.target // null 表示标记已清除;宿主也应取消相应的模型请求。
}
})
const handlers = createRichTextToolHandlers(bridge, {
createDocument: async ({ title, bytes }) => {
// 由宿主保存 .nordox 字节,并返回持久化后的文档 ID。
return { documentId: await hostSaveDocument(title, bytes) }
},
})
const definitions = getRichTextToolDefinitions()
// 将 definitions 和 handlers 中的同名函数注册到宿主 AI 运行时。
function beginAiEdit(userInstruction: string) {
const target = bridge.getAiTarget()
if (!target) throw new Error('请先选择 AI 编辑范围')
const context = bridge.createEditContext({
requestId: crypto.randomUUID(), instruction: userInstruction,
scope: 'selection', targetId: target.targetId,
})
// 后续读取与局部修改只使用 context.contextId、context.targetRefs 等返回值。
return context
}
// 宿主的“取消范围”按钮;编辑器正文点击和标记关闭按钮也会发出清除事件。
function clearRange() {
if (activeTarget.value) {
bridge.clearAiTarget({ targetId: activeTarget.value.targetId, reason: 'host-close' })
}
}
// 文档页面卸载时:stop(); bridge.dispose()
Skill 指导 AI 使用 richtext_get_capabilities、richtext_get_selection、richtext_read_target、richtext_apply_patch 等工具;工具必须由宿主实际注册和授权。在单人模式和 Yjs 协作模式下均通过同一个 Bridge 执行局部编辑;协作模式还需要宿主原有的协同连接、权限和保存流程。运行时示例见 examples/ai/browser/App.vue,完整约束、流式预览和资源处理见 AI 接入指南及 Skill 集成说明。
**只生成/校验文件:**Node.js 20+ 环境安装兼容的 @norio-office/rich-text 后,在 Skill 目录运行:
node scripts/rich-text.mjs capabilities
node scripts/rich-text.mjs validate-document --input examples/basic-document.json
node scripts/rich-text.mjs create --input examples/basic-document.json --output result.nordox
CLI 不能直接修改打开的编辑器;在线编辑必须由宿主接通 Bridge 和工具处理函数。
保存、加载与清空
单人编辑时,把完整 JSON 存入数据库,打开文档时再赋给 content。不要只保存 getText(),它会丢失标题、颜色、表格和图片等结构。
下面是可独立使用的本地演示:手动保存到浏览器 localStorage,重新打开页面时恢复。它不涉及上传或服务器,也不适用于多人协同的内容同步。
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const storageKey = 'rich-text-demo'
const createEmptyDocument = (): JSONContent => ({
type: 'doc',
content: [{ type: 'paragraph' }],
})
const content = ref<JSONContent>(createEmptyDocument())
const status = ref('')
onMounted(() => {
try {
const saved = localStorage.getItem(storageKey)
if (!saved) return
const parsed = JSON.parse(saved) as JSONContent
if (parsed.type !== 'doc' || !Array.isArray(parsed.content)) {
throw new Error('文档格式不正确')
}
content.value = parsed
status.value = '已恢复上次保存的文档'
} catch {
status.value = '读取失败,已打开空文档'
}
})
function saveDocument() {
try {
localStorage.setItem(storageKey, JSON.stringify(content.value))
status.value = '已保存到当前浏览器'
} catch {
status.value = '保存失败,请检查浏览器存储空间或权限'
}
}
function clearDocument() {
content.value = createEmptyDocument()
status.value = '已清空编辑区,点击保存后才覆盖旧存档'
}
</script>
<template>
<button type="button" @click="saveDocument">保存</button>
<button type="button" @click="clearDocument">清空</button>
<span>{{ status }}</span>
<RichTextEditor v-model="content" document-name="演示文档" />
</template>
实际项目可以将 localStorage 换成自己的读写接口。若监听 change 自动保存,建议做防抖和失败提示;v-model 和 change 对应同一次内容更新,不要分别调用两次保存接口。
注意:当前实现中,初次挂载不传 modelValue 或传 null,单人模式会显示内置示例文字,不是空文档;挂载后将它改成 null 也不会清空。要清空请传上例的 { type: 'doc', content: [{ type: 'paragraph' }] }。协同模式以 Yjs 为准,不能用这个赋值方式覆盖房间内容。
Props
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue |
JSONContent | null |
null |
编辑器 JSON,支持 v-model;null 不表示清空,见上方示例。 |
documentName |
string |
'' |
文档名称,用作 .nordox 导出时的标题;PDF、图片、HTML 的下载文件名由宿主决定。 |
mode |
'edit' | 'preview' |
'edit' |
编辑模式或预览模式。 |
showToolbar |
boolean |
true |
编辑状态下是否显示顶部工具栏。预览状态下始终隐藏。 |
showAiTargetButton |
boolean |
false |
在工具栏末尾显示 AI 编辑目标按钮;需宿主创建 Bridge 并订阅目标事件。预览状态下隐藏。 |
watermark |
RichTextEditorWatermarkOptions | null |
null |
文档页面文本水印配置;不传或 text 为空时不显示水印。 |
showOutline |
boolean |
true |
是否启用内置大纲及贴边入口,面板默认收起。设为 false 后可以配合外部 RichTextOutline 组件自行放置目录。 |
outlinePlacement |
'left' | 'right' |
'right' |
大纲面板位置。 |
messages |
RichTextEditorMessages | null |
null |
覆盖内置 UI 文案。 |
featureCodes |
RichTextEditorCode[] | null |
null |
受控入口的统一白名单,适用边界见下方说明。Vue 模板中写作 feature-codes。 |
enabledFeatureItems |
RichTextEditorFeatureItemKey[] | null |
null |
旧版高级功能白名单;未传 featureCodes 时生效,支持旧的 outline。 |
enabledExportItems |
RichTextEditorExportItemKey[] | null |
undefined |
兼容保留的旧版参数;内置导出/打印入口已移除,不影响实例导出方法。 |
enabledInsertMenuItems |
RichTextEditorInsertMenuItemKey[] | null |
undefined |
旧版插入菜单白名单;未传 featureCodes 时兼容生效。 |
enabledToolbarActions |
RichTextEditorToolbarActionKey[] | null |
undefined |
旧版工具栏能力白名单;未传 featureCodes 时兼容生效。 |
placeholder |
string |
'' |
空内容占位文案。 |
mention |
boolean |
false |
是否启用 @ 提及功能;只有设为 true 时输入 @ 才会打开候选面板,insertMention() 才会插入提及节点。 |
collaboration |
RichTextEditorCollaborationOptions | null |
undefined |
Yjs 协同配置,在组件创建时使用。 |
uploadImage |
RichTextEditorUploadHook | null |
null |
内置图片选择、图片块上传时调用;返回最终可访问 URL 后组件插入图片。 |
uploadVideo |
RichTextEditorUploadHook | null |
null |
内置视频选择、视频块上传时调用;返回最终可访问 URL 后组件插入视频。 |
uploadFile |
RichTextEditorUploadHook | null |
null |
本地文件选择器上传时调用;返回最终可访问 URL 后组件插入文件卡片。 |
onUploadError |
RichTextEditorUploadErrorHandler | null |
null |
上传失败回调;同时也会触发 upload-error 事件。 |
mentionProvider |
RichTextEditorMentionProvider | null |
undefined |
@ 提及候选数据提供函数,兼容用法。 |
onMentionSearch |
RichTextEditorMentionProvider | null |
undefined |
@mention-search 函数式事件对应的异步候选加载函数;优先于 mentionProvider。 |
comments |
RichTextEditorCommentThread[] | null |
null |
评论列表,由业务保存并回传;不写入正文 JSON。 |
commentUser |
RichTextEditorCommentUser | null |
null |
当前评论用户;未传时尝试从协同用户中获取,两者都不可用则不能新建或回复。 |
showComments |
boolean |
false |
显示评论入口和侧栏;使用功能白名单时还需包含 comments。 |
commentMention |
boolean |
true |
评论输入框中的提及开关,与正文 mention 独立;白名单还需包含 mention。 |
uploadCommentImage |
RichTextEditorUploadHook | null |
null |
评论图片上传;未传时回退到 uploadImage。 |
commentMentionProvider |
RichTextEditorCommentMentionProvider | null |
null |
评论提及候选加载函数,与正文候选独立。 |
onCommentMentionSearch |
RichTextEditorCommentMentionProvider | null |
null |
评论候选加载函数的另一种传法;优先于 commentMentionProvider。 |
featureCodes 不传或为 null 时回退到对应的旧版白名单;旧版也未配置时,不限制这些受控功能。评论、正文提及和协同仍需各自的配置。传数组后,优先于仍有界面入口的旧版白名单;传 [] 表示不开放受白名单控制的入口。比如 ['image'] 保留插入菜单里的图片入口。导出和打印不再提供内置界面入口,pdf、html、print 代码仅为兼容旧配置保留,实例导出方法不受这些代码限制。
表中的 undefined 表示组件没有给该可选 prop 设置显式默认值,即“未传入”;这些配置传 null 同样按未配置处理,白名单会继续使用对应的旧版配置(若有)。
这里的“授权码”是前端功能开关,不是安全权限:基础文字格式仍可使用,当前句柄的插入/转换菜单、拖拽上传和多数实例方法没有统一按此数组过滤,也不会删除文档已有节点。业务权限、上传权限和保存权限需要在业务层及后端校验。新项目建议统一使用 feature-codes,不要依赖它实现严格的编辑权限隔离。
水印
watermark 只作为页面视觉层渲染,不写入编辑器 JSON,也不会影响 getText() 的纯文本结果。当前导出清理流程会移除水印层,所以 PDF、图片、HTML 输出都不保留它。若业务要求导出水印,需要在业务导出流程中另行处理,不能把页面水印当成文件防泄漏能力。
<RichTextEditor
v-model="content"
:watermark="{
text: '内部资料',
color: 'rgba(37, 99, 235, 0.12)',
fontSize: 20,
rotate: -24,
showInEdit: true,
}"
/>
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
text |
string |
- | 水印文字,必填。 |
color |
string |
'rgba(15, 23, 42, 0.12)' |
水印文字颜色。 |
fontSize |
number |
18 |
水印字号,单位 px。 |
rotate |
number |
-24 |
水印倾斜角度,单位 deg。 |
showInEdit |
boolean |
true |
编辑状态下是否显示水印;预览状态始终按配置显示。 |
旧版白名单兼容示例(不要同时传 feature-codes):
<RichTextEditor
v-model="content"
:enabled-export-items="['html', 'image']"
:enabled-insert-menu-items="['image', 'local-file', 'blockquote']"
:enabled-toolbar-actions="['blockquote']"
/>
可用 Key
所有授权码统一从 feature-codes 传入,也可以从包里导入各类 RICH_TEXT_EDITOR_*_CODES 避免手写字符串:
import {
RICH_TEXT_EDITOR_EXPORT_CODES,
RICH_TEXT_EDITOR_FEATURE_CODES,
RICH_TEXT_EDITOR_INSERT_MENU_CODES,
RICH_TEXT_EDITOR_TOOLBAR_ACTION_CODES,
} from '@norio-office/rich-text'
import type { RichTextEditorCode } from '@norio-office/rich-text'
const featureCodes: RichTextEditorCode[] = [
RICH_TEXT_EDITOR_EXPORT_CODES.pdf,
RICH_TEXT_EDITOR_INSERT_MENU_CODES.image,
RICH_TEXT_EDITOR_FEATURE_CODES.outlineRight,
RICH_TEXT_EDITOR_TOOLBAR_ACTION_CODES.blockquote,
]
上述常量是对象,取其中的值组成数组即可。featureCodes 是受控入口共用的白名单;它的适用边界见 Props 下方说明:
<RichTextEditor
v-model="content"
:feature-codes="['pdf', 'image']"
/>
上面这个配置会开启 PDF 导出、图片导出和插入图片入口。因为 image 是统一授权码,所以它会同时命中导出图片和插入图片。
完整授权码清单:
| Code | 分类 | 控制内容 | 仍需配合的 prop |
|---|---|---|---|
comments |
高级功能 | 评论按钮、右侧评论栏、正文评论锚点点击和评论交互。 | showComments、comments、commentUser |
outlineLeft |
高级功能 | 内置大纲和贴边图标按钮,授权后位于左侧。 | showOutline |
outlineRight |
高级功能 | 内置大纲和贴边图标按钮,授权后位于右侧。 | showOutline |
watermark |
高级功能 | 页面水印显示;当前导出不会保留水印。 | watermark |
collaboration |
高级功能 | Yjs 协同编辑和协同光标;未授权时按单人模式初始化。 | collaboration |
mention |
高级功能 | 正文 @ 提及、评论输入框里的 @ 提及弹层和提及实例方法。 |
mention、commentMention、mentionProvider、commentMentionProvider |
pdf |
兼容代码 | 内置入口已移除;exportPdf() 仍可调用。 |
- |
html |
兼容代码 | 内置入口已移除;exportHtml() 仍可调用。 |
- |
image |
插入菜单 | 控制插入菜单里的图片入口;exportImage() 不受白名单限制。 |
uploadImage |
print |
兼容代码 | 内置打印入口已移除。 | - |
video |
插入菜单 | 插入菜单里的视频入口。 | uploadVideo |
table |
插入菜单 | 插入菜单里的表格入口。 | - |
local-file |
插入菜单 | 插入菜单里的本地文件入口。 | uploadFile |
columns |
插入菜单 | 插入菜单里的分栏入口。 | - |
highlight-block |
插入菜单 | 插入菜单里的高亮块入口。 | - |
date |
插入菜单 | 插入菜单里的日期入口;当前作为授权码预留。 | - |
code-block |
插入菜单 | 插入菜单里的代码块入口。 | - |
formula |
插入菜单 | 插入菜单里的公式入口。 | - |
blockquote |
插入菜单 + 工具栏 | 插入菜单里的引用入口,同时控制工具栏里的引用按钮。 | - |
emoji |
插入菜单 + 工具栏 | 插入菜单里的表情入口,同时控制工具栏里的表情按钮。 | - |
link |
插入菜单 | 插入菜单里的链接入口。 | - |
divider |
插入菜单 | 插入菜单里的分割线入口。 | - |
countdown |
插入菜单 | 插入菜单里的倒计时入口。 | - |
markdown-import |
插入菜单 | 插入菜单里的 Markdown 导入入口。 | - |
同一个 code 只写一次即可;例如 blockquote 同时控制插入引用和工具栏引用按钮。导出实例方法不受 code 控制。
大纲授权规则:featureCodes 里只有 outlineLeft 时,大纲及入口显示在左侧;只有 outlineRight 时显示在右侧;两个都有时按 outlinePlacement 决定位置;两个都没有时不显示内置大纲和贴边入口。
举例:想保留评论、正文提及和右侧大纲,可以传 ['comments', 'mention', 'outlineRight'],同时配置 :show-comments="true"、:mention="true"、当前用户和各自的数据源。只写 code 不会自动生成评论用户、提及候选或上传接口。
复制、剪切和粘贴
先分清两个菜单:选中文字后浮在附近的是“文字气泡”,用来改选中文字的格式;鼠标移到段落、图片、表格等块旁边后出现的按钮是“句柄”,打开的是整块操作菜单。句柄菜单始终操作它对应的块,不一定是光标所在的块。
句柄只在空闲时悬停显示,不会随光标自动出现。输入(包括中文输入法)、拖动选中文字或块框选时,句柄及其菜单会隐藏;有文字、图片、视频、链接或表格气泡菜单时,也不显示句柄。例如,选中文字后只能看到格式气泡,收起选区后再次移动鼠标到块旁边,才会显示句柄。
块侧边菜单与 Ctrl/Cmd+C、Ctrl/Cmd+X、Ctrl/Cmd+V 可以交叉使用,保留表格、高亮块、任务列表、图片等节点类型和属性。菜单粘贴在当前块下方插入,优先读取最新系统剪贴板;普通快捷键粘贴保留文字选区和代码块的原生行为,无需额外对接。
菜单读取剪贴板需要安全上下文(HTTPS 或 localhost)和浏览器权限。读取被拒绝时回退到本编辑器缓存,外部内容可用快捷键粘贴;菜单剪切只有在成功写入系统剪贴板后才删除原节点。
块菜单的对齐与颜色
正文和标题的句柄菜单提供“缩进和对齐”:左、中、右对齐显示当前选中项,分割线下方是增加、减少缩进;缩进范围为 0-8,到达边界时对应操作禁用。
“颜色”独立放在“缩进和对齐”下方,可设置字体颜色和文字背景色,每项都有“自定义颜色”入口,打开三级内置颜色选择器。“恢复默认”只清除文字色和背景色,保留字体、字号、加粗等格式。
有序列表和无序列表的句柄菜单也提供“缩进和对齐”,支持左/中/右对齐及增减缩进。操作统一作用于该列表内的正文和标题(包括嵌套项),保留列表结构、编号及文字格式,不影响其他列表;缩进范围为 0–8,各文本块独立限制,设置后菜单保持打开。
句柄菜单顶部的类型按钮会高亮回显当前句柄节点:正文、H1–H6、有序列表、无序列表或待办。回显跟随句柄目标,不受其他位置的光标影响;转换或修改节点后重新打开菜单会显示新类型,插入菜单不会误显示为当前类型。
句柄操作菜单默认以句柄中心上下居中,仅在顶部或底部空间不足时向可视区域内避让。定位按实际菜单高度计算,跟随滚动、窗口尺寸和缩放更新;空行“+”的插入菜单仍在按钮下方展开。
句柄菜单的“颜色”和“剪切”之间、以及“删除”上方使用统一横线分组,样式与“删除”和添加菜单之间的分割线一致。
句柄菜单提供“在上方添加”和“在下方添加”,分别使用对应方向的图标。两者共用基础/常用节点子菜单,在目标块前后插入并保留原块(包括空段落),支持撤销;窄屏使用带“返回”的子菜单。
编辑器自有弹出菜单统一使用直角,包括工具栏下拉、插入及句柄多级菜单、文字/表格气泡、颜色/表情/@提及面板,以及图片、视频、链接、代码、倒计时、日期和公式的浮动设置。面板、矩形选项和色块均无圆角;头像、单选圆点及开关保留圆形,正文内容块、图片和主工具栏外观不变。
二级、三级颜色菜单选色后保持打开,可连续调整;高亮块边框/填充及分割线颜色同样不会自动关闭。小屏下使用带“返回”的逐级菜单。操作仅作用于句柄对应的块,支持撤销、重做,沿用文档 JSON/HTML 保存,无需新增对接参数。
文字背景色使用现有 textStyle.backgroundColor,与高亮块整块填充、表格单元格背景色分别设置。段落、标题、列表、引用和高亮块均支持文字颜色菜单。
文字选区气泡菜单
在编辑状态选中文字,会在选区附近显示格式气泡菜单,提供正文/标题、字体、字号、文字色、文字背景色、加粗、斜体、下划线、删除线、上下标、对齐与缩进、行内代码和清除格式。上下标互斥切换;颜色使用内置颜色选择器,最近使用颜色与顶部工具栏共享,选色后不关闭面板。
气泡在选择结束后才出现:按住鼠标拖选时隐藏,松开鼠标后显示;按住 Shift 用方向键扩展选区时隐藏,松开 Shift 后显示。例如,拖选两段文字的过程中不会有菜单挡住正文,松开后才显示格式按钮。重新拖选时,已有气泡也会暂时隐藏。
顶部工具栏和选区气泡菜单的字号选项统一只保留初号、小初、一号、小一、二号、小二、三号、小三、四号、小四、五号、小五,不显示纯数字字号选项;已有文档的字号数据保持不变。
选区气泡的正文按钮显示当前块类型图标;菜单提供正文、一至三级标题、“其他标题”(二级菜单含四至六级)、有序列表、无序列表、任务、代码块、引用和高亮块。代码块与引用之间使用分隔线,代码块、引用、高亮块分别遵循现有 code-block、blockquote、highlight-block 授权码。选择后直接转换或包裹原有内容,不额外插入空块;不提供尚未实现的同步块入口。
操作保留选区:行内格式只修改选中文字,对齐、缩进和标题修改所在块。表格内选中文字、单元格或整表时,统一显示一个表格气泡:上方提供字体、字号、文字色、文字背景色、加粗、斜体、下划线、删除线、对齐与缩进,不提供正文/标题、上下标、行内代码和清除格式;下方提供合并、拆分、单元格背景色、主题色及增删行列等表格操作,不能执行的操作会禁用。例如,只选中单元格中的“评论”二字,改字体、字号或文字颜色不会影响同格其他文字;“文字背景颜色”与整格的“设置单元格背景色”互相独立。拖选期间不显示,选择结束后才出现。收起选区、点击外部或按 Escape 可关闭;小屏自动换行,预览/只读和演示时不显示。文字选区优先显示气泡,并关闭已打开的句柄菜单。无需新增对接参数,格式仍随现有文档 JSON/HTML 保存。
表格主题色
选中表格单元格、行或列后,在气泡菜单的“设置单元格背景色”后点击主题图标。内置经典蓝、清新绿、优雅紫、活力橙、玫瑰粉、简约灰,支持分别自定义表头、奇数行、偶数行颜色。
也可以鼠标移入表格,打开左侧块操作菜单,悬停或点击“主题色”。该入口上下使用菜单分割线分隔;这里复用相同主题面板,直接修改该菜单对应的表格,不需要先选中单元格;光标位于另一张表格时也不会改错目标。
点击三个颜色块会打开编辑器自带的颜色面板,包含默认色、色板、标准色、最近使用和“更多颜色”。默认色恢复经典蓝主题对应区域的颜色;最近使用与工具栏背景色面板共享,选择后立即生效。
主题作用于整张表:首行为表头色,第二行开始交替填色,增删行后会自动重新交替。选主题时清除当前表已有的单元格背景覆盖色,之后仍可单独设置单元格背景。“清除样式”清除主题和单元格背景,保留内容、文字格式、合并关系和列宽。
无需额外对接接口;主题保存在现有文档 JSON 的表格节点 attrs.tableTheme 中,通过 v-model / change 一起保存,支持撤销、重做、协同和导出。也可在初始文档的表格节点中传入:
attrs: {
tableTheme: { header: '#1670d2', odd: '#e8f2ff', even: '#ffffff' },
}
不传 tableTheme 或设置为 null 时保留原有表格外观。
分割线样式与颜色
鼠标移入分割线,打开左侧块操作菜单:
- 样式:只展示实线、虚线、点状线的预览,悬停可查看名称,点击即可切换。
- 颜色:复用编辑器颜色面板,支持默认色、色板、标准色、最近使用和更多颜色;默认色恢复原来的浅灰色,最近使用与文字颜色面板共享。
样式和颜色为独立一组,与上方粘贴、下方删除之间使用菜单分割线分隔。
修改只作用于当前菜单对应的分割线,不需要先选中它。无需额外回调;属性保存在 horizontalRule 节点中,通过现有 v-model / change 保存,支持撤销、重做、复制粘贴和导出。初始文档示例:
{ type: 'horizontalRule', attrs: { lineStyle: 'dashed', lineColor: '#1677ff' } }
lineStyle 可选 solid(实线)、dashed(虚线)、dotted(点状线);lineColor 使用六位 HEX 色值,null 表示默认色。不传属性的旧文档仍显示默认实线。feature-codes 中的 divider 控制插入入口,无需新增授权码。
高亮块颜色与设置
移入高亮块并打开句柄菜单,在“粘贴”和“删除”之间可设置。句柄主菜单按内容撑开,不显示内部滚动条,靠近底部时自动上移:
- 边框颜色:二级菜单提供预设色、无颜色和恢复默认;“自定义颜色”打开三级内置颜色选择器,支持标准色、最近使用、更多颜色、默认和清除颜色。
- 填充颜色:独立设置块背景,颜色选择方式同上。
- 设置:切换“启用表情”;关闭后隐藏左侧表情,开启后可继续点击表情进行选择。
边框和填充独立设置,选色后二级、三级菜单保持打开,可以连续修改;窄屏通过“返回”切换菜单层级。
高亮块不再显示气泡菜单。操作仅影响句柄对应的高亮块,不依赖当前光标位置;属性仍保存在文档 JSON/HTML 中,支持撤销、重做,无需新增对接参数。
引用颜色
移入引用块并打开左侧句柄菜单,在“粘贴”和“删除”之间可设置 边框颜色、背景颜色。二级菜单各提供 15 个预设色,加上无颜色按钮,完整显示两行、每行 8 格;保留原有预设和默认色,并支持恢复默认。“自定义颜色”打开三级内置颜色选择器。选色后菜单保持打开,窄屏通过“返回”切换层级。
修改只作用于句柄对应的引用块,不依赖光标位置;边框色、块背景色、文字颜色互不影响。原顶部工具栏的引用颜色设置保留。数据仍使用 blockquote.attrs.quoteBorderColor、blockquote.attrs.quoteBackgroundColor,随现有文档 JSON/HTML 保存,支持撤销和重做,无需新增对接参数。
图片描述与链接
直接按住图片拖到文档其他位置,会移动原图片,保留宽高、对齐、旋转、描述和链接,不会重复插入。图片组内拖到另一张图片上可调整顺序,拖到组外会移动整组;侧边句柄拖动仍可使用。外部图片文件拖入仍走上传 hook。
直接拖图片、句柄拖动和外部文件拖入的块级落点提示统一为蓝色横线及左端圆点。
选中图片,点击气泡菜单的“设置图片描述”,即可在图片下方直接输入。描述随输入保存,支持换行;清空文字后再按一次 Backspace,会隐藏描述框,不会删除图片。旧文档或上传结果中已有的 description 会自动显示,预览/只读时显示为普通文字。
点击“设置图片链接”,在气泡菜单下方弹层输入网址,可勾选“在当前页面打开”。点击“确定”或按 Enter 才保存;“取消”、Escape、点击外部或切换图片均放弃本次修改。清空网址后确定即移除链接,无效网址不会覆盖旧值。
编辑状态下点击已设置链接的图片只选中图片,不会跳转;预览、只读或演示状态下才按配置打开链接。保存与导出仍保留链接数据。
无需新增对接事件,数据通过现有文档 v-model / change 保存,也可通过 insertImage() 传入:
editorRef.value?.insertImage({
src: 'https://example.com/photo.png',
description: '图片描述',
descriptionVisible: true,
link: 'https://example.com/details',
linkTarget: '_blank',
})
descriptionVisible 可选,不传时根据描述是否为空决定显示;true 可显示空描述框,false 隐藏描述。linkTarget 可选 _blank(默认,新页面)或 _self(当前页面)。getImages() 返回这些字段;描述和已确认链接支持撤销、重做、复制粘贴和导出,未确认的链接草稿不会导出。
事件
| 事件 | Payload | 说明 |
|---|---|---|
update:modelValue |
JSONContent |
内容更新,用于 v-model。 |
change |
JSONContent |
内容更新事件。 |
local-file-upload |
RichTextEditorLocalFilePayload |
用户通过本地文件选择器插入文件后触发。 |
local-file-click |
RichTextEditorLocalFilePayload |
用户点击本地文件卡片非下载按钮区域时触发。 |
local-file-download |
RichTextEditorLocalFilePayload |
用户点击本地文件卡片下载按钮时触发。 |
upload-error |
RichTextEditorUploadErrorPayload |
图片、视频或文件上传 hook 失败时触发。 |
mention-search |
RichTextEditorMentionProviderPayload |
函数式监听写法,对应 onMentionSearch prop;输入 @ 后加载候选数据,可返回数组或 Promise。 |
mention-item-click |
RichTextEditorMentionItem |
点击弹窗候选项或已插入的提及节点时触发。 |
mention-submit |
RichTextEditorMentionItem |
点击弹窗 提及 按钮并插入提及节点后触发。 |
outline-change |
RichTextEditorOutlineState |
大纲条目或当前激活位置更新;可用 @outline-change 获取 { items, activePos }。 |
mention-search 没有出现在组件的 defineEmits 中;Vue 模板里的 @mention-search="handler" 会作为 onMentionSearch 函数式 prop 传入组件。
评论的创建、回复、删除等事件单独列在评论事件中。正文提及加载函数可以返回候选数组;评论的 @comment-mention-search 是通知事件,返回值不会被用作候选数据,务必使用对应的 provider prop。
@ 提及
mention 默认关闭。需要提及能力时请显式传入 :mention="true";未开启时输入 @ 只会保留普通文本,@mention-search 不会被调用,insertMention() 会返回 false。
输入 @ 会打开候选弹窗。宿主项目通过 @mention-search 异步返回候选数据,组件不会内置默认人员或文档数据。推荐使用 @mention-search,mentionProvider 仅作为函数 prop 兼容保留。
type: 1 表示人,type: 2 表示文档。人和文档共用 id、name、type、avatar、icon、tag、updatedAt 等字段;文档传入 tag 时会在标题旁显示标签文字,不传则不显示;人没有头像时会用名字最后两个字和固定亮色颜色池生成头像。
<script setup lang="ts">
import { ref } from 'vue'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import type {
RichTextEditorMentionItem,
RichTextEditorMentionProviderPayload,
} from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
const mentionItems: RichTextEditorMentionItem[] = [
{ id: 'user-1001', name: '崔国强', type: 1 },
{ id: 'user-1002', name: '刘佳宁', type: 1 },
{ id: 'doc-2001', name: 'IT资产管理系统', type: 2, tag: '外部', updatedAt: '2025年12月1日' },
{ id: 'doc-2002', name: '客户满意度调研表', type: 2, updatedAt: '2025年12月4日' },
]
async function handleMentionSearch(payload: RichTextEditorMentionProviderPayload) {
const query = payload.query.trim().toLowerCase()
await new Promise((resolve) => window.setTimeout(resolve, 120))
return mentionItems.filter((item) => {
const matchedType = !payload.type || payload.type === 'all' || item.type === payload.type
const matchedKeyword = !query || item.name.toLowerCase().includes(query)
return matchedType && matchedKeyword
})
}
function handleMentionItemClick(item: RichTextEditorMentionItem) {
console.log('clicked mention item:', item)
}
function handleMentionSubmit(item: RichTextEditorMentionItem) {
console.log('inserted mention:', item)
}
</script>
<template>
<RichTextEditor
v-model="content"
:mention="true"
@mention-search="handleMentionSearch"
@mention-item-click="handleMentionItemClick"
@mention-submit="handleMentionSubmit"
/>
</template>
点击候选项只会触发 mention-item-click 并选中当前项;点击底部 提及 按钮才会插入结构化提及节点并触发 mention-submit。关闭弹窗或不点击 提及 时,编辑器会保留普通 @ 文本。
自定义文案
编辑器默认 UI 文案为中文。messages 只会覆盖代码实际读取的文案 key,并非所有界面文字都已经支持替换。句柄菜单和文字选区菜单的部分文案仍是内置中文;传入不存在的 key 不会改变界面。
<RichTextEditor
v-model="content"
:messages="{
'insert.localFile': '附件',
'export.label': '下载',
}"
/>
常用文案 key:
| Key | 默认中文 |
|---|---|
insert.label |
插入 |
insert.section.general |
通用 |
insert.section.apps |
小应用 |
insert.section.external |
外部内容 |
insert.image |
图片 |
insert.video |
视频 |
insert.table |
表格 |
insert.localFile |
本地文件 |
insert.columns |
分栏 |
insert.highlightBlock |
高亮块 |
insert.date |
日期 |
insert.codeBlock |
代码块 |
insert.formula |
公式 |
insert.blockquote |
引用 |
insert.emoji |
表情符号 |
insert.link |
超链接 |
insert.divider |
分隔线 |
insert.countdown |
倒计时 |
insert.markdownImport |
Markdown 导入 |
export.label |
导出 |
export.pdf |
导出 PDF |
export.pdf.loading |
导出 PDF 中... |
export.html |
导出 HTML |
export.html.loading |
导出 HTML 中... |
export.image |
导出图片 |
export.image.loading |
导出图片中... |
print.label |
打印 |
print.loading |
打印中... |
quote.apply |
应用引用 |
quote.cancel |
取消引用 |
quote.borderColor |
边框颜色 |
quote.backgroundColor |
背景颜色 |
outline.label |
大纲 |
outline.collapse |
收起大纲 |
outline.empty.description |
对文档内容应用“标题”样式,即可自动生成大纲。 |
outline.empty.tip |
点击内容区边缘的大纲图标可以展开大纲。 |
status.wordCountUnit |
个字 |
status.presentation.enter |
演示 |
status.presentation.exit |
退出演示 |
status.fullscreen.enter |
全屏 |
status.fullscreen.exit |
退出全屏 |
countdown.selectTime |
请选择时间 |
countdown.settingsTitle |
倒计时设置 |
formula.insertTitle |
插入 LaTeX 公式 |
预览模式
mode="preview" 会切换到只读预览外壳。预览模式下顶部工具栏、气泡菜单和句柄菜单都不显示,正文不能编辑;图片设置的跳转链接生效,大纲仍可放在左侧或右侧。正文可编辑时,点击图片只用于选中和编辑,不会跳转。
| 配置 | 正文能否编辑 | 顶部工具栏 | 适用场景 |
|---|---|---|---|
默认 mode="edit" |
能 | 显示 | 写文档 |
mode="edit"、:show-toolbar="false" |
能 | 隐藏 | 只使用选中文字的气泡菜单和块句柄菜单 |
mode="preview" |
不能 | 隐藏 | 阅读/预览 |
这里限制的是用户交互,不是业务 API 的安全边界。业务方仍应避免在只读时主动调用修改内容的方法。
showToolbar 默认是 true,只控制顶部工具栏,不影响正文编辑、气泡菜单、句柄菜单或底部信息栏。可以动态切换;预览和演示时无论这个属性是什么值,顶部工具栏都会隐藏。
例如,保留编辑能力,但隐藏顶部工具栏:
<RichTextEditor v-model="content" :show-toolbar="false" />
切换成只读预览:
<RichTextEditor
v-model="content"
mode="preview"
outline-placement="right"
/>
窄屏下预览模式会自动缩放页面画布,让文档在手机上保持可读。
底部信息栏提供演示、全屏和缩放;编辑器界面不提供导出或打印入口。点击“演示”后,组件临时进入只读预览并全屏显示;按 Esc 或通过浏览器退出全屏后,恢复进入前的编辑/预览状态,不修改业务传入的 mode 或文档内容。例如,从编辑页开始演示,退出后可以继续编辑;从预览页开始演示,退出后仍是预览。
协同编辑
推荐使用统一协同服务接入当前富文本组件。当前富文本组件的类型是 rich,不要把它作为未来 Word 组件的 documentType=word 接入。
npm install yjs y-websocket
最小接入示例:
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const fileId = 'rich-demo-file-001'
const content = ref<JSONContent | null>(null)
const ydoc = new Y.Doc()
const user = {
userId: 'user_001',
userName: 'Alice',
clientUniqueCode: crypto.randomUUID(),
color: '#3b82f6',
}
const provider = new WebsocketProvider(
'ws://127.0.0.1:1234/collaboration',
fileId,
ydoc,
{
params: {
roomId: fileId,
token: 'dev-token',
userId: user.userId,
userName: user.userName,
clientType: 'web',
clientUniqueCode: user.clientUniqueCode,
documentType: 'rich',
},
},
)
const collaboration = computed(() => ({
document: ydoc,
field: 'content',
provider,
user: {
name: user.userName,
color: user.color,
userId: user.userId,
clientUniqueCode: user.clientUniqueCode,
},
}))
onBeforeUnmount(() => {
provider.destroy()
ydoc.destroy()
})
</script>
<template>
<RichTextEditor
v-model="content"
:collaboration="collaboration"
/>
</template>
关键规则:
WebsocketProvider的 room name 和 query 里的roomId建议保持同一个值。- 同一个协同房间里的所有客户端必须使用同一个
field,普通一文档一编辑器场景固定用content。 - 同一个用户开两个浏览器窗口时,
clientUniqueCode必须不同;后端不会按userId去重在线客户端。 provider + user都传入时,组件才会显示远程光标和远程选区。- 协同模式下 Y.Doc/Yjs fragment 是内容事实来源,外部
modelValue不会再作为远程内容的权威写入源。 - 协同扩展在组件创建时配置。切换文档/房间或从单人切换到协同时,应销毁旧 provider/Y.Doc,并用新的
key重新挂载编辑器;只改 prop 不会重建协同扩展。
初始化规则:
- 默认情况下,组件不会把
modelValue写入空的协同 fragment。 - 如果后端已有 Yjs 快照,必须以快照为准,不要传
initializeContent: true。 - 如果是全新的空房间,宿主业务必须先确认当前客户端拥有初始化权,再传
initializeContent: true和initialContent。
const collaboration = computed(() => ({
document: ydoc,
field: 'content',
provider,
user: { name: user.userName, color: user.color, userId: user.userId },
initializeContent: ownsInitialContent,
initialContent: ownsInitialContent ? businessContent : null,
}))
两窗口测试时,保持相同 roomId,但使用不同 userId 和 clientUniqueCode。只有第一个窗口应该带初始化权:
http://127.0.0.1:5177/?collab=1&room=rich-demo-file-001&server=ws://127.0.0.1:1234/collaboration&token=dev-token&userId=user_001&name=Alice&clientUniqueCode=browser-tab-001&init=1
http://127.0.0.1:5177/?collab=1&room=rich-demo-file-001&server=ws://127.0.0.1:1234/collaboration&token=dev-token&userId=user_002&name=Bob&clientUniqueCode=browser-tab-002
本包内置的
yjs/服务示例只适合快速本地验证。生产或完整联调建议使用统一协同后端,例如ws://127.0.0.1:1234/collaboration。
组件支持普通单人编辑,也支持可选的 Yjs 协同编辑。
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', 'office-word-demo', ydoc)
const content = ref<JSONContent | null>(null)
onBeforeUnmount(() => {
provider.destroy()
ydoc.destroy()
})
</script>
<template>
<RichTextEditor
v-model="content"
:collaboration="{
document: ydoc,
field: 'content',
provider,
user: {
name: '张三',
color: '#3b82f6',
},
}"
/>
</template>
协同模式下,Yjs fragment 是内容的事实来源。组件仍会 emit JSON 更新,但外部 modelValue 的变化不会再反向写入编辑器。如果需要远程光标,请在宿主项目安装 y-websocket,并传入 provider 和 user。
不要把 modelValue 当作协同房间的初始化种子。默认情况下,组件不会把 modelValue 写入空的协同 fragment;如果全新房间需要默认内容,请先确认当前客户端拥有初始化权,再在 collaboration 里传入 initializeContent: true 和 initialContent。如果后端已经有 Yjs 快照,不要传初始化开关。
内置 Yjs 服务示例
发布包内包含一个最小 Yjs WebSocket 服务示例:
cd node_modules/@norio-office/rich-text/yjs
npm install
npm run start
默认监听地址为 ws://0.0.0.0:1234。客户端需要使用相同 WebSocket 地址和房间名:
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('ws://127.0.0.1:1234', 'office-word-demo', ydoc)
实例 API
通过 Vue ref 获取组件实例方法:
<script setup lang="ts">
import { ref } from 'vue'
import { RichTextEditor } from '@norio-office/rich-text'
import type { RichTextEditorInstance } from '@norio-office/rich-text'
const editorRef = ref<RichTextEditorInstance | null>(null)
</script>
<template>
<RichTextEditor ref="editorRef" />
</template>
可用方法:
| 方法 | 返回值 | 说明 |
|---|---|---|
exportPdf() |
Promise<Blob | null> |
导出 PDF。 |
exportImage(options?) |
Promise<Blob | null> |
导出图片,支持 PNG/JPEG。 |
exportHtml() |
string | null |
导出 HTML 字符串。 |
insertImage(payload) |
boolean |
插入 1 到 4 张图片。 |
insertVideo(payload) |
boolean |
插入视频块。 |
insertFile(payload) |
boolean |
插入链接/文件预览块。 |
insertLocalFile(payload) |
boolean |
插入本地文件卡片。 |
insertMention(payload) |
boolean |
插入一个结构化行内提及节点;mention 未开启时返回 false。 |
openLocalFilePicker() |
void |
打开本地文件选择器。 |
focus() |
void |
聚焦编辑器。 |
getJSON() |
JSONContent | null |
获取当前 JSON 内容。 |
getText() |
string |
获取当前纯文本内容,不包含 HTML 标签。 |
getImages() |
RichTextEditorImagePayload[] |
获取文档内所有图片信息。 |
getVideos() |
RichTextEditorVideoPayload[] |
获取文档内所有视频信息。 |
getFiles() |
RichTextEditorCollectedFilePayload[] |
获取文档内所有文件信息;kind: 'file' 表示链接/文件预览块,kind: 'local-file' 表示本地文件卡片。 |
getOutlineItems() |
RichTextEditorOutlineItem[] |
获取当前目录项。 |
getActiveOutlinePos() |
number | null |
获取当前激活目录项的文档位置。 |
focusOutlineItem(pos) |
boolean |
聚焦并跳转到指定目录项。 |
onOutlineChange(handler) |
() => void |
监听目录变化,返回取消监听函数。 |
focusCommentThread(threadId) |
boolean |
跳转到指定评论锚点;找不到时返回 false。 |
媒体插入 API 的位置规则很重要:insertImage()、insertVideo()、insertFile()、insertLocalFile() 不会插到一句话的中间。光标所在文本块非空时,在该块前插入并保留原文;为空或只有空白时,替换这个占位块;块级选区则按选区起点插入。若要插到指定位置,先让用户在目标空段落中放置光标再调用。图片数组超过 4 项只取前 4 项。insertMention() 则是在当前选区插入行内提及。
这些媒体方法只接收已经上传好的地址,不会调用上传 hook。它们和导出实例方法不受顶部菜单白名单统一限制;媒体方法目前也不会自动检查预览/只读状态,调用方应自行判断。实例尚未就绪或参数缺少必要地址时无法插入,返回 false;不要将返回值当作服务器保存成功的确认。
getImages()、getVideos()、getFiles() 返回有效媒体的摘要,不包含上传占位块,也不是完整存档。例如当前返回值没有完整保留 assetId 和图片块尺寸,insertFile() 当前也没有保存传入的 assetId。需要完整数据时请使用 getJSON() 或 v-model。
外置目录组件
内置大纲默认收起,内容区边线中部显示图标按钮。点击后按钮隐藏,大纲作为悬浮层展开,上下及配置的一侧贴住内容区边线,不占正文空间;关闭后恢复按钮。左侧按钮为左直角、右圆角,右侧镜像。底部状态栏不再显示大纲按钮。编辑与预览均支持,演示时隐藏。
内置目录默认跟随编辑器一起渲染。如果需要把目录放到任意侧栏、抽屉或自定义布局里,可以关闭内置目录,并把编辑器实例传给 RichTextOutline。
<script setup lang="ts">
import { ref } from 'vue'
import { RichTextEditor, RichTextOutline } from '@norio-office/rich-text'
import type { RichTextEditorInstance } from '@norio-office/rich-text'
import type { JSONContent } from '@tiptap/core'
import '@norio-office/rich-text/style.css'
const editorRef = ref<RichTextEditorInstance | null>(null)
const content = ref<JSONContent>({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '项目说明' }] },
{ type: 'paragraph' },
],
})
</script>
<template>
<RichTextEditor ref="editorRef" v-model="content" :show-outline="false" />
<aside class="outline-sidebar">
<RichTextOutline :editor="editorRef" />
</aside>
</template>
RichTextOutline 会自动订阅编辑器目录状态,点击条目时会调用 focusOutlineItem 跳转到对应标题。组件自身最小高度为 200px,默认根据内容自适应增高;如果需要固定高度或内部滚动,请在外层套一个容器控制高度。可选 props:open、placement、title、collapseTitle、emptyDescription、emptyTip、showCollapse;事件:select、toggle、change。
上传并插入
推荐集成流程:
- 在业务层上传文件。
- 等待接口返回可访问 URL。
- 调用组件实例方法插入返回内容。
这是业务自行上传的方式;如果使用下面的内置上传 hook,组件会自动插入,不要在 hook 中再次调用插入 API,否则会出现重复内容。
如果使用编辑器内置的图片、视频、本地文件选择器或页面拖拽上传,请传入上传 hook。编辑器只负责把用户选中的 File 交给业务侧上传,并在 hook 返回 URL 后插入内容;组件不会内置业务上传接口,也不会自动回退为 base64/blob URL。
页面拖拽上传会按文件类型分流:image/* 走图片上传,video/* 走视频上传,其他格式全部走 uploadFile 并插入本地文件卡片。
<script setup lang="ts">
import { ref } from 'vue'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import type {
RichTextEditorUploadInput,
RichTextEditorUploadResult,
RichTextEditorUploadErrorPayload,
} from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
async function uploadToServer(payload: RichTextEditorUploadInput): Promise<RichTextEditorUploadResult> {
const form = new FormData()
form.append('file', payload.file)
form.append('kind', payload.kind)
const response = await fetch('/api/upload', {
method: 'POST',
body: form,
})
if (!response.ok) {
throw new Error('上传失败')
}
const result = await response.json() as RichTextEditorUploadResult
if (!result.url) throw new Error('上传接口没有返回 url')
return result
}
function handleUploadError(payload: RichTextEditorUploadErrorPayload) {
console.error(payload.kind, payload.fileName, payload.error)
}
</script>
<template>
<RichTextEditor
v-model="content"
:upload-image="uploadToServer"
:upload-video="uploadToServer"
:upload-file="uploadToServer"
@local-file-click="(file) => console.log('file clicked', file)"
@upload-error="handleUploadError"
/>
</template>
onUploadError callback prop 和 @upload-error 事件都能接收上传失败信息。通常选择一种接入方式即可,避免业务层重复处理。
上面的 /api/upload 是业务自己实现的接口。hook 接收 { file: File, kind: 'image' | 'video' | 'file' };返回值不是 URL 字符串,而是以下对象:
{
"url": "https://cdn.example.com/uploads/photo.png",
"assetId": "asset-1001",
"name": "photo.png",
"size": 2048,
"mimeType": "image/png",
"alt": "项目照片",
"description": "本次项目的示意图"
}
只有 url 必填,其余字段可选;缺少 name、size、mimeType 时,内置上传流程会使用原文件的信息。缺少 hook 会报上传错误,不会自动生成 base64;失败任务可在上传面板重试。评论图片可单独传 uploadCommentImage,未传时使用 uploadImage。
地址必须能被阅读文档的人访问。不要把本地磁盘路径或临时 blob: 地址当成长期文件地址;返回地址和文件权限由业务负责。
插入图片:
async function uploadImage(file: File) {
const imageUrl = await yourUploadApi(file)
editorRef.value?.insertImage({
src: imageUrl,
name: file.name,
alt: file.name,
description: '',
})
}
一次最多插入 4 张图片:
editorRef.value?.insertImage([
{ src: 'https://cdn.example.com/a.png', name: 'a.png' },
{ src: 'https://cdn.example.com/b.png', name: 'b.png' },
])
插入视频:
async function uploadVideo(file: File) {
const videoUrl = await yourUploadApi(file)
editorRef.value?.insertVideo({
src: videoUrl,
name: file.name,
mimeType: file.type || 'video/mp4',
description: 'video description',
})
}
插入文件链接/预览块:
async function uploadFile(file: File) {
const fileUrl = await yourUploadApi(file)
editorRef.value?.insertFile({
url: fileUrl,
name: file.name,
displayMode: 'text',
})
}
插入本地文件卡片:
editorRef.value?.insertLocalFile({
url: 'https://cdn.example.com/files/demo.txt',
name: 'demo.txt',
size: 2048,
mimeType: 'text/plain',
})
导出示例
下面的函数与“实例 API”示例中的 editorRef 一起使用。编辑器不显示导出按钮;实例方法仅返回 Blob/HTML,下载由宿主控制。
| 导出方式 | 当前实现与适用范围 |
|---|---|
exportImage() |
页面截图;默认 PNG,可选 JPEG。quality 可选 0-1,主要影响 JPEG,不传时使用浏览器默认质量。 |
exportPdf() |
简化的 DOM 文字和矩形色块绘制,生成一张随文档高度延伸的 PDF 页面;不是页面截图,也不是 Word 文件。当前不嵌入图片、视频、SVG 图标,不保证字体、公式、圆角、虚线、水印等细节还原。 |
exportHtml() |
含内联样式的完整 HTML 文档;媒体地址仍指向原文件服务,不是离线资源包。 |
当前几种导出都先复制页面并清理编辑控件,视频块和页面水印也会被清除。因此不能假设导出文件包含页面上的所有元素。
function downloadBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.append(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function handleExportPdf() {
const blob = await editorRef.value?.exportPdf()
if (!blob) return
downloadBlob(blob, 'demo.pdf')
}
async function handleExportImage() {
const blob = await editorRef.value?.exportImage({
type: 'image/png',
})
if (!blob) return
downloadBlob(blob, 'demo.png')
}
function handleExportHtml() {
const html = editorRef.value?.exportHtml()
if (!html) return
console.log(html)
}
类型导入
import type {
OfficeColorIconProps,
OfficeIconProps,
RichTextEditorAlign,
RichTextEditorCollaborationAwareness,
RichTextEditorCollaborationDocument,
RichTextEditorCollaborationProvider,
RichTextEditorCollaborationUser,
RichTextEditorCollaborationOptions,
RichTextEditorCollectedFilePayload,
RichTextEditorExportItemKey,
RichTextEditorCode,
RichTextEditorFilePayload,
RichTextEditorImageExportOptions,
RichTextEditorImagePayload,
RichTextEditorInsertMenuItemKey,
RichTextEditorInstance,
RichTextEditorLocalFilePayload,
RichTextEditorMentionItem,
RichTextEditorMentionProvider,
RichTextEditorMentionProviderPayload,
RichTextEditorMentionType,
RichTextEditorMessages,
RichTextEditorOutlineChangeHandler,
RichTextEditorOutlineItem,
RichTextEditorOutlineState,
RichTextEditorProps,
RichTextEditorToolbarActionKey,
RichTextEditorUploadErrorHandler,
RichTextEditorUploadErrorPayload,
RichTextEditorUploadHook,
RichTextEditorUploadInput,
RichTextEditorUploadKind,
RichTextEditorUploadResult,
RichTextEditorWatermarkOptions,
RichTextEditorVideoPayload,
} from '@norio-office/rich-text'
包导出
import RichTextEditor, {
RichTextEditor as NamedRichTextEditor,
RichTextOutline,
OfficeIcon,
OfficeColorIcon,
ScrollArea,
monoIconNames,
colorIconNames,
} from '@norio-office/rich-text'
包内文档
- 使用指南:
docs/usage.md - Yjs 服务示例:
yjs/README.md
常见问题
| 问题 | 先检查什么 |
|---|---|
| 接入后没有样式 | 是否引入 @norio-office/rich-text/style.css,是否由宿主 CSS 覆盖。 |
传 null 却没有清空 |
传一个包含空段落的 doc 对象,见保存示例。 |
| 图片/视频选择后无法上传 | 对应 hook 是否传入,是否返回 { url: '...' },接口地址是否真实可用。 |
| 调插入方法后图片跑到段落上方 | 媒体 API 默认在非空文本块前插入;指定位置请先把光标放到目标空段落。 |
| 设置了白名单,某些句柄项还在 | 白名单不是完整权限系统;当前句柄插入/转换未统一过滤。 |
| 评论按钮没显示或不能发布 | showComments、comments code、当前评论用户是否配置;预览不能新建评论。 |
输入 @ 没有候选 |
正文需要 mention=true 及数据源;评论使用独立的数据源;白名单需含 mention。 |
| 两个窗口内容没有同步 | WebSocket 地址、房间名、field 是否一致;白名单是否允许 collaboration。 |
| 菜单粘贴读不到系统内容 | 需要 HTTPS/localhost 和浏览器权限,可改用 Ctrl/Cmd+V。 |
| 导出图片缺少远程图片 | 检查图片服务的 CORS、访问权限及加载情况;截图导出依赖浏览器能读取图片。 |
| PDF 里图片或样式不完整 | 当前 PDF 是简化的文字/色块绘制,不嵌入图片;可尝试浏览器打印保存 PDF 并检查结果。 |
长期保存、重新编辑请使用 JSON,评论列表另存;不要将导出 HTML 或简化 PDF 当成无损导入格式。导出前请在目标浏览器验证媒体加载、字体和排版,尤其是包含复杂块的长文档。
English
@norio-office/rich-text is a reusable rich text editor component for Vue 3 and Tiptap 3. It ships with a document-style editing UI, image/video/file blocks, tables, formulas, countdown blocks, highlight blocks, outline navigation, preview mode, export APIs, and optional Yjs collaboration.
Installation
npm install @norio-office/rich-text
The component styles are published as a separate CSS file. Import it in your application entry:
import '@norio-office/rich-text/style.css'
Use Vue 3.5+ and the compatible Tiptap 3.x ranges declared in package.json. Keep @tiptap/core, @tiptap/pm, @tiptap/vue-3, starter-kit and extensions on matching compatible 3.x versions; mixing minor releases (for example core 3.32 with vue-3 3.22) can fail at runtime. If your package manager does not install peers automatically, use the versioned installation command in the Chinese installation section.
npm install vue@^3.5.0 @tiptap/core@^3.22.1 @tiptap/vue-3@^3.22.1 @tiptap/pm@^3.22.1 @tiptap/starter-kit@^3.22.1 @tiptap/extension-placeholder@^3.22.1 @tiptap/extension-code-block@^3.22.1 @tiptap/extension-code-block-lowlight@^3.22.1 @tiptap/extension-collaboration@^3.22.2 @tiptap/extension-collaboration-caret@^3.22.2 @tiptap/extension-font-family@^3.22.1 @tiptap/extension-horizontal-rule@^3.22.1 @tiptap/extension-subscript@^3.22.1 @tiptap/extension-superscript@^3.22.1 @tiptap/extension-table@^3.22.1 @tiptap/extension-table-cell@^3.22.1 @tiptap/extension-table-header@^3.22.1 @tiptap/extension-table-row@^3.22.1 @tiptap/extension-task-item@^3.22.1 @tiptap/extension-task-list@^3.22.1 @tiptap/extension-text-style@^3.22.1 @tiptap/extension-underline@^3.22.1 @tiptap/y-tiptap@^3.0.2 lowlight@^3.3.0 y-prosemirror@^1.2.6 yjs@^13.6.30
html2canvas, jspdf, katex, marked, and plyr are regular dependencies and are installed with the package.
Quick Start
<script setup lang="ts">
import { ref } from 'vue'
import type { JSONContent } from '@tiptap/core'
import { RichTextEditor } from '@norio-office/rich-text'
import '@norio-office/rich-text/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
</script>
<template>
<RichTextEditor v-model="content" />
</template>
Default import is also supported:
import RichTextEditor from '@norio-office/rich-text'
AI Skill
The Skill source is packages/skills/norio-rich-text/ in this repository; its separate publishing destination is the Skill repository. Copy the entire norio-rich-text/ directory to a Skill location supported by your AI platform. It is not included in the editor's npm package, and copying it does not automatically register tools or connect a live editor.
For live editing, install the editor package, opt in with :show-ai-target-button="true", bind createRichTextAiBridge to the mounted editor, subscribe to ai-target-change, and register the definitions from getRichTextToolDefinitions() with matching createRichTextToolHandlers in your host AI runtime. The host supplies model transport, permissions, resource authorization, persistence and request cancellation. Use bridge.getAiTarget() and its targetId when creating an edit context; never invent target references. The same Bridge supports local and Yjs collaborative editing when the host collaboration session is ready. See the Chinese integration steps, AI integration guide, Skill instructions, and runnable browser example.
For offline validation or .nordox creation, use Node.js 20+ and a compatible installed @norio-office/rich-text package, then run node scripts/rich-text.mjs capabilities or the other commands documented in the Skill directory. The CLI cannot edit an open browser editor.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue |
JSONContent | null |
null |
Tiptap JSON content, not an HTML string. null does not clear it. |
documentName |
string |
'' |
Document name used as the .nordox export title. The host chooses PDF, image, and HTML download filenames. |
mode |
'edit' | 'preview' |
'edit' |
Edit mode or read-only preview mode. |
showToolbar |
boolean |
true |
Shows the top toolbar in edit mode. Always hidden in preview mode. |
showAiTargetButton |
boolean |
false |
Shows the AI target button at the end of the toolbar. The host must bind a Bridge and subscribe to target events. Hidden in preview mode. |
watermark |
RichTextEditorWatermarkOptions | null |
null |
Text watermark configuration for the document page. No watermark is rendered when omitted or when text is empty. |
showOutline |
boolean |
true |
Enables the built-in outline and edge toggle; the panel starts collapsed. Set it to false when placing an external RichTextOutline component yourself. |
outlinePlacement |
'left' | 'right' |
'right' |
Outline panel placement. |
messages |
RichTextEditorMessages | null |
null |
Overrides built-in UI labels. |
featureCodes |
RichTextEditorCode[] | null |
null |
Unified whitelist for controlled entries, with the limitations described below. |
enabledFeatureItems |
RichTextEditorFeatureItemKey[] | null |
null |
Legacy advanced-feature whitelist, including the legacy outline code. |
enabledExportItems |
RichTextEditorExportItemKey[] | null |
undefined |
Retained for compatibility; built-in export/print controls have been removed and instance export methods remain available. |
enabledInsertMenuItems |
RichTextEditorInsertMenuItemKey[] | null |
undefined |
Legacy insert menu whitelist; used only when featureCodes is unset. |
enabledToolbarActions |
RichTextEditorToolbarActionKey[] | null |
undefined |
Legacy toolbar action whitelist; used only when featureCodes is unset. |
placeholder |
string |
'' |
Placeholder text for empty content. |
mention |
boolean |
false |
Enables @ mention. Only when true, typing @ opens the candidate popup and insertMention() can insert a mention node. |
collaboration |
RichTextEditorCollaborationOptions | null |
undefined |
Yjs collaboration options, used at component creation. |
uploadImage |
RichTextEditorUploadHook | null |
null |
Upload hook required by built-in image pickers and image drops. The editor inserts the returned URL. |
uploadVideo |
RichTextEditorUploadHook | null |
null |
Upload hook required by built-in video pickers and video drops. The editor inserts the returned URL. |
uploadFile |
RichTextEditorUploadHook | null |
null |
Upload hook required by the local file card picker. The editor inserts the returned URL. |
onUploadError |
RichTextEditorUploadErrorHandler | null |
null |
Optional callback for upload errors. |
mentionProvider |
RichTextEditorMentionProvider | null |
undefined |
Compatibility function prop for loading @ mention candidates. |
onMentionSearch |
RichTextEditorMentionProvider | null |
undefined |
Async loader used by function-style @mention-search; takes precedence over mentionProvider. |
comments |
RichTextEditorCommentThread[] | null |
null |
Business-managed comment threads, stored separately from document JSON. |
commentUser |
RichTextEditorCommentUser | null |
null |
Current author; falls back to the collaboration user when available. |
showComments |
boolean |
false |
Shows comment UI; also requires the comments code when using a whitelist. |
commentMention |
boolean |
true |
Comment mention switch, independent of the main editor's mention prop. |
uploadCommentImage |
RichTextEditorUploadHook | null |
null |
Comment image upload hook; falls back to uploadImage. |
commentMentionProvider |
RichTextEditorCommentMentionProvider | null |
null |
Separate loader for comment mentions. |
onCommentMentionSearch |
RichTextEditorCommentMentionProvider | null |
null |
Alternative comment mention loader, taking precedence over commentMentionProvider. |
When featureCodes is omitted or null, controlled areas fall back to their legacy whitelists; without either list, controlled entries are unrestricted. Comments, mentions and collaboration still need their own configuration. A supplied array takes precedence for entries that still exist; [] disables only whitelist-controlled entries. Export and print have no built-in controls: pdf, html, and print remain as compatibility codes, while instance export methods are not filtered by them. These codes are UI switches, not security permissions: basic formatting, current handle insert/transform menus, file drops and most instance methods are not uniformly filtered. Existing nodes are not removed. Enforce business permissions on the host and server.
Persist the full JSON to reopen an editable document; getText() and exported HTML are not lossless substitutes. Comments are stored separately. On initial non-collaborative mount, missing/null content currently displays built-in sample text; subsequent null assignments are ignored. Clear with { type: 'doc', content: [{ type: 'paragraph' }] }. Collaboration uses Yjs instead of model-value assignments. See the Chinese save/load example for a complete localStorage demo.
Watermark
watermark is a visual page layer, not document JSON, and does not affect getText(). The current export cleanup removes that layer from PDF, image, and HTML output. Add export watermarks in your host export workflow when required; the on-screen layer is not file-level data-loss protection.
<RichTextEditor
v-model="content"
:watermark="{
text: 'Internal',
color: 'rgba(37, 99, 235, 0.12)',
fontSize: 20,
rotate: -24,
showInEdit: true,
}"
/>
| Field | Type | Default | Description |
|---|---|---|---|
text |
string |
- | Watermark text. Required. |
color |
string |
'rgba(15, 23, 42, 0.12)' |
Watermark text color. |
fontSize |
number |
18 |
Watermark font size in px. |
rotate |
number |
-24 |
Watermark rotation in degrees. |
showInEdit |
boolean |
true |
Whether to show the watermark while editing. Preview mode still renders it when configured. |
<RichTextEditor
v-model="content"
:enabled-export-items="['html', 'image']"
:enabled-insert-menu-items="['image', 'local-file', 'blockquote']"
:enabled-toolbar-actions="['blockquote']"
/>
Available Keys
All authorization codes are passed through one feature-codes list. Import the RICH_TEXT_EDITOR_*_CODES constants to avoid hand-written strings:
import {
RICH_TEXT_EDITOR_EXPORT_CODES,
RICH_TEXT_EDITOR_FEATURE_CODES,
RICH_TEXT_EDITOR_INSERT_MENU_CODES,
RICH_TEXT_EDITOR_TOOLBAR_ACTION_CODES,
} from '@norio-office/rich-text'
import type { RichTextEditorCode } from '@norio-office/rich-text'
const featureCodes: RichTextEditorCode[] = [
RICH_TEXT_EDITOR_EXPORT_CODES.pdf,
RICH_TEXT_EDITOR_INSERT_MENU_CODES.image,
RICH_TEXT_EDITOR_FEATURE_CODES.outlineRight,
RICH_TEXT_EDITOR_TOOLBAR_ACTION_CODES.blockquote,
]
featureCodes is one unified authorization pool. Export, insert menu, toolbar, and advanced features are all checked against the same array:
<RichTextEditor
v-model="content"
:feature-codes="['pdf', 'image']"
/>
This enables PDF export, image export, and the insert-image entry. Because image is a unified code, it matches both image export and image insertion.
Complete authorization code list:
| Code | Group | Controls | Related props |
|---|---|---|---|
comments |
Advanced feature | Comment button, right-side comment panel, document comment anchor clicks, and comment interactions. | showComments, comments, commentUser |
outlineLeft |
Advanced feature | Built-in outline and edge icon toggle on the left. | showOutline |
outlineRight |
Advanced feature | Built-in outline and edge icon toggle on the right. | showOutline |
watermark |
Advanced feature | Page watermark display; current exports remove this layer. | watermark |
collaboration |
Advanced feature | Yjs collaboration and remote cursors; without authorization, the editor initializes in single-user mode. | collaboration |
mention |
Advanced feature | Main editor @ mention, comment input @ mention popup, and mention instance methods. |
mention, commentMention, mentionProvider, commentMentionProvider |
pdf |
Compatibility code | Built-in control removed; exportPdf() remains available. |
- |
html |
Compatibility code | Built-in control removed; exportHtml() remains available. |
- |
image |
Insert menu | Controls image insertion; exportImage() is not filtered by the whitelist. |
uploadImage |
print |
Compatibility code | Built-in print control removed. | - |
video |
Insert menu | Video entry in the insert menu. | uploadVideo |
table |
Insert menu | Table entry in the insert menu. | - |
local-file |
Insert menu | Local file entry in the insert menu. | uploadFile |
columns |
Insert menu | Columns entry in the insert menu. | - |
highlight-block |
Insert menu | Highlight block entry in the insert menu. | - |
date |
Insert menu | Date entry in the insert menu; currently reserved as an authorization code. | - |
code-block |
Insert menu | Code block entry in the insert menu. | - |
formula |
Insert menu | Formula entry in the insert menu. | - |
blockquote |
Insert menu + toolbar | Blockquote entry in the insert menu and blockquote button in the toolbar. | - |
emoji |
Insert menu + toolbar | Emoji entry in the insert menu and emoji button in the toolbar. | - |
link |
Insert menu | Link entry in the insert menu. | - |
divider |
Insert menu | Divider entry in the insert menu. | - |
countdown |
Insert menu | Countdown entry in the insert menu. | - |
markdown-import |
Insert menu | Markdown import entry in the insert menu. | - |
Write a shared code only once. For example, blockquote controls both insert-menu blockquote and the toolbar blockquote button. Instance export methods are not controlled by these codes.
Outline authorization rule: with only outlineLeft, the outline and its edge toggle appear on the left; with only outlineRight, they appear on the right; with both, outlinePlacement decides placement; with neither, the built-in outline and edge toggle are hidden.
Copy, Cut, And Paste
Block-menu actions interoperate with Ctrl/Cmd+C, Ctrl/Cmd+X, and Ctrl/Cmd+V, preserving node types, attributes, and formatting. Menu paste inserts below the current block and prefers the latest system clipboard. Native paste keeps text-selection and code-block semantics. No additional integration is required.
Menu clipboard reads require a secure context (HTTPS or localhost) and browser permission. If reads are denied, the editor falls back to its local copy cache; use keyboard paste for external content. Menu cut deletes the source only after a successful rich clipboard write.
Block Alignment And Colors
Block handles appear only on idle hover, not automatically at the caret. Typing (including IME composition), dragging a text selection, and block marquee selection hide the handles and their menus. Text, image, video, link, and table bubbles take priority over handles. For example, selecting text shows only its formatting bubble; collapse the selection and hover over a block again to reveal its handle.
Paragraph, heading, ordered-list, and bullet-list handle menus provide left/center/right alignment with an active check, followed by separated increase/decrease indent actions. A list handle updates its paragraph/heading content, including nested items, while preserving list structure, numbering, marks, and unrelated blocks. Mixed alignments show no check; indentation is limited to 0-8 per text block, with actions disabled only when no target can change.
The type buttons highlight the handle target's current paragraph, H1-H6, ordered/bullet/task-list type, independently of the caret. Active buttons expose aria-pressed and use the same blue styling for their text and icons. Reopening after a conversion reflects the new type; insertion controls have no current-type highlight.
Handle menus provide Add Above and Add Below with corresponding directional icons. Both reuse the basic/common insertion submenu and insert at the target block's boundaries without replacing it, including empty paragraphs. Insertions support undo; narrow screens use a submenu with a Back button.
Action menus center vertically on the handle using their measured height, shifting only to stay within the visible editor/window intersection. Placement updates on scroll, resize, and zoom. Empty-line plus/insert menus still open below their button.
Editor-owned popup menus, panels, and their rectangular controls use square corners, including dropdowns, nested handle menus, bubbles, palettes, emoji/mention panels, and floating media/code/date/formula editing controls. Semantic circles (avatars, radio dots, switches) and document surfaces retain their existing shapes. Native browser pickers are not restyled.
The separate Color entry provides font and text-background presets and per-field custom colors in a third-level shared palette. Restore-default clears only these two color attributes, preserving fonts, sizes, and other formatting. Paragraphs, headings, lists, blockquotes, and highlight blocks support text colors; text backgrounds use the existing textStyle.backgroundColor mark independently of highlight-block fills and table-cell backgrounds.
Second/third-level color menus remain open after changes, including highlight-block border/fill and divider color palettes. Narrow viewports use stacked menus with back navigation. Changes target the handle block, support undo/redo and existing JSON/HTML persistence, and require no new integration API.
Text Selection Bubble
Selecting text in edit mode shows paragraph/headings, font family/size, text/background colors, bold/italic/underline/strike, superscript/subscript, alignment/indentation, inline code, and clear-format controls. Actions reuse the toolbar commands and preserve the selection. Superscript/subscript are mutually exclusive; color palettes share recent colors with the toolbar and remain open after changes.
The bubble appears after selection finishes, not during a mouse drag. It stays hidden until the pointer is released, or until Shift is released after expanding a selection with navigation keys. Starting another selection temporarily hides an existing bubble. For example, dragging across two paragraphs leaves the text unobstructed; releasing the mouse reveals the formatting controls.
The block-type button shows the current type's icon. Its dropdown offers paragraph, headings 1-3, a nested headings 4-6 menu, ordered/bullet/task lists, code blocks, then a separator followed by quotes and highlight blocks. Code, quote, and highlight options respect the existing code-block, blockquote, and highlight-block feature codes. Choices transform or wrap the existing content rather than insert empty blocks. Font-size menus offer only the 12 Chinese named sizes, without numeric-only options.
Inline formatting affects only the selected text, while heading/alignment/indentation changes affect its blocks. Text, cell, and whole-table selections share one table bubble: font, size, text/background colors, bold, italic, underline, strike, and alignment/indentation above, and merge/split, cell background, theme, and row/column actions below. The table bubble omits block type, superscript/subscript, inline code, and clear formatting. Unavailable operations are disabled. For example, changing the font or color of a selected word does not change the rest of its cell; text background and whole-cell background are separate. The bubble stays hidden during selection gestures and appears when they finish. Outside clicks, collapsed selections, and Escape dismiss the menu. It wraps on narrow screens and is hidden in preview/read-only or presentation. A text selection takes priority and closes any open handle menu. No new integration API or document attributes are required.
Table Themes
Select table cells, rows, or columns, then click the theme icon after the cell-background button in the bubble menu. Six presets (blue, green, purple, orange, pink, and gray) and custom header, odd-row, and even-row colors are available.
Alternatively, hover over a table, open its left-hand block menu, then hover or click Theme Color. Separators appear above and below this entry. This opens the same panel and updates that menu's table without requiring a cell selection, even when the caret is in another table.
Each custom color opens the editor's shared palette with Default, swatches, standard colors, recent colors, and More Colors. Default restores that region's classic-blue color. Recent colors are shared with the toolbar background-color palette, and changes apply immediately.
The first row receives the header color; data rows start at the second row and alternate automatically after rows are inserted or removed. Applying a theme removes existing cell background overrides; individual cells can be recolored afterward. Clear Styles removes the theme and cell backgrounds while preserving content, text formatting, merged cells, and column widths.
No extra integration callback is required. Table themes are saved in attrs.tableTheme through the existing document v-model / change flow, and support undo/redo, collaboration, and exports. To initialize a themed table, supply:
attrs: {
tableTheme: { header: '#1670d2', odd: '#e8f2ff', even: '#ffffff' },
}
Omit tableTheme or set it to null to retain the default table appearance.
Divider Style And Color
Hover over a divider and open its left-hand block menu. Style and Color form a separate group between Paste and Delete, with separators above and below. Style shows only solid, dashed, and dotted previews, with names in hover tooltips. Color uses the shared editor palette, including Default, swatches, standard colors, recent colors, and More Colors. Default restores the original light gray; recent colors are shared with text colors.
Only that menu's divider is modified; no selection or additional callback is required. The existing document v-model / change flow persists its attributes, with undo/redo, clipboard, and export support:
{ type: 'horizontalRule', attrs: { lineStyle: 'dashed', lineColor: '#1677ff' } }
lineStyle accepts solid, dashed, or dotted. lineColor accepts six-digit HEX colors or null for the default. Old documents without these attributes retain the default solid line. The existing divider feature code controls insertion; no new authorization code is needed.
Highlight Block Colors And Settings
The highlight-block handle menu provides Border Color, Fill Color, and Settings between paste and delete. The main handle menu grows naturally without an internal scrollbar and shifts upward near the bottom edge. Each color menu has second-level presets, no-color, and restore-default actions; Custom Color opens the shared third-level palette with standard, recent, custom, default, and clear-color actions. Border and fill remain independent, and both menu levels stay open after select
Related Packages
Vue 2 adapter for Universal Rich Text Editor (Vue 2.6+ & 2.7)
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.8.0 | ^3.5.0 | MIT | |
| 0.7.1 | ^3.5.0 | MIT | |
| 0.7.0 | ^3.5.0 | MIT | |
| 0.6.0 | ^3.5.0 | MIT | |
| 0.3.0 | ^3.5.0 | MIT | |
| 0.1.0 | ^3.5.0 | MIT |