Image Cropper: Support custom implementation (#5993)
This commit is contained in:
parent
c50bc7347a
commit
8f9310c7d5
@ -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))
|
* [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))
|
* [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))
|
* [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))
|
* [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))
|
* [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))
|
* [lowlight](https://github.com/wooorm/lowlight) ([MIT License](https://github.com/wooorm/lowlight/blob/main/license))
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -12,7 +12,6 @@
|
|||||||
"@cryptography/aes": "^0.1.1",
|
"@cryptography/aes": "^0.1.1",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"big-integer": "github:painor/BigInteger.js",
|
"big-integer": "github:painor/BigInteger.js",
|
||||||
"croppie": "^2.6.5",
|
|
||||||
"emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105",
|
"emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
@ -42,7 +41,6 @@
|
|||||||
"@stylistic/stylelint-plugin": "^3.1.2",
|
"@stylistic/stylelint-plugin": "^3.1.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@twbs/fantasticon": "^3.1.0",
|
"@twbs/fantasticon": "^3.1.0",
|
||||||
"@types/croppie": "^2.6.4",
|
|
||||||
"@types/dom-view-transitions": "^1.0.6",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@ -5699,13 +5697,6 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"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": "^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": {
|
"node_modules/cross-dirname": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||||
|
|||||||
@ -63,7 +63,6 @@
|
|||||||
"@stylistic/stylelint-plugin": "^3.1.2",
|
"@stylistic/stylelint-plugin": "^3.1.2",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@twbs/fantasticon": "^3.1.0",
|
"@twbs/fantasticon": "^3.1.0",
|
||||||
"@types/croppie": "^2.6.4",
|
|
||||||
"@types/dom-view-transitions": "^1.0.6",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@ -139,7 +138,6 @@
|
|||||||
"@cryptography/aes": "^0.1.1",
|
"@cryptography/aes": "^0.1.1",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"big-integer": "github:painor/BigInteger.js",
|
"big-integer": "github:painor/BigInteger.js",
|
||||||
"croppie": "^2.6.5",
|
|
||||||
"emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105",
|
"emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#443f1c9d7b16a82e7ee53f7f226d7d9a9920a105",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
|
|||||||
@ -12,76 +12,12 @@
|
|||||||
.CropModal {
|
.CropModal {
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: auto;
|
||||||
width: calc(100% - 2rem);
|
min-width: inherit !important;
|
||||||
max-width: 35rem;
|
max-width: inherit !important;
|
||||||
height: calc(100% - 1rem);
|
|
||||||
max-height: 35rem;
|
|
||||||
}
|
}
|
||||||
|
.modal-content {
|
||||||
.modal-content,
|
padding: 1rem;
|
||||||
#avatar-crop {
|
padding-top: 0.5rem;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,134 +1,43 @@
|
|||||||
import type { FC } from '../../lib/teact/teact';
|
|
||||||
import {
|
import {
|
||||||
memo, useCallback, useEffect, useState,
|
memo,
|
||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
|
|
||||||
import { DEBUG } from '../../config';
|
import useImageLoader from '../../hooks/useImageLoader';
|
||||||
import { blobToDataUri, blobToFile } from '../../util/files';
|
|
||||||
|
|
||||||
import useLang from '../../hooks/useLang';
|
import useLang from '../../hooks/useLang';
|
||||||
|
|
||||||
import Icon from '../common/icons/Icon';
|
import ImageCropper from './ImageCropper';
|
||||||
import Button from './Button';
|
|
||||||
import Loading from './Loading';
|
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
|
|
||||||
import './CropModal.scss';
|
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 = {
|
type OwnProps = {
|
||||||
file?: Blob;
|
file?: Blob;
|
||||||
onChange: (file: File) => void;
|
onChange: (file: File) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CropModal: FC<OwnProps> = ({ file, onChange, onClose }: OwnProps) => {
|
const MAX_OUTPUT_SIZE = 1024;
|
||||||
const [isCroppieReady, setIsCroppieReady] = useState(false);
|
const MIN_OUTPUT_SIZE = 256;
|
||||||
|
|
||||||
|
const CropModal = ({ file, onChange, onClose }: OwnProps) => {
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
const { image } = useImageLoader(file);
|
||||||
useEffect(() => {
|
const isOpen = Boolean(file) && Boolean(image);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={Boolean(file)}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={lang('CropperTitle')}
|
title={lang('CropperTitle')}
|
||||||
className="CropModal"
|
className="CropModal"
|
||||||
hasCloseButton
|
hasCloseButton
|
||||||
|
isCondensedHeader
|
||||||
>
|
>
|
||||||
{isCroppieReady ? (
|
<ImageCropper
|
||||||
<div id="avatar-crop" />
|
onChange={onChange}
|
||||||
) : (
|
image={image}
|
||||||
<Loading />
|
maxOutputSize={MAX_OUTPUT_SIZE}
|
||||||
)}
|
minOutputSize={MIN_OUTPUT_SIZE}
|
||||||
<Button
|
/>
|
||||||
className="confirm-button"
|
|
||||||
round
|
|
||||||
color="primary"
|
|
||||||
onClick={handleCropClick}
|
|
||||||
ariaLabel={lang('CropperApply')}
|
|
||||||
>
|
|
||||||
<Icon name="check" />
|
|
||||||
</Button>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
77
src/components/ui/ImageCropper.module.scss
Normal file
77
src/components/ui/ImageCropper.module.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
212
src/components/ui/ImageCropper.tsx
Normal file
212
src/components/ui/ImageCropper.tsx
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={styles.previewContainer}
|
||||||
|
style={buildStyle(
|
||||||
|
`width: ${previewContainerSize}px`,
|
||||||
|
`height: ${previewContainerSize}px`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.src}
|
||||||
|
className={styles.backgroundImage}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
draggable={false}
|
||||||
|
onMouseDown={startDrag}
|
||||||
|
onTouchStart={startDrag}
|
||||||
|
alt=""
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
<div className={styles.previewMask} />
|
||||||
|
<div className={styles.cropArea}>
|
||||||
|
<img
|
||||||
|
src={image.src}
|
||||||
|
className={styles.foregroundImage}
|
||||||
|
style={foregroundImageStyle}
|
||||||
|
draggable={false}
|
||||||
|
alt=""
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bottomControls}>
|
||||||
|
<RangeSlider
|
||||||
|
className={styles.zoomSlider}
|
||||||
|
min={MIN_ZOOM}
|
||||||
|
max={MAX_ZOOM}
|
||||||
|
value={zoom}
|
||||||
|
onChange={handleZoomChange}
|
||||||
|
/>
|
||||||
|
<Button className={styles.confirmButton} round color="primary" onClick={handleCrop} ariaLabel="Crop">
|
||||||
|
<Icon name="check" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(ImageCropper);
|
||||||
@ -49,6 +49,7 @@ export type OwnProps = {
|
|||||||
onCloseAnimationEnd?: () => void;
|
onCloseAnimationEnd?: () => void;
|
||||||
onEnter?: () => void;
|
onEnter?: () => void;
|
||||||
withBalanceBar?: boolean;
|
withBalanceBar?: boolean;
|
||||||
|
isCondensedHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Modal: FC<OwnProps> = ({
|
const Modal: FC<OwnProps> = ({
|
||||||
@ -74,6 +75,7 @@ const Modal: FC<OwnProps> = ({
|
|||||||
onCloseAnimationEnd,
|
onCloseAnimationEnd,
|
||||||
onEnter,
|
onEnter,
|
||||||
withBalanceBar,
|
withBalanceBar,
|
||||||
|
isCondensedHeader,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
ref: modalRef,
|
ref: modalRef,
|
||||||
@ -158,7 +160,7 @@ const Modal: FC<OwnProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={buildClassName('modal-header', headerClassName)}>
|
<div className={buildClassName('modal-header', headerClassName, isCondensedHeader && 'modal-header-condensed')}>
|
||||||
{withCloseButton && closeButton}
|
{withCloseButton && closeButton}
|
||||||
<div className="modal-title">{title}</div>
|
<div className="modal-title">{title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
38
src/hooks/useImageLoader.ts
Normal file
38
src/hooks/useImageLoader.ts
Normal file
@ -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<string | undefined>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import Croppie from 'croppie';
|
|
||||||
|
|
||||||
import 'croppie/croppie.css';
|
|
||||||
|
|
||||||
export default Croppie;
|
|
||||||
Loading…
x
Reference in New Issue
Block a user