avatar_editor.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. // tslint:disable:max-line-length
  18. /**
  19. * Support uploading and resizing avatar
  20. */
  21. export default [
  22. '$window',
  23. '$timeout',
  24. '$translate',
  25. '$filter',
  26. '$log',
  27. '$mdDialog',
  28. function($window, $timeout: ng.ITimeoutService, $translate, $filter: any, $log: ng.ILogService, $mdDialog) {
  29. return {
  30. restrict: 'EA',
  31. scope: {
  32. onChange: '=',
  33. },
  34. link(scope: any, element, attrs, controller) {
  35. const logTag: string = '[AvatarEditorDirective]';
  36. // Constants
  37. const DRAGOVER_CSS_CLASS = 'is-dragover';
  38. const VIEWPORT_SIZE = 220;
  39. // Elements
  40. const editorArea: any = angular.element(element[0].querySelector('.avatar-editor'));
  41. const fileTrigger: any = angular.element(element[0].querySelector('.file-trigger'));
  42. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  43. // const avatarRemove: any = angular.element(element[0].querySelector('.avatar-remove'));
  44. // const navigation: any = angular.element(element[0].querySelector('.avatar-area-navigation'));
  45. const enabled = scope.enabled === undefined || scope.enabled === true;
  46. let croppieInstance = null;
  47. const initCroppie = () => {
  48. if (croppieInstance !== null) {
  49. return croppieInstance;
  50. }
  51. croppieInstance = new Croppie(element[0].querySelector('.croppie-container'), {
  52. viewport: {
  53. type: 'square',
  54. width: VIEWPORT_SIZE,
  55. height: VIEWPORT_SIZE,
  56. },
  57. customClass: 'has-image',
  58. showZoomer: true,
  59. update() {
  60. if (updateTimeout !== undefined) {
  61. clearTimeout(updateTimeout);
  62. }
  63. updateTimeout = setTimeout(() => {
  64. croppieInstance.result({
  65. type: 'blob',
  66. // max allowed size on device
  67. size: [512, 512],
  68. circle: false,
  69. format: 'png',
  70. })
  71. .then((blob: Blob) => {
  72. const fileReader = new FileReader();
  73. fileReader.onload = function() {
  74. scope.onChange(this.result);
  75. };
  76. fileReader.readAsArrayBuffer(blob);
  77. });
  78. }, 500);
  79. },
  80. });
  81. return croppieInstance;
  82. };
  83. function loading(show: boolean): void {
  84. if (show) {
  85. editorArea.addClass('loading');
  86. } else {
  87. editorArea.removeClass('loading');
  88. }
  89. }
  90. // set after default avatar is set
  91. let updateTimeout;
  92. // Function to fetch file contents
  93. // Resolve to ArrayBuffer or reject to ErrorEvent.
  94. function fetchFileContent(file: File): Promise<ArrayBuffer> {
  95. return new Promise((resolve, reject) => {
  96. const reader = new FileReader();
  97. reader.onload = (ev: Event) => {
  98. resolve((ev.target as FileReader).result);
  99. };
  100. reader.onerror = (ev: ErrorEvent) => {
  101. // set a null object
  102. reject(ev);
  103. };
  104. reader.onprogress = function(data) {
  105. if (data.lengthComputable) {
  106. // TODO implement progress?
  107. // let progress = ((data.loaded / data.total) * 100);
  108. }
  109. };
  110. reader.readAsArrayBuffer(file);
  111. });
  112. }
  113. function uploadFiles(fileList: FileList): void {
  114. if (fileList.length < 1) {
  115. return;
  116. }
  117. // get first
  118. fetchFileContent(fileList[0]).then((data: ArrayBuffer) => {
  119. const image = $filter('bufferToUrl')(data, 'image/jpeg', false);
  120. setImage(image);
  121. }).catch((ev: ErrorEvent) => {
  122. $log.error(logTag, 'Could not load file:', ev.message);
  123. });
  124. }
  125. // Handle the drop effect
  126. function onDrop(ev: DragEvent): void {
  127. ev.stopPropagation();
  128. ev.preventDefault();
  129. // simulate a leave to reset styles
  130. onDragleave(ev);
  131. // Upload files
  132. uploadFiles(ev.dataTransfer.files);
  133. }
  134. // File is dragged over compose area
  135. function onDragover(ev: DragEvent): void {
  136. ev.stopPropagation();
  137. ev.preventDefault();
  138. editorArea.addClass(DRAGOVER_CSS_CLASS);
  139. }
  140. // File is dragged out of compose area
  141. function onDragleave(ev: DragEvent): void {
  142. ev.stopPropagation();
  143. ev.preventDefault();
  144. editorArea.removeClass(DRAGOVER_CSS_CLASS);
  145. }
  146. // File trigger is clicked
  147. function onFileTrigger(ev: MouseEvent): void {
  148. ev.preventDefault();
  149. ev.stopPropagation();
  150. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  151. input.click();
  152. }
  153. // File(s) are uploaded via input field
  154. function onFileUploaded() {
  155. uploadFiles(this.files);
  156. }
  157. function setImage(newImage: any) {
  158. const croppie = initCroppie();
  159. if (newImage === null) {
  160. // set a none image
  161. // TODO
  162. croppie.bind({
  163. url: null,
  164. });
  165. scope.onChange(null);
  166. return;
  167. }
  168. loading(true);
  169. // load image to calculate size
  170. const img = new Image();
  171. img.addEventListener('load', function() {
  172. $log.debug(logTag, 'Image loaded');
  173. const w = this.naturalWidth;
  174. const h = this.naturalHeight;
  175. const size = Math.min(w, h);
  176. // set to center
  177. const imageSize = [
  178. (w - size) / 2,
  179. (h - size) / 2,
  180. size,
  181. size];
  182. croppie.bind({
  183. url: newImage,
  184. points: imageSize,
  185. }).then(() => {
  186. loading(false);
  187. }).catch((e) => {
  188. $log.error(logTag, 'Could not bind avatar preview:', e);
  189. loading(false);
  190. });
  191. if (newImage === null) {
  192. scope.onChange(null);
  193. }
  194. });
  195. img.addEventListener('error', function(e) {
  196. // this is not a valid image
  197. $log.error(logTag, 'Could not load image:', e);
  198. loading(false);
  199. });
  200. img.src = newImage;
  201. }
  202. // Handle dragover / dragleave events
  203. editorArea.on('dragover dragenter', onDragover);
  204. editorArea.on('dragleave dragend', onDragleave);
  205. // Handle drop event
  206. editorArea.on('drop', onDrop);
  207. // Handle click on file trigger
  208. fileTrigger.on('click', onFileTrigger);
  209. // Handle file uploads
  210. fileInput.on('change', onFileUploaded);
  211. // Handle remove
  212. if (scope.enableClear !== undefined && scope.enableClear === true) {
  213. // avatarRemove.on('click', () => {
  214. // setImage(null);
  215. // });
  216. } else {
  217. // remove element if clear disabled
  218. // avatarRemove.remove();
  219. }
  220. },
  221. template: `
  222. <div class="avatar-editor">
  223. <div class="avatar-editor-drag croppie-container"></div>
  224. <div class="avatar-editor-navigation" layout="column" layout-wrap layout-margin layout-align="center center">
  225. <input class="file-input" type="file" style="visibility: hidden" multiple/>
  226. <md-button type="submit" class="file-trigger md-raised">
  227. <span translate>messenger.UPLOAD_AVATAR</span>
  228. </md-button>
  229. </div>
  230. </div>
  231. `,
  232. };
  233. },
  234. ];