710 lines
20 KiB
TypeScript
710 lines
20 KiB
TypeScript
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) {
|
||
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<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!");
|
||
}
|
||
|
||
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--light"
|
||
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">
|
||
Click Add to select a photo
|
||
</div>
|
||
<div class="photo-modal__empty-sub">
|
||
Adjust the crop before saving
|
||
</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--ghost"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
Add
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="photo-button photo-button--ghost"
|
||
onClick={uploadCropped}
|
||
disabled={!state.previewUrl}
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|