Skip to content

Instantly share code, notes, and snippets.

@samselikoff
Created April 27, 2021 13:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save samselikoff/4a4e8d5e0ae7dcc1f2eb5149c1362e0c to your computer and use it in GitHub Desktop.
Save samselikoff/4a4e8d5e0ae7dcc1f2eb5149c1362e0c to your computer and use it in GitHub Desktop.
ImageCropper from Fitness App as of 4/27/21
import { animate, motion, useMotionValue } from "framer-motion";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { usePrevious } from "react-use";
import { useGesture } from "react-use-gesture";
export function ImageCropper({
dataURL,
aspectRatio,
crop,
onCropChange,
guide,
}) {
let [imageContainerEl, setImageContainerEl] = useState();
let [image, setImage] = useState(false);
let previousDataURL = usePrevious(dataURL);
let dataURLHasntChanged =
previousDataURL && dataURL && previousDataURL === dataURL;
let loadedImage = dataURLHasntChanged ? image : null;
let dims =
imageContainerEl && loadedImage
? getDims({ image: loadedImage, imageContainerEl, aspectRatio })
: null;
useEffect(() => {
async function f() {
let image = await getImageFromDataUrl(dataURL);
setImage(image);
}
f();
}, [dataURL]);
return (
<>
<div
className={`relative overflow-hidden bg-black ring-4 ring-white`}
style={{
paddingBottom: `${(1 / aspectRatio) * 100}%`,
}}
>
<div
ref={setImageContainerEl}
className="absolute inset-0 w-full h-full"
>
{loadedImage && imageContainerEl && (
<ImageCropperInner
dims={dims}
src={loadedImage.src}
crop={crop}
onCropChange={onCropChange}
guide={guide}
/>
)}
</div>
</div>
</>
);
}
function ImageCropperInner({ src, dims, crop, onCropChange, onLoad, guide }) {
let [isDragging, setIsDragging] = useState(false);
let [isPinching, setIsPinching] = useState(false);
let imageRef = useRef();
let centerCropX =
((dims.image.naturalWidth - dims.croppedImage.width) /
2 /
dims.image.naturalWidth) *
dims.image.DOMWidth;
let defaultCrop = {
x: -centerCropX,
y: 0,
scale: 1,
};
let startingCrop = crop
? convertAbsoluteCropToRelativeCrop({
absoluteCrop: crop,
dims,
})
: defaultCrop;
let x = useMotionValue(startingCrop.x);
let y = useMotionValue(startingCrop.y);
let scale = useMotionValue(startingCrop.scale);
// If the ImageCropper was rendered with no crop, notify the parent of the defaultCrop.
useEffect(() => {
if (!crop) {
let newAbsoluteCrop = convertRelativeCropToAbsoluteCrop({
relativeCrop: defaultCrop,
dims,
});
onCropChange(newAbsoluteCrop);
}
});
// React to changes to the crop and src props
let previousCrop = usePrevious(crop);
let cropHasChanged = previousCrop && crop && previousCrop !== crop;
let previousSrc = usePrevious(src);
let srcHasChanged = previousSrc && src && previousSrc !== src;
useLayoutEffect(() => {
if (previousCrop && crop && !srcHasChanged) {
let newCrop = convertAbsoluteCropToRelativeCrop({
absoluteCrop: crop,
dims,
});
x.set(newCrop.x);
y.set(newCrop.y);
scale.set(newCrop.scale);
}
}, [
crop,
dims,
scale,
x,
y,
previousCrop,
srcHasChanged,
cropHasChanged,
defaultCrop.x,
defaultCrop.y,
defaultCrop.scale,
]);
useGesture(
{
onDrag: ({ dragging, pinching, cancel, movement: [dx, dy] }) => {
if (pinching) return cancel();
setIsDragging(dragging);
x.stop();
y.stop();
let imageDOMRect = imageRef.current.getBoundingClientRect();
let widthOverhang = (imageDOMRect.width - dims.image.DOMWidth) / 2;
let heightOverhang = (imageDOMRect.height - dims.image.DOMHeight) / 2;
let maxX = widthOverhang;
let minX =
-(imageDOMRect.width - dims.imageContainer.DOMRect.width) +
widthOverhang;
let maxY = heightOverhang;
let minY =
-(imageDOMRect.height - dims.imageContainer.DOMRect.height) +
heightOverhang;
x.set(dampen(dx, [minX, maxX]));
y.set(dampen(dy, [minY, maxY]));
},
onPinch: ({
pinching,
event,
memo,
origin: [pinchOriginX, pinchOriginY],
da: [distance],
initial: [initialDistance],
// offset: [d],
}) => {
event.preventDefault();
setIsPinching(pinching);
x.stop();
y.stop();
memo ??= {
imageDOMRect: imageRef.current.getBoundingClientRect(),
crop: { x: x.get(), y: y.get(), scale: scale.get() },
};
// From image-cropper YT series
// let transformOriginX = memo.crop.x + memo.imageDOMRect.width / 2;
// let transformOriginY = memo.crop.y + memo.imageDOMRect.height / 2;
// let displacementX = (transformOriginX - pinchOriginX) / memo.crop.scale;
// let displacementY = (transformOriginY - pinchOriginY) / memo.crop.scale;
// let initialOffsetDistance = (memo.crop.scale - 1) * 200;
// let movementDistance = d - initialOffsetDistance;
// scale.set(1 + d / 200);
// x.set(memo.crop.x + (displacementX * movementDistance) / 200);
// y.set(memo.crop.y + (displacementY * movementDistance) / 200);
// Old from alpha where initial is 60
let movementScaled = distance / initialDistance;
let newScale = memo.crop.scale * movementScaled;
let transformOriginX =
memo.imageDOMRect.x + memo.imageDOMRect.width / 2;
let transformOriginY =
memo.imageDOMRect.y + memo.imageDOMRect.height / 2;
let displacementX = transformOriginX - pinchOriginX;
let displacementY = transformOriginY - pinchOriginY;
let newX = memo.crop.x + (movementScaled - 1) * displacementX;
let newY = memo.crop.y + (movementScaled - 1) * displacementY;
if (newScale >= 1) {
scale.set(newScale);
x.set(newX);
y.set(newY);
} else {
scale.set(1);
}
return memo;
},
onDragEnd: maybeAdjustImage,
onPinchEnd: maybeAdjustImage,
},
{
drag: {
initial: () => [x.get(), y.get()],
},
pinch: {
distanceBounds: { min: 0 },
},
domTarget: imageRef,
eventOptions: { passive: false },
}
);
function maybeAdjustImage() {
let newCrop = { x: x.get(), y: y.get(), scale: scale.get() };
let imageDOMRect = imageRef.current.getBoundingClientRect();
let imageContainerDOMRect = dims.imageContainer.DOMRect;
let originalWidth = imageDOMRect.width / scale.get();
let widthOverhang = (imageDOMRect.width - originalWidth) / 2;
let originalHeight = imageDOMRect.height / scale.get();
let heightOverhang = (imageDOMRect.height - originalHeight) / 2;
if (imageDOMRect.left > imageContainerDOMRect.left) {
newCrop.x = widthOverhang;
} else if (imageDOMRect.right < imageContainerDOMRect.right) {
newCrop.x =
-(imageDOMRect.width - imageContainerDOMRect.width) + widthOverhang;
}
if (imageDOMRect.top > imageContainerDOMRect.top) {
newCrop.y = heightOverhang;
} else if (imageDOMRect.bottom < imageContainerDOMRect.bottom) {
newCrop.y =
-(imageDOMRect.height - imageContainerDOMRect.height) + heightOverhang;
}
animate(x, newCrop.x, {
type: "tween",
duration: 0.4,
ease: [0.25, 1, 0.5, 1],
});
animate(y, newCrop.y, {
type: "tween",
duration: 0.4,
ease: [0.25, 1, 0.5, 1],
});
let newAbsoluteCrop = convertRelativeCropToAbsoluteCrop({
dims,
relativeCrop: newCrop,
});
onCropChange(newAbsoluteCrop);
}
return (
<>
<motion.img
src={src}
onLoad={onLoad}
ref={imageRef}
style={{
x: x,
y: y,
scale: scale,
touchAction: "none",
userSelect: "none",
MozUserSelect: "none",
WebkitUserDrag: "none",
}}
className={`
${isDragging ? "cursor-grabbing" : "cursor-grab"}
${dims.image.isWiderThanContainer ? "w-auto h-full" : "w-full h-auto"}
"w-auto h-full"
} relative max-w-none max-h-none`}
/>
{guide({ dragging: isDragging, pinching: isPinching })}
{/* Grid of thrids */}
{/* <div
className={`pointer-events-none absolute inset-0 transition duration-300 ${
isDragging || isPinching ? "opacity-100" : "opacity-0"
}`}
>
<div className="absolute inset-0 flex flex-col">
<div className="self-stretch flex-1 border-b border-gray-50 "></div>
<div className="self-stretch flex-1 border-b border-gray-50 "></div>
<div className="self-stretch flex-1"></div>
</div>
<div className="absolute inset-0 flex">
<div className="self-stretch flex-1 border-r border-gray-50 "></div>
<div className="self-stretch flex-1 border-r border-gray-50 "></div>
<div className="self-stretch flex-1"></div>
</div>
</div> */}
</>
);
}
// async function getImageFromDataUrl(dataUrl) {
// let image = new window.Image();
// image.src = dataUrl;
// return await new Promise((resolve) => {
// image.onload = () => {
// resolve(image);
// };
// });
// }
async function getImageFromDataUrl(dataUrl) {
let image = new window.Image();
image.src = dataUrl;
await new Promise((resolve) => {
image.onload = resolve;
});
return image;
}
// async function getLoadedRemoteImageFromUrl(url) {
// let image = new window.Image();
// await new Promise((resolve) => {
// image.onload = resolve;
// image.src = url;
// });
// return image;
// }
function dampen(val, [min, max]) {
if (val > max) {
let extra = val - max;
let dampenedExtra = extra > 0 ? Math.sqrt(extra) : -Math.sqrt(-extra);
return max + dampenedExtra * 4;
} else if (val < min) {
let extra = val - min;
let dampenedExtra = extra > 0 ? Math.sqrt(extra) : -Math.sqrt(-extra);
return min + dampenedExtra * 4;
} else {
return val;
}
}
function convertAbsoluteCropToRelativeCrop({ absoluteCrop, dims }) {
let imageDOMRectWidth = dims.image.DOMWidth * absoluteCrop.scale;
let widthOverhang = (imageDOMRectWidth - dims.image.DOMWidth) / 2;
let imageDOMRectHeight = dims.image.DOMHeight * absoluteCrop.scale;
let heightOverhang = (imageDOMRectHeight - dims.image.DOMHeight) / 2;
let relativeX =
(absoluteCrop.x *
absoluteCrop.scale *
dims.imageContainer.DOMRect.width *
-1) /
dims.croppedImage.width +
widthOverhang;
let relativeY =
(absoluteCrop.y *
absoluteCrop.scale *
dims.imageContainer.DOMRect.height *
-1) /
dims.croppedImage.height +
heightOverhang;
return {
x: relativeX,
y: relativeY,
scale: absoluteCrop.scale,
};
}
function convertRelativeCropToAbsoluteCrop({ dims, relativeCrop }) {
let imageDOMRectWidth = dims.image.DOMWidth * relativeCrop.scale;
let widthOverhang = (imageDOMRectWidth - dims.image.DOMWidth) / 2;
let imageDOMRectHeight = dims.image.DOMHeight * relativeCrop.scale;
let heightOverhang = (imageDOMRectHeight - dims.image.DOMHeight) / 2;
let xPixels = (relativeCrop.x - widthOverhang) * -1;
let xPixelsPercentage = xPixels / dims.imageContainer.DOMRect.width;
let absoluteX =
(xPixelsPercentage * dims.croppedImage.width) / relativeCrop.scale;
let yPixels = (relativeCrop.y - heightOverhang) * -1;
let yPixelsPercentage = yPixels / dims.imageContainer.DOMRect.height;
let absoluteY =
(yPixelsPercentage * dims.croppedImage.height) / relativeCrop.scale;
return {
x: absoluteX,
y: absoluteY,
scale: relativeCrop.scale,
};
}
/*
The main reason for this function is because we can know these values before the image loads.
*/
function getDims({ image, imageContainerEl, aspectRatio }) {
let imageAspectRatio = image.width / image.height;
let imageIsWiderThanContainer = imageAspectRatio > aspectRatio;
let imageContainerDOMRect = imageContainerEl.getBoundingClientRect();
let naturalWidth = image.naturalWidth;
let naturalHeight = image.naturalHeight;
let DOMWidth = imageIsWiderThanContainer
? imageAspectRatio * imageContainerDOMRect.height
: imageContainerDOMRect.width;
let DOMHeight = imageIsWiderThanContainer
? imageContainerDOMRect.height
: (1 / imageAspectRatio) * imageContainerDOMRect.width;
let croppedImageWidth = imageIsWiderThanContainer
? aspectRatio * naturalHeight
: naturalWidth;
let croppedImageHeight = imageIsWiderThanContainer
? naturalHeight
: (1 / aspectRatio) * naturalWidth;
return {
croppedImage: {
aspectRatio,
width: croppedImageWidth,
height: croppedImageHeight,
},
image: {
naturalWidth,
naturalHeight,
DOMWidth,
DOMHeight,
isWiderThanContainer: imageIsWiderThanContainer,
},
imageContainer: {
DOMRect: imageContainerDOMRect,
},
};
}
export function DebugCroppedImage({ file, crop, aspectRatio }) {
let [croppedImageUrl, setCroppedImageUrl] = useState();
useEffect(() => {
async function f() {
if (file) {
let image = await getLoadedImageFromFile(file);
let croppedImage = await getCroppedImage({
image,
crop,
fileName: file.name,
aspectRatio,
});
setCroppedImageUrl(URL.createObjectURL(croppedImage));
}
}
f();
}, [file, aspectRatio, crop]);
return (
<>
{croppedImageUrl && (
<div className="p-4">
<img src={croppedImageUrl} alt="" />
</div>
)}
</>
);
}
/**
* @param {HTMLImageElement} image - Image File Object
* @param {Object} crop - crop Object
* @param {String} fileName - Name of the returned file in Promise
*/
async function getCroppedImage({ image, crop, fileName, aspectRatio }) {
let { naturalWidth, naturalHeight } = image;
let imageAspectRatio = naturalWidth / naturalHeight;
let imageIsWiderThanContainer = imageAspectRatio > aspectRatio;
let width = imageIsWiderThanContainer
? aspectRatio * naturalHeight
: naturalWidth;
let height = imageIsWiderThanContainer
? naturalHeight
: (1 / aspectRatio) * naturalWidth;
const canvas = document.createElement("canvas");
const scaleX = crop.scale;
const scaleY = crop.scale;
canvas.width = width / scaleX;
canvas.height = height / scaleY;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, crop.x, crop.y, width, height, 0, 0, width, height);
// As Base64 string
// const base64Image = canvas.toDataURL('image/jpeg');
// As a blob
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
blob.name = fileName;
resolve(blob);
},
"image/jpeg",
1
);
});
}
async function getLoadedImageFromFile(file) {
let image = new window.Image();
await new Promise((resolve) => {
image.onload = resolve;
image.src = URL.createObjectURL(file);
});
return image;
}
// function usePrevious(value) {
// const ref = useRef();
// useEffect(() => {
// ref.current = value;
// }, [value]);
// return ref.current;
// }
/*
DEBUG CODE
Render this to see the cropped image. (Should prob turn this into another component.)
This gives you the cropped image for a given image and crop:
//@param {HTMLImageElement} image - Image File Object
// @param {Object} crop - crop Object
// @param {String} fileName - Name of the returned file in Promise
async function getCroppedImage({ image, crop, fileName }) {
let { naturalWidth, naturalHeight } = image;
let imageAspectRatio = naturalWidth / naturalHeight;
let imageIsWiderThanContainer = imageAspectRatio > CONTAINER_ASPECT_RATIO;
let width = imageIsWiderThanContainer
? CONTAINER_ASPECT_RATIO * naturalHeight
: naturalWidth;
let height = imageIsWiderThanContainer
? naturalHeight
: (1 / CONTAINER_ASPECT_RATIO) * naturalWidth;
const canvas = document.createElement("canvas");
const scaleX = crop.scale;
const scaleY = crop.scale;
canvas.width = width / scaleX;
canvas.height = height / scaleY;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, crop.x, crop.y, width, height, 0, 0, width, height);
// As Base64 string
// const base64Image = canvas.toDataURL('image/jpeg');
// As a blob
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
blob.name = fileName;
resolve(blob);
},
"image/jpeg",
1
);
});
}
Use it in an effect to get a croppedImageURL:
let [croppedImageUrl, setCroppedImageUrl] = useState();
useEffect(() => {
async function f() {
if (frontPhoto && frontPhotoCrop) {
let image = await getLoadedImageFromFile(frontPhoto);
let croppedImage = await getCroppedImage({
image,
crop: frontPhotoCrop,
fileName: frontPhoto.name,
});
setCroppedImageUrl(URL.createObjectURL(croppedImage));
}
}
f();
}, [frontPhoto, frontPhotoCrop]);
and then render it:
{croppedImageUrl && (
<div>
<img src={croppedImageUrl} alt="" />
</div>
)}
Might need this:
async function getLoadedImageFromFile(file) {
let image = new window.Image();
await new Promise((resolve) => {
image.onload = resolve;
image.src = URL.createObjectURL(file);
});
return image;
}
Or this:
async function getLoadedImageFromUrl(url) {
let image = new window.Image();
await new Promise((resolve) => {
image.onload = resolve;
image.src = url;
});
return image;
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment