|
@@ -15,15 +15,16 @@
|
|
|
* along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
|
|
|
*/
|
|
|
|
|
|
+import {ComposeArea} from '@threema/compose-area';
|
|
|
import * as twemoji from 'twemoji';
|
|
|
|
|
|
-import {extractText, hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
|
|
|
-import {emojify, shortnameToUnicode} from '../helpers/emoji';
|
|
|
+import {hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
|
|
|
+import {emojify, emojifyNew, shortnameToUnicode} from '../helpers/emoji';
|
|
|
import {BrowserService} from '../services/browser';
|
|
|
import {ReceiverService} from '../services/receiver';
|
|
|
import {StringService} from '../services/string';
|
|
|
import {TimeoutService} from '../services/timeout';
|
|
|
-import {isElementNode, isTextNode} from '../typeguards';
|
|
|
+import {isElementNode, isEmojiInfo, isTextNode} from '../typeguards';
|
|
|
|
|
|
/**
|
|
|
* The compose area where messages are written.
|
|
@@ -52,6 +53,9 @@ export default [
|
|
|
return {
|
|
|
restrict: 'EA',
|
|
|
scope: {
|
|
|
+ // Callback to get a reference to the initialized ComposeArea instance.
|
|
|
+ onInit: '=',
|
|
|
+
|
|
|
// Callback to submit text or file data
|
|
|
submit: '=',
|
|
|
|
|
@@ -79,7 +83,7 @@ export default [
|
|
|
const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
|
|
|
|
|
|
// Elements
|
|
|
- const composeArea: any = element;
|
|
|
+ const wrapper: 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'));
|
|
@@ -87,6 +91,12 @@ export default [
|
|
|
const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
|
|
|
const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
|
|
|
|
|
|
+ // Initialize compose area lib
|
|
|
+ const composeArea = ComposeArea.bind_to(composeDiv[0]);
|
|
|
+ if (scope.onInit) {
|
|
|
+ scope.onInit(composeArea);
|
|
|
+ }
|
|
|
+
|
|
|
// Set initial text
|
|
|
if (scope.initialData.initialText) {
|
|
|
composeDiv[0].innerText = scope.initialData.initialText;
|
|
@@ -95,16 +105,6 @@ export default [
|
|
|
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;
|
|
|
-
|
|
|
let chatBlocked = false;
|
|
|
|
|
|
// Function to update blocking state
|
|
@@ -158,7 +158,7 @@ export default [
|
|
|
get: function() {
|
|
|
if (instance === undefined) {
|
|
|
instance = {
|
|
|
- htmlElement: composeArea[0].querySelector('div.twemoji-picker'),
|
|
|
+ htmlElement: wrapper[0].querySelector('div.twemoji-picker'),
|
|
|
};
|
|
|
// append stop propagation
|
|
|
angular.element(instance.htmlElement).on('click', click);
|
|
@@ -208,24 +208,14 @@ export default [
|
|
|
|
|
|
// Determine whether field is empty
|
|
|
function composeAreaIsEmpty() {
|
|
|
- const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
|
|
|
- return text.length === 0;
|
|
|
+ return composeArea.get_text(false).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 rawText = extractText(composeDiv[0], logAdapter($log.warn, logTag));
|
|
|
-
|
|
|
- // Due to #731, and the hack introduced in #706, the
|
|
|
- // extracted text may contain non-breaking spaces (U+00A0).
|
|
|
- // Replace them with actual whitespace to avoid strange
|
|
|
- // behavior when submitting the text.
|
|
|
- //
|
|
|
- // TODO: Remove this once we have a compose area rewrite and can
|
|
|
- // fix the actual bug.
|
|
|
- const text = rawText.replace(/\u00A0/g, ' ');
|
|
|
+ const text = composeArea.get_text(false).replace(/\r/g, '');
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
const submitTexts = (strings: string[]) => {
|
|
@@ -241,9 +231,8 @@ export default [
|
|
|
.catch(reject);
|
|
|
};
|
|
|
|
|
|
- const fullText = text.trim().replace(/\r/g, '');
|
|
|
- if (fullText.length > scope.maxTextLength) {
|
|
|
- const pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
|
|
|
+ if (text.length > scope.maxTextLength) {
|
|
|
+ const pieces: string[] = stringService.byteChunk(text, 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', {
|
|
@@ -259,7 +248,7 @@ export default [
|
|
|
reject();
|
|
|
});
|
|
|
} else {
|
|
|
- submitTexts([fullText]);
|
|
|
+ submitTexts([text]);
|
|
|
}
|
|
|
});
|
|
|
}
|
|
@@ -268,6 +257,7 @@ export default [
|
|
|
if (!composeAreaIsEmpty()) {
|
|
|
submitText().then(() => {
|
|
|
// Clear compose div
|
|
|
+ // TODO: Use clear() and focus() methods
|
|
|
composeDiv[0].innerText = '';
|
|
|
composeDiv[0].focus();
|
|
|
|
|
@@ -321,30 +311,31 @@ export default [
|
|
|
$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);
|
|
|
+ const text = composeArea.get_text(true);
|
|
|
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);
|
|
|
- }
|
|
|
- }
|
|
|
+ // TODO
|
|
|
+ // } 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) {
|
|
|
+ // Update typing information
|
|
|
+ if (text.trim().length === 0) {
|
|
|
stopTyping();
|
|
|
scope.onTyping('');
|
|
|
} else {
|
|
|
startTyping();
|
|
|
- scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
|
|
|
+ scope.onTyping(text.trim(), null/* TODO stringService.getWord(text, caretPosition.from)*/);
|
|
|
}
|
|
|
|
|
|
updateView();
|
|
@@ -479,23 +470,17 @@ export default [
|
|
|
// 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();
|
|
|
+ if (text) {
|
|
|
+ const tokens = emojifyNew(text);
|
|
|
+ for (const token of tokens) {
|
|
|
+ if (isEmojiInfo(token)) {
|
|
|
+ insertEmoji(token);
|
|
|
+ } else {
|
|
|
+ composeArea.insert_text(token);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ updateView();
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -563,7 +548,7 @@ export default [
|
|
|
// Emoji is chosen
|
|
|
function onEmojiChosen(ev: MouseEvent): void {
|
|
|
ev.stopPropagation();
|
|
|
- insertEmoji(this.textContent);
|
|
|
+ insertEmojiString(this.textContent);
|
|
|
}
|
|
|
|
|
|
// Emoji tab is selected
|
|
@@ -574,113 +559,30 @@ export default [
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- 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);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ // Insert a single emoji, passed in as string
|
|
|
+ function insertEmojiString(emojiString: string): void {
|
|
|
+ const tokens = emojifyNew(emojiString);
|
|
|
+ if (tokens.length !== 1) {
|
|
|
+ throw new Error(`Emoji parsing failed: Expected 1 element, found ${tokens.length}`);
|
|
|
}
|
|
|
-
|
|
|
- // 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,
|
|
|
- };
|
|
|
+ const emoji = tokens[0];
|
|
|
+ if (!isEmojiInfo(emoji)) {
|
|
|
+ throw new Error(`Emoji parsing failed: Returned text, not emoji info`);
|
|
|
}
|
|
|
- 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);
|
|
|
+ insertEmoji(emoji);
|
|
|
+ }
|
|
|
|
|
|
- updateView();
|
|
|
+ // Insert a single emoji
|
|
|
+ function insertEmoji(emoji: threema.EmojiInfo): void {
|
|
|
+ composeArea.insert_image(emoji.imgPath, emoji.emojiString, 'em');
|
|
|
}
|
|
|
|
|
|
+ // TODO
|
|
|
+ // function insertMention(mentionString, posFrom?: number, posTo?: number): void {
|
|
|
+ // const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
|
|
|
+ // insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
|
|
|
+ // }
|
|
|
+
|
|
|
// File trigger is clicked
|
|
|
function onFileTrigger(ev: UIEvent): void {
|
|
|
ev.preventDefault();
|
|
@@ -716,120 +618,14 @@ export default [
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 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 selection change
|
|
|
+ document.addEventListener('selectionchange', () => {
|
|
|
+ composeArea.store_selection_range();
|
|
|
+ });
|
|
|
|
|
|
// Handle paste event
|
|
|
composeDiv.on('paste', onPaste);
|
|
@@ -869,14 +665,6 @@ export default [
|
|
|
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) => {
|
|
@@ -895,6 +683,7 @@ export default [
|
|
|
<div>
|
|
|
<div
|
|
|
class="compose"
|
|
|
+ id="composeDiv"
|
|
|
contenteditable
|
|
|
autofocus
|
|
|
translate
|