avatar_editor.ts 11 KB

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