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