securityos/components/apps/Photos/index.tsx

197 lines
5.9 KiB
TypeScript

import {
ExitFullscreen,
Fullscreen,
ZoomIn,
ZoomOut,
} from "components/apps/Photos/PhotoIcons";
import StyledPhotos from "components/apps/Photos/StyledPhotos";
import useFullscreen from "components/apps/Photos/useFullscreen";
import usePanZoom, { panZoomConfig } from "components/apps/Photos/usePanZoom";
import type { ComponentProcessProps } from "components/system/Apps/RenderComponent";
import useFileDrop from "components/system/Files/FileManager/useFileDrop";
import useTitle from "components/system/Window/useTitle";
import { useFileSystem } from "contexts/fileSystem";
import { useProcesses } from "contexts/process";
import useDoubleClick from "hooks/useDoubleClick";
import { basename, extname } from "path";
import { useCallback, useEffect, useRef, useState } from "react";
import Button from "styles/common/Button";
import {
HIGH_PRIORITY_ELEMENT,
ONE_TIME_PASSIVE_EVENT,
TIFF_IMAGE_FORMATS,
} from "utils/constants";
import {
bufferToUrl,
cleanUpBufferUrl,
decodeJxl,
getGifJs,
haltEvent,
imageToBufferUrl,
imgDataToBuffer,
label,
} from "utils/functions";
const { maxScale, minScale } = panZoomConfig;
const aniToGif = async (aniBuffer: Buffer): Promise<Buffer> => {
const gif = await getGifJs();
const { parseAni } = await import("ani-cursor/dist/parser");
let images: Uint8Array[] = [];
try {
({ images } = parseAni(aniBuffer));
} catch {
return aniBuffer;
}
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
const imageIcon = new Image();
const bufferUrl = bufferToUrl(Buffer.from(image));
imageIcon.addEventListener(
"load",
() => {
gif.addFrame(imageIcon);
cleanUpBufferUrl(bufferUrl);
resolve();
},
ONE_TIME_PASSIVE_EVENT
);
imageIcon.src = bufferUrl;
})
)
);
return new Promise((resolve) => {
gif
.on("finished", (blob) =>
blob
.arrayBuffer()
.then((arrayBuffer) => resolve(Buffer.from(arrayBuffer)))
)
.render();
});
};
const Photos: FC<ComponentProcessProps> = ({ id }) => {
const { processes: { [id]: process } = {} } = useProcesses();
const { closing = false, url = "" } = process || {};
const [src, setSrc] = useState<Record<string, string>>({});
const [brokenImage, setBrokenImage] = useState(false);
const { prependFileToTitle } = useTitle(id);
const { readFile } = useFileSystem();
const containerRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const imageContainerRef = useRef<HTMLDivElement | null>(null);
const { reset, scale, zoomIn, zoomOut, zoomToPoint } = usePanZoom(
id,
imageRef.current,
imageContainerRef.current
);
const { fullscreen, toggleFullscreen } = useFullscreen(containerRef);
const loadPhoto = useCallback(async (): Promise<void> => {
let fileContents: Buffer | string = await readFile(url);
const ext = extname(url).toLowerCase();
if ([".ani", ".cur"].includes(ext)) {
fileContents = await aniToGif(fileContents);
} else if (ext === ".jxl") {
fileContents = imgDataToBuffer(await decodeJxl(fileContents));
} else if (ext === ".qoi") {
const { decodeQoi } = await import("components/apps/Photos/qoi");
fileContents = decodeQoi(fileContents);
} else if (TIFF_IMAGE_FORMATS.has(ext)) {
fileContents = (await import("utif"))
.bufferToURI(fileContents)
.replace("data:image/png;base64,", "");
}
setSrc((currentSrc) => {
const [currentUrl] = Object.keys(currentSrc);
if (currentUrl) {
if (currentUrl === url) return currentSrc;
reset?.();
}
return { [url]: imageToBufferUrl(url, fileContents) };
});
prependFileToTitle(basename(url));
}, [prependFileToTitle, readFile, reset, url]);
useEffect(() => {
if (url && !src[url] && !closing) loadPhoto();
}, [closing, loadPhoto, src, url]);
return (
<StyledPhotos
ref={containerRef}
$showImage={Boolean(src[url] && !brokenImage)}
onContextMenu={haltEvent}
{...useFileDrop({ id })}
>
<nav className="top">
<Button
disabled={!url || scale === maxScale || brokenImage}
onClick={zoomIn}
{...label("Zoom in")}
>
<ZoomIn />
</Button>
<Button
disabled={!url || scale === minScale || brokenImage}
onClick={zoomOut}
{...label("Zoom out")}
>
<ZoomOut />
</Button>
</nav>
<figure
ref={imageContainerRef}
{...useDoubleClick((event) => {
if (scale === minScale) {
zoomToPoint?.(minScale * 2, event, { animate: true });
} else {
reset?.();
}
})}
>
<img
ref={imageRef}
alt={basename(url, extname(url))}
decoding="async"
loading="eager"
onError={() => setBrokenImage(true)}
onLoad={() => setBrokenImage(false)}
src={src[url]}
{...HIGH_PRIORITY_ELEMENT}
/>
{brokenImage && (
<div>
{basename(url)}
<br />
Sorry, Photos can&apos;t open this file because the format is
currently unsupported, or the file is corrupted
</div>
)}
</figure>
<nav className="bottom">
<Button
disabled={!url}
onClick={toggleFullscreen}
{...label("Full-screen")}
>
{fullscreen ? <ExitFullscreen /> : <Fullscreen />}
</Button>
</nav>
</StyledPhotos>
);
};
export default Photos;