/** * 编辑器内的「占位符 / 勾选框」可视化标记。 * * 问题:占位符({患者姓名})与勾选框(□)在富文本编辑器里就是一段普通文字, * 和正文长得一模一样。科室在长文书里看不出哪些内容是要在签署时采集的, * 漏一个签名位往往要等到发起签署、甚至签署完才发现。 * * 方案:**不改数据模型,只改渲染**。 * 用 wangEditor(Slate)的 decorate 把文本节点里的占位符 / 勾选框切成独立的 * 叶子节点(leaf),再用 registerRenderStyle 给这些叶子挂上类名与 data-*, * 由 EditorWangPane 的样式渲染成带类型角标的「字段胶囊」。 * * 为什么不用自定义 Slate 元素()承载: * ① 纯文本占位符可以无损往返,存量模板零迁移;换成结构化元素就得在载入时 * 把已有正文里的占位符批量改写一遍,是对既有文书内容的变换,风险与收益不成比例; * ② 渲染管线(utils/signing-document)与填写项统计(collectFieldDefs)都按 * {…} 文本解析,改模型要同步改签署侧与统计侧,回归面被放大; * ③ decorate 是只读投影:它不改变 editor.getHtml() 的输出, * 保存 / 回填 / 签署全链路一行都不用动。最坏情况只是"显示得不好看", * 不会丢内容、不会写出编辑器读不回来的 HTML。 * * 一条硬约束:叶子里的原文必须原样留在 DOM 中(大括号不能 display:none / font-size:0)。 * Slate 用 Range.cloneContents().textContent.length 反算光标偏移(见 slate 的 toSlatePoint), * 把字符藏起来会让光标落点整体算错;而且「在胶囊左边缘按退格」会静默删掉看不见的字符。 * 所以大括号只是被缩小、调淡,仍然可见可选——顺带也让科室能看见并手敲这个书写格式。 */ import { Boot } from '@wangeditor/editor' import type { IEditorConfig } from '@wangeditor/editor' import { createFieldTokenPattern, isCustomFieldId, resolveFieldByLabel, SIGNING_FIELD_KIND_LABELS, SIGNING_OPTION_MARKERS, } from './signing-fields' type DecorateConfig = NonNullable type DecoratedEntry = Parameters[0] type DecoratedRange = ReturnType[number] type RenderStyleConfig = Parameters[0] type StyledNode = Parameters[0] type StyledVNode = Parameters[1] /** * 挂在文本叶子上、供渲染钩子读取的标记。 * 统一加 sign 前缀,避免与 wangEditor / Slate 自身的 leaf 字段撞名 * (叶子是原文本节点展开出来的,标点、bold 等既有属性都会一并带过来)。 */ interface SigningLeafMark { /** field = 占位符;box = 勾选框 */ signMarker?: 'field' | 'box' /** 占位符被切成的三段:{ / 标签 / } */ signPart?: 'open' | 'label' | 'close' signKind?: string /** 控件类型的中文名,渲染在胶囊右侧 */ signTag?: string /** 是否目录外的自定义文本项(现场手填),决定悬浮提示里说"自动填充"还是"现场填写" */ signCustom?: boolean /** 原文,如 {患者姓名},用于悬浮提示 */ signRaw?: string } type SigningRange = DecoratedRange & SigningLeafMark /** * decorate 回调:把一段文本里的占位符与勾选框切成独立叶子。 * * 占位符切成三段而不是一段,是为了让大括号能单独缩小、调淡—— * 一段文本节点没法只给其中两个字符换样式。 */ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedRange[] { const { text } = node as { text?: string } if (typeof text !== 'string' || !text) { return [] } const ranges: SigningRange[] = [] const tokenSpans: Array<{ start: number; end: number }> = [] const pattern = createFieldTokenPattern() let match: RegExpExecArray | null while ((match = pattern.exec(text)) !== null) { const label = (match[1] ?? '').trim() const start = match.index const end = pattern.lastIndex // {x} 至少三个字符,否则中间切不出标签叶子,反而会造出零长度区间 if (!label || end - start < 3) { continue } const field = resolveFieldByLabel(label) const mark: SigningLeafMark = { signMarker: 'field', signKind: field.kind, signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本', signCustom: isCustomFieldId(field.id), signRaw: text.slice(start, end), } tokenSpans.push({ start, end }) ranges.push( { anchor: { path, offset: start }, focus: { path, offset: start + 1 }, ...mark, signPart: 'open', }, { anchor: { path, offset: start + 1 }, focus: { path, offset: end - 1 }, ...mark, signPart: 'label', }, { anchor: { path, offset: end - 1 }, focus: { path, offset: end }, ...mark, signPart: 'close', }, ) } const boxPattern = new RegExp(`[${SIGNING_OPTION_MARKERS}]`, 'g') while ((match = boxPattern.exec(text)) !== null) { const index = match.index // 占位符标签里如果写了方框(如 {□}),它是标签文字的一部分,不能再标成勾选胶囊 if (tokenSpans.some((span) => index >= span.start && index < span.end)) { continue } ranges.push({ anchor: { path, offset: index }, focus: { path, offset: index + 1 }, signMarker: 'box', }) } // Slate 的 Text.decorations 是"按数组顺序逐个切分叶子",区间乱序会让切分结果错位。 // 占位符区间与勾选框区间在本函数里是两轮扫描产出的,必须显式排序。 return ranges.sort((a, b) => a.anchor.offset - b.anchor.offset || a.focus.offset - b.focus.offset) } /** * 渲染钩子:给带标记的叶子挂上类名与属性,样式见 EditorWangPane。 * * 直接改传入的 vnode.data 而不新建 vnode:新建需要 snabbdom 的 h, * 而 snabbdom 是 wangEditor 的内部依赖,不该由业务代码直接引用。 * * **类名与 title 必须走 data.attrs。** 这一条是踩出来的,两个坑叠在一起: * * ① `data.class` 根本到不了 snabbdom 的 class 模块。 * wangEditor 在渲染前会用一个规范化函数(`RT`)递归重写 vnode.data: * * var LT = ["props","attrs","style","dataset","on","hook"] // 白名单 * Object.keys(data).forEach(key => { * if (LT.includes(key)) return * if (key.startsWith("data-")) { 移到 data.dataset; delete data[key] } * else { 合并进 data.props; delete data[key] } * }) * * `class` 不在白名单里,于是 `data.class` 被搬成 `data.props.class` 并删掉原键, * class 模块永远读不到它 —— 类名压根不会出现在 DOM 上(胶囊整个不渲染)。 * 注意 `data.dataset` 与 `data.attrs` 都在白名单里,能原样透传。 * * ② `data.props` 只单向写入,摘不掉旧值。 * * function propsModule(oldVnode, vnode) { * for (key in newProps) { old === cur || (elm[key] = cur) } * } * * 它只遍历**新** props,从不清理已经消失的旧 prop;而 className / title 都是 * 原型上的访问器,`delete elm.className` 是个空操作。于是 props 里的类名一旦 * 挂上就再也摘不掉:勾选框被退格删掉后,那个叶子已经空了、decorate 也早已不再 * 返回它,DOM 里却还留着 ms-sign-box —— 编辑器里表现为一个擦不掉的浅蓝小方块 * (签署侧按文本解析,文本确实空了,所以那边一直是对的)。 * * `attrs` 模块两头都正常,所以统一走它: * * create/update 都执行 setAttribute,并且有 `for (key in oldAttrs) key in newAttrs || removeAttribute(key)` * * 于是叶子从"有标记"变成"无标记"时,类名会被真正摘掉。 */ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode { const mark = node as unknown as SigningLeafMark if (!mark.signMarker) { return vnode } const data = (vnode.data ??= {}) const dataset = (data.dataset ??= {}) dataset.signMarker = mark.signMarker if (mark.signMarker === 'box') { setAttrs(data, { class: 'ms-sign-box', title: '勾选项:签署时转为可勾选控件', }) return vnode } dataset.signKind = mark.signKind ?? 'text' dataset.signPart = mark.signPart ?? 'label' if (mark.signPart === 'label') { const kind = mark.signTag ?? '文本' // 提示必须分开说:目录里的字段签署时自动填充,自定义文本项是留给现场手填的空框。 // 一律写成"自动填充",科室会以为写了占位符就有人替他填。 const source = mark.signCustom ? '签署时由患者/家属现场填写' : '签署时按患者与就诊信息自动填充' setAttrs(data, { class: 'ms-sign-chip', title: `待填写项:${mark.signRaw ?? ''}(${kind}),${source}`, }) if (mark.signTag) { dataset.signTag = mark.signTag } return vnode } // 大括号只缩小调淡,不移除:见文件头的"硬约束"。 // 左右分开给类名,样式里才能各收各的边距(全角括号两侧留白很宽) setAttrs(data, { class: `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}` }) return vnode } /** * 写 data.attrs(attrs 模块)。 * 不要写 data.class —— 会被规范化函数搬进 props 且到不了 class 模块; * 也不要写 data.props.className —— props 模块只增不删,类名摘不掉。理由见上。 */ function setAttrs(data: NonNullable, attrs: Record) { Object.assign((data.attrs ??= {}), attrs) } let registered = false /** * 注册编辑器内的占位符 / 勾选框渲染钩子。 * * wangEditor 的渲染钩子是全局注册表,重复注册会让同一个叶子被处理多次; * 用模块级开关挡住。HMR 重新求值该模块时开关会重置,但重复处理是幂等的,不会出错。 */ export function registerSigningEditorMarks(): void { if (registered) { return } registered = true Boot.registerRenderStyle(renderSigningMarker) }