/* * Copyright 2016 Software Freedom Conservancy Inc. * Copyright 2016 Michael Gratton * * This software is licensed under the GNU Lesser General Public License * (version 2.1 or later). See the COPYING file in this distribution. */ /** * Application logic for ComposerWebView. */ let ComposerPageState = function() { this.init.apply(this, arguments); }; ComposerPageState.KEYWORD_SPLIT_REGEX = /[\s]+/g; ComposerPageState.QUOTE_START = "\x91"; // private use one ComposerPageState.QUOTE_END = "\x92"; // private use two ComposerPageState.QUOTE_MARKER = "\x7f"; // delete ComposerPageState.PROTOCOL_REGEX = /^(aim|apt|bitcoin|cvs|ed2k|ftp|file|finger|git|gtalk|http|https|irc|ircs|irc6|lastfm|ldap|ldaps|magnet|news|nntp|rsync|sftp|skype|smb|sms|svn|telnet|tftp|ssh|webcal|xmpp):/i; // Taken from Geary.HTML.URL_REGEX, without the inline modifier (?x) // at the start, which is unsupported in JS ComposerPageState.URL_REGEX = new RegExp("\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))", "gi"); ComposerPageState.prototype = { __proto__: PageState.prototype, init: function() { PageState.prototype.init.apply(this, []); this.bodyPart = null; this.signaturePart = null; this.quotePart = null; this.focusedPart = null; this.undoEnabled = false; this.redoEnabled = false; this.selections = new Map(); this.nextSelectionId = 0; this.cursorContext = null; let state = this; document.addEventListener("click", function(e) { if (e.target.tagName == "A") { e.preventDefault(); } }, true); let modifiedId = null; this.bodyObserver = new MutationObserver(function() { if (modifiedId == null) { modifiedId = window.setTimeout(function() { state.documentModified(); state.checkCommandStack(); modifiedId = null; }, 1000); } }); }, loaded: function() { let state = this; this.bodyPart = document.getElementById("geary-body"); if (this.bodyPart != null) { // Capture clicks on the document that aren't on an // existing part and prevent focus leaving it. Bug 779369. document.addEventListener("mousedown", function(e) { if (!state.containedInPart(e.target)) { e.preventDefault(); e.stopPropagation(); } }); } else { // This happens if we are loading a draft created by a MUA // that isn't Geary, so we can't rely on any of the // expected HTML structure to be in place. this.bodyPart = document.body; } this.signaturePart = document.getElementById("geary-signature"); this.quotePart = document.getElementById("geary-quote"); // Should be using 'e.key' in listeners below instead of // keyIdentifier, but that was only fixed in WK in Oct 2016 // (WK Bug 36267). Migrate to that when we can rely on it // being in WebKitGTK. document.body.addEventListener("keydown", function(e) { if (e.keyIdentifier == "U+0009" &&// Tab !e.ctrlKey && !e.altKey && !e.metaKey) { if (!e.shiftKey) { state.tabOut(); } else { state.tabIn(); } e.preventDefault(); } }); // We can't use keydown for this, captured or bubbled, since // that will also cause the line that the cursor is currenty // positioned on when Enter is pressed to also be outdented. document.body.addEventListener("keyup", function(e) { if (e.keyIdentifier == "Enter" && !e.shiftKey) { // XXX WebKit seems to support both InsertNewline and // InsertNewlineInQuotedContent arguments for // execCommand, both of which sound like they would be // useful here. After a quick bit of testing neither // worked out of the box, so need to investigate // further. See: // https://github.com/WebKit/webkit/blob/master/Source/WebCore/editing/EditorCommand.cpp state.breakBlockquotes(); } }, true); // Search for and remove a particular styling when we quote // text. If that style exists in the quoted text, we alter it // slightly so we don't mess with it later. let nodeList = document.querySelectorAll( "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]"); for (let i = 0; i < nodeList.length; ++i) { nodeList.item(i).setAttribute( "style", "margin: 0 0 0 40px; padding: 0px; border:none;" ); } // Focus within the HTML document document.body.focus(); this.updateFocusClass(this.bodyPart); // Set text cursor at appropriate position let cursor = document.getElementById("cursormarker"); if (cursor != null) { let range = document.createRange(); range.selectNodeContents(cursor); range.collapse(false); let selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); cursor.parentNode.removeChild(cursor); } // Enable editing and observation machinery only after // modifying the body above. this.bodyPart.contentEditable = true; if (this.signaturePart != null) { this.signaturePart.contentEditable = true; } if (this.quotePart != null) { this.quotePart.contentEditable = true; } let config = { attributes: true, childList: true, characterData: true, subtree: true }; this.bodyObserver.observe(document.body, config); // Chain up PageState.prototype.loaded.apply(this, []); }, undo: function() { document.execCommand("undo", false, null); this.checkCommandStack(); }, redo: function() { document.execCommand("redo", false, null); this.checkCommandStack(); }, saveSelection: function() { let id = (++this.nextSelectionId).toString(); this.selections.set(id, SelectionUtil.save()); return id; }, freeSelection: function(id) { this.selections.delete(id); }, insertLink: function(href, selectionId) { if (!window.getSelection().isCollapsed) { // There is currently a selection, so assume the user // knows what they are doing and just linkify it. document.execCommand("createLink", false, href); } else { SelectionUtil.restore(this.selections.get(selectionId)); document.execCommand("createLink", false, href); } }, deleteLink: function() { if (!window.getSelection().isCollapsed) { // There is currently a selection, so assume the user // knows what they are doing and just unlink it. document.execCommand("unlink", false, null); } else { let selected = SelectionUtil.getCursorElement(); if (selected != null && selected.tagName == "A") { // The current cursor element is an A, so select it // since unlink requires a range let selection = SelectionUtil.save(); SelectionUtil.selectNode(selected); document.execCommand("unlink", false, null); SelectionUtil.restore(selection); } } }, indentLine: function() { document.execCommand("indent", false, null); let nodeList = document.querySelectorAll( "blockquote[style=\"margin: 0 0 0 40px; border: none; padding: 0px;\"]" ); for (let i = 0; i < nodeList.length; ++i) { let element = nodeList.item(i); element.removeAttribute("style"); element.setAttribute("type", "cite"); } }, insertOrderedList: function() { document.execCommand("insertOrderedList", false, null); }, insertUnorderedList: function() { document.execCommand("insertUnorderedList", false, null); }, updateSignature: function(signature) { if (this.signaturePart != null) { console.log(signature); this.signaturePart.innerHTML = signature; } }, deleteQuotedMessage: function() { if (this.quotePart != null) { this.quotePart.parentNode.removeChild(this.quotePart); this.quotePart = null; } }, /** * Determines if subject or body content refers to attachments. */ containsAttachmentKeyword: function(keywordSpec, subject) { let ATTACHMENT_KEYWORDS_SUFFIX = "doc|pdf|xls|ppt|rtf|pps"; let completeKeys = new Set(keywordSpec.toLocaleLowerCase().split("|")); let suffixKeys = new Set(ATTACHMENT_KEYWORDS_SUFFIX.split("|")); // Check the subject line if (ComposerPageState.containsKeywords(subject, completeKeys, suffixKeys)) { return true; } // Check interesting body text let node = this.bodyPart.firstChild; let content = []; let breakingElements = new Set([ "BR", "P", "DIV", "BLOCKQUOTE", "TABLE", "OL", "UL", "HR" ]); while (node != null) { if (node.nodeType == Node.TEXT_NODE) { content.push(node.textContent); } else if (content.nodeType == Node.ELEMENT_NODE) { let isBreaking = breakingElements.has(node.nodeName); if (isBreaking) { content.push("\n"); } // Only include non-quoted text if (content.nodeName != "BLOCKQUOTE") { content.push(content.textContent); } } node = node.nextSibling; } return ComposerPageState.containsKeywords( content.join(""), completeKeys, suffixKeys ); }, tabOut: function() { document.execCommand( "inserthtml", false, "\t" ); }, tabIn: function() { // If there is no selection and the character before the // text cursor is tab, delete it. let selection = window.getSelection(); if (selection.isCollapsed) { selection.modify("extend", "backward", "character"); if (selection.getRangeAt(0).toString() == "\t") { document.execCommand("delete", false, null); } else { selection.collapseToEnd(); } } }, breakBlockquotes: function() { // Do this in two phases to avoid in-line mutations caused by // execCommand affecting the DOM srtcuture let count = 0; let node = SelectionUtil.getCursorElement(); while (node != document.body) { if (node.nodeName == "BLOCKQUOTE") { count++; } node = node.parentNode; } while (count > 0) { document.execCommand("outdent", false, null); count--; } }, cleanContent: function() { ComposerPageState.cleanPart(this.bodyPart, false); ComposerPageState.linkify(this.bodyPart); this.signaturePart = ComposerPageState.cleanPart(this.signaturePart, true); this.quotePart = ComposerPageState.cleanPart(this.quotePart, true); }, getHtml: function() { // Clone the message parts so we can clean them without // modifiying the DOM, needed when saving drafts. In contrast // with cleanContent above, we don't remove empty elements so // they still exist when restoring from draft let parent = document.createElement("DIV"); parent.appendChild( ComposerPageState.cleanPart(this.bodyPart.cloneNode(true), false) ); if (this.signaturePart != null) { parent.appendChild( ComposerPageState.cleanPart(this.signaturePart.cloneNode(true), false) ); } if (this.quotePart != null) { parent.appendChild( ComposerPageState.cleanPart(this.quotePart.cloneNode(true), false) ); } return parent.innerHTML; }, getText: function() { return ComposerPageState.htmlToQuotedText(document.body); }, setRichText: function(enabled) { if (enabled) { document.body.classList.remove("plain"); } else { document.body.classList.add("plain"); } }, checkCommandStack: function() { let canUndo = document.queryCommandEnabled("undo"); let canRedo = document.queryCommandEnabled("redo"); if (canUndo != this.undoEnabled || canRedo != this.redoEnabled) { this.undoEnabled = canUndo; this.redoEnabled = canRedo; window.webkit.messageHandlers.commandStackChanged.postMessage( this.undoEnabled + "," + this.redoEnabled ); } }, documentModified: function(element) { window.webkit.messageHandlers.documentModified.postMessage(null); }, selectionChanged: function() { PageState.prototype.selectionChanged.apply(this, []); let cursor = SelectionUtil.getCursorElement(); // Update cursor context if (cursor != null) { let newContext = new EditContext(cursor); if (!newContext.equals(this.cursorContext)) { this.cursorContext = newContext; window.webkit.messageHandlers.cursorContextChanged.postMessage( newContext.encode() ); } } while (cursor != null) { let parent = cursor.parentNode; if (parent == document.body) { this.updateFocusClass(cursor); break; } cursor = parent; } }, /** * Work around WebKit note yet supporting :focus-inside pseudoclass. */ updateFocusClass: function(newFocus) { if (this.focusedPart != null) { this.focusedPart.classList.remove("geary-focus"); this.focusedPart = null; } if (newFocus == this.bodyPart || newFocus == this.signaturePart || newFocus == this.quotePart) { this.focusedPart = newFocus; this.focusedPart.classList.add("geary-focus"); } }, containedInPart: function(target) { let inPart = false; for (let part of [this.bodyPart, this.quotePart, this.signaturePart]) { if (part != null && (part == target || part.contains(target))) { inPart = true; break; } } return inPart; } }; /** * Determines if any keywords are present in a string. */ ComposerPageState.containsKeywords = function(line, completeKeys, suffixKeys) { let tokens = new Set( line.toLocaleLowerCase().split(ComposerPageState.KEYWORD_SPLIT_REGEX) ); for (let key of completeKeys) { if (tokens.has(key)) { return true; } } let urlRegex = ComposerPageState.URL_REGEX; // XXX assuming all suffixes have length = 3 here. let extLen = 3; for (let token of tokens) { let extDelim = token.length - (extLen + 1); // We do care about "a.pdf", but not ".pdf" if (token.length >= extLen + 2 && token.charAt(extDelim) == ".") { let suffix = token.substring(extDelim + 1); if (suffixKeys.has(suffix)) { if (token.match(urlRegex) == null) { return true; } } } } return false; }; /** * Removes internal attributes from a composer part.. */ ComposerPageState.cleanPart = function(part, removeIfEmpty) { if (part != null) { part.removeAttribute("class"); part.removeAttribute("contenteditable"); if (removeIfEmpty && part.innerText.trim() == "") { part.parentNode.removeChild(part); part = null; } } return part; }; /** * Convert a HTML DOM tree to plain text with delineated quotes. * * Lines are delinated using LF. Quoted lines are prefixed with * `ComposerPageState.QUOTE_MARKER`, where the number of markers * indicates the depth of nesting of the quote. * * This will modify/reset the DOM, since it ultimately requires * stuffing `QUOTE_MARKER` into existing paragraphs and getting it * back out in a way that preserves the visual presentation. */ ComposerPageState.htmlToQuotedText = function(root) { // XXX It would be nice to just clone the root and modify that, or // see if we can implement this some other way so as to not modify // the DOM at all, but currently unit test show that the results // are not the same if we work on a clone, likely because of the // use of HTMLElement::innerText. Need to look into it more. let savedDoc = root.innerHTML; let blockquotes = root.querySelectorAll("blockquote"); let nbq = blockquotes.length; let bqtexts = new Array(nbq); // Get text of blockquotes and pull them out of DOM. They are // replaced with tokens deliminated with the characters // QUOTE_START and QUOTE_END (from a unicode private use block). // We need to get the text while they're still in the DOM to get // newlines at appropriate places. We go through the list of // blockquotes from the end so that we get the innermost ones // first. for (let i = nbq - 1; i >= 0; i--) { let bq = blockquotes.item(i); let text = bq.innerText; if (text.substr(-1, 1) == "\n") { text = text.slice(0, -1); } else { console.debug( " no newline at end of quote: " + text.length > 0 ? "0x" + text.codePointAt(text.length - 1).toString(16) : "empty line" ); } bqtexts[i] = text; bq.innerText = ( ComposerPageState.QUOTE_START + i.toString() + ComposerPageState.QUOTE_END ); } // Reassemble plain text out of parts, and replace non-breaking // space with regular space. let text = ComposerPageState.resolveNesting(root.innerText, bqtexts); // Reassemble DOM now we have the plain text root.innerHTML = savedDoc; return ComposerPageState.replaceNonBreakingSpace(text); }; // Linkifies "plain text" link ComposerPageState.linkify = function(node) { if (node.nodeType == Node.TEXT_NODE) { // Examine text node for something that looks like a URL let input = node.nodeValue; if (input != null) { let output = input.replace(ComposerPageState.URL_REGEX, function(url) { if (url.match(ComposerPageState.PROTOCOL_REGEX) != null) { url = "\x01" + url + "\x01"; } return url; }); if (input != output) { // We got one! Now split the text and swap in a new anchor. let parent = node.parentNode; let sibling = node.nextSibling; for (let part of output.split("\x01")) { let newNode = null; if (part.match(ComposerPageState.URL_REGEX) != null) { newNode = document.createElement("A"); newNode.href = part; newNode.innerText = part; } else { newNode = document.createTextNode(part); } parent.insertBefore(newNode, sibling); } parent.removeChild(node); } } } else { // Recurse let child = node.firstChild; while (child != null) { // Save the child and get its next sibling early since if // it does actually contain a URL, it will be removed from // the tree let target = child; child = child.nextSibling; // Don't attempt to linkify existing links if (target.nodeName != "A") { ComposerPageState.linkify(target); } } } }; ComposerPageState.resolveNesting = function(text, values) { let tokenregex = new RegExp( "(.?)" + ComposerPageState.QUOTE_START + "([0-9]*)" + ComposerPageState.QUOTE_END + "(?=(.?))", "g" ); return text.replace(tokenregex, function(match, p1, p2, p3, offset, str) { let key = new Number(p2); let prevChars = p1; let nextChars = p3; let insertNext = ""; // Make sure there's a newline before and after the quote. if (prevChars != "" && prevChars != "\n") prevChars = prevChars + "\n"; if (nextChars != "" && nextChars != "\n") insertNext = "\n"; let value = ""; if (key >= 0 && key < values.length) { let nested = ComposerPageState.resolveNesting(values[key], values); value = prevChars + ComposerPageState.quoteLines(nested) + insertNext; } else { console.error("Regex error in denesting blockquotes: Invalid key"); } return value; }); }; /** * Prefixes each NL-delineated line with `ComposerPageState.QUOTE_MARKER`. */ ComposerPageState.quoteLines = function(text) { let lines = text.split("\n"); for (let i = 0; i < lines.length; i++) lines[i] = ComposerPageState.QUOTE_MARKER + lines[i]; return lines.join("\n"); }; /** * Converts all non-breaking space chars to plain spaces. */ ComposerPageState.replaceNonBreakingSpace = function(text) { // XXX this is a separate function for unit testing - since when // running as a unit test, HTMLElement.innerText appears to not // convert   into U+00A0. return text.replace(new RegExp(" ", "g"), " "); }; /** * Encapsulates editing-related state for a specific DOM node. * * This must be kept in sync with the vala object of the same name. */ let EditContext = function() { this.init.apply(this, arguments); }; EditContext.LINK_MASK = 1 << 0; EditContext.prototype = { init: function(node) { this.context = 0; this.linkUrl = ""; if (node.nodeName == "A") { this.context |= EditContext.LINK_MASK; this.linkUrl = node.href; } let styles = window.getComputedStyle(node); let fontFamily = styles.getPropertyValue("font-family"); if (["'", "\""].indexOf(fontFamily.charAt()) != -1) { fontFamily = fontFamily.substr(1, fontFamily.length - 2); } this.fontFamily = fontFamily; this.fontSize = styles.getPropertyValue("font-size").replace("px", ""); }, equals: function(other) { return other != null && this.context == other.context && this.linkUrl == other.linkUrl && this.fontFamily == other.fontFamily && this.fontSize == other.fontSize; }, encode: function() { return [ this.context.toString(16), this.linkUrl, this.fontFamily, this.fontSize ].join(","); } }; /** * Utility methods for managing the DOM Selection. */ let SelectionUtil = { /** * Returns the element immediately under the text cursor. * * If there is a non-empty selection, the element at the end of the * selection is returned. */ getCursorElement: function() { let selection = window.getSelection(); let node = selection.focusNode; if (node != null && node.nodeType != Node.ELEMENT_NODE) { node = node.parentNode; } return node; }, /** * Modifies the selection so that it contains the given target node. */ selectNode: function(target) { let newRange = new Range(); newRange.selectNode(target); let selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(newRange); }, /** * Saves the current selection so it can be restored with `restore`. */ save: function() { let selection = window.getSelection(); var ranges = []; let len = selection.rangeCount; for (let i = 0; i < len; ++i) { ranges.push(selection.getRangeAt(i)); } return ranges; }, /** * Restores the selection saved with `save`. */ restore: function(saved) { let selection = window.getSelection(); selection.removeAllRanges(); for (let i = 0; i < saved.length; i++) { selection.addRange(saved[i]); } } }; var geary = new ComposerPageState();