From 38ff67a6a8979638af05db120345315d79041d4c Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 17 Apr 2026 09:47:21 +0800 Subject: [PATCH] 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
when adjacent to smart-field-wrapper
- Update experience record (#14)
---
src/index.css | 1 +
src/pages/TemplateManage.tsx | 57 ++++++--
src/utils/defaultContent.ts | 10 +-
工程分析/实现方案-2026-04-17-09-36-07.md | 162 +++++++++++++++++++++++
工程分析/测试方案-2026-04-17-09-36-07.md | 77 +++++++++++
工程分析/经验记录.md | 26 +++-
工程分析/需求分析-2026-04-17-09-36-07.md | 45 +++++++
7 files changed, 359 insertions(+), 19 deletions(-)
create mode 100644 工程分析/实现方案-2026-04-17-09-36-07.md
create mode 100644 工程分析/测试方案-2026-04-17-09-36-07.md
create mode 100644 工程分析/需求分析-2026-04-17-09-36-07.md
diff --git a/src/index.css b/src/index.css
index 0f76ca8..73a5363 100644
--- a/src/index.css
+++ b/src/index.css
@@ -102,6 +102,7 @@
align-items: center;
margin: 0 2px;
vertical-align: text-bottom;
+ white-space: nowrap;
}
.smart-field-wrapper .field-label {
color: #64748b;
diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx
index 0e41cce..c7680c7 100644
--- a/src/pages/TemplateManage.tsx
+++ b/src/pages/TemplateManage.tsx
@@ -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('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('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 = `
-
-
-
-
- `;
+ const html = ` `;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};
diff --git a/src/utils/defaultContent.ts b/src/utils/defaultContent.ts
index 447fdd7..a798d74 100644
--- a/src/utils/defaultContent.ts
+++ b/src/utils/defaultContent.ts
@@ -1,12 +1,4 @@
-const smartField = (key: string) => `
-
-
-
-
-`;
+const smartField = (key: string) => ` `;
export const defaultReportContent = `
diff --git a/工程分析/实现方案-2026-04-17-09-36-07.md b/工程分析/实现方案-2026-04-17-09-36-07.md
new file mode 100644
index 0000000..5dffd97
--- /dev/null
+++ b/工程分析/实现方案-2026-04-17-09-36-07.md
@@ -0,0 +1,162 @@
+# 实现方案 — 2026-04-17-09-36-07
+
+## 根因分析
+
+1. **多余空格**:`TemplateManage.tsx` 的 `insertSmartField` 函数在 HTML 字符串末尾追加了 ` `,这是导致字段后跟随大量空白的主要原因。
+2. **异常换行**:`inline-block` 元素默认会在边界处根据容器宽度自动换行;`contenteditable="false"` 节点在行尾时,浏览器可能将其视为独立的渲染单元进行换行。
+3. **Backspace 误删整行**:当光标位于 `contenteditable="false"` 的内联元素之后时,Webkit/Blink 内核的默认行为无法正确删除该节点,而是向上寻找到父级 ` ` 并将其删除。这是 `contentEditable` 的经典 Bug。
+4. **默认模板未预置**:`defaultContent.ts` 中的第一行仍使用红色纯文本占位符,没有使用 `smartField()` 函数生成智能控件。
+
+## 修改文件清单
+
+| 文件 | 修改类型 | 说明 |
+|------|---------|------|
+| `src/pages/TemplateManage.tsx` | 修改 | 优化 `insertSmartField` HTML(移除 ` `、压缩为一行);增加 `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 = `
+
+
+
+
+ `;
+ document.execCommand('insertHTML', false, html);
+ editorRef.current?.focus();
+ };
+```
+
+**修改为:**
+```tsx
+ const insertSmartField = (field: FormField) => {
+ editorRef.current?.focus();
+ const html = ``;
+ document.execCommand('insertHTML', false, html);
+ editorRef.current?.focus();
+ };
+```
+
+**改动点**:
+- 移除末尾的 ` `。
+- 将多行模板字符串压缩为一行,消除源码换行被渲染为空格的问题。
+- 在 `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
+
+ 姓名:${smartField('patientName')}
+ 性别:${smartField('patientGender')}
+ 年龄:${smartField('patientAge')}
+ 科别:${smartField('department')}
+ 床号:${smartField('bedNumber')}
+ 住院号:${smartField('hospitalId')}
+ ` **完好保留** |
+| 3.4 | 再次按 Backspace | 正常删除方格前方的文字字符(如冒号或文字),不会删行 |
+
+### 用例 4:Delete 键同样受保护
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|--------- |
+| 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 结构和键盘事件响应。
+
+---
+
+**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md
index 7b57f38..5ef3ae8 100644
--- a/工程分析/经验记录.md
+++ b/工程分析/经验记录.md
@@ -120,7 +120,7 @@
2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
**C. 解决问题方案**
-1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。
+1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draf t 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
@@ -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` 插入智能字段后,字段后方会出现一个可见的空格(由 ` ` 和多行模板字符串中的换行/缩进空白引起)。
+2. 在 `contenteditable` 中,光标位于 ` ` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace,WebKit 内核会直接删除整段 ` ` 而不是仅删除字段节点。
+3. `defaultContent.ts` 中的 `smartField` 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。
+
+**B. 产生问题原因**
+1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 ` `,导致插入后字段与后续文字之间总有一个不必要的空格。
+2. `contenteditable="false"` 的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。
+3. `defaultContent.ts` 中的 `smartField` 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 `smartField` 调用前后都引入了额外的空白文本节点。
+
+**C. 解决问题方案**
+1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 ` `。
+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"` 的内联控件,若放置在块级边界(如 ` ` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。
+- 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。
diff --git a/工程分析/需求分析-2026-04-17-09-36-07.md b/工程分析/需求分析-2026-04-17-09-36-07.md
new file mode 100644
index 0000000..852bfc4
--- /dev/null
+++ b/工程分析/需求分析-2026-04-17-09-36-07.md
@@ -0,0 +1,45 @@
+# 需求分析 — 2026-04-17-09-36-07
+
+## 原始需求摘要
+
+全面优化 `TemplateManage` 模板编辑器的交互体验,解决以下三个核心问题:
+1. 消除从右侧字段库插入智能字段后产生的多余空格与排版松散问题。
+2. 修复在行尾插入字段时出现的异常换行,以及按 Backspace 删除字段时误删整行的底层 Bug。
+3. 将常用基本信息字段(姓名、性别、年龄等)直接预置到系统默认模板中,实现开箱即用。
+
+## 需求拆解
+
+### 功能点
+
+1. **消除插入字段后的多余空格**
+ - 问题:`insertSmartField` 生成的 HTML 字符串末尾带有 ` `,且可能包含换行符,导致字段后跟随大量不可见空格。
+ - 方案:移除 ` `,将 HTML 压缩为一行;调整 `margin` 为更小的值(如 `0 2px`)。
+
+2. **修复异常换行与 Backspace 误删整行**
+ - 问题 2a(异常换行):当在"住院号:"等行尾插入 `smart-field-wrapper` 后,即使空间足够,字段也可能被挤到下一行。这与 `inline-block` 的默认换行行为以及 `contenteditable="false"` 节点的边界处理有关。
+ - 问题 2b(Backspace 误删):光标位于 `contenteditable="false"` 的字段节点之后时,浏览器内核(Webkit/Blink)无法正确选中该不可编辑节点,会向上寻址删除其父级 ` ` 节点,导致整行被删。
+ - 方案:
+ - 给 `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`(可选) |
+
+## 待确认问题
+
+无。用户已提供详细的问题现象和解决方案思路,可直接进入实现方案。