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(null); const fileInputRef = useRef(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) { 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, }, }); } 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((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!"); } 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 (
{state.previewUrl ? (
) : (
Click Add to select a photo
Adjust the crop before saving
)}
); }