compose_area.ts 38 KB

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