diff options
Diffstat (limited to 'FrontEnd')
-rw-r--r-- | FrontEnd/src/components/ImageCropper.tsx | 262 | ||||
-rw-r--r-- | FrontEnd/src/components/common.ts | 2 | ||||
-rw-r--r-- | FrontEnd/src/utilities/geometry.ts | 291 |
3 files changed, 388 insertions, 167 deletions
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx index 1f530004..acbbbe0c 100644 --- a/FrontEnd/src/components/ImageCropper.tsx +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -1,29 +1,20 @@ -import { - useState, - useRef, - SyntheticEvent, - PointerEvent, - useMemo, - MutableRefObject, -} from "react"; +import { useState, useRef, PointerEvent } from "react"; import classnames from "classnames"; -import { UiLogicError } from "./common"; +import { UiLogicError, geometry } from "./common"; + import BlobImage from "./BlobImage"; import "./ImageCropper.css"; -// All in natural size of image. -export interface Clip { - left: number; - top: number; - width: number; - height: number; -} +const { Rect } = geometry; + +type Rect = geometry.Rect; +type Movement = geometry.Movement; -export function applyClipToImage( +export function crop( image: HTMLImageElement, - clip: Clip, + clip: Rect, mimeType: string, ): Promise<Blob> { return new Promise((resolve, reject) => { @@ -56,17 +47,13 @@ export function applyClipToImage( }); } -interface Movement { - x: number; - y: number; -} - interface ImageInfo { element: HTMLImageElement; width: number; height: number; ratio: number; landscape: boolean; + rect: Rect; } export interface CropConstraint { @@ -77,11 +64,7 @@ export interface CropConstraint { // maxClipHeight?: number; } -function generateImageInfo( - imageElement: HTMLImageElement | null, -): ImageInfo | null { - if (imageElement == null) return null; - +function generateImageInfo(imageElement: HTMLImageElement): ImageInfo { const { naturalWidth, naturalHeight } = imageElement; const imageRatio = naturalHeight / naturalWidth; @@ -91,108 +74,15 @@ function generateImageInfo( height: naturalHeight, ratio: imageRatio, landscape: imageRatio < 1, + rect: new Rect(0, 0, naturalWidth, naturalHeight), }; } -const emptyClip: Clip = { - left: 0, - top: 0, - width: 0, - height: 0, -}; - -const allClip : Clip = { - left: 0, - top: 0, - width: Number.MAX_VALUE, - height: Number.MAX_VALUE, -} - -// TODO: Continue here... mode... -function adjustClip( - clip: Clip, - mode: "move" | "resize" | "both", - imageSize: { width: number; height: number }, - targetRatio?: number | null | undefined, -): Clip { - class ClipGeometry { - constructor( - public left: number, - public top: number, - public width: number, - public height: number, - ) {} - - get right(): number { - return this.left + this.width; - } - - set right(value: number) { - this.width = this.left + value; - } - - get bottom(): number { - return this.top + this.height; - } - - set bottom(value: number) { - this.height = this.top + value; - } - - get ratio(): number { - return this.height / this.width; - } - - toClip(): Clip { - return { - left: this.left, - top: this.top, - width: this.width, - height: this.height, - }; - } - } - - const clipGeometry = new ClipGeometry( - clip.left, - clip.top, - clip.width, - clip.height, - ); - - // Make clip in image. - clipGeometry.left = Math.max(clipGeometry.left, 0); - clipGeometry.top = Math.max(clipGeometry.top, 0); - clipGeometry.right = Math.min(clipGeometry.right, imageSize.width); - clipGeometry.bottom = Math.min(clipGeometry.bottom, imageSize.height); - - // Make image "positive" - if (clipGeometry.right < clipGeometry.left) { - clipGeometry.right = clipGeometry.left; - } - if (clipGeometry.bottom < clipGeometry.top) { - clipGeometry.bottom = clipGeometry.top; - } - - // Now correct ratio - const currentRatio = clipGeometry.ratio; - if (targetRatio != null && targetRatio > 0 && currentRatio !== targetRatio) { - if (currentRatio < targetRatio) { - // too wide - clipGeometry.width = clipGeometry.height / targetRatio; - } else { - clipGeometry.height = clipGeometry.width * targetRatio; - } - } - - return clipGeometry.toClip(); -} - interface ImageCropperProps { - clip: Clip; + clip: Rect; image: Blob | string | null; - imageElementRef: MutableRefObject<HTMLImageElement | null>; - onImageLoad: (event: SyntheticEvent<HTMLImageElement>) => void; + imageElementCallback: (element: HTMLImageElement | null) => void; + onImageLoad: () => void; onMove: (movement: Movement) => void; onResize: (movement: Movement) => void; containerClassName?: string; @@ -204,28 +94,26 @@ export function useImageCrop( constraint?: CropConstraint; }, ): { - clip: Clip; - setClip: (clip: Clip) => void; + clip: Rect; + setClip: (clip: Rect) => void; canCrop: boolean; crop: () => Promise<Blob>; imageCropperProps: ImageCropperProps; } { const targetRatio = options?.constraint?.ratio; - const imageElementRef = useRef<HTMLImageElement | null>(null); - const [image, setImage] = useState<ImageInfo | null>(null); - const [clip, setClip] = useState<Clip>(emptyClip ); + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); + const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null); + const [clip, setClip] = useState<Rect>(Rect.empty); - if (imageElementRef.current == null && image != null) { - setImage(null); - setClip(emptyClip); + if (imageElement == null && imageInfo != null) { + setImageInfo(null); + setClip(Rect.empty); } - const canCrop = file != null && image != null; - - const adjustedClip = useMemo(() => { - return image == null ? emptyClip : adjustClip(clip, image, targetRatio); - }, [clip, image, targetRatio]); + const canCrop = file != null && imageElement != null && imageInfo != null; return { clip, @@ -233,19 +121,43 @@ export function useImageCrop( canCrop, crop() { if (!canCrop) throw new UiLogicError(); - return applyClipToImage(image.element, adjustedClip, file.type); + return crop(imageElement, clip, file.type); }, imageCropperProps: { - clip: adjustedClip, + clip, image: file, - imageElementRef: imageElementRef, - // TODO: Continue here... - onMove: , - onResize: , + imageElementCallback: setImageElement, + onMove: (movement) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + clip.copy().move(movement), + imageInfo.rect, + "move", + { + targetRatio, + }, + ); + setClip(newClip); + }, + onResize: (movement) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + clip.copy().expand(movement), + imageInfo.rect, + "resize", + { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, + ); + setClip(newClip); + }, onImageLoad: () => { - const image = generateImageInfo(imageElementRef.current); - setImage(image); - setClip(adjustClip(allClip, "both", image, targetRatio)); + if (imageElement == null) throw new UiLogicError(); + const image = generateImageInfo(imageElement); + setImageInfo(image); + setClip( + geometry.adjustRectToContainer(Rect.max, image.rect, "both", { + targetRatio, + }), + ); }, }, }; @@ -261,17 +173,17 @@ const imageCropperHandlerSize = 15; export function ImageCropper(props: ImageCropperProps) { function convertClipToElement( - clip: Clip, + clip: Rect, imageElement: HTMLImageElement, - ): Clip { + ): Rect { const xRatio = imageElement.clientWidth / imageElement.naturalWidth; const yRatio = imageElement.clientHeight / imageElement.naturalHeight; - return { + return Rect.from({ left: xRatio * clip.left, top: yRatio * clip.top, width: xRatio * clip.width, height: yRatio * clip.height, - }; + }); } function convertMovementFromElement( @@ -289,7 +201,7 @@ export function ImageCropper(props: ImageCropperProps) { const { clip, image, - imageElementRef, + imageElementCallback, onImageLoad, onMove, onResize, @@ -297,18 +209,21 @@ export function ImageCropper(props: ImageCropperProps) { } = props; const pointerStateRef = useRef<PointerState | null>(null); + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); - const clipInElement = - imageElementRef.current != null - ? convertClipToElement(clip, imageElementRef.current) - : emptyClip; + const clipInElement: Rect = + imageElement != null + ? convertClipToElement(clip, imageElement) + : Rect.empty; const actOnMovement = ( e: PointerEvent, change: (movement: Movement) => void, ) => { if ( - imageElementRef.current == null || + imageElement == null || pointerStateRef.current == null || pointerStateRef.current.pointerId != e.pointerId ) { @@ -322,12 +237,14 @@ export function ImageCropper(props: ImageCropperProps) { y: e.clientY - y, }; - change(movement); + pointerStateRef.current.x = e.clientX; + pointerStateRef.current.y = e.clientY; + + change(convertMovementFromElement(movement, imageElement)); }; const onPointerDown = (e: PointerEvent) => { - if (imageElementRef.current == null || pointerStateRef.current != null) - return; + if (imageElement == null || pointerStateRef.current != null) return; e.currentTarget.setPointerCapture(e.pointerId); @@ -362,16 +279,27 @@ export function ImageCropper(props: ImageCropperProps) { <div className={classnames("cru-image-cropper-container", containerClassName)} > - <BlobImage imgRef={imageElementRef} src={image} onLoad={onImageLoad} /> + <BlobImage + imgRef={(element) => { + setImageElement(element); + imageElementCallback(element); + }} + src={image} + onLoad={onImageLoad} + /> <div className="cru-image-cropper-mask-container"> <div className="cru-image-cropper-mask" - style={{ - left: clipInElement.left, - top: clipInElement.top, - width: clipInElement.width, - height: clipInElement.height, - }} + style={ + clipInElement == null + ? undefined + : { + left: clipInElement.left, + top: clipInElement.top, + width: clipInElement.width, + height: clipInElement.height, + } + } onPointerMove={onMaskPointerMove} onPointerDown={onPointerDown} onPointerUp={onPointerUp} diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts index b96388ab..171ebc48 100644 --- a/FrontEnd/src/components/common.ts +++ b/FrontEnd/src/components/common.ts @@ -11,3 +11,5 @@ export const themeColors = [ export type ThemeColor = (typeof themeColors)[number]; export { breakpoints } from "./breakpoints"; + +export * as geometry from "~src/utilities/geometry"; diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts new file mode 100644 index 00000000..12dd4bbb --- /dev/null +++ b/FrontEnd/src/utilities/geometry.ts @@ -0,0 +1,291 @@ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export interface Point { + x: number; + y: number; +} + +export type Movement = Point; + +export interface Size { + width: number; + height: number; +} + +export class Rect { + static empty = new Rect(0, 0, 0, 0); + static max = new Rect( + Number.MIN_VALUE, + Number.MIN_VALUE, + Number.MAX_VALUE, + Number.MAX_VALUE, + ); + + static from({ + left, + top, + width, + height, + }: { + left: number; + top: number; + width: number; + height: number; + }): Rect { + return new Rect(left, top, width, height); + } + + constructor( + public left: number, + public top: number, + public width: number, + public height: number, + ) {} + + get right(): number { + return this.left + this.width; + } + + set right(value: number) { + this.width = this.left + value; + } + + get bottom(): number { + return this.top + this.height; + } + + set bottom(value: number) { + this.height = this.top + value; + } + + get ratio(): number { + return this.height / this.width; + } + + get position(): Point { + return { + x: this.left, + y: this.top, + }; + } + + set position(value: Point) { + this.left = value.x; + this.top = value.y; + } + + get size(): Size { + return { + width: this.width, + height: this.height, + }; + } + + set size(value: Size) { + this.width = value.width; + this.height = value.height; + } + + get normalizedLeft(): number { + return this.width >= 0 ? this.left : this.right; + } + + get normalizedTop(): number { + return this.height >= 0 ? this.top : this.bottom; + } + + get normalizedRight(): number { + return this.width >= 0 ? this.right : this.left; + } + + get normalizedBottom(): number { + return this.height >= 0 ? this.bottom : this.top; + } + + get normalizedWidth(): number { + return Math.abs(this.width); + } + + get normalizedHeight(): number { + return Math.abs(this.height); + } + + get normalizedSize(): Size { + return { + width: this.normalizedWidth, + height: this.normalizedHeight, + }; + } + + get normalizedRatio(): number { + return Math.abs(this.ratio); + } + + normalize(): Rect { + if (this.width < 0) { + this.width = -this.width; + this.left -= this.width; + } + if (this.height < 0) { + this.height = -this.height; + this.top -= this.height; + } + return this; + } + + move(movement: Movement): Rect { + this.left += movement.x; + this.top += movement.y; + return this; + } + + expand(size: Size | Point): Rect { + if ("x" in size) { + this.width += size.x; + this.height += size.y; + } else { + this.width += size.width; + this.height += size.height; + } + return this; + } + + copy(): Rect { + return new Rect(this.left, this.top, this.width, this.height); + } +} + +export function adjustRectToContainer( + rect: Rect, + container: Rect, + mode: "move" | "resize" | "both", + options?: { + targetRatio?: number; + resizeNoFlip?: boolean; + ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height"; + }, +): Rect { + rect = rect.copy(); + container = container.copy().normalize(); + + if (process.env.NODE_ENV === "development") { + if (mode === "move") { + if (rect.normalizedWidth > container.width) { + console.warn( + "adjust rect (move): rect.normalizedWidth > container.normalizedWidth", + ); + } + if (rect.normalizedHeight > container.height) { + console.warn( + "adjust rect (move): rect.normalizedHeight > container.normalizedHeight", + ); + } + } + if (mode === "resize") { + if (rect.left < container.left) { + console.warn( + "adjust rect (resize): rect.left < container.normalizedLeft", + ); + } + if (rect.left > container.right) { + console.warn( + "adjust rect (resize): rect.left > container.normalizedRight", + ); + } + if (rect.top < container.top) { + console.warn( + "adjust rect (resize): rect.top < container.normalizedTop", + ); + } + if (rect.top > container.bottom) { + console.warn( + "adjust rect (resize): rect.top > container.normalizedBottom", + ); + } + } + } + + if (mode === "move") { + rect.left = + rect.width >= 0 + ? clamp(rect.left, container.left, container.right - rect.width) + : clamp(rect.left, container.left - rect.width, container.right); + rect.top = + rect.height >= 0 + ? clamp(rect.top, container.top, container.bottom - rect.height) + : clamp(rect.top, container.top - rect.height, container.bottom); + } else if (mode === "resize") { + const noFlip = options?.resizeNoFlip; + rect.right = clamp( + rect.right, + noFlip ? 0 : container.left, + container.right, + ); + rect.bottom = clamp( + rect.bottom, + noFlip ? 0 : container.top, + container.bottom, + ); + } else { + rect.left = clamp(rect.left, container.left, container.right); + rect.top = clamp(rect.top, container.top, container.bottom); + rect.right = clamp(rect.right, container.left, container.right); + rect.bottom = clamp(rect.bottom, container.top, container.bottom); + } + + // Now correct ratio + const currentRatio = rect.normalizedRatio; + let targetRatio = options?.targetRatio; + if (targetRatio != null) targetRatio = Math.abs(targetRatio); + if (targetRatio != null && currentRatio !== targetRatio) { + const { ratioCorrectBasedOn } = options ?? {}; + + const newWidth = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + const newHeight = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + + const newBottom = rect.top + newHeight; + const newRight = rect.left + newWidth; + + if (ratioCorrectBasedOn === "width") { + if (newBottom >= container.top && newBottom <= container.bottom) { + rect.height = newHeight; + } else { + rect.bottom = clamp(newBottom, container.top, container.bottom); + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } + } else if (ratioCorrectBasedOn === "height") { + if (newRight >= container.left && newRight <= container.right) { + rect.width = newWidth; + } else { + rect.right = clamp(newRight, container.left, container.right); + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else if (ratioCorrectBasedOn === "smaller") { + if (currentRatio > targetRatio) { + // too tall + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else { + if (currentRatio < targetRatio) { + // too wide + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } + } + + return rect; +} |