123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853 |
- /**
- * This file is part of Threema Web.
- *
- * Threema Web is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
- * General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
- */
- import * as twemoji from 'twemoji';
- import {extractText, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
- import {emojify, shortnameToUnicode} from '../helpers/emoji';
- import {BrowserService} from '../services/browser';
- import {StringService} from '../services/string';
- import {TimeoutService} from '../services/timeout';
- import {isElementNode, isTextNode} from '../typeguards';
- /**
- * The compose area where messages are written.
- */
- export default [
- 'BrowserService',
- 'StringService',
- 'TimeoutService',
- '$timeout',
- '$translate',
- '$mdDialog',
- '$filter',
- '$log',
- '$rootScope',
- function(browserService: BrowserService,
- stringService: StringService,
- timeoutService: TimeoutService,
- $timeout: ng.ITimeoutService,
- $translate: ng.translate.ITranslateService,
- $mdDialog: ng.material.IDialogService,
- $filter: ng.IFilterService,
- $log: ng.ILogService,
- $rootScope: ng.IRootScopeService) {
- return {
- restrict: 'EA',
- scope: {
- // Callback to submit text or file data
- submit: '=',
- // Callbacks to update typing information
- startTyping: '=',
- stopTyping: '=',
- onTyping: '=',
- onKeyDown: '=',
- // Reference to initial text and draft
- initialData: '=',
- // Callback that is called when uploading files
- onUploading: '=',
- maxTextLength: '=',
- },
- link(scope: any, element) {
- // Logging
- const logTag = '[Directives.ComposeArea]';
- // Constants
- const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
- const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
- // Elements
- const composeArea: any = element;
- const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
- const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
- const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
- const sendTrigger: any = angular.element(element[0].querySelector('i.send-trigger'));
- const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
- const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
- // Set initial text
- if (scope.initialData.initialText) {
- composeDiv[0].innerText = scope.initialData.initialText;
- scope.initialData.initialText = '';
- } else if (scope.initialData.draft !== undefined) {
- composeDiv[0].innerText = scope.initialData.draft;
- }
- // The current caret position, used when inserting objects
- let caretPosition: {
- // The position in the source HTML
- from?: number,
- to?: number,
- // The position in the visible character list
- fromChar?: number,
- toChar?: number,
- } = null;
- /**
- * Stop propagation of click events and hold htmlElement of the emojipicker
- */
- const EmojiPickerContainer = (function() {
- let instance;
- function click(e) {
- e.stopPropagation();
- }
- return {
- get: function() {
- if (instance === undefined) {
- instance = {
- htmlElement: composeArea[0].querySelector('div.twemoji-picker'),
- };
- // append stop propagation
- angular.element(instance.htmlElement).on('click', click);
- }
- return instance;
- },
- destroy: function() {
- if (instance !== undefined) {
- // remove stop propagation
- angular.element(instance.htmlElement).off('click', click);
- instance = undefined;
- }
- },
- };
- })();
- // Typing events
- let stopTypingTimer: ng.IPromise<void> = null;
- function stopTyping() {
- // We can only stop typing of the timer is set (meaning
- // that we started typing earlier)
- if (stopTypingTimer !== null) {
- // Cancel timer
- timeoutService.cancel(stopTypingTimer);
- stopTypingTimer = null;
- // Send stop typing message
- scope.stopTyping();
- }
- }
- function startTyping() {
- if (stopTypingTimer === null) {
- // If the timer wasn't set previously, we just
- // started typing!
- scope.startTyping();
- } else {
- // Cancel timer, we'll re-create it
- timeoutService.cancel(stopTypingTimer);
- }
- // Define a timeout to send the stopTyping event
- stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
- }
- // Determine whether field is empty
- function composeAreaIsEmpty() {
- const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
- return text.length === 0;
- }
- // Submit the text from the compose area.
- //
- // Emoji images are converted to their alt text in this process.
- function submitText(): Promise<any> {
- const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
- return new Promise((resolve, reject) => {
- const submitTexts = (strings: string[]) => {
- const messages: threema.TextMessageData[] = [];
- for (const piece of strings) {
- messages.push({
- text: piece,
- });
- }
- scope.submit('text', messages)
- .then(resolve)
- .catch(reject);
- };
- const fullText = text.trim().replace(/\r/g, '');
- if (fullText.length > scope.maxTextLength) {
- const pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
- const confirm = $mdDialog.confirm()
- .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
- .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
- max: scope.maxTextLength,
- count: pieces.length,
- }))
- .ok($translate.instant('common.YES'))
- .cancel($translate.instant('common.NO'));
- $mdDialog.show(confirm).then(function() {
- submitTexts(pieces);
- }, () => {
- reject();
- });
- } else {
- submitTexts([fullText]);
- }
- });
- }
- function sendText(): boolean {
- if (!composeAreaIsEmpty()) {
- submitText().then(() => {
- // Clear compose div
- composeDiv[0].innerText = '';
- composeDiv[0].focus();
- // Send stopTyping event
- stopTyping();
- // Clear draft
- scope.onTyping('');
- updateView();
- }).catch(() => {
- // do nothing
- $log.warn(logTag, 'Failed to submit text');
- });
- return true;
- }
- return false;
- }
- // Handle typing events
- function onKeyDown(ev: KeyboardEvent): void {
- // If enter is pressed, prevent default event from being dispatched
- if (!ev.shiftKey && ev.key === 'Enter') {
- ev.preventDefault();
- }
- // If the keydown is handled and aborted outside
- if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
- ev.preventDefault();
- return;
- }
- // At link time, the element is not yet evaluated.
- // Therefore add following code to end of event loop.
- $timeout(() => {
- // Shift + enter to insert a newline. Enter to send.
- if (!ev.shiftKey && ev.key === 'Enter') {
- if (sendText()) {
- return;
- }
- }
- updateView();
- }, 0);
- }
- function onKeyUp(ev: KeyboardEvent): void {
- // At link time, the element is not yet evaluated.
- // Therefore add following code to end of event loop.
- $timeout(() => {
- // If the compose area contains only a single <br>, make it fully empty.
- // See also: https://stackoverflow.com/q/14638887/284318
- const text = extractText(composeDiv[0], logAdapter($log.warn, logTag), false);
- if (text === '\n') {
- composeDiv[0].innerText = '';
- } else if ((ev.keyCode === 190 || ev.key === ':') && caretPosition !== null) {
- // A ':' is pressed, try to parse
- const currentWord = stringService.getWord(text, caretPosition.fromChar, [':']);
- if (currentWord.realLength > 2 && currentWord.word.substr(0, 1) === ':') {
- const trimmed = currentWord.word.substr(1, currentWord.word.length - 2);
- const unicodeEmoji = shortnameToUnicode(trimmed);
- if (unicodeEmoji !== null) {
- return insertEmoji(unicodeEmoji,
- caretPosition.from - currentWord.realLength,
- caretPosition.to);
- }
- }
- }
- // Update typing information (use text instead method)
- if (text.trim().length === 0 || caretPosition === null) {
- stopTyping();
- scope.onTyping('');
- } else {
- startTyping();
- scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
- }
- updateView();
- }, 0);
- }
- // Function to fetch file contents
- // Resolve to ArrayBuffer or reject to ErrorEvent.
- function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
- return new Promise((resolve) => {
- const buffers = new Map<File, ArrayBuffer>();
- const fileCounter = fileList.length;
- const next = (file: File, res: ArrayBuffer | null, error: any) => {
- buffers.set(file, res);
- if (buffers.size >= fileCounter) {
- resolve(buffers);
- }
- };
- for (let n = 0; n < fileCounter; n++) {
- const reader = new FileReader();
- const file = fileList.item(n);
- reader.onload = function(ev: ProgressEvent) {
- next(file, this.result as ArrayBuffer, ev);
- };
- reader.onerror = function(ev: ProgressEvent) {
- // set a null object
- next(file, null, ev);
- };
- reader.onprogress = function(ev: ProgressEvent) {
- if (ev.lengthComputable) {
- const progress = ((ev.loaded / ev.total) * 100);
- scope.onUploading(true, progress, 100 / fileCounter * n);
- }
- };
- reader.readAsArrayBuffer(file);
- }
- });
- }
- function uploadFiles(fileList: FileList): void {
- scope.onUploading(true, 0, 0);
- fetchFileListContents(fileList).then((data: Map<File, ArrayBuffer>) => {
- const fileMessages = [];
- data.forEach((buffer, file) => {
- const fileMessageData: threema.FileMessageData = {
- name: file.name,
- fileType: file.type,
- size: file.size,
- data: buffer,
- };
- // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1240259
- if (browserService.getBrowser().isFirefox(false)) {
- if (fileMessageData.name.endsWith('.ogg') && fileMessageData.fileType === 'video/ogg') {
- fileMessageData.fileType = 'audio/ogg';
- }
- }
- fileMessages.push(fileMessageData);
- });
- scope
- .submit('file', fileMessages)
- .catch((msg) => $log.error('Could not send file:', msg));
- scope.onUploading(false);
- }).catch((ev: ErrorEvent) => {
- $log.error(logTag, 'Could not load file:', ev.message);
- });
- }
- // Handle pasting
- function onPaste(ev: ClipboardEvent) {
- ev.preventDefault();
- // If no clipboard data is available, do nothing.
- if (!ev.clipboardData) {
- return;
- }
- // Extract pasted items
- const items: DataTransferItemList = ev.clipboardData.items;
- if (!items) {
- return;
- }
- // Find available types
- let fileIdx: number | null = null;
- let textIdx: number | null = null;
- for (let i = 0; i < items.length; i++) {
- if (items[i].type.indexOf('image/') !== -1 || items[i].type === 'application/x-moz-file') {
- fileIdx = i;
- } else if (items[i].type === 'text/plain') {
- textIdx = i;
- }
- }
- // Handle pasting of files
- if (fileIdx !== null) {
- // Read clipboard data as blob
- const blob: Blob = items[fileIdx].getAsFile();
- // Convert blob to arraybuffer
- const reader = new FileReader();
- reader.onload = function(progressEvent: ProgressEvent) {
- const buffer: ArrayBuffer = this.result as ArrayBuffer;
- // Construct file name
- let fileName: string;
- if ((blob as any).name) {
- fileName = (blob as any).name;
- } else if (blob.type && blob.type.match(/^[^;]*\//) !== null) {
- const fileExt = blob.type.split(';')[0].split('/')[1];
- fileName = 'clipboard.' + fileExt;
- } else {
- $log.warn(logTag, 'Pasted file has an invalid MIME type: "' + blob.type + '"');
- return;
- }
- // Send data as file
- const fileMessageData: threema.FileMessageData = {
- name: fileName,
- fileType: blob.type,
- size: blob.size,
- data: buffer,
- };
- scope
- .submit('file', [fileMessageData])
- .catch((msg) => $log.error('Could not send file:', msg));
- };
- reader.readAsArrayBuffer(blob);
- // Handle pasting of text
- } else if (textIdx !== null) {
- const text = ev.clipboardData.getData('text/plain');
- // Look up some filter functions
- // tslint:disable-next-line:max-line-length
- const escapeHtml = $filter('escapeHtml') as (a: string) => string;
- const mentionify = $filter('mentionify') as (a: string) => string;
- const nlToBr = $filter('nlToBr') as (a: string, b?: boolean) => string;
- // Escape HTML markup
- const escaped = escapeHtml(text);
- // Apply filters (emojify, convert newline, etc)
- const formatted = emojify(mentionify(replaceWhitespace(nlToBr(escaped, true))));
- // Insert resulting HTML
- document.execCommand('insertHTML', false, formatted);
- updateView();
- }
- }
- // Translate placeholder texts
- let regularPlaceholder = '';
- let dragoverPlaceholder = '';
- $translate('messenger.COMPOSE_MESSAGE').then((translated) => regularPlaceholder = translated);
- $translate('messenger.COMPOSE_MESSAGE_DRAGOVER').then((translated) => dragoverPlaceholder = translated);
- // Show emoji picker element
- function showEmojiPicker() {
- const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
- // Show
- emojiKeyboard.addClass('active');
- emojiTrigger.addClass(TRIGGER_ACTIVE_CSS_CLASS);
- // Find some selectors
- const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .em'));
- const allEmojiTabs: any = angular.element(emojiPicker.querySelectorAll('.tab label img'));
- // Add event handlers
- allEmoji.on('click', onEmojiChosen);
- allEmojiTabs.on('keydown', onEmojiTabSelected);
- // set focus to fix chat scroll bug
- $timeout(() => {
- composeDiv[0].focus();
- });
- }
- // Hide emoji picker element
- function hideEmojiPicker() {
- const emojiPicker: HTMLElement = EmojiPickerContainer.get().htmlElement;
- // Hide
- emojiKeyboard.removeClass('active');
- emojiTrigger.removeClass(TRIGGER_ACTIVE_CSS_CLASS);
- // Find some selectors
- const allEmoji: any = angular.element(emojiPicker.querySelectorAll('.content .em'));
- const allEmojiTabs: any = angular.element(emojiPicker.querySelectorAll('.tab label img'));
- // Remove event handlers
- allEmoji.off('click', onEmojiChosen);
- allEmojiTabs.off('keydown', onEmojiTabSelected);
- EmojiPickerContainer.destroy();
- }
- // Emoji trigger is clicked
- function onEmojiTrigger(ev: UIEvent): void {
- ev.stopPropagation();
- // Toggle visibility of picker
- if (emojiKeyboard.hasClass('active')) {
- hideEmojiPicker();
- } else {
- showEmojiPicker();
- }
- }
- // Emoji is chosen
- function onEmojiChosen(ev: MouseEvent): void {
- ev.stopPropagation();
- insertEmoji(this.textContent);
- }
- // Emoji tab is selected
- function onEmojiTabSelected(ev: KeyboardEvent): void {
- if (ev.key === ' ' || ev.key === 'Enter') {
- // Warning: Hacky
- this.parentElement.previousElementSibling.checked = true;
- }
- }
- function insertEmoji(emoji, posFrom?: number, posTo?: number): void {
- const emojiElement = emojify(emoji);
- insertHTMLElement(emoji, emojiElement, posFrom, posTo);
- }
- function insertMention(mentionString, posFrom?: number, posTo?: number): void {
- const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
- insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
- }
- function insertHTMLElement(
- elementText: string, // The element as the original text representation, not yet converted to HTML
- elementHtml: string, // The element converted to HTML
- posFrom?: number,
- posTo?: number,
- ): void {
- // In Chrome in right-to-left mode, our content editable
- // area may contain a DIV element.
- const childNodes = composeDiv[0].childNodes;
- const nestedDiv = childNodes.length === 1
- && childNodes[0].tagName !== undefined
- && childNodes[0].tagName.toLowerCase() === 'div';
- let contentElement;
- if (nestedDiv === true) {
- contentElement = composeDiv[0].childNodes[0];
- } else {
- contentElement = composeDiv[0];
- }
- let currentHtml = '';
- for (let i = 0; i < contentElement.childNodes.length; i++) {
- const node: Node = contentElement.childNodes[i];
- if (isTextNode(node)) {
- currentHtml += node.textContent;
- } else if (isElementNode(node)) {
- const tag = node.tagName.toLowerCase();
- if (tag === 'img' || tag === 'span') {
- currentHtml += getOuterHtml(node);
- } else if (tag === 'br') {
- // Firefox inserts a <br> after editing content editable fields.
- // Remove the last <br> to fix this.
- if (i < contentElement.childNodes.length - 1) {
- currentHtml += getOuterHtml(node);
- }
- } else if (tag === 'div') {
- // Safari inserts a <div><br></div> after editing content editable fields.
- // Remove the last instance to fix this.
- if (node.childNodes.length === 1
- && isElementNode(node.lastChild)
- && node.lastChild.tagName.toLowerCase() === 'br') {
- // Ignore
- } else {
- currentHtml += getOuterHtml(node);
- }
- }
- }
- }
- // Because the browser may transform HTML code when
- // inserting it into the DOM, we temporarily write it to a
- // DOM element to ensure that the current representation
- // corresponds to the representation when inserted into the
- // DOM. (See #671 for details.)
- const tmpDiv = document.createElement('div');
- tmpDiv.innerHTML = elementHtml;
- const cleanedElementHtml = tmpDiv.innerHTML;
- // Insert element into currentHtml and determine new caret position
- let newPos = posFrom;
- if (caretPosition !== null) {
- // If the caret position is set, then the user has moved around
- // in the contenteditable field and might not be ad the end
- // of the line.
- posFrom = posFrom === undefined ? caretPosition.from : posFrom;
- posTo = posTo === undefined ? caretPosition.to : posTo;
- currentHtml = currentHtml.substr(0, posFrom)
- + cleanedElementHtml
- + currentHtml.substr(posTo);
- // Change caret position
- caretPosition.from += cleanedElementHtml.length;
- caretPosition.fromChar += elementText.length;
- newPos = posFrom + cleanedElementHtml.length;
- } else {
- // If the caret position is not set, then the user must be at the
- // end of the line. Insert element there.
- newPos = currentHtml.length;
- currentHtml += cleanedElementHtml;
- caretPosition = {
- from: currentHtml.length,
- };
- }
- caretPosition.to = caretPosition.from;
- caretPosition.toChar = caretPosition.fromChar;
- contentElement.innerHTML = currentHtml;
- setCaretPosition(newPos);
- // Update the draft text
- const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
- scope.onTyping(text);
- updateView();
- }
- // File trigger is clicked
- function onFileTrigger(ev: UIEvent): void {
- ev.preventDefault();
- ev.stopPropagation();
- const input = element[0].querySelector('.file-input') as HTMLInputElement;
- input.click();
- }
- function onSendTrigger(ev: UIEvent): boolean {
- ev.preventDefault();
- ev.stopPropagation();
- return sendText();
- }
- // File(s) are uploaded via input field
- function onFileSelected() {
- uploadFiles(this.files);
- fileInput.val('');
- }
- // Set all correct styles
- function updateView() {
- if (composeAreaIsEmpty()) {
- sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
- } else {
- sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
- }
- }
- // return the outer html of a node element
- function getOuterHtml(node: Node): string {
- const pseudoElement = document.createElement('pseudo');
- pseudoElement.appendChild(node.cloneNode(true));
- return pseudoElement.innerHTML;
- }
- // return the html code position of the container element
- function getPositions(offset: number, container: Node): {html: number, text: number} {
- let pos = null;
- let textPos = null;
- if (composeDiv[0].contains(container)) {
- let selectedElement;
- if (container === composeDiv[0]) {
- if (offset === 0) {
- return {
- html: 0, text: 0,
- };
- }
- selectedElement = composeDiv[0].childNodes[offset - 1];
- pos = 0;
- textPos = 0;
- } else {
- selectedElement = container.previousSibling;
- pos = offset;
- textPos = offset;
- }
- while (selectedElement !== null) {
- if (selectedElement.nodeType === Node.TEXT_NODE) {
- pos += selectedElement.textContent.length;
- textPos += selectedElement.textContent.length;
- } else {
- pos += getOuterHtml(selectedElement).length;
- textPos += 1;
- }
- selectedElement = selectedElement.previousSibling;
- }
- }
- return {
- html: pos,
- text: textPos,
- };
- }
- // Update the current caret position or selection
- function updateCaretPosition() {
- caretPosition = null;
- if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
- const selection = window.getSelection();
- if (selection.rangeCount) {
- const range = selection.getRangeAt(0);
- const from = getPositions(range.startOffset, range.startContainer);
- if (from !== null && from.html >= 0) {
- const to = getPositions(range.endOffset, range.endContainer);
- caretPosition = {
- from: from.html,
- to: to.html,
- fromChar: from.text,
- toChar: to.text,
- };
- }
- }
- }
- }
- // Set the correct cart position in the content editable div.
- // Pos is the position in the html content (not in the visible plain text).
- function setCaretPosition(pos: number) {
- const rangeAt = (node: Node, offset?: number) => {
- const range = document.createRange();
- range.collapse(false);
- if (offset !== undefined) {
- range.setStart(node, offset);
- } else {
- range.setStartAfter(node);
- }
- const sel = window.getSelection();
- sel.removeAllRanges();
- sel.addRange(range);
- };
- for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
- const node = composeDiv[0].childNodes[i];
- let size;
- let offset;
- switch (node.nodeType) {
- case Node.TEXT_NODE:
- size = node.textContent.length;
- offset = pos;
- break;
- case Node.ELEMENT_NODE:
- size = getOuterHtml(node).length ;
- break;
- default:
- $log.warn(logTag, 'Unhandled node:', node);
- }
- if (pos < size) {
- // use this node
- rangeAt(node, offset);
- } else if (i === composeDiv[0].childNodes.length - 1) {
- rangeAt(node);
- }
- pos -= size;
- }
- }
- // Handle typing events
- composeDiv.on('keydown', onKeyDown);
- composeDiv.on('keyup', onKeyUp);
- composeDiv.on('keyup mouseup', updateCaretPosition);
- composeDiv.on('selectionchange', updateCaretPosition);
- // Handle paste event
- composeDiv.on('paste', onPaste);
- // Handle click on emoji trigger
- emojiTrigger.on('click', onEmojiTrigger);
- emojiTrigger.on('keypress', (ev: KeyboardEvent) => {
- if (isActionTrigger(ev)) {
- onEmojiTrigger(ev);
- }
- });
- // Handle click on file trigger
- fileTrigger.on('click', onFileTrigger);
- fileTrigger.on('keypress', (ev: KeyboardEvent) => {
- if (isActionTrigger(ev)) {
- onFileTrigger(ev);
- }
- });
- // Handle file uploads
- fileInput.on('change', onFileSelected);
- // Handle click on send trigger
- sendTrigger.on('click', onSendTrigger);
- sendTrigger.on('keypress', (ev: KeyboardEvent) => {
- if (isActionTrigger(ev)) {
- onSendTrigger(ev);
- }
- });
- updateView();
- // Listen to broadcasts
- const unsubscribeListeners = [];
- unsubscribeListeners.push($rootScope.$on('onQuoted', (event: ng.IAngularEvent, args: any) => {
- composeDiv[0].focus();
- }));
- unsubscribeListeners.push($rootScope.$on('onMentionSelected', (event: ng.IAngularEvent, args: any) => {
- if (args.query && args.mention) {
- // Insert resulting HTML
- insertMention(args.mention, caretPosition ? caretPosition.to - args.query.length : null,
- caretPosition ? caretPosition.to : null);
- }
- }));
- // When switching chat, send stopTyping message
- scope.$on('$destroy', () => {
- unsubscribeListeners.forEach((u) => {
- // Unsubscribe
- u();
- });
- stopTyping();
- });
- },
- // tslint:disable:max-line-length
- template: `
- <div>
- <div>
- <i class="md-primary emoji-trigger trigger is-enabled material-icons" role="button" aria-label="emoji" tabindex="0">tag_faces</i>
- </div>
- <div>
- <div
- class="compose"
- contenteditable
- autofocus
- translate
- translate-attr-data-placeholder="messenger.COMPOSE_MESSAGE"
- translate-attr-aria-label="messenger.COMPOSE_MESSAGE"
- tabindex="0"
- ></div>
- </div>
- <div>
- <i class="md-primary send-trigger trigger material-icons" role="button" aria-label="send" tabindex="0">send</i>
- <i class="md-primary file-trigger trigger is-enabled material-icons" role="button" aria-label="attach file" tabindex="0">attach_file</i>
- <input class="file-input" type="file" style="visibility: hidden" multiple>
- </div>
- </div>
- <div class="emoji-keyboard">
- <ng-include src="'partials/emoji-picker.html'" include-replace></ng-include>
- </div>
- `,
- };
- },
- ];
|