Files
openscreen/src/components/video-editor/VideoEditor.tsx
T
2025-12-05 22:32:26 -07:00

797 lines
28 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import type { Span } from "dnd-timeline";
import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
} from "./types";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [shadowIntensity, setShadowIntensity] = useState(0);
const [showBlur, setShowBlur] = useState(false);
const [motionBlurEnabled, setMotionBlurEnabled] = useState(true);
const [borderRadius, setBorderRadius] = useState(0);
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
// Helper to convert file path to proper file:// URL
const toFileUrl = (filePath: string): string => {
// Normalize path separators to forward slashes
const normalized = filePath.replace(/\\/g, '/');
// Check if it's a Windows absolute path (e.g., C:/Users/...)
if (normalized.match(/^[a-zA-Z]:/)) {
const fileUrl = `file:///${normalized}`;
return fileUrl;
}
// Unix-style absolute path
const fileUrl = `file://${normalized}`;
return fileUrl;
};
useEffect(() => {
async function loadVideo() {
try {
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
const videoUrl = toFileUrl(result.path);
setVideoPath(videoUrl);
} else {
setError('No video to load. Please record or select a video.');
}
} catch (err) {
setError('Error loading video: ' + String(err));
} finally {
setLoading(false);
}
}
loadVideo();
}, []);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
if (mounted) {
setWallpaper(resolvedPath);
}
} catch (err) {
// If resolution fails, keep the fallback
console.warn('Failed to resolve default wallpaper path:', err);
}
})();
return () => { mounted = false };
}, []);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
if (!playback || !video) return;
if (isPlaying) {
playback.pause();
} else {
playback.play().catch(err => console.error('Video play failed:', err));
}
}
function handleSeek(time: number) {
const video = videoPlaybackRef.current?.video;
if (!video) return;
video.currentTime = time;
}
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
if (id) setSelectedTrimId(null);
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSelectAnnotation = useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
}
}, []);
const handleZoomAdded = useCallback((span: Span) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 },
};
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}, []);
const handleTrimAdded = useCallback((span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
const newRegion: TrimRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
};
setTrimRegions((prev) => [...prev, newRegion]);
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}, []);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
setZoomRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
setTrimRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
setZoomRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
focus: clampFocusToDepth(focus, region.depth),
}
: region,
),
);
}, []);
const handleZoomDepthChange = useCallback((depth: ZoomDepth) => {
if (!selectedZoomId) return;
setZoomRegions((prev) =>
prev.map((region) =>
region.id === selectedZoomId
? {
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
);
}, [selectedZoomId]);
const handleZoomDelete = useCallback((id: string) => {
setZoomRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
}, [selectedZoomId]);
const handleTrimDelete = useCallback((id: string) => {
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
}, [selectedTrimId]);
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: 'text',
content: 'Enter text...',
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
setAnnotationRegions((prev) => [...prev, newRegion]);
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
}, []);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleAnnotationDelete = useCallback((id: string) => {
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId]);
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
// Store content in type-specific fields
if (region.type === 'text') {
return { ...region, content, textContent: content };
} else if (region.type === 'image') {
return { ...region, content, imageContent: content };
} else {
return { ...region, content };
}
});
return updated;
});
}, []);;
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
// Restore content from type-specific storage
if (type === 'text') {
updatedRegion.content = region.textContent || 'Enter text...';
} else if (type === 'image') {
updatedRegion.content = region.imageContent || '';
} else if (type === 'figure') {
updatedRegion.content = '';
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
return updatedRegion;
});
return updated;
});
}, []);
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, style: { ...region.style, ...style } }
: region,
),
);
}, []);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, figureData }
: region,
),
);
}, []);
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, position }
: region,
),
);
}, []);
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, size }
: region,
),
);
}, []);
// Global Tab prevention
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
// Allow tab only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
}
if (e.key === ' ' || e.code === 'Space') {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
const playback = videoPlaybackRef.current;
if (playback?.video) {
if (playback.video.paused) {
playback.play().catch(console.error);
} else {
playback.pause();
}
}
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
}
}, [selectedZoomId, zoomRegions]);
useEffect(() => {
if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) {
setSelectedTrimId(null);
}
}, [selectedTrimId, trimRegions]);
useEffect(() => {
if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId, annotationRegions]);
const handleExport = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
setShowExportDialog(true);
setIsExporting(true);
setExportProgress(null);
setExportError(null);
try {
const wasPlaying = isPlaying;
if (wasPlaying) {
videoPlaybackRef.current?.pause();
}
// Get actual video dimensions to match recording resolution
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
const aspectRatioValue = getAspectRatioValue(aspectRatio);
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (exportQuality === 'source') {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
// Iterate down from baseWidth to find exact match
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
// Iterate down from baseHeight to find exact match
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = exportQuality === 'medium' ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2; // Ensure even
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Ensure even
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000; // 10 Mbps for 720p
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000; // 20 Mbps for 1080p
} else {
bitrate = 30_000_000;
}
}
// Get preview CONTAINER dimensions for scaling
// Annotations render in HTML overlay matching container, not PixiJS canvas
const playbackRef = videoPlaybackRef.current;
const containerElement = playbackRef?.containerRef?.current;
const previewWidth = containerElement?.clientWidth || 1920;
const previewHeight = containerElement?.clientHeight || 1080;
const exporter = new VideoExporter({
videoUrl: videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
}
} else {
setExportError(result.error || 'Export failed');
toast.error(result.error || 'Export failed');
}
if (wasPlaying) {
videoPlaybackRef.current?.play();
}
} catch (error) {
console.error('Export error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
setIsExporting(false);
exporterRef.current = null;
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
toast.info('Export cancelled');
setShowExportDialog(false);
setIsExporting(false);
setExportProgress(null);
setExportError(null);
}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-foreground">Loading video...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-destructive">{error}</div>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<div
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex-1" />
</div>
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
{/* Left Column - Video & Timeline */}
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} minSize={40}>
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ''}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurEnabled={motionBlurEnabled}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
/>
</div>
</div>
{/* Playback controls */}
<div className="w-full flex justify-center items-center" style={{ height: '48px', flexShrink: 0, padding: '6px 12px', margin: '6px 0 6px 0' }}>
<div style={{ width: '100%', maxWidth: '700px' }}>
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</div>
</Panel>
<PanelResizeHandle className="h-3 bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full mx-4 flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Timeline section */}
<Panel defaultSize={30} minSize={20}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={setShadowIntensity}
showBlur={showBlur}
onBlurChange={setShowBlur}
motionBlurEnabled={motionBlurEnabled}
onMotionBlurChange={setMotionBlurEnabled}
borderRadius={borderRadius}
onBorderRadiusChange={setBorderRadius}
padding={padding}
onPaddingChange={setPadding}
cropRegion={cropRegion}
onCropChange={setCropRegion}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
onExport={handleExport}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
/>
</div>
<Toaster theme="dark" className="pointer-events-auto" />
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
progress={exportProgress}
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
/>
</div>
);
}