integratedhub/islands/PhotoUpdate.tsx
2026-01-20 09:02:26 -06:00

852 lines
25 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 { useEffect, useRef } from "preact/hooks";
import "../photo-editor/types.ts";
import {
useCropCalculator,
usePhotoEditorState,
} from "../photo-editor/hooks.ts";
import { applyOrientation, getOrientation } from "../photo-editor/utils.ts";
export default function PhotoUpdateIsland() {
const { state, updateState } = usePhotoEditorState();
const { getPixelDimensions } = useCropCalculator(state, updateState);
const imgRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleResize = () => {
const img = imgRef.current;
if (!img || !img.complete) return;
const currentWidth = img.style.width;
const currentHeight = img.style.height;
img.style.height = "8000px";
img.style.width = "auto";
void img.offsetHeight;
const achievedWidth = img.getBoundingClientRect().width;
updateState({ maxWidth: achievedWidth });
const displayRatio = img.naturalHeight / img.naturalWidth;
const maxHeight = displayRatio * achievedWidth;
updateState({ maxHeight });
img.style.width = currentWidth;
img.style.height = currentHeight;
updateState({ cropSize: 256, zoom: Math.max(1, state.minZoom) });
void img.offsetHeight;
};
useEffect(() => {
if (state.previewUrl && imgRef.current && imgRef.current.parentElement) {
const img = imgRef.current;
const centerCrop = () => {
if (!img.parentElement) return;
const imgRect = img.getBoundingClientRect();
const baseWidth = imgRect.width / state.zoom;
const baseHeight = imgRect.height / state.zoom;
const landscape = baseWidth >= baseHeight;
updateState({
baseDimensions: { width: baseWidth, height: baseHeight },
});
updateState({ isLandscape: landscape });
const longestDimension = Math.max(baseWidth, baseHeight);
const calculatedMinZoom = longestDimension > 0
? 256 / longestDimension
: 0.2;
updateState({ minZoom: calculatedMinZoom });
const containerRect = img.parentElement.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - 256) / 2;
let centerY = imgOffsetTop + (imgRect.height - 256) / 2;
if (imgRect.width < 256) {
console.log(
`Width ${imgRect.width}px < crop ${256}px, allowing overflow`,
);
} else {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height < 256) {
console.log(
`Height ${imgRect.height}px < crop ${256}px, allowing overflow`,
);
} else {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({
crop: {
x: centerX,
y: centerY,
size: 256,
},
});
};
if (img.complete) {
centerCrop();
} else {
img.addEventListener("load", centerCrop, { once: true });
return () => img.removeEventListener("load", centerCrop);
}
}
}, [state.previewUrl]);
useEffect(() => {
globalThis.addEventListener("resize", handleResize);
handleResize();
return () => globalThis.removeEventListener("resize", handleResize);
}, [state.previewUrl]);
useEffect(() => {
if (!state.previewUrl) return;
const longerDimension = state.isLandscape
? state.baseDimensions.width
: state.baseDimensions.height;
if (longerDimension <= 0) return;
const calculatedDimension = Math.round(longerDimension * state.zoom);
let clampedDimension = Math.max(calculatedDimension, state.cropSize);
let constrainedByMax = false;
if (
!state.isLandscape && state.maxHeight > 0 &&
clampedDimension > state.maxHeight
) {
clampedDimension = state.maxHeight;
constrainedByMax = true;
} else if (
state.isLandscape && state.maxWidth > 0 &&
clampedDimension > state.maxWidth
) {
clampedDimension = state.maxWidth;
constrainedByMax = true;
}
const actualZoom = clampedDimension / longerDimension;
let overRunPercent = constrainedByMax && actualZoom > 0
? ((state.zoom - actualZoom) / actualZoom)
: 0;
if (!isFinite(overRunPercent)) {
overRunPercent = 0;
}
const timer = setTimeout(() => {
const maxOverrunPercent = 0.9;
if (overRunPercent > 0.0 && overRunPercent <= maxOverrunPercent) {
const newCropSize = Math.max(25.6, 256 - (256 * overRunPercent));
if (Math.abs(newCropSize - state.cropSize) > 1) {
updateState({ cropSize: newCropSize, zoomInDisabled: false });
}
} else if (overRunPercent > maxOverrunPercent) {
const targetZoom = actualZoom * (1 + maxOverrunPercent);
const clampedZoom = Math.max(state.minZoom, targetZoom);
if (Math.abs(clampedZoom - state.zoom) > 0.01) {
updateState({ zoom: clampedZoom, zoomInDisabled: true });
} else {
updateState({ zoomInDisabled: true });
}
}
}, 100);
return () => clearTimeout(timer);
}, [
state.zoom,
state.cropSize,
state.baseDimensions,
state.isLandscape,
state.maxWidth,
state.maxHeight,
]);
useEffect(() => {
if (
typeof globalThis.heicTo !== "undefined" &&
typeof globalThis.isHeic !== "undefined"
) {
updateState({ heicToLoaded: true });
return;
}
const script = document.createElement("script");
script.type = "module";
script.textContent = `
import { heicTo, isHeic } from '/heic-to.min.js';
globalThis.heicTo = heicTo;
globalThis.isHeic = isHeic;
globalThis.dispatchEvent(new CustomEvent('heicToLoaded'));
`;
const handleLoaded = () => {
updateState({ heicToLoaded: true });
console.log("heic-to library loaded successfully");
};
globalThis.addEventListener("heicToLoaded", handleLoaded, { once: true });
document.head.appendChild(script);
return () => {
globalThis.removeEventListener("heicToLoaded", handleLoaded);
if (script.parentNode) {
document.head.removeChild(script);
}
};
}, []);
async function handleFile(e: Event) {
const originalFile = (e.target as HTMLInputElement).files?.[0];
if (!originalFile) return;
const validImageTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
"image/heic",
"image/heif",
];
const fileExtension = originalFile.name.toLowerCase().split(".").pop();
const isImageByType = originalFile.type.startsWith("image/") ||
validImageTypes.includes(originalFile.type);
const isImageByExtension = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
"heic",
"heif",
].includes(fileExtension || "");
if (!isImageByType && !isImageByExtension) {
alert(
`Unsupported file type: ${
originalFile.type || "unknown"
}\n\nPlease select an image file.`,
);
return;
}
const maxRecommendedSize = 20 * 1024 * 1024;
if (originalFile.size > maxRecommendedSize) {
const sizeMB = (originalFile.size / (1024 * 1024)).toFixed(1);
const proceed = confirm(
`Large file detected (${sizeMB}MB).\n\nProcessing large images may be slow or cause the browser to become unresponsive.\n\nDo you want to continue?`,
);
if (!proceed) {
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
}
let file: Blob = originalFile;
if (
!state.heicToLoaded || typeof globalThis.isHeic === "undefined" ||
typeof globalThis.heicTo === "undefined"
) {
alert(
"Image converter is still loading. Please wait a moment and try again.",
);
return;
}
try {
const isHeicFile = await globalThis.isHeic(originalFile);
if (isHeicFile) {
console.log("HEIC/HEIF file detected, converting to BMP...");
const convertedBlob = await globalThis.heicTo({
blob: originalFile,
type: "image/bmp",
quality: 1.0,
});
file = convertedBlob;
console.log("HEIC converted successfully to BMP:", {
originalSize: originalFile.size,
convertedSize: file.size,
type: file.type,
});
} else {
console.log(
`Processing ${
originalFile.type || fileExtension?.toUpperCase()
} image:`,
{
name: originalFile.name,
size: originalFile.size,
type: originalFile.type,
},
);
}
} catch (error) {
alert("Failed to process image. Please try another file.");
console.error("Image processing error:", error);
return;
}
const orientationReader = new FileReader();
orientationReader.onload = (orientEvent) => {
const arrayBuffer = orientEvent.target?.result as ArrayBuffer;
const orientation = getOrientation(arrayBuffer);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const response = await fetch(event.target?.result as string);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob, {
colorSpaceConversion: "none",
imageOrientation: "none",
premultiplyAlpha: "default",
resizeQuality: "high",
}).catch((err) => {
console.error("ImageBitmap creation failed:", err);
throw new Error(
"Unable to decode image. The file may be corrupted or in an unsupported format.",
);
});
let canvasWidth = imageBitmap.width;
let canvasHeight = imageBitmap.height;
if (orientation >= 5 && orientation <= 8) {
[canvasWidth, canvasHeight] = [canvasHeight, canvasWidth];
}
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
let ctx: CanvasRenderingContext2D | null = null;
let colorSpaceUsed = "sRGB";
try {
ctx = canvas.getContext("2d", {
colorSpace: "display-p3",
}) as CanvasRenderingContext2D;
if (ctx) {
colorSpaceUsed = "display-p3";
}
} catch (_e) {
ctx = canvas.getContext("2d");
}
if (!ctx) {
alert("Failed to create canvas context");
return;
}
applyOrientation(ctx, orientation, canvasWidth, canvasHeight);
ctx.drawImage(imageBitmap, 0, 0);
imageBitmap.close();
canvas.toBlob(
(blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
updateState({ previewUrl: url });
updateState({ zoom: 1 });
setTimeout(() => {
handleResize();
}, 100);
} else {
alert("Failed to convert image");
}
},
"image/png",
);
} catch (error) {
console.error("Image processing error:", error);
const errorMessage = error instanceof Error
? error.message
: "Unknown error occurred";
alert(
`Failed to process image: ${errorMessage}\n\nPlease try a different file.`,
);
}
};
reader.onerror = () => {
alert(
"Failed to read file. The file may be corrupted or inaccessible.",
);
};
reader.readAsDataURL(file);
};
orientationReader.onerror = () => {
alert("Failed to read file metadata. The file may be corrupted.");
};
orientationReader.readAsArrayBuffer(file);
}
function centerCropBox(resetZoom = false) {
let thisCropSize = 256;
if (!resetZoom) {
thisCropSize = state.cropSize;
}
if (!imgRef.current || !imgRef.current.parentElement) return;
setTimeout(() => {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement
.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
let centerY = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
if (imgRect.width >= thisCropSize) {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height >= thisCropSize) {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({ crop: { x: centerX, y: centerY, size: thisCropSize } });
}, 50);
}
function zoomIn() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.1, 10.0) });
centerCropBox();
}
function zoomOut() {
updateState({
zoom: Math.max(state.zoom - 0.1, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function zoomIn1() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.01, 10.0) });
centerCropBox();
}
function zoomOut1() {
updateState({
zoom: Math.max(state.zoom - 0.01, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function resetZoom() {
const safeMinZoom = isFinite(state.minZoom) && state.minZoom > 0
? state.minZoom
: 0.2;
updateState({
cropSize: 256,
zoom: Math.max(1, safeMinZoom),
zoomInDisabled: false,
});
centerCropBox(true);
}
function handleZoomInput(e: Event) {
const input = e.target as HTMLInputElement;
const value = parseFloat(input.value);
if (!isNaN(value) && isFinite(value)) {
const safeMinZoom = isFinite(state.minZoom) && state.minZoom > 0
? state.minZoom
: 0.2;
const clampedValue = Math.max(safeMinZoom, Math.min(value, 10.0));
updateState({ zoom: clampedValue });
centerCropBox();
}
}
function startDrag(_e: MouseEvent) {
updateState({ dragging: true });
}
function stopDrag() {
updateState({ dragging: false });
}
function drag(e: MouseEvent) {
let thisCropSize = state.cropSize;
if (!state.dragging || !imgRef.current || !imgRef.current.parentElement) {
return;
}
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const containerX = e.clientX - containerRect.left - thisCropSize / 2;
const containerY = e.clientY - containerRect.top - thisCropSize / 2;
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let x: number;
let y: number;
if (imgRect.width < thisCropSize) {
x = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
} else {
x = Math.max(
imgOffsetLeft,
Math.min(containerX, imgOffsetLeft + imgRect.width - thisCropSize),
);
}
if (imgRect.height < thisCropSize) {
y = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
} else {
y = Math.max(
imgOffsetTop,
Math.min(containerY, imgOffsetTop + imgRect.height - thisCropSize),
);
}
updateState({
crop: {
x,
y,
size: thisCropSize,
},
});
}
async function uploadCropped() {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
const imgX = state.crop.x - imgOffsetLeft;
const imgY = state.crop.y - imgOffsetTop;
const canvas = document.createElement("canvas");
canvas.width = state.crop.size;
canvas.height = state.crop.size;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(
imgRef.current,
imgX / imgRef.current.width * imgRef.current.naturalWidth,
imgY / imgRef.current.height * imgRef.current.naturalHeight,
state.crop.size / imgRef.current.width * imgRef.current.naturalWidth,
state.crop.size / imgRef.current.height * imgRef.current.naturalHeight,
0,
0,
state.crop.size,
state.crop.size,
);
const blob = await new Promise<Blob>((res) =>
canvas.toBlob((b) => res(b!), "image/jpeg", 0.92)
);
const form = new FormData();
form.append("photo", blob, "photo.jpg");
const token = localStorage.getItem("accessToken");
await fetch("/api/photo-upload", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: form,
});
alert("Uploaded!");
}
return (
<div
style={{
borderRadius: "12px",
position: "fixed",
width: "100%",
height: "100%",
zIndex: 1000,
background: "rgba(255, 255, 255, 1)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
onMouseMove={drag}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
>
<input
type="file"
accept="image/*"
onChange={handleFile}
ref={fileInputRef}
style={{ display: "none" }}
/>
<div
style={{
position: "relative",
overflow: "auto",
flex: "1",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{state.previewUrl
? (
<div
style={{
position: "relative",
display: "inline-block",
minWidth: "fit-content",
minHeight: "fit-content",
}}
>
<img
src={state.previewUrl}
ref={imgRef}
style={{
display: "block",
width: state.isLandscape
? `${getPixelDimensions().width}px`
: "auto",
height: !state.isLandscape
? `${getPixelDimensions().height}px`
: "auto",
margin: "0 auto",
}}
/>
<div
style={{
position: "absolute",
left: state.crop.x,
top: state.crop.y,
border: "2px solid #00ffcc",
width: state.crop.size,
height: state.crop.size,
cursor: "move",
boxShadow: "0 0 0 9999px rgba(0,0,0,0.25)",
}}
onMouseDown={startDrag}
>
</div>
</div>
)
: (
<div
style={{
textAlign: "center",
color: "#666",
}}
>
<div
style={{
fontSize: "18px",
marginBottom: "8px",
}}
>
Click Add to select a photo
</div>
<div
style={{
fontSize: "14px",
color: "#999",
}}
>
Adjust the crop before saving
</div>
</div>
)}
</div>
<div
style={{
height: "100px",
display: "flex",
gap: "6px",
alignItems: "center",
justifyContent: "center",
width: "100%",
padding: "0 16px",
flexShrink: 0,
}}
>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut}
disabled={!state.previewUrl}
>
−10
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut1}
disabled={!state.previewUrl}
>
−1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "6px 16px",
cursor: "pointer",
fontSize: "14px",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={resetZoom}
disabled={!state.previewUrl}
>
Reset
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn1}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+10
</button>
<div style={{ width: "16px" }}></div>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = ""}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={() => fileInputRef.current?.click()}
>
Add
</button>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseOver={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.background = "#f0f0f0")}
onMouseOut={(e) => e.currentTarget.style.background = ""}
onMouseDown={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.transform = "scale(0.95)")}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={uploadCropped}
disabled={!state.previewUrl}
>
Save
</button>
</div>
</div>
);
}