@orilight/editor
@orilight/editor
基于 Tiptap 3 封装的 Vue 3 富文本编辑器组件。开箱即用:自带工具栏与中文排版能力, 重点解决「从 Word 粘贴」与「图片 / 附件上传」这两个真实业务里最容易踩坑的场景。
- 字符格式:加粗、斜体、下划线、字符边框、删除线、上标、下标、清除格式、格式刷
- 段落排版:H1 ~ H6、字号、行间距、段前距 / 段后距、首行缩进、有序 / 无序列表、四种对齐
- 插入内容:字体颜色、背景色、超链接、分割线、表格(增删行列 / 合并拆分 / 表头切换)
- 图片:单图 / 多图上传、粘贴与拖拽上传、八向手柄自由调整宽高(按住 Shift 锁定宽高比)、 行内 ↔ 独立一行互转、独立行图片支持左 / 中 / 右对齐
- 附件:以卡片形式插入(行内节点,可与文字同行),展示文件图标、名称与大小
- Word 粘贴:保留字体、字号、颜色、对齐与表格列宽;还原 ☑ / ☐ 等符号字体勾选框; 图文混排里内嵌的 base64 图片会在后台自动上传并就地替换地址
- 上传:编辑器不含任何请求代码,只依赖宿主实现的
EditorUploader接口; 多图 / 附件弹窗支持分批添加、边传边看进度、失败重试与移除 - 字数统计与上限:底部实时计数(按字形簇,emoji 也算 1 个),可配置上限; 超限只拒绝新输入并标红提醒,不会静默截断已有内容
- 零样式依赖:组件样式自带(
custom-editor__*前缀 + 内置最小化重置), 不依赖宿主引入 Tailwind / normalize 等全局样式
安装
pnpm add @orilight/editor vue
# npm i @orilight/editor vue
# yarn add @orilight/editor vue
vue是 peer 依赖,需要由宿主项目提供(^3.5.0)。- Tiptap 与图标库由本包自带,无需额外安装。
- 只提供 ESM 产物,构建工具请使用 Vite / Rollup / webpack 5 等支持 ESM 的方案。
快速开始
<script setup lang="ts">
import { CustomEditor } from '@orilight/editor'
import '@orilight/editor/style.css' // 必须引入,否则样式缺失
import { ref } from 'vue'
const html = ref('<p>你好,世界</p>')
</script>
<template>
<CustomEditor v-model="html" placeholder="请输入内容" min-height="360px" />
</template>
样式文件是独立的 CSS,不会被自动注入,必须显式引入一次(通常在 main.ts 里全局引入也可以)。
若项目没有
vite/client类型(例如tsconfig里写了"types": []且没有vite-env.d.ts),import '@orilight/editor/style.css'会报TS2882。按 Vite 官方模板补一行/// <reference types="vite/client" />即可。
Props
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue |
string |
'' |
双向绑定的 HTML 字符串 |
placeholder |
string |
'请输入内容' |
内容为空时的占位提示 |
editable |
boolean |
true |
是否可编辑(false 时工具栏整体禁用) |
minHeight |
string |
'320px' |
内容区最小高度 |
maxHeight |
string |
'' |
内容区最大高度,超出后内容区内部滚动 |
autofocus |
boolean |
false |
是否自动聚焦(聚焦到文档末尾) |
showToolbar |
boolean |
true |
是否展示工具栏 |
maxLength |
number |
0 |
最大字数,0 表示不限制 |
showCount |
boolean |
true |
是否在底部展示字数统计 |
uploader |
EditorUploader |
— | 文件上传器,见下文;不传时所有上传入口置灰 |
事件
| 名称 | 参数 | 说明 |
|---|---|---|
update:modelValue |
(html: string) |
v-model 同步 |
change |
(html: string, editor: Editor) |
内容变化 |
ready |
(editor: Editor) |
编辑器创建完成 |
focus / blur |
(editor: Editor) |
获得 / 失去焦点 |
countChange |
(count: number, limit: number | null) |
字数变化,limit 为 null 表示未限制 |
uploadSuccess |
(file: File, result: UploadResult) |
单个文件上传成功 |
uploadError |
(file: File, reason: UploadErrorReason, message: string) |
未接入上传服务 / 文件校验不通过 / 上传失败 |
reason 取值:'not-ready' | 'invalid-file' | 'upload-failed'。
通过 ref 调用
<script setup lang="ts">
import type { CustomEditor } from '@orilight/editor'
import { ref } from 'vue'
const editorRef = ref<InstanceType<typeof CustomEditor> | null>(null)
function save() {
// 保存时用 getHTML():它会返回解析清洗之后的 HTML(见「注意事项」)
fetch('/api/save', { method: 'POST', body: editorRef.value?.getHTML() })
}
</script>
| 成员 | 类型 | 说明 |
|---|---|---|
editor |
ShallowRef<Editor | undefined> |
底层 tiptap 编辑器实例 |
getHTML() / getJSON() / getText() |
string / object | null / string |
读取内容 |
getCount() / count / limit / isOverLimit |
number / ComputedRef<number> / ComputedRef<number | null> / ComputedRef<boolean> |
字数相关 |
isEmpty() |
boolean |
文档是否为空 |
setContent(content) / clearContent() / insertContent(content) |
— | 写入内容 |
focus(position?) / blur() |
— | 焦点控制 |
undo() / redo() |
— | 撤销 / 重做 |
isActive(name, attrs?) |
boolean |
判断当前选区是否处于某个节点 / mark |
uploadReady / isUploading / uploadingCount |
ComputedRef<boolean> / ComputedRef<boolean> / ComputedRef<number> |
上传状态(可用于保存前校验) |
文件上传
编辑器不关心上传细节(请求方式、鉴权、分片、重试策略……),只需宿主实现一个接口并通过 uploader prop 传入:
import type { EditorUploader } from '@orilight/editor'
import { DEFAULT_UPLOAD_RULES } from '@orilight/editor'
const uploader: EditorUploader = {
// 可以传函数:上传器异步初始化完成(例如拿到 token)后会自动恢复可用
isReady: () => !!token.value,
rules: {
...DEFAULT_UPLOAD_RULES,
image: { accept: 'image/*', maxSize: 8 * 1024 * 1024 },
},
async upload(file, { onProgress, signal }) {
const form = new FormData()
form.append('file', file)
const { data } = await axios.post('/api/upload', form, {
signal,
onUploadProgress: e => onProgress?.(Math.round((e.loaded / (e.total ?? 1)) * 100)),
})
// 返回的 url 会被写入文档,请确保它是长期有效的地址
return { url: data.url, name: data.name, size: data.size, mimeType: file.type }
},
// 可选:删除占位、取消弹窗时中止请求
abort: fileId => pending.get(fileId)?.abort(),
}
<CustomEditor v-model="html" :uploader="uploader" @upload-error="onUploadError" />
EditorUploader 字段:
| 字段 | 类型 | 说明 |
|---|---|---|
upload |
(file: File, context: UploadContext) => Promise<UploadResult> |
必填;失败时 reject,Error.message 会展示给用户 |
isReady |
boolean | (() => boolean) |
未就绪时所有上传入口置灰;传函数时按需轮询,就绪后自动恢复 |
abort |
(fileId: string) => void |
可选;取消 / 删除占位时中止对应请求 |
rules |
Partial<Record<'image' | 'attachment', UploadRule>> |
可选;accept 语法同 input[accept],maxSize 单位为字节 |
UploadContext 提供 fileId、signal(AbortSignal)与 onProgress(0 - 100)。
UploadResult 至少需要 url,可选 name / size / mimeType。
行为说明:
- 未传
uploader或未就绪时,工具栏的上传按钮、粘贴、拖拽三条入口全部失效并给出提示, 不会静默失败。 - 上传过程不写文档:先插入「上传中」占位节点,成功后就地替换,因此一次上传只占 2 步撤销
(插入占位、替换),进度刷新不会污染撤销栈,也不会触发
v-model更新。 - 从 Word 粘贴的 base64 内嵌图片会自动上传:图片先正常显示,随后在后台上传并就地替换
src; 失败时保留原图并显示重试 / 忽略按钮。程序性setContent不会触发这个行为。 - 上传中取 HTML(例如用户提前保存)会包含占位节点,但占位节点不参与解析, 重新加载时被丢弃,不会残留死占位。
导出的类型与常量
// 组件与类型
import type {
CustomEditorProps, CustomEditorEmits, SelectOption,
BlockTypeValue, TextAlignValue, ImageAlign, ImageMode, BlockStyleAttr,
EditorUploader, UploadContext, UploadResult, UploadRule, UploadKind,
UploadStatus, UploadTask, UploadNotice, UploadDialogState, UploadController,
UploadErrorReason, CreateUploadControllerOptions,
Content, Editor, FocusPosition, // 转出的 tiptap 类型
} from '@orilight/editor'
import { CustomEditor, createUploadController, DEFAULT_UPLOAD_RULES, UPLOAD_KIND_LABELS } from '@orilight/editor'
createUploadController(options) 只在「自建 tiptap 编辑器、复用本包的上传控制器」这类高级场景才需要;
常规用法直接给 CustomEditor 传 uploader prop 即可。
样式与定制
-
样式前缀统一为
custom-editor__*,富文本内容区为.custom-editor__content, 节点视图(图片 / 附件 / 占位)为.ce-*。 -
高度通过内联 CSS 变量控制,也可以自己覆盖:
.custom-editor { --ce-min-height: 480px; --ce-max-height: 70vh; border-radius: 10px; } -
组件自带一份最小化重置(
box-sizing、表单元素字体继承等), 因此宿主项目不需要引入 Tailwind / normalize;若宿主已引入,也不会冲突。
注意事项
- 仅客户端渲染:编辑器依赖
document/window。SSR 项目请把它包在客户端组件里 (Nuxt 用<ClientOnly>/.client.vue,Next.js 用动态导入 +ssr: false)。 v-model不回写清洗结果:外部写入的 HTML 由 ProseMirror 的 schema 白名单解析,<script>、事件属性等会被丢弃,但解析后的结果只在编辑器内部,v-model变量仍是原始串。 保存 / 入库请一律使用getHTML()或change事件给出的 HTML。- 图片默认行内:无论上传、粘贴、拖拽还是导入 HTML,
<img>默认解析为行内图片(可与文字同行); 导出的独立行图片会带data-align标记与内联margin,重新导入仍保持独立行。 - 表格列宽:会同时兼容 tiptap 自身的
colwidth、Word 写在单元格上的width/style.width以及<colgroup><col width>。导出格式带<col style="width: ...">,往返不丢列宽。 - 字数上限:达到上限后拒绝「变多」的编辑并标红,但不会截断已有内容; 带下划线撤销 / 重做可以自由越过上限(历史内容属于宿主的权威数据)。
link粘贴不自动加链接:粘贴纯文本里的裸网址不会自动转成链接(输入时仍会自动识别), 避免把「网址 + 中文备注」整段吞进href。
本地开发
仓库同时是组件库本体与它的演示应用:
pnpm install
pnpm dev # 启动演示应用(Vite dev server)
pnpm build:demo # 构建演示站点到 dist-demo/
pnpm preview # 预览演示站点
pnpm build # 构建组件库到 dist/(ESM + 类型声明 + style.css)
pnpm type-check # 类型检查
pnpm lint / lintf # ESLint(lintf 自动修复)
发布:
pnpm build # prepublishOnly 会自动执行
npm publish --access public
目录结构
src/
├── index.ts # 组件库公共入口(唯一对外出口)
├── App.vue / main.ts # 演示应用(不参与组件库构建)
└── components/CustomEditor/
├── index.vue # CustomEditor 组件本体
├── types.ts # props / emits / 选项常量
├── useEditorToolbar.ts # 工具栏状态与命令封装
├── components/ # 工具栏、下拉、弹层、上传弹窗、节点视图
├── extensions/ # tiptap 扩展(图片、附件、链接、字数上限、Word 粘贴……)
├── uploader/ # 上传契约与控制器(零 HTTP 代码)
├── styles/editor.scss # 组件全部样式(原生 CSS,不依赖 Tailwind)
└── utils/ # 文本 / 文件 / 颜色 / 块样式工具
License
MIT
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.1 | ^3.5.0 | MIT | |
| 0.1.0 | ^3.5.0 | MIT |