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
+189 -9
View File
@@ -1,12 +1,22 @@
/**
* 编辑器占位符 / 勾选框胶囊的渲染验证。
*
* 验证目标(utils/signing-editor-marks + EditorWangPane):
* ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,而不是和正文一样的裸文字;
* ② □ 渲染成勾选胶囊;
* 验证目标(utils/signing-editor-marks + EditorWangPane + WordEditorDialog):
* ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,□ 渲染成勾选胶囊,
* 而不是和正文一样的裸文字;
* ② 所有占位符**共用一个样式**:胶囊之间配色/边框完全一致,没有「必填」红角标,
* 没有签名位专属的橙色,插入按钮与「占位符检查」面板也不再出现 `*` / 「必填」;
* ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针
* (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本;
* ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部。
* ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部;
* ⑤ **删干净**:插入勾选框后按退格,编辑区 DOM 里不能残留任何 `ms-sign-*` 元素。
* 这一条是回归重点,也是最容易反复踩的地方——渲染通道选错会以两种方式坏掉:
* · 走 `data.props.className`:挂得上,但 props 模块只单向写入、从不清理旧 prop,
* 内容删了类名还挂在空叶子上(表现为擦不掉的浅蓝小方块);
* · 走 `data.class`:wangEditor 的规范化函数(`RT`)白名单里没有 `class`,
* 会被搬进 `data.props` 并删掉原键,class 模块读不到 → 类名压根不渲染。
* 正确通道是 `data.attrs`(在白名单里,且模块自带 removeAttribute)。
* 详见 signing-editor-marks.ts 的函数注释与 scripts/README.md「踩过的坑 · 5」。
*
* 用法:
* 1. 起 dev server
@@ -181,13 +191,14 @@ const EDITOR_PROBE = `(() => {
text: c.textContent,
tag: c.dataset.signTag || null,
kind: c.dataset.signKind || null,
required: c.dataset.signRequired || null,
display: style(c).display,
bg: style(c).backgroundColor,
color: style(c).color,
border: style(c).borderTopWidth + ' ' + style(c).borderTopColor,
afterContent: style(c, '::after').content,
title: c.title,
afterColor: style(c, '::after').color,
title: c.getAttribute('title'),
cls: c.className,
})),
braceCount: braces.length,
braceTexts: braces.map((b) => b.textContent),
@@ -200,10 +211,40 @@ const EDITOR_PROBE = `(() => {
text: b.textContent,
bg: style(b).backgroundColor,
border: style(b).borderTopWidth,
title: b.title,
title: b.getAttribute('title'),
cls: b.className,
})),
panelText: panel ? panel.innerText.replace(/\\s+/g, ' ').trim() : null,
panelItems: panel ? [...panel.querySelectorAll('.placeholder-check__item')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()) : [],
insertButtons: [...document.querySelectorAll('.placeholder-chip')].map((b) => b.textContent.trim()),
}
})()`
/**
* 叶子结构探针:把编辑区每个段落的叶子原样 dump 出来。
* 用来抓「数据已删、DOM 里类名还挂着」的残留——只看 querySelectorAll('.ms-sign-box')
* 会漏掉那些类名被挂在 zero-width 空叶子上的情况。
*/
const LEAF_PROBE = `(() => {
const root = document.querySelector('.pane-content [data-slate-editor]')
if (!root) return { error: 'NO_EDITOR' }
const codes = (s) => [...s].map((c) => (c.codePointAt(0) > 126 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c)).join('')
const blocks = [...root.querySelectorAll('[data-slate-node="element"]')]
return {
blocks: blocks.map((el) => ({
tag: el.tagName,
leaves: [...el.querySelectorAll('[data-slate-leaf]')].map((leaf) => {
const span = leaf.firstElementChild
return {
cls: span ? span.className : null,
text: span ? span.textContent : null,
codes: span ? codes(span.textContent || '') : null,
zeroWidth: span ? span.getAttribute('data-slate-zero-width') : null,
title: span ? span.getAttribute('title') : null,
outer: span ? span.outerHTML : null,
}
}),
})),
}
})()`
@@ -230,8 +271,27 @@ async function mouse(ws, type, x, y) {
})
}
async function key(ws, k, code, vk) {
for (const type of ['keyDown', 'keyUp']) {
await send(ws, 'Input.dispatchKeyEvent', {
type,
key: k,
code,
windowsVirtualKeyCode: vk,
nativeVirtualKeyCode: vk,
})
}
}
const pageErrors = []
/** 断言台账:脚本不做「只打印不判断」,结论必须一眼可读 */
const checks = []
function check(name, ok, detail) {
checks.push({ name, ok: !!ok })
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
}
;(async () => {
try {
const res = await fetch(TARGET_URL)
@@ -295,7 +355,10 @@ const pageErrors = []
if (!dialog.hasEditor) throw new Error('Word 编辑器没有打开')
console.log('\n=== 插入占位符与勾选框 ===')
for (const label of ['患者签名', '签署日期', '患者姓名', '勾选框 □', '二选一']) {
// 勾选框必须最后插入:退格是逐字符删,只有让 □ 成为最后一个字符,
// 才会走到「叶子变空」那条路径——残留 bug 只在那条路径上出现。
const INSERT_LABELS = ['患者签名', '签署日期', '患者姓名', '二选一 □是 □否', '勾选框 □']
for (const label of INSERT_LABELS) {
console.log(
' ' + label + ' -> ' + (await evaluate(ws, clickByText(label, '.placeholder-chip'))),
)
@@ -309,6 +372,107 @@ const pageErrors = []
await shot(ws, '04-editor-marks.png')
await zoomShot(ws, '.pane-content [data-slate-editor] p', '04b-editor-marks-zoom.png', 4)
console.log('\n=== 断言:胶囊渲染与样式统一 ===')
check(
'占位符渲染成胶囊(.ms-sign-chip ≥ 1)',
probe.chips.length >= 1,
`${probe.chips.length} 个`,
)
check(
'勾选框渲染成胶囊(.ms-sign-box ≥ 1)',
probe.boxes.length >= 1,
`${probe.boxes.length} 个`,
)
check(
'每个胶囊都有类型角标(::after 有文案)',
probe.chips.length > 0 &&
probe.chips.every(
(c) => c.afterContent && c.afterContent !== 'none' && c.afterContent !== '""',
),
probe.chips.map((c) => `${c.text}=${c.afterContent}`).join(' | '),
)
const skin = (c) => `${c.bg}/${c.color}/${c.border}/${c.afterColor}`
const skins = [...new Set(probe.chips.map(skin))]
check(
'所有占位符共用一个样式(配色/边框/角标色完全一致)',
probe.chips.length >= 2 && skins.length === 1,
skins.length === 1 ? skins[0] : `出现 ${skins.length} 种:${skins.join(' ;; ')}`,
)
check(
'胶囊上不再有必填标记(无 data-sign-required、无 `*` 角标)',
probe.chips.every(
(c) => !c.cls.includes('required') && !String(c.afterContent).includes('*'),
),
probe.chips.map((c) => c.cls).join(' | '),
)
check(
'插入按钮文字不再带 `*`',
probe.insertButtons.every((t) => !t.includes('*')),
probe.insertButtons.join(' / '),
)
check(
'「占位符检查」面板不再出现「必填」',
probe.panelItems.every((t) => !t.includes('必填')),
probe.panelItems.join(' / '),
)
check(
'底层仍是 {…} 纯文本(面板能数出填写项)',
/个填写项/.test(probe.panelText || '') && !/0 个填写项/.test(probe.panelText || ''),
probe.panelText,
)
// 删除测试必须紧跟插入、且在光标测试之前:
// 插入后光标就在文末,退格才删得到最后那个 □;
// 光标测试会往胶囊里插字符,之后光标不在文末,退格删的就是别的字符了。
console.log('\n=== 断言:删掉勾选框后 DOM 不残留 ===')
console.log(' 按一次退格(删掉最后插入的 □)')
await key(ws, 'Backspace', 'Backspace', 8)
await sleep(1000)
const leaves = await evaluate(ws, LEAF_PROBE)
const afterDelete = await evaluate(ws, EDITOR_PROBE)
const allLeaves = leaves.blocks.flatMap((b) => b.leaves)
// 残留的确切签名:叶子已经空了(zero-width 或空文本),类名却还挂着
const residue = allLeaves.filter(
(l) => l.cls && l.cls.includes('ms-sign-') && (l.zeroWidth || !l.text),
)
const countBoxes = (text) => ((text || '').match(/□/g) || []).length
console.log(
' [退格后胶囊] ' +
JSON.stringify({
chips: afterDelete.chips.map((c) => c.text),
braces: afterDelete.braceTexts,
boxes: afterDelete.boxes.map((b) => b.text),
}),
)
console.log(
' [退格后叶子] ' + JSON.stringify(allLeaves.map((l) => ({ cls: l.cls, codes: l.codes }))),
)
await shot(ws, '05b-editor-marks-delete.png')
check(
'勾选框已被删除(.ms-sign-box 比插入时少一个)',
afterDelete.boxes.length === probe.boxes.length - 1,
`${probe.boxes.length} -> ${afterDelete.boxes.length}`,
)
check(
'勾选框字符已被删除(正文 □ 比插入时少一个)',
countBoxes(afterDelete.editorText) === countBoxes(probe.editorText) - 1,
`${countBoxes(probe.editorText)} -> ${countBoxes(afterDelete.editorText)}`,
)
check(
'DOM 里的勾选胶囊数与正文 □ 数一致(没有多出来的空壳)',
afterDelete.boxes.length === countBoxes(afterDelete.editorText),
`boxes=${afterDelete.boxes.length} / □=${countBoxes(afterDelete.editorText)}`,
)
check(
'空叶子上没有残留 ms-sign-* 类名',
residue.length === 0,
residue.length ? residue.map((l) => `${l.cls}:${l.codes}`).join(' | ') : '干净',
)
console.log('\n=== 光标落点(点进胶囊中部后插入 X) ===')
const chipBox = await evaluate(
ws,
@@ -326,7 +490,7 @@ const pageErrors = []
await mouse(ws, 'mousePressed', cx, cy)
await mouse(ws, 'mouseReleased', cx, cy)
await sleep(500)
console.log(' 点击前选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
console.log(' 点击后选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
await send(ws, 'Input.insertText', { text: 'X' })
await sleep(600)
@@ -334,10 +498,26 @@ const pageErrors = []
console.log(' 插入 X 后正文: ' + after.editorText)
console.log(' 插入 X 后填写项面板: ' + after.panelText)
await shot(ws, '05-editor-marks-caret.png')
console.log('\n=== 断言:装饰没有破坏光标偏移 ===')
check(
'插入的字符落在占位符内部',
/{[^}]*X[^}]*}/.test(after.editorText),
after.editorText,
)
} else {
check('插入的字符落在占位符内部', false, '找不到可点的胶囊')
}
console.log('\n=== 页面运行时错误 ===')
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
const failed = checks.filter((c) => !c.ok)
console.log(`\n=== 结论:${checks.length - failed.length}/${checks.length} 通过 ===`)
if (failed.length) {
failed.forEach((c) => console.log(' 未通过:' + c.name))
process.exitCode = 1
}
} catch (err) {
console.error('验证失败:', err.message)
process.exitCode = 1