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; 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 { 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(); canvas = createRef(); image = createRef(); cropContainer = createRef(); // 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) => { 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) => { 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) => { 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) => { 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 ( e.preventDefault()} /> {Object.keys(resizerPositions).map(pos => ( ))} ); } render() { const src = this.imageSrc; const upload = ; const uploader = (

{tct('[upload:Upload an image] to get started.', {upload})}

); return ( {!src && uploader} {src && } {this.renderImageCrop()}
); } } 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; `;