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
@@ -3,14 +3,28 @@ import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { DomEditor } from '@wangeditor/editor'
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
import type { SigningFieldDef } from '@/api/workbench/types'
import { normalizePastedHtml } from '@/utils/rich-text'
import { decorateSigningMarkers, registerSigningEditorMarks } from '@/utils/signing-editor-marks'
import {
createSignFieldNode,
isSignFieldElement,
readFieldFromSignFieldNode,
registerSigningFieldNode,
removeSignFieldNodes,
SIGN_FIELD_CHIP_CLASS,
SIGN_FIELD_TYPE,
updateSignFieldNodes,
} from '@/utils/signing-field-node'
// 渲染钩子是 wangEditor 的全局注册表,必须在编辑器创建前挂上,
// 否则首帧渲染出来的占位符/勾选框还是普通文字(要等下一次重渲染才变胶囊)。
// 这两个都是 wangEditor 的全局注册表,必须在编辑器创建前挂上:
// - 渲染钩子决定旧式 {…} 占位符与 □ 勾选框的显示(要等下一次重渲染才生效就晚了);
// - 元素节点决定结构化占位符能不能被还原成原子胶囊(首帧就要是胶囊,否则看起来像丢内容)。
registerSigningEditorMarks()
registerSigningFieldNode()
const props = defineProps<{
modelValue: string
@@ -18,6 +32,8 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: string]
/** 点中了正文里的结构化占位符,交给上层打开「填写项配置」 */
'select-field': [field: SigningFieldDef]
}>()
const editorRef = shallowRef<IDomEditor>()
@@ -96,6 +112,44 @@ function handleCreated(editor: IDomEditor) {
editableRef.value = editable
editable.addEventListener('pointerdown', markUserCaret)
editable.addEventListener('keydown', markUserCaret)
editable.addEventListener('click', handleFieldClick)
}
/**
* 点中正文里的占位符胶囊 → 整块选中并通知上层打开配置面板。
*
* 必须再判一次 toSlateNode 的结果:旧式 {…} 占位符被 decorate 渲染成了
* 同名的 .ms-sign-chip,它只是个叶子、不是原子节点,
* 点它时什么都不该发生(它的配置从上方「填写项」列表里改)。
*/
function handleFieldClick(event: Event) {
const editor = editorRef.value
const target = event.target
if (!editor || !(target instanceof Element)) {
return
}
const chip = target.closest(`.${SIGN_FIELD_CHIP_CLASS}`)
if (!chip) {
return
}
try {
const node = DomEditor.toSlateNode(editor, chip)
if (!isSignFieldElement(node)) {
return
}
editor.focus()
editor.select(DomEditor.findPath(editor, node))
emit('select-field', readFieldFromSignFieldNode(node))
} catch {
// 解不出节点(点到装饰件、或节点已不在模型里)就静默放过:
// 点击本身不该报错,保持浏览器默认行为即可
}
}
function getText(): string {
@@ -116,11 +170,68 @@ function insertText(text: string) {
editor.insertNode({ text })
}
defineExpose({ getText, insertText })
/**
* 在光标处插入一个**结构化占位符**(Slate 元素,不是文本)。
*
* 与 insertText 的区别就是这个模块存在的理由:它进的是原子节点,
* 点一下即整块选中、一次退格整块删除;字段键、类型、必填、选项、多选
* 都挂在元素上,导出正文时还原成 `<span data-field>`,
* 与签署渲染端(utils/signing-document)共用同一份契约。
*/
function insertField(field: SigningFieldDef) {
const editor = editorRef.value
if (!editor) {
return
}
editor.focus(!userPlacedCaret.value)
// 当前选中的就是占位符时先右移一格:否则新节点会插进上一个占位符内部,
// 连插两个签名位会互相嵌套(内置图片节点也是这么处理的)
if (DomEditor.getSelectedNodeByType(editor, SIGN_FIELD_TYPE)) {
editor.move(1)
}
editor.insertNode(createSignFieldNode(field))
}
/**
* 就地改写某个字段的每一处占位符,返回命中的节点数。
*
* 面板改字段走这里而不是「改 HTML 字符串再塞回编辑器」:
* 字符串往返要经过 setHtml,而 setHtml 内部会 `e.focus()`——它会把缓存的上次选区装回去,
* 于是新正文的节点还没绑上 DOM 就先做了一次 DOM 同步,抛
* `Cannot resolve a DOM node from Slate node`(实测必现,且与光标在哪儿无关)。
* 改模型既保住光标,也彻底绕开这条路径。字段 id 不变,历史取值不受影响。
*/
function updateField(fieldId: string, patch: Partial<Omit<SigningFieldDef, 'id'>>): number {
const editor = editorRef.value
if (!editor) {
return 0
}
return updateSignFieldNodes(editor, fieldId, patch)
}
/** 就地删掉某个字段的每一处占位符,返回命中的节点数 */
function removeField(fieldId: string): number {
const editor = editorRef.value
if (!editor) {
return 0
}
return removeSignFieldNodes(editor, fieldId)
}
defineExpose({ getText, insertText, insertField, updateField, removeField })
onBeforeUnmount(() => {
editableRef.value?.removeEventListener('pointerdown', markUserCaret)
editableRef.value?.removeEventListener('keydown', markUserCaret)
editableRef.value?.removeEventListener('click', handleFieldClick)
editorRef.value?.destroy()
})
</script>
@@ -205,8 +316,12 @@ onBeforeUnmount(() => {
}
/* ---- 占位符 / 勾选框胶囊 ----
utils/signing-editor-marks 把 {患者姓名} 切成「{ / 患者姓名 / }」三段叶子、
把 □ 切成一 段叶子,这里负责把它们渲染成一眼能认出是"待签署内容"的形态。 */
现在有两种占位符胶囊,样式刻意保持一致(都代表"待签署内容",不该靠外观区分重要性):
① 结构化占位符(.ms-sign-node):Slate 元素节点,正文里存成 <span data-field>。
标签文字走下面的 ::before,DOM 里没有可落光标的文字,一次退格整块删除。
② 旧式 {患者姓名}(.ms-sign-brace + .ms-sign-chip):decorate 出来的叶子,
只是显示成胶囊,本质还是文字,要按多次退格。存量模板打开后会被升级成 ①。 */
/* 大括号:只缩小调淡,不移除。这两个字符必须留在 DOM 里参与光标偏移计算,
隐藏它们会让点击胶囊时的落点整体算错,也会让退格静默删掉看不见的字符 */
@@ -238,6 +353,25 @@ onBeforeUnmount(() => {
border-radius: 4px;
}
/* 结构化占位符的标签文字:走 CSS 内容而不是 DOM 文本节点。
元素是 void,DOM 里只有它自己与一个绝对定位的零宽占位叶子;
若在这里放真实文字,浏览器原生光标会落进文字里,
一次退格就只删掉一个字符 —— 正好破坏"一次退格整块删除"这个设计目标 */
.pane-content :deep(.ms-sign-node)::before {
content: attr(data-sign-label);
}
/* 选中态:点一下即整块选中,给个明确的反色,让人知道接下来的退格会删掉整块 */
.pane-content :deep(.ms-sign-node.is-selected) {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.pane-content :deep(.ms-sign-node.is-selected)::after {
border-left-color: rgb(255 255 255 / 35%);
}
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。
所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */
.pane-content :deep(.ms-sign-chip)::after {