Files
xh-medical-sign-web/clinical-web/src/utils/signing-field-node.ts
T

348 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 内置的 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
fieldPdfPosition?: SigningFieldDef['pdfPosition']
/** 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,
fieldPdfPosition: field.pdfPosition ? { ...field.pdfPosition } : undefined,
}
}
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 } : {}),
...(elem.fieldPdfPosition ? { pdfPosition: { ...elem.fieldPdfPosition } } : {}),
}
}
/**
* 这个占位符当前是否处于选中态。
*
* 必须包一层 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 展示标签、::before/::after 与选择器区分类型,
// 避免在 DOM 里留下可落光标的文字(见文件头 ③)
'data-sign-label': elem.fieldLabel,
'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)
}