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))
|
||||
* [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))
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<OwnProps> = ({ 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 (
|
||||
<Modal
|
||||
isOpen={Boolean(file)}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={lang('CropperTitle')}
|
||||
className="CropModal"
|
||||
hasCloseButton
|
||||
isCondensedHeader
|
||||
>
|
||||
{isCroppieReady ? (
|
||||
<div id="avatar-crop" />
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
<Button
|
||||
className="confirm-button"
|
||||
round
|
||||
color="primary"
|
||||
onClick={handleCropClick}
|
||||
ariaLabel={lang('CropperApply')}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</Button>
|
||||
<ImageCropper
|
||||
onChange={onChange}
|
||||
image={image}
|
||||
maxOutputSize={MAX_OUTPUT_SIZE}
|
||||
minOutputSize={MIN_OUTPUT_SIZE}
|
||||
/>
|
||||
</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;
|
||||
onEnter?: () => void;
|
||||
withBalanceBar?: boolean;
|
||||
isCondensedHeader?: boolean;
|
||||
};
|
||||
|
||||
const Modal: FC<OwnProps> = ({
|
||||
@ -74,6 +75,7 @@ const Modal: FC<OwnProps> = ({
|
||||
onCloseAnimationEnd,
|
||||
onEnter,
|
||||
withBalanceBar,
|
||||
isCondensedHeader,
|
||||
}) => {
|
||||
const {
|
||||
ref: modalRef,
|
||||
@ -158,7 +160,7 @@ const Modal: FC<OwnProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={buildClassName('modal-header', headerClassName)}>
|
||||
<div className={buildClassName('modal-header', headerClassName, isCondensedHeader && 'modal-header-condensed')}>
|
||||
{withCloseButton && closeButton}
|
||||
<div className="modal-title">{title}</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