avatarCropper.tsx 13 KB

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