- Fix React hooks violations: ref updates during render (useDraftPersistence, useChipDraftPersistence, useAttachments), setState in effects across 15+ components, useCallback self-reference TDZ in useResizableColumns - Fix TypeScript lint: remove unnecessary type assertions, replace inline import() annotations with direct imports, remove unused variables/imports - Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver, no-misleading-array-reverse in TeamProvisioningService, use-type-alias in ClaudeLogsSection, variable shadowing in ChangeExtractorService - Fix accessibility: associate labels with controls in filter popovers - Fix template expression safety: wrap unknown errors with String() - Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity to match filesystem birthtimeMs resolution on Linux - Replace TODO comments with NOTE where features are intentionally disabled - Remove unused leadContextByTeam from TeamDetailView store selector 62 files changed across main process, renderer, shared types, and hooks. All 1646 tests pass, typecheck clean, 0 lint errors.
137 lines
4 KiB
TypeScript
137 lines
4 KiB
TypeScript
/**
|
||
* 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 { 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 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">Loading preview…</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="Open full-size preview"
|
||
>
|
||
<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}>
|
||
Open in System Viewer
|
||
</Button>
|
||
</div>
|
||
|
||
<ImageLightbox
|
||
open={lightboxOpen}
|
||
onClose={() => setLightboxOpen(false)}
|
||
src={dataUrl}
|
||
alt={fileName}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|