Files
xh-medical-sign-web/clinical-web/.probe-field-node/main.ts
T

444 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「签署占位符 = 原子节点」的浏览器行为探针。
*
* 为什么需要它:类型检查、构建、纯逻辑断言都只能证明"代码写得对",
* 证明不了 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 {}