From f5dfd52f6efece2f4cad227044ecf4dd66301bbc Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 26 Aug 2023 21:36:58 +0800
Subject: ...

---
 FrontEnd/src/components/BlobImage.tsx | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)
 create mode 100644 FrontEnd/src/components/BlobImage.tsx

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

diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
new file mode 100644
index 00000000..259c2210
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,26 @@
+import { ComponentPropsWithoutRef, useState, useEffect } from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+  imgRef?: React.Ref<HTMLImageElement>;
+  src?: Blob | string | null;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+  const { imgRef, src, ...otherProps } = props;
+
+  const [url, setUrl] = useState<string | null | undefined>(undefined);
+
+  useEffect(() => {
+    if (src instanceof Blob) {
+      const url = URL.createObjectURL(src);
+      setUrl(url);
+      return () => {
+        URL.revokeObjectURL(url);
+      };
+    } else {
+      setUrl(src);
+    }
+  }, [src]);
+
+  return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
+}
-- 
cgit v1.2.3


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/BlobImage.tsx             |  15 +-
 FrontEnd/src/components/ImageCropper.css          |  15 +-
 FrontEnd/src/components/ImageCropper.tsx          | 564 +++++++++++++---------
 FrontEnd/src/components/LoadFailReload.tsx        |  37 ++
 FrontEnd/src/pages/setting/ChangeAvatarDialog.css |   1 +
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx |  34 +-
 FrontEnd/src/pages/timeline/TimelineCard.tsx      |   2 +-
 7 files changed, 394 insertions(+), 274 deletions(-)
 create mode 100644 FrontEnd/src/components/LoadFailReload.tsx

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

diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
index 259c2210..cccf2f74 100644
--- a/FrontEnd/src/components/BlobImage.tsx
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -1,12 +1,13 @@
-import { ComponentPropsWithoutRef, useState, useEffect } from "react";
+import { ComponentPropsWithoutRef, useState, useEffect, useMemo } from "react";
 
 type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
   imgRef?: React.Ref<HTMLImageElement>;
   src?: Blob | string | null;
+  keyBySrc?: boolean;
 };
 
 export default function BlobImage(props: BlobImageProps) {
-  const { imgRef, src, ...otherProps } = props;
+  const { imgRef, src, keyBySrc, ...otherProps } = props;
 
   const [url, setUrl] = useState<string | null | undefined>(undefined);
 
@@ -22,5 +23,13 @@ export default function BlobImage(props: BlobImageProps) {
     }
   }, [src]);
 
-  return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
+  const key = useMemo(() => {
+    if (keyBySrc) {
+      return url == null ? undefined : btoa(url);
+    } else {
+      return undefined;
+    }
+  }, [url, keyBySrc]);
+
+  return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />;
 }
diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
index 2c4d0a8c..9631cf1d 100644
--- a/FrontEnd/src/components/ImageCropper.css
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -1,18 +1,17 @@
-.image-cropper-container {
+.cru-image-cropper-container {
   position: relative;
   box-sizing: border-box;
   user-select: none;
 }
 
-.image-cropper-container img {
-  position: absolute;
+.cru-image-cropper-container img {
   left: 0;
   top: 0;
   width: 100%;
   height: 100%;
 }
 
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
   position: absolute;
   left: 0;
   top: 0;
@@ -21,18 +20,16 @@
   overflow: hidden;
 }
 
-.image-cropper-mask {
+.cru-image-cropper-mask {
   position: absolute;
   box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
   touch-action: none;
 }
 
-.image-cropper-handler {
+.cru-image-cropper-handler {
   position: absolute;
-  width: 26px;
-  height: 26px;
   border: black solid 2px;
   border-radius: 50%;
   background: white;
   touch-action: none;
-}
+}
\ No newline at end of file
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);
-  });
 }
diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
new file mode 100644
index 00000000..81ba1f67
--- /dev/null
+++ b/FrontEnd/src/components/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+import { Trans } from "react-i18next";
+
+export interface LoadFailReloadProps {
+  className?: string;
+  style?: React.CSSProperties;
+  onReload: () => void;
+}
+
+const LoadFailReload: React.FC<LoadFailReloadProps> = ({
+  onReload,
+  className,
+  style,
+}) => {
+  return (
+    <Trans
+      i18nKey="loadFailReload"
+      parent="div"
+      className={className}
+      style={style}
+    >
+      0
+      <a
+        href="#"
+        onClick={(e) => {
+          onReload();
+          e.preventDefault();
+        }}
+      >
+        1
+      </a>
+      2
+    </Trans>
+  );
+};
+
+export default LoadFailReload;
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
index 2aa0bb54..c9eb8011 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -11,6 +11,7 @@
 }
 
 .change-avatar-cropper {
+  max-width: 400px;
   max-height: 400px;
 }
 
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index 011c5059..2fcfef2c 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -6,10 +6,7 @@ import { useUser } from "~src/services/user";
 
 import { getHttpUserClient } from "~src/http/user";
 
-import ImageCropper, {
-  Clip,
-  applyClipToImage,
-} from "~src/components/ImageCropper";
+import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
 import BlobImage from "~src/components/BlobImage";
 import { ButtonRowV2 } from "~src/components/button";
 import { Dialog, DialogContainer } from "~src/components/dialog";
@@ -40,10 +37,13 @@ export default function ChangeAvatarDialog({
   const [state, setState] = useState<State>("select");
 
   const [file, setFile] = useState<File | null>(null);
-  const [clip, setClip] = useState<Clip | null>(null);
-  const [cropImgElement, setCropImgElement] = useState<HTMLImageElement | null>(
-    null,
-  );
+
+  const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+    constraint: {
+      ratio: 1,
+    },
+  });
+
   const [resultBlob, setResultBlob] = useState<Blob | null>(null);
   const [message, setMessage] = useState<Text>(
     "settings.dialogChangeAvatar.prompt.select",
@@ -65,18 +65,13 @@ export default function ChangeAvatarDialog({
   };
 
   const onCropNext = () => {
-    if (
-      cropImgElement == null ||
-      clip == null ||
-      clip.width === 0 ||
-      file == null
-    ) {
+    if (!canCrop) {
       throw new UiLogicError();
     }
 
     setState("process-crop");
 
-    void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+    void crop().then((b) => {
       setState("preview");
       setResultBlob(b);
     });
@@ -151,7 +146,7 @@ export default function ChangeAvatarDialog({
         action: "primary",
         text: "operationDialog.nextStep",
         onClick: onCropNext,
-        disabled: cropImgElement == null || clip == null || clip.width === 0,
+        disabled: !canCrop,
       },
     ],
     "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
@@ -214,11 +209,8 @@ export default function ChangeAvatarDialog({
             return (
               <div className="change-avatar-dialog-container">
                 <ImageCropper
-                  className="change-avatar-cropper"
-                  clip={clip}
-                  onChange={setClip}
-                  image={file}
-                  imageElementCallback={setCropImgElement}
+                  {...imageCropperProps}
+                  containerClassName="change-avatar-cropper"
                 />
                 <div className="change-avatar-dialog-prompt">
                   {c("settings.dialogChangeAvatar.prompt.crop")}
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index 82d6d350..f17a3ce9 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -7,7 +7,7 @@ import { pushAlert } from "~src/services/alert";
 import { HttpTimelineInfo } from "~src/http/timeline";
 import { getHttpBookmarkClient } from "~src/http/bookmark";
 
-import { useMobile } from "~src/components/common";
+import { useMobile } from "~src/components/hooks";
 import { Dialog, useDialog } from "~src/components/dialog";
 import UserAvatar from "~src/components/user/UserAvatar";
 import PopupMenu from "~src/components/menu/PopupMenu";
-- 
cgit v1.2.3


From 1a543da5a018d1a99b51bfe5d7d90edff0502a11 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 31 Aug 2023 18:33:29 +0800
Subject: Fix a fatal bug in geometry.

---
 FrontEnd/src/components/BlobImage.tsx             | 10 ++++++++--
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 10 ++++------
 FrontEnd/src/utilities/geometry.ts                | 15 ++++++++-------
 3 files changed, 20 insertions(+), 15 deletions(-)

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

diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
index cccf2f74..047a13b4 100644
--- a/FrontEnd/src/components/BlobImage.tsx
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -1,7 +1,13 @@
-import { ComponentPropsWithoutRef, useState, useEffect, useMemo } from "react";
+import {
+  useState,
+  useEffect,
+  useMemo,
+  Ref,
+  ComponentPropsWithoutRef,
+} from "react";
 
 type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
-  imgRef?: React.Ref<HTMLImageElement>;
+  imgRef?: Ref<HTMLImageElement>;
   src?: Blob | string | null;
   keyBySrc?: boolean;
 };
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index 96ae971b..0df10411 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -109,7 +109,6 @@ export default function ChangeAvatarDialog() {
 
   const cancelButton = {
     key: "cancel",
-    action: "secondary",
     text: "operationDialog.cancel",
     onClick: close,
   } as const;
@@ -117,7 +116,6 @@ export default function ChangeAvatarDialog() {
   const createPreviousButton = (onClick: () => void) =>
     ({
       key: "previous",
-      action: "secondary",
       text: "operationDialog.previousStep",
       onClick,
     }) as const;
@@ -130,7 +128,7 @@ export default function ChangeAvatarDialog() {
       cancelButton,
       {
         key: "next",
-        action: "primary",
+        action: "major",
         text: "operationDialog.nextStep",
         onClick: () => setState("crop"),
         disabled: file == null,
@@ -141,7 +139,7 @@ export default function ChangeAvatarDialog() {
       createPreviousButton(onCropPrevious),
       {
         key: "next",
-        action: "primary",
+        action: "major",
         text: "operationDialog.nextStep",
         onClick: onCropNext,
         disabled: !canCrop,
@@ -153,7 +151,7 @@ export default function ChangeAvatarDialog() {
       createPreviousButton(onPreviewPrevious),
       {
         key: "upload",
-        action: "primary",
+        action: "major",
         text: "settings.dialogChangeAvatar.upload",
         onClick: upload,
       },
@@ -171,7 +169,7 @@ export default function ChangeAvatarDialog() {
       cancelButton,
       {
         key: "retry",
-        action: "primary",
+        action: "major",
         text: "operationDialog.retry",
         onClick: upload,
       },
diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts
index 12dd4bbb..60a8d3d4 100644
--- a/FrontEnd/src/utilities/geometry.ts
+++ b/FrontEnd/src/utilities/geometry.ts
@@ -49,7 +49,7 @@ export class Rect {
   }
 
   set right(value: number) {
-    this.width = this.left + value;
+    this.width = value - this.left;
   }
 
   get bottom(): number {
@@ -57,7 +57,7 @@ export class Rect {
   }
 
   set bottom(value: number) {
-    this.height = this.top + value;
+    this.height = value - this.top;
   }
 
   get ratio(): number {
@@ -218,15 +218,16 @@ export function adjustRectToContainer(
         : clamp(rect.top, container.top - rect.height, container.bottom);
   } else if (mode === "resize") {
     const noFlip = options?.resizeNoFlip;
-    rect.right = clamp(
+    const newRight = clamp(
       rect.right,
-      noFlip ? 0 : container.left,
-      container.right,
+      rect.width > 0 && noFlip ? rect.left : container.left,
+      rect.width < 0 && noFlip ? rect.left : container.right,
     );
+    rect.right = newRight;
     rect.bottom = clamp(
       rect.bottom,
-      noFlip ? 0 : container.top,
-      container.bottom,
+      rect.height > 0 && noFlip ? rect.top : container.top,
+      rect.height < 0 && noFlip ? rect.top : container.bottom,
     );
   } else {
     rect.left = clamp(rect.left, container.left, container.right);
-- 
cgit v1.2.3

