feat(signing): 将签署占位符改为原子节点并支持多选选项组
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>sign-field 原子占位符探针</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font: 14px/1.6 system-ui, 'Microsoft YaHei', sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
#editor {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
min-height: 140px;
|
||||
padding: 12px;
|
||||
}
|
||||
#result {
|
||||
white-space: pre-wrap;
|
||||
font: 12px/1.5 ui-monospace, Consolas, monospace;
|
||||
color: #374151;
|
||||
}
|
||||
/*
|
||||
下面这几条是 EditorWangPane.vue 里胶囊样式的镜像(探针页没有 SFC 的作用域样式)。
|
||||
探针只用来验"标签靠 CSS ::before 展示、DOM 里没有可落光标的文字"这个机制,
|
||||
所以颜色/间距不必与生产逐字一致,但 ::before 的 attr() 取值必须一致。
|
||||
*/
|
||||
.ms-sign-chip {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
margin: 0 2px;
|
||||
border-radius: 4px;
|
||||
background: #eef2ff;
|
||||
color: #3b5bdb;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.ms-sign-node::before {
|
||||
content: attr(data-sign-label);
|
||||
}
|
||||
.ms-sign-node::after {
|
||||
content: attr(data-sign-tag);
|
||||
margin-left: 4px;
|
||||
padding-left: 4px;
|
||||
border-left: 1px solid rgb(59 91 219 / 30%);
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ms-sign-node.is-selected {
|
||||
background: #3b5bdb;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor"></div>
|
||||
<pre id="result">PENDING</pre>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
/**
|
||||
* 只给 .probe-field-node 用的最小构建配置。
|
||||
* 不引 vue 插件(探针是纯 TS),但要复用应用的 @ 别名,
|
||||
* 否则 signing-field-node.ts 里的 `@/api/workbench/types` 解析不了。
|
||||
*/
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// 不自动清空:清空会触发沙箱对删除操作的拦截,而且产物本来就带 hash、覆盖即可
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user