diff --git a/README.md b/README.md index beea786df..6089f47d6 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ Publish configuration in `src/electron/config.yml` config file allows to set Git * [rlottie](https://github.com/Samsung/rlottie) ([MIT License](https://github.com/Samsung/rlottie/blob/master/COPYING)) * [opus-recorder](https://github.com/chris-rudmin/opus-recorder) ([Various Licenses](https://github.com/chris-rudmin/opus-recorder/blob/master/LICENSE.md)) * [qr-code-styling](https://github.com/kozakdenys/qr-code-styling) ([MIT License](https://github.com/kozakdenys/qr-code-styling/blob/master/LICENSE)) -* [croppie](https://github.com/Foliotek/Croppie) ([MIT License](https://github.com/Foliotek/Croppie/blob/master/LICENSE)) * [mp4box](https://github.com/gpac/mp4box.js) ([BSD-3-Clause license](https://github.com/gpac/mp4box.js/blob/master/LICENSE)) * [music-metadata-browser](https://github.com/Borewit/music-metadata-browser) ([MIT License](https://github.com/Borewit/music-metadata-browser/blob/master/LICENSE.txt)) * [lowlight](https://github.com/wooorm/lowlight) ([MIT License](https://github.com/wooorm/lowlight/blob/main/license)) diff --git a/package-lock.json b/package-lock.json index dd2e98462..38ea818b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@cryptography/aes": "^0.1.1", "async-mutex": "^0.5.0", "big-integer": "github:painor/BigInteger.js", - "croppie": "^2.6.5", "emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105", "idb-keyval": "^6.2.2", "lowlight": "^3.3.0", @@ -42,7 +41,6 @@ "@stylistic/stylelint-plugin": "^3.1.2", "@testing-library/jest-dom": "^6.6.3", "@twbs/fantasticon": "^3.1.0", - "@types/croppie": "^2.6.4", "@types/dom-view-transitions": "^1.0.6", "@types/hast": "^3.0.4", "@types/jest": "^29.5.14", @@ -5699,13 +5697,6 @@ "@types/node": "*" } }, - "node_modules/@types/croppie": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/croppie/-/croppie-2.6.4.tgz", - "integrity": "sha512-rxKLA5S+QarlaMVlsMqhn2fMMC5XlvogFzTYdMlkeupPgxT1mWaheucdZNzkUJACn61+JjN/eJYt5dS9GMoeXw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -9393,12 +9384,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/croppie": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz", - "integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ==", - "license": "MIT" - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", diff --git a/package.json b/package.json index 4ddb290c9..636e815d2 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@stylistic/stylelint-plugin": "^3.1.2", "@testing-library/jest-dom": "^6.6.3", "@twbs/fantasticon": "^3.1.0", - "@types/croppie": "^2.6.4", "@types/dom-view-transitions": "^1.0.6", "@types/hast": "^3.0.4", "@types/jest": "^29.5.14", @@ -139,7 +138,6 @@ "@cryptography/aes": "^0.1.1", "async-mutex": "^0.5.0", "big-integer": "github:painor/BigInteger.js", - "croppie": "^2.6.5", "emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105", "idb-keyval": "^6.2.2", "lowlight": "^3.3.0", diff --git a/src/components/ui/CropModal.scss b/src/components/ui/CropModal.scss index ca30be331..9ed2efdab 100644 --- a/src/components/ui/CropModal.scss +++ b/src/components/ui/CropModal.scss @@ -12,76 +12,12 @@ .CropModal { .modal-dialog { position: relative; - - width: calc(100% - 2rem); - max-width: 35rem; - height: calc(100% - 1rem); - max-height: 35rem; + width: auto; + min-width: inherit !important; + max-width: inherit !important; } - - .modal-content, - #avatar-crop { - overflow: hidden; - } - - .confirm-button { - position: absolute; - right: 1rem; - bottom: 1rem; - box-shadow: 0 1px 2px var(--color-default-shadow); - } - - #avatar-crop { - position: relative; - max-width: 25rem; - margin: 0 auto; - - &::before { - content: ""; - display: block; - padding-top: 100%; - } - - .cr-boundary { - position: absolute; - top: 0; - left: 0; - border-radius: var(--border-radius-messages-small); - } - - .cr-viewport { - border: none; - box-shadow: 0 0 2000px 2000px rgba(#7f7f7f, 0.5); - } - - .cr-slider { - // Note that while we're repeating code here, - // that's necessary as you can't comma-separate these type of selectors. - // Browsers will drop the entire selector if it doesn't understand a part of it. - - &::-webkit-slider-runnable-track { - background: var(--color-borders); - } - - &::-moz-range-track { - background: var(--color-borders); - } - - &::-ms-track { - background: var(--color-borders); - } - - &::-webkit-slider-thumb { - @include thumb-styles(); - } - - &::-moz-range-thumb { - @include thumb-styles(); - } - - &::-ms-thumb { - @include thumb-styles(); - } - } + .modal-content { + padding: 1rem; + padding-top: 0.5rem; } } diff --git a/src/components/ui/CropModal.tsx b/src/components/ui/CropModal.tsx index 03e0caea0..6cdad4341 100644 --- a/src/components/ui/CropModal.tsx +++ b/src/components/ui/CropModal.tsx @@ -1,134 +1,43 @@ -import type { FC } from '../../lib/teact/teact'; import { - memo, useCallback, useEffect, useState, + memo, } from '../../lib/teact/teact'; -import { DEBUG } from '../../config'; -import { blobToDataUri, blobToFile } from '../../util/files'; - +import useImageLoader from '../../hooks/useImageLoader'; import useLang from '../../hooks/useLang'; -import Icon from '../common/icons/Icon'; -import Button from './Button'; -import Loading from './Loading'; +import ImageCropper from './ImageCropper'; import Modal from './Modal'; import './CropModal.scss'; -// Change to 'base64' to get base64-encoded string -const cropperResultOptions: Croppie.ResultOptions & { type: 'blob' } = { - type: 'blob', - quality: 1, - format: 'jpeg', - circle: false, - size: { width: 1024, height: 1024 }, -}; - -type ICroppie = typeof import('croppie'); -let Croppie: ICroppie; -let croppiePromise: Promise<{ default: ICroppie }> | undefined; - -async function ensureCroppie() { - if (!croppiePromise) { - croppiePromise = import('../../lib/croppie') as unknown as Promise<{ default: ICroppie }>; - Croppie = (await croppiePromise).default; - } - - return croppiePromise; -} - -let cropper: Croppie; - -async function initCropper(imgFile: Blob) { - try { - const cropContainer = document.getElementById('avatar-crop'); - if (!cropContainer) { - return; - } - - const { offsetWidth, offsetHeight } = cropContainer; - - cropper = new Croppie(cropContainer, { - enableZoom: true, - boundary: { - width: offsetWidth, - height: offsetHeight, - }, - viewport: { - width: offsetWidth - 16, - height: offsetHeight - 16, - type: 'circle', - }, - }); - - const dataUri = await blobToDataUri(imgFile); - await cropper.bind({ url: dataUri }); - } catch (err) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.error(err); - } - } -} - type OwnProps = { file?: Blob; onChange: (file: File) => void; onClose: () => void; }; -const CropModal: FC = ({ file, onChange, onClose }: OwnProps) => { - const [isCroppieReady, setIsCroppieReady] = useState(false); +const MAX_OUTPUT_SIZE = 1024; +const MIN_OUTPUT_SIZE = 256; +const CropModal = ({ file, onChange, onClose }: OwnProps) => { const lang = useLang(); - - useEffect(() => { - if (!file) { - return; - } - - if (!isCroppieReady) { - ensureCroppie().then(() => setIsCroppieReady(true)); - - return; - } - - initCropper(file); - }, [file, isCroppieReady]); - - const handleCropClick = useCallback(async () => { - if (!cropper) { - return; - } - - const result: Blob | string = await cropper.result(cropperResultOptions); - const croppedImg = typeof result === 'string' ? result : blobToFile(result, 'avatar.jpg'); - - onChange(croppedImg); - }, [onChange]); - + const { image } = useImageLoader(file); + const isOpen = Boolean(file) && Boolean(image); return ( - {isCroppieReady ? ( -
- ) : ( - - )} - + ); }; diff --git a/src/components/ui/ImageCropper.module.scss b/src/components/ui/ImageCropper.module.scss new file mode 100644 index 000000000..157ce38da --- /dev/null +++ b/src/components/ui/ImageCropper.module.scss @@ -0,0 +1,77 @@ +.previewContainer { + position: relative; + overflow: hidden; + border-radius: 1rem; +} + +.foregroundImage, +.backgroundImage { + pointer-events: auto; + cursor: grab; + user-select: none; + + position: absolute; + z-index: 1; + + object-fit: contain; +} + +.backgroundImage { + filter: blur(0.125rem) brightness(0.75); +} + +.foregroundImage { + pointer-events: none; + z-index: 3; +} + +.previewMask { + pointer-events: none; + + position: absolute; + z-index: 2; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + background: rgba(0, 0, 0, 0.5); + filter: blur(8px); +} + +.cropArea { + pointer-events: none; + cursor: grab; + user-select: none; + + position: absolute; + z-index: 3; + inset: 0.125rem; + + overflow: hidden; + + border-radius: 50%; + + box-shadow: 0 0 0.5rem #0004; +} + +.confirmButton { + box-shadow: 0 1px 2px var(--color-default-shadow); +} + +.zoomSlider { + flex: 1; + margin: 0; + margin-inline-end: 1rem; + + input[type="range"] { + margin: 0; + } +} + +.bottomControls { + display: flex; + align-items: center; + margin-top: 1rem; +} diff --git a/src/components/ui/ImageCropper.tsx b/src/components/ui/ImageCropper.tsx new file mode 100644 index 000000000..750f45261 --- /dev/null +++ b/src/components/ui/ImageCropper.tsx @@ -0,0 +1,212 @@ +import { memo, useMemo, useRef, useState } from '../../lib/teact/teact'; + +import buildStyle from '../../util/buildStyle'; +import getPointerPosition from '../../util/events/getPointerPosition'; +import { blobToFile } from '../../util/files'; +import { clamp } from '../../util/math'; +import { REM } from '../common/helpers/mediaDimensions'; + +import useLastCallback from '../../hooks/useLastCallback'; +import useWindowSize from '../../hooks/window/useWindowSize'; + +import Icon from '../common/icons/Icon'; +import Button from './Button'; +import RangeSlider from './RangeSlider'; + +import styles from './ImageCropper.module.scss'; + +type OwnProps = { + onChange: (file: File) => void; + image?: HTMLImageElement; + maxOutputSize: number; + minOutputSize: number; +}; + +const PREVIEW_SIZE = 400; +const MIN_ZOOM = 100; +const MAX_ZOOM = 200; +const CROP_AREA_INSET = 0.125 * REM; +const MODAL_INLINE_PADDING = REM * 2; + +const ImageCropper = ({ + onChange, + image, + maxOutputSize, + minOutputSize, +}: OwnProps) => { + const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(MIN_ZOOM); + const isDragging = useRef(false); + const lastMousePosition = useRef({ x: 0, y: 0 }); + const lastImagePosition = useRef({ x: 0, y: 0 }); + + const { width: windowWidth } = useWindowSize(); + const previewContainerSize = Math.min(PREVIEW_SIZE, windowWidth - MODAL_INLINE_PADDING * 2); + const scaleFactor = image + ? Math.max( + previewContainerSize / image.width, + previewContainerSize / image.height, + ) : 1; + const zoomFactor = scaleFactor * zoom / 100; + + const previewImageSize = useMemo(() => { + if (!image) return { width: 0, height: 0 }; + return { + width: image.width * zoomFactor, + height: image.height * zoomFactor, + }; + }, [image, zoomFactor]); + + const clampPosition = (x: number, y: number, previewSize: { width: number; height: number }) => { + const radius = previewContainerSize / 2; + const imgHalfWidth = previewSize.width / 2; + const imgHalfHeight = previewSize.height / 2; + + const maxOffsetX = Math.max(0, imgHalfWidth - radius); + const maxOffsetY = Math.max(0, imgHalfHeight - radius); + + return { + x: clamp(x, -maxOffsetX, maxOffsetX), + y: clamp(y, -maxOffsetY, maxOffsetY), + }; + }; + + const startDrag = (e: any) => { + isDragging.current = true; + lastMousePosition.current = getPointerPosition(e); + lastImagePosition.current = { ...imagePosition }; + document.addEventListener('mousemove', moveDrag); + document.addEventListener('touchmove', moveDrag); + document.addEventListener('mouseup', endDrag); + document.addEventListener('touchend', endDrag); + }; + const moveDrag = useLastCallback((e: any) => { + if ('touches' in e && e.touches.length > 1) return; + if (!isDragging.current) return; + const { x: mouseX, y: mouseY } = getPointerPosition(e); + const deltaX = mouseX - lastMousePosition.current.x; + const deltaY = mouseY - lastMousePosition.current.y; + const newPosition = clampPosition( + lastImagePosition.current.x + deltaX, + lastImagePosition.current.y + deltaY, + previewImageSize, + ); + setImagePosition(newPosition); + }); + const endDrag = useLastCallback(() => { + isDragging.current = false; + document.removeEventListener('mousemove', moveDrag); + document.removeEventListener('touchmove', moveDrag); + document.removeEventListener('mouseup', endDrag); + document.removeEventListener('touchend', endDrag); + }); + + const handleZoomChange = useLastCallback((newZoom: number) => { + const newZoomFactor = scaleFactor * newZoom / 100; + const newPreviewImageSize = { + width: image!.width * newZoomFactor, + height: image!.height * newZoomFactor, + }; + + const ratio = newZoom / zoom; + const newPosition = clampPosition( + imagePosition.x * ratio, + imagePosition.y * ratio, + newPreviewImageSize, + ); + + setZoom(newZoom); + setImagePosition(newPosition); + }); + + const handleCrop = () => { + if (!image) return; + + const cropSize = previewContainerSize / zoomFactor; + const cropX = (image.width / 2) - (cropSize / 2) - (imagePosition.x / zoomFactor); + const cropY = (image.height / 2) - (cropSize / 2) - (imagePosition.y / zoomFactor); + + const outputSize = Math.min(maxOutputSize, Math.max(minOutputSize, cropSize)); + + const canvas = document.createElement('canvas'); + canvas.width = outputSize; + canvas.height = outputSize; + const ctx = canvas.getContext('2d')!; + ctx.drawImage( + image, + cropX, cropY, + cropSize, cropSize, + 0, 0, + outputSize, outputSize, + ); + canvas.toBlob((blob) => { + if (blob) onChange(blobToFile(blob, 'avatar.jpg')); + }, 'image/jpeg'); + }; + + if (!image) return undefined; + + const imageLeft = (previewContainerSize - previewImageSize.width) / 2 + imagePosition.x; + const imageTop = (previewContainerSize - previewImageSize.height) / 2 + imagePosition.y; + const backgroundImageStyle = buildStyle( + `width: ${previewImageSize.width}px`, + `height: ${previewImageSize.height}px`, + `left: ${imageLeft}px`, + `top: ${imageTop}px`, + ); + + const foregroundImageStyle = buildStyle( + `width: ${previewImageSize.width}px`, + `height: ${previewImageSize.height}px`, + `left: ${imageLeft - CROP_AREA_INSET}px`, + `top: ${imageTop - CROP_AREA_INSET}px`, + ); + + return ( +
+
+ +
+
+ +
+
+
+ + +
+
+ ); +}; + +export default memo(ImageCropper); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 5d6419ae9..e5a58732d 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -49,6 +49,7 @@ export type OwnProps = { onCloseAnimationEnd?: () => void; onEnter?: () => void; withBalanceBar?: boolean; + isCondensedHeader?: boolean; }; const Modal: FC = ({ @@ -74,6 +75,7 @@ const Modal: FC = ({ onCloseAnimationEnd, onEnter, withBalanceBar, + isCondensedHeader, }) => { const { ref: modalRef, @@ -158,7 +160,7 @@ const Modal: FC = ({ } return ( -
+
{withCloseButton && closeButton}
{title}
diff --git a/src/hooks/useImageLoader.ts b/src/hooks/useImageLoader.ts new file mode 100644 index 000000000..3ddf08aee --- /dev/null +++ b/src/hooks/useImageLoader.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef } from '../lib/teact/teact'; + +import { preloadImage } from '../util/files'; +import useAsync from './useAsync'; + +export type UseImageLoaderResult = { + image?: HTMLImageElement; +}; + +export default function useImageLoader(file?: Blob): UseImageLoaderResult { + const urlRef = useRef(); + + const { result: image } = useAsync(() => { + if (!file) { + return Promise.resolve(undefined); + } + + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + } + + const url = URL.createObjectURL(file); + urlRef.current = url; + + return preloadImage(url); + }, [file]); + + useEffect(() => { + return () => { + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = undefined; + } + }; + }, []); + + return { image }; +} diff --git a/src/lib/croppie.ts b/src/lib/croppie.ts deleted file mode 100644 index aadbf9f87..000000000 --- a/src/lib/croppie.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Croppie from 'croppie'; - -import 'croppie/croppie.css'; - -export default Croppie;