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:
2026-05-02 04:57:00 +08:00
parent 558498a4bb
commit 3774657ef5
9 changed files with 83 additions and 29 deletions

View File

@@ -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'));

View 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
View 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);