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
@@ -0,0 +1,348 @@
/**
* 编辑器里的「签署占位符」元素节点。
*
* 为什么要有这个模块:占位符原本是纯文本 {患者姓名},在编辑器里就是一段文字——
* 删掉一个要按五六次退格,退格一次只吃掉一个字符,稍微快点就会把相邻正文也带走;
* 而且纯文本没法承载「有哪些选项」「能不能多选」这类结构化信息。
*
* 现在把它做成一个**原子节点**:点一下即整块选中,一次退格整体删除,
* 内部不落任何可编辑的文字。
*
* 关键实现点(都是照着 wangEditor 内置的 image 模块抄的,那是它自己的 void 元素):
*
* ① **isVoid + isInline 都要覆写,且必须走 editorPlugin**。
* 只有 isVoid 会变成块级 <div>,把整行撑断;两个都覆写才得到内联的 <span>。
* 覆写方式与 image 模块一致:保留原方法再或上自己的类型判断。
*
* ② **renderElem 拿到的 children 是 null**。void 元素渲染时 wangEditor 不传子节点
* (见其内部 renderElement:`children = isVoid ? null : node.children.map(...)`),
* 所以标签要自己渲染,不能指望 children。
*
* ③ **标签文字走 CSS ::before,不落成 DOM 文本节点**。
* void 元素的 DOM 结构是 `wrapper > [renderElem 结果, data-slate-spacer]`,
* spacer 里才有 wangEditor 用来算光标位置的 data-slate-zero-width 叶子。
* 如果我们的胶囊里再放一段真实文字,浏览器原生光标有可能落进那段文字里,
* 一次退格就只删掉一个字符——正好破坏「一次删掉」这个诉求。
* 用 ::before { content: attr(data-sign-label) } 展示,DOM 里就没有可落点文字。
*
* ④ **存下来的正文仍然是干净的 `<span data-field>`**,与 utils/signing-fields 的
* 结构化契约、以及签署渲染管线(utils/signing-document 的 renderStructuredFields)
* 完全一致,签署侧一行都不用改。编辑器专属的标记只活在解析期,见 ⑤。
*
* ⑤ **`<span>` 必须带 `data-w-e-type` 才会被当成元素解析**。
* wangEditor 的解析分派里有一段特判:
* if (tagName === 'span') {
* if ($elem.attr('data-w-e-type')) return parseCommonElemHtml(...)
* else return parseTextElemHtml(...) // 退化成行内文本
* }
* 只有 `data-w-e-type` 存在时才会去查 PARSE_ELEM_HTML_CONF 里的选择器。
* 所以「让 span 被认成 sign-field 元素」这一步必须补。补在 preParseHtml 而不是
* 直接写进正文,是为了让 contentHtml 保持干净:preParseHtml 只在解析前跑一遍,
* 给它加上 `data-w-e-type` / `data-w-e-is-void` 两个 wangEditor 内部标记,
* 导出时(elemToHtml)不带上它们。若把这两个标记写进正文,
* 编辑器的内部实现细节就渗进了模板内容,还会连带改变模板哈希。
* preParseHtml 是解析入口 parseElemHtml 的第一步(`PRE_PARSE_HTML_CONF_LIST.forEach`),
* setHtml 与粘贴走的都是它,所以两条路径都能正确还原。
*
* ⑥ 显式引入 snabbdom 的 h:registerRenderElem 必须返回 vnode,而 vnode 只能由
* snabbdom 构造(wangEditor 未导出 h)。这条依赖已写进 package.json,
* 版本与其内部使用的保持一致,避免打包出两份 snabbdom 实例。
*/
import { Boot, DomEditor, SlateEditor, SlateTransforms } from '@wangeditor/editor'
import type { IDomEditor } from '@wangeditor/editor'
import { h } from 'snabbdom'
import type { VNode } from 'snabbdom'
import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
import {
buildFieldSpanHtml,
FIELD_ELEMENT_SELECTOR,
readFieldFromElement,
SIGNING_FIELD_KIND_LABELS,
} from './signing-fields'
/** Slate 元素类型名。改这个值要同步下面两处选择器。 */
export const SIGN_FIELD_TYPE = 'sign-field'
/**
* 解析 `<span data-field>` 时用的选择器。
* 注意不是 FIELD_ELEMENT_SELECTOR —— 解析阶段这个 span 已经被 preParseHtml
* 打上了 data-w-e-type,见文件头 ⑤。
*/
export const SIGN_FIELD_PARSE_SELECTOR = `span[data-w-e-type="${SIGN_FIELD_TYPE}"]`
/** wangEditor 用来判断「这个 span 是元素而不是行内文本」的标记属性。 */
const WANGEDITOR_TYPE_ATTR = 'data-w-e-type'
/** wangEditor 用来跳过「从内部文字生成 children」的标记属性。 */
const WANGEDITOR_VOID_ATTR = 'data-w-e-is-void'
/** 编辑器里胶囊的类名,样式见 EditorWangPane。 */
export const SIGN_FIELD_CHIP_CLASS = 'ms-sign-chip'
/** 原子占位符额外的类名:与 decorate 出来的旧式胶囊共用底色,但只有它有 ::before 标签与选中态。 */
export const SIGN_FIELD_NODE_CLASS = 'ms-sign-node'
/**
* 占位符在 Slate 模型里的样子。
*
* 字段信息全部挂在元素自己身上(而不是像纯文本那样靠「第几次出现」推断位置),
* 所以改名字、调顺序、删除都不会让取值归属漂移。
*/
export interface SignFieldElement {
type: typeof SIGN_FIELD_TYPE
fieldId: string
fieldKind: SigningFieldKind
fieldLabel: string
fieldRequired: boolean
fieldOptions: string[]
fieldMulti?: boolean
fieldRisk?: string
/** void 元素也要有一个空文本子节点,否则 Slate 会认为结构非法 */
children: [{ text: '' }]
}
/**
* 把字段定义摊成 Slate 元素的属性。
*
* `fieldMulti` / `fieldRisk` **显式写成 undefined 而不是省略**:Transforms.setNodes 是合并语义,
* 省略这两个键会让节点上原有的值残留下来(比如把「可多选」关掉之后模型里还是 true)。
* createSignFieldNode 也走这里,两边属性名不会漂移。
*/
function buildSignFieldProps(field: SigningFieldDef): Omit<SignFieldElement, 'type' | 'children'> {
return {
fieldId: field.id,
fieldKind: field.kind,
fieldLabel: field.label,
fieldRequired: field.required,
fieldOptions: [...field.options],
fieldMulti: field.multi,
fieldRisk: field.risk,
}
}
export function createSignFieldNode(field: SigningFieldDef): SignFieldElement {
return {
type: SIGN_FIELD_TYPE,
...buildSignFieldProps(field),
children: [{ text: '' }],
}
}
export function isSignFieldElement(value: unknown): value is SignFieldElement {
return Boolean(value) && (value as { type?: unknown }).type === SIGN_FIELD_TYPE
}
/** 收集编辑器里某个字段的全部占位符节点与路径(先收集再改,避免边遍历边改结构) */
function findSignFieldEntries(
editor: IDomEditor,
fieldId: string,
): Array<{ node: SignFieldElement; path: number[] }> {
const entries: Array<{ node: SignFieldElement; path: number[] }> = []
for (const [node, path] of SlateEditor.nodes(editor, {
at: [],
match: (candidate) => isSignFieldElement(candidate) && candidate.fieldId === fieldId,
})) {
entries.push({ node: node as SignFieldElement, path })
}
return entries
}
/**
* 就地改写编辑器里某个字段的每一处占位符。返回命中的节点数。
*
* 为什么不走「getHtml() → 字符串改写 → setHtml()」的往返(面板改字段原本是这么写的):
* ① setHtml 会重建整篇文档,光标位置随之失效;
* ② 更糟的是,改写时用户的光标通常还停在正文里,setHtml 会让 wangEditor 拿旧选区去
* 新 DOM 上找位置,偏移一超界就抛 `Cannot resolve a DOM point from Slate point`
* (实测必现:面板里改字段名 → 正文里那段文字被拆成新节点 → 旧偏移落空)。
* 直接改模型既保住光标,也彻底没有这个竞态。
*
* 字段 id 刻意不变:改名只是改给人看的标签,改了 id 会让历史 fieldAnswers 对不上。
*/
export function updateSignFieldNodes(
editor: IDomEditor,
fieldId: string,
patch: Partial<Omit<SigningFieldDef, 'id'>>,
): number {
const entries = findSignFieldEntries(editor, fieldId)
entries.forEach(({ node, path }) => {
const merged = { ...readFieldFromSignFieldNode(node), ...patch, id: fieldId }
// 类型放宽一次:Slate 的 Element 是 BaseElement 与自定义类型的交叉类型、没有属性索引签名,
// Partial<Element> 不接纳我们的 field* 扩展属性(内置 image 模块走 insertNodes,
// 参数是 Node,所以没遇到这个问题)。先落到 Partial<SignFieldElement> 再传,
// 非字面量的额外属性就不再触发多余属性检查。
const props: Partial<SignFieldElement> = buildSignFieldProps(merged)
SlateTransforms.setNodes(editor, props, { at: path })
})
return entries.length
}
/** 就地删掉编辑器里某个字段的每一处占位符。返回命中的节点数。 */
export function removeSignFieldNodes(editor: IDomEditor, fieldId: string): number {
const entries = findSignFieldEntries(editor, fieldId)
SlateTransforms.removeNodes(editor, {
at: [],
match: (candidate) => isSignFieldElement(candidate) && candidate.fieldId === fieldId,
})
return entries.length
}
/** 从 Slate 元素还原字段定义,交给编辑器面板 / 签署侧使用。 */
export function readFieldFromSignFieldNode(elem: SignFieldElement): SigningFieldDef {
return {
id: elem.fieldId,
label: elem.fieldLabel,
kind: elem.fieldKind,
required: elem.fieldRequired,
options: [...elem.fieldOptions],
...(elem.fieldMulti === undefined ? {} : { multi: elem.fieldMulti }),
...(elem.fieldRisk ? { risk: elem.fieldRisk } : {}),
}
}
/**
* 这个占位符当前是否处于选中态。
*
* 必须包一层 try/catch:DomEditor.isNodeSelected 内部会拿 editor.selection 去跑 nodes(),
* 而 selection 有可能指向一个已经不在文档里的路径——整篇替换正文时 setHtml **不会**清掉旧选区,
* 于是渲染那一刻的选区是脏的,nodes() 抛 `Cannot find a descendant at path`。
* 渲染函数不能因为选区脏了就整篇渲染失败(一个占位符渲染不出来,编辑器看起来就像丢了内容),
* 取不到就当作未选中。
*/
function isSelected(editor: IDomEditor, elemNode: unknown): boolean {
try {
return DomEditor.isNodeSelected(
editor,
elemNode as Parameters<typeof DomEditor.isNodeSelected>[1],
)
} catch {
return false
}
}
/**
* 编辑器内渲染:一个只靠属性展示的胶囊。
* 不渲染 children,也不放文本节点,理由见文件头的 ③。
*/
function renderSignField(
elemNode: unknown,
_children: VNode[] | null,
editor: IDomEditor,
): VNode {
const elem = elemNode as SignFieldElement
const kindLabel = SIGNING_FIELD_KIND_LABELS[elem.fieldKind] ?? '文本'
const selected = isSelected(editor, elemNode)
const source = elem.fieldId.startsWith('custom:')
? '签署时由患者/家属现场填写'
: '签署时按患者与就诊信息自动填充'
const extra = [
elem.fieldRequired ? '必填' : '选填',
elem.fieldOptions.length ? `选项:${elem.fieldOptions.join(' / ')}` : '',
elem.fieldMulti === undefined ? '' : elem.fieldMulti ? '可多选' : '单选',
elem.fieldRisk ? `命中「${elem.fieldRisk}」记为异常项` : '',
].filter(Boolean)
return h('span', {
attrs: {
class: `${SIGN_FIELD_CHIP_CLASS} ${SIGN_FIELD_NODE_CLASS}${selected ? ' is-selected' : ''}`,
// 供 CSS ::before / ::after 展示,避免在 DOM 里留下可落光标的文字
'data-sign-label': elem.fieldLabel,
'data-sign-tag': kindLabel,
'data-sign-kind': elem.fieldKind,
title: `待填写项:${elem.fieldLabel}(${kindLabel}),${source}。${extra.join(',')}。点击选中后可整块删除`,
contenteditable: 'false',
},
})
}
/** 导出正文时还原成结构化占位符,与签署渲染端共用同一份属性口径。 */
function signFieldToHtml(elemNode: unknown): string {
return buildFieldSpanHtml(readFieldFromSignFieldNode(elemNode as SignFieldElement))
}
/** 解析 `<span data-field>`:字段信息原样搬进模型,不再从文字反推。 */
function parseSignField($elem: Element): SignFieldElement {
const field = readFieldFromElement($elem)
if (!field) {
return createSignFieldNode({
id: 'field',
label: '填写项',
kind: 'text',
required: false,
options: [],
})
}
return createSignFieldNode(field)
}
/**
* 让占位符成为内联的原子节点。
* 必须同时覆写 isInline 与 isVoid:只覆写 isVoid 会渲染成块级 div 并撑断行。
*
* 参数 element 的声明类型是 Slate 的 BaseElement,没有 type 字段,
* 所以这里按「可能带 type 的任意对象」读,读不到就不是我们的节点(与内置 image 模块同构)。
*/
function withSignField<T extends IDomEditor>(editor: T): T {
const { isInline, isVoid } = editor
editor.isInline = (element) =>
(element as { type?: unknown }).type === SIGN_FIELD_TYPE || isInline(element)
editor.isVoid = (element) =>
(element as { type?: unknown }).type === SIGN_FIELD_TYPE || isVoid(element)
return editor
}
let registered = false
/**
* 注册占位符元素节点。
*
* 与 signing-editor-marks 一样是 wangEditor 的全局注册表,重复注册会让同一个元素
* 被处理多次,用模块级开关挡住(HMR 重新求值时开关会重置,但注册本身是幂等的)。
* 必须在编辑器创建前调用,否则首帧渲染出来的还是裸文字。
*/
export function registerSigningFieldNode(): void {
if (registered) {
return
}
registered = true
// 解析前补上 wangEditor 认得的元素标记(详见文件头 ⑤)
Boot.registerPreParseHtml({
selector: FIELD_ELEMENT_SELECTOR,
preParseHtml: ($node: Element) => {
$node.setAttribute(WANGEDITOR_TYPE_ATTR, SIGN_FIELD_TYPE)
$node.setAttribute(WANGEDITOR_VOID_ATTR, 'true')
return $node
},
})
Boot.registerRenderElem({
type: SIGN_FIELD_TYPE,
renderElem: renderSignField,
})
Boot.registerElemToHtml({
type: SIGN_FIELD_TYPE,
elemToHtml: signFieldToHtml,
})
Boot.registerParseElemHtml({
selector: SIGN_FIELD_PARSE_SELECTOR,
parseElemHtml: ($elem: Element) => parseSignField($elem),
})
Boot.registerPlugin(withSignField)
}