feat(文档库): 编辑器内渲染占位符与勾选框胶囊
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
resolveAnswersFromSystem,
|
||||
resolveFieldByLabel,
|
||||
SIGNING_DOCUMENT_HEADER,
|
||||
SIGNING_OPTION_MARKERS,
|
||||
type SigningSystemSnapshot,
|
||||
} from './signing-fields'
|
||||
|
||||
@@ -45,11 +46,10 @@ const FIELD_REQUIRED_ATTR = 'data-sign-required'
|
||||
const FIELD_RISK_ATTR = 'data-sign-risk'
|
||||
|
||||
/**
|
||||
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
|
||||
* Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、
|
||||
* 只剩下码位,所以这些也要一并识别。
|
||||
* 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields):
|
||||
* 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。
|
||||
*/
|
||||
const OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE'
|
||||
const OPTION_MARKERS = SIGNING_OPTION_MARKERS
|
||||
/** split 用:捕获组保证分隔符本身保留在结果里 */
|
||||
const OPTION_SPLIT_PATTERN = new RegExp(`([${OPTION_MARKERS}])`)
|
||||
/** match 用(带 g 只用于 String.match,不参与 RegExp.test,避免 lastIndex 副作用) */
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 编辑器内的「占位符 / 勾选框」可视化标记。
|
||||
*
|
||||
* 问题:占位符({患者姓名})与勾选框(□)在富文本编辑器里就是一段普通文字,
|
||||
* 和正文长得一模一样。科室在长文书里看不出哪些内容是要在签署时采集的,
|
||||
* 漏一个签名位往往要等到发起签署、甚至签署完才发现。
|
||||
*
|
||||
* 方案:**不改数据模型,只改渲染**。
|
||||
* 用 wangEditor(Slate)的 decorate 把文本节点里的占位符 / 勾选框切成独立的
|
||||
* 叶子节点(leaf),再用 registerRenderStyle 给这些叶子挂上类名与 data-*,
|
||||
* 由 EditorWangPane 的样式渲染成带类型角标的「字段胶囊」。
|
||||
*
|
||||
* 为什么不用自定义 Slate 元素(<span data-field>)承载:
|
||||
* ① 纯文本占位符可以无损往返,存量模板零迁移;换成结构化元素就得在载入时
|
||||
* 把已有正文里的占位符批量改写一遍,是对既有文书内容的变换,风险与收益不成比例;
|
||||
* ② 渲染管线(utils/signing-document)与填写项统计(collectFieldDefs)都按
|
||||
* {…} 文本解析,改模型要同步改签署侧与统计侧,回归面被放大;
|
||||
* ③ decorate 是只读投影:它不改变 editor.getHtml() 的输出,
|
||||
* 保存 / 回填 / 签署全链路一行都不用动。最坏情况只是"显示得不好看",
|
||||
* 不会丢内容、不会写出编辑器读不回来的 HTML。
|
||||
*
|
||||
* 一条硬约束:叶子里的原文必须原样留在 DOM 中(大括号不能 display:none / font-size:0)。
|
||||
* Slate 用 Range.cloneContents().textContent.length 反算光标偏移(见 slate 的 toSlatePoint),
|
||||
* 把字符藏起来会让光标落点整体算错;而且「在胶囊左边缘按退格」会静默删掉看不见的字符。
|
||||
* 所以大括号只是被缩小、调淡,仍然可见可选——顺带也让科室能看见并手敲这个书写格式。
|
||||
*/
|
||||
|
||||
import { Boot } from '@wangeditor/editor'
|
||||
import type { IEditorConfig } from '@wangeditor/editor'
|
||||
|
||||
import {
|
||||
createFieldTokenPattern,
|
||||
resolveFieldByLabel,
|
||||
SIGNING_FIELD_KIND_LABELS,
|
||||
SIGNING_OPTION_MARKERS,
|
||||
} from './signing-fields'
|
||||
|
||||
type DecorateConfig = NonNullable<IEditorConfig['decorate']>
|
||||
type DecoratedEntry = Parameters<DecorateConfig>[0]
|
||||
type DecoratedRange = ReturnType<DecorateConfig>[number]
|
||||
|
||||
type RenderStyleConfig = Parameters<typeof Boot.registerRenderStyle>[0]
|
||||
type StyledNode = Parameters<RenderStyleConfig>[0]
|
||||
type StyledVNode = Parameters<RenderStyleConfig>[1]
|
||||
|
||||
/**
|
||||
* 挂在文本叶子上、供渲染钩子读取的标记。
|
||||
* 统一加 sign 前缀,避免与 wangEditor / Slate 自身的 leaf 字段撞名
|
||||
* (叶子是原文本节点展开出来的,标点、bold 等既有属性都会一并带过来)。
|
||||
*/
|
||||
interface SigningLeafMark {
|
||||
/** field = 占位符;box = 勾选框 */
|
||||
signMarker?: 'field' | 'box'
|
||||
/** 占位符被切成的三段:{ / 标签 / } */
|
||||
signPart?: 'open' | 'label' | 'close'
|
||||
signKind?: string
|
||||
/** 控件类型的中文名,渲染在胶囊右侧 */
|
||||
signTag?: string
|
||||
signRequired?: boolean
|
||||
/** 原文,如 {患者姓名},用于悬浮提示 */
|
||||
signRaw?: string
|
||||
}
|
||||
|
||||
type SigningRange = DecoratedRange & SigningLeafMark
|
||||
|
||||
/**
|
||||
* decorate 回调:把一段文本里的占位符与勾选框切成独立叶子。
|
||||
*
|
||||
* 占位符切成三段而不是一段,是为了让大括号能单独缩小、调淡——
|
||||
* 一段文本节点没法只给其中两个字符换样式。
|
||||
*/
|
||||
export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedRange[] {
|
||||
const { text } = node as { text?: string }
|
||||
|
||||
if (typeof text !== 'string' || !text) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: SigningRange[] = []
|
||||
const tokenSpans: Array<{ start: number; end: number }> = []
|
||||
const pattern = createFieldTokenPattern()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const label = (match[1] ?? '').trim()
|
||||
const start = match.index
|
||||
const end = pattern.lastIndex
|
||||
|
||||
// {x} 至少三个字符,否则中间切不出标签叶子,反而会造出零长度区间
|
||||
if (!label || end - start < 3) {
|
||||
continue
|
||||
}
|
||||
|
||||
const field = resolveFieldByLabel(label)
|
||||
const mark: SigningLeafMark = {
|
||||
signMarker: 'field',
|
||||
signKind: field.kind,
|
||||
signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本',
|
||||
signRequired: field.required,
|
||||
signRaw: text.slice(start, end),
|
||||
}
|
||||
|
||||
tokenSpans.push({ start, end })
|
||||
ranges.push(
|
||||
{
|
||||
anchor: { path, offset: start },
|
||||
focus: { path, offset: start + 1 },
|
||||
...mark,
|
||||
signPart: 'open',
|
||||
},
|
||||
{
|
||||
anchor: { path, offset: start + 1 },
|
||||
focus: { path, offset: end - 1 },
|
||||
...mark,
|
||||
signPart: 'label',
|
||||
},
|
||||
{
|
||||
anchor: { path, offset: end - 1 },
|
||||
focus: { path, offset: end },
|
||||
...mark,
|
||||
signPart: 'close',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const boxPattern = new RegExp(`[${SIGNING_OPTION_MARKERS}]`, 'g')
|
||||
|
||||
while ((match = boxPattern.exec(text)) !== null) {
|
||||
const index = match.index
|
||||
|
||||
// 占位符标签里如果写了方框(如 {□}),它是标签文字的一部分,不能再标成勾选胶囊
|
||||
if (tokenSpans.some((span) => index >= span.start && index < span.end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
anchor: { path, offset: index },
|
||||
focus: { path, offset: index + 1 },
|
||||
signMarker: 'box',
|
||||
})
|
||||
}
|
||||
|
||||
// Slate 的 Text.decorations 是"按数组顺序逐个切分叶子",区间乱序会让切分结果错位。
|
||||
// 占位符区间与勾选框区间在本函数里是两轮扫描产出的,必须显式排序。
|
||||
return ranges.sort((a, b) => a.anchor.offset - b.anchor.offset || a.focus.offset - b.focus.offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染钩子:给带标记的叶子挂上类名与 data-*,样式见 EditorWangPane。
|
||||
*
|
||||
* 直接改传入的 vnode.data 而不新建 vnode:新建需要 snabbdom 的 h,
|
||||
* 而 snabbdom 是 wangEditor 的内部依赖,不该由业务代码直接引用。
|
||||
* data.props / data.dataset 是 vnode 的规范字段(见 core 的 normalizeVnodeData),
|
||||
* 改它们不会被后续的归一化覆盖。
|
||||
*/
|
||||
function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode {
|
||||
const mark = node as unknown as SigningLeafMark
|
||||
|
||||
if (!mark.signMarker) {
|
||||
return vnode
|
||||
}
|
||||
|
||||
const data = (vnode.data ??= {})
|
||||
const props = (data.props ??= {})
|
||||
const dataset = (data.dataset ??= {})
|
||||
|
||||
dataset.signMarker = mark.signMarker
|
||||
|
||||
if (mark.signMarker === 'box') {
|
||||
props.className = 'ms-sign-box'
|
||||
props.title = '勾选项:签署时转为可勾选控件'
|
||||
return vnode
|
||||
}
|
||||
|
||||
dataset.signKind = mark.signKind ?? 'text'
|
||||
dataset.signPart = mark.signPart ?? 'label'
|
||||
|
||||
if (mark.signPart === 'label') {
|
||||
const kind = mark.signTag ?? '文本'
|
||||
props.className = 'ms-sign-chip'
|
||||
props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),签署时按患者与就诊信息自动填充`
|
||||
|
||||
if (mark.signTag) {
|
||||
dataset.signTag = mark.signTag
|
||||
}
|
||||
|
||||
if (mark.signRequired) {
|
||||
dataset.signRequired = 'true'
|
||||
}
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
// 大括号只缩小调淡,不移除:见文件头的"硬约束"。
|
||||
// 左右分开给类名,样式里才能各收各的边距(全角括号两侧留白很宽)
|
||||
props.className = `ms-sign-brace ms-sign-brace--${mark.signPart ?? 'open'}`
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
* 注册编辑器内的占位符 / 勾选框渲染钩子。
|
||||
*
|
||||
* wangEditor 的渲染钩子是全局注册表,重复注册会让同一个叶子被处理多次;
|
||||
* 用模块级开关挡住。HMR 重新求值该模块时开关会重置,但重复处理是幂等的,不会出错。
|
||||
*/
|
||||
export function registerSigningEditorMarks(): void {
|
||||
if (registered) {
|
||||
return
|
||||
}
|
||||
|
||||
registered = true
|
||||
Boot.registerRenderStyle(renderSigningMarker)
|
||||
}
|
||||
@@ -20,6 +20,26 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo
|
||||
export const FIELD_TOKEN_OPEN = '{'
|
||||
export const FIELD_TOKEN_CLOSE = '}'
|
||||
|
||||
/**
|
||||
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
|
||||
* Word「插入 → 符号」里的方框多为私有区字符(U+F0A8 等),粘贴后字体信息丢失、
|
||||
* 只剩下码位,所以这些也要一并识别。
|
||||
*
|
||||
* 放在这里而不是各端各写一份:签署端(utils/signing-document)靠它把方框转成
|
||||
* 可勾选控件,编辑器(utils/signing-editor-marks)靠它把方框标成勾选胶囊,
|
||||
* 两边识别的字符集必须完全一致,否则会出现"编辑器标了、签署时没转"的静默失配。
|
||||
*/
|
||||
export const SIGNING_OPTION_MARKERS = '□☐▢◻❑❒\uF0A8\uF0FE'
|
||||
|
||||
/** 填写项控件类型的中文名,编辑器、占位符检查面板、签署端提示共用一份口径。 */
|
||||
export const SIGNING_FIELD_KIND_LABELS: Record<SigningFieldKind, string> = {
|
||||
text: '文本',
|
||||
date: '日期',
|
||||
choice: '单选',
|
||||
checkbox: '勾选',
|
||||
signature: '签名',
|
||||
}
|
||||
|
||||
const FIELD_TOKEN_SOURCE = `${FIELD_TOKEN_OPEN}([^${FIELD_TOKEN_OPEN}${FIELD_TOKEN_CLOSE}]{1,40})${FIELD_TOKEN_CLOSE}`
|
||||
|
||||
/** /g 正则有 lastIndex 副作用,每次解析都新建一个,避免跨调用互相干扰。 */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildFieldToken,
|
||||
collectFieldDefs,
|
||||
findDuplicateFields,
|
||||
SIGNING_FIELD_KIND_LABELS,
|
||||
SIGNING_FIELD_PRESETS,
|
||||
} from '@/utils/signing-fields'
|
||||
|
||||
@@ -138,13 +139,8 @@ const placeholderHints = computed(() => {
|
||||
return hints
|
||||
})
|
||||
|
||||
const kindLabels: Record<string, string> = {
|
||||
text: '文本',
|
||||
date: '日期',
|
||||
choice: '单选',
|
||||
checkbox: '勾选',
|
||||
signature: '签名',
|
||||
}
|
||||
// 类型中文名与编辑器胶囊、签署端提示共用一份(utils/signing-fields)
|
||||
const kindLabels = SIGNING_FIELD_KIND_LABELS
|
||||
|
||||
async function handleDocxChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -349,6 +345,7 @@ function save() {
|
||||
<span v-else class="docx-hint">
|
||||
建议从 Word/WPS
|
||||
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
|
||||
占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。
|
||||
</span>
|
||||
</div>
|
||||
<p class="docx-header-note">
|
||||
|
||||
@@ -6,6 +6,11 @@ import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
||||
|
||||
import { normalizePastedHtml } from '@/utils/rich-text'
|
||||
import { decorateSigningMarkers, registerSigningEditorMarks } from '@/utils/signing-editor-marks'
|
||||
|
||||
// 渲染钩子是 wangEditor 的全局注册表,必须在编辑器创建前挂上,
|
||||
// 否则首帧渲染出来的占位符/勾选框还是普通文字(要等下一次重渲染才变胶囊)。
|
||||
registerSigningEditorMarks()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
@@ -58,6 +63,10 @@ const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
||||
// 把 {患者姓名} 这类占位符与 □ 勾选框在编辑器里渲染成可辨识的胶囊,
|
||||
// 而不是和正文一样的裸文字。只影响显示,不改 getHtml() 的输出,
|
||||
// 保存 / 签署链路口径不变,细节见 utils/signing-editor-marks。
|
||||
decorate: decorateSigningMarkers,
|
||||
// 接管粘贴:优先使用剪贴板中的富 HTML(保留居中、加粗、表格等结构),
|
||||
// 但先做归一化——Word 表格的绝对列宽与 nowrap 会让整表在窄容器里被裁切;
|
||||
// 纯文本粘贴(无富 HTML)时返回 true 走默认处理。
|
||||
@@ -194,4 +203,81 @@ onBeforeUnmount(() => {
|
||||
.pane-content :deep(td p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- 占位符 / 勾选框胶囊 ----
|
||||
utils/signing-editor-marks 把 {患者姓名} 切成「{ / 患者姓名 / }」三段叶子、
|
||||
把 □ 切成一 段叶子,这里负责把它们渲染成一眼能认出是"待签署内容"的形态。 */
|
||||
|
||||
/* 大括号:只缩小调淡,不移除。这两个字符必须留在 DOM 里参与光标偏移计算,
|
||||
隐藏它们会让点击胶囊时的落点整体算错,也会让退格静默删掉看不见的字符 */
|
||||
.pane-content :deep(.ms-sign-brace) {
|
||||
color: var(--brand-m);
|
||||
font-size: 0.78em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 全角大括号自带很宽的边距,负外边距把它收紧到贴着胶囊,读起来才像一对括号 */
|
||||
.pane-content :deep(.ms-sign-brace--open) {
|
||||
margin-right: -0.18em;
|
||||
}
|
||||
|
||||
.pane-content :deep(.ms-sign-brace--close) {
|
||||
margin-left: -0.18em;
|
||||
}
|
||||
|
||||
/* 占位符主体:带类型角标的胶囊 */
|
||||
.pane-content :deep(.ms-sign-chip) {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
color: var(--brand-d);
|
||||
font-size: 0.92em;
|
||||
font-weight: 700;
|
||||
line-height: 1.65;
|
||||
background: var(--brand-l);
|
||||
border: 1px solid #bcd9e3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么 */
|
||||
.pane-content :deep(.ms-sign-chip)::after {
|
||||
padding-left: 5px;
|
||||
margin-left: 5px;
|
||||
color: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
content: attr(data-sign-tag);
|
||||
border-left: 1px solid rgb(0 0 0 / 10%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 必填项:角标转红并补一个星号,和下方「占位符检查」面板的口径一致 */
|
||||
.pane-content :deep(.ms-sign-chip[data-sign-required='true'])::after {
|
||||
color: var(--err);
|
||||
content: attr(data-sign-tag) ' *';
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 签名位是最不能漏的一项:与「插入签署占位符」按钮里的橙色区分保持一致 */
|
||||
.pane-content :deep(.ms-sign-chip[data-sign-kind='signature']) {
|
||||
color: #a96900;
|
||||
background: #fff4e2;
|
||||
border-color: #f0d5a8;
|
||||
}
|
||||
|
||||
.pane-content :deep(.ms-sign-brace[data-sign-kind='signature']) {
|
||||
color: #c08a3e;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 勾选框:签署时会转成可勾选控件,这里给它一个"框"的观感。
|
||||
只上底色不加边框——□ 本身就是个方框,外面再套一圈边框会变成"框中框" */
|
||||
.pane-content :deep(.ms-sign-box) {
|
||||
padding: 0 4px;
|
||||
color: var(--brand);
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
background: var(--brand-l);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user