/** * 编辑器里的「签署占位符」元素节点。 * * 为什么要有这个模块:占位符原本是纯文本 {患者姓名},在编辑器里就是一段文字—— * 删掉一个要按五六次退格,退格一次只吃掉一个字符,稍微快点就会把相邻正文也带走; * 而且纯文本没法承载「有哪些选项」「能不能多选」这类结构化信息。 * * 现在把它做成一个**原子节点**:点一下即整块选中,一次退格整体删除, * 内部不落任何可编辑的文字。 * * 关键实现点(都是照着 wangEditor 内置的 image 模块抄的,那是它自己的 void 元素): * * ① **isVoid + isInline 都要覆写,且必须走 editorPlugin**。 * 只有 isVoid 会变成块级
,把整行撑断;两个都覆写才得到内联的 。 * 覆写方式与 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 里就没有可落点文字。 * * ④ **存下来的正文仍然是干净的 ``**,与 utils/signing-fields 的 * 结构化契约、以及签署渲染管线(utils/signing-document 的 renderStructuredFields) * 完全一致,签署侧一行都不用改。编辑器专属的标记只活在解析期,见 ⑤。 * * ⑤ **`` 必须带 `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' /** * 解析 `` 时用的选择器。 * 注意不是 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 { 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>, ): number { const entries = findSignFieldEntries(editor, fieldId) entries.forEach(({ node, path }) => { const merged = { ...readFieldFromSignFieldNode(node), ...patch, id: fieldId } // 类型放宽一次:Slate 的 Element 是 BaseElement 与自定义类型的交叉类型、没有属性索引签名, // Partial 不接纳我们的 field* 扩展属性(内置 image 模块走 insertNodes, // 参数是 Node,所以没遇到这个问题)。先落到 Partial 再传, // 非字面量的额外属性就不再触发多余属性检查。 const props: Partial = 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[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)) } /** 解析 ``:字段信息原样搬进模型,不再从文字反推。 */ 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(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) }