build(deps): 移除 ckeditor 和 tiptap 相关依赖

This commit is contained in:
yelan
2026-09-14 09:38:24 +08:00
parent 545326bd81
commit ff67f88696
9 changed files with 144 additions and 3882 deletions
+1 -3407
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -13,23 +13,9 @@
"format:check": "prettier . --check"
},
"dependencies": {
"@ckeditor/ckeditor5-vue": "^8.2.0",
"@tiptap/extension-color": "^2.27.3",
"@tiptap/extension-font-family": "^2.27.3",
"@tiptap/extension-table": "^2.27.3",
"@tiptap/extension-table-cell": "^2.27.3",
"@tiptap/extension-table-header": "^2.27.3",
"@tiptap/extension-table-row": "^2.27.3",
"@tiptap/extension-text-align": "^2.27.3",
"@tiptap/extension-text-style": "^2.27.3",
"@tiptap/extension-underline": "^2.27.3",
"@tiptap/pm": "^2.27.3",
"@tiptap/starter-kit": "^2.27.3",
"@tiptap/vue-3": "^2.27.3",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.20.0",
"ckeditor5": "^48.5.0",
"element-plus": "^2.14.5",
"mammoth": "^1.12.2",
"pinia": "^4.0.3",
+99
View File
@@ -0,0 +1,99 @@
/**
* Word / WPS 粘贴富文本的归一化处理。
*
* 直接插入 Word 复制出的 HTML 会出现「表格被截断」,原因有三:
* ① 表格与单元格带绝对宽度(pt / px),宽度总和超过容器后被整体裁切;
* ② 单元格带 `white-space: nowrap` 或 `nowrap` 属性,内容不换行而横向溢出;
* ③ Word 私有 `mso-*` 样式与 `overflow: hidden` 把超出部分直接隐藏。
*
* 本模块只解除这些「布局锁定」,表格结构(行列、合并、边框、加粗、对齐)全部保留,
* 宽度改由各渲染端的 CSS 统一控制,避免同一份文书在不同容器里表现不一致。
*/
const COMMENTS = /<!--[\s\S]*?-->/g
/** 成对出现的标签整体删除(含内容) */
const BLOCK_TAGS = /<(script|style|meta|link)\b[^>]*>[\s\S]*?<\/\1>/gi
/** 自闭合标签:`<script />`、`<meta ...>`、`<link ...>` */
const VOID_TAGS = /<(script|meta|link)\b[^>]*\/?>/gi
/** 会造成截断的样式声明:绝对宽度、固定布局与内容裁剪 */
const LAYOUT_LOCK_STYLES = new Set([
'width',
'min-width',
'max-width',
'height',
'table-layout',
'white-space',
'overflow',
'overflow-x',
'overflow-y',
])
/** 与样式等价的 HTML 属性,Word 会同时输出两者 */
const LAYOUT_LOCK_ATTRIBUTES = ['width', 'height', 'nowrap']
function sanitizeStyleAttribute(style: string): string {
return style
.split(';')
.map((declaration) => declaration.trim())
.filter(Boolean)
.filter((declaration) => {
const property = (declaration.split(':')[0] ?? '').trim().toLowerCase()
if (!property || property.startsWith('mso-')) {
return false
}
return !LAYOUT_LOCK_STYLES.has(property)
})
.join('; ')
}
/**
* 归一化 Word / WPS 粘贴的富文本 HTML。
* 入参为空或解析失败时原样返回,不会抛错。
*/
export function normalizePastedHtml(html: string): string {
if (!html) {
return ''
}
const stripped = html.replace(COMMENTS, '').replace(BLOCK_TAGS, '').replace(VOID_TAGS, '')
const parsed = new DOMParser().parseFromString(`<div id="pasted-root">${stripped}</div>`, 'text/html')
const root = parsed.getElementById('pasted-root')
if (!root) {
return stripped
}
// Word 私有属性(mso-*、align 之外的 xmlns 等)在其他渲染环境里没有意义,留着反而干扰布局
root.querySelectorAll('*').forEach((element) => {
Array.from(element.attributes).forEach((attribute) => {
if (attribute.name.toLowerCase().startsWith('mso-')) {
element.removeAttribute(attribute.name)
}
})
})
// 表格、列定义与单元格:解除绝对尺寸与不换行约束,宽度交由渲染端 CSS 决定
root.querySelectorAll('table, col, colgroup, td, th').forEach((element) => {
LAYOUT_LOCK_ATTRIBUTES.forEach((name) => element.removeAttribute(name))
const style = element.getAttribute('style')
if (!style) {
return
}
const sanitized = sanitizeStyleAttribute(style)
if (sanitized) {
element.setAttribute('style', sanitized)
} else {
element.removeAttribute('style')
}
})
return root.innerHTML
}
@@ -36,6 +36,7 @@ const DOCUMENT_BASE_STYLE = `
font-size: 14px;
line-height: 1.85;
background: #fff;
overflow-x: auto;
}
img {
max-width: 100%;
@@ -44,6 +45,7 @@ const DOCUMENT_BASE_STYLE = `
width: 100%;
margin: 8px 0;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
@@ -52,6 +54,12 @@ const DOCUMENT_BASE_STYLE = `
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
th p,
td p {
margin: 0;
}
`
@@ -4,8 +4,6 @@ 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 EditorWangPane from './editors/EditorWangPane.vue'
import type { DocumentTemplate, TemplateEditorForm } from '../types'
@@ -24,40 +22,13 @@ const emit = defineEmits<{
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tiptap'
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste)。
interface EditorPaneHandle {
getText: () => string
insertText: (text: string) => void
}
const engineComponents = {
wangeditor: EditorWangPane,
ckeditor5: EditorCkPane,
tiptap: EditorTipTapPane,
}
const engineOptions: Array<{ key: EditorEngine; label: string; note: string }> = [
{
key: 'wangeditor',
label: 'WangEditor',
note: '已接管粘贴,尽量保留内联样式(MIT)',
},
{
key: 'ckeditor5',
label: 'CKEditor 5',
note: 'PasteFromOffice 插件,Word 粘贴保真最强(GPL2)',
},
{
key: 'tiptap',
label: 'TipTap',
note: 'ProseMirror 架构,可自定义签署域节点;粘贴走 schema,样式有损(MIT)',
},
]
const engine = ref<EditorEngine>('wangeditor')
const engineNote = ref(engineOptions[0]?.note ?? '')
const valueHtml = ref('<p><br></p>')
const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
@@ -126,11 +97,6 @@ function handleCampusChange() {
}
}
function selectEngine(next: EditorEngine) {
engine.value = next
engineNote.value = engineOptions.find((option) => option.key === next)?.note ?? ''
}
function insertPlaceholder(text: string) {
paneRef.value?.insertText(text)
}
@@ -270,20 +236,6 @@ function save() {
</div>
<div class="editor-shell">
<div class="engine-bar">
<span class="engine-label">编辑器引擎</span>
<button
v-for="option in engineOptions"
:key="option.key"
type="button"
class="engine-button"
:class="{ active: engine === option.key }"
@click="selectEngine(option.key)"
>
{{ option.label }}
</button>
<span class="engine-note">{{ engineNote }}</span>
</div>
<div class="docx-bar">
<button
type="button"
@@ -307,9 +259,7 @@ function save() {
</span>
</div>
<div class="editor-area">
<component
:is="engineComponents[engine]"
:key="engine"
<EditorWangPane
ref="paneRef"
:model-value="valueHtml"
@update:model-value="valueHtml = $event"
@@ -514,46 +464,6 @@ function save() {
font-size: 12px;
}
.engine-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.engine-label {
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.engine-button {
padding: 5px 12px;
color: var(--mut);
font-size: 12px;
font-weight: 700;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
}
.engine-button:hover {
border-color: var(--brand);
color: var(--brand);
}
.engine-button.active {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.engine-note {
color: var(--mut);
font-size: 11.5px;
}
.editor-area {
display: flex;
flex-direction: column;
@@ -1,149 +0,0 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
import {
Alignment,
Bold,
ClassicEditor,
Essentials,
FontColor,
FontSize,
GeneralHtmlSupport,
Heading,
Italic,
PasteFromOffice,
Paragraph,
Table,
TableToolbar,
Underline,
} from 'ckeditor5'
import type { Editor } from 'ckeditor5'
import 'ckeditor5/ckeditor5.css'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorRef = shallowRef<Editor>()
// PasteFromOffice + GeneralHtmlSupport:CKEditor 的 Word 粘贴保真核心,
// 开源 GPL 构建即包含(对应 config 中 licenseKey: 'GPL')。
const config = {
licenseKey: 'GPL',
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(PasteFromOffice 插件处理 Word 粘贴)。',
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Underline,
Alignment,
FontSize,
FontColor,
Table,
TableToolbar,
GeneralHtmlSupport,
PasteFromOffice,
],
toolbar: [
'heading',
'|',
'bold',
'italic',
'underline',
'|',
'fontSize',
'fontColor',
'|',
'alignment:left',
'alignment:center',
'alignment:right',
'alignment:justify',
'|',
'bulletedList',
'numberedList',
'|',
'insertTable',
'undo',
'redo',
],
heading: {
options: [
{ model: 'paragraph' as const, title: '正文', class: 'ck-heading_paragraph' },
{ model: 'heading1' as const, view: 'h1', title: '标题 1', class: 'ck-heading_heading1' },
{ model: 'heading2' as const, view: 'h2', title: '标题 2', class: 'ck-heading_heading2' },
{ model: 'heading3' as const, view: 'h3', title: '标题 3', class: 'ck-heading_heading3' },
],
},
// 尽量保留粘贴内容中的内联样式与 class(Word 居中等视觉信息)。
htmlSupport: {
allow: [{ name: /[\s\S]+/, classes: /[\s\S]+/, styles: /[\s\S]+/, attributes: /[\s\S]+/ }],
},
table: {
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
},
}
function onReady(editor: Editor) {
editorRef.value = editor
}
function getText(): string {
const html = editorRef.value?.getData() ?? ''
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.trim()
}
function insertText(text: string) {
const editor = editorRef.value
if (!editor) {
return
}
editor.model.change((writer) => {
const focus = editor.model.document.selection.focus
if (focus) {
writer.insertText(text, focus)
}
})
}
defineExpose({ getText, insertText })
</script>
<template>
<div class="ck-pane">
<Ckeditor
class="pane-editor"
:editor="ClassicEditor"
:model-value="props.modelValue"
:config="config"
@update:model-value="emit('update:modelValue', String($event))"
@ready="onReady"
/>
</div>
</template>
<style scoped>
.ck-pane {
height: 100%;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.ck-pane :deep(.ck-editor__editable_inline) {
min-height: 380px;
max-height: 460px;
}
</style>
@@ -1,206 +0,0 @@
<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>
@@ -5,6 +5,8 @@ import { onBeforeUnmount, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
import { normalizePastedHtml } from '@/utils/rich-text'
const props = defineProps<{
modelValue: string
}>()
@@ -16,28 +18,20 @@ const emit = defineEmits<{
const editorRef = shallowRef<IDomEditor>()
const editorMode = 'default'
// 剪贴板富 HTML 的轻量清理:去掉脚本/样式表/条件注释(WangEditor 默认粘贴会过滤掉
// 大量内联样式导致"粘贴变形",这里接管粘贴以尽量保留 Word 原样式)。
function cleanClipboardHtml(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<(script|style|meta|link)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
.replace(/<(script|meta|link)\b[^>]*\/?>/gi, '')
}
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video', 'fullScreen'],
}
const editorConfig: Partial<IEditorConfig> = {
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
// 接管粘贴:优先使用剪贴板中的富 HTML 原样插入(保留居中、加粗、表格等内联样式);
// 接管粘贴:优先使用剪贴板中的富 HTML(保留居中、加粗、表格等结构),
// 但先做归一化——Word 表格的绝对列宽与 nowrap 会让整表在窄容器里被裁切;
// 纯文本粘贴(无富 HTML)时返回 true 走默认处理。
customPaste: (editor, event) => {
const html = event.clipboardData?.getData('text/html')
if (html) {
editor.dangerouslyInsertHtml(cleanClipboardHtml(html))
editor.dangerouslyInsertHtml(normalizePastedHtml(html))
return false
}
@@ -102,6 +96,28 @@ onBeforeUnmount(() => {
.pane-content {
flex: 1;
min-height: 340px;
overflow-y: auto;
overflow: auto;
}
/* 粘贴的 Word 表格:解除绝对列宽、允许单元格换行,
超出容器时横向滚动而不是被裁切 */
.pane-content :deep(table) {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.pane-content :deep(th),
.pane-content :deep(td) {
padding: 4px 6px;
vertical-align: top;
border: 1px solid #52646e;
overflow-wrap: anywhere;
word-break: break-word;
}
.pane-content :deep(th p),
.pane-content :deep(td p) {
margin: 0;
}
</style>
@@ -240,7 +240,7 @@ function handleSelectionChange(event: Event) {
.sign-doc-body {
flex: 1;
padding: 26px 32px 30px;
overflow-y: auto;
overflow: auto;
}
.sign-doc-footer {
@@ -266,10 +266,12 @@ function handleSelectionChange(event: Event) {
color: #1c2b33;
}
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
.sign-doc-body :deep(table) {
width: 100%;
margin: 8px 0 12px;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
@@ -279,6 +281,8 @@ function handleSelectionChange(event: Event) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
.sign-doc-body :deep(th) {