自定义渲染器渲染钩子改走 data.attrs 挂类名与 title:data.class 会被 wangEditor 的规范化函数搬进 props 且到不了 class 模块(胶囊不渲染), data.props.className 则因 props 模块只单向写入而摘不掉旧值,导致勾选框 退格删除后 DOM 仍残留 ms-sign-box 空壳。 同时移除必填标记相关的红角标、签名位专属橙色、插入按钮与「占位符检查」 面板里的 `*` / 「必填」文案,所有占位符共用一个样式。 verify-editor-marks.cjs 增加断言台账(PASS/FAIL 并反映退出码)、叶子结构 探针与退格删除回归,README 补充「踩过的坑 · 5」说明渲染通道的选择。
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
||
* 编辑器内的「占位符 / 勾选框」可视化标记。
|
||
*
|
||
* 问题:占位符({患者姓名})与勾选框(□)在富文本编辑器里就是一段普通文字,
|
||
* 和正文长得一模一样。科室在长文书里看不出哪些内容是要在签署时采集的,
|
||
* 漏一个签名位往往要等到发起签署、甚至签署完才发现。
|
||
*
|
||
* 方案:**不改数据模型,只改渲染**。
|
||
* 用 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)
|
||
}
|