aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FrontEnd/src/components/ImageCropper.tsx262
-rw-r--r--FrontEnd/src/components/common.ts2
-rw-r--r--FrontEnd/src/utilities/geometry.ts291
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;
+}