agent-ecosystem/src/renderer/components/team/editor/EditorImagePreview.tsx

139 lines
4.1 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.

/**
* Inline image preview for the project editor.
*
* Loads binary file as base64 data URL via IPC, displays centered image
* with checkerboard background for transparency, metadata, and lightbox on click.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
interface EditorImagePreviewProps {
filePath: string;
fileName: string;
size: number;
}
export const EditorImagePreview = ({
filePath,
fileName,
size,
}: EditorImagePreviewProps): React.ReactElement => {
const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
// Reset state when filePath changes
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setLoading(true);
setError(null);
setDataUrl(null);
setDimensions(null);
setLightboxOpen(false);
}, [filePath]);
useEffect(() => {
let cancelled = false;
window.electronAPI.editor
.readBinaryPreview(filePath)
.then((result) => {
if (cancelled) return;
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filePath]);
const handleImageLoad = useCallback(() => {
const img = imgRef.current;
if (img) {
setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
}
}, []);
const handleOpenExternal = useCallback((): void => {
window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error);
}, [filePath, projectPath]);
const sizeFormatted =
size < 1024
? `${size} B`
: size < 1024 * 1024
? `${(size / 1024).toFixed(1)} KB`
: `${(size / 1024 / 1024).toFixed(1)} MB`;
if (loading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Loader2 className="size-8 animate-spin opacity-40" />
<p className="text-xs">{t('editor.imagePreview.loading')}</p>
</div>
);
}
if (error || !dataUrl) {
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<button
type="button"
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
onClick={() => setLightboxOpen(true)}
aria-label={t('editor.imagePreview.openFullSize')}
>
<img
ref={imgRef}
src={dataUrl}
alt={fileName}
className="max-h-[60vh] object-contain"
onLoad={handleImageLoad}
draggable={false}
/>
</button>
<p className="text-xs text-text-muted">
{fileName}
{dimensions ? `${dimensions.w}×${dimensions.h}` : ''}
{`${sizeFormatted}`}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleOpenExternal}>
{t('editor.imagePreview.openSystemViewer')}
</Button>
</div>
<ImageLightbox
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
src={dataUrl}
alt={fileName}
/>
</div>
);
};