Sync inserted AI regions immediately

- Track report editor AI regions in React state instead of only scanning contentEditable DOM during render.

- Observe editor AI region mutations and refresh the AI writing target dropdown without requiring page navigation or refresh.

- Select newly inserted AI regions immediately after insertion and keep a live DOM fallback for generation.

- Harden AI region insertion so it still appends the region if execCommand has no active editor selection.

- Escape AI region names before injecting template HTML and add an accessible label for the insert button.

- Add Playwright coverage for inserting an AI region and seeing it immediately in the AI writing dropdown.

- Update report editor, feature, progress, testing, and AGENTS documentation for AI region synchronization.
This commit is contained in:
2026-05-02 04:08:48 +08:00
parent 5a4056d899
commit 7631ae34ce
7 changed files with 101 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
@@ -28,6 +28,30 @@ type AudioWindow = Window & typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
type AiRegionOption = {
id: string;
title: string;
};
const getAiRegionOptions = (root: HTMLElement | null): AiRegionOption[] => {
if (!root) return [];
return Array.from(root.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter((region) => region.id);
};
const areAiRegionOptionsEqual = (a: AiRegionOption[], b: AiRegionOption[]) =>
a.length === b.length && a.every((region, index) => region.id === b[index]?.id && region.title === b[index]?.title);
const escapeHtmlAttribute = (value: string) =>
value
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const getApiErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof ApiError) {
if (error.status === 401) return '登录状态已失效,请重新登录后再保存。';
@@ -87,6 +111,7 @@ export default function ReportEditor() {
const [isGenerating, setIsGenerating] = useState(false);
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
const [aiRegions, setAiRegions] = useState<AiRegionOption[]>([]);
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
const [isListening, setIsListening] = useState(false);
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
@@ -201,6 +226,29 @@ export default function ReportEditor() {
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
const syncAiRegions = useCallback(() => {
const nextRegions = getAiRegionOptions(editorRef.current);
setAiRegions(prev => areAiRegionOptionsEqual(prev, nextRegions) ? prev : nextRegions);
setAiTargetRegion(prev => {
if (nextRegions.length === 0) return 'none';
if (prev !== 'none' && nextRegions.some(region => region.id === prev)) return prev;
return nextRegions[0].id;
});
}, []);
useEffect(() => {
if (!editorRef.current) return;
syncAiRegions();
const observer = new MutationObserver(syncAiRegions);
observer.observe(editorRef.current, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'data-ai-id', 'data-ai-title'],
});
return () => observer.disconnect();
}, [syncAiRegions]);
const updatePageHeight = () => {
if (!editorRef.current) return;
const contentHeight = editorRef.current.scrollHeight;
@@ -862,15 +910,26 @@ export default function ReportEditor() {
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
const regionName = name?.trim();
if (!regionName) return;
const hasExistingRegion = Array.from(editorRef.current?.querySelectorAll('.ai-region') || [])
.some((region) => (region as HTMLElement).getAttribute('data-ai-id') === regionName);
if (hasExistingRegion) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
const safeRegionName = escapeHtmlAttribute(regionName);
const html = `<div class="ai-region" data-ai-id="${safeRegionName}" data-ai-title="${safeRegionName}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${safeRegionName}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
const didInsert = Array.from(editorRef.current?.querySelectorAll('.ai-region') || [])
.some((region) => (region as HTMLElement).getAttribute('data-ai-id') === regionName);
if (!didInsert) {
editorRef.current?.insertAdjacentHTML('beforeend', html);
}
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
syncAiRegions();
setAiTargetRegion(regionName);
saveDraftToStorage();
};
@@ -1041,12 +1100,8 @@ export default function ReportEditor() {
};
const checkAiRegions = () => {
if (!editorRef.current) return [];
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter(r => r.id);
const liveRegions = getAiRegionOptions(editorRef.current);
return liveRegions.length > 0 ? liveRegions : aiRegions;
};
const stripHtml = (html: string): string => {
@@ -2165,7 +2220,7 @@ export default function ReportEditor() {
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域" aria-label="插入AI可编辑区域"><Bot size={16} /></button>
</div>
</div>
@@ -2742,8 +2797,8 @@ export default function ReportEditor() {
disabled={!aiModifyEnabled}
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
>
{checkAiRegions().length > 0 ? (
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
{aiRegions.length > 0 ? (
aiRegions.map((r) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
) : (
<option value="none"> AI </option>
)}