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」说明渲染通道的选择。
This commit is contained in:
yelan
2026-09-18 16:40:08 +08:00
parent 6034f4f8e2
commit b967077873
6 changed files with 348 additions and 311 deletions
+55 -15
View File
@@ -57,7 +57,6 @@ interface SigningLeafMark {
signKind?: string
/** 控件类型的中文名,渲染在胶囊右侧 */
signTag?: string
signRequired?: boolean
/** 是否目录外的自定义文本项(现场手填),决定悬浮提示里说"自动填充"还是"现场填写" */
signCustom?: boolean
/** 原文,如 {患者姓名},用于悬浮提示 */
@@ -99,7 +98,6 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR
signMarker: 'field',
signKind: field.kind,
signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本',
signRequired: field.required,
signCustom: isCustomFieldId(field.id),
signRaw: text.slice(start, end),
}
@@ -150,12 +148,44 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR
}
/**
* 渲染钩子:给带标记的叶子挂上类名与 data-*,样式见 EditorWangPane。
* 渲染钩子:给带标记的叶子挂上类名与属性,样式见 EditorWangPane。
*
* 直接改传入的 vnode.data 而不新建 vnode:新建需要 snabbdom 的 h,
* 而 snabbdom 是 wangEditor 的内部依赖,不该由业务代码直接引用。
* data.props / data.dataset 是 vnode 的规范字段(见 core 的 normalizeVnodeData),
* 改它们不会被后续的归一化覆盖。
*
* **类名与 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
@@ -165,14 +195,16 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode
}
const data = (vnode.data ??= {})
const props = (data.props ??= {})
const dataset = (data.dataset ??= {})
dataset.signMarker = mark.signMarker
if (mark.signMarker === 'box') {
props.className = 'ms-sign-box'
props.title = '勾选项:签署时转为可勾选控件'
setAttrs(data, {
class: 'ms-sign-box',
title: '勾选项:签署时转为可勾选控件',
})
return vnode
}
@@ -184,27 +216,35 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode
// 提示必须分开说:目录里的字段签署时自动填充,自定义文本项是留给现场手填的空框。
// 一律写成"自动填充",科室会以为写了占位符就有人替他填。
const source = mark.signCustom ? '签署时由患者/家属现场填写' : '签署时按患者与就诊信息自动填充'
props.className = 'ms-sign-chip'
props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),${source}`
setAttrs(data, {
class: 'ms-sign-chip',
title: `待填写项:${mark.signRaw ?? ''}(${kind}),${source}`,
})
if (mark.signTag) {
dataset.signTag = mark.signTag
}
if (mark.signRequired) {
dataset.signRequired = 'true'
}
return vnode
}
// 大括号只缩小调淡,不移除:见文件头的"硬约束"。
// 左右分开给类名,样式里才能各收各的边距(全角括号两侧留白很宽)
props.className = `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}`
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
/**
@@ -295,10 +295,10 @@ function save() {
type="button"
class="placeholder-chip"
:class="{ 'is-signature': field.kind === 'signature' }"
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充${field.required ? ',必填' : ''}`"
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充`"
@click="insertField(field)"
>
{{ field.label }}{{ field.required ? ' *' : '' }}
{{ field.label }}
</button>
<button
type="button"
@@ -336,7 +336,6 @@ function save() {
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
{{ field.label }}
<em>{{ kindLabels[field.kind] }}</em>
<i v-if="field.required">必填</i>
</span>
</div>
<p v-else class="placeholder-check__empty">
@@ -590,12 +589,6 @@ function save() {
font-style: normal;
}
.placeholder-check__item i {
color: var(--err);
font-size: 10.5px;
font-style: normal;
}
.placeholder-check__empty {
margin: 0;
color: var(--mut);
@@ -238,7 +238,8 @@ onBeforeUnmount(() => {
border-radius: 4px;
}
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么 */
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。
所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */
.pane-content :deep(.ms-sign-chip)::after {
padding-left: 5px;
margin-left: 5px;
@@ -250,25 +251,6 @@ onBeforeUnmount(() => {
opacity: 0.7;
}
/* 必填项:角标转红并补一个星号,和下方「占位符检查」面板的口径一致 */
.pane-content :deep(.ms-sign-chip[data-sign-required='true'])::after {
color: var(--err);
content: attr(data-sign-tag) ' *';
opacity: 1;
}
/* 签名位是最不能漏的一项:与「插入签署占位符」按钮里的橙色区分保持一致 */
.pane-content :deep(.ms-sign-chip[data-sign-kind='signature']) {
color: #a96900;
background: #fff4e2;
border-color: #f0d5a8;
}
.pane-content :deep(.ms-sign-brace[data-sign-kind='signature']) {
color: #c08a3e;
opacity: 0.7;
}
/* 勾选框:签署时会转成可勾选控件,这里给它一个"框"的观感。
只上底色不加边框——□ 本身就是个方框,外面再套一圈边框会变成"框中框" */
.pane-content :deep(.ms-sign-box) {