integratedhub/islands/PhotoCropperModal.tsx
2026-02-14 02:20:19 +00:00

731 lines
21 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";
interface PhotoCropperModalProps {
onComplete: (imageDataUrl: string) => void;
onCancel: () => void;
}
export default function PhotoCropperModal(
{ onComplete, onCancel }: PhotoCropperModalProps,
) {
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);
}
};
}, []);
// Auto-open file picker on mount
useEffect(() => {
setTimeout(() => {
fileInputRef.current?.click();
}, 100);
}, []);
async function handleFile(e: Event) {
const originalFile = (e.target as HTMLInputElement).files?.[0];
if (!originalFile) {
onCancel();
return;
}
const validImageTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
"image/heic",
"image/heif",
"image/tiff",
"image/avif",
"image/x-icon",
"image/vnd.microsoft.icon",
"image/ico",
"image/jfif",
"image/pjpeg",
"image/pjp",
];
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",
"tiff",
"tif",
"avif",
"ico",
"jfif",
"pjpeg",
"pjp",
].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;
// Check if file might be HEIC/HEIF format
const isHeicExtension = ["heic", "heif"].includes(fileExtension || "");
const isHeicMime = ["image/heic", "image/heif"].includes(originalFile.type);
const mightBeHeic = isHeicExtension || isHeicMime;
// Only require HEIC library for HEIC files
if (mightBeHeic) {
if (
!state.heicToLoaded || typeof globalThis.isHeic === "undefined" ||
typeof globalThis.heicTo === "undefined"
) {
alert(
"HEIC 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,
});
}
} catch (error) {
alert("Failed to convert HEIC image. Please try another file.");
console.error("HEIC processing error:", error);
return;
}
} else {
console.log(
`Processing ${
originalFile.type || fileExtension?.toUpperCase()
} image:`,
{
name: originalFile.name,
size: originalFile.size,
type: originalFile.type,
},
);
}
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;
try {
ctx = canvas.getContext("2d", {
colorSpace: "display-p3",
}) as CanvasRenderingContext2D;
} 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 startDrag(_e: MouseEvent) {
updateState({ dragging: true });
}
function stopDrag() {
updateState({ dragging: false });
}
function drag(e: MouseEvent) {
const 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,
},
});
}
function handleUseCropped() {
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 = 512;
canvas.height = 512;
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,
512,
512,
);
// Get base64 data URL
const dataUrl = canvas.toDataURL("image/png");
// Extract just the base64 part (remove "data:image/png;base64,")
const base64Data = dataUrl.split(",")[1];
onComplete(base64Data);
}
const pixelDimensions = getPixelDimensions();
const imageWidth = state.isLandscape && pixelDimensions.width !== "auto"
? Math.round(pixelDimensions.width)
: undefined;
const imageHeight = !state.isLandscape && pixelDimensions.height !== "auto"
? Math.round(pixelDimensions.height)
: undefined;
const cropStyle = {
"--crop-x": `${state.crop.x}px`,
"--crop-y": `${state.crop.y}px`,
"--crop-size": `${state.crop.size}px`,
} as React.CSSProperties;
return (
<div
class="photo-modal photo-modal--dark"
onMouseMove={drag}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
>
<input
type="file"
accept="image/*"
onChange={handleFile}
ref={fileInputRef}
class="hidden-file-input"
/>
<div class="photo-modal__viewer">
{state.previewUrl
? (
<div class="photo-modal__image-wrapper">
<img
src={state.previewUrl}
ref={imgRef}
class="photo-modal__image"
width={imageWidth}
height={imageHeight}
/>
<div
class="photo-modal__crop"
style={cropStyle}
onMouseDown={startDrag}
>
</div>
</div>
)
: (
<div class="photo-modal__empty">
<div class="photo-modal__empty-title">
Select a photo to crop
</div>
<div class="photo-modal__empty-sub">
Adjust the crop before using
</div>
</div>
)}
</div>
<div class="photo-modal__toolbar">
<button
type="button"
class="photo-button photo-button--square photo-button--neutral"
onClick={zoomOut}
disabled={!state.previewUrl}
>
−10
</button>
<button
type="button"
class="photo-button photo-button--square photo-button--neutral"
onClick={zoomOut1}
disabled={!state.previewUrl}
>
−1
</button>
<button
type="button"
class="photo-button photo-button--neutral"
onClick={resetZoom}
disabled={!state.previewUrl}
>
Reset
</button>
<button
type="button"
class="photo-button photo-button--square photo-button--neutral"
onClick={zoomIn1}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+1
</button>
<button
type="button"
class="photo-button photo-button--square photo-button--neutral"
onClick={zoomIn}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+10
</button>
<div class="photo-toolbar-spacer"></div>
<button
type="button"
class="photo-button photo-button--neutral"
onClick={() => fileInputRef.current?.click()}
>
Choose File
</button>
<button
type="button"
class="photo-button photo-button--primary"
onClick={handleUseCropped}
disabled={!state.previewUrl}
>
Use Image
</button>
<button
type="button"
class="photo-button photo-button--danger"
onClick={onCancel}
>
Cancel
</button>
</div>
</div>
);
}