compose_area.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  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 {extractText, isActionTrigger, logAdapter} from '../helpers';
  18. import {BrowserService} from '../services/browser';
  19. import {StringService} from '../services/string';
  20. import {TimeoutService} from '../services/timeout';
  21. import {isElementNode, isTextNode} from '../typeguards';
  22. /**
  23. * The compose area where messages are written.
  24. */
  25. export default [
  26. 'BrowserService',
  27. 'StringService',
  28. 'TimeoutService',
  29. '$timeout',
  30. '$translate',
  31. '$mdDialog',
  32. '$filter',
  33. '$log',
  34. '$rootScope',
  35. function(browserService: BrowserService,
  36. stringService: StringService,
  37. timeoutService: TimeoutService,
  38. $timeout: ng.ITimeoutService,
  39. $translate: ng.translate.ITranslateService,
  40. $mdDialog: ng.material.IDialogService,
  41. $filter: ng.IFilterService,
  42. $log: ng.ILogService,
  43. $rootScope: ng.IRootScopeService) {
  44. return {
  45. restrict: 'EA',
  46. scope: {
  47. // Callback to submit text or file data
  48. submit: '=',
  49. // Callbacks to update typing information
  50. startTyping: '=',
  51. stopTyping: '=',
  52. onTyping: '=',
  53. onKeyDown: '=',
  54. // Reference to initial text and draft
  55. initialData: '=',
  56. // Callback that is called when uploading files
  57. onUploading: '=',
  58. maxTextLength: '=',
  59. // Optional emoji PNG path prefix
  60. emojiImagePath: '@?',
  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.emojione-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 && caretPosition !== null) {
  240. // A ':' is pressed, try to parse
  241. const currentWord = stringService.getWord(text, caretPosition.fromChar, [':']);
  242. if (currentWord.realLength > 2
  243. && currentWord.word.substr(0, 1) === ':') {
  244. const unicodeEmoji = emojione.shortnameToUnicode(currentWord.word);
  245. if (unicodeEmoji && unicodeEmoji !== currentWord.word) {
  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 emojify = $filter('emojify') as (a: string, b?: boolean, c?: boolean, d?: string) => string;
  381. const escapeHtml = $filter('escapeHtml') as (a: string) => string;
  382. const mentionify = $filter('mentionify') as (a: string) => string;
  383. const nlToBr = $filter('nlToBr') as (a: string, b?: boolean) => string;
  384. // Escape HTML markup
  385. const escaped = escapeHtml(text);
  386. // Apply filters (emojify, convert newline, etc)
  387. const formatted = nlToBr(mentionify(emojify(escaped, true, false, scope.emojiImagePath)), true);
  388. // Insert resulting HTML
  389. document.execCommand('insertHTML', false, formatted);
  390. cleanupComposeContent();
  391. updateView();
  392. }
  393. }
  394. // Translate placeholder texts
  395. let regularPlaceholder = '';
  396. let dragoverPlaceholder = '';
  397. $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
  398. $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
  399. // Show emoji picker element
  400. function showEmojiPicker() {
  401. const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
  402. // Show
  403. emojiKeyboard.addClass('active');
  404. emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
  405. // Find all emoji
  406. const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .e1'));
  407. // Add event handlers
  408. allEmoji.on('click', onEmojiChosen);
  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. // Hide
  417. emojiKeyboard.removeClass('active');
  418. emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
  419. // Find all emoji
  420. const allEmoji: any = angular.element(
  421. EmojiPickerContainer.get().htmlElement.querySelectorAll('.content .e1'));
  422. // Remove event handlers
  423. allEmoji.off('click', onEmojiChosen);
  424. EmojiPickerContainer.destroy();
  425. }
  426. // Emoji trigger is clicked
  427. function onEmojiTrigger(ev: UIEvent): void {
  428. ev.stopPropagation();
  429. // Toggle visibility of picker
  430. if (emojiKeyboard.hasClass('active')) {
  431. hideEmojiPicker();
  432. } else {
  433. showEmojiPicker();
  434. }
  435. }
  436. // Emoji is chosen
  437. function onEmojiChosen(ev: MouseEvent): void {
  438. ev.stopPropagation();
  439. insertEmoji(this.textContent);
  440. }
  441. function insertEmoji(emoji, posFrom?: number, posTo?: number): void {
  442. const emojiElement = ($filter('emojify') as any)(emoji, true, true, scope.emojiImagePath) as string;
  443. insertHTMLElement(emoji, emojiElement, posFrom, posTo);
  444. }
  445. function insertMention(mentionString, posFrom?: number, posTo?: number): void {
  446. const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
  447. insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
  448. }
  449. function insertHTMLElement(
  450. elementText: string, // The element as the original text representation, not yet converted to HTML
  451. elementHtml: string, // The element converted to HTML
  452. posFrom?: number,
  453. posTo?: number,
  454. ): void {
  455. // In Chrome in right-to-left mode, our content editable
  456. // area may contain a DIV element.
  457. const childNodes = composeDiv[0].childNodes;
  458. const nestedDiv = childNodes.length === 1
  459. && childNodes[0].tagName !== undefined
  460. && childNodes[0].tagName.toLowerCase() === 'div';
  461. let contentElement;
  462. if (nestedDiv === true) {
  463. contentElement = composeDiv[0].childNodes[0];
  464. } else {
  465. contentElement = composeDiv[0];
  466. }
  467. let currentHtml = '';
  468. for (let i = 0; i < contentElement.childNodes.length; i++) {
  469. const node: Node = contentElement.childNodes[i];
  470. if (isTextNode(node)) {
  471. currentHtml += node.textContent;
  472. } else if (isElementNode(node)) {
  473. const tag = node.tagName.toLowerCase();
  474. if (tag === 'img' || tag === 'span') {
  475. currentHtml += getOuterHtml(node);
  476. } else if (tag === 'br') {
  477. // Firefox inserts a <br> after editing content editable fields.
  478. // Remove the last <br> to fix this.
  479. if (i < contentElement.childNodes.length - 1) {
  480. currentHtml += getOuterHtml(node);
  481. }
  482. } else if (tag === 'div') {
  483. // Safari inserts a <div><br></div> after editing content editable fields.
  484. // Remove the last instance to fix this.
  485. if (node.childNodes.length === 1
  486. && isElementNode(node.lastChild)
  487. && node.lastChild.tagName.toLowerCase() === 'br') {
  488. // Ignore
  489. } else {
  490. currentHtml += getOuterHtml(node);
  491. }
  492. }
  493. }
  494. }
  495. // Because the browser may transform HTML code when
  496. // inserting it into the DOM, we temporarily write it to a
  497. // DOM element to ensure that the current representation
  498. // corresponds to the representation when inserted into the
  499. // DOM. (See #671 for details.)
  500. const tmpDiv = document.createElement('div');
  501. tmpDiv.innerHTML = elementHtml;
  502. const cleanedElementHtml = tmpDiv.innerHTML;
  503. // Insert element into currentHtml and determine new caret position
  504. let newPos = posFrom;
  505. if (caretPosition !== null) {
  506. // If the caret position is set, then the user has moved around
  507. // in the contenteditable field and might not be ad the end
  508. // of the line.
  509. posFrom = posFrom === undefined ? caretPosition.from : posFrom;
  510. posTo = posTo === undefined ? caretPosition.to : posTo;
  511. currentHtml = currentHtml.substr(0, posFrom)
  512. + cleanedElementHtml
  513. + currentHtml.substr(posTo);
  514. // Change caret position
  515. caretPosition.from += cleanedElementHtml.length;
  516. caretPosition.fromChar += elementText.length;
  517. newPos = posFrom + cleanedElementHtml.length;
  518. } else {
  519. // If the caret position is not set, then the user must be at the
  520. // end of the line. Insert element there.
  521. newPos = currentHtml.length;
  522. currentHtml += cleanedElementHtml;
  523. caretPosition = {
  524. from: currentHtml.length,
  525. };
  526. }
  527. caretPosition.to = caretPosition.from;
  528. caretPosition.toChar = caretPosition.fromChar;
  529. contentElement.innerHTML = currentHtml;
  530. cleanupComposeContent();
  531. setCaretPosition(newPos);
  532. // Update the draft text
  533. const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
  534. scope.onTyping(text);
  535. updateView();
  536. }
  537. // File trigger is clicked
  538. function onFileTrigger(ev: UIEvent): void {
  539. ev.preventDefault();
  540. ev.stopPropagation();
  541. const input = element[0].querySelector('.file-input') as HTMLInputElement;
  542. input.click();
  543. }
  544. function onSendTrigger(ev: UIEvent): boolean {
  545. ev.preventDefault();
  546. ev.stopPropagation();
  547. return sendText();
  548. }
  549. // File(s) are uploaded via input field
  550. function onFileSelected() {
  551. uploadFiles(this.files);
  552. fileInput.val('');
  553. }
  554. // Disable content editable and dragging for contained images (emoji)
  555. function cleanupComposeContent() {
  556. for (const img of composeDiv[0].getElementsByTagName('img')) {
  557. img.ondragstart = () => false;
  558. }
  559. for (const span of composeDiv[0].getElementsByTagName('span')) {
  560. span.setAttribute('contenteditable', false);
  561. }
  562. if (browserService.getBrowser().isFirefox(false)) {
  563. // Disabling object resizing is the only way to disable resizing of
  564. // emoji (contenteditable must be true, otherwise the emoji can not
  565. // be removed with backspace (in FF))
  566. //
  567. // Note: This is not required anymore for FF63+ (but
  568. // please test before removing it to make sure).
  569. (document.execCommand as any)('enableObjectResizing', false, false);
  570. }
  571. }
  572. // Set all correct styles
  573. function updateView() {
  574. if (composeAreaIsEmpty()) {
  575. sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
  576. } else {
  577. sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
  578. }
  579. }
  580. // return the outer html of a node element
  581. function getOuterHtml(node: Node): string {
  582. const pseudoElement = document.createElement('pseudo');
  583. pseudoElement.appendChild(node.cloneNode(true));
  584. return pseudoElement.innerHTML;
  585. }
  586. // return the html code position of the container element
  587. function getPositions(offset: number, container: Node): {html: number, text: number} {
  588. let pos = null;
  589. let textPos = null;
  590. if (composeDiv[0].contains(container)) {
  591. let selectedElement;
  592. if (container === composeDiv[0]) {
  593. if (offset === 0) {
  594. return {
  595. html: 0, text: 0,
  596. };
  597. }
  598. selectedElement = composeDiv[0].childNodes[offset - 1];
  599. pos = 0;
  600. textPos = 0;
  601. } else {
  602. selectedElement = container.previousSibling;
  603. pos = offset;
  604. textPos = offset;
  605. }
  606. while (selectedElement !== null) {
  607. if (selectedElement.nodeType === Node.TEXT_NODE) {
  608. pos += selectedElement.textContent.length;
  609. textPos += selectedElement.textContent.length;
  610. } else {
  611. pos += getOuterHtml(selectedElement).length;
  612. textPos += 1;
  613. }
  614. selectedElement = selectedElement.previousSibling;
  615. }
  616. }
  617. return {
  618. html: pos,
  619. text: textPos,
  620. };
  621. }
  622. // Update the current caret position or selection
  623. function updateCaretPosition() {
  624. caretPosition = null;
  625. if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
  626. const selection = window.getSelection();
  627. if (selection.rangeCount) {
  628. const range = selection.getRangeAt(0);
  629. const from = getPositions(range.startOffset, range.startContainer);
  630. if (from !== null && from.html >= 0) {
  631. const to = getPositions(range.endOffset, range.endContainer);
  632. caretPosition = {
  633. from: from.html,
  634. to: to.html,
  635. fromChar: from.text,
  636. toChar: to.text,
  637. };
  638. }
  639. }
  640. }
  641. }
  642. // Set the correct cart position in the content editable div.
  643. // Pos is the position in the html content (not in the visible plain text).
  644. function setCaretPosition(pos: number) {
  645. const rangeAt = (node: Node, offset?: number) => {
  646. const range = document.createRange();
  647. range.collapse(false);
  648. if (offset !== undefined) {
  649. range.setStart(node, offset);
  650. } else {
  651. range.setStartAfter(node);
  652. }
  653. const sel = window.getSelection();
  654. sel.removeAllRanges();
  655. sel.addRange(range);
  656. };
  657. for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
  658. const node = composeDiv[0].childNodes[i];
  659. let size;
  660. let offset;
  661. switch (node.nodeType) {
  662. case Node.TEXT_NODE:
  663. size = node.textContent.length;
  664. offset = pos;
  665. break;
  666. case Node.ELEMENT_NODE:
  667. size = getOuterHtml(node).length ;
  668. break;
  669. default:
  670. $log.warn(logTag, 'Unhandled node:', node);
  671. }
  672. if (pos < size) {
  673. // use this node
  674. rangeAt(node, offset);
  675. } else if (i === composeDiv[0].childNodes.length - 1) {
  676. rangeAt(node);
  677. }
  678. pos -= size;
  679. }
  680. }
  681. // Handle typing events
  682. composeDiv.on('keydown', onKeyDown);
  683. composeDiv.on('keyup', onKeyUp);
  684. composeDiv.on('keyup mouseup', updateCaretPosition);
  685. composeDiv.on('selectionchange', updateCaretPosition);
  686. // Handle paste event
  687. composeDiv.on('paste', onPaste);
  688. // Handle click on emoji trigger
  689. emojiTrigger.on('click', onEmojiTrigger);
  690. emojiTrigger.on('keypress', (ev: KeyboardEvent) => {
  691. if (isActionTrigger(ev)) {
  692. onEmojiTrigger(ev);
  693. }
  694. });
  695. // Handle click on file trigger
  696. fileTrigger.on('click', onFileTrigger);
  697. fileTrigger.on('keypress', (ev: KeyboardEvent) => {
  698. if (isActionTrigger(ev)) {
  699. onFileTrigger(ev);
  700. }
  701. });
  702. // Handle file uploads
  703. fileInput.on('change', onFileSelected);
  704. // Handle click on send trigger
  705. sendTrigger.on('click', onSendTrigger);
  706. sendTrigger.on('keypress', (ev: KeyboardEvent) => {
  707. if (isActionTrigger(ev)) {
  708. onSendTrigger(ev);
  709. }
  710. });
  711. updateView();
  712. // Listen to broadcasts
  713. const unsubscribeListeners = [];
  714. unsubscribeListeners.push($rootScope.$on('onQuoted', (event: ng.IAngularEvent, args: any) => {
  715. composeDiv[0].focus();
  716. }));
  717. unsubscribeListeners.push($rootScope.$on('onMentionSelected', (event: ng.IAngularEvent, args: any) => {
  718. if (args.query && args.mention) {
  719. // Insert resulting HTML
  720. insertMention(args.mention, caretPosition ? caretPosition.to - args.query.length : null,
  721. caretPosition ? caretPosition.to : null);
  722. }
  723. }));
  724. // When switching chat, send stopTyping message
  725. scope.$on('$destroy', () => {
  726. unsubscribeListeners.forEach((u) => {
  727. // Unsubscribe
  728. u();
  729. });
  730. stopTyping();
  731. });
  732. },
  733. // tslint:disable:max-line-length
  734. template: `
  735. <div>
  736. <div>
  737. <i class="md-primary emoji-trigger trigger is-enabled material-icons" role="button" aria-label="emoji" tabindex="0">tag_faces</i>
  738. </div>
  739. <div>
  740. <div
  741. class="compose"
  742. contenteditable
  743. autofocus
  744. translate
  745. translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE"
  746. translate-attr-aria-label="messenger.COMPOSE_MESSAGE"
  747. tabindex="0"
  748. ></div>
  749. </div>
  750. <div>
  751. <i class="md-primary send-trigger trigger material-icons" role="button" aria-label="send" tabindex="0">send</i>
  752. <i class="md-primary file-trigger trigger is-enabled material-icons" role="button" aria-label="attach file" tabindex="0">attach_file</i>
  753. <input class="file-input" type="file" style="visibility: hidden" multiple>
  754. </div>
  755. </div>
  756. <div class="emoji-keyboard">
  757. <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
  758. </div>
  759. `,
  760. };
  761. },
  762. ];