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')} +

+``` + +这部分已经在上一版中被替换为 `smartField()`,但需要确认是否末尾有空格问题。由于 `smartField()` 函数本身返回的 HTML 不带 ` `(且是压缩的一行),`defaultContent.ts` 中的这段代码本身没有问题。 + +**但**:如果之前的 `smartField()` 定义末尾带有 ` `,则需要一并修正。当前 `defaultContent.ts` 中的 `smartField` 定义已经在上一版中被修正为压缩的一行且不带 ` `,所以默认模板本身已经符合要求。 + +**确认结果**:`defaultContent.ts` 中的第一行在上一版(`2026-04-17-00-13-09`)中已经替换为 `smartField('patientName')` 等智能控件。**本次只需确保 `smartField` 辅助函数的定义与变更 1 保持一致(移除 ` `、压缩为一行、增加 `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` 增加此属性可作为双重保险。 + +## 风险点 + +| 风险 | 级别 | 应对措施 | +|------|------|---------| +| 移除 ` ` 后,字段与前/后文本之间没有间隔,显得拥挤 | 低 | `margin: 0 2px` 已经提供了 2px 的左右间距,视觉上足够紧凑 | +| `keydown` 拦截可能影响编辑器其他正常删除操作 | 低 | 拦截逻辑严格限定在光标前一个节点为 `.smart-field-wrapper` 时才生效,其他情况正常放行 | +| 老模板中已插入的字段仍带有 ` ` | 低 | 老模板中的字段只是带有一个额外的空格,不影响功能;用户可手动删除重插 | + +## 回滚策略 + +本次修改范围极小,仅调整 `insertSmartField` 的 HTML 输出和增加一个 `keydown` 事件监听。如出现异常,可直接 `git revert` 回滚。 + +--- + +**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。** diff --git a/工程分析/测试方案-2026-04-17-09-36-07.md b/工程分析/测试方案-2026-04-17-09-36-07.md new file mode 100644 index 0000000..dfcf677 --- /dev/null +++ b/工程分析/测试方案-2026-04-17-09-36-07.md @@ -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 中没有 ` `,`smart-field-wrapper` 与前后文本节点紧密相连 | + +### 用例 2:行尾插入字段不异常换行 + +| 步骤 | 操作 | 预期结果 | +|------|------|--------- | +| 2.1 | 在第一行"住院号:"的方格后点击,使光标位于行尾 | — | +| 2.2 | 点击字段库插入「手术日期」 | 新插入的方格**紧跟在住院号方格后面**,不会跳到下一行(只要一行空间足够) | + +### 用例 3:Backspace 删除字段不误删整行 + +| 步骤 | 操作 | 预期结果 | +|------|------|--------- | +| 3.1 | 在编辑器中插入一个「手术名称」字段 | 方格正常插入 | +| 3.2 | 将光标定位到该方格的**紧右侧**(点击方格后方的文字前) | 光标闪烁在方格之后 | +| 3.3 | 按下键盘 **Backspace** 键 | **仅删除该「手术名称」方格**,方格前方的文字(如"手术名称:")和整行 `

` **完好保留** | +| 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`(可选) | + +## 待确认问题 + +无。用户已提供详细的问题现象和解决方案思路,可直接进入实现方案。