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:
@@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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;">​</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;">​</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user