From b66a57071316434356e77e294ec22181e4db54d5 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 28 Aug 2023 01:41:41 +0800
Subject: ...

---
 FrontEnd/src/components/ImageCropper.tsx | 564 ++++++++++++++++++-------------
 1 file changed, 324 insertions(+), 240 deletions(-)

(limited to 'FrontEnd/src/components/ImageCropper.tsx')

diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
index f23994e2..1f530004 100644
--- a/FrontEnd/src/components/ImageCropper.tsx
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -1,312 +1,396 @@
-import * as React from "react";
+import {
+  useState,
+  useRef,
+  SyntheticEvent,
+  PointerEvent,
+  useMemo,
+  MutableRefObject,
+} from "react";
 import classnames from "classnames";
 
-import { UiLogicError } from "~src/common";
+import { UiLogicError } from "./common";
+import BlobImage from "./BlobImage";
 
 import "./ImageCropper.css";
-import BlobImage from "./BlobImage";
 
+// All in natural size of image.
 export interface Clip {
   left: number;
   top: number;
   width: number;
+  height: number;
 }
 
-interface NormailizedClip extends Clip {
-  height: number;
+export function applyClipToImage(
+  image: HTMLImageElement,
+  clip: Clip,
+  mimeType: string,
+): Promise<Blob> {
+  return new Promise((resolve, reject) => {
+    const canvas = document.createElement("canvas");
+    canvas.width = clip.width;
+    canvas.height = clip.height;
+    const context = canvas.getContext("2d");
+
+    if (context == null) throw new Error("Failed to create context.");
+
+    context.drawImage(
+      image,
+      clip.left,
+      clip.top,
+      clip.width,
+      clip.height,
+      0,
+      0,
+      clip.width,
+      clip.height,
+    );
+
+    canvas.toBlob((blob) => {
+      if (blob == null) {
+        reject(new Error("canvas.toBlob returns null"));
+      } else {
+        resolve(blob);
+      }
+    }, mimeType);
+  });
+}
+
+interface Movement {
+  x: number;
+  y: number;
 }
 
 interface ImageInfo {
+  element: HTMLImageElement;
   width: number;
   height: number;
-  landscape: boolean;
   ratio: number;
-  maxClipWidth: number;
-  maxClipHeight: number;
+  landscape: boolean;
 }
 
-interface ImageCropperSavedState {
-  clip: NormailizedClip;
-  x: number;
-  y: number;
-  pointerId: number;
+export interface CropConstraint {
+  ratio?: number;
+  //   minClipWidth?: number;
+  //   minClipHeight?: number;
+  //   maxClipWidth?: number;
+  //   maxClipHeight?: number;
 }
 
-export interface ImageCropperProps {
-  clip: Clip | null;
-  image: string | Blob;
-  onChange: (clip: Clip) => void;
-  imageElementCallback?: (element: HTMLImageElement | null) => void;
-  className?: string;
+function generateImageInfo(
+  imageElement: HTMLImageElement | null,
+): ImageInfo | null {
+  if (imageElement == null) return null;
+
+  const { naturalWidth, naturalHeight } = imageElement;
+  const imageRatio = naturalHeight / naturalWidth;
+
+  return {
+    element: imageElement,
+    width: naturalWidth,
+    height: naturalHeight,
+    ratio: imageRatio,
+    landscape: imageRatio < 1,
+  };
 }
 
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
-  const { clip, image, onChange, imageElementCallback, className } = props;
+const emptyClip: Clip = {
+  left: 0,
+  top: 0,
+  width: 0,
+  height: 0,
+};
 
-  const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
-    null,
-  );
-  const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+const allClip : Clip = {
+  left: 0,
+  top: 0,
+  width: Number.MAX_VALUE,
+  height: Number.MAX_VALUE,
+}
 
-  const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
-    if (c == null) {
-      return { left: 0, top: 0, width: 0, height: 0 };
+// 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;
     }
 
-    return {
-      left: c.left || 0,
-      top: c.top || 0,
-      width: c.width || 0,
-      height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
-    };
-  };
+    set right(value: number) {
+      this.width = this.left + value;
+    }
 
-  const c = normalizeClip(clip);
+    get bottom(): number {
+      return this.top + this.height;
+    }
 
-  const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+    set bottom(value: number) {
+      this.height = this.top + value;
+    }
 
-  const onImageRef = React.useCallback(
-    (e: HTMLImageElement | null) => {
-      imgElementRef.current = e;
-      if (imageElementCallback != null && e == null) {
-        imageElementCallback(null);
-      }
-    },
-    [imageElementCallback],
-  );
+    get ratio(): number {
+      return this.height / this.width;
+    }
 
-  const onImageLoad = React.useCallback(
-    (e: React.SyntheticEvent<HTMLImageElement>) => {
-      const img = e.currentTarget;
-      const landscape = img.naturalWidth >= img.naturalHeight;
-
-      const info = {
-        width: img.naturalWidth,
-        height: img.naturalHeight,
-        landscape,
-        ratio: img.naturalHeight / img.naturalWidth,
-        maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
-        maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+    toClip(): Clip {
+      return {
+        left: this.left,
+        top: this.top,
+        width: this.width,
+        height: this.height,
       };
-      setImageInfo(info);
-      onChange({ left: 0, top: 0, width: info.maxClipWidth });
-      if (imageElementCallback != null) {
-        imageElementCallback(img);
-      }
-    },
-    [onChange, imageElementCallback],
-  );
+    }
+  }
 
-  const onPointerDown = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState != null) return;
-      e.currentTarget.setPointerCapture(e.pointerId);
-      setOldState({
-        x: e.clientX,
-        y: e.clientY,
-        clip: c,
-        pointerId: e.pointerId,
-      });
-    },
-    [oldState, c],
+  const clipGeometry = new ClipGeometry(
+    clip.left,
+    clip.top,
+    clip.width,
+    clip.height,
   );
 
-  const onPointerUp = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null || oldState.pointerId !== e.pointerId) return;
-      e.currentTarget.releasePointerCapture(e.pointerId);
-      setOldState(null);
-    },
-    [oldState],
-  );
-
-  const onPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
-
-      const oldClip = oldState.clip;
-
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
-      const { current: imgElement } = imgElementRef;
-
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
+  // 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;
+    }
+  }
 
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.y / imgElement.height,
-      };
+  return clipGeometry.toClip();
+}
 
-      const newRatio = {
-        x: oldClip.left + moveRatio.x,
-        y: oldClip.top + moveRatio.y,
-      };
-      if (newRatio.x < 0) {
-        newRatio.x = 0;
-      } else if (newRatio.x > 1 - oldClip.width) {
-        newRatio.x = 1 - oldClip.width;
-      }
-      if (newRatio.y < 0) {
-        newRatio.y = 0;
-      } else if (newRatio.y > 1 - oldClip.height) {
-        newRatio.y = 1 - oldClip.height;
-      }
+interface ImageCropperProps {
+  clip: Clip;
+  image: Blob | string | null;
+  imageElementRef: MutableRefObject<HTMLImageElement | null>;
+  onImageLoad: (event: SyntheticEvent<HTMLImageElement>) => void;
+  onMove: (movement: Movement) => void;
+  onResize: (movement: Movement) => void;
+  containerClassName?: string;
+}
 
-      onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+export function useImageCrop(
+  file: File | null,
+  options?: {
+    constraint?: CropConstraint;
+  },
+): {
+  clip: Clip;
+  setClip: (clip: Clip) => 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  );
+
+  if (imageElementRef.current == null && image != null) {
+    setImage(null);
+    setClip(emptyClip);
+  }
+
+  const canCrop = file != null && image != null;
+
+  const adjustedClip = useMemo(() => {
+    return image == null ? emptyClip : adjustClip(clip, image, targetRatio);
+  }, [clip, image, targetRatio]);
+
+  return {
+    clip,
+    setClip,
+    canCrop,
+    crop() {
+      if (!canCrop) throw new UiLogicError();
+      return applyClipToImage(image.element, adjustedClip, file.type);
     },
-    [oldState, onChange],
-  );
-
-  const onHandlerPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
+    imageCropperProps: {
+      clip: adjustedClip,
+      image: file,
+      imageElementRef: imageElementRef,
+      // TODO: Continue here...
+      onMove: ,
+      onResize: ,
+      onImageLoad: () => {
+        const image = generateImageInfo(imageElementRef.current);
+        setImage(image);
+        setClip(adjustClip(allClip, "both", image, targetRatio));
+      },
+    },
+  };
+}
 
-      const oldClip = oldState.clip;
+interface PointerState {
+  x: number;
+  y: number;
+  pointerId: number;
+}
 
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+const imageCropperHandlerSize = 15;
 
-      const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+export function ImageCropper(props: ImageCropperProps) {
+  function convertClipToElement(
+    clip: Clip,
+    imageElement: HTMLImageElement,
+  ): Clip {
+    const xRatio = imageElement.clientWidth / imageElement.naturalWidth;
+    const yRatio = imageElement.clientHeight / imageElement.naturalHeight;
+    return {
+      left: xRatio * clip.left,
+      top: yRatio * clip.top,
+      width: xRatio * clip.width,
+      height: yRatio * clip.height,
+    };
+  }
+
+  function convertMovementFromElement(
+    move: Movement,
+    imageElement: HTMLImageElement,
+  ): Movement {
+    const xRatio = imageElement.naturalWidth / imageElement.clientWidth;
+    const yRatio = imageElement.naturalHeight / imageElement.clientHeight;
+    return {
+      x: xRatio * move.x,
+      y: yRatio * move.y,
+    };
+  }
+
+  const {
+    clip,
+    image,
+    imageElementRef,
+    onImageLoad,
+    onMove,
+    onResize,
+    containerClassName,
+  } = props;
+
+  const pointerStateRef = useRef<PointerState | null>(null);
+
+  const clipInElement =
+    imageElementRef.current != null
+      ? convertClipToElement(clip, imageElementRef.current)
+      : emptyClip;
+
+  const actOnMovement = (
+    e: PointerEvent,
+    change: (movement: Movement) => void,
+  ) => {
+    if (
+      imageElementRef.current == null ||
+      pointerStateRef.current == null ||
+      pointerStateRef.current.pointerId != e.pointerId
+    ) {
+      return;
+    }
 
-      const { current: imgElement } = imgElementRef;
+    const { x, y } = pointerStateRef.current;
 
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
+    const movement = {
+      x: e.clientX - x,
+      y: e.clientY - y,
+    };
 
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.x / imgElement.width / ratio,
-      };
+    change(movement);
+  };
 
-      const newRatio = {
-        x: oldClip.width + moveRatio.x,
-        y: oldClip.height + moveRatio.y,
-      };
+  const onPointerDown = (e: PointerEvent) => {
+    if (imageElementRef.current == null || pointerStateRef.current != null)
+      return;
 
-      const maxRatio = {
-        x: Math.min(1 - oldClip.left, newRatio.x),
-        y: Math.min(1 - oldClip.top, newRatio.y),
-      };
+    e.currentTarget.setPointerCapture(e.pointerId);
 
-      const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+    pointerStateRef.current = {
+      x: e.clientX,
+      y: e.clientY,
+      pointerId: e.pointerId,
+    };
+  };
 
-      let newWidth;
-      if (newRatio.x < 0) {
-        newWidth = 0;
-      } else if (newRatio.x > maxWidthRatio) {
-        newWidth = maxWidthRatio;
-      } else {
-        newWidth = newRatio.x;
-      }
+  const onPointerUp = (e: PointerEvent) => {
+    if (
+      pointerStateRef.current == null ||
+      pointerStateRef.current.pointerId != e.pointerId
+    ) {
+      return;
+    }
 
-      onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
-    },
-    [imageInfo, oldState, onChange],
-  );
+    e.currentTarget.releasePointerCapture(e.pointerId);
+    pointerStateRef.current = null;
+  };
 
-  const toPercentage = (n: number): string => `${n}%`;
+  const onMaskPointerMove = (e: PointerEvent) => {
+    actOnMovement(e, onMove);
+  };
 
-  // fuck!!! I just can't find a better way to implement this in pure css
-  const containerStyle: React.CSSProperties = (() => {
-    if (imageInfo == null) {
-      return { width: "100%", paddingTop: "100%", height: 0 };
-    } else {
-      if (imageInfo.ratio > 1) {
-        return {
-          width: toPercentage(100 / imageInfo.ratio),
-          paddingTop: "100%",
-          height: 0,
-        };
-      } else {
-        return {
-          width: "100%",
-          paddingTop: toPercentage(100 * imageInfo.ratio),
-          height: 0,
-        };
-      }
-    }
-  })();
+  const onResizeHandlerPointerMove = (e: PointerEvent) => {
+    actOnMovement(e, onResize);
+  };
 
   return (
     <div
-      className={classnames("image-cropper-container", className)}
-      style={containerStyle}
+      className={classnames("cru-image-cropper-container", containerClassName)}
     >
-      <BlobImage
-        imgRef={onImageRef}
-        src={image}
-        onLoad={onImageLoad}
-        alt="to crop"
-      />
-      <div className="image-cropper-mask-container">
+      <BlobImage imgRef={imageElementRef} src={image} onLoad={onImageLoad} />
+      <div className="cru-image-cropper-mask-container">
         <div
-          className="image-cropper-mask"
+          className="cru-image-cropper-mask"
           style={{
-            left: toPercentage(c.left * 100),
-            top: toPercentage(c.top * 100),
-            width: toPercentage(c.width * 100),
-            height: toPercentage(c.height * 100),
+            left: clipInElement.left,
+            top: clipInElement.top,
+            width: clipInElement.width,
+            height: clipInElement.height,
           }}
-          onPointerMove={onPointerMove}
+          onPointerMove={onMaskPointerMove}
           onPointerDown={onPointerDown}
           onPointerUp={onPointerUp}
         />
       </div>
       <div
-        className="image-cropper-handler"
+        className="cru-image-cropper-handler"
         style={{
-          left: `calc(${(c.left + c.width) * 100}% - 15px)`,
-          top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+          left:
+            clipInElement.left + clipInElement.width - imageCropperHandlerSize,
+          top:
+            clipInElement.top + clipInElement.height - imageCropperHandlerSize,
+          width: imageCropperHandlerSize * 2,
+          height: imageCropperHandlerSize * 2,
         }}
-        onPointerMove={onHandlerPointerMove}
+        onPointerMove={onResizeHandlerPointerMove}
         onPointerDown={onPointerDown}
         onPointerUp={onPointerUp}
       />
     </div>
   );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
-  image: HTMLImageElement,
-  clip: Clip,
-  mimeType: string,
-): Promise<Blob> {
-  return new Promise((resolve, reject) => {
-    const naturalSize = {
-      width: image.naturalWidth,
-      height: image.naturalHeight,
-    };
-    const clipArea = {
-      x: naturalSize.width * clip.left,
-      y: naturalSize.height * clip.top,
-      length: naturalSize.width * clip.width,
-    };
-
-    const canvas = document.createElement("canvas");
-    canvas.width = clipArea.length;
-    canvas.height = clipArea.length;
-    const context = canvas.getContext("2d");
-
-    if (context == null) throw new Error("Failed to create context.");
-
-    context.drawImage(
-      image,
-      clipArea.x,
-      clipArea.y,
-      clipArea.length,
-      clipArea.length,
-      0,
-      0,
-      clipArea.length,
-      clipArea.length,
-    );
-
-    canvas.toBlob((blob) => {
-      if (blob == null) {
-        reject(new Error("canvas.toBlob returns null"));
-      } else {
-        resolve(blob);
-      }
-    }, mimeType);
-  });
 }
-- 
cgit v1.2.3

