build(deps): 移除 ckeditor 和 tiptap 相关依赖
This commit is contained in:
Generated
+1
-3407
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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(/ /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) {
|
||||
|
||||
Reference in New Issue
Block a user