ARTICLE DETAIL

资讯详情

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

H5电子病历编辑器开发实战:文档模型、光标控制与打印踩坑

H5电子病历编辑器开发实战:文档模型、光标控制与打印踩坑 简介这款基于JavaScript和HTML的HCView-H5电子病历编辑器控件设计源码面向医疗信息化开发者与前端工程师解决了在浏览器中高效编辑电子病历、复用桌面级文字排版能力的痛点。压缩包共收录58个文件包含30个PNG图标与界面资源、21个JS逻辑脚本、3个HTML入口页面、2个ICO图标另附LICENSE与说明文档整体仅645KB目录划分清楚便于按模块阅读和二次开发。目前已有302人学习下载适合需要快速集成文本编辑器或研究Web排版实现的开发者。控件本身实现了类Word/WPS的格式化排版支持字体样式调整、表格插入、图片上传、分页与打印并内置医学符号与术语的便捷录入方式还提供快捷键操作、自定义模板及数据加密传输可与医院信息系统对接按角色进行权限管理。源码遵循开源协议便于医疗机构和第三方开发者针对具体业务深度定制。1. 拿到这套 HCView-H5 源码后我先把预期放低了拿到这份基于 JavaScript 和 HTML 的 HCView-H5 电子病历编辑器控件设计源码我先在浏览器里跑了一遍默认 demo。说实话第一印象平平一个 contenteditable 区域、一排工具栏按钮看起来跟普通富文本编辑器没区别。真正让我觉得值得拆的是它把病历这种「既要自由排版、又要结构化存储」的文档拆成了段落、表格、签名域这样的块级模型并且把光标、图片、打印这些最容易翻车的点都做了控制。如果你正在做 HIS 电子病历、健康档案、体检报告这类 H5 端编辑需求这套源码的思路可以直接搬如果你只是需要一个很轻的记录框它反而偏重。下面是我拆完源码后留下的一线经验照着走能少踩一半坑。2. 编辑器内核先定文档模型再谈光标与选区H5 编辑器最忌讳一上来就contenteditabletrue然后开始敲功能。表面看浏览器帮你做了很多事实际上它把所有行为差异都留给了你。HCView 在桌面端能稳定用十几年靠的不是命令多而是文档模型清晰病历不是一个长字符串而是一组有顺序的块每个块有自己的类型和样式。H5 版只有先把这件事想清楚后续导出、回填、打印才不别扭。2.1 文档模型与块级结构把病历拆成段落、表格和签名域病历文档里的内容类型是有限的。主诉、现病史、既往史是段落体温单、检查结果表是表格医嘱和签名是特殊标记。把这类文档建模成一维数组比解析任意 HTML 可靠得多常见做法是定义一个blocks数组每个元素代表一个块。const docModel { version: h5doc-1.0, blocks: [ { type: paragraph, style: { align: left, lineHeight: 1.5 }, content: [{ type: text, text: 主诉 }] }, { type: paragraph, style: { align: left }, content: [{ type: text, text: 患者无明显诱因出现胸痛2小时。 }] }, { type: table, rows: 2, cols: 4, data: [[{ content: 项目 }, { content: 结果 }], [{ content: WBC }, { content: 9.8×10^9/L }]] }, { type: signature, label: 主治医师, value: } ] };这套模型我做项目时一直在用。type决定这个块怎么渲染、怎么校验、怎么参与统计content在段落里是文本节点数组在表格里是二维单元格数组。为什么不用 HTML 字符串存因为存储格式一旦变成 HTML服务器端要做病历质控、按时间排序病程、按患者合并检查结果时都得先写解析器。而上面这种结构可以直接进数据库校验规则也能写得很直白比如要求每个病程记录至少有一个段落内容签名域必须非空。2.2 光标与选区range 是编辑器的「心脏」在 H5 编辑器里所有插入操作——签名、特殊符号、检查结果图片——几乎都依赖光标位置。新手最容易犯的错是操作完 DOM 之后发现之前保存的Range已经失效文字跑到文档开头去了。Range不是快照它指向的是 DOM 节点只要节点被移动或删除它也跟着变了。function saveRange() { const sel window.getSelection(); if (sel.rangeCount 0) { return sel.getRangeAt(0).cloneRange(); } return null; } function restoreRange(range) { if (!range) return; const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(range); const editor document.getElementById(editor); editor.focus(); }保存的光标用cloneRange()克隆出一份独立对象避免后续 DOM 操作改动原选区。恢复时先removeAllRanges()清掉当前选区再addRange(range)加回来这步不做的话getSelection可能拒绝覆盖既有选区。restoreRange最后强制focus()因为很多工具栏按钮点击后焦点已经移到按钮上不拉回来命令会打到按钮所在的文档上。我一般把这两个函数挂在编辑器对象上每次插入图片和特殊符号前都执行一遍这个流程。3. 从零搭一个可编辑病历页面结构初始化与命令封装模型定完可以动手搭界面了。很多人直接把execCommand当万能钥匙但实际用起来会发现浏览器对命令的支持不统一光标状态不一样结果也不一样。所以源码里值得抄的部分是把命令一层层封装成自己的 API而不是到处裸调document.execCommand。3.1 结构初始化DOCTYPE、contenteditable 与工具栏页面骨架看着简单但有一个细节对移动端影响很大viewport必须写否则在手机上点工具栏按钮时编辑器区域会被浏览器自动放大光标位置错乱。我通常在写 HTML 结构时就固定住!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title电子病历编辑器/title style .hc-editor { min-height: 400px; padding: 12px; border: 1px solid #ccc; line-height: 1.5; word-break: break-all; outline: none; } #toolbar button { margin-right: 4px; padding: 4px 10px; } /style /head body div idtoolbar button typebutton>function execCommand(cmd, value) { const editor document.getElementById(editor); editor.focus(); const ok document.execCommand(cmd, false, value || null); if (!ok) { // 个别命令在部分浏览器返回 false需要手动 fallback console.warn(command not supported:, cmd); } } document.getElementById(toolbar).addEventListener(click, function (e) { const btn e.target.closest(button); if (!btn) return; e.preventDefault(); execCommand(btn.dataset.cmd, btn.dataset.value); });execCommand第二个参数固定传false也就是不显示浏览器自带 UI第三个参数是命令值bold这类开关命令不需要值formatBlock需要传标签名insertText需要传要插入的文本。有一点要注意execCommand虽然已经被标准标记为弃用但各大浏览器至今仍支持在 H5 编辑器场景里它仍然是兼容性最好的方案。真遇到不支持的场景我一般才退回去自己写 Range 操作。命令参数可以按需求拆成一张对照表方便后期加按钮命令参数示例用途bold / italic / underline无基本字型开关formatBlockp/h2/blockquote切成块级标签fontSize3/5字号1 到 7 级foreColor#333333文字颜色insertText 插入占位符号insertImage图片 URL插入图片insertHTMLHTML 字符串插入复杂内容另外建议在初始化时执行一次document.execCommand(styleWithCSS, false, true)让后续字号、颜色命令输出 CSS class 而不是过时的font标签这对打印和导出统一风格都有帮助。4. 让病历数据可存储DOM 导出与 JSON 回填编辑器里能看见内容只是第一步。电子病历要过审、要归档、要参与质控就离不开导出和回填。只把innerHTML存进数据库是偷懒跨系统迁数据、按病程时间排序、做内容校验时都会变成灾难。这章说清楚从 DOM 到结构化数据的映射过程以及还原回去时的安全红线。4.1 导出 JSON遍历 DOM 生成结构化数据我的做法是遍历编辑器根节点下的子元素每个块元素转换成一个对象。转换时不建议用innerHTML去拼字符串再解析而是直接用childNodes逐节点处理这样能保留文本节点和图片的顺序也能避开浏览器自动补全标签带来的差异。function blocksToJson(root) { const blocks []; for (const node of root.children) { const block { type: paragraph, style: {}, content: [] }; if (node.tagName P) { block.style.align node.style.textAlign || left; for (const child of node.childNodes) { if (child.nodeType Node.TEXT_NODE) { block.content.push({ type: text, text: child.textContent }); } else if (child.tagName IMG) { block.content.push({ type: image, src: child.getAttribute(src) }); } } } else if (node.tagName TABLE) { block.type table; block.data domTableToArray(node); } blocks.push(block); } return { version: h5doc-1.0, blocks }; }遍历时先判断node.tagName段落走文本和图片分支表格走单独的行列解析。注意node.children和childNodes的差异children只包含元素节点适合做块级遍历childNodes包含文本节点适合做段内内容提取。生产环境中我还会把style白名单化只保留align、textAlign、fontSize这些必要字段防止用户从别处粘贴一堆内联样式进来。4.2 回填渲染JSON 还原 DOM防 XSS 是底线回填就是导出的逆过程但有一个绝不能省的动作转义 HTML。病历内容大多来自医生输入如果直接组装 HTML 字符串插入一处img onerror...就能让整页脚本失控。我一般单独写一个escapeHtml函数处理所有文本节点。function escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); } function jsonToHtml(doc) { return doc.blocks.map(function (block) { if (block.type paragraph) { const align block.style.align ? styletext-align: block.style.align : ; const inner block.content.map(function (item) { if (item.type image) { return img src item.src ; } return escapeHtml(item.text || ); }).join(); return p align inner /p; } if (block.type table) { return tableToHtml(block.data); } return p/p; }).join(); }图片的src也要做协议白名单校验只允许http、https、data:image/开头其他协议一律拦截。这一步看起来啰嗦但电子病历涉及患者隐私任何 XSS 缺口都可能被利用来读取同页面内的其他患者数据。需要做预览的时候我通常是在这个 HTML 基础上再套一层独立 iframe 来显示和编辑区隔离。如果有同事提出「把病历转成 Markdown 预览」更稳妥的做法是保留这份 JSON 作为存储格式渲染预览时临时用turndown转成 Markdown而不是改动主数据结构。5. 高频踩坑排查换行、图片、打印与焦点恢复光有命令还不够线上翻车基本都集中在这几处。下面按我实际排查时的顺序把现象、原因、解决写在同一个位置方便对照处理。5.1 换行为什么按一次回车会多一个空行现象Chrome 里按 Enter段落间距明显变大Firefox 里按 Enter只是段内换行两种结果不一致。原因contenteditable对 Enter 的处理没有统一标准Chrome 会插入新的div或pFirefox 则生成br。解决拦截 Enter 键自己控制插入内容。editor.addEventListener(keydown, function (e) { if (e.key Enter !e.shiftKey) { e.preventDefault(); document.execCommand(insertHTML, false, br); } });ShiftEnter 保留默认行为这样医生可以用它做段内换行普通 Enter 只插br段落间距就不会失控。如果你希望每次 Enter 都开启新段落可以把上面insertHTML的参数改成/pp同时限定工具栏的formatBlock只允许p标签避免混入div。5.2 图片插入后光标消失输入跑到开头现象插入检查结果图片后继续打字光标跑到文档最前面输入的位置完全错乱。原因insertImage执行后原先保存的选区被移除编辑器焦点丢失下一次键盘事件落到了文档头部。解决插入图片前保存 Range插入后立刻恢复这就是第 2 章里那两个函数的调用场景。function insertImageAtCursor(src) { const range saveRange(); const editor document.getElementById(editor); editor.focus(); document.execCommand(insertImage, false, src); restoreRange(range); editor.dispatchEvent(new Event(input)); }移动端还有一个常见附加问题图片插入后手指捏合放大缩小时浏览器手势被页面滚动抢走图片原地不动。我一般在图片外层包一个容器并设置touch-action: pan-x pan-y放大行为交给图片自身的 transform 处理。这是拆源码时看到它做得比较好的地方很多编辑器直接忽略了手机端这个需求。5.3 打印分页A4 宽度在屏幕上的换算现象打印预览时表格被截断、页边距不对屏幕上看好的行到了纸上断成两截。原因没有区分屏幕 CSS 和打印 CSS直接拿 100% 宽度去适配 A4。解决用media print单独设置打印布局并把 A4 的物理尺寸换算成屏幕像素来做预览。media print { body { width: auto; margin: 0; } page { size: A4; margin: 12mm; } .hc-editor p, .hc-editor table { page-break-inside: avoid; } }A4 纸宽 210mm去左右边距 12mm×2正文宽约 186mm。按 96dpi 换算屏幕模拟宽度约186 / 25.4 * 96 ≈ 703px。如果编辑器宽度设成 100% 在手机和 PC 上差别太大我建议在桌面端就固定 703px 左右打印出来的行宽和屏幕预览基本一致。另外表格默认背景色在打印时不输出需要保留底色的话加-webkit-print-color-adjust: exact。5.4 工具栏按钮失焦javascript:void(0) 与 button 的选择现象工具栏用a hrefjavascript:void(0)做按钮点击后光标消失再点加粗没有反应。原因谷歌浏览器执行javascript:伪协议后会重置当前选区编辑器失去焦点后续execCommand没有可作用的光标位置。解决按钮一律用button typebuttonclick 事件里先editor.focus()再执行命令。document.getElementById(toolbar).addEventListener(mousedown, function (e) { // 防止按钮抢焦点 if (e.target.closest(button)) e.preventDefault(); });mousedown时preventDefault()是常见做法可以避免焦点在按下鼠标那一刻就离开编辑器。如果项目里存量代码已经用了javascript:void(0)至少要在 click 处理函数第一行强制把焦点拉回编辑器否则命令队列全部落空。6. 把编辑器封装成可复用控件事件钩子与生命周期一个科室的编辑器可能出现在门诊、住院、体检三个子系统里所以封装是收尾时必须做的事。我一般把编辑器收敛成一个类对外只暴露设置内容、读取内容、销毁三个方法内部细节全部隐藏。class HcEditor { constructor(root, options) { this.root root; this.editor root.querySelector(.hc-editor); this.onChange options.onChange || function () {}; this.handleInput this.emitChange.bind(this); this.editor.addEventListener(input, this.handleInput); this.editor.addEventListener(blur, this.handleInput); } emitChange() { this.onChange(blocksToJson(this.editor)); } setContent(doc) { this.editor.innerHTML jsonToHtml(doc); } destroy() { this.editor.removeEventListener(input, this.handleInput); this.editor.removeEventListener(blur, this.handleInput); } }input事件在contenteditable里兼容性最好每次内容变化都会触发blur作为兜底确保用户点保存按钮时即使没触发 input 也能拿到最新数据。实际接入时onChange回调里我不建议直接发请求而是先把 JSON 放进一个待保存队列做 300ms 防抖用户停止输入后再交给后端接口。销毁方法必须留着单页应用切路由时不调用的话编辑器事件会一直挂在已卸载的 DOM 上内存泄漏会在切换几十次页面后变得非常明显。这一步做完控件就能脱离源码 demo 独立使用了。我第一次做这个控件时因为在保存图片前没恢复光标导致医生写完的病程记录在插入图片后全部串到开头那一次改得相当狼狈。从那以后我每次改造都强制走一遍「存光标、做操作、恢复光标、测打印」四步这套流程帮我挡掉了不少线上问题希望这个经验能帮到你。本文还有配套的精品资源点击获取
返回列表