/** * 「签署占位符 = 原子节点」的浏览器行为探针。 * * 为什么需要它:类型检查、构建、纯逻辑断言都只能证明"代码写得对", * 证明不了 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 = '

甲乙丙

' 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(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 { 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(`.${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) { 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(`.${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(`.${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) { 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 {}