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"
|
"format:check": "prettier . --check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"ckeditor5": "^48.5.0",
|
|
||||||
"element-plus": "^2.14.5",
|
"element-plus": "^2.14.5",
|
||||||
"mammoth": "^1.12.2",
|
"mammoth": "^1.12.2",
|
||||||
"pinia": "^4.0.3",
|
"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;
|
font-size: 14px;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -44,6 +45,7 @@ const DOCUMENT_BASE_STYLE = `
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,12 @@ const DOCUMENT_BASE_STYLE = `
|
|||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
border: 1px solid #52646e;
|
border: 1px solid #52646e;
|
||||||
vertical-align: middle;
|
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 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 EditorWangPane from './editors/EditorWangPane.vue'
|
||||||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||||
|
|
||||||
@@ -24,40 +22,13 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
||||||
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
||||||
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
|
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
|
||||||
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tiptap'
|
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste)。
|
||||||
|
|
||||||
interface EditorPaneHandle {
|
interface EditorPaneHandle {
|
||||||
getText: () => string
|
getText: () => string
|
||||||
insertText: (text: string) => void
|
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 valueHtml = ref('<p><br></p>')
|
||||||
const docxInput = ref<HTMLInputElement | null>(null)
|
const docxInput = ref<HTMLInputElement | null>(null)
|
||||||
const importing = ref(false)
|
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) {
|
function insertPlaceholder(text: string) {
|
||||||
paneRef.value?.insertText(text)
|
paneRef.value?.insertText(text)
|
||||||
}
|
}
|
||||||
@@ -270,20 +236,6 @@ function save() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-shell">
|
<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">
|
<div class="docx-bar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -307,9 +259,7 @@ function save() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-area">
|
<div class="editor-area">
|
||||||
<component
|
<EditorWangPane
|
||||||
:is="engineComponents[engine]"
|
|
||||||
:key="engine"
|
|
||||||
ref="paneRef"
|
ref="paneRef"
|
||||||
:model-value="valueHtml"
|
:model-value="valueHtml"
|
||||||
@update:model-value="valueHtml = $event"
|
@update:model-value="valueHtml = $event"
|
||||||
@@ -514,46 +464,6 @@ function save() {
|
|||||||
font-size: 12px;
|
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 {
|
.editor-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
||||||
|
|
||||||
|
import { normalizePastedHtml } from '@/utils/rich-text'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
}>()
|
}>()
|
||||||
@@ -16,28 +18,20 @@ const emit = defineEmits<{
|
|||||||
const editorRef = shallowRef<IDomEditor>()
|
const editorRef = shallowRef<IDomEditor>()
|
||||||
const editorMode = 'default'
|
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> = {
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
excludeKeys: ['group-video', 'fullScreen'],
|
excludeKeys: ['group-video', 'fullScreen'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorConfig: Partial<IEditorConfig> = {
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
||||||
// 接管粘贴:优先使用剪贴板中的富 HTML 原样插入(保留居中、加粗、表格等内联样式);
|
// 接管粘贴:优先使用剪贴板中的富 HTML(保留居中、加粗、表格等结构),
|
||||||
|
// 但先做归一化——Word 表格的绝对列宽与 nowrap 会让整表在窄容器里被裁切;
|
||||||
// 纯文本粘贴(无富 HTML)时返回 true 走默认处理。
|
// 纯文本粘贴(无富 HTML)时返回 true 走默认处理。
|
||||||
customPaste: (editor, event) => {
|
customPaste: (editor, event) => {
|
||||||
const html = event.clipboardData?.getData('text/html')
|
const html = event.clipboardData?.getData('text/html')
|
||||||
|
|
||||||
if (html) {
|
if (html) {
|
||||||
editor.dangerouslyInsertHtml(cleanClipboardHtml(html))
|
editor.dangerouslyInsertHtml(normalizePastedHtml(html))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +96,28 @@ onBeforeUnmount(() => {
|
|||||||
.pane-content {
|
.pane-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 340px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ function handleSelectionChange(event: Event) {
|
|||||||
.sign-doc-body {
|
.sign-doc-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 26px 32px 30px;
|
padding: 26px 32px 30px;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sign-doc-footer {
|
.sign-doc-footer {
|
||||||
@@ -266,10 +266,12 @@ function handleSelectionChange(event: Event) {
|
|||||||
color: #1c2b33;
|
color: #1c2b33;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
|
||||||
.sign-doc-body :deep(table) {
|
.sign-doc-body :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0 12px;
|
margin: 8px 0 12px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -279,6 +281,8 @@ function handleSelectionChange(event: Event) {
|
|||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
border: 1px solid #52646e;
|
border: 1px solid #52646e;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sign-doc-body :deep(th) {
|
.sign-doc-body :deep(th) {
|
||||||
|
|||||||
Reference in New Issue
Block a user