Files
Mdeical_Sur_Report/src/pages/ReportEditor.tsx

1333 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
export default function ReportEditor() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const reportId = searchParams.get('id');
const restoreFlag = searchParams.get('restore');
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [reportData, setReportData] = useState<Partial<Report>>({
title: '腹腔镜胆囊切除术报告',
patientName: '',
hospitalId: '',
patientGender: '',
patientAge: '',
department: '',
bedNumber: '',
surgeryDate: new Date().toISOString().split('T')[0],
startHour: '',
startMinute: '',
endHour: '',
endMinute: '',
surgeon: [],
assistant: [],
anesthesiologist: [],
anesthesiaType: '',
reportNote: '',
status: 'draft'
});
const [templates, setTemplates] = useState<Template[]>([]);
const [videos, setVideos] = useState<{id: string, name: string, url: string, duration: number}[]>([]);
const [currentVideoIndex, setCurrentVideoIndex] = useState(-1);
const [capturedFrames, setCapturedFrames] = useState<CapturedFrame[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isSaved, setIsSaved] = useState(false);
const [loadedTemplateId, setLoadedTemplateId] = useState('');
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
surgeon: ['张医生', '李医生', '王医生'],
assistant: ['赵医生', '钱医生', '孙医生'],
anesthesiologist: ['周医生', '吴医生', '郑医生']
});
const [anesthesiaOptions, setAnesthesiaOptions] = useState<string[]>(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const editorRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab });
stateRef.current = { reportData, videos, capturedFrames, activeTab };
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
const updatePageHeight = () => {
if (!editorRef.current) return;
const contentHeight = editorRef.current.scrollHeight;
const pageHeightMm = 297;
const mmToPx = 3.7795275591;
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
loadedTemplateId,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId, loadedTemplateId]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user) { navigate('/'); return; }
setCurrentUser(user);
const savedMulti = storage.get<Record<string, string[]> | null>('multiSelectOptions', null);
if (savedMulti) setMultiSelectOptions(savedMulti);
const savedAnesthesia = storage.get<string[] | null>('anesthesiaOptions', null);
if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia);
const allTemplates = storage.get<Template[]>('templates', []);
const visibleTplIds = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
setTemplates(filteredTemplates);
if (reportId) {
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && draft.draftReportId === reportId) {
if (draft.reportData) setReportData(draft.reportData);
if (draft.videos) {
setVideos(draft.videos);
if (draft.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = draft.videos.length;
}
if (draft.capturedFrames) {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
} else {
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
setReportData(found);
if (editorRef.current) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) {
editorRef.current.innerHTML = restoreContent;
storage.removeSession(`restore_${reportId}`);
} else {
editorRef.current.innerHTML = found.content;
contentRef.current = found.content;
contentRef.current = found.content;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
if (found.capturedFrames) {
setCapturedFrames(found.capturedFrames.sort((a, b) => a.time - b.time));
}
if (found.videos) {
setVideos(found.videos);
if (found.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = found.videos.length;
}
}
}
} else {
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && !draft.draftReportId) {
if (draft.reportData) setReportData(draft.reportData);
if (draft.videos) {
setVideos(draft.videos);
if (draft.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = draft.videos.length;
}
if (draft.capturedFrames) {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
}
if (!contentLoadedRef.current && editorRef.current) {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
}
}, [reportId, navigate, draftKey, restoreFlag]);
useEffect(() => {
const save = () => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
};
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
};
}, [reportId]);
useEffect(() => {
if (!editorRef.current) return;
const observer = new MutationObserver(() => {
updatePageHeight();
});
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true });
return () => observer.disconnect();
}, [currentUser]);
const triggerPlaceholderUpload = (placeholder: HTMLElement) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
reader.readAsDataURL(file);
}
};
input.click();
};
// Handle image placeholder interactions via click capture for reliable contenteditable behavior
useEffect(() => {
const handleEditorClick = (e: MouseEvent) => {
// e.target may be a text node; safely resolve to an Element
let node: Node | null = e.target as Node;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
if (targetEl.closest('.delete-btn')) {
e.stopPropagation();
e.preventDefault();
if (placeholder.classList.contains('has-image')) {
placeholder.classList.remove('has-image');
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
`;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
saveDraftToStorage();
}
return;
}
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
triggerPlaceholderUpload(placeholder);
}
};
const editor = editorRef.current;
if (editor) {
editor.addEventListener('click', handleEditorClick, true);
}
return () => {
if (editor) {
editor.removeEventListener('click', handleEditorClick, true);
}
};
}, [saveDraftToStorage, currentUser]);
// Prevent backspace from deleting image-placeholder elements
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.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// If selection is inside a placeholder, or if the range intersects a placeholder
let node: Node | null = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const placeholder = (node as HTMLElement)?.closest('.image-placeholder');
if (placeholder) {
e.preventDefault();
return;
}
// If collapsed and next/previous sibling is a placeholder
if (range.collapsed) {
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType === Node.ELEMENT_NODE) {
const next = (container as Element).children[offset];
const prev = (container as Element).children[offset - 1];
if ((e.key === 'Delete' && next?.classList?.contains('image-placeholder')) ||
(e.key === 'Backspace' && prev?.classList?.contains('image-placeholder'))) {
e.preventDefault();
}
}
}
};
editor.addEventListener('keydown', handleKeyDown);
return () => editor.removeEventListener('keydown', handleKeyDown);
}, []);
const execCmd = (command: string, value: string | undefined = undefined) => {
editorRef.current?.focus();
document.execCommand(command, false, value);
editorRef.current?.focus();
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
if (rowsStr && colsStr) {
const rows = parseInt(rowsStr);
const cols = parseInt(colsStr);
if (isNaN(rows) || isNaN(cols)) return;
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
}
};
const insertImage = () => {
editorRef.current?.focus();
const id = 'ph_' + Date.now();
const html = `
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
`;
execCmd('insertHTML', html);
};
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []) as File[];
const newVideos = files.map(file => ({
id: Math.random().toString(36).substr(2, 9),
name: file.name,
url: URL.createObjectURL(file),
duration: 0
}));
const combined = [...videos, ...newVideos];
setVideos(combined);
setCurrentVideoIndex(videos.length); // select first newly uploaded video
if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage();
};
const removeVideo = (id: string) => {
const idx = videos.findIndex(v => v.id === id);
const updated = videos.filter(v => v.id !== id);
setVideos(updated);
if (currentVideoIndex >= updated.length) {
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
} else if (currentVideoIndex === idx && updated.length > 0) {
setCurrentVideoIndex(0);
}
setCapturedFrames(prev => prev.filter(f => f.videoIndex !== idx).map(f => {
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
return f;
}).sort((a, b) => a.time - b.time));
saveDraftToStorage();
};
const selectVideo = (index: number) => {
setCurrentVideoIndex(index);
setIsPlaying(false);
};
const togglePlay = () => {
if (!videoRef.current) return;
if (isPlaying) videoRef.current.pause();
else videoRef.current.play();
setIsPlaying(!isPlaying);
};
const captureFrame = () => {
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time: video.currentTime,
timeFormatted: formatTime(video.currentTime),
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
isManual: true
};
setCapturedFrames(prev => [...prev, newFrame].sort((a, b) => a.time - b.time));
saveDraftToStorage();
};
const autoCaptureFrames = async () => {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
const dur = video.duration || 1;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
const newFrames: CapturedFrame[] = [];
for (const pos of positions) {
const time = (pos / 100) * dur;
video.currentTime = time;
await new Promise<void>(resolve => {
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
});
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
newFrames.push({
id: Date.now() + Math.random(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time,
timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
isManual: false
});
}
setCapturedFrames(prev => [...prev, ...newFrames].sort((a, b) => a.time - b.time));
if (wasPlaying) video.play();
saveDraftToStorage();
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
e.dataTransfer.setData('frameId', frame.id.toString());
};
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" />
`;
placeholder.classList.add('has-image');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
const seekToFrame = (frame: CapturedFrame) => {
if (!videoRef.current) return;
if (frame.videoIndex !== currentVideoIndex) {
setCurrentVideoIndex(frame.videoIndex);
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.currentTime = frame.time;
}
}, 50);
};
const handleFrameKeyNav = (direction: 'left' | 'right') => {
if (!videoRef.current) return;
const delta = direction === 'left' ? -5 : 5;
videoRef.current.currentTime = Math.max(0, Math.min(duration, videoRef.current.currentTime + delta));
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Only if video tab is active or video element is focused
if (activeTab === 'video' || document.activeElement === videoRef.current) {
handleFrameKeyNav(e.key === 'ArrowLeft' ? 'left' : 'right');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTab, duration]);
// Auto-capture frames when new videos are uploaded
useEffect(() => {
if (videos.length > prevVideoCountRef.current && currentVideoIndex !== -1) {
const timer = setTimeout(() => {
if (videoRef.current && videoRef.current.duration) {
autoCaptureFrames();
} else if (videoRef.current) {
const onMeta = () => {
autoCaptureFrames();
videoRef.current?.removeEventListener('loadedmetadata', onMeta);
};
videoRef.current.addEventListener('loadedmetadata', onMeta);
}
}, 300);
return () => clearTimeout(timer);
}
prevVideoCountRef.current = videos.length;
}, [videos.length, currentVideoIndex]);
// Apply selected template content
useEffect(() => {
if (pendingTemplateId && editorRef.current) {
const tpl = templates.find(t => t.id === pendingTemplateId);
if (tpl) {
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
setLoadedTemplateId(tpl.id);
setReportData({
title: tpl.name || '腹腔镜胆囊切除术报告',
patientName: '',
hospitalId: '',
patientGender: '',
patientAge: '',
department: '',
bedNumber: '',
surgeryDate: new Date().toISOString().split('T')[0],
startHour: '',
startMinute: '',
endHour: '',
endMinute: '',
surgeon: [],
assistant: [],
anesthesiologist: [],
anesthesiaType: '',
status: 'draft'
});
setVideos([]);
setCapturedFrames([]);
setCurrentVideoIndex(-1);
prevVideoCountRef.current = 0;
updatePageHeight();
saveDraftToStorage();
}
setPendingTemplateId(null);
}
}, [pendingTemplateId, templates]);
// Safety net: ensure editor gets initialized if ref was not ready during init effect
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
const draft = key ? storage.get<Record<string, any> | null>(key, null) : null;
if (reportId) {
if (draft && draft.draftReportId === reportId && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
return;
}
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) {
editorRef.current.innerHTML = restoreContent;
storage.removeSession(`restore_${reportId}`);
} else {
editorRef.current.innerHTML = found.content;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
return;
}
} else {
if (draft && !draft.draftReportId && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
return;
}
}
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const allTemplates = storage.get<Template[]>('templates', []);
const userData = storage.get<User | null>('currentUser', null);
const visibleTplIds = Array.isArray(userData?.visibleTemplates) ? userData.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
editorRef.current.innerHTML = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
});
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const addTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const current = reportData[field] || [];
if (!current.includes(value)) {
const next = { ...reportData, [field]: [...current, value] };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
}
// Persist custom value to global options for future reuse
const opts = multiSelectOptions[field] || [];
if (!opts.includes(value)) {
const next = { ...multiSelectOptions, [field]: [...opts, value] };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
}
};
const removeTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const current = reportData[field] || [];
const next = { ...reportData, [field]: current.filter(v => v !== value) };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
};
const removeMultiOption = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
const current = multiSelectOptions[field] || [];
const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
};
const removeAnesthesiaOption = (value: string) => {
const next = anesthesiaOptions.filter(v => v !== value);
setAnesthesiaOptions(next);
storage.set('anesthesiaOptions', next);
};
// Close dropdowns on outside click
useEffect(() => {
const handleDocClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.select-dropdown-root')) {
setOpenDropdown(null);
}
};
document.addEventListener('click', handleDocClick);
return () => document.removeEventListener('click', handleDocClick);
}, []);
const saveReport = (status: 'draft' | 'completed') => {
if (status === 'completed' && (!reportData.patientName || !reportData.hospitalId)) {
alert('请填写患者姓名和住院号');
return;
}
const content = editorRef.current?.innerHTML || '';
const now = new Date().toISOString();
const finalReport: Report = {
...(reportData as Report),
id: reportId || 'RPT_' + Date.now(),
content,
author: currentUser?.username || '',
authorName: currentUser?.name || '',
createdAt: reportData.createdAt || now.split('T')[0],
status,
capturedFrames,
videos,
updatedAt: now
};
const reports = storage.get<Report[]>('reports', []);
let updatedReports: Report[];
if (reportId) {
const old = reports.find(r => r.id === reportId);
const history = old?.history ? [...old.history] : [];
if (old) {
history.push({
content: old.content,
updatedAt: old.updatedAt || old.createdAt,
updatedBy: currentUser?.name || currentUser?.username || '',
action: status === 'completed' ? 'complete_report' : 'save_draft'
});
}
updatedReports = reports.map(r => r.id === reportId ? { ...finalReport, history } : r);
} else {
updatedReports = [...reports, finalReport];
if (draftKey) storage.remove(draftKey);
}
storage.set('reports', updatedReports);
if (draftKey) storage.remove(draftKey);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
if (status === 'completed') navigate('/report-manage');
};
if (!currentUser) return null;
const hasVisibleTemplates = templates.length > 0;
return (
<div className="flex h-screen bg-bg overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-20 bg-white border-b border-border flex items-center justify-between px-8 shrink-0">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/report-manage')}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<ChevronLeft size={24} />
</button>
<div>
<h1 className="text-lg font-bold text-text-main"></h1>
<p className="text-[10px] text-text-muted mt-0.5 uppercase tracking-wider font-bold">
{reportId ? `编辑报告: ${reportId}` : '新建手术报告'}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-xs text-text-muted whitespace-nowrap"></span>
<select
value=""
onChange={(e) => {
const tplId = e.target.value;
const tpl = templates.find(t => t.id === tplId);
if (tpl && window.confirm(`确定要应用模板 "${tpl.name}" 吗?当前内容、基本信息、视频分析将被重置。`)) {
setPendingTemplateId(tplId);
}
}}
className="h-8 px-2 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value="" disabled>
{templates.find(t => t.id === loadedTemplateId)?.name || '无'}
</option>
{templates.map(tpl => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
{isSaved && (
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
<Check size={14} />
</span>
)}
<button
onClick={() => saveReport('draft')}
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
>
稿
</button>
<button
onClick={() => saveReport('completed')}
className="btn-accent inline-flex items-center gap-2"
>
<Check size={16} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
>
<Printer size={18} />
</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Editor Main */}
<div className="flex-1 flex flex-col overflow-hidden border-r border-border">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('undo')} 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="撤销"><Undo size={16} /></button>
<button onClick={() => execCmd('redo')} 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="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} 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="粗体"><Bold size={16} /></button>
<button onClick={() => execCmd('italic')} 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="斜体"><Italic size={16} /></button>
<button onClick={() => execCmd('underline')} 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="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onChange={(e) => execCmd('foreColor', e.target.value)}
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
title="文字颜色"
/>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('justifyLeft')} 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="左对齐"><AlignLeft size={16} /></button>
<button onClick={() => execCmd('justifyCenter')} 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="居中"><AlignCenter size={16} /></button>
<button onClick={() => execCmd('justifyRight')} 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="右对齐"><AlignRight size={16} /></button>
</div>
<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>
</div>
</div>
{/* Editor Content */}
<div
className="editor-content-wrapper print-wrapper"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const target = e.target as HTMLElement;
const placeholder = target.closest('.image-placeholder') as HTMLElement;
if (placeholder) handleDrop(e, placeholder);
}}
>
<div
ref={editorRef}
contentEditable
onInput={() => { contentRef.current = editorRef.current?.innerHTML || ''; updatePageHeight(); saveDraftToStorage(); }}
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
className="editor-content print-content"
>
</div>
</div>
</div>
{/* Right Sidebar */}
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['info', 'video'] as const).map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'info' ? '基本信息' : '视频分析'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"> <span className="text-red-500">*</span></label>
<input
type="text"
value={reportData.patientName}
onChange={(e) => { const next = {...reportData, patientName: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, patientName: true }))}
className={`input-minimal ${touched.patientName && !reportData.patientName ? 'border-red-500' : ''}`}
placeholder="患者姓名"
/>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"> <span className="text-red-500">*</span></label>
<input
type="text"
value={reportData.hospitalId}
onChange={(e) => { const next = {...reportData, hospitalId: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, hospitalId: true }))}
className={`input-minimal ${touched.hospitalId && !reportData.hospitalId ? 'border-red-500' : ''}`}
placeholder="住院号"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.title}
onChange={(e) => { const next = {...reportData, title: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="请输入手术名称"
/>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<select
value={reportData.patientGender}
onChange={(e) => { const next = {...reportData, patientGender: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white"
>
<option value=""></option>
<option value="男"></option>
<option value="女"></option>
</select>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.patientAge}
onChange={(e) => { const next = {...reportData, patientAge: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="年龄"
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.department}
onChange={(e) => { const next = {...reportData, department: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="科室"
/>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="text"
value={reportData.bedNumber}
onChange={(e) => { const next = {...reportData, bedNumber: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
placeholder="床号"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<input
type="date"
value={reportData.surgeryDate}
onChange={(e) => { const next = {...reportData, surgeryDate: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal"
/>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<div className="flex items-center gap-2">
<select
value={reportData.startHour}
onChange={(e) => { const next = {...reportData, startHour: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={reportData.startMinute}
onChange={(e) => { const next = {...reportData, startMinute: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<div className="flex-1 space-y-1">
<label className="block text-xs font-bold text-text-main"></label>
<div className="flex items-center gap-2">
<select
value={reportData.endHour}
onChange={(e) => { const next = {...reportData, endHour: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={reportData.endMinute}
onChange={(e) => { const next = {...reportData, endMinute: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
</div>
{(['surgeon', 'assistant', 'anesthesiologist'] as const).map((field) => {
const labels = { surgeon: '手术者', assistant: '助手', anesthesiologist: '麻醉师' };
const isOpen = openDropdown === field;
return (
<div key={field} className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main">{labels[field]}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field)}
>
{(reportData[field] || []).map(tag => (
<span key={tag} className="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 flex items-center gap-1">
{tag}
<span className="cursor-pointer hover:text-amber-900" onClick={(e) => { e.stopPropagation(); removeTag(field, tag); }}>×</span>
</span>
))}
<input
type="text"
className="outline-none text-sm min-w-[60px] flex-1 bg-transparent"
placeholder="输入或选择"
onFocus={() => setOpenDropdown(field)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) { addTag(field, val); (e.target as HTMLInputElement).value = ''; }
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{(multiSelectOptions[field] || []).map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { addTag(field, opt); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeMultiOption(field, opt); }}
>×</span>
</div>
))}
{(multiSelectOptions[field] || []).length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
})}
<div className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main"></label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown('anesthesia')}
>
<input
type="text"
className="outline-none text-sm flex-1 bg-transparent"
placeholder="输入或选择麻醉方式"
value={reportData.anesthesiaType || ''}
onChange={(e) => { const next = {...reportData, anesthesiaType: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onFocus={() => setOpenDropdown('anesthesia')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
const next = {...reportData, anesthesiaType: val};
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
if (!anesthesiaOptions.includes(val)) {
const next = [...anesthesiaOptions, val];
setAnesthesiaOptions(next);
storage.set('anesthesiaOptions', next);
}
setOpenDropdown(null);
}
}
}}
/>
</div>
{openDropdown === 'anesthesia' && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{anesthesiaOptions.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { const next = {...reportData, anesthesiaType: opt}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeAnesthesiaOption(opt); }}
>×</span>
</div>
))}
{anesthesiaOptions.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
</div>
)}
{activeTab === 'video' && (
<div className="space-y-4">
<input
ref={videoInputRef}
type="file"
accept="video/*"
multiple
className="hidden"
onChange={handleVideoUpload}
/>
<button
onClick={() => videoInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 p-3 border border-dashed border-border rounded-lg hover:border-accent hover:bg-slate-50 transition-all"
>
<Video size={18} />
<div className="text-left">
<p className="text-xs font-bold text-text-main"></p>
<p className="text-[10px] text-text-muted"> MP4, MOV </p>
</div>
</button>
{videos.length > 0 && (
<div className="space-y-4">
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
{videos.map((v, i) => (
<div
key={v.id}
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
}`}
>
<div
onClick={() => selectVideo(i)}
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
>
<Play size={16} />
</div>
<div
onClick={() => selectVideo(i)}
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
>{v.name}</div>
<button
onClick={() => removeVideo(v.id)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
>
<X size={12} />
</button>
</div>
))}
</div>
{currentVideoIndex !== -1 && (
<div className="space-y-4">
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
<video
ref={videoRef}
src={videos[currentVideoIndex].url}
className="w-full h-full"
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
onLoadedMetadata={() => {
const dur = videoRef.current?.duration || 0;
setDuration(dur);
setVideos(prev => prev.map((v, i) => i === currentVideoIndex ? { ...v, duration: dur } : v));
}}
/>
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 cursor-pointer" onClick={togglePlay}>
<div className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white">
<Play size={32} fill="currentColor" />
</div>
</div>
)}
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-3 py-1.5 rounded-full">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div className="flex items-center gap-4">
<button onClick={togglePlay} className="w-10 h-10 flex items-center justify-center bg-slate-100 rounded-xl text-text-main hover:bg-slate-200 transition-colors">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden cursor-pointer relative" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (videoRef.current) videoRef.current.currentTime = pos * duration;
}}>
<div className="h-full bg-accent" style={{ width: `${(currentTime / duration) * 100}%` }} />
</div>
</div>
<div className="flex justify-between items-center pt-2">
<span className="text-[10px] font-bold text-text-main uppercase tracking-wider"></span>
<button
onClick={captureFrame}
className="px-4 py-2 bg-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-wider hover:bg-blue-700 transition-colors shadow-sm"
>
</button>
</div>
<div className="grid grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{capturedFrames.filter(f => f.videoIndex === currentVideoIndex).map((frame) => (
<div
key={frame.id}
draggable
onDragStart={(e) => handleDragStart(e, frame)}
onClick={() => seekToFrame(frame)}
className={`group relative card-minimal p-1 border-2 cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${
frame.isManual ? 'border-yellow-400 hover:border-yellow-500' : 'border-transparent hover:border-accent'
}`}
>
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setCapturedFrames(prev => prev.filter(f => f.id !== frame.id).sort((a, b) => a.time - b.time)); saveDraftToStorage(); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-all shadow-md"
>
<X size={14} />
</button>
</div>
))}
</div>
<p className="text-[10px] text-text-muted text-center">
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
</aside>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
</div>
);
}