compose_area.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  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. /**
  18. * The compose area where messages are written.
  19. */
  20. export default [
  21. 'BrowserService',
  22. 'StringService',
  23. '$window',
  24. '$timeout',
  25. '$translate',
  26. '$mdDialog',
  27. '$filter',
  28. '$sanitize',
  29. '$log',
  30. function(browserService: threema.BrowserService,
  31. stringService: threema.StringService,
  32. $window, $timeout: ng.ITimeoutService,
  33. $translate: ng.translate.ITranslateService,
  34. $mdDialog: ng.material.IDialogService,
  35. $filter: ng.IFilterService,
  36. $sanitize: ng.sanitize.ISanitizeService,
  37. $log: ng.ILogService) {
  38. return {
  39. restrict: 'EA',
  40. scope: {
  41. submit: '=',
  42. startTyping: '=',
  43. stopTyping: '=',
  44. onTyping: '=',
  45. draft: '=',
  46. onUploading: '=',
  47. maxTextLength: '=',
  48. },
  49. link(scope: any, element) {
  50. // Logging
  51. const logTag = '[Directives.ComposeArea]';
  52. // Constants
  53. const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
  54. const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
  55. // Elements
  56. const composeArea: any = element;
  57. const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
  58. const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
  59. const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
  60. const sendTrigger: any = angular.element(element[0].querySelector('i.send-trigger'));
  61. const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
  62. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  63. // Restore drafts
  64. if (scope.draft !== undefined) {
  65. composeDiv[0].innerText = scope.draft;
  66. }
  67. let caretPosition: {from?: number, to?: number} = null;
  68. /**
  69. * Stop propagation of click events and hold htmlElement of the emojipicker
  70. */
  71. let EmoijPickerContainer = (function(){
  72. let instance;
  73. function click(e) {
  74. e.stopPropagation();
  75. }
  76. return {
  77. get: function() {
  78. if (instance === undefined) {
  79. instance = {
  80. htmlElement: composeArea[0].querySelector('div.emojione-picker'),
  81. };
  82. // append stop propagation
  83. angular.element(instance.htmlElement).on('click', click);
  84. }
  85. return instance;
  86. },
  87. destroy: function() {
  88. if (instance !== undefined) {
  89. // remove stop propagation
  90. angular.element(instance.htmlElement).off('click', click);
  91. instance = undefined;
  92. }
  93. },
  94. };
  95. })();
  96. // Submit the text from the compose area.
  97. //
  98. // Emoji images are converted to their alt text in this process.
  99. function submitText(): Promise<any> {
  100. let text = '';
  101. for (let node of composeDiv[0].childNodes) {
  102. switch (node.nodeType) {
  103. case Node.TEXT_NODE:
  104. text += node.nodeValue;
  105. break;
  106. case Node.ELEMENT_NODE:
  107. const tag = node.tagName.toLowerCase();
  108. if (tag === 'img') {
  109. text += node.alt;
  110. break;
  111. } else if (tag === 'br') {
  112. text += '\n';
  113. break;
  114. }
  115. default:
  116. $log.warn(logTag, 'Unhandled node:', node);
  117. }
  118. }
  119. return new Promise((resolve, reject) => {
  120. let submitTexts = (strings: string[]) => {
  121. let messages: threema.TextMessageData[] = [];
  122. for (let piece of strings) {
  123. messages.push({
  124. text: piece,
  125. });
  126. }
  127. scope.submit('text', messages)
  128. .then(resolve)
  129. .catch(reject);
  130. };
  131. let fullText = text.trim();
  132. if (fullText.length > scope.maxTextLength) {
  133. let pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
  134. let confirm = $mdDialog.confirm()
  135. .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
  136. .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
  137. max: scope.maxTextLength,
  138. count: pieces.length,
  139. }))
  140. .ok($translate.instant('common.YES'))
  141. .cancel($translate.instant('common.NO'));
  142. $mdDialog.show(confirm).then(function () {
  143. submitTexts(pieces);
  144. }, () => {
  145. reject();
  146. });
  147. } else {
  148. submitTexts([fullText]);
  149. }
  150. });
  151. }
  152. function sendText(): boolean {
  153. if (composeDiv[0].innerHTML.length > 0) {
  154. submitText().then(() => {
  155. // Clear compose div
  156. composeDiv[0].innerText = '';
  157. // Send stopTyping event
  158. scope.stopTyping();
  159. scope.onTyping('');
  160. updateView();
  161. }).catch(() => {
  162. // do nothing
  163. this.$log.warn('failed to submit text');
  164. });
  165. return true;
  166. }
  167. return false;
  168. }
  169. // Handle typing events
  170. function onTyping(ev: KeyboardEvent): void {
  171. // If enter is pressed, prevent default event from being dispatched
  172. if (!ev.shiftKey && ev.which === 13) {
  173. ev.preventDefault();
  174. }
  175. // At link time, the element is not yet evaluated.
  176. // Therefore add following code to end of event loop.
  177. $timeout(() => {
  178. // Shift + enter to insert a newline. Enter to send.
  179. if (!ev.shiftKey && ev.which === 13) {
  180. if (sendText()) {
  181. return;
  182. }
  183. }
  184. // Update typing information
  185. if (composeDiv[0].innerText.length === 0) {
  186. scope.stopTyping();
  187. } else {
  188. scope.startTyping(composeDiv[0].innerText);
  189. }
  190. // Notify about typing event
  191. scope.onTyping(composeDiv[0].innerText);
  192. updateView();
  193. }, 0);
  194. }
  195. // Function to fetch file contents
  196. // Resolve to ArrayBuffer or reject to ErrorEvent.
  197. function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
  198. return new Promise((resolve) => {
  199. let buffers = new Map<File, ArrayBuffer>();
  200. let next = (file: File, res: ArrayBuffer | null, error: any) => {
  201. buffers.set(file, res);
  202. if (buffers.size >= fileList.length) {
  203. resolve(buffers);
  204. }
  205. };
  206. for (let n = 0; n < fileList.length; n++) {
  207. const reader = new FileReader();
  208. const file = fileList.item(n);
  209. reader.onload = (ev: Event) => {
  210. next(file, (ev.target as FileReader).result, ev);
  211. };
  212. reader.onerror = (ev: ErrorEvent) => {
  213. // set a null object
  214. next(file, null, ev);
  215. };
  216. reader.onprogress = function(data) {
  217. if (data.lengthComputable) {
  218. let progress = ((data.loaded / data.total) * 100);
  219. scope.onUploading(true, progress, 100 / fileList.length * n);
  220. }
  221. };
  222. reader.readAsArrayBuffer(file);
  223. }
  224. });
  225. }
  226. function uploadFiles(fileList: FileList): void {
  227. scope.onUploading(true, 0, 0);
  228. fetchFileListContents(fileList).then((data: Map<File, ArrayBuffer>) => {
  229. let fileMessages = [];
  230. data.forEach((buffer, file) => {
  231. const fileMessageData: threema.FileMessageData = {
  232. name: file.name,
  233. fileType: file.type,
  234. size: file.size,
  235. data: buffer,
  236. };
  237. fileMessages.push(fileMessageData);
  238. });
  239. scope.submit('file', fileMessages);
  240. scope.onUploading(false);
  241. }).catch((ev: ErrorEvent) => {
  242. $log.error(logTag, 'Could not load file:', ev.message);
  243. });
  244. }
  245. function applyFilters(text: string): string {
  246. const emojify = $filter('emojify') as (a: string, b?: boolean) => string;
  247. const parseNewLine = $filter('nlToBr') as (a: string, b?: boolean) => string;
  248. return parseNewLine(emojify(text, true), true);
  249. }
  250. // Handle pasting
  251. function onPaste(ev: ClipboardEvent) {
  252. ev.preventDefault();
  253. const text = ev.clipboardData.getData('text/plain');
  254. // Apply filters (emojify, convert newline, etc)
  255. const formatted = applyFilters(text);
  256. // Replace HTML formatting with ASCII counterparts
  257. const htmlToAsciiMarkup = $filter('htmlToAsciiMarkup') as (a: string) => string;
  258. // Sanitize
  259. const sanitized = $sanitize(htmlToAsciiMarkup(formatted));
  260. // Insert HTML
  261. document.execCommand('insertHTML', false, sanitized);
  262. cleanupComposeContent();
  263. updateView();
  264. }
  265. // Translate placeholder texts
  266. let regularPlaceholder = '';
  267. let dragoverPlaceholder = '';
  268. $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
  269. $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
  270. // Show emoji picker element
  271. function showEmojiPicker() {
  272. const emojiPicker: HTMLElement = EmoijPickerContainer.get().htmlElement;
  273. // Show
  274. emojiKeyboard.addClass('active');
  275. emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
  276. // Find all emoji
  277. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .e1'));
  278. // Add event handlers
  279. allEmoji.on('click', onEmojiChosen);
  280. // set focus to fix chat scroll bug
  281. $timeout(() => {
  282. composeDiv[0].focus();
  283. });
  284. }
  285. // Hide emoji picker element
  286. function hideEmojiPicker() {
  287. // Hide
  288. emojiKeyboard.removeClass('active');
  289. emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
  290. // Find all emoji
  291. const allEmoji: any = angular.element(
  292. EmoijPickerContainer.get().htmlElement.querySelectorAll('.content .e1'));
  293. // Remove event handlers
  294. allEmoji.off('click', onEmojiChosen);
  295. EmoijPickerContainer.destroy();
  296. }
  297. // Emoji trigger is clicked
  298. function onEmojiTrigger(ev: MouseEvent): void {
  299. ev.stopPropagation();
  300. // Toggle visibility of picker
  301. if (emojiKeyboard.hasClass('active')) {
  302. hideEmojiPicker();
  303. } else {
  304. showEmojiPicker();
  305. }
  306. }
  307. // Emoji is chosen
  308. function onEmojiChosen(ev: MouseEvent): void {
  309. ev.stopPropagation();
  310. const emoji = this.textContent; // Unicode character
  311. const formatted = applyFilters(emoji);
  312. // Firefox inserts a <br> after editing content editable fields.
  313. // Remove the last <br> to fix this.
  314. let currentHTML = '';
  315. composeDiv[0].childNodes.forEach((node: Node|any, pos: number) => {
  316. if (node.nodeType === node.TEXT_NODE) {
  317. currentHTML += node.textContent;
  318. } else if (node.nodeType === node.ELEMENT_NODE) {
  319. let tag = node.tagName.toLowerCase();
  320. if (tag === 'img') {
  321. currentHTML += getOuterHtml(node);
  322. } else if (tag === 'br') {
  323. // not not append br if the br is the LAST element
  324. if (pos < composeDiv[0].childNodes.length - 1) {
  325. currentHTML += getOuterHtml(node);
  326. }
  327. }
  328. }
  329. });
  330. if (caretPosition !== null) {
  331. currentHTML = currentHTML.substr(0, caretPosition.from)
  332. + formatted
  333. + currentHTML.substr(caretPosition.to);
  334. // change caret position
  335. caretPosition.from += formatted.length - 1;
  336. caretPosition.to = caretPosition.from;
  337. } else {
  338. // insert at the end of line
  339. currentHTML += formatted;
  340. caretPosition = {
  341. from: currentHTML.length,
  342. to: currentHTML.length,
  343. };
  344. }
  345. composeDiv[0].innerHTML = currentHTML;
  346. cleanupComposeContent();
  347. setCaretPosition(caretPosition.from);
  348. updateView();
  349. }
  350. // File trigger is clicked
  351. function onFileTrigger(ev: MouseEvent): void {
  352. ev.preventDefault();
  353. ev.stopPropagation();
  354. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  355. input.click();
  356. }
  357. function onSendTrigger(ev: MouseEvent): boolean {
  358. ev.preventDefault();
  359. ev.stopPropagation();
  360. return sendText();
  361. }
  362. // File(s) are uploaded via input field
  363. function onFileSelected() {
  364. uploadFiles(this.files);
  365. fileInput.val('');
  366. }
  367. // Disable content editable and dragging for contained images (emoji)
  368. function cleanupComposeContent() {
  369. for (let img of composeDiv[0].getElementsByTagName('img')) {
  370. img.ondragstart = () => false;
  371. }
  372. if (browserService.getBrowser().firefox) {
  373. // disable object resizing is the only way to disable resizing of
  374. // emoji (contenteditable must be true, otherwise the emoji can not
  375. // be removed with backspace (in FF))
  376. document.execCommand('enableObjectResizing', false, false);
  377. }
  378. }
  379. // Set all correct styles
  380. function updateView() {
  381. if (composeDiv[0].innerHTML.length === 0) {
  382. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  383. } else {
  384. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  385. }
  386. }
  387. // return the outer html of a node element
  388. function getOuterHtml(node: Node): string {
  389. let pseudoElement = document.createElement('pseudo');
  390. pseudoElement.appendChild(node.cloneNode());
  391. return pseudoElement.innerHTML;
  392. }
  393. // return the html code position of the container element
  394. function getHTMLPosition(offset: number, container: Node) {
  395. let pos = null;
  396. if (composeDiv[0].contains(container)) {
  397. let selectedElement;
  398. if (container === composeDiv[0]) {
  399. if (offset === 0) {
  400. return 0;
  401. }
  402. selectedElement = composeDiv[0].childNodes[offset - 1];
  403. pos = 0;
  404. } else {
  405. selectedElement = container.previousSibling;
  406. pos = offset;
  407. }
  408. while (selectedElement !== null) {
  409. if (selectedElement.nodeType === Node.TEXT_NODE) {
  410. pos += selectedElement.textContent.length;
  411. } else {
  412. pos += getOuterHtml(selectedElement).length;
  413. }
  414. selectedElement = selectedElement.previousSibling;
  415. }
  416. }
  417. return pos;
  418. }
  419. // define position of caret
  420. function updateCaretPosition() {
  421. caretPosition = null;
  422. if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
  423. const selection = window.getSelection();
  424. if (selection.rangeCount) {
  425. const range = selection.getRangeAt(0);
  426. let from = getHTMLPosition(range.startOffset, range.startContainer);
  427. if (from !== null && from >= 0) {
  428. caretPosition = {
  429. from: from,
  430. to: getHTMLPosition(range.endOffset, range.endContainer),
  431. };
  432. }
  433. }
  434. }
  435. }
  436. // set the correct cart position in the content editable div, position
  437. // is the position in the html content (not plain text)
  438. function setCaretPosition(pos: number) {
  439. let rangeAt = (node: Node, offset?: number) => {
  440. let range = document.createRange();
  441. range.collapse(false);
  442. if (offset !== undefined) {
  443. range.setStart(node, offset);
  444. } else {
  445. range.setStartAfter(node);
  446. }
  447. let sel = window.getSelection();
  448. sel.removeAllRanges();
  449. sel.addRange(range);
  450. };
  451. for (let nodeIndex = 0; nodeIndex < composeDiv[0].childNodes.length; nodeIndex++) {
  452. let node = composeDiv[0].childNodes[nodeIndex];
  453. let size;
  454. let offset;
  455. switch (node.nodeType) {
  456. case Node.TEXT_NODE:
  457. size = node.textContent.length;
  458. offset = pos;
  459. break;
  460. case Node.ELEMENT_NODE:
  461. size = getOuterHtml(node).length ;
  462. break;
  463. default:
  464. $log.warn(logTag, 'Unhandled node:', node);
  465. }
  466. if (pos < size) {
  467. // use this node
  468. rangeAt(node, offset);
  469. this.stop = true;
  470. } else if (nodeIndex === composeDiv[0].childNodes.length - 1) {
  471. rangeAt(node);
  472. }
  473. pos -= size;
  474. }
  475. }
  476. // Handle typing events
  477. composeDiv.on('keydown', onTyping);
  478. composeDiv.on('keyup mouseup', updateCaretPosition);
  479. composeDiv.on('selectionchange', updateCaretPosition);
  480. // When switching chat, send stopTyping message
  481. scope.$on('$destroy', scope.stopTyping);
  482. // Handle paste event
  483. composeDiv.on('paste', onPaste);
  484. // Handle click on emoji trigger
  485. emojiTrigger.on('click', onEmojiTrigger);
  486. // Handle click on file trigger
  487. fileTrigger.on('click', onFileTrigger);
  488. // Handle file uploads
  489. fileInput.on('change', onFileSelected);
  490. // Handle click on send trigger
  491. sendTrigger.on('click', onSendTrigger);
  492. updateView();
  493. },
  494. // tslint:disable:max-line-length
  495. template: `
  496. <div>
  497. <div>
  498. <i class="md-primary emoji-trigger trigger is-enabled material-icons">tag_faces</i>
  499. </div>
  500. <div>
  501. <div class="compose" contenteditable translate translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE" autofocus></div>
  502. </div>
  503. <div>
  504. <i class="md-primary send-trigger trigger material-icons">send</i>
  505. <i class="md-primary file-trigger trigger is-enabled material-icons">attach_file</i>
  506. <input class="file-input" type="file" style="visibility: hidden" multiple>
  507. </div>
  508. </div>
  509. <div class="emoji-keyboard">
  510. <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
  511. </div>
  512. `,
  513. };
  514. },
  515. ];