avatar_editor.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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:no-reference
  18. /// <reference path="../types/croppie.d.ts" />
  19. // tslint:disable:max-line-length
  20. import {bufferToUrl, logAdapter} from '../helpers';
  21. /**
  22. * Support uploading and resizing avatar
  23. */
  24. export default [
  25. '$log',
  26. function($log: ng.ILogService) {
  27. return {
  28. restrict: 'EA',
  29. scope: {
  30. onChange: '=',
  31. },
  32. link(scope: any, element, attrs, controller) {
  33. const logTag: string = '[AvatarEditorDirective]';
  34. // Constants
  35. const DRAGOVER_CSS_CLASS = 'is-dragover';
  36. const VIEWPORT_SIZE = 220;
  37. // Elements
  38. const editorArea: any = angular.element(element[0].querySelector('.avatar-editor'));
  39. const fileTrigger: any = angular.element(element[0].querySelector('.file-trigger'));
  40. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  41. let croppieInstance: Croppie = null;
  42. const initCroppie = (): Croppie => {
  43. if (croppieInstance === null) {
  44. // Create croppie
  45. const croppieTarget: HTMLElement = element[0].querySelector('.croppie-target');
  46. croppieInstance = new Croppie(croppieTarget, {
  47. viewport: {
  48. type: 'square',
  49. width: VIEWPORT_SIZE,
  50. height: VIEWPORT_SIZE,
  51. },
  52. customClass: 'has-image',
  53. showZoomer: true,
  54. update: (): void => {
  55. if (updateTimeout !== undefined) {
  56. clearTimeout(updateTimeout);
  57. }
  58. updateTimeout = self.setTimeout(async () => {
  59. const image: Blob = await croppieInstance.result({
  60. type: 'blob',
  61. // TODO: Should be retrieved from clientInfo once available
  62. size: { width: 512, height: 512 },
  63. circle: false,
  64. format: 'png',
  65. });
  66. const fileReader = new FileReader();
  67. fileReader.onload = function() {
  68. scope.onChange(fileReader.result);
  69. };
  70. fileReader.readAsArrayBuffer(image);
  71. }, 500);
  72. },
  73. });
  74. }
  75. return croppieInstance;
  76. };
  77. function loading(show: boolean): void {
  78. if (show) {
  79. editorArea.addClass('loading');
  80. } else {
  81. editorArea.removeClass('loading');
  82. }
  83. }
  84. // set after default avatar is set
  85. let updateTimeout;
  86. // Function to fetch file contents
  87. // Resolve to ArrayBuffer or reject to ErrorEvent.
  88. function fetchFileContent(file: File): Promise<ArrayBuffer> {
  89. return new Promise((resolve, reject) => {
  90. const reader = new FileReader();
  91. reader.onload = function(ev: ProgressEvent) {
  92. // The result will be an ArrayBuffer because we call
  93. // the `FileReader.readAsArrayBuffer` method.
  94. resolve(this.result as ArrayBuffer);
  95. };
  96. reader.onerror = function(ev: ProgressEvent) {
  97. // set a null object
  98. reject(ev);
  99. };
  100. reader.onprogress = function(ev: ProgressEvent) {
  101. if (ev.lengthComputable) {
  102. // TODO implement progress?
  103. // let progress = ((data.loaded / data.total) * 100);
  104. }
  105. };
  106. reader.readAsArrayBuffer(file);
  107. });
  108. }
  109. function uploadFiles(fileList: FileList): void {
  110. if (fileList.length < 1) {
  111. return;
  112. }
  113. // get first
  114. fetchFileContent(fileList[0]).then((data: ArrayBuffer) => {
  115. const image = bufferToUrl(data, 'image/jpeg', logAdapter($log.warn, logTag));
  116. setImage(image);
  117. }).catch((ev: ErrorEvent) => {
  118. $log.error(logTag, 'Could not load file:', ev.message);
  119. });
  120. }
  121. // Handle the drop effect
  122. function onDrop(ev: DragEvent): void {
  123. ev.stopPropagation();
  124. ev.preventDefault();
  125. // simulate a leave to reset styles
  126. onDragleave(ev);
  127. // Upload files
  128. uploadFiles(ev.dataTransfer.files);
  129. }
  130. // File is dragged over compose area
  131. function onDragover(ev: DragEvent): void {
  132. ev.stopPropagation();
  133. ev.preventDefault();
  134. editorArea.addClass(DRAGOVER_CSS_CLASS);
  135. }
  136. // File is dragged out of compose area
  137. function onDragleave(ev: DragEvent): void {
  138. ev.stopPropagation();
  139. ev.preventDefault();
  140. editorArea.removeClass(DRAGOVER_CSS_CLASS);
  141. }
  142. // File trigger is clicked
  143. function onFileTrigger(ev: MouseEvent): void {
  144. ev.preventDefault();
  145. ev.stopPropagation();
  146. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  147. input.click();
  148. }
  149. // File(s) are uploaded via input field
  150. function onFileUploaded() {
  151. uploadFiles(this.files);
  152. }
  153. function setImage(imageBase64Url: string) {
  154. const croppie = initCroppie();
  155. loading(true);
  156. // load image to calculate size
  157. const img = new Image();
  158. img.addEventListener('load', async () => {
  159. $log.debug(logTag, 'Image loaded');
  160. const w = img.naturalWidth;
  161. const h = img.naturalHeight;
  162. const size = Math.min(w, h);
  163. // set to center
  164. const imageSize: [number, number, number, number] = [
  165. (w - size) / 2,
  166. (h - size) / 2,
  167. size,
  168. size,
  169. ];
  170. try {
  171. await croppie.bind({
  172. url: imageBase64Url,
  173. points: imageSize,
  174. });
  175. } catch (error) {
  176. $log.error(logTag, 'Could not bind avatar preview:', error);
  177. }
  178. loading(false);
  179. });
  180. img.addEventListener('error', function(e) {
  181. // this is not a valid image
  182. $log.error(logTag, 'Could not load image:', e);
  183. loading(false);
  184. });
  185. img.src = imageBase64Url;
  186. }
  187. // Handle dragover / dragleave events
  188. editorArea.on('dragover dragenter', onDragover);
  189. editorArea.on('dragleave dragend', onDragleave);
  190. // Handle drop event
  191. editorArea.on('drop', onDrop);
  192. // Handle click on file trigger
  193. fileTrigger.on('click', onFileTrigger);
  194. // Handle file uploads
  195. fileInput.on('change', onFileUploaded);
  196. },
  197. template: `
  198. <div class="avatar-editor">
  199. <div class="avatar-editor-drag croppie-target"></div>
  200. <div class="avatar-editor-navigation" layout="column" layout-wrap layout-margin layout-align="center center">
  201. <input class="file-input" type="file" style="visibility: hidden" multiple>
  202. <md-button type="submit" class="file-trigger md-raised">
  203. <span translate>messenger.UPLOAD_AVATAR</span>
  204. </md-button>
  205. </div>
  206. </div>
  207. `,
  208. };
  209. },
  210. ];