feat(signing): 将签署占位符改为原子节点并支持多选选项组

This commit is contained in:
yelan
2026-09-24 12:13:24 +08:00
parent ec24e85829
commit 9a6ac93e21
13 changed files with 2141 additions and 121 deletions
+443
View File
@@ -0,0 +1,443 @@
/**
* 「签署占位符 = 原子节点」的浏览器行为探针。
*
* 为什么需要它:类型检查、构建、纯逻辑断言都只能证明"代码写得对",
* 证明不了 wangEditor/Slate 在真实浏览器里**真的**把占位符当成了原子节点。
* 用户的核心诉求是「一次退格就能整块删掉」,这件事只有真跑一遍才算验证过。
*
* 探针只 import 生产源码(utils/signing-field-node),不复制任何逻辑。
* 通过 window.__probe 暴露一组原语,由 CDP 脚本编排场景。
*/
import '@wangeditor/editor/dist/css/style.css'
import { createEditor, DomEditor, SlateEditor, SlateTransforms } from '@wangeditor/editor'
import type { IDomEditor } from '@wangeditor/editor'
import type { SigningFieldDef } from '../src/api/workbench/types'
import {
createSignFieldNode,
isSignFieldElement,
readFieldFromSignFieldNode,
registerSigningFieldNode,
removeSignFieldNodes,
SIGN_FIELD_CHIP_CLASS,
SIGN_FIELD_NODE_CLASS,
SIGN_FIELD_TYPE,
updateSignFieldNodes,
} from '../src/utils/signing-field-node'
// 与 EditorWangPane.vue 一样,在创建编辑器之前注册
registerSigningFieldNode()
const FIELD: SigningFieldDef = {
id: 'patient',
label: '患者姓名',
kind: 'text',
required: true,
options: [],
}
const INITIAL_HTML = '<p>甲乙丙</p>'
function createProbeEditor(html: string): IDomEditor {
const container = document.getElementById('editor')
// 复用同一个容器重建,先把上一次留下的 DOM 清掉
if (container) {
container.innerHTML = ''
}
return createEditor({
selector: '#editor',
html,
mode: 'default',
config: { placeholder: '正文' },
})
}
let editor: IDomEditor = createProbeEditor(INITIAL_HTML)
function plain<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null)) as T
}
/**
* 等 DOM 追上模型。
*
* wangEditor 的 setHtml / insertNode / select 只改 Slate 模型,DOM 由 snabbdom 在下一帧 patch。
* 采样太快会读到上一版 DOM(表现是"模型里有元素、DOM 里没有"),
* 会把正常的渲染误判成"胶囊没渲染出来"。
*/
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 80))
}
/** 深度优先找第一个占位符节点的路径(正文里可能嵌在段落/列表里) */
function findFieldPath(): number[] | null {
let hit: number[] | null = null
const visit = (nodes: unknown[], prefix: number[]) => {
nodes.forEach((node, index) => {
if (!node || typeof node !== 'object') {
return
}
if (isSignFieldElement(node)) {
if (!hit) {
hit = [...prefix, index]
}
return
}
const children = (node as { children?: unknown }).children
if (Array.isArray(children)) {
visit(children, [...prefix, index])
}
})
}
visit(editor.children as unknown[], [])
return hit
}
function countFields(): number {
let count = 0
const visit = (nodes: unknown[]) => {
nodes.forEach((node) => {
if (!node || typeof node !== 'object') {
return
}
if (isSignFieldElement(node)) {
count += 1
return
}
const children = (node as { children?: unknown }).children
if (Array.isArray(children)) {
visit(children)
}
})
}
visit(editor.children as unknown[])
return count
}
/** 把 Slate 模型压成一眼能看懂的形状,方便在 CDP 输出里核对 */
function outline(): unknown[] {
const walk = (nodes: unknown[]): unknown[] =>
nodes.map((node) => {
const record = node as { type?: string; text?: string; children?: unknown[] }
if (typeof record.text === 'string') {
return { text: record.text }
}
if (isSignFieldElement(node)) {
return { element: SIGN_FIELD_TYPE, fieldId: record.fieldId, label: record.fieldLabel }
}
return { type: record.type, children: walk(record.children ?? []) }
})
return walk(editor.children as unknown[])
}
/** 胶囊在 DOM 里的样子:标签必须靠 CSS 展示,DOM 里不能有可落光标的文字 */
function chipInfo() {
const chip = document.querySelector<HTMLElement>(`.${SIGN_FIELD_NODE_CLASS}`)
if (!chip) {
return null
}
return {
tagName: chip.tagName,
className: chip.className,
hasChipClass: chip.classList.contains(SIGN_FIELD_CHIP_CLASS),
dataSignLabel: chip.getAttribute('data-sign-label'),
dataSignTag: chip.getAttribute('data-sign-tag'),
contentEditableAttr: chip.getAttribute('contenteditable'),
// 文本节点数量是这条诉求的关键:有文字节点,原生光标就可能落进去
textNodeCount: Array.from(chip.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE).length,
textContent: chip.textContent,
childElementCount: chip.childElementCount,
beforeContent: getComputedStyle(chip, '::before').content,
afterContent: getComputedStyle(chip, '::after').content,
backgroundColor: getComputedStyle(chip).backgroundColor,
rect: plain(chip.getBoundingClientRect()),
}
}
const probe = {
/**
* 只灌正文,不碰光标。
* 用来区分"setHtml 本身有没有问题"与"setHtml 之后立刻 focus 有没有问题"。
*/
async setHtmlOnly(html: string) {
editor.setHtml(html)
return probe.snapshot()
},
/** 变体:先清选区再灌正文(用来证明"清选区"并不能规避 setHtml 的竞态) */
async deselectThenSetHtml(html: string) {
SlateTransforms.deselect(editor)
editor.setHtml(html)
return probe.snapshot()
},
/**
* 复位成指定正文:**销毁重建编辑器**。
*
* ⚠️ 不能用 setHtml 复位。实测(见 [0] 节的对照实验)wangEditor 的 setHtml 在
* "编辑器产生过选区"之后是不可靠的:
* ① 异步抛 `Cannot resolve a DOM node from Slate node`;
* ② 有时直接同步抛 `Cannot find a descendant at path`。
* 读源码可知原因:with-content.ts 的 setHtml 内部会 `e.focus()`,
* 而 with-dom.ts 的 `e.focus()` 会把**缓存的上次选区**(EDITOR_TO_SELECTION)装回去,
* 于是 `e.clear()` 之后、`Transforms.insertFragment` 之前,选区已经指向不存在的路径。
* 事先 `editor.selection = null` 或 `Transforms.deselect` 都没用——focus() 会把它装回来。
*
* 生产里只有「导入 docx」会走到这条路径,那是一个既有的独立问题(已在 WordEditorDialog
* 对应位置留了说明)。探针需要频繁复位正文,所以改成重建实例——这也更贴近生产的真实形态
* (WordEditorDialog 整块内容在 v-if="visible" 里,弹窗每次打开都是新编辑器)。
*/
async reset(html: string) {
editor.destroy()
editor = createProbeEditor(html)
await settle()
return probe.snapshot()
},
async snapshot() {
await settle()
return {
html: editor.getHtml(),
text: editor.getText(),
selection: plain(editor.selection),
fieldPath: findFieldPath(),
fieldCount: countFields(),
outline: outline(),
chip: chipInfo(),
// 排查用:渲染失败时只有编辑器自己的 DOM 能说明它到底渲染成了什么
editorDom: document.getElementById('editor')?.innerHTML ?? null,
result: document.getElementById('result')?.textContent,
}
},
/** 与 EditorWangPane.insertField 同一套顺序:focus → 若已选中占位符则右移 → insertNode */
async insertField(patch?: Partial<SigningFieldDef>) {
const def: SigningFieldDef = { ...FIELD, ...(patch ?? {}) }
editor.focus(true)
if (DomEditor.getSelectedNodeByType(editor, SIGN_FIELD_TYPE)) {
editor.move(1)
}
editor.insertNode(createSignFieldNode(def) as never)
return probe.snapshot()
},
/** 光标放到占位符之后(退格即应删掉整个占位符) */
async caretAfterField() {
const path = findFieldPath()
if (!path) {
return { ok: false, reason: 'NO_FIELD' }
}
const next = SlateEditor.next(editor, { at: path })
if (next) {
editor.select({ path: next[1], offset: 0 })
await settle()
return { ok: true, how: 'next', selection: plain(editor.selection) }
}
// 占位符是最后一个子节点:光标落到父节点末尾
const parentPath = path.slice(0, -1)
const parent = SlateEditor.node(editor, parentPath)[0] as { children: unknown[] }
editor.select({ path: parentPath, offset: parent.children.length })
await settle()
return { ok: true, how: 'parentEnd', selection: plain(editor.selection) }
},
/** 光标放到占位符之前(用于对照:退格应删掉前一个字符,不动占位符) */
async caretBeforeField() {
const path = findFieldPath()
if (!path) {
return { ok: false, reason: 'NO_FIELD' }
}
const prev = SlateEditor.previous(editor, { at: path })
if (prev) {
const text = (prev[0] as { text?: string }).text ?? ''
editor.select({ path: prev[1], offset: text.length })
await settle()
return { ok: true, how: 'previous', selection: plain(editor.selection) }
}
editor.select({ path: path.slice(0, -1), offset: 0 })
await settle()
return { ok: true, how: 'parentStart', selection: plain(editor.selection) }
},
/**
* 光标放到文末(纯文本对照用)。
* 直接算文末的点再 select,不依赖 focus(true) 在"已有选区"时的行为——
* 上一节点过胶囊之后选区还停在那个 void 节点上,focus 不一定会把它挪走。
*/
async caretToEnd() {
editor.select(SlateEditor.end(editor, []))
await settle()
return { ok: true, selection: plain(editor.selection) }
},
/** 当前 DOM 里是否已渲染出胶囊 */
chipExists() {
return Boolean(document.querySelector(`.${SIGN_FIELD_NODE_CLASS}`))
},
/**
* 复刻 EditorWangPane.handleFieldClick 的定位路径:
* 点胶囊 → closest('.ms-sign-chip') → DomEditor.toSlateNode → 判类型 → 取路径。
* 这一步的风险是 toSlateNode 能不能从 ::before 展示的胶囊解回 Slate 节点。
*/
clickChip() {
const chip = document.querySelector<HTMLElement>(`.${SIGN_FIELD_CHIP_CLASS}`)
if (!chip) {
return { ok: false, reason: 'NO_CHIP' }
}
chip.click()
return probe.resolveChip()
},
/** 不走点击,直接按 handleFieldClick 的逻辑解一遍(排除 click 事件本身的干扰) */
async resolveChip() {
await settle()
const chip = document.querySelector<HTMLElement>(`.${SIGN_FIELD_CHIP_CLASS}`)
if (!chip) {
return { ok: false, reason: 'NO_CHIP' }
}
try {
const node = DomEditor.toSlateNode(editor, chip) as unknown
if (!isSignFieldElement(node)) {
return { ok: false, reason: 'NOT_SIGN_FIELD', node: plain(node) }
}
const path = DomEditor.findPath(editor, node as never)
return {
ok: true,
field: readFieldFromSignFieldNode(node),
path: plain(path),
isSelectedBefore: DomEditor.isNodeSelected(editor, node as never),
}
} catch (error) {
return { ok: false, reason: 'THREW', message: String(error) }
}
},
/** 选中占位符节点,供"选中态样式"断言使用 */
async selectField() {
const path = findFieldPath()
if (!path) {
return { ok: false, reason: 'NO_FIELD' }
}
editor.focus()
editor.select(path)
await settle()
return { ok: true, selection: plain(editor.selection), chip: chipInfo() }
},
/** 未选中时的胶囊样式,作为选中态的对照基线 */
async unselectedChip() {
await settle()
return chipInfo()
},
/** 当前编辑区是否持有焦点(CDP 发真实按键前必须为 true) */
focusState() {
const editable = document.querySelector('[contenteditable="true"]')
return {
activeElementIsEditable: document.activeElement === editable,
editableFound: Boolean(editable),
}
},
/** 供 CDP 读回的原始编辑器引用(排查用)。getter:reset 会重建实例 */
get editor() {
return editor
},
DomEditor,
SlateEditor,
/** 面板改字段:就地改模型(EditorWangPane.updateField 的底层) */
async updateField(fieldId: string, patch: Record<string, unknown>) {
const count = updateSignFieldNodes(editor, fieldId, patch as never)
return { count, snapshot: await probe.snapshot() }
},
/** 面板删除字段:就地改模型(EditorWangPane.removeField 的底层) */
async removeField(fieldId: string) {
const count = removeSignFieldNodes(editor, fieldId)
return { count, snapshot: await probe.snapshot() }
},
/**
* 人为把选区弄脏(指向文档里不存在的路径)后强制重渲染。
*
* 复现的是这条链路:整篇替换正文时 setHtml 不清旧选区 → 选区越界 →
* renderElem 里 DomEditor.isNodeSelected 拿脏选区跑 nodes() 抛
* `Cannot find a descendant at path` → 整个占位符渲染失败。
* 渲染函数不该因为选区脏了就崩,所以 isSelected 包了 try/catch。
*/
async renderWithDirtySelection() {
editor.selection = {
anchor: { path: [0, 99, 0], offset: 0 },
focus: { path: [0, 99, 0], offset: 0 },
} as never
// 改一个属性强制重渲染(渲染钩子会去读 editor.selection)
updateSignFieldNodes(editor, 'patient', { required: false })
await settle()
return { chip: chipInfo(), selection: plain(editor.selection), fieldCount: countFields() }
},
}
Object.assign(window, { __probe: probe })
document.getElementById('result')!.textContent = 'READY'
export {}