avatarCropper.tsx 13 KB

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