Refresh AI region list after editor content loads
- Extract AI region scanning into a reusable utility with unit coverage. - Refresh AI region dropdown state after drafts, reports, default templates, and selected templates write HTML into the editor. - Keep the existing MutationObserver path for later DOM edits and inserted AI regions. - Add E2E coverage for existing template AI regions appearing on initial report editor load. - Update README, AGENTS, report editor, progress, and testing docs for AI region synchronization behavior.
This commit is contained in:
@@ -23,28 +23,12 @@ import { getFieldLibrary, updateFieldLibrary } from '../api/library';
|
||||
import { listFiles, uploadFileResource } from '../api/files';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
import { diffChars } from 'diff';
|
||||
import { areAiRegionOptionsEqual, getAiRegionOptions, type AiRegionOption } from '../utils/aiRegions';
|
||||
|
||||
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, '&')
|
||||
@@ -250,6 +234,14 @@ export default function ReportEditor() {
|
||||
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
|
||||
};
|
||||
|
||||
const refreshEditorDerivedState = () => {
|
||||
syncAiRegions();
|
||||
setTimeout(() => {
|
||||
updatePageHeight();
|
||||
syncAiRegions();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
const key = user ? `reportEditorDraft_${user.username}` : '';
|
||||
@@ -416,7 +408,7 @@ export default function ReportEditor() {
|
||||
contentRef.current = draft.content;
|
||||
contentLoadedRef.current = true;
|
||||
setLoadedTemplateId(draft.loadedTemplateId || '');
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}
|
||||
} else {
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
@@ -445,7 +437,7 @@ export default function ReportEditor() {
|
||||
contentRef.current = found.content;
|
||||
}
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}
|
||||
if (found.capturedFrames) {
|
||||
setCapturedFrames(found.capturedFrames.sort((a, b) => a.time - b.time));
|
||||
@@ -484,7 +476,7 @@ export default function ReportEditor() {
|
||||
contentRef.current = draft.content;
|
||||
contentLoadedRef.current = true;
|
||||
setLoadedTemplateId(draft.loadedTemplateId || '');
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}
|
||||
}
|
||||
if (!contentLoadedRef.current && editorRef.current) {
|
||||
@@ -505,7 +497,7 @@ export default function ReportEditor() {
|
||||
contentRef.current = defaultReportContent;
|
||||
}
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}
|
||||
}
|
||||
}, [reportId, navigate, draftKey, restoreFlag]);
|
||||
@@ -565,7 +557,7 @@ export default function ReportEditor() {
|
||||
editorRef.current.innerHTML = apiReport.content;
|
||||
contentRef.current = apiReport.content;
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}
|
||||
} catch {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
@@ -1588,7 +1580,7 @@ export default function ReportEditor() {
|
||||
chatInput: '',
|
||||
activeTab: stateRef.current.activeTab
|
||||
};
|
||||
updatePageHeight();
|
||||
refreshEditorDerivedState();
|
||||
saveDraftToStorage();
|
||||
}
|
||||
setPendingTemplateId(null);
|
||||
@@ -1618,7 +1610,7 @@ export default function ReportEditor() {
|
||||
chatMessages: draft.chatMessages || [],
|
||||
chatInput: draft.chatInput || ''
|
||||
};
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
return;
|
||||
}
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
@@ -1644,7 +1636,7 @@ export default function ReportEditor() {
|
||||
videos: found.videos || [],
|
||||
capturedFrames: found.capturedFrames || []
|
||||
};
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -1660,7 +1652,7 @@ export default function ReportEditor() {
|
||||
capturedFrames: draft.capturedFrames,
|
||||
loadedTemplateId: draft.loadedTemplateId || ''
|
||||
};
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1682,7 +1674,7 @@ export default function ReportEditor() {
|
||||
editorRef.current.innerHTML = defaultReportContent;
|
||||
}
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
refreshEditorDerivedState();
|
||||
}, []);
|
||||
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
|
||||
29
src/utils/aiRegions.test.ts
Normal file
29
src/utils/aiRegions.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { areAiRegionOptionsEqual, getAiRegionOptions } from './aiRegions';
|
||||
|
||||
describe('ai region utilities', () => {
|
||||
it('finds AI regions from editor HTML', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = `
|
||||
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理"></div>
|
||||
<div class="ai-region" data-ai-id="病灶描述"></div>
|
||||
<div class="ai-region"></div>
|
||||
`;
|
||||
|
||||
expect(getAiRegionOptions(root)).toEqual([
|
||||
{ id: '手术步骤', title: '手术步骤、术中出现的情况及处理' },
|
||||
{ id: '病灶描述', title: '病灶描述' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares region lists by id and title', () => {
|
||||
expect(areAiRegionOptionsEqual(
|
||||
[{ id: 'a', title: 'A' }],
|
||||
[{ id: 'a', title: 'A' }],
|
||||
)).toBe(true);
|
||||
expect(areAiRegionOptionsEqual(
|
||||
[{ id: 'a', title: 'A' }],
|
||||
[{ id: 'a', title: 'B' }],
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
16
src/utils/aiRegions.ts
Normal file
16
src/utils/aiRegions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type AiRegionOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export 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);
|
||||
};
|
||||
|
||||
export const areAiRegionOptionsEqual = (a: AiRegionOption[], b: AiRegionOption[]) =>
|
||||
a.length === b.length && a.every((region, index) => region.id === b[index]?.id && region.title === b[index]?.title);
|
||||
Reference in New Issue
Block a user