compose_area.ts 41 KB

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