Files
xh-medical-sign-web/clinical-web/src/utils/signing-editor-marks.ts
T
yelan b967077873 fix(signing-editor-marks): 统一占位符样式并修复删除后 DOM 残留
自定义渲染器渲染钩子改走 data.attrs 挂类名与 title:data.class 会被
wangEditor 的规范化函数搬进 props 且到不了 class 模块(胶囊不渲染),
data.props.className 则因 props 模块只单向写入而摘不掉旧值,导致勾选框
退格删除后 DOM 仍残留 ms-sign-box 空壳。

同时移除必填标记相关的红角标、签名位专属橙色、插入按钮与「占位符检查」
面板里的 `*` / 「必填」文案,所有占位符共用一个样式。

verify-editor-marks.cjs 增加断言台账(PASS/FAIL 并反映退出码)、叶子结构
探针与退格删除回归,README 补充「踩过的坑 · 5」说明渲染通道的选择。
2026-09-18 16:40:08 +08:00

264 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 编辑器内的「占位符 / 勾选框」可视化标记。
*
* 问题:占位符({患者姓名})与勾选框(□)在富文本编辑器里就是一段普通文字,
* 和正文长得一模一样。科室在长文书里看不出哪些内容是要在签署时采集的,
* 漏一个签名位往往要等到发起签署、甚至签署完才发现。
*
* 方案:**不改数据模型,只改渲染**。
* 用 wangEditor(Slate)的 decorate 把文本节点里的占位符 / 勾选框切成独立的
* 叶子节点(leaf),再用 registerRenderStyle 给这些叶子挂上类名与 data-*,
* 由 EditorWangPane 的样式渲染成带类型角标的「字段胶囊」。
*
* 为什么不用自定义 Slate 元素(<span data-field>)承载:
* ① 纯文本占位符可以无损往返,存量模板零迁移;换成结构化元素就得在载入时
* 把已有正文里的占位符批量改写一遍,是对既有文书内容的变换,风险与收益不成比例;
* ② 渲染管线(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<IEditorConfig['decorate']>
type DecoratedEntry = Parameters<DecorateConfig>[0]
type DecoratedRange = ReturnType<DecorateConfig>[number]
type RenderStyleConfig = Parameters<typeof Boot.registerRenderStyle>[0]
type StyledNode = Parameters<RenderStyleConfig>[0]
type StyledVNode = Parameters<RenderStyleConfig>[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<StyledVNode['data']>, attrs: Record<string, string>) {
Object.assign((data.attrs ??= {}), attrs)
}
let registered = false
/**
* 注册编辑器内的占位符 / 勾选框渲染钩子。
*
* wangEditor 的渲染钩子是全局注册表,重复注册会让同一个叶子被处理多次;
* 用模块级开关挡住。HMR 重新求值该模块时开关会重置,但重复处理是幂等的,不会出错。
*/
export function registerSigningEditorMarks(): void {
if (registered) {
return
}
registered = true
Boot.registerRenderStyle(renderSigningMarker)
}