Created
April 27, 2021 13:27
-
-
Save samselikoff/4a4e8d5e0ae7dcc1f2eb5149c1362e0c to your computer and use it in GitHub Desktop.
ImageCropper from Fitness App as of 4/27/21
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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