avatarUploader.tsx 13 KB

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