build(deps): 添加 Tiptap 富文本编辑器依赖
This commit is contained in:
@@ -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(/ /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>
|
||||
Reference in New Issue
Block a user