193 lines
5.1 KiB
TypeScript
193 lines
5.1 KiB
TypeScript
import { useCallback, useState } from "preact/hooks";
|
|
|
|
export function usePhotoEditorState() {
|
|
const [state, setState] = useState({
|
|
previewUrl: null as string | null,
|
|
cropSize: 256,
|
|
crop: { x: 0, y: 0, size: 256 },
|
|
zoom: 1.0,
|
|
minZoom: 0.2,
|
|
maxWidth: 0,
|
|
maxHeight: 0,
|
|
baseDimensions: { width: 0, height: 0 },
|
|
isLandscape: true,
|
|
dragging: false,
|
|
heicToLoaded: false,
|
|
zoomInDisabled: false,
|
|
});
|
|
|
|
const updateState = useCallback((updates: Partial<typeof state>) => {
|
|
setState((prev) => ({ ...prev, ...updates }));
|
|
}, []);
|
|
|
|
const resetCropState = useCallback(() => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
cropSize: 256,
|
|
crop: { x: 0, y: 0, size: 256 },
|
|
zoom: Math.max(1, prev.minZoom),
|
|
}));
|
|
}, []);
|
|
|
|
return { state, updateState, resetCropState };
|
|
}
|
|
|
|
export function useImageProcessor(state: { heicToLoaded: boolean }) {
|
|
const [processing, setProcessing] = useState(false);
|
|
|
|
const processImageFile = useCallback(
|
|
async (file: File): Promise<string | null> => {
|
|
setProcessing(true);
|
|
try {
|
|
const validImageTypes = [
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/bmp",
|
|
"image/svg+xml",
|
|
"image/heic",
|
|
"image/heif",
|
|
];
|
|
|
|
const fileExtension = file.name.toLowerCase().split(".").pop();
|
|
const isImageByType = file.type.startsWith("image/") ||
|
|
validImageTypes.includes(file.type);
|
|
const isImageByExtension = [
|
|
"jpg",
|
|
"jpeg",
|
|
"png",
|
|
"gif",
|
|
"webp",
|
|
"bmp",
|
|
"svg",
|
|
"heic",
|
|
"heif",
|
|
]
|
|
.includes(fileExtension || "");
|
|
|
|
if (!isImageByType && !isImageByExtension) {
|
|
alert(
|
|
`Unsupported file type: ${
|
|
file.type || "unknown"
|
|
}\n\nPlease select an image file.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const maxRecommendedSize = 20 * 1024 * 1024;
|
|
if (file.size > maxRecommendedSize) {
|
|
const sizeMB = (file.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) return null;
|
|
}
|
|
|
|
let processedFile: Blob = file;
|
|
|
|
if (state.heicToLoaded && globalThis.isHeic && globalThis.heicTo) {
|
|
try {
|
|
const isHeicFile = await globalThis.isHeic(file);
|
|
if (isHeicFile) {
|
|
processedFile = await globalThis.heicTo({
|
|
blob: file,
|
|
type: "image/bmp",
|
|
quality: 1.0,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("HEIC conversion failed:", error);
|
|
}
|
|
}
|
|
const dataUrl = URL.createObjectURL(processedFile);
|
|
return Promise.resolve(dataUrl);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
},
|
|
[state.heicToLoaded],
|
|
);
|
|
|
|
return { processImageFile, processing };
|
|
}
|
|
|
|
export function useCropCalculator(
|
|
state: {
|
|
isLandscape: boolean;
|
|
baseDimensions: { width: number; height: number };
|
|
zoom: number;
|
|
cropSize: number;
|
|
maxHeight: number;
|
|
maxWidth: number;
|
|
},
|
|
updateState: (updates: Record<string, unknown>) => void,
|
|
) {
|
|
const getPixelDimensions = useCallback(() => {
|
|
function getPixelDimensions() {
|
|
const longerDimension = state.isLandscape
|
|
? state.baseDimensions.width
|
|
: state.baseDimensions.height;
|
|
|
|
if (longerDimension <= 0) {
|
|
return {
|
|
width: 256,
|
|
height: 256,
|
|
actualZoom: 1,
|
|
overRunPercent: 0,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (state.isLandscape) {
|
|
return {
|
|
width: Math.max(256, clampedDimension),
|
|
height: "auto" as const,
|
|
actualZoom: Math.max(0.01, actualZoom),
|
|
overRunPercent,
|
|
};
|
|
} else {
|
|
return {
|
|
width: "auto" as const,
|
|
height: Math.max(256, clampedDimension),
|
|
actualZoom: Math.max(0.01, actualZoom),
|
|
overRunPercent,
|
|
};
|
|
}
|
|
}
|
|
|
|
return getPixelDimensions();
|
|
}, [state, updateState]);
|
|
|
|
return { getPixelDimensions };
|
|
}
|