fix: smart field spacing/line-break in TemplateManage and default template

- Compress insertSmartField HTML to single-line, remove trailing  
- Compress smartField helper in defaultContent.ts to single-line
- Add white-space: nowrap to .smart-field-wrapper (CSS + inline)
- Add keydown interceptor in TemplateManage to prevent Backspace/Delete
  from removing whole <p> when adjacent to smart-field-wrapper
- Update experience record (#14)
This commit is contained in:
2026-04-17 09:47:21 +08:00
parent 2a4934e7c4
commit 38ff67a6a8
7 changed files with 359 additions and 19 deletions

View File

@@ -102,6 +102,7 @@
align-items: center;
margin: 0 2px;
vertical-align: text-bottom;
white-space: nowrap;
}
.smart-field-wrapper .field-label {
color: #64748b;

View File

@@ -162,6 +162,53 @@ export default function TemplateManage() {
};
}, [currentTemplateId, currentUser]);
// Intercept Backspace/Delete next to smart fields to avoid whole-line deletion
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const offset = range.startOffset;
if (e.key === 'Backspace' && offset === 0) {
const prev = node.previousSibling;
if (prev && prev.nodeType === Node.ELEMENT_NODE && (prev as Element).classList?.contains('smart-field-wrapper')) {
e.preventDefault();
prev.remove();
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
}
} else if (e.key === 'Delete' && offset === (node.textContent?.length || 0)) {
const next = node.nextSibling;
if (next && next.nodeType === Node.ELEMENT_NODE && (next as Element).classList?.contains('smart-field-wrapper')) {
e.preventDefault();
next.remove();
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
}
}
};
editor.addEventListener('keydown', handleKeyDown, true);
return () => {
editor.removeEventListener('keydown', handleKeyDown, true);
};
}, [currentTemplateId]);
const execCmd = (command: string, value: string | undefined = undefined) => {
editorRef.current?.focus();
document.execCommand(command, false, value);
@@ -170,15 +217,7 @@ export default function TemplateManage() {
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
const html = `
<span class="smart-field-wrapper" contenteditable="false">
<span class="field-value"
data-bind="${field.key}"
contenteditable="true"
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
</span>
</span>&nbsp;
`;
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};

View File

@@ -1,12 +1,4 @@
const smartField = (key: string) => `
<span class="smart-field-wrapper" contenteditable="false">
<span class="field-value"
data-bind="${key}"
contenteditable="true"
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
</span>
</span>
`;
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;"><span class="field-value" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;"> </span></span>`;
export const defaultReportContent = `
<!-- 医院Logo -->

View File

@@ -0,0 +1,162 @@
# 实现方案 — 2026-04-17-09-36-07
## 根因分析
1. **多余空格**`TemplateManage.tsx``insertSmartField` 函数在 HTML 字符串末尾追加了 `&nbsp;`,这是导致字段后跟随大量空白的主要原因。
2. **异常换行**`inline-block` 元素默认会在边界处根据容器宽度自动换行;`contenteditable="false"` 节点在行尾时,浏览器可能将其视为独立的渲染单元进行换行。
3. **Backspace 误删整行**:当光标位于 `contenteditable="false"` 的内联元素之后时Webkit/Blink 内核的默认行为无法正确删除该节点,而是向上寻找到父级 `<p>` 并将其删除。这是 `contentEditable` 的经典 Bug。
4. **默认模板未预置**`defaultContent.ts` 中的第一行仍使用红色纯文本占位符,没有使用 `smartField()` 函数生成智能控件。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/TemplateManage.tsx` | 修改 | 优化 `insertSmartField` HTML移除 `&nbsp;`、压缩为一行);增加 `keydown` 事件拦截,保护 `.smart-field-wrapper` 不被 Backspace 误删 |
| `src/utils/defaultContent.ts` | 修改 | 将默认模板第一行的红色占位符替换为预置的智能字段控件 |
| `src/index.css` | 修改(可选) | 给 `.smart-field-wrapper` 增加 `white-space: nowrap` |
---
## 具体代码变更
### 变更 1`src/pages/TemplateManage.tsx` — 优化插入 HTML
**当前代码(约第 159-173 行):**
```tsx
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
const html = `
<span class="smart-field-wrapper" contenteditable="false">
<span class="field-value"
data-bind="${field.key}"
contenteditable="true"
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
</span>
</span>&nbsp;
`;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};
```
**修改为:**
```tsx
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
const html = `<span class="smart-field-wrapper" contenteditable="false"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em; white-space: nowrap;"></span></span>`;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};
```
**改动点**
- 移除末尾的 `&nbsp;`
- 将多行模板字符串压缩为一行,消除源码换行被渲染为空格的问题。
-`field-value` 的内联样式中增加 `white-space: nowrap;`
### 变更 2`src/pages/TemplateManage.tsx` — 拦截 Backspace/Delete 防止误删整行
在现有的 `useEffect`(用于监听编辑器 click 事件)附近,新增一个 `useEffect` 监听 `keydown`
```tsx
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if (!range.collapsed) return;
const container = range.startContainer;
const offset = range.startOffset;
// Find the node immediately before the cursor
let prevNode: Node | null = null;
if (container.nodeType === Node.TEXT_NODE) {
if (offset === 0) {
prevNode = container.previousSibling;
}
} else if (container.nodeType === Node.ELEMENT_NODE) {
prevNode = (container as Element).childNodes[offset - 1] || null;
}
if (!prevNode) return;
// If the previous node is our smart field wrapper, remove it manually
const fieldWrapper = prevNode.nodeType === Node.ELEMENT_NODE
? (prevNode as Element).closest('.smart-field-wrapper')
: prevNode.parentElement?.closest('.smart-field-wrapper');
if (fieldWrapper && editorRef.current?.contains(fieldWrapper)) {
e.preventDefault();
e.stopPropagation();
fieldWrapper.remove();
}
};
const editor = editorRef.current;
if (editor) {
editor.addEventListener('keydown', handleKeyDown, true);
}
return () => {
if (editor) {
editor.removeEventListener('keydown', handleKeyDown, true);
}
};
}, [currentTemplateId]);
```
> 注意:此逻辑与 `ReportEditor.tsx` 中保护 `.image-placeholder` 不被误删的 `handleKeyDown` 思路一致。
### 变更 3`src/utils/defaultContent.ts` — 默认模板预置字段控件
**当前第一行(约第 15-23 行):**
```html
<div class="template-info-section">
<p style="font-family: SimSun;">
姓名:${smartField('patientName')}
性别:${smartField('patientGender')}
年龄:${smartField('patientAge')}
科别:${smartField('department')}
床号:${smartField('bedNumber')}
住院号:${smartField('hospitalId')}
</p>
```
这部分已经在上一版中被替换为 `smartField()`,但需要确认是否末尾有空格问题。由于 `smartField()` 函数本身返回的 HTML 不带 `&nbsp;`(且是压缩的一行),`defaultContent.ts` 中的这段代码本身没有问题。
**但**:如果之前的 `smartField()` 定义末尾带有 `&nbsp;`,则需要一并修正。当前 `defaultContent.ts` 中的 `smartField` 定义已经在上一版中被修正为压缩的一行且不带 `&nbsp;`,所以默认模板本身已经符合要求。
**确认结果**`defaultContent.ts` 中的第一行在上一版(`2026-04-17-00-13-09`)中已经替换为 `smartField('patientName')` 等智能控件。**本次只需确保 `smartField` 辅助函数的定义与变更 1 保持一致(移除 `&nbsp;`、压缩为一行、增加 `white-space: nowrap`)即可。**
### 变更 4`src/index.css` — 增加 `white-space: nowrap`
`.smart-field-wrapper` 的样式中增加:
```css
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 2px;
vertical-align: text-bottom;
white-space: nowrap;
}
```
> 由于 `field-value` 已经通过内联样式设置了 `white-space: nowrap`,给外层 `.smart-field-wrapper` 增加此属性可作为双重保险。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 移除 `&nbsp;` 后,字段与前/后文本之间没有间隔,显得拥挤 | 低 | `margin: 0 2px` 已经提供了 2px 的左右间距,视觉上足够紧凑 |
| `keydown` 拦截可能影响编辑器其他正常删除操作 | 低 | 拦截逻辑严格限定在光标前一个节点为 `.smart-field-wrapper` 时才生效,其他情况正常放行 |
| 老模板中已插入的字段仍带有 `&nbsp;` | 低 | 老模板中的字段只是带有一个额外的空格,不影响功能;用户可手动删除重插 |
## 回滚策略
本次修改范围极小,仅调整 `insertSmartField` 的 HTML 输出和增加一个 `keydown` 事件监听。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View File

@@ -0,0 +1,77 @@
# 测试方案 — 2026-04-17-09-36-07
## 测试目标
验证 `TemplateManage` 中插入智能字段后的空格消除、Backspace 删除保护、异常换行修复,以及默认模板预置字段控件的正确性。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
- 测试页面:`/template-manage`
---
## 测试用例设计
### 用例 1插入字段后无多余空格
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/template-manage` | 默认模板加载,第一行已预置姓名/性别/年龄等智能字段方格 |
| 1.2 | 将光标定位到编辑器任意位置,点击字段库中的「手术名称」按钮 | 编辑器中插入一个蓝色边框的方格,**方格与后方文字之间没有明显的大片空白** |
| 1.3 | 右键检查插入元素的 DOM | HTML 中没有 `&nbsp;``smart-field-wrapper` 与前后文本节点紧密相连 |
### 用例 2行尾插入字段不异常换行
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 2.1 | 在第一行"住院号:"的方格后点击,使光标位于行尾 | — |
| 2.2 | 点击字段库插入「手术日期」 | 新插入的方格**紧跟在住院号方格后面**,不会跳到下一行(只要一行空间足够) |
### 用例 3Backspace 删除字段不误删整行
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 3.1 | 在编辑器中插入一个「手术名称」字段 | 方格正常插入 |
| 3.2 | 将光标定位到该方格的**紧右侧**(点击方格后方的文字前) | 光标闪烁在方格之后 |
| 3.3 | 按下键盘 **Backspace** 键 | **仅删除该「手术名称」方格**,方格前方的文字(如"手术名称:")和整行 `<p>` **完好保留** |
| 3.4 | 再次按 Backspace | 正常删除方格前方的文字字符(如冒号或文字),不会删行 |
### 用例 4Delete 键同样受保护
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 4.1 | 将光标定位到「手术名称」方格的**紧左侧**(点击方格前方的文字后) | 光标闪烁在方格之前 |
| 4.2 | 按下键盘 **Delete** 键 | **仅删除该「手术名称」方格**,整行内容保留 |
### 用例 5默认模板预置字段验证
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 5.1 | 进入 `/template-manage`,观察默认模板第一行 | 姓名、性别、年龄、科别、床号、住院号后面**直接就是可填写的蓝色边框方格**,没有红色 `*姓名*` 纯文本占位符 |
| 5.2 | 新建一个模板 | 新模板内容中也包含第一行的预置智能字段 |
### 用例 6类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 6.1 | 在项目根目录执行 `npm run lint` | 无 TypeScript 编译错误 |
---
## 验收标准
- [ ] 插入字段后,方格与前后文字之间没有多余空格。
- [ ] 行尾插入字段时,空间足够则不会异常跳到下一行。
- [ ] 按 Backspace/Delete 删除字段时,仅删除该字段节点,不会误删整行。
- [ ] 默认模板第一行已预置姓名、性别、年龄等智能字段方格。
- [ ] `npm run lint` 通过。
## 测试方式
手工浏览器验证,结合 DevTools 观察 DOM 结构和键盘事件响应。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -342,3 +342,27 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。
- 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"Config-Driven UI降低后续维护成本。
- 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。
---
## 记录 14智能字段插入间距修复与 Backspace 防误删
**A. 具体问题**
1. `TemplateManage.tsx` 中使用 `insertSmartField` 插入智能字段后,字段后方会出现一个可见的空格(由 `&nbsp;` 和多行模板字符串中的换行/缩进空白引起)。
2. 在 `contenteditable` 中,光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 BackspaceWebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。
3. `defaultContent.ts` 中的 `smartField` 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。
**B. 产生问题原因**
1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 `&nbsp;`,导致插入后字段与后续文字之间总有一个不必要的空格。
2. `contenteditable="false"` 的 inline 元素处于行边界时WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。
3. `defaultContent.ts` 中的 `smartField` 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 `smartField` 调用前后都引入了额外的空白文本节点。
**C. 解决问题方案**
1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML移除所有无意义的换行和缩进并去掉尾部的 `&nbsp;`。
2. **防止内部折行**:给 `.smart-field-wrapper` 增加 `white-space: nowrap;`(内联样式 + CSS 类双保险),确保标签和输入框不会在行中间被拆开。
3. **拦截 Backspace/Delete**:在 `TemplateManage.tsx` 的编辑器上增加 `keydown` 事件监听capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace或光标在文本节点末尾且后一个兄弟节点是 `.smart-field-wrapper` 时按 Delete主动 `preventDefault()` 并手动移除该字段节点,随后同步更新 `localStorage` 中的模板内容。
**D. 后续如何避免问题**
- 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。
- 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `<p>` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。
- 默认模板或任何通过代码生成的 HTML应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。

View File

@@ -0,0 +1,45 @@
# 需求分析 — 2026-04-17-09-36-07
## 原始需求摘要
全面优化 `TemplateManage` 模板编辑器的交互体验,解决以下三个核心问题:
1. 消除从右侧字段库插入智能字段后产生的多余空格与排版松散问题。
2. 修复在行尾插入字段时出现的异常换行,以及按 Backspace 删除字段时误删整行的底层 Bug。
3. 将常用基本信息字段(姓名、性别、年龄等)直接预置到系统默认模板中,实现开箱即用。
## 需求拆解
### 功能点
1. **消除插入字段后的多余空格**
- 问题:`insertSmartField` 生成的 HTML 字符串末尾带有 `&nbsp;`,且可能包含换行符,导致字段后跟随大量不可见空格。
- 方案:移除 `&nbsp;`,将 HTML 压缩为一行;调整 `margin` 为更小的值(如 `0 2px`)。
2. **修复异常换行与 Backspace 误删整行**
- 问题 2a异常换行当在"住院号:"等行尾插入 `smart-field-wrapper` 后,即使空间足够,字段也可能被挤到下一行。这与 `inline-block` 的默认换行行为以及 `contenteditable="false"` 节点的边界处理有关。
- 问题 2bBackspace 误删):光标位于 `contenteditable="false"` 的字段节点之后时浏览器内核Webkit/Blink无法正确选中该不可编辑节点会向上寻址删除其父级 `<p>` 节点,导致整行被删。
- 方案:
-`smart-field-wrapper` 增加 `white-space: nowrap`
-`TemplateManage.tsx` 中增加 `keydown` 事件监听,拦截 Backspace/Delete当光标紧挨着 `.smart-field-wrapper` 时,手动选中并删除该节点,阻止默认行为。
3. **默认模板预置字段控件**
- 问题:当前 `defaultContent.ts` 中第一行是红色纯文本占位符(`*姓名* *性别*...`),用户需要手动在 `TemplateManage` 中逐个替换为智能字段。
- 方案:修改 `defaultContent.ts`,将第一行的纯文本直接替换为 `smartField('patientName')` 等智能控件,使新建模板时即自带可联动的字段方格。
### 非功能点
- 不引入新的依赖。
- 保持 `npm run lint` 通过。
- 保持现有 `ReportEditor` 的双向绑定逻辑不受影响。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertSmartField` HTML 结构、增加 `keydown` 拦截逻辑保护字段节点 |
| `src/utils/defaultContent.ts` | 中 | 默认模板第一行替换为预置的智能字段控件 |
| `src/index.css` | 低 | 给 `.smart-field-wrapper` 增加 `white-space: nowrap`(可选) |
## 待确认问题
无。用户已提供详细的问题现象和解决方案思路,可直接进入实现方案。