compose_area.ts 37 KB

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