build(deps): 添加 Tiptap 富文本编辑器依赖

This commit is contained in:
yelan
2026-09-11 16:13:05 +08:00
parent 7035c6f378
commit 1b576c6095
5 changed files with 1083 additions and 9 deletions
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import EditorCkPane from './editors/EditorCkPane.vue'
import EditorTipTapPane from './editors/EditorTipTapPane.vue'
import EditorTinyPane from './editors/EditorTinyPane.vue'
import EditorWangPane from './editors/EditorWangPane.vue'
import type { DocumentTemplate, TemplateEditorForm } from '../types'
@@ -24,7 +26,7 @@ const emit = defineEmits<{
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tinymce'
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tinymce' | 'tiptap'
interface EditorPaneHandle {
getText: () => string
@@ -35,6 +37,7 @@ const engineComponents = {
wangeditor: EditorWangPane,
ckeditor5: EditorCkPane,
tinymce: EditorTinyPane,
tiptap: EditorTipTapPane,
}
const engineOptions: Array<{ key: EditorEngine; label: string; note: string }> = [
@@ -53,6 +56,11 @@ const engineOptions: Array<{ key: EditorEngine; label: string; note: string }> =
label: 'TinyMCE',
note: '均衡之选(MIT)',
},
{
key: 'tiptap',
label: 'TipTap',
note: 'ProseMirror 架构,可自定义签署域节点;粘贴走 schema,样式有损(MIT)',
},
]
const engine = ref<EditorEngine>('wangeditor')
@@ -162,15 +170,23 @@ async function handleDocxChange(event: Event) {
}
function save() {
const text = paneRef.value?.getText() ?? ''
// 引擎面板句柄可能因初始化时序暂不可用,此时用 valueHtml 兜底判空,避免误报"内容为空"。
const handleText = paneRef.value?.getText() ?? ''
const fallbackText = valueHtml.value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.trim()
const text = handleText || fallbackText
if (!form.name.trim()) {
importMessage.value = '请填写文书名称'
ElMessage.error('请填写文书名称')
return
}
if (!text) {
importMessage.value = '文书内容为空,请粘贴 Word 内容或导入 .docx'
ElMessage.error('文书内容为空,请粘贴 Word 内容或导入 .docx')
return
}
@@ -33,7 +33,8 @@ const init = {
license_key: 'gpl',
menubar: false,
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
plugins: 'lists table paste',
// TinyMCE 8:paste 已并入核心,不能再声明(声明会导致插件加载失败)。
plugins: 'lists table',
toolbar:
'undo redo | bold italic underline | alignleft aligncenter alignright alignjustify | fontsizeselect forecolor backcolor | bullist numlist | table',
skin: false,
@@ -45,10 +46,10 @@ const init = {
`,
paste_data_images: true,
height: 460,
}
function onInit(_event: unknown, editor: TinyMceApi) {
editorRef.value = editor
// tinymce-vue 不派发 init 事件,编辑器实例只能从这里拿。
init_instance_callback: (editor: TinyMceApi) => {
editorRef.value = editor
},
}
function getText(): string {
@@ -68,7 +69,6 @@ defineExpose({ getText, insertText })
:model-value="props.modelValue"
:init="init"
@update:model-value="emit('update:modelValue', $event)"
@init="onInit"
/>
</div>
</template>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import FontFamily from '@tiptap/extension-font-family'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
// TipTap(ProseMirror 架构):自定义能力最强(未来可把"勾选签署域"做成自定义节点),
// 但粘贴内容会经过 schema 过滤,未注册的内联样式(如 font-size)会丢失——保真是它的弱项。
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editor = shallowRef<Editor>()
// 工具栏激活状态依赖 editor 内部状态,用事务计数驱动模板刷新。
const transactionTick = ref(0)
const editorInstance = new Editor({
content: props.modelValue || '<p></p>',
extensions: [
StarterKit,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
FontFamily,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
],
onUpdate: ({ editor: current }) => {
emit('update:modelValue', current.getHTML())
},
onTransaction: () => {
transactionTick.value += 1
},
})
editor.value = editorInstance
watch(
() => props.modelValue,
(value) => {
const current = editor.value
if (current && value !== current.getHTML()) {
current.commands.setContent(value || '<p></p>')
}
},
)
function getText(): string {
return editor.value?.getText().trim() ?? ''
}
function insertText(text: string) {
editor.value?.commands.insertContent(text)
}
defineExpose({ getText, insertText })
onBeforeUnmount(() => {
editorInstance.destroy()
})
</script>
<template>
<div class="tiptap-pane">
<div v-if="editor" class="pane-toolbar">
<button
type="button"
:class="{ active: editor.isActive('bold') }"
:data-tick="transactionTick"
@click="editor.chain().focus().toggleBold().run()"
>
B
</button>
<button
type="button"
:class="{ active: editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
I
</button>
<button
type="button"
:class="{ active: editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
>
U
</button>
<button
type="button"
:class="{ active: editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>
H1
</button>
<button
type="button"
:class="{ active: editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
H2
</button>
<button
type="button"
:class="{ active: editor.isActive({ textAlign: 'center' }) }"
@click="editor.chain().focus().setTextAlign('center').run()"
>
居中
</button>
<button
type="button"
:class="{ active: editor.isActive({ textAlign: 'left' }) }"
@click="editor.chain().focus().setTextAlign('left').run()"
>
左
</button>
<button
type="button"
@click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()"
>
表格
</button>
<button type="button" @click="editor.chain().focus().undo().run()">撤销</button>
<button type="button" @click="editor.chain().focus().redo().run()">重做</button>
</div>
<EditorContent :editor="editor" class="pane-content" />
</div>
</template>
<style scoped>
.tiptap-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.pane-toolbar {
display: flex;
flex-wrap: wrap;
flex-shrink: 0;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--line);
}
.pane-toolbar button {
padding: 4px 10px;
color: var(--ink);
font-size: 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: 5px;
}
.pane-toolbar button.active {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.pane-content {
flex: 1;
min-height: 340px;
max-height: 460px;
overflow-y: auto;
padding: 12px 16px;
}
.pane-content :deep(.ProseMirror) {
min-height: 320px;
outline: none;
}
.pane-content :deep(.ProseMirror table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
table-layout: fixed;
}
.pane-content :deep(.ProseMirror th),
.pane-content :deep(.ProseMirror td) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
}
</style>