avatarCropper.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import Well from 'sentry/components/well';
  5. import {AVATAR_URL_MAP} from 'sentry/constants';
  6. import {t, tct} from 'sentry/locale';
  7. import {AvatarUser} from 'sentry/types';
  8. const resizerPositions = {
  9. nw: ['top', 'left'],
  10. ne: ['top', 'right'],
  11. se: ['bottom', 'right'],
  12. sw: ['bottom', 'left'],
  13. };
  14. type Position = keyof typeof resizerPositions;
  15. type Model = Pick<AvatarUser, 'avatar'>;
  16. type Props = {
  17. model: Model;
  18. updateDataUrlState: (opts: {savedDataUrl?: string | null; dataUrl?: string}) => void;
  19. type:
  20. | 'user'
  21. | 'team'
  22. | 'organization'
  23. | 'project'
  24. | 'sentryAppColor'
  25. | 'sentryAppSimple';
  26. savedDataUrl?: string;
  27. };
  28. type State = {
  29. file: File | null;
  30. objectURL: string | null;
  31. mousePosition: {pageX: number; pageY: number};
  32. resizeDimensions: {top: number; left: number; size: number};
  33. resizeDirection: Position | null;
  34. };
  35. class AvatarCropper extends React.Component<Props, State> {
  36. state: State = {
  37. file: null,
  38. objectURL: null,
  39. mousePosition: {pageX: 0, pageY: 0},
  40. resizeDimensions: {top: 0, left: 0, size: 0},
  41. resizeDirection: null,
  42. };
  43. componentWillUnmount() {
  44. this.revokeObjectUrl();
  45. }
  46. file = React.createRef<HTMLInputElement>();
  47. canvas = React.createRef<HTMLCanvasElement>();
  48. image = React.createRef<HTMLImageElement>();
  49. cropContainer = React.createRef<HTMLDivElement>();
  50. // These values must be synced with the avatar endpoint in backend.
  51. MIN_DIMENSION = 256;
  52. MAX_DIMENSION = 1024;
  53. ALLOWED_MIMETYPES = 'image/gif,image/jpeg,image/png';
  54. onSelectFile = (ev: React.ChangeEvent<HTMLInputElement>) => {
  55. const file = ev.target.files && ev.target.files[0];
  56. // No file selected (e.g. user clicked "cancel")
  57. if (!file) {
  58. return;
  59. }
  60. if (!/^image\//.test(file.type)) {
  61. addErrorMessage(t('That is not a supported file type.'));
  62. return;
  63. }
  64. this.revokeObjectUrl();
  65. const {updateDataUrlState} = this.props;
  66. const objectURL = window.URL.createObjectURL(file);
  67. this.setState({file, objectURL}, () => updateDataUrlState({savedDataUrl: null}));
  68. };
  69. revokeObjectUrl = () =>
  70. this.state.objectURL && window.URL.revokeObjectURL(this.state.objectURL);
  71. onImageLoad = () => {
  72. const error = this.validateImage();
  73. if (error) {
  74. this.revokeObjectUrl();
  75. this.setState({objectURL: null});
  76. addErrorMessage(error);
  77. return;
  78. }
  79. const image = this.image.current;
  80. if (!image) {
  81. return;
  82. }
  83. const dimension = Math.min(image.clientHeight, image.clientWidth);
  84. const state = {resizeDimensions: {size: dimension, top: 0, left: 0}};
  85. this.setState(state, this.drawToCanvas);
  86. };
  87. updateDimensions = (ev: MouseEvent) => {
  88. const cropContainer = this.cropContainer.current;
  89. if (!cropContainer) {
  90. return;
  91. }
  92. const {mousePosition, resizeDimensions} = this.state;
  93. let pageY = ev.pageY;
  94. let pageX = ev.pageX;
  95. let top = resizeDimensions.top + (pageY - mousePosition.pageY);
  96. let left = resizeDimensions.left + (pageX - mousePosition.pageX);
  97. if (top < 0) {
  98. top = 0;
  99. pageY = mousePosition.pageY;
  100. } else if (top + resizeDimensions.size > cropContainer.clientHeight) {
  101. top = cropContainer.clientHeight - resizeDimensions.size;
  102. pageY = mousePosition.pageY;
  103. }
  104. if (left < 0) {
  105. left = 0;
  106. pageX = mousePosition.pageX;
  107. } else if (left + resizeDimensions.size > cropContainer.clientWidth) {
  108. left = cropContainer.clientWidth - resizeDimensions.size;
  109. pageX = mousePosition.pageX;
  110. }
  111. this.setState(state => ({
  112. resizeDimensions: {...state.resizeDimensions, top, left},
  113. mousePosition: {pageX, pageY},
  114. }));
  115. };
  116. onMouseDown = (ev: React.MouseEvent<HTMLDivElement>) => {
  117. ev.preventDefault();
  118. this.setState({mousePosition: {pageY: ev.pageY, pageX: ev.pageX}});
  119. document.addEventListener('mousemove', this.updateDimensions);
  120. document.addEventListener('mouseup', this.onMouseUp);
  121. };
  122. onMouseUp = (ev: MouseEvent) => {
  123. ev.preventDefault();
  124. document.removeEventListener('mousemove', this.updateDimensions);
  125. document.removeEventListener('mouseup', this.onMouseUp);
  126. this.drawToCanvas();
  127. };
  128. startResize = (direction: Position, ev: React.MouseEvent<HTMLDivElement>) => {
  129. ev.stopPropagation();
  130. ev.preventDefault();
  131. document.addEventListener('mousemove', this.updateSize);
  132. document.addEventListener('mouseup', this.stopResize);
  133. this.setState({
  134. resizeDirection: direction,
  135. mousePosition: {pageY: ev.pageY, pageX: ev.pageX},
  136. });
  137. };
  138. stopResize = (ev: MouseEvent) => {
  139. ev.stopPropagation();
  140. ev.preventDefault();
  141. document.removeEventListener('mousemove', this.updateSize);
  142. document.removeEventListener('mouseup', this.stopResize);
  143. this.setState({resizeDirection: null});
  144. this.drawToCanvas();
  145. };
  146. updateSize = (ev: MouseEvent) => {
  147. const cropContainer = this.cropContainer.current;
  148. if (!cropContainer) {
  149. return;
  150. }
  151. const {mousePosition} = this.state;
  152. const yDiff = ev.pageY - mousePosition.pageY;
  153. const xDiff = ev.pageX - mousePosition.pageX;
  154. this.setState({
  155. resizeDimensions: this.getNewDimensions(cropContainer, yDiff, xDiff),
  156. mousePosition: {pageX: ev.pageX, pageY: ev.pageY},
  157. });
  158. };
  159. // Normalize diff across dimensions so that negative diffs are always making
  160. // the cropper smaller and positive ones are making the cropper larger
  161. getDiffNW = (yDiff: number, xDiff: number) =>
  162. (yDiff - yDiff * 2 + (xDiff - xDiff * 2)) / 2;
  163. getDiffNE = (yDiff: number, xDiff: number) => (yDiff - yDiff * 2 + xDiff) / 2;
  164. getDiffSW = (yDiff: number, xDiff: number) => (yDiff + (xDiff - xDiff * 2)) / 2;
  165. getDiffSE = (yDiff: number, xDiff: number) => (yDiff + xDiff) / 2;
  166. getNewDimensions = (container: HTMLDivElement, yDiff: number, xDiff: number) => {
  167. const {resizeDimensions: oldDimensions, resizeDirection} = this.state;
  168. const diff = this['getDiff' + resizeDirection!.toUpperCase()](yDiff, xDiff);
  169. let height = container.clientHeight - oldDimensions.top;
  170. let width = container.clientWidth - oldDimensions.left;
  171. // Depending on the direction, we update different dimensions:
  172. // nw: size, top, left
  173. // ne: size, top
  174. // sw: size, left
  175. // se: size
  176. const editingTop = resizeDirection === 'nw' || resizeDirection === 'ne';
  177. const editingLeft = resizeDirection === 'nw' || resizeDirection === 'sw';
  178. const newDimensions = {
  179. top: 0,
  180. left: 0,
  181. size: oldDimensions.size + diff,
  182. };
  183. if (editingTop) {
  184. newDimensions.top = oldDimensions.top - diff;
  185. height = container.clientHeight - newDimensions.top;
  186. }
  187. if (editingLeft) {
  188. newDimensions.left = oldDimensions.left - diff;
  189. width = container.clientWidth - newDimensions.left;
  190. }
  191. if (newDimensions.top < 0) {
  192. newDimensions.size = newDimensions.size + newDimensions.top;
  193. if (editingLeft) {
  194. newDimensions.left = newDimensions.left - newDimensions.top;
  195. }
  196. newDimensions.top = 0;
  197. }
  198. if (newDimensions.left < 0) {
  199. newDimensions.size = newDimensions.size + newDimensions.left;
  200. if (editingTop) {
  201. newDimensions.top = newDimensions.top - newDimensions.left;
  202. }
  203. newDimensions.left = 0;
  204. }
  205. const maxSize = Math.min(width, height);
  206. if (newDimensions.size > maxSize) {
  207. if (editingTop) {
  208. newDimensions.top = newDimensions.top + newDimensions.size - maxSize;
  209. }
  210. if (editingLeft) {
  211. newDimensions.left = newDimensions.left + newDimensions.size - maxSize;
  212. }
  213. newDimensions.size = maxSize;
  214. } else if (newDimensions.size < this.MIN_DIMENSION) {
  215. if (editingTop) {
  216. newDimensions.top = newDimensions.top + newDimensions.size - this.MIN_DIMENSION;
  217. }
  218. if (editingLeft) {
  219. newDimensions.left = newDimensions.left + newDimensions.size - this.MIN_DIMENSION;
  220. }
  221. newDimensions.size = this.MIN_DIMENSION;
  222. }
  223. return {...oldDimensions, ...newDimensions};
  224. };
  225. validateImage() {
  226. const img = this.image.current;
  227. if (!img) {
  228. return null;
  229. }
  230. if (img.naturalWidth < this.MIN_DIMENSION || img.naturalHeight < this.MIN_DIMENSION) {
  231. return tct('Please upload an image larger than [size]px by [size]px.', {
  232. size: this.MIN_DIMENSION - 1,
  233. });
  234. }
  235. if (img.naturalWidth > this.MAX_DIMENSION || img.naturalHeight > this.MAX_DIMENSION) {
  236. return tct('Please upload an image smaller than [size]px by [size]px.', {
  237. size: this.MAX_DIMENSION,
  238. });
  239. }
  240. return null;
  241. }
  242. drawToCanvas() {
  243. const canvas = this.canvas.current;
  244. if (!canvas) {
  245. return;
  246. }
  247. const image = this.image.current;
  248. if (!image) {
  249. return;
  250. }
  251. const {left, top, size} = this.state.resizeDimensions;
  252. // Calculate difference between natural dimensions and rendered dimensions
  253. const ratio =
  254. (image.naturalHeight / image.clientHeight +
  255. image.naturalWidth / image.clientWidth) /
  256. 2;
  257. canvas.width = size * ratio;
  258. canvas.height = size * ratio;
  259. canvas
  260. .getContext('2d')!
  261. .drawImage(
  262. image,
  263. left * ratio,
  264. top * ratio,
  265. size * ratio,
  266. size * ratio,
  267. 0,
  268. 0,
  269. size * ratio,
  270. size * ratio
  271. );
  272. this.props.updateDataUrlState({dataUrl: canvas.toDataURL()});
  273. }
  274. get imageSrc() {
  275. const {savedDataUrl, model, type} = this.props;
  276. const uuid = model.avatar?.avatarUuid;
  277. const photoUrl = uuid && `/${AVATAR_URL_MAP[type] || 'avatar'}/${uuid}/`;
  278. return savedDataUrl || this.state.objectURL || photoUrl;
  279. }
  280. uploadClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
  281. ev.preventDefault();
  282. this.file.current && this.file.current.click();
  283. };
  284. renderImageCrop() {
  285. const src = this.imageSrc;
  286. if (!src) {
  287. return null;
  288. }
  289. const {resizeDimensions, resizeDirection} = this.state;
  290. const style = {
  291. top: resizeDimensions.top,
  292. left: resizeDimensions.left,
  293. width: resizeDimensions.size,
  294. height: resizeDimensions.size,
  295. };
  296. return (
  297. <ImageCropper resizeDirection={resizeDirection}>
  298. <CropContainer ref={this.cropContainer}>
  299. <img
  300. ref={this.image}
  301. src={src}
  302. onLoad={this.onImageLoad}
  303. onDragStart={e => e.preventDefault()}
  304. />
  305. <Cropper style={style} onMouseDown={this.onMouseDown}>
  306. {Object.keys(resizerPositions).map(pos => (
  307. <Resizer
  308. key={pos}
  309. position={pos as Position}
  310. onMouseDown={this.startResize.bind(this, pos)}
  311. />
  312. ))}
  313. </Cropper>
  314. </CropContainer>
  315. </ImageCropper>
  316. );
  317. }
  318. render() {
  319. const src = this.imageSrc;
  320. const upload = <a onClick={this.uploadClick} />;
  321. const uploader = (
  322. <Well hasImage centered>
  323. <p>{tct('[upload:Upload a photo] to get started.', {upload})}</p>
  324. </Well>
  325. );
  326. return (
  327. <React.Fragment>
  328. {!src && uploader}
  329. {src && <HiddenCanvas ref={this.canvas} />}
  330. {this.renderImageCrop()}
  331. <div className="form-group">
  332. {src && <a onClick={this.uploadClick}>{t('Change Photo')}</a>}
  333. <UploadInput
  334. ref={this.file}
  335. type="file"
  336. accept={this.ALLOWED_MIMETYPES}
  337. onChange={this.onSelectFile}
  338. />
  339. </div>
  340. </React.Fragment>
  341. );
  342. }
  343. }
  344. export default AvatarCropper;
  345. const UploadInput = styled('input')`
  346. position: absolute;
  347. opacity: 0;
  348. `;
  349. const ImageCropper = styled('div')<{resizeDirection: Position | null}>`
  350. cursor: ${p => (p.resizeDirection ? `${p.resizeDirection}-resize` : 'default')};
  351. text-align: center;
  352. margin-bottom: 20px;
  353. background-size: 20px 20px;
  354. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  355. background-color: ${p => p.theme.background};
  356. background-image: linear-gradient(
  357. 45deg,
  358. ${p => p.theme.backgroundSecondary} 25%,
  359. rgba(0, 0, 0, 0) 25%
  360. ),
  361. linear-gradient(-45deg, ${p => p.theme.backgroundSecondary} 25%, rgba(0, 0, 0, 0) 25%),
  362. linear-gradient(45deg, rgba(0, 0, 0, 0) 75%, ${p => p.theme.backgroundSecondary} 75%),
  363. linear-gradient(-45deg, rgba(0, 0, 0, 0) 75%, ${p => p.theme.backgroundSecondary} 75%);
  364. `;
  365. const CropContainer = styled('div')`
  366. display: inline-block;
  367. position: relative;
  368. max-width: 100%;
  369. `;
  370. const Cropper = styled('div')`
  371. position: absolute;
  372. border: 2px dashed ${p => p.theme.gray300};
  373. `;
  374. const Resizer = styled('div')<{position: Position}>`
  375. border-radius: 5px;
  376. width: 10px;
  377. height: 10px;
  378. position: absolute;
  379. background-color: ${p => p.theme.gray300};
  380. cursor: ${p => `${p.position}-resize`};
  381. ${p => resizerPositions[p.position].map(pos => `${pos}: -5px;`)}
  382. `;
  383. const HiddenCanvas = styled('canvas')`
  384. display: none;
  385. `;