avatarCropper.tsx 13 KB

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