【EMR 编辑器 · React 集成详解】
从零搭一个编辑页 · Props 全解 · 保存加载闭环 · 四个典型场景
本篇假设你有一个现成的 React ≥ 18 工程(Vite / CRA / webpack / Next.js 均可)。从「装包」一路讲到「上线前要注意什么」,照抄即可运行。
【一、安装与最小可运行页面】
安装(两个包必须成对安装,缺一会报「找不到 @emr/core」):
npm install ./emr-core-x.y.z.tgz ./emr-editor-x.y.z.tgz
新建一个页面组件 EditorPage.tsx,写入以下内容并挂到路由,打开就能看到一张带工具栏的 A4 纸:
import { useRef } from 'react';
import { EmrEditor, emptyDoc, type EmrEditorHandle } from '@emr/editor';
export default function EditorPage() {
// 命令式 API 句柄:保存、导出、赋值等都从这调用
const api = useRef<EmrEditorHandle>(null);
return (
<div style={{ height: '100vh' }}>
<EmrEditor ref={api} initialDoc={emptyDoc()} toolbar />
</div>
);
}
emptyDoc() 会创建一份带默认 A4 页面设置的空白文档;
容器必须有确定高度:height: '100vh'、flex 布局撑开、固定像素都行——高度为 0 时编辑器不可见(最常见的新手问题);
滚动由编辑器内部接管(虚拟滚动),不要在外面再套 overflow: auto。
【二、Props 全解】
所有 Props 都是可选的。最简只用 initialDoc;其余按需添加。
initialDoc:初始文档(Doc 对象)。只在组件首次挂载时生效——React 里 props 变化不会重新加载文档(这是刻意的:避免每次渲染都重置用户正在编辑的内容)。运行中换文档有两条路:用 key 重建组件(受控场景),或调用 api.setDoc(doc)(命令式)。
toolbar:内置工具栏。不传 = 不显示(自己建工具栏的场景);true = 显示全部 14 个功能组;传对象可精细配置——groups 裁剪功能组、pdfFonts 配置「导出 PDF」按钮字体、customItems / fileMenuItems 注入自家按钮菜单(详见《工具栏与自定义菜单详解》)。
<EmrEditor
toolbar={{
groups: ['file', 'undo', 'view', 'font', 'textStyle', 'paragraph',
'insert', 'protect', 'page'], // 裁掉数据类功能组
pdfFonts: { regular: '/fonts/SimSun.ttf' }, // 否则导出按钮禁用
}}
/>
readonly / viewMode:readonly 禁止一切编辑(可滚动、选中、复制);viewMode 切换渲染样式:'edit' 显示数据域标记色,'preview' 按普通文本渲染(导出 PDF 同款样式)。两者可组合,例如只读查看器用 viewMode="preview" readonly。
user:当前登录用户 { id, name }。会话级信息(不写入文档 JSON):留痕日志记录「谁改的」、导出 PDF 元数据作者、打印记录,都从这里取。用户切换时传入新值即可自动同步。
onChange:文档变更回调,参数是最新 Doc 对象。注意:每次按键都会触发,直接在里面 fetch 保存会太频繁——应做节流(见下文场景二)或用保存按钮。
onSelectionChange:选区变化回调。自建工具栏时用它同步按钮状态(比如光标在加粗文字上时点亮 B 按钮)。
config:覆盖默认文档配置(Partial<DocConfig>):页面尺寸、边距、默认字体字号等。注意这只影响「初始默认值」,文档加载后以文档自身 config 为准。
scale / bg:渲染缩放(1 = 100%,适合做「放大镜」功能)与纸张外工作区背景色。className / style 是常规容器属性;defaultSidePanels 控制右侧数据源 / 录入域面板初始展开状态;license 见《下载与授权说明》。
【三、命令式 API:ref 句柄】
除了声明式 Props,编辑器提供一整套命令式方法(EmrEditorHandle),保存、导出、数据赋值、插入内容都靠它。通过 useRef 拿到句柄:
const api = useRef<EmrEditorHandle>(null);
<EmrEditor ref={api} ... />
// 之后在任何事件里调用(注意判空)
api.current?.undo(); // 撤销
const json = api.current?.getJSON(); // 取文档 JSON 字符串
api.current?.insertTable(4, 4, 1); // 光标处插入 4x4 表格(1 行表头)
全部方法约 90 个,按「文档视图 / 文本样式 / 表格 / 图片 / 数据绑定 / 留痕关键词 / 导出打印 / 授权」分组列在《API 速查》,此处不展开。Vue 和纯 HTML 用的是同一套。
【四、场景实战】
【场景一:从服务端打开病历 → 编辑 → 点按钮保存】
最典型的用法。页面加载时拉取文档 JSON,通过 state 传入 initialDoc;保存按钮调用 getJSON 上传。用 key 保证「换一份病历就整页重建」:
function EditorPage({ recordId }: { recordId: string }) {
const api = useRef<EmrEditorHandle>(null);
const [doc, setDoc] = useState<Doc | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDoc(null); setDirty(false);
fetch(`/api/emr/load?id=${recordId}`).then(r => r.json())
.then(setDoc);
}, [recordId]);
const save = async () => {
await fetch('/api/emr/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: api.current!.getJSON(),
});
setDirty(false);
};
if (!doc) return <Spin />;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<button disabled={!dirty} onClick={save}>保存</button>
<EmrEditor key={recordId} ref={api} initialDoc={doc}
toolbar onChange={() => setDirty(true)}
style={{ flex: 1, minHeight: 0 }} />
</div>
);
}
key={recordId}:切换病历时销毁重建编辑器,干净利落,不会串内容;
onChange 只置脏标记,不上传——保存时机交给用户按钮(要做自动保存见场景二);
flex: 1 + minHeight: 0 让编辑器占满剩余高度(flex 子项默认不收缩,记得加 minHeight: 0)。
【场景二:定时自动保存】
用户打字频繁,onChange 每次触发都保存会打爆接口。做法:onChange 只记录「变动」,用定时器每 30 秒检查一次:
const dirtyRef = useRef(false);
useEffect(() => {
const t = setInterval(() => {
if (!dirtyRef.current) return;
dirtyRef.current = false;
fetch('/api/emr/save', { method: 'POST', body: api.current?.getJSON() });
}, 30_000);
return () => clearInterval(t);
}, []);
<EmrEditor onChange={() => { dirtyRef.current = true; }} ... />
【场景三:只读文档查看器(本站用法)】
把编辑器当「文档播放器」用:fetch 静态 JSON → initialDoc → 预览 + 只读。用户能滚动、选中文本、复制,但不能改。本站全部帮助文档就是这么做出来的:
const [doc, setDoc] = useState<Doc | null>(null);
useEffect(() => {
fetch(url).then(r => r.json()).then(setDoc);
}, [url]);
return doc ? <EmrEditor initialDoc={doc} readonly viewMode="preview" /> : <Spin />;
【场景四:打开模板 → 回填患者数据 → 导出 PDF 归档】
模板里预先埋好数据占位符(制作方法见《数据源与录入域详解》),业务代码只需三步。整个过程无需用户操作,甚至可以把编辑器藏在看不见的地方(离屏挂载)执行:
const api = useRef<EmrEditorHandle>(null);
async function archive(patient) {
// 1. 加载模板
api.current.setDoc(templateDoc);
// 2. 回填数据(占位符立即显示值,列表源展开循环行)
api.current.setDataValues({
patient: { name: patient.name, sex: patient.sex },
vitals: { records: patient.vitalRecords },
});
// 3. 导出 PDF 上传(字体说明见《PDF 导出详解》)
const bytes = await api.current.exportPDF({
fonts: { regular: '/fonts/SimSun.ttf' },
metadata: { title: '入院记录' },
});
await upload(new Blob([bytes], { type: 'application/pdf' }));
}
提示:exportPDF 内部会自动同步布局,不需要等渲染完成;这种全程无 UI 的用法叫「静默流水线」,完整说明见《静默打印对接》末节。
【五、TypeScript 提示】
常用类型:Doc(文档)、EmrEditorHandle(API 句柄)、EmrEditorProps(Props)、InlineStyle(样式)都从 '@emr/editor' 导出;
服务端返回的 JSON 直接 as Doc 使用即可(Doc 是纯数据结构,无方法无类);
样式补丁 setStyle({ bold: true }) 里的 null 表示「清除该字段」(如恢复默认字号),undefined 表示「不修改」——两者语义不同,清除用 null。
【六、常见坑速查】
编辑器不显示:容器高度为 0。检查父级是否 flex / 是否给了确定高度;flex 子项加 minHeight: 0;
换了 initialDoc 没反应:initialDoc 只在挂载时消费。用 key 重建组件或 api.setDoc();
onChange 里直接保存导致卡顿 / 请求风暴:改为置脏标记 + 节流保存(场景二);
编辑器抢了页面其他输入框的快捷键:不会。INPUT / TEXTAREA 聚焦时编辑器不响应 Ctrl+C 等快捷键;
「导出 PDF」按钮灰色:没配 toolbar.pdfFonts(PDF 必须嵌字体,详见《PDF 导出详解》)。