From f5dfd52f6efece2f4cad227044ecf4dd66301bbc Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 26 Aug 2023 21:36:58 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.tsx | 312 +++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 FrontEnd/src/components/ImageCropper.tsx (limited to 'FrontEnd/src/components/ImageCropper.tsx') diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx new file mode 100644 index 00000000..f23994e2 --- /dev/null +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -0,0 +1,312 @@ +import * as React from "react"; +import classnames from "classnames"; + +import { UiLogicError } from "~src/common"; + +import "./ImageCropper.css"; +import BlobImage from "./BlobImage"; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + image: string | Blob; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, image, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState( + null, + ); + const [imageInfo, setImageInfo] = React.useState(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElementRef = React.useRef(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElementRef.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback], + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent) => { + 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, + }; + 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 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."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.y / imgElement.height, + }; + + 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; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange], + ); + + const onHandlerPointerMove = 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 ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.x / imgElement.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange], + ); + + const toPercentage = (n: number): string => `${n}%`; + + // 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, + }; + } + } + })(); + + return ( +
+ +
+
+
+
+
+ ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string, +): Promise { + 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 From b66a57071316434356e77e294ec22181e4db54d5 Mon Sep 17 00:00:00 2001 From: crupest 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/ImageCropper.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, "src"> & { imgRef?: React.Ref; 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(undefined); @@ -22,5 +23,13 @@ export default function BlobImage(props: BlobImageProps) { } }, [src]); - return ; + const key = useMemo(() => { + if (keyBySrc) { + return url == null ? undefined : btoa(url); + } else { + return undefined; + } + }, [url, keyBySrc]); + + return ; } 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 { + 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( - null, - ); - const [imageInfo, setImageInfo] = React.useState(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(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) => { - 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; + onImageLoad: (event: SyntheticEvent) => 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; + imageCropperProps: ImageCropperProps; +} { + const targetRatio = options?.constraint?.ratio; + + const imageElementRef = useRef(null); + const [image, setImage] = useState(null); + const [clip, setClip] = useState(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(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 (
- -
+ +
); -}; - -export default ImageCropper; - -export function applyClipToImage( - image: HTMLImageElement, - clip: Clip, - mimeType: string, -): Promise { - 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 = ({ + onReload, + className, + style, +}) => { + return ( + + 0 + { + onReload(); + e.preventDefault(); + }} + > + 1 + + 2 + + ); +}; + +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("select"); const [file, setFile] = useState(null); - const [clip, setClip] = useState(null); - const [cropImgElement, setCropImgElement] = useState( - null, - ); + + const { canCrop, crop, imageCropperProps } = useImageCrop(file, { + constraint: { + ratio: 1, + }, + }); + const [resultBlob, setResultBlob] = useState(null); const [message, setMessage] = useState( "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 (
{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 877f4ff87c39e3484ae2e7e6c920fc7fb8c04c23 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 21:14:20 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.tsx | 262 ++++++++++------------------ FrontEnd/src/components/common.ts | 2 + FrontEnd/src/utilities/geometry.ts | 291 +++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 167 deletions(-) create mode 100644 FrontEnd/src/utilities/geometry.ts (limited to 'FrontEnd/src/components/ImageCropper.tsx') 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 { 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; - onImageLoad: (event: SyntheticEvent) => 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; imageCropperProps: ImageCropperProps; } { const targetRatio = options?.constraint?.ratio; - const imageElementRef = useRef(null); - const [image, setImage] = useState(null); - const [clip, setClip] = useState(emptyClip ); + const [imageElement, setImageElement] = useState( + null, + ); + const [imageInfo, setImageInfo] = useState(null); + const [clip, setClip] = useState(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(null); + const [imageElement, setImageElement] = useState( + 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) {
- + { + setImageElement(element); + imageElementCallback(element); + }} + src={image} + onLoad={onImageLoad} + />
= 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; +} -- cgit v1.2.3 From 8236f228557f5305aed8a04359cd05ad8db4a8ed Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 21:19:24 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) (limited to 'FrontEnd/src/components/ImageCropper.tsx') diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx index acbbbe0c..4dfdd0cd 100644 --- a/FrontEnd/src/components/ImageCropper.tsx +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -83,8 +83,8 @@ interface ImageCropperProps { image: Blob | string | null; imageElementCallback: (element: HTMLImageElement | null) => void; onImageLoad: () => void; - onMove: (movement: Movement) => void; - onResize: (movement: Movement) => void; + onMove: (movement: Movement, originalClip: Rect) => void; + onResize: (movement: Movement, originalClip: Rect) => void; containerClassName?: string; } @@ -127,10 +127,10 @@ export function useImageCrop( clip, image: file, imageElementCallback: setImageElement, - onMove: (movement) => { + onMove: (movement, originalClip) => { if (imageInfo == null) return; const newClip = geometry.adjustRectToContainer( - clip.copy().move(movement), + originalClip.copy().move(movement), imageInfo.rect, "move", { @@ -139,10 +139,10 @@ export function useImageCrop( ); setClip(newClip); }, - onResize: (movement) => { + onResize: (movement, originalClip) => { if (imageInfo == null) return; const newClip = geometry.adjustRectToContainer( - clip.copy().expand(movement), + originalClip.copy().expand(movement), imageInfo.rect, "resize", { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, @@ -167,6 +167,7 @@ interface PointerState { x: number; y: number; pointerId: number; + originalClip: Rect; } const imageCropperHandlerSize = 15; @@ -220,7 +221,7 @@ export function ImageCropper(props: ImageCropperProps) { const actOnMovement = ( e: PointerEvent, - change: (movement: Movement) => void, + change: (movement: Movement, originalClip: Rect) => void, ) => { if ( imageElement == null || @@ -230,17 +231,14 @@ export function ImageCropper(props: ImageCropperProps) { return; } - const { x, y } = pointerStateRef.current; + const { x, y, originalClip } = pointerStateRef.current; const movement = { x: e.clientX - x, y: e.clientY - y, }; - pointerStateRef.current.x = e.clientX; - pointerStateRef.current.y = e.clientY; - - change(convertMovementFromElement(movement, imageElement)); + change(convertMovementFromElement(movement, imageElement), originalClip); }; const onPointerDown = (e: PointerEvent) => { @@ -252,6 +250,7 @@ export function ImageCropper(props: ImageCropperProps) { x: e.clientX, y: e.clientY, pointerId: e.pointerId, + originalClip: clip, }; }; -- cgit v1.2.3