@naersen/tts-rich-text

Reusable Vue 3 rich-text editor for TTS workflows
2,492
Install
npm install @naersen/tts-rich-text
Latest Version:0.9.1
Vue:3
License:MIT
Last Updated:Sep 24, 2026
Links: npm
Maintainer: naersen

@naersen/tts-rich-text

面向 Vue 3 项目的 TTS 富文本编辑器。包内维护统一的 Tiptap 文档协议、TTS 标签、分句和数据转换;各业务项目通过 features、services 和 slots 注入差异,不再复制组件源码。

环境要求

  • Vue 3.4+
  • Node.js 18+
  • pnpm 9+

本地开发

pnpm install
pnpm dev

开发预览默认运行在 Vite 输出的本地地址。提交前执行:

pnpm format:check
pnpm build
pnpm test:e2e

format:check 按仓库根目录的 .prettierrc 校验代码风格,不一致时用 pnpm format 自动修复。build 会依次完成类型检查、单元测试和组件库构建。test:e2e 使用本机 Chrome 验证拼音标注、停顿插入、长按锁定、文本区右键面板、选区浮层标记与软换行边界上的浮层锚点、原主题样式和窄屏工具栏收纳。

安装

通过 npm 在业务项目中安装:

pnpm add @naersen/tts-rich-text

Vue、Tiptap 和 Element Plus 是 peer dependencies。业务项目没有安装时,需要同时添加 peer packages,并在应用入口加载 Element Plus 样式。

引入全局样式

使用默认的 TtsRichTextEditor 时,在业务项目的 src/main.ts 中全局引入 Element Plus 和组件库样式:

import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import '@naersen/tts-rich-text/styles.css'

import App from './App.vue'

createApp(App).mount('#app')

如果项目已有 main.ts 初始化逻辑,只需要把下面两行加入现有入口,不需要重复创建 Vue 应用:

import 'element-plus/dist/index.css'
import '@naersen/tts-rich-text/styles.css'

必须先引入 Element Plus,再引入组件库样式,因为 styles.css 包含组件基础样式、配套图标和对 Element Plus 的主题覆盖。请保持全局引入,不要放在某个页面组件的 scoped 样式中,否则弹窗、下拉菜单等挂载到页面其他位置的内容可能无法获得完整样式。

基础使用

<template>
	<tts-rich-text-editor
		v-model="documentValue"
		:voice-version="voiceVersion"
		:features="features"
		:services="services"
		:get-preview-params="getPreviewParams"
		:get-playback-speed="getPlaybackSpeed"
	/>
</template>

<script setup lang="ts">
import type { JSONContent } from '@tiptap/core'
import { ref } from 'vue'

import type {
	TtsEditorFeatures,
	TtsRichTextServices
} from '@naersen/tts-rich-text'
import {
	TtsRichTextEditor,
	getInitContent
} from '@naersen/tts-rich-text'

const documentValue = ref<JSONContent>(getInitContent())
const voiceVersion = ref(2)

const features: TtsEditorFeatures = {
	tools: ['stop', 'customStop', 'pinyin', 'replace', 'language', 'ignore'],
	enablePreview: true
}

const services: TtsRichTextServices = {
	parseDocument: async (file, signal) => {
		return api.parseDocument(file, { signal })
	},
	readFormula: async (formula, { signal, onChunk }) => {
		return api.readFormula(formula, { signal, onChunk })
	},
	synthesizePreview: async (request, signal) => {
		return api.synthesizePreview(request, { signal })
	},
	notify: (type, message) => messageServicetype
}

const getPreviewParams = () => ({
	timberId: selectedVoice.value.id,
	speed: selectedVoice.value.speed
})

// 仅用于预估时长,固定以 1 表示 1 倍速。
const getPlaybackSpeed = () => selectedVoice.value.speed / 50
</script>

上例只负责使用编辑器组件,样式应按前面的说明在 main.ts 中统一引入,不需要在每个业务组件中重复加载。

上例中的 api、messageService 和 selectedVoice 由业务项目提供,组件包不会读取业务项目的环境变量、请求实例或状态管理。

编辑器交互

编辑器内置三种标记入口,业务项目不需要额外接入。

长按锁定

工具栏中属于「光标插入类」的工具(默认为「停顿」「长停顿」)支持长按 0.5 秒锁定:

  • 锁定后在文本区任意位置点击,即在落点插入一个标记,可连续插入任意次,不用每次回到工具栏。
  • 锁定态按钮显示锁图标与当前参数(如「0.5 秒」),弹层底部会提示「已锁定 0.5 秒,点击文本插入标记」,回显的是本次实际会插入的时长。
  • 再次长按同一按钮,或按 Escape,即可解锁。

可被锁定的范围由 isInsertTagType() 判定,它以 deleteMode === DELETE_NODE 为准,只包含停顿、长停顿这类不包裹选中文本的标签。

锁定使用的参数优先取业务方在弹层里选过的值(由 rememberToolParams 记住),从未选过时回落到 getDefaultLockPayload(),长停顿默认 0.5 秒。

停顿时长

「停顿」「长停顿」弹层提供预设时长与自定义时长:

  • 预设(如「停顿 0.5 秒」「停顿 1 秒」)点一下即按该时长插入。
  • 自定义槽位点铅笔图标进入输入态,旧值会带入并全选,Enter 或再点对勾提交,Esc 放弃本次输入。这里不使用失焦提交,避免想取消时反而插入了一个标记。
  • 确认过的时长会留在槽位里,之后点槽位本体即按该值插入,可反复使用;铅笔在已设置时半透明显示。
  • 输入值下限为 0.1 秒、保留一位小数,解析不出数字时按放弃处理。

弹层是「创建菜单」而不是「设置面板」:正文里可以并存多个不同时长的停顿标记,因此预设项不显示选中态,「当前用的是多少秒」由上方锁定态按钮回答。

文本区右键面板

在文本区右键会唤起标记面板,头部按当前形态提示:

  • 光标处于文本中时提示「在光标处插入」,提供插入类标记。
  • 右键落在已有选区上时提示「标注选中文本」,提供包裹类标记。

面板打开时会自动收起选区浮层,避免两个浮层叠放。

选区浮层

选中文本后浮出的菜单包含试听与全部包裹类标记,参数型标记(如语种、数字读法)会在第二行展开可选值。未接入 synthesizePreview 时试听按钮不渲染,其余标记照常可用。

差异扩展

功能配置

features 可以覆盖工具列表、语言列表、拼音最大选区长度、数字最大长度和试听开关。包内提供 V1/V2 默认能力配置,项目配置拥有最高优先级。V2 默认提供全部工具;V1 也支持 ignore,但默认不提供 stop 和 continue。

拼音标注统一输出数字声调格式(如 chong2 qing4),不再根据 voiceVersion 转换为带声调符号的拼音。ü 会规范化为 v,轻声统一使用 5 表示。业务接口应按该格式处理拼音标签的 payload.text。

ignore 对应“忽略读音”工具,只能标记纯文本选区。convertToTtsFormat() 会保留句子原文,并为忽略区域输出替换指令:

{
	action: 'replace',
	model: 'replace',
	payload: ' ',
	position: 0,
	text: '需要忽略的文本',
	actionCode: 'ignore'
}

合成端应按 position 和 text 将该区域替换为空格,从而保留编辑器可见文本但不合成读音。

分句策略

convertToTtsFormat() 使用 splitTextToSentences() 生成 sentences。默认按视觉长度计算,中文、全角字符计 2,其他字符计 1;每段最小目标长度为 32,最大目标长度为 64。

超长内容优先在分号处分割,其次是逗号或冒号、空格,顿号仅在没有其他自然切点时使用。数字中的小数点和千分位逗号不会被当作分句点,过短尾段会自动重平衡。

需要自定义长度时,可以从核心入口调用:

import { splitTextToSentences } from '@naersen/tts-rich-text/core'

const sentences = splitTextToSentences(text, {
	minSegmentLength: 32,
	maxSegmentLength: 64,
	rebalanceRatio: 0.4
})

服务适配

以下能力只有传入对应 service 时才可用;未提供时保留原工具位置并显示为禁用态:

  • parseDocument:上传并解析 TXT/DOCX。
  • generateDraft:AI 写稿,支持通过 onChunk 增量输出。
  • readFormula:公式转读法,支持通过 onChunk 增量输出。
  • synthesizePreview:选区试听,返回音频 URL 或 Blob。
  • synthesizePreviewStream:保持原编辑器的流式分片试听,逐条调用 onMessage 传入后端音频消息。
  • confirmVoiceChange:接管音色版本冲突确认框;未提供时使用 Element Plus MessageBox。
  • notify:接入项目自己的消息组件。

所有耗时服务都会收到 AbortSignal,项目请求层必须将取消信号继续传给实际请求。

Slots

  • toolbar-before
  • toolbar-after
  • editor-after,参数为 { editor }
  • footer-before,参数为 { editor, textLength }
  • footer-after,参数为 { editor, textLength }

项目专属工具可以通过 slots 放在默认布局中;页面结构差异较大时,使用下方组合式布局。

组合式布局

TtsEditorRoot 维护编辑器实例、初始内容、服务和上下文,不输出固定布局,并默认挂载选区试听菜单。它的后代可以按项目需要组合 TtsEditorContent、工具栏和底部栏:

<template>
	<tts-editor-root
		ref="editorRef"
		:initial-content="documentValue"
		:services="services"
		@change="handleEditorChange"
	>
		<section class="project-editor">
			<header class="project-editor-header">
				<project-title />
				<tts-editor-toolbar />
				<button type="button" @click="editorRef?.clear()">
					清空
				</button>
			</header>

			<main class="project-editor-content">
				<tts-editor-content />
				<project-side-panel :editor="editorRef?.editor" />
			</main>

			<footer class="project-editor-footer">
				<span>{{ editorRef?.textLength ?? 0 }} 字</span>
				<tts-editor-footer />
			</footer>
		</section>
	</tts-editor-root>
</template>

<script setup lang="ts">
import { ref } from 'vue'

import type {
	TtsEditorRootExpose,
	TtsRichTextServices
} from '@naersen/tts-rich-text'
import {
	TtsEditorContent,
	TtsEditorFooter,
	TtsEditorRoot,
	TtsEditorToolbar,
	getInitContent
} from '@naersen/tts-rich-text'
import '@naersen/tts-rich-text/base.css'

const editorRef = ref<TtsEditorRootExpose>()
const documentValue = ref(getInitContent())
const services: TtsRichTextServices = {}

const handleEditorChange = () => {
	const value = editorRef.value?.getJson()
	if (value) documentValue.value = value
}
</script>

initialContent 只在编辑器初始化时读取,后续变化不会反向覆盖编辑器。需要主动替换内容时,调用 editorRef.value?.setContent(content)。Root 默认插槽只负责组合布局,不提供作用域参数。Root 内的后代组件通过 useEditorContext() 访问编辑器上下文;外层业务组件通过 TtsEditorRootExpose 和组件 ref 访问编辑器状态与命令。

Root 会自动挂载 TtsSelectionMenu,不用在默认插槽中重复引入。项目需要关闭选区试听或替换为自定义菜单时,传入 :show-selection-menu="false";TtsSelectionMenu 仍保留独立导出,供高级组合场景使用。

组合模式只需要引入 base.css。需要复用原项目的 Element Plus 全局主题时,再在它后面引入:

import 'element-plus/dist/index.css'
import '@naersen/tts-rich-text/base.css'
import '@naersen/tts-rich-text/theme-default.css'

styles.css 等价于依次引入 base.css 和 theme-default.css,继续用于默认成品组件。

原子组件

对编辑器外壳差异较大的项目,可以直接组合包内原始组件:

  • TtsEditorToolbar
  • TtsEditorContextMenu
  • TtsEditorRoot
  • TtsEditorContent
  • TtsEditorFooter
  • TtsSelectionMenu
  • TtsMarkPinyinModal
  • TtsTagNodeView
  • TtsAiRewriteModal
  • TtsFormulaModal
  • TtsReplaceTextModal
  • TtsUploadModal

这些组件共享 TtsEditorRoot 提供的编辑器上下文,不能脱离 Root 单独使用。TtsEditorContextMenu 是例外:它由 visible、x、y、hasSelection、items 驱动,通过 select(载荷为 { key, value })和 close 事件对外通信,可以脱离 Root 独立使用。

布局接近原项目时使用完整的 TtsRichTextEditor;页面结构差异较大时再组合原子组件,业务接口仍统一通过 services 注入。

组合式 API

自定义工具栏、试听面板或业务弹窗时,可以从包根入口引入:

import {
	useEditorToolbar,
	useEditorToolkit,
	useGlobalAudioControl,
	useSSETool,
	useSynthesizeAudioTool,
	useTestAudioTool,
	useVoiceChangeConfirm
} from '@naersen/tts-rich-text'

useSynthesizeAudioTool 和 useTestAudioTool 会读取编辑器 services,需要在 TtsEditorRoot 的后代组件中调用。useGlobalAudioControl 和 useSSETool 可以独立使用。

useEditorToolkit 提供工具栏、选区浮层和右键面板共用的工具清单(insertItems、selectionItems)与统一执行入口 runTool(key, value),自定义外层工具栏时用它可以让三处行为保持一致;useEditorToolbar 是底层实现,单独使用时需要自行维护清单。

对外方法

通过组件 ref 可以调用:

  • clear()
  • focus()
  • getJson()
  • getTtsData()
  • replaceText(text)
  • handleChangeVoice(currentModel, nextModel):返回 Promise<boolean>;存在版本冲突时先请求确认,确认后清空冲突内容,取消时返回 false。

核心转换函数也可以独立导入:

import { convertToTtsFormat } from '@naersen/tts-rich-text/core'

预估语音时长

estimateTtsDuration() 可独立计算文本的预估语音时长,返回值单位为秒。计算时会去除所有空白字符;汉字按 0.28 秒/字、其余字符按 0.14 秒/字符计算,再除以播放倍速。

import { estimateTtsDuration } from '@naersen/tts-rich-text'

const duration = estimateTtsDuration('你好 hello', 1.5)

第二个参数为播放倍速,默认为 1;传入 0、负数或无效值时也会按 1 倍速处理。

版本管理

各版本的功能变化和升级注意事项见 CHANGELOG.md。

遵循 SemVer:

  • 修复兼容问题:patch,例如 0.1.1。
  • 增加向后兼容能力:minor,例如 0.2.0。
  • 修改文档协议、TTS 输出或删除公开 API:major。

项目应依赖明确版本,通过升级依赖完成同步,不要把 src 目录复制回业务项目。

Related Packages

@norio-office/rich-text

A reusable Vue 3 rich text editor component built with Tiptap 3.

1,067
@henryx/pagely

A block-style rich text editor built with Vue 3 and TipTap

3,379
@shamimsofte/universal-editor-vue2

Vue 2 adapter for Universal Rich Text Editor (Vue 2.6+ & 2.7)

571 0
@shamimsofte/universal-editor-vue3

Vue 3 adapter and components for Universal Editor

564 0
@nova_voyager/article-editor-vue3

Protocol-driven Vue 3 article editor based on Tiptap

0 0

Version History

Version Released Vue License
0.9.1 ^3.4.0 MIT
0.9.0 ^3.4.0 MIT
0.8.3 ^3.4.0 MIT
0.8.2 ^3.4.0 MIT
0.8.1 ^3.4.0 MIT