ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CKEditor 5设计图粘贴实践:剪贴板解析、压缩上传与回显方案

CKEditor 5设计图粘贴实践:剪贴板解析、压缩上传与回显方案 1. 汽车制造场景下的粘贴需求为什么不能简单调用一个上传按钮先交代一下背景。我在做某主机厂的质量追溯与工艺协同平台时收到一个很具体的需求工艺工程师在系统里填写现场问题反馈单时要把CAD设计截图、检具设计方案参考图直接粘贴进富文本编辑器保存后其他部门的人打开单据能原样看到这张图并且图纸细节要足够清晰。这个需求听起来没什么技术含量但真正落地的时候才发现汽车制造行业的“粘贴”需求比普通内容后台要复杂得多。普通博客编辑器里粘贴一张截图无非是转成base64存进HTML或者上传到图床替换成URL放在内容管理系统里跑得挺顺。但制造行业的图纸粘贴有几个特殊约束第一设计图往往是从专业软件里截出来的图片尺寸大、细节密集压缩后根本看不清焊缝编号和公差标注第二这类图片通常以剪贴板里的Bitmap或PNG二进制形式出现部分场景下甚至带着CAD软件的OLE对象信息第三图纸是要进入正式单据流程的后续要流转到审批、归档、审计环节图片不能只存在于编辑器内部的临时引用里必须有明确的存储路径和生命周期管理。所以当标题里出现“汽车制造CKEDITOR如何通过示例实现设计图粘贴”这样的问题时我第一反应是这绝不是一个简单的“打开编辑器、粘贴、完事”的示例而是一整套从剪贴板数据读取、到图片落地存储、再到编辑器内容回显的完整链路。这篇内容我会按照我们实际项目里的落地过程来讲包含初始化编辑器的基本姿势、粘贴事件的拦截方式、二进制数据的解析思路、图片存储与回显方案以及几个在制造行业环境里特别容易踩的坑。整个方案基于CKEditor 5的ClassicEditor实现示例代码可以直接复制改造但更重要的是理解每一步为什么这么设计。2. 先搭一个“能粘贴”的编辑器同时想清楚设计图的归档路径2.1 基础编辑器初始化与工具栏配置CKEditor 5的初始化方式很直接纯JavaScript引入也行通过npm基于现有前端工程构建也行。我们当时的前端是Vue2技术栈所以用的是ckeditor/ckeditor5-vue2结合ckeditor/ckeditor5-build-classic。先看一个最基础的初始化示例import ClassicEditor from ckeditor/ckeditor5-build-classic import Vue from vue new Vue({ el: #app, data: { editorData: }, methods: { onEditorReady(editor) { this.editor editor // 这里先把编辑器实例存下来后面挂载paste监听要用 } }, template: div ckeditor :editoreditorType v-modeleditorData :configeditorConfig readyonEditorReady /ckeditor /div })编辑器配置项里比较关键的是toolbar和paste相关的选项。汽车制造场景下工具栏必须有imageUpload或自定义的上传按钮因为设计图不能只靠粘贴进入历史数据里还有很多以附件形式上传承的图纸需要兼容两种入口。editorConfig: { toolbar: [ heading, |, bold, italic, bulletedList, numberedList, |, imageUpload, |, undo, redo ], language: zh-cn }这里有必要先解释一个问题为什么标题里强调的是“通过示例实现设计图粘贴”而不是直接说“配置一下就行”因为CKEditor 5的默认行为里从剪贴板粘贴图片时编辑器会自动把图片转成base64字符串内联进HTML内容里。这在演示环境看没问题但放到制造行业的业务系统里一份工单可能包含几十张图纸截图每张2MB到5MB全部base64进HTML单据详情页打开时浏览器直接卡死数据库字段体积也爆炸。所以我们要做的“示例”核心不是让编辑器能识别图片而是把“编辑器识别图片”这个动作改造成“编辑器拿到图片数据后按照我们的归档策略落地存储再以稳定的URL形式嵌入内容”。2.2 设计图的归档路径规划为什么不能直接依赖默认base64在写任何代码之前先做存储规划。我建议看到这个问题的读者无论你们项目里最终选了哪个编辑器都先想清楚一件事粘贴进来的设计图最终应该存到哪里制造行业的数据交互有个特点——图纸不是孤立的它要和ERP工单号、BOM编码、质量问题编号关联。如果图片只是作为编辑器HTML的一部分存在后续做图纸版本比对、按工单检索图纸、做数据审计时都会非常痛苦。所以我们的方案是粘贴进来的图片优先提取为File或Blob对象交给后端服务保存到独立的文件服务MinIO或Nginx静态目录均可后端返回图片的持久化URL编辑器内容里只保存这个URL不保存图片实体。这套路径的好处在于编辑器生成的HTML可以做到“轻量化”同时图片生命周期独立管理。工艺工程师打开历史单据时即使编辑器内容被缓存图片也能从文件服务单独拉取不会拖慢详情页加载。3. 粘贴事件拦截拿到剪贴板里的真实二进制图纸数据3.1 监听paste事件并解析剪贴板数据结构这是整条链路技术上最核心的一环。CKEditor 5本身对粘贴事件做了封装但我们要做的自定义行为最好还是绕开默认处理直接从原生DOM事件层面取数据。思路是在编辑器初始化完成后拿到底层编辑器的DOM根节点给它挂一个paste事件监听在CKEditor内部处理之前先尝试获取剪贴板里的图片数据。onEditorReady(editor) { // 获取编辑器可编辑区域的DOM元素 const domElement editor.ui.getEditableElement() domElement.addEventListener(paste, handlePaste, true) function handlePaste(event) { // 检查剪贴板数据 const items event.clipboardData event.clipboardData.items if (!items) { return } let imageFile null for (let i 0; i items.length; i) { const item items[i] if (item.type.indexOf(image) ! -1) { // 关键点getAsFile可以把剪贴板里的图片数据转换成File对象 imageFile item.getAsFile() break } } if (!imageFile) { // 剪贴板里没有图片让编辑器继续默认处理纯文本或HTML return } // 阻止编辑器原有的粘贴处理因为我们接下来要接管这张图 event.preventDefault() event.stopPropagation() // 走我们自己的图纸上传流程 uploadDesignImage(imageFile) } }这里有几个细节值得展开说明。第一个细节是clipboardData.items的判断。当你从CAD、画图、Photoshop或者微信截图里复制图片时剪贴板里的数据类型往往是image/png或者image/x-png但偶尔有些软件会同时写入text/html和image/png两种数据。如果我们不优先判断图片类型编辑器会默认优先处理HTML导致图片被当成一个外部链接或者直接丢失。第二个细节是item.getAsFile()的兼容性。这个API在现代Chromium内核的浏览器里都很稳定但制造企业里偶尔还能见到老版本的IE或旧版Edge这种情况下getAsFile可能不存在。所以代码里应该补一个兼容分支if (typeof item.getAsFile function) { imageFile item.getAsFile() } else if (item.kind file) { // 极老的环境下尝试通过webkitGetAsEntry等方式获取 imageFile item.getAsEntry ? null : item.getAsFile() }如果取不到imageFile我建议直接放弃自定义处理把控制权交还给编辑器默认逻辑至少用户不会觉得“粘贴无效”。第三个细节比较关键是行业场景带来的——汽车制造领域的设计图经常是从专业软件里“复制-粘贴”出来的而不是从截图工具里来的。这种情况下剪贴板里往往除了PNG位图数据还有OLE对象、元数据、甚至矢量格式。我们遍历items时不要只匹配image/png我的实际做法是const imageTypes [image/png, image/jpeg, image/jpg, image/bmp, image/webp] const matched Array.from(items).find(item { const type item.type.toLowerCase() return imageTypes.some(t type.indexOf(t) ! -1) })因为CAD软件往剪贴板写数据时类型字符串有时会带后缀参数比如image/png;charsetutf-8直接用匹配容易漏掉。3.2 从剪贴板数据构造文件名和类型信息拿到File对象后还有一个容易忽略的问题文件对象的name属性通常是空的因为剪贴板不是文件系统。但后端存储设计图时需要文件名来做格式识别和路径管理。我的做法是先判断imageFile.type手动拼一个合适的后缀function getImageExtension(file) { const type file.type if (type image/png) { return png } if (type image/jpeg || type image/jpg) { return jpg } if (type image/bmp) { return bmp } if (type image/webp) { return webp } return png // 兜底 } const ext getImageExtension(imageFile) const originName imageFile.name imageFile.name.length 0 ? imageFile.name : clipboard_design_ Date.now() . ext另外对设计图场景我建议在文件名或者上传接口的附加参数里带上来源标记比如sourceTypeclipboard、relatedNo工单号。后续审计图纸来源或者排查“这张图到底是谁在哪个环节贴进来的”时这一点信息能省很多事。4. 设计图在编辑器中的落地上传策略、占位符与插入方式4.1 先转base64预览再上传还是直接异步上传粘贴拦截拿到File对象之后设计的下一步是图片上传。这里有两个截然不同的路线路线A直接把File对象通过FormData异步上传到后端接口后端返回URL后用editor.execute(insertImage, { source: url })插入编辑器。路线B先把File对象转成base64立刻插入编辑器让用户看到图同时异步执行上传上传完成后把编辑器里的base64替换成后端URL。两种方案我都试过。在制造企业的内部网络环境里路线B的体验明显更好因为部分地区节点的上传带宽不高一张3MB的图纸可能要传五六秒如果采用路线A用户在这五六秒里看不到任何反馈很容易产生“是不是没粘上”的怀疑于是又复制粘贴一次结果产生重复图片。但路线B有个副作用——先插入base64再替换URL如果替换时机没控制好用户在图片上传过程中就点了保存后端存储的HTML内容里就是base64前面的功夫全白费了。所以我在实际项目里采用的是折中方案生成一个带“上传中”状态的占位HTML片段插入编辑器同时开始上传上传成功后通过编辑器模型替换占位内容上传失败时占位内容替换为错误提示文本并销毁未完成的图片引用。核心实现如下function insertPlaceholder(editor) { const viewFragment editor.data.processor.toView( div classdesign-image-uploading span设计图上传中.../span /div ) const modelFragment editor.data.toModel(viewFragment) editor.model.insertContent(modelFragment) } async function uploadDesignImage(file) { const formData new FormData() formData.append(file, file) formData.append(sourceType, clipboard) formData.append(relatedNo, currentWorkOrderNo || ) insertPlaceholder(editor) try { const res await fetch(/api/design-image/upload, { method: POST, body: formData }) const data await res.json() if (data data.url) { replacePlaceholderWithImage(data.url) } else { replacePlaceholderWithError() } } catch (e) { replacePlaceholderWithError() } }4.2 replacePlaceholderWithImage怎么做到精准替换替换占位符时最怕的就是用户在图片上传过程中又插入了别的内容导致选择范围错乱。我的处理方式是给占位元素加一个特定属性然后在编辑器模型里通过find来定位function replacePlaceholderWithImage(url) { const writer editor.model.change() // 遍历模型找到上一步插入的占位节点 let placeholderNode null const root editor.model.document.getRoot() for (const node of writer.createRangeIn(root).getItems()) { if (node.is(element) node.hasAttribute(data-design-image-uploading)) { placeholderNode node break } } if (!placeholderNode) { // 说明占位节点已不在内容中比如用户撤销了 return } // 构建图片元素并替换 const imageElement writer.createElement(imageBlock, { src: url, alt: 设计图纸, data-origin: clipboard }) writer.insert(imageElement, placeholderNode, after) writer.remove(placeholderNode) }这里要注意占位节点的属性要写入到模型里否则在前面的insertPlaceholder步骤中>function insertPlaceholder(editor) { editor.model.change(writer { const placeholder writer.createElement(paragraph, { data-design-image-uploading: true }) writer.insertText(设计图上传中..., placeholder) editor.model.insertContent(placeholder) }) }>async function compressDesignImage(file, maxSize 2400, quality 0.82) { const bitmap await createImageBitmap(file) const ratio Math.min(maxSize / bitmap.width, maxSize / bitmap.height, 1) const width Math.round(bitmap.width * ratio) const height Math.round(bitmap.height * ratio) const canvas document.createElement(canvas) canvas.width width canvas.height height const ctx canvas.getContext(2d) ctx.drawImage(bitmap, 0, 0, width, height) const outputType file.type image/png ? image/png : image/jpeg const blob await new Promise(resolve { canvas.toBlob(resolve, outputType, quality) }) return new File([blob], file.name || design.png, { type: outputType }) }如果你发现createImageBitmap在目标浏览器上兼容性不够稳定可以直接用URL.createObjectURL配合Image标签加载同样能完成缩放只是代码会稍微绕一点。5. 保存与回显让图纸链路在单据流转中真正闭环5.1 编辑器内容里存的到底是什么粘贴链路处理完之后编辑器里的内容是一段包含图片URL的HTML。保存时我们把这段HTML连同工单号、问题编号等业务字段一起提交到后端。这里有一个我建议务必执行的步骤保存之前从前端或后端扫描一遍HTML内容把所有img标签的src提取出来与文件服务里的记录做关联。这样每次保存都能自动建立起“工单-图片-附件记录”的三方关系表后续做图纸数量统计、来源追溯时都不用再解析富文本。前端可以在提交表单前的beforeSubmit钩子里做一次轻量解析function extractImagesFromHtml(html) { const doc new DOMParser().parseFromString(html, text/html) const imgs doc.querySelectorAll(img[src]) return Array.from(imgs).map(img img.getAttribute(src)) }5.2 内容回显阶段最常见的坑图片URL跨域与防盗链编辑器保存后打开历史单据回显会碰到一个制造行业尤其常见的坑——图纸存储的文件服务与门户系统域名不同而企业内网的文件服务有时候开启Referer防盗链。具体现象是单据列表页一切正常点进详情页图片区域一片空白F12里看到图片请求报403。解决思路有两层。第一层是后端放行Referer或者改成不带Referer的请求策略第二层是如果图片服务不允许改动前端可以用meta namereferrer contentno-referrer来规避。但这里必须提醒一点制造行业的系统通常对安全权限要求高有些内网系统不允许设置no-referrer因为这会隐匿内部访问路径。所以更稳妥的做法是在上传接口返回URL时统一改成经过网关代理的地址让编辑器里存的HTTPS URL与文件服务真实地址解耦。以我们项目为例编辑器里保存的图片地址格式是这样https://app-cmms.example.cn/api/file/proxy?fileIdxxxxtokenyyyy网关层根据fileId找到真实文件服务地址再由后端发起拉取这样前端不存在跨域和防盗链问题权限控制也更集中。5.3 回显时的尺寸控制与查看大图设计图在小屏幕上和单据详情页里通常要给一个合理的显示宽度。CKEditor 5的imageBlock默认会按内容宽度自适应但如果图片原始尺寸很大建议在配置里加上图片样式限制image: { styles: { options: [ full, alignLeft, alignRight ] }, resizeOptions: [ { name: resizeImage:original, label: 原始尺寸, value: null }, { name: resizeImage:50, label: 50%, value: 50 } ], toolbar: [ imageStyle:alignLeft, imageStyle:full, imageStyle:alignRight, |, resizeImage ] }同时我建议在回显页面提供“点击图纸查看大图”的能力。这个功能不一定要写在编辑器里可以在单据详情页对编辑器渲染出来的img统一做事件委托点击后弹出新窗口打开原图URL。新窗口打开时注意目标地址是前面说的网关代理地址别暴露内部文件服务路径。6. 三个容易被忽视的“生产环境暗坑”以及我的调试经验6.1 粘贴图片后编辑器内容丢失检查是否有非打印字符有段时间我们收到用户反馈说是从Teamcenter复制图纸截图粘贴后编辑器里会出现一个空段落图片却不见了。排查链路是这样的先确定用户使用的浏览器版本再从浏览器开发者工具里打印event.clipboardData发现数据里除了图片还有大量不可见的OLE对象和特殊字符。当我们的遍历逻辑优先匹配到image/png时理论上应该能拿到图片但问题出在event.preventDefault()之后CKEditor 5内部的剪贴板事件也收到同样的数据其内置的粘贴处理尝试解析一段以text/html形态存在的复杂文档时抛了异常导致整个编辑操作被回滚。解决方法是在拿到图片File之后把剪贴板数据重新写入一份干净的副本再交给编辑器默认处理。或者更简单粗暴一点既然我们已经完全接管图片粘贴就用editor.execute(input, { text: })清空默认插入内容避免CKEditor继续处理原始剪贴板数据。6.2 粘贴报错“Zero-size image”是怎么回事CAD软件有时会在剪贴板里同时写入一个宽度和高度均为0的空图片占位对象。我们的遍历逻辑会匹配到这张空图上传时后端收到一个0字节文件保存后编辑器里出现一个破图图标。解决办法是拿到imageFile后校验size过滤掉size 0的项目同时如果imageFile.size小于几百字节大概率是一张空位图直接跳过继续遍历后面的数据项。6.3 上传并发与用户快速连续粘贴制造现场的用户操作习惯往往比较急经常会连续粘贴多张图纸。如果按前面的方案每次粘贴都立即发起上传浏览器对同域名的并发连接数限制会导致部分上传请求排队或者超时。我的处理办法是做一个简单的队列控制同一时间最多并发两个上传任务其余排队等待。这样后端文件服务的压力也平稳用户体验上只是多等一两秒不会出现图片丢失。class UploadQueue { constructor(limit 2) { this.limit limit this.activeCount 0 this.queue [] } push(task) { return new Promise((resolve, reject) { this.queue.push({ task, resolve, reject }) this.run() }) } run() { if (this.activeCount this.limit || this.queue.length 0) { return } const { task, resolve, reject } this.queue.shift() this.activeCount task().then(res { this.activeCount-- resolve(res) this.run() }).catch(err { this.activeCount-- reject(err) this.run() }) } }7. 针对制造行业局域网环境的性能与兼容性补充最后补充几个我们踩过之后觉得很有价值的点特别是针对汽车制造企业这类局域网环境。首先是浏览器版本问题。很多汽车制造企业的产线终端还停留在Windows 7加旧版Chrome的水平甚至有些质量部门用的还是IE11兼容模式的国产浏览器。CKEditor 5官方对IE11的支持在版本5.1之后就不再维护了但我们实际跑下来本文示例的大部分核心代码——clipboardData.items遍历、File对象处理、FormData上传——在IE11上都能工作只是createImageBitmap不可用。所以压缩那一步在旧浏览器上要降级为canvas绘制方案别指望createImageBitmap。其次是上传接口的响应时间。内网文件服务如果走的是HTTP/1.1的大文件链路单个3MB图片上传时间不稳定建议后端接口在处理上传时开启gzip压缩并给同一工单的多张图片提供批量上传接口减少握手次数。最后是打印场景。工艺单据经常需要打印归档粘贴进编辑器的设计图如果宽高比例特殊打印时容易出现图片被截断。建议在回显页额外提供一个“打印视图”这个视图下图片路径强制加载原始大图同时CSS里加入media print { .design-image-container img { max-width: 100% !important; page-break-inside: avoid; } }这一条看似外围但真到了质量审计要纸质归档的时候它能省掉不少返工。从我个人的体会来说汽车制造场景下做富文本编辑器的定制最大的难点从来不是编辑器API本身而是和行业的文件流转、权限模型、网络环境做适配。上面这套“粘贴拦截—数据校验—压缩上传—占位替换—代理回显”的链路我们上线后跑了近一年日均处理几百张设计图粘贴基本没有出过图片丢失或无法回显的严重问题。如果你所在的项目正好也有类似需求建议先把存储路径和网关代理这两件事定下来再动手写粘贴处理的代码否则后面返工的成本会很高。
返回列表