123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- import {Component, createRef, Fragment} from 'react';
- import styled from '@emotion/styled';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import Well from 'sentry/components/well';
- import {AVATAR_URL_MAP} from 'sentry/constants';
- import {t, tct} from 'sentry/locale';
- import {AvatarUser} from 'sentry/types';
- export function getDiffNW(yDiff: number, xDiff: number) {
- return (yDiff - yDiff * 2 + (xDiff - xDiff * 2)) / 2;
- }
- export function getDiffNE(yDiff: number, xDiff: number) {
- return (yDiff - yDiff * 2 + xDiff) / 2;
- }
- export function getDiffSW(yDiff: number, xDiff: number) {
- return (yDiff + (xDiff - xDiff * 2)) / 2;
- }
- export function getDiffSE(yDiff: number, xDiff: number) {
- return (yDiff + xDiff) / 2;
- }
- const resizerPositions = {
- nw: ['top', 'left'],
- ne: ['top', 'right'],
- se: ['bottom', 'right'],
- sw: ['bottom', 'left'],
- };
- type Position = keyof typeof resizerPositions;
- type Model = Pick<AvatarUser, 'avatar'>;
- type Props = {
- model: Model;
- type:
- | 'user'
- | 'team'
- | 'organization'
- | 'project'
- | 'sentryAppColor'
- | 'sentryAppSimple'
- | 'docIntegration';
- updateDataUrlState: (opts: {dataUrl?: string; savedDataUrl?: string | null}) => void;
- savedDataUrl?: string;
- };
- type State = {
- file: File | null;
- mousePosition: {pageX: number; pageY: number};
- objectURL: string | null;
- resizeDimensions: {left: number; size: number; top: number};
- resizeDirection: Position | null;
- };
- export class AvatarCropper extends Component<Props, State> {
- state: State = {
- file: null,
- objectURL: null,
- mousePosition: {pageX: 0, pageY: 0},
- resizeDimensions: {top: 0, left: 0, size: 0},
- resizeDirection: null,
- };
- componentWillUnmount() {
- this.revokeObjectUrl();
- }
- file = createRef<HTMLInputElement>();
- canvas = createRef<HTMLCanvasElement>();
- image = createRef<HTMLImageElement>();
- cropContainer = createRef<HTMLDivElement>();
- // These values must be synced with the avatar endpoint in backend.
- MIN_DIMENSION = 256;
- MAX_DIMENSION = 1024;
- ALLOWED_MIMETYPES = 'image/gif,image/jpeg,image/png';
- onSelectFile = (ev: React.ChangeEvent<HTMLInputElement>) => {
- const file = ev.target.files && ev.target.files[0];
- // No file selected (e.g. user clicked "cancel")
- if (!file) {
- return;
- }
- if (!/^image\//.test(file.type)) {
- addErrorMessage(t('That is not a supported file type.'));
- return;
- }
- this.revokeObjectUrl();
- const {updateDataUrlState} = this.props;
- const objectURL = window.URL.createObjectURL(file);
- this.setState({file, objectURL}, () => updateDataUrlState({savedDataUrl: null}));
- };
- revokeObjectUrl = () =>
- this.state.objectURL && window.URL.revokeObjectURL(this.state.objectURL);
- onImageLoad = () => {
- const error = this.validateImage();
- if (error) {
- this.revokeObjectUrl();
- this.setState({objectURL: null});
- addErrorMessage(error);
- return;
- }
- const image = this.image.current;
- if (!image) {
- return;
- }
- const dimension = Math.min(image.clientHeight, image.clientWidth);
- const state = {resizeDimensions: {size: dimension, top: 0, left: 0}};
- this.setState(state, this.drawToCanvas);
- };
- updateDimensions = (ev: MouseEvent) => {
- const cropContainer = this.cropContainer.current;
- if (!cropContainer) {
- return;
- }
- const {mousePosition, resizeDimensions} = this.state;
- let pageY = ev.pageY;
- let pageX = ev.pageX;
- let top = resizeDimensions.top + (pageY - mousePosition.pageY);
- let left = resizeDimensions.left + (pageX - mousePosition.pageX);
- if (top < 0) {
- top = 0;
- pageY = mousePosition.pageY;
- } else if (top + resizeDimensions.size > cropContainer.clientHeight) {
- top = cropContainer.clientHeight - resizeDimensions.size;
- pageY = mousePosition.pageY;
- }
- if (left < 0) {
- left = 0;
- pageX = mousePosition.pageX;
- } else if (left + resizeDimensions.size > cropContainer.clientWidth) {
- left = cropContainer.clientWidth - resizeDimensions.size;
- pageX = mousePosition.pageX;
- }
- this.setState(state => ({
- resizeDimensions: {...state.resizeDimensions, top, left},
- mousePosition: {pageX, pageY},
- }));
- };
- onMouseDown = (ev: React.MouseEvent<HTMLDivElement>) => {
- ev.preventDefault();
- this.setState({mousePosition: {pageY: ev.pageY, pageX: ev.pageX}});
- document.addEventListener('mousemove', this.updateDimensions);
- document.addEventListener('mouseup', this.onMouseUp);
- };
- onMouseUp = (ev: MouseEvent) => {
- ev.preventDefault();
- document.removeEventListener('mousemove', this.updateDimensions);
- document.removeEventListener('mouseup', this.onMouseUp);
- this.drawToCanvas();
- };
- startResize = (direction: Position, ev: React.MouseEvent<HTMLDivElement>) => {
- ev.stopPropagation();
- ev.preventDefault();
- document.addEventListener('mousemove', this.updateSize);
- document.addEventListener('mouseup', this.stopResize);
- this.setState({
- resizeDirection: direction,
- mousePosition: {pageY: ev.pageY, pageX: ev.pageX},
- });
- };
- stopResize = (ev: MouseEvent) => {
- ev.stopPropagation();
- ev.preventDefault();
- document.removeEventListener('mousemove', this.updateSize);
- document.removeEventListener('mouseup', this.stopResize);
- this.setState({resizeDirection: null});
- this.drawToCanvas();
- };
- updateSize = (ev: MouseEvent) => {
- const cropContainer = this.cropContainer.current;
- if (!cropContainer) {
- return;
- }
- const {mousePosition} = this.state;
- const yDiff = ev.pageY - mousePosition.pageY;
- const xDiff = ev.pageX - mousePosition.pageX;
- this.setState({
- resizeDimensions: this.getNewDimensions(cropContainer, yDiff, xDiff),
- mousePosition: {pageX: ev.pageX, pageY: ev.pageY},
- });
- };
- // Normalize diff across dimensions so that negative diffs are always making
- // the cropper smaller and positive ones are making the cropper larger
- getDiffNW = getDiffNW;
- getDiffNE = getDiffNE;
- getDiffSW = getDiffSW;
- getDiffSE = getDiffSE;
- getNewDimensions = (container: HTMLDivElement, yDiff: number, xDiff: number) => {
- const {resizeDimensions: oldDimensions, resizeDirection} = this.state;
- const diff = this['getDiff' + resizeDirection!.toUpperCase()](yDiff, xDiff);
- let height = container.clientHeight - oldDimensions.top;
- let width = container.clientWidth - oldDimensions.left;
- // Depending on the direction, we update different dimensions:
- // nw: size, top, left
- // ne: size, top
- // sw: size, left
- // se: size
- const editingTop = resizeDirection === 'nw' || resizeDirection === 'ne';
- const editingLeft = resizeDirection === 'nw' || resizeDirection === 'sw';
- const newDimensions = {
- top: 0,
- left: 0,
- size: oldDimensions.size + diff,
- };
- if (editingTop) {
- newDimensions.top = oldDimensions.top - diff;
- height = container.clientHeight - newDimensions.top;
- }
- if (editingLeft) {
- newDimensions.left = oldDimensions.left - diff;
- width = container.clientWidth - newDimensions.left;
- }
- if (newDimensions.top < 0) {
- newDimensions.size = newDimensions.size + newDimensions.top;
- if (editingLeft) {
- newDimensions.left = newDimensions.left - newDimensions.top;
- }
- newDimensions.top = 0;
- }
- if (newDimensions.left < 0) {
- newDimensions.size = newDimensions.size + newDimensions.left;
- if (editingTop) {
- newDimensions.top = newDimensions.top - newDimensions.left;
- }
- newDimensions.left = 0;
- }
- const maxSize = Math.min(width, height);
- if (newDimensions.size > maxSize) {
- if (editingTop) {
- newDimensions.top = newDimensions.top + newDimensions.size - maxSize;
- }
- if (editingLeft) {
- newDimensions.left = newDimensions.left + newDimensions.size - maxSize;
- }
- newDimensions.size = maxSize;
- } else if (newDimensions.size < this.MIN_DIMENSION) {
- if (editingTop) {
- newDimensions.top = newDimensions.top + newDimensions.size - this.MIN_DIMENSION;
- }
- if (editingLeft) {
- newDimensions.left = newDimensions.left + newDimensions.size - this.MIN_DIMENSION;
- }
- newDimensions.size = this.MIN_DIMENSION;
- }
- return {...oldDimensions, ...newDimensions};
- };
- validateImage() {
- const img = this.image.current;
- if (!img) {
- return null;
- }
- if (img.naturalWidth < this.MIN_DIMENSION || img.naturalHeight < this.MIN_DIMENSION) {
- return tct('Please upload an image larger than [size]px by [size]px.', {
- size: this.MIN_DIMENSION - 1,
- });
- }
- if (img.naturalWidth > this.MAX_DIMENSION || img.naturalHeight > this.MAX_DIMENSION) {
- return tct('Please upload an image smaller than [size]px by [size]px.', {
- size: this.MAX_DIMENSION,
- });
- }
- return null;
- }
- drawToCanvas() {
- const canvas = this.canvas.current;
- if (!canvas) {
- return;
- }
- const image = this.image.current;
- if (!image) {
- return;
- }
- const {left, top, size} = this.state.resizeDimensions;
- // Calculate difference between natural dimensions and rendered dimensions
- const ratio =
- (image.naturalHeight / image.clientHeight +
- image.naturalWidth / image.clientWidth) /
- 2;
- canvas.width = size * ratio;
- canvas.height = size * ratio;
- canvas
- .getContext('2d')!
- .drawImage(
- image,
- left * ratio,
- top * ratio,
- size * ratio,
- size * ratio,
- 0,
- 0,
- size * ratio,
- size * ratio
- );
- this.props.updateDataUrlState({dataUrl: canvas.toDataURL()});
- }
- get imageSrc() {
- const {savedDataUrl, model, type} = this.props;
- const uuid = model.avatar?.avatarUuid;
- const photoUrl = uuid && `/${AVATAR_URL_MAP[type] || 'avatar'}/${uuid}/`;
- return savedDataUrl || this.state.objectURL || photoUrl;
- }
- uploadClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
- ev.preventDefault();
- this.file.current && this.file.current.click();
- };
- renderImageCrop() {
- const src = this.imageSrc;
- if (!src) {
- return null;
- }
- const {resizeDimensions, resizeDirection} = this.state;
- const style = {
- top: resizeDimensions.top,
- left: resizeDimensions.left,
- width: resizeDimensions.size,
- height: resizeDimensions.size,
- };
- return (
- <ImageCropper resizeDirection={resizeDirection}>
- <CropContainer ref={this.cropContainer}>
- <img
- ref={this.image}
- src={src}
- onLoad={this.onImageLoad}
- onDragStart={e => e.preventDefault()}
- />
- <Cropper style={style} onMouseDown={this.onMouseDown}>
- {Object.keys(resizerPositions).map(pos => (
- <Resizer
- key={pos}
- position={pos as Position}
- onMouseDown={this.startResize.bind(this, pos)}
- />
- ))}
- </Cropper>
- </CropContainer>
- </ImageCropper>
- );
- }
- render() {
- const src = this.imageSrc;
- const upload = <a onClick={this.uploadClick} />;
- const uploader = (
- <Well hasImage centered>
- <p>{tct('[upload:Upload an image] to get started.', {upload})}</p>
- </Well>
- );
- return (
- <Fragment>
- {!src && uploader}
- {src && <HiddenCanvas ref={this.canvas} />}
- {this.renderImageCrop()}
- <div className="form-group">
- {src && <a onClick={this.uploadClick}>{t('Change Photo')}</a>}
- <UploadInput
- ref={this.file}
- type="file"
- accept={this.ALLOWED_MIMETYPES}
- onChange={this.onSelectFile}
- />
- </div>
- </Fragment>
- );
- }
- }
- const UploadInput = styled('input')`
- position: absolute;
- opacity: 0;
- `;
- const ImageCropper = styled('div')<{resizeDirection: Position | null}>`
- cursor: ${p => (p.resizeDirection ? `${p.resizeDirection}-resize` : 'default')};
- text-align: center;
- margin-bottom: 20px;
- background-size: 20px 20px;
- background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
- background-color: ${p => p.theme.background};
- background-image: linear-gradient(
- 45deg,
- ${p => p.theme.backgroundSecondary} 25%,
- rgba(0, 0, 0, 0) 25%
- ),
- linear-gradient(-45deg, ${p => p.theme.backgroundSecondary} 25%, rgba(0, 0, 0, 0) 25%),
- linear-gradient(45deg, rgba(0, 0, 0, 0) 75%, ${p => p.theme.backgroundSecondary} 75%),
- linear-gradient(-45deg, rgba(0, 0, 0, 0) 75%, ${p => p.theme.backgroundSecondary} 75%);
- `;
- const CropContainer = styled('div')`
- display: inline-block;
- position: relative;
- max-width: 100%;
- `;
- const Cropper = styled('div')`
- position: absolute;
- border: 2px dashed ${p => p.theme.gray300};
- `;
- const Resizer = styled('div')<{position: Position}>`
- border-radius: 5px;
- width: 10px;
- height: 10px;
- position: absolute;
- background-color: ${p => p.theme.gray300};
- cursor: ${p => `${p.position}-resize`};
- ${p => resizerPositions[p.position].map(pos => `${pos}: -5px;`)}
- `;
- const HiddenCanvas = styled('canvas')`
- display: none;
- `;
|