compose_area.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  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. import {isActionTrigger} from '../helpers';
  18. import {BrowserService} from '../services/browser';
  19. import {StringService} from '../services/string';
  20. import {TimeoutService} from '../services/timeout';
  21. /**
  22. * The compose area where messages are written.
  23. */
  24. export default [
  25. 'BrowserService',
  26. 'StringService',
  27. 'TimeoutService',
  28. '$timeout',
  29. '$translate',
  30. '$mdDialog',
  31. '$filter',
  32. '$log',
  33. '$rootScope',
  34. function(browserService: BrowserService,
  35. stringService: StringService,
  36. timeoutService: TimeoutService,
  37. $timeout: ng.ITimeoutService,
  38. $translate: ng.translate.ITranslateService,
  39. $mdDialog: ng.material.IDialogService,
  40. $filter: ng.IFilterService,
  41. $log: ng.ILogService,
  42. $rootScope: ng.IRootScopeService) {
  43. return {
  44. restrict: 'EA',
  45. scope: {
  46. // Callback to submit text or file data
  47. submit: '=',
  48. // Callbacks to update typing information
  49. startTyping: '=',
  50. stopTyping: '=',
  51. onTyping: '=',
  52. onKeyDown: '=',
  53. // Reference to initial text and draft
  54. initialData: '=',
  55. // Callback that is called when uploading files
  56. onUploading: '=',
  57. maxTextLength: '=',
  58. },
  59. link(scope: any, element) {
  60. // Logging
  61. const logTag = '[Directives.ComposeArea]';
  62. // Constants
  63. const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
  64. const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
  65. // Elements
  66. const composeArea: any = element;
  67. const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
  68. const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
  69. const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
  70. const sendTrigger: any = angular.element(element[0].querySelector('i.send-trigger'));
  71. const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
  72. const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
  73. // Set initial text
  74. if (scope.initialData.initialText) {
  75. composeDiv[0].innerText = scope.initialData.initialText;
  76. scope.initialData.initialText = '';
  77. } else if (scope.initialData.draft !== undefined) {
  78. composeDiv[0].innerText = scope.initialData.draft;
  79. }
  80. let caretPosition: {
  81. from?: number,
  82. to?: number,
  83. fromBytes?: number,
  84. toBytes?: number,
  85. } = null;
  86. /**
  87. * Stop propagation of click events and hold htmlElement of the emojipicker
  88. */
  89. const EmojiPickerContainer = (function() {
  90. let instance;
  91. function click(e) {
  92. e.stopPropagation();
  93. }
  94. return {
  95. get: function() {
  96. if (instance === undefined) {
  97. instance = {
  98. htmlElement: composeArea[0].querySelector('div.emojione-picker'),
  99. };
  100. // append stop propagation
  101. angular.element(instance.htmlElement).on('click', click);
  102. }
  103. return instance;
  104. },
  105. destroy: function() {
  106. if (instance !== undefined) {
  107. // remove stop propagation
  108. angular.element(instance.htmlElement).off('click', click);
  109. instance = undefined;
  110. }
  111. },
  112. };
  113. })();
  114. // Typing events
  115. let stopTypingTimer: ng.IPromise<void> = null;
  116. function stopTyping() {
  117. // We can only stop typing of the timer is set (meaning
  118. // that we started typing earlier)
  119. if (stopTypingTimer !== null) {
  120. // Cancel timer
  121. timeoutService.cancel(stopTypingTimer);
  122. stopTypingTimer = null;
  123. // Send stop typing message
  124. scope.stopTyping();
  125. }
  126. }
  127. function startTyping() {
  128. if (stopTypingTimer === null) {
  129. // If the timer wasn't set previously, we just
  130. // started typing!
  131. scope.startTyping();
  132. } else {
  133. // Cancel timer, we'll re-create it
  134. timeoutService.cancel(stopTypingTimer);
  135. }
  136. // Define a timeout to send the stopTyping event
  137. stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
  138. }
  139. // Process a DOM node recursively and extract text from compose area.
  140. function getText(trim = true) {
  141. let text = '';
  142. const visitChildNodes = (parentNode: HTMLElement) => {
  143. // When pressing shift-enter and typing more text:
  144. //
  145. // - Firefox and chrome insert a <br> between two text nodes
  146. // - Safari creates two <div>s without any line break in between
  147. // (except for the first line, which stays plain text)
  148. //
  149. // Thus, for Safari, we need to detect <div>s and insert a newline.
  150. // tslint:disable-next-line: prefer-for-of (see #98)
  151. for (let i = 0; i < parentNode.childNodes.length; i++) {
  152. const node = parentNode.childNodes[i] as HTMLElement;
  153. switch (node.nodeType) {
  154. case Node.TEXT_NODE:
  155. // Append text, but strip leading and trailing newlines
  156. text += node.nodeValue.replace(/(^[\r\n]*|[\r\n]*$)/g, '');
  157. break;
  158. case Node.ELEMENT_NODE:
  159. const tag = node.tagName.toLowerCase();
  160. if (tag === 'div') {
  161. text += '\n';
  162. visitChildNodes(node);
  163. break;
  164. } else if (tag === 'img') {
  165. text += (node as HTMLImageElement).alt;
  166. break;
  167. } else if (tag === 'br') {
  168. text += '\n';
  169. break;
  170. } else if (tag === 'span' && node.hasAttribute('text')) {
  171. text += node.getAttributeNode('text').value;
  172. break;
  173. }
  174. default:
  175. $log.warn(logTag, 'Unhandled node:', node);
  176. }
  177. }
  178. };
  179. visitChildNodes(composeDiv[0]);
  180. return trim ? text.trim() : text;
  181. }
  182. // Determine whether field is empty
  183. function composeAreaIsEmpty() {
  184. return getText().length === 0;
  185. }
  186. // Submit the text from the compose area.
  187. //
  188. // Emoji images are converted to their alt text in this process.
  189. function submitText(): Promise<any> {
  190. const text = getText();
  191. return new Promise((resolve, reject) => {
  192. const submitTexts = (strings: string[]) => {
  193. const messages: threema.TextMessageData[] = [];
  194. for (const piece of strings) {
  195. messages.push({
  196. text: piece,
  197. });
  198. }
  199. scope.submit('text', messages)
  200. .then(resolve)
  201. .catch(reject);
  202. };
  203. const fullText = text.trim().replace(/\r/g, '');
  204. if (fullText.length > scope.maxTextLength) {
  205. const pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
  206. const confirm = $mdDialog.confirm()
  207. .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
  208. .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
  209. max: scope.maxTextLength,
  210. count: pieces.length,
  211. }))
  212. .ok($translate.instant('common.YES'))
  213. .cancel($translate.instant('common.NO'));
  214. $mdDialog.show(confirm).then(function() {
  215. submitTexts(pieces);
  216. }, () => {
  217. reject();
  218. });
  219. } else {
  220. submitTexts([fullText]);
  221. }
  222. });
  223. }
  224. function sendText(): boolean {
  225. if (!composeAreaIsEmpty()) {
  226. submitText().then(() => {
  227. // Clear compose div
  228. composeDiv[0].innerText = '';
  229. composeDiv[0].focus();
  230. // Send stopTyping event
  231. stopTyping();
  232. // Clear draft
  233. scope.onTyping('');
  234. updateView();
  235. }).catch(() => {
  236. // do nothing
  237. $log.warn(logTag, 'Failed to submit text');
  238. });
  239. return true;
  240. }
  241. return false;
  242. }
  243. // Handle typing events
  244. function onKeyDown(ev: KeyboardEvent): void {
  245. // If enter is pressed, prevent default event from being dispatched
  246. if (!ev.shiftKey && ev.key === 'Enter') {
  247. ev.preventDefault();
  248. }
  249. // If the keydown is handled and aborted outside
  250. if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
  251. ev.preventDefault();
  252. return;
  253. }
  254. // At link time, the element is not yet evaluated.
  255. // Therefore add following code to end of event loop.
  256. $timeout(() => {
  257. // Shift + enter to insert a newline. Enter to send.
  258. if (!ev.shiftKey && ev.key === 'Enter') {
  259. if (sendText()) {
  260. return;
  261. }
  262. }
  263. updateView();
  264. }, 0);
  265. }
  266. function onKeyUp(ev: KeyboardEvent): void {
  267. // At link time, the element is not yet evaluated.
  268. // Therefore add following code to end of event loop.
  269. $timeout(() => {
  270. // If the compose area contains only a single <br>, make it fully empty.
  271. // See also: https://stackoverflow.com/q/14638887/284318
  272. const text = getText(false);
  273. if (text === '\n') {
  274. composeDiv[0].innerText = '';
  275. } else if (ev.keyCode === 190 && caretPosition !== null) {
  276. // A ':' is pressed, try to parse
  277. const currentWord = stringService.getWord(text, caretPosition.fromBytes, [':']);
  278. if (currentWord.realLength > 2
  279. && currentWord.word.substr(0, 1) === ':') {
  280. const unicodeEmoji = emojione.shortnameToUnicode(currentWord.word);
  281. if (unicodeEmoji && unicodeEmoji !== currentWord.word) {
  282. return insertEmoji(unicodeEmoji,
  283. caretPosition.from - currentWord.realLength,
  284. caretPosition.to);
  285. }
  286. }
  287. }
  288. // Update typing information (use text instead method)
  289. if (text.trim().length === 0 || caretPosition === null) {
  290. stopTyping();
  291. scope.onTyping('');
  292. } else {
  293. startTyping();
  294. scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
  295. }
  296. updateView();
  297. }, 0);
  298. }
  299. // Function to fetch file contents
  300. // Resolve to ArrayBuffer or reject to ErrorEvent.
  301. function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
  302. return new Promise((resolve) => {
  303. const buffers = new Map<File, ArrayBuffer>();
  304. const fileCounter = fileList.length;
  305. const next = (file: File, res: ArrayBuffer | null, error: any) => {
  306. buffers.set(file, res);
  307. if (buffers.size >= fileCounter) {
  308. resolve(buffers);
  309. }
  310. };
  311. for (let n = 0; n < fileCounter; n++) {
  312. const reader = new FileReader();
  313. const file = fileList.item(n);
  314. reader.onload = function(ev: FileReaderProgressEvent) {
  315. next(file, ev.target.result, ev);
  316. };
  317. reader.onerror = function(ev: FileReaderProgressEvent) {
  318. // set a null object
  319. next(file, null, ev);
  320. };
  321. reader.onprogress = function(ev: FileReaderProgressEvent) {
  322. if (ev.lengthComputable) {
  323. const progress = ((ev.loaded / ev.total) * 100);
  324. scope.onUploading(true, progress, 100 / fileCounter * n);
  325. }
  326. };
  327. reader.readAsArrayBuffer(file);
  328. }
  329. });
  330. }
  331. function uploadFiles(fileList: FileList): void {
  332. scope.onUploading(true, 0, 0);
  333. fetchFileListContents(fileList).then((data: Map<File, ArrayBuffer>) => {
  334. const fileMessages = [];
  335. data.forEach((buffer, file) => {
  336. const fileMessageData: threema.FileMessageData = {
  337. name: file.name,
  338. fileType: file.type,
  339. size: file.size,
  340. data: buffer,
  341. };
  342. // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1240259
  343. if (browserService.getBrowser().isFirefox(false)) {
  344. if (fileMessageData.name.endsWith('.ogg') && fileMessageData.fileType === 'video/ogg') {
  345. fileMessageData.fileType = 'audio/ogg';
  346. }
  347. }
  348. fileMessages.push(fileMessageData);
  349. });
  350. scope
  351. .submit('file', fileMessages)
  352. .catch((msg) => $log.error('Could not send file:', msg));
  353. scope.onUploading(false);
  354. }).catch((ev: ErrorEvent) => {
  355. $log.error(logTag, 'Could not load file:', ev.message);
  356. });
  357. }
  358. // Handle pasting
  359. function onPaste(ev: ClipboardEvent) {
  360. ev.preventDefault();
  361. // If no clipboard data is available, do nothing.
  362. if (!ev.clipboardData) {
  363. return;
  364. }
  365. // Extract pasted items
  366. const items: DataTransferItemList = ev.clipboardData.items;
  367. if (!items) {
  368. return;
  369. }
  370. // Find available types
  371. let fileIdx: number | null = null;
  372. let textIdx: number | null = null;
  373. for (let i = 0; i < items.length; i++) {
  374. if (items[i].type.indexOf('image/') !== -1 || items[i].type === 'application/x-moz-file') {
  375. fileIdx = i;
  376. } else if (items[i].type === 'text/plain') {
  377. textIdx = i;
  378. }
  379. }
  380. // Handle pasting of files
  381. if (fileIdx !== null) {
  382. // Read clipboard data as blob
  383. const blob: Blob = items[fileIdx].getAsFile();
  384. // Convert blob to arraybuffer
  385. const reader = new FileReader();
  386. reader.onload = function(progressEvent: FileReaderProgressEvent) {
  387. const buffer: ArrayBuffer = this.result;
  388. // Construct file name
  389. let fileName: string;
  390. if ((blob as any).name) {
  391. fileName = (blob as any).name;
  392. } else if (blob.type && blob.type.match(/^[^;]*\//) !== null) {
  393. const fileExt = blob.type.split(';')[0].split('/')[1];
  394. fileName = 'clipboard.' + fileExt;
  395. } else {
  396. $log.warn(logTag, 'Pasted file has an invalid MIME type: "' + blob.type + '"');
  397. return;
  398. }
  399. // Send data as file
  400. const fileMessageData: threema.FileMessageData = {
  401. name: fileName,
  402. fileType: blob.type,
  403. size: blob.size,
  404. data: buffer,
  405. };
  406. scope
  407. .submit('file', [fileMessageData])
  408. .catch((msg) => $log.error('Could not send file:', msg));
  409. };
  410. reader.readAsArrayBuffer(blob);
  411. // Handle pasting of text
  412. } else if (textIdx !== null) {
  413. const text = ev.clipboardData.getData('text/plain');
  414. // Look up some filter functions
  415. const escapeHtml = $filter('escapeHtml') as (a: string) => string;
  416. const emojify = $filter('emojify') as (a: string, b?: boolean) => string;
  417. const mentionify = $filter('mentionify') as (a: string) => string;
  418. const nlToBr = $filter('nlToBr') as (a: string, b?: boolean) => string;
  419. // Escape HTML markup
  420. const escaped = escapeHtml(text);
  421. // Apply filters (emojify, convert newline, etc)
  422. const formatted = nlToBr(mentionify(emojify(escaped, true)), true);
  423. // Insert resulting HTML
  424. document.execCommand('insertHTML', false, formatted);
  425. cleanupComposeContent();
  426. updateView();
  427. }
  428. }
  429. // Translate placeholder texts
  430. let regularPlaceholder = '';
  431. let dragoverPlaceholder = '';
  432. $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
  433. $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
  434. // Show emoji picker element
  435. function showEmojiPicker() {
  436. const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
  437. // Show
  438. emojiKeyboard.addClass('active');
  439. emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
  440. // Find all emoji
  441. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .e1'));
  442. // Add event handlers
  443. allEmoji.on('click', onEmojiChosen);
  444. // set focus to fix chat scroll bug
  445. $timeout(() => {
  446. composeDiv[0].focus();
  447. });
  448. }
  449. // Hide emoji picker element
  450. function hideEmojiPicker() {
  451. // Hide
  452. emojiKeyboard.removeClass('active');
  453. emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
  454. // Find all emoji
  455. const allEmoji: any = angular.element(
  456. EmojiPickerContainer.get().htmlElement.querySelectorAll('.content .e1'));
  457. // Remove event handlers
  458. allEmoji.off('click', onEmojiChosen);
  459. EmojiPickerContainer.destroy();
  460. }
  461. // Emoji trigger is clicked
  462. function onEmojiTrigger(ev: UIEvent): void {
  463. ev.stopPropagation();
  464. // Toggle visibility of picker
  465. if (emojiKeyboard.hasClass('active')) {
  466. hideEmojiPicker();
  467. } else {
  468. showEmojiPicker();
  469. }
  470. }
  471. // Emoji is chosen
  472. function onEmojiChosen(ev: MouseEvent): void {
  473. ev.stopPropagation();
  474. insertEmoji (this.textContent);
  475. }
  476. function insertEmoji(emoji, posFrom = null, posTo = null): void {
  477. const emojiElement = ($filter('emojify') as any)(emoji, true, true) as string;
  478. insertHTMLElement(emoji, emojiElement, posFrom, posTo);
  479. }
  480. function insertMention(mentionString, posFrom = null, posTo = null): void {
  481. const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
  482. insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
  483. }
  484. function insertHTMLElement(original: string, formatted: string, posFrom = null, posTo = null): void {
  485. // In Chrome in right-to-left mode, our content editable
  486. // area may contain a DIV element.
  487. const childNodes = composeDiv[0].childNodes;
  488. const nestedDiv = childNodes.length === 1
  489. && childNodes[0].tagName !== undefined
  490. && childNodes[0].tagName.toLowerCase() === 'div';
  491. let contentElement;
  492. if (nestedDiv === true) {
  493. contentElement = composeDiv[0].childNodes[0];
  494. } else {
  495. contentElement = composeDiv[0];
  496. }
  497. let currentHTML = '';
  498. for (let i = 0; i < contentElement.childNodes.length; i++) {
  499. const node = contentElement.childNodes[i];
  500. if (node.nodeType === node.TEXT_NODE) {
  501. currentHTML += node.textContent;
  502. } else if (node.nodeType === node.ELEMENT_NODE) {
  503. const tag = node.tagName.toLowerCase();
  504. if (tag === 'img' || tag === 'span') {
  505. currentHTML += getOuterHtml(node);
  506. } else if (tag === 'br') {
  507. // Firefox inserts a <br> after editing content editable fields.
  508. // Remove the last <br> to fix this.
  509. if (i < contentElement.childNodes.length - 1) {
  510. currentHTML += getOuterHtml(node);
  511. }
  512. }
  513. }
  514. }
  515. if (caretPosition !== null) {
  516. posFrom = null === posFrom ? caretPosition.from : posFrom;
  517. posTo = null === posTo ? caretPosition.to : posTo;
  518. currentHTML = currentHTML.substr(0, posFrom)
  519. + formatted
  520. + currentHTML.substr(posTo);
  521. // change caret position
  522. caretPosition.from += formatted.length;
  523. caretPosition.fromBytes += original.length;
  524. posFrom += formatted.length;
  525. } else {
  526. // insert at the end of line
  527. posFrom = currentHTML.length;
  528. currentHTML += formatted;
  529. caretPosition = {
  530. from: currentHTML.length,
  531. };
  532. }
  533. caretPosition.to = caretPosition.from;
  534. caretPosition.toBytes = caretPosition.fromBytes;
  535. contentElement.innerHTML = currentHTML;
  536. cleanupComposeContent();
  537. setCaretPosition(posFrom);
  538. // Update the draft text
  539. scope.onTyping(getText());
  540. updateView();
  541. }
  542. // File trigger is clicked
  543. function onFileTrigger(ev: UIEvent): void {
  544. ev.preventDefault();
  545. ev.stopPropagation();
  546. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  547. input.click();
  548. }
  549. function onSendTrigger(ev: UIEvent): boolean {
  550. ev.preventDefault();
  551. ev.stopPropagation();
  552. return sendText();
  553. }
  554. // File(s) are uploaded via input field
  555. function onFileSelected() {
  556. uploadFiles(this.files);
  557. fileInput.val('');
  558. }
  559. // Disable content editable and dragging for contained images (emoji)
  560. function cleanupComposeContent() {
  561. for (const img of composeDiv[0].getElementsByTagName('img')) {
  562. img.ondragstart = () => false;
  563. }
  564. for (const span of composeDiv[0].getElementsByTagName('span')) {
  565. span.setAttribute('contenteditable', false);
  566. }
  567. if (browserService.getBrowser().isFirefox(false)) {
  568. // disable object resizing is the only way to disable resizing of
  569. // emoji (contenteditable must be true, otherwise the emoji can not
  570. // be removed with backspace (in FF))
  571. document.execCommand('enableObjectResizing', false, false);
  572. }
  573. }
  574. // Set all correct styles
  575. function updateView() {
  576. if (composeAreaIsEmpty()) {
  577. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  578. } else {
  579. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  580. }
  581. }
  582. // return the outer html of a node element
  583. function getOuterHtml(node: Node): string {
  584. const pseudoElement = document.createElement('pseudo');
  585. pseudoElement.appendChild(node.cloneNode(true));
  586. return pseudoElement.innerHTML;
  587. }
  588. // return the html code position of the container element
  589. function getPositions(offset: number, container: Node): {html: number, text: number} {
  590. let pos = null;
  591. let textPos = null;
  592. if (composeDiv[0].contains(container)) {
  593. let selectedElement;
  594. if (container === composeDiv[0]) {
  595. if (offset === 0) {
  596. return {
  597. html: 0, text: 0,
  598. };
  599. }
  600. selectedElement = composeDiv[0].childNodes[offset - 1];
  601. pos = 0;
  602. textPos = 0;
  603. } else {
  604. selectedElement = container.previousSibling;
  605. pos = offset;
  606. textPos = offset;
  607. }
  608. while (selectedElement !== null) {
  609. if (selectedElement.nodeType === Node.TEXT_NODE) {
  610. pos += selectedElement.textContent.length;
  611. textPos += selectedElement.textContent.length;
  612. } else {
  613. pos += getOuterHtml(selectedElement).length;
  614. textPos += 1;
  615. }
  616. selectedElement = selectedElement.previousSibling;
  617. }
  618. }
  619. return {
  620. html: pos,
  621. text: textPos,
  622. };
  623. }
  624. // Update the current caret position or selection
  625. function updateCaretPosition() {
  626. caretPosition = null;
  627. if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
  628. const selection = window.getSelection();
  629. if (selection.rangeCount) {
  630. const range = selection.getRangeAt(0);
  631. const from = getPositions(range.startOffset, range.startContainer);
  632. if (from !== null && from.html >= 0) {
  633. const to = getPositions(range.endOffset, range.endContainer);
  634. caretPosition = {
  635. from: from.html,
  636. to: to.html,
  637. fromBytes: from.text,
  638. toBytes: to.text,
  639. };
  640. }
  641. }
  642. }
  643. }
  644. // set the correct cart position in the content editable div, position
  645. // is the position in the html content (not plain text)
  646. function setCaretPosition(pos: number) {
  647. const rangeAt = (node: Node, offset?: number) => {
  648. const range = document.createRange();
  649. range.collapse(false);
  650. if (offset !== undefined) {
  651. range.setStart(node, offset);
  652. } else {
  653. range.setStartAfter(node);
  654. }
  655. const sel = window.getSelection();
  656. sel.removeAllRanges();
  657. sel.addRange(range);
  658. };
  659. for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
  660. const node = composeDiv[0].childNodes[i];
  661. let size;
  662. let offset;
  663. switch (node.nodeType) {
  664. case Node.TEXT_NODE:
  665. size = node.textContent.length;
  666. offset = pos;
  667. break;
  668. case Node.ELEMENT_NODE:
  669. size = getOuterHtml(node).length ;
  670. break;
  671. default:
  672. $log.warn(logTag, 'Unhandled node:', node);
  673. }
  674. if (pos < size) {
  675. // use this node
  676. rangeAt(node, offset);
  677. } else if (i === composeDiv[0].childNodes.length - 1) {
  678. rangeAt(node);
  679. }
  680. pos -= size;
  681. }
  682. }
  683. // Handle typing events
  684. composeDiv.on('keydown', onKeyDown);
  685. composeDiv.on('keyup', onKeyUp);
  686. composeDiv.on('keyup mouseup', updateCaretPosition);
  687. composeDiv.on('selectionchange', updateCaretPosition);
  688. // Handle paste event
  689. composeDiv.on('paste', onPaste);
  690. // Handle click on emoji trigger
  691. emojiTrigger.on('click', onEmojiTrigger);
  692. emojiTrigger.on('keypress', (ev: KeyboardEvent) => {
  693. if (isActionTrigger(ev)) {
  694. onEmojiTrigger(ev);
  695. }
  696. });
  697. // Handle click on file trigger
  698. fileTrigger.on('click', onFileTrigger);
  699. fileTrigger.on('keypress', (ev: KeyboardEvent) => {
  700. if (isActionTrigger(ev)) {
  701. onFileTrigger(ev);
  702. }
  703. });
  704. // Handle file uploads
  705. fileInput.on('change', onFileSelected);
  706. // Handle click on send trigger
  707. sendTrigger.on('click', onSendTrigger);
  708. sendTrigger.on('keypress', (ev: KeyboardEvent) => {
  709. if (isActionTrigger(ev)) {
  710. onSendTrigger(ev);
  711. }
  712. });
  713. updateView();
  714. // Listen to broadcasts
  715. const unsubscribeListeners = [];
  716. unsubscribeListeners.push($rootScope.$on('onQuoted', (event: ng.IAngularEvent, args: any) => {
  717. composeDiv[0].focus();
  718. }));
  719. unsubscribeListeners.push($rootScope.$on('onMentionSelected', (event: ng.IAngularEvent, args: any) => {
  720. if (args.query && args.mention) {
  721. // Insert resulting HTML
  722. insertMention(args.mention, caretPosition ? caretPosition.to - args.query.length : null,
  723. caretPosition ? caretPosition.to : null);
  724. }
  725. }));
  726. // When switching chat, send stopTyping message
  727. scope.$on('$destroy', () => {
  728. unsubscribeListeners.forEach((u) => {
  729. // Unsubscribe
  730. u();
  731. });
  732. stopTyping();
  733. });
  734. },
  735. // tslint:disable:max-line-length
  736. template: `
  737. <div>
  738. <div>
  739. <i class="md-primary emoji-trigger trigger is-enabled material-icons" role="button" aria-label="emoji" tabindex="0">tag_faces</i>
  740. </div>
  741. <div>
  742. <div
  743. class="compose"
  744. contenteditable
  745. autofocus
  746. translate
  747. translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE"
  748. translate-attr-aria-label="messenger.COMPOSE_MESSAGE"
  749. tabindex="0"
  750. ></div>
  751. </div>
  752. <div>
  753. <i class="md-primary send-trigger trigger material-icons" role="button" aria-label="send" tabindex="0">send</i>
  754. <i class="md-primary file-trigger trigger is-enabled material-icons" role="button" aria-label="attach file" tabindex="0">attach_file</i>
  755. <input class="file-input" type="file" style="visibility: hidden" multiple>
  756. </div>
  757. </div>
  758. <div class="emoji-keyboard">
  759. <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
  760. </div>
  761. `,
  762. };
  763. },
  764. ];