@nova_voyager/article-editor-vue3
v0.5.0
Published
Protocol-driven Vue 3 article editor based on Tiptap
Maintainers
Readme
Vue 3 Article Editor
一个基于 Vue 3、TypeScript、Tiptap 3 和 ProseMirror 的协议驱动文章编辑器。组件只接收和输出 ProseMirror 风格 JSON。
内置 Article Content Protocol v1,并提供协议注册、版本切换、显式迁移、Schema 校验和自定义工具栏能力。
安装与运行
npm install
npm run dev验证组件库:
npm run check构建产物位于 dist/:
article-editor.js:ESM。article-editor.umd.cjs:UMD/CommonJS。article-editor.css:独立样式。index.d.ts:TypeScript 声明入口。
基础用法
<script setup lang="ts">
import { ref } from 'vue'
import {
ArticleEditor,
ARTICLE_PROTOCOL_V1_ID,
type JSONContent,
} from '@nova_voyager/article-editor-vue3'
import '@nova_voyager/article-editor-vue3/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [] })
function save(payload: { content: JSONContent; protocol: string; valid: boolean }) {
// 保存 payload.content
}
</script>
<template>
<ArticleEditor
v-model:content="content"
:protocol="ARTICLE_PROTOCOL_V1_ID"
:editable="true"
@change="save"
/>
</template>update:content 在有效编辑 transaction 后同步触发;change 默认防抖 200ms,适合连接保存逻辑。
配置
<ArticleEditor
v-model:content="content"
protocol="article-content@1"
:config="{
validationMode: 'strict',
changeDebounce: 300,
layout: {
contentHeight: 480,
},
link: {
defaultTarget: '_blank',
allowedProtocols: ['http', 'https'],
},
image: {
allowedProtocols: ['https'],
accept: 'image/png,image/jpeg,image/webp',
maxFileSize: 10 * 1024 * 1024,
},
table: {
defaultRows: 3,
defaultColumns: 3,
maxRows: 20,
maxColumns: 20,
resizable: false,
},
codeBlock: {
languages: [
{ value: '', label: '纯文本' },
{ value: 'typescript', label: 'TypeScript' },
],
},
toolbar: {
image: false,
textAlign: true,
articleButton: true,
},
}"
/>config.layout.contentHeight 设置正文滚动区域的高度,数字按像素处理,也可以传入 "60vh"、"calc(100vh - 240px)" 等 CSS 长度。默认值为 480px。工具栏位于滚动区域之外,正文滚动时会固定在编辑器顶部。
配置只能隐藏或收紧协议已有能力。若需要增加 Node、Mark 或属性,应注册新的协议版本。
普通链接与自定义链接
V1 协议的 link mark 支持 href 和 custom 两种类型。工具栏的“链接类型”可以在两者之间切换。
href 链接
type: "href" 要求提供 href 和 target,不能包含 id 或 title。为了兼容旧文章,省略 type 时也按 href 链接处理:
{
"type": "link",
"attrs": {
"type": "href",
"href": "#section",
"target": "_self"
}
}href 支持不带协议的 URI 引用、配置中允许的绝对 URL 协议,以及 fragment-only 页面锚点。以下值都可以通过严格模式校验:
#
#section
example.com/article
https://example.com/article#section普通文本链接和 articleButton 链接都会保留输入的 href,不会为不带协议的地址自动补充
https://。例如输入 example.com/article,协议 JSON 中仍保存为 example.com/article。
如果输入中显式包含协议,则该协议仍必须位于 config.link.allowedProtocols 白名单中。
fragment-only 例外只适用于 link.href,不适用于图片 src。javascript:、data: 等未配置的协议仍会被严格模式拒绝。
custom 链接
type: "custom" 要求提供非空的 id、title 和 target,并且不能包含 href:
{
"type": "link",
"attrs": {
"type": "custom",
"id": "article-42",
"title": "查看相关文章",
"target": "_blank"
}
}custom 链接渲染为没有 href 的 <a>,对应属性为
data-link-type="custom"、data-link-id、title 和 target。宿主展示文章时可根据
data-link-id 或协议 JSON 中的 id 实现自己的跳转逻辑。
协议注册与切换
协议结构:
interface ArticleProtocolDefinition {
id: string
version: string
label: string
capabilities: ProtocolCapabilities
createExtensions(config): Extensions
validate(content, config): ValidationResult
toEditorJSON(content): JSONContent
fromEditorJSON(content): JSONContent
migrations?: ProtocolMigration[]
}注册并切换到新协议:
import {
articleProtocolV1,
cloneJSON,
type ArticleProtocolDefinition,
} from '@nova_voyager/article-editor-vue3'
const articleProtocolV2: ArticleProtocolDefinition = {
...articleProtocolV1,
id: 'article-content@2',
version: '2.0.0',
label: 'Article Content Protocol v2',
// 实际项目应替换为 v2 Extension、校验器和 JSON 适配器。
migrations: [
{
from: articleProtocolV1.id,
migrate: cloneJSON,
},
],
}<ArticleEditor
v-model:content="content"
:protocol="currentProtocol"
:protocols="[articleProtocolV2]"
@protocol-changed="handleProtocolChanged"
@protocol-change-error="handleProtocolError"
/>协议切换会先迁移并校验目标 JSON,再创建目标 Schema。校验或创建失败时,原编辑器实例和内容保持不变。Tiptap Schema 不支持安全的原地替换,因此成功切换时会原子化重建编辑器。
图片接入
点击默认工具栏的图片按钮后,可选择“上传图片”或“网络图片”:“网络图片”会打开 URL 表单;“上传图片”会调用宿主项目传入的 imageUploader(file)。编辑器不绑定具体上传服务,只把上传成功后的 URL 写入协议 JSON。
图片插入后,点击正文中的图片即可在工具栏看到“图片居左”“图片居中”“图片居右”三个按钮。对齐结果保存到图片节点的 attrs.imageAlign,允许值为 left、center、right;旧数据没有该属性时按居中显示。
上传图片
下面是一个完整的 Vue 3 接入示例:
<script setup lang="ts">
import { ref } from 'vue'
import {
ArticleEditor,
type ArticleEditorConfig,
type ImageUploader,
type JSONContent,
} from '@nova_voyager/article-editor-vue3'
import '@nova_voyager/article-editor-vue3/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [] })
const editorConfig: ArticleEditorConfig = {
image: {
allowedProtocols: ['https'],
accept: 'image/png,image/jpeg,image/webp',
maxFileSize: 10 * 1024 * 1024,
},
}
const uploadImage: ImageUploader = async (file) => {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/article-images', {
method: 'POST',
body,
})
if (!response.ok) {
throw new Error('图片上传失败')
}
const result = await response.json() as {
url: string
width?: number
height?: number
}
return {
src: result.url,
alt: file.name,
width: result.width,
height: result.height,
imageAlign: 'center',
}
}
function handleEditorError(error: Error) {
console.error(error.message)
}
</script>
<template>
<ArticleEditor
v-model:content="content"
:config="editorConfig"
:image-uploader="uploadImage"
@error="handleEditorError"
/>
</template>imageUploader 可以返回以下结果:
- URL 字符串,例如
https://cdn.example.com/image.png。 - 图片属性对象
{ src, alt?, title?, width?, height?, imageAlign? }。 null,表示用户取消,不插入图片。
传入 imageUploader 后,也可以直接把一张或多张本地图片拖入可编辑的正文区域。编辑器会:
- 使用同一个
imageUploader(file)逐张上传图片,并保持文件原有顺序。 - 使用鼠标松开时对应的正文位置插入上传结果并立即回显。
- 在上传前执行
config.image.accept和config.image.maxFileSize校验。 - 对上传结果执行 URL 协议、宽高和
imageAlign校验。
拖拽过程中正文区域会显示上传提示。只读模式、未提供 imageUploader 或当前协议不支持
image 节点时,组件不会接管图片拖放。单张图片上传失败不会阻止同一批次中其他有效图片
插入,错误仍通过组件的 error 事件返回。
回调抛出的错误会通过组件的 error 事件传出。config.image.accept 控制文件选择器接受的类型,config.image.maxFileSize 控制上传前的文件大小校验;默认分别为 image/* 和 10 MB。
上传服务返回的 src 必须符合 config.image.allowedProtocols。v1 协议保存的是最终图片 URL,不会把 File、Base64 或临时对象 URL 写入文章 JSON。
网络图片与素材库
“网络图片”不需要配置上传器,用户可以直接填写图片 URL、替代文本、标题和尺寸。
已有素材库可以继续使用 imageResolver。当没有配置 imageUploader、但配置了 imageResolver 时,“上传图片”入口会调用素材选择器:
async function selectImage() {
const asset = await openAssetPicker()
return asset ? { src: asset.url, alt: asset.alt } : null
}文章按钮
v1 协议支持独立的 articleButton 块节点。点击工具栏的“文章按钮”后,可以选择“手动配置”或“从外部选择”。text 和 button 样式需要填写文章 ID;link 样式的文章 ID 为选填。显示文字和样式始终必填,提示文字为选填。
生成的协议 JSON:
{
"type": "articleButton",
"attrs": {
"id": "article-123",
"text": "查看文章",
"title": "打开相关文章",
"style": "button"
}
}style 支持 button、text 和 link。text 是文字操作样式;link 会渲染为 <a>,并允许携带 href;其他样式继续渲染为 <button>。title 对应 HTML 的悬停提示,未填写时不会输出该字段。
链接样式示例:
{
"type": "articleButton",
"attrs": {
"text": "查看完整文章",
"style": "link",
"href": "https://example.com/articles/456"
}
}协议规定 href 只能在 style: "link" 时出现。默认手动配置表单选择“链接”后会把文章 ID 标记为选填,同时显示链接地址并要求填写;支持不带协议的地址、配置允许的绝对 URL,以及 #、#section 等 fragment-only 地址,输入值不会被自动补充 https://。外部选择器返回 link 样式时也应提供 href,可以省略 id。text 和 button 样式仍必须提供非空 id。
接入外部文章选择器
宿主项目通过 articleButtonResolver 接入自己的弹窗、列表或素材库。resolver 可以同步返回,也可以返回 Promise;返回 null 表示用户取消。
<script setup lang="ts">
import { ref } from 'vue'
import {
ArticleEditor,
type ArticleButtonResolver,
type JSONContent,
} from '@nova_voyager/article-editor-vue3'
import '@nova_voyager/article-editor-vue3/style.css'
const content = ref<JSONContent>({ type: 'doc', content: [] })
const selectArticle: ArticleButtonResolver = async ({ mode, current }) => {
// openArticlePicker 由宿主项目实现,可以打开任意 Vue 弹窗或选择组件。
const selected = await openArticlePicker({ mode, currentId: current?.id })
if (!selected) return null
return {
id: selected.id,
text: selected.title,
title: `打开文章:${selected.title}`,
style: 'link',
href: selected.url,
}
}
</script>
<template>
<ArticleEditor
v-model:content="content"
:article-button-resolver="selectArticle"
@error="console.error($event)"
/>
</template>如果当前选中了已有的文章按钮,resolver 会收到 { mode: 'update', current };否则收到 { mode: 'insert', current: undefined }。返回结果只会保留协议允许的 id、text、title、style 和 href 字段,字符串会去除首尾空格。href 仅在 style: 'link' 时保留;link 可省略 id,其他样式缺少 id 时会拒绝插入。
编辑器内点击文章按钮只会选中节点,便于再次编辑,不会执行业务跳转。最终展示页面可读取 data-article-button-id 或协议 JSON 中的 attrs.id,自行实现打开文章等行为。
仓库内置了一个可运行的外部选择器 Demo。执行 npm run dev 后,点击“文章按钮 → 从外部选择”,可以体验文章搜索、列表选择、样式选择、取消,以及编辑已有文章按钮时的数据回填。Promise 桥接代码位于 src/App.vue,独立选择弹窗位于 src/components/DemoArticlePicker.vue。
自定义工具栏
可以替换默认工具栏。工具栏应只调用 Tiptap command,不能直接修改 JSON:
<ArticleEditor v-model:content="content">
<template #toolbar="{ editor, protocol, config, revision }">
<MyToolbar
:editor="editor"
:protocol="protocol"
:config="config"
:revision="revision"
/>
</template>
</ArticleEditor>组件也导出 useArticleEditor(),可用于构建完全无默认 UI 的编辑器封装。
Props
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| content | JSONContent | 空文档 | 协议 JSON |
| protocol | string \| ArticleProtocolDefinition | article-content@1 | 当前协议 |
| protocols | ArticleProtocolDefinition[] | [] | 附加协议注册表 |
| config | ArticleEditorConfig | {} | 交互与限制配置 |
| editable | boolean | true | 是否允许编辑 |
| showToolbar | boolean | true | 是否渲染工具栏 slot/default toolbar |
| imageResolver | ImageResolver | undefined | 外部图片选择器 |
| imageUploader | ImageUploader | undefined | 本地图片上传回调,返回 URL 或图片属性 |
| articleButtonResolver | ArticleButtonResolver | undefined | 外部文章选择器,返回 articleButton 属性或 null |
Events
| 事件 | 说明 |
|---|---|
| update:content | 同步有效协议 JSON |
| change | 防抖后的 { content, protocol, valid } |
| ready | 编辑器实例创建完成 |
| validation-error | 输入、输出或切换校验失败 |
| protocol-changed | 协议切换成功 |
| protocol-change-error | 协议切换失败 |
| error | 编辑器或外部解析器运行错误 |
暴露方法
editor:当前 Tiptap Vue Editor。activeProtocol:当前协议定义。setContent(content):校验并静默替换内容。getJSON():取得协议 JSON。validate(content?):执行当前协议校验。focus(position?):聚焦编辑器。
v1 范围
支持 paragraph、H1–H6、blockquote、bullet/ordered list、code block、horizontal rule、URL image、articleButton、table,以及 bold、italic、strike、underline、inline code 和 link。paragraph 与 heading 额外支持 attrs.textAlign,允许 left、center、right、justify;image 支持 attrs.imageAlign,允许 left、center、right;link 支持 href 与 custom 两种类型;articleButton 支持 text、button 和带 href 的 link 样式。
不支持 Highlight、上下标、Video、Mention、Emoji、Embed、AI Block 或协作评论。完整示例见 examples/article-demo.json。
