avatarUploader.tsx 13 KB

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