feat(signing): 正文手打方框在导入粘贴时转为结构化勾选项
This commit is contained in:
@@ -849,6 +849,167 @@ export function convertCheckboxColumnsToFields(contentHtml: string): string {
|
||||
return changed ? root.innerHTML : contentHtml
|
||||
}
|
||||
|
||||
/** 匹配单个方框符号(SIGNING_OPTION_MARKERS 里的任意一个) */
|
||||
const BOX_TEST_PATTERN = new RegExp(`[${SIGNING_OPTION_MARKERS}]`)
|
||||
/** 方框转出来的勾选项取不到上下文名称时的兜底名称(与工具栏「勾选项」按钮同名) */
|
||||
const DEFAULT_BOX_LABEL = '勾选项'
|
||||
/** 选项名最长取多少字:方框后面若是一整句话,别把整句当成字段名 */
|
||||
const BOX_LABEL_MAX_LENGTH = 20
|
||||
|
||||
/**
|
||||
* 方框后第一段文字(到标点 / 空白为止)就是选项名。
|
||||
*
|
||||
* 与签署渲染期的 replaceOptionMarkers 用**同一条规则** —— 两边认出来的选项名必须一致,
|
||||
* 否则「导入时按一套切分成组、签署渲染时又按另一套切分」会得到对不上的选项。
|
||||
*/
|
||||
function readOptionAfter(text: string, from: number): { label: string; end: number } {
|
||||
const match = /^(\s*)([^\s。,、;:,.;:]*)/.exec(text.slice(from))
|
||||
const leading = match?.[1]?.length ?? 0
|
||||
const raw = match?.[2] ?? ''
|
||||
|
||||
return { label: raw.slice(0, BOX_LABEL_MAX_LENGTH), end: from + leading + raw.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个在本文书里没被占用的勾选项字段键。
|
||||
* 与 allocateCustomFieldId 同款规则:同名的第二处往后编号,两处方框不会共用一份取值。
|
||||
*/
|
||||
function nextBoxField(
|
||||
claimed: Set<string>,
|
||||
label: string,
|
||||
options: string[],
|
||||
multi?: boolean,
|
||||
): SigningFieldDef {
|
||||
let sequence = 1
|
||||
|
||||
while (claimed.has(createCustomField(label, 'checkbox', options, sequence).id)) {
|
||||
sequence += 1
|
||||
}
|
||||
|
||||
const field = createCustomField(label, 'checkbox', options, sequence, multi)
|
||||
claimed.add(field.id)
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
/** 把一个文本节点里的方框换成结构化占位符;没有方框时返回 null,调用方据此跳过替换 */
|
||||
function rewriteBoxesInText(
|
||||
doc: Document,
|
||||
text: string,
|
||||
claimed: Set<string>,
|
||||
): DocumentFragment | null {
|
||||
const pattern = new RegExp(`[${SIGNING_OPTION_MARKERS}]`, 'g')
|
||||
const boxes: Array<{ index: number; end: number; label: string }> = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const option = readOptionAfter(text, pattern.lastIndex)
|
||||
boxes.push({ index: match.index, end: option.end, label: option.label })
|
||||
}
|
||||
|
||||
if (!boxes.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fragment = doc.createDocumentFragment()
|
||||
const first = boxes[0]
|
||||
const last = boxes[boxes.length - 1]
|
||||
const options = boxes.map((box) => box.label).filter(Boolean)
|
||||
|
||||
// 同一处出现多个方框时合成一个勾选组:签署渲染期对这种情况本来就是按「互斥单选」处理的
|
||||
// (convertInlineOptions 数方框个数 > 1 → radio),拆成两个独立勾选框会让患者把
|
||||
// 「是」「否」同时勾上。
|
||||
if (first && last && boxes.length > 1 && options.length > 1) {
|
||||
fragment.appendChild(doc.createTextNode(text.slice(0, first.index)))
|
||||
fragment.appendChild(
|
||||
createFieldElement(doc, nextBoxField(claimed, DEFAULT_BOX_LABEL, options, false)),
|
||||
)
|
||||
fragment.appendChild(doc.createTextNode(text.slice(last.end)))
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
// 单个方框:只换掉方框本身,后面的文字原样留在正文里(「□ 已阅读」→「[勾选框] 已阅读」)
|
||||
let cursor = 0
|
||||
|
||||
boxes.forEach((box) => {
|
||||
fragment.appendChild(doc.createTextNode(text.slice(cursor, box.index)))
|
||||
fragment.appendChild(
|
||||
createFieldElement(doc, nextBoxField(claimed, box.label || DEFAULT_BOX_LABEL, [])),
|
||||
)
|
||||
cursor = box.index + 1
|
||||
})
|
||||
|
||||
fragment.appendChild(doc.createTextNode(text.slice(cursor)))
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* 把正文里科室手打的方框(□ / ☐ / Wingdings 私有区码位,见 SIGNING_OPTION_MARKERS)
|
||||
* 替换成**结构化勾选占位符** —— 与工具栏「勾选项」按钮插出来的是同一种东西。
|
||||
*
|
||||
* 为什么要在导入 / 粘贴时转,而不是留到签署渲染再转(见 signing-document 的 convertInlineOptions):
|
||||
* ① 渲染期那一步只改签署态的外观,编辑层里它始终是一段普通文字 —— 科室看到的是
|
||||
* 「浅蓝小方块」,与插入的绿色胶囊两副长相,也点不开填写项配置;
|
||||
* ② 转成占位符后它就是一个正常的填写项,可以改名称、类型、选项,与手工插入的没有区别。
|
||||
*
|
||||
* 取值规则照抄签署渲染期:方框后第一段文字(到标点为止)就是选项名。于是
|
||||
* - 一块里只有一个方框 → 一个无选项的勾选框,方框被替换、后面的文字留在正文里;
|
||||
* - 一块里有多个方框 → 合成一个勾选组(选项名进 data-options,显式 multi=false)。
|
||||
*
|
||||
* 只处理**同一个文本节点内**的方框:跨节点的方框(如 `<td>□<b>是</b> □否</td>`)罕见,
|
||||
* 要重建多个文本节点才能合并,留给签署渲染期的启发式兜底,不做半吊子的合并。
|
||||
*
|
||||
* 已经结构化的占位符内部不扫:它的标签文字里可能带方框字符(如 {□} 那种旧写法)。
|
||||
*/
|
||||
export function convertInlineBoxesToFields(contentHtml: string): string {
|
||||
if (!contentHtml || !BOX_TEST_PATTERN.test(contentHtml)) {
|
||||
return contentHtml
|
||||
}
|
||||
|
||||
const parsed = new DOMParser().parseFromString(
|
||||
`<div id="${SCAN_ROOT_ID}">${contentHtml}</div>`,
|
||||
'text/html',
|
||||
)
|
||||
const root = parsed.getElementById(SCAN_ROOT_ID)
|
||||
|
||||
if (!root) {
|
||||
return contentHtml
|
||||
}
|
||||
|
||||
const claimed = collectStructuredIdsFrom(root)
|
||||
const walker = parsed.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const targets: Text[] = []
|
||||
let node = walker.nextNode()
|
||||
|
||||
while (node) {
|
||||
const text = node.textContent ?? ''
|
||||
|
||||
// 结构化占位符内部的文字是它自己的标签,不再当方框扫一遍
|
||||
if (BOX_TEST_PATTERN.test(text) && !node.parentElement?.closest(FIELD_ELEMENT_SELECTOR)) {
|
||||
targets.push(node as Text)
|
||||
}
|
||||
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
let changed = false
|
||||
|
||||
targets.forEach((textNode) => {
|
||||
const replacement = rewriteBoxesInText(parsed, textNode.textContent ?? '', claimed)
|
||||
|
||||
if (!replacement) {
|
||||
return
|
||||
}
|
||||
|
||||
textNode.replaceWith(replacement)
|
||||
changed = true
|
||||
})
|
||||
|
||||
return changed ? root.innerHTML : contentHtml
|
||||
}
|
||||
|
||||
/**
|
||||
* 给新插入的自定义文本项分配一个在本文书里唯一的字段键。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user