UNPKG

react-native-wordpress-editor

Version:

React Native Wrapper for WordPress Rich Text Editor. The WordPress-Editor is the text editor used in the official WordPress mobile apps to create and edit pages & posts

1,417 lines (1,156 loc) 137 kB
/*! * * ZSSRichTextEditor v1.0 * http://www.zedsaid.com * * Copyright 2013 Zed Said Studio * */ // If we are using iOS or desktop var isUsingiOS = false; var isUsingAndroid = true; // THe default callback parameter separator var defaultCallbackSeparator = '~'; const NodeName = { BLOCKQUOTE: "BLOCKQUOTE", PARAGRAPH: "P", STRONG: "STRONG", DEL: "DEL", EM: "EM", A: "A", OL: "OL", UL: "UL", LI: "LI", CODE: "CODE", SPAN: "SPAN", BR: "BR", DIV: "DIV", BODY: "BODY" }; // The editor object var ZSSEditor = {}; // These variables exist to reduce garbage (as in memory garbage) generation when typing real fast // in the editor. // ZSSEditor.caretArguments = ['yOffset=' + 0, 'height=' + 0]; ZSSEditor.caretInfo = { y: 0, height: 0 }; // Is this device an iPad ZSSEditor.isiPad; // The current selection ZSSEditor.currentSelection; // The current editing image ZSSEditor.currentEditingImage; // The current editing video ZSSEditor.currentEditingVideo; // The current editing link ZSSEditor.currentEditingLink; ZSSEditor.focusedField = null; // The objects that are enabled ZSSEditor.enabledItems = {}; ZSSEditor.editableFields = {}; ZSSEditor.lastTappedNode = null; // The default paragraph separator ZSSEditor.defaultParagraphSeparator = 'div'; // We use a MutationObserver to catch user deletions of uploading or failed media // This is only officially supported on API>18; when the WebView doesn't recognize the MutationObserver, // we fall back to the deprecated DOMNodeRemoved event ZSSEditor.mutationObserver; ZSSEditor.defaultMutationObserverConfig = { attributes: false, childList: true, characterData: false }; /** * The initializer function that must be called onLoad */ ZSSEditor.init = function() { rangy.init(); // Change a few CSS values if the device is an iPad ZSSEditor.isiPad = (navigator.userAgent.match(/iPad/i) != null); if (ZSSEditor.isiPad) { $(document.body).addClass('ipad_body'); $('#zss_field_title').addClass('ipad_field_title'); $('#zss_field_content').addClass('ipad_field_content'); } document.execCommand('insertBrOnReturn', false, false); var editor = $('div.field').each(function() { var editableField = new ZSSField($(this)); var editableFieldId = editableField.getNodeId(); ZSSEditor.editableFields[editableFieldId] = editableField; ZSSEditor.callback("callback-new-field", "id=" + editableFieldId); }); document.addEventListener("selectionchange", function(e) { ZSSEditor.currentEditingLink = null; // DRM: only do something here if the editor has focus. The reason is that when the // selection changes due to the editor loosing focus, the focusout event will not be // sent if we try to load a callback here. // if (editor.is(":focus")) { ZSSEditor.selectionChangedCallback(); ZSSEditor.sendEnabledStyles(e); var clicked = $(e.target); if (!clicked.hasClass('zs_active')) { $('img').removeClass('zs_active'); } } }, false); // Attempt to instantiate a MutationObserver. This should fail for API<19, unless the OEM of the device has // modified the WebView. If it fails, the editor will fall back to DOMNodeRemoved events. try { ZSSEditor.mutationObserver = new MutationObserver(function(mutations) { ZSSEditor.onMutationObserved(mutations);} ); } catch(e) { // no op } }; //end // MARK: - Debugging logs ZSSEditor.logMainElementSizes = function() { msg = 'Window [w:' + $(window).width() + '|h:' + $(window).height() + ']'; this.log(msg); var msg = encodeURIComponent('Viewport [w:' + window.innerWidth + '|h:' + window.innerHeight + ']'); this.log(msg); msg = encodeURIComponent('Body [w:' + $(document.body).width() + '|h:' + $(document.body).height() + ']'); this.log(msg); msg = encodeURIComponent('HTML [w:' + $('html').width() + '|h:' + $('html').height() + ']'); this.log(msg); msg = encodeURIComponent('Document [w:' + $(document).width() + '|h:' + $(document).height() + ']'); this.log(msg); }; // MARK: - Viewport Refreshing ZSSEditor.refreshVisibleViewportSize = function() { $(document.body).css('min-height', window.innerHeight + 'px'); $('#zss_field_content').css('min-height', (window.innerHeight - $('#zss_field_content').position().top) + 'px'); }; // MARK: - Fields ZSSEditor.focusFirstEditableField = function() { $('div[contenteditable=true]:first').focus(); }; ZSSEditor.formatNewLine = function(e) { var currentField = this.getFocusedField(); if (currentField.isMultiline()) { var parentBlockQuoteNode = ZSSEditor.closerParentNodeWithName('blockquote'); if (parentBlockQuoteNode) { this.formatNewLineInsideBlockquote(e); } else if (!ZSSEditor.isCommandEnabled('insertOrderedList') && !ZSSEditor.isCommandEnabled('insertUnorderedList')) { document.execCommand('formatBlock', false, 'div'); } } else { e.preventDefault(); } }; ZSSEditor.formatNewLineInsideBlockquote = function(e) { this.insertBreakTagAtCaretPosition(); e.preventDefault(); }; ZSSEditor.getField = function(fieldId) { var field = this.editableFields[fieldId]; return field; }; ZSSEditor.getFocusedField = function() { var currentField = $(this.findParentContenteditableDiv()); var currentFieldId; if (currentField) { currentFieldId = currentField.attr('id'); } if (!currentFieldId) { ZSSEditor.resetSelectionOnField('zss_field_content'); currentFieldId = 'zss_field_content'; } return this.editableFields[currentFieldId]; }; ZSSEditor.execFunctionForResult = function(methodName) { var functionArgument = "function=" + methodName; var resultArgument = "result=" + window["ZSSEditor"][methodName].apply(); ZSSEditor.callback('callback-response-string', functionArgument + defaultCallbackSeparator + resultArgument); }; // MARK: - Mutation observing /** * @brief Register a node to be tracked for modifications */ ZSSEditor.trackNodeForMutation = function(target) { if (ZSSEditor.mutationObserver != undefined) { ZSSEditor.mutationObserver.observe(target[0], ZSSEditor.defaultMutationObserverConfig); } else { // The WebView doesn't support MutationObservers - fall back to DOMNodeRemoved events target.bind("DOMNodeRemoved", function(event) { ZSSEditor.onDomNodeRemoved(event); }); } }; /** * @brief Called when the MutationObserver registers a mutation to a node it's listening to */ ZSSEditor.onMutationObserved = function(mutations) { mutations.forEach(function(mutation) { for (var i = 0; i < mutation.removedNodes.length; i++) { var removedNode = mutation.removedNodes[i]; if (!removedNode.attributes) { // Fix for https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/394 // If removedNode doesn't have an attributes property, it's not of type Node and we shouldn't proceed continue; } if (ZSSEditor.isMediaContainerNode(removedNode)) { // An uploading or failed container node was deleted manually - notify native var mediaIdentifier = ZSSEditor.extractMediaIdentifier(removedNode); ZSSEditor.sendMediaRemovedCallback(mediaIdentifier); } else if (removedNode.attributes.getNamedItem("data-wpid")) { // An uploading or failed image was deleted manually - remove its container and send the callback var mediaIdentifier = removedNode.attributes.getNamedItem("data-wpid").value; var parentRange = ZSSEditor.getParentRangeOfFocusedNode(); ZSSEditor.removeImage(mediaIdentifier); if (parentRange != null) { ZSSEditor.setRange(parentRange); } ZSSEditor.sendMediaRemovedCallback(mediaIdentifier); } else if (removedNode.attributes.getNamedItem("data-video_wpid")) { // An uploading or failed video was deleted manually - remove its container and send the callback var mediaIdentifier = removedNode.attributes.getNamedItem("data-video_wpid").value; var parentRange = ZSSEditor.getParentRangeOfFocusedNode(); ZSSEditor.removeVideo(mediaIdentifier); if (parentRange != null) { ZSSEditor.setRange(parentRange); } ZSSEditor.sendMediaRemovedCallback(mediaIdentifier); } else if (mutation.target.className == "edit-container") { // A media item wrapped in an edit container was deleted manually - remove its container // No callback in this case since it's not an uploading or failed media item var parentRange = ZSSEditor.getParentRangeOfFocusedNode(); mutation.target.remove(); if (parentRange != null) { ZSSEditor.setRange(parentRange); } ZSSEditor.getFocusedField().emptyFieldIfNoContents(); } } }); }; /** * @brief Called when a DOMNodeRemoved event is triggered for an element we're tracking * (only used when MutationObserver is unsupported by the WebView) */ ZSSEditor.onDomNodeRemoved = function(event) { if (event.target.id.length > 0) { var mediaId = ZSSEditor.extractMediaIdentifier(event.target); } else if (event.target.parentNode.id.length > 0) { var mediaId = ZSSEditor.extractMediaIdentifier(event.target.parentNode); } else { return; } ZSSEditor.sendMediaRemovedCallback(mediaId); }; // MARK: - Logging ZSSEditor.log = function(msg) { ZSSEditor.callback('callback-log', 'msg=' + msg); }; // MARK: - Callbacks ZSSEditor.domLoadedCallback = function() { ZSSEditor.callback("callback-dom-loaded"); }; ZSSEditor.selectionChangedCallback = function () { var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments(); ZSSEditor.callback('callback-selection-changed', joinedArguments); this.callback("callback-input", joinedArguments); }; ZSSEditor.callback = function(callbackScheme, callbackPath) { var url = callbackScheme + ":"; if (callbackPath) { url = url + callbackPath; } if (isUsingiOS) { ZSSEditor.callbackThroughIFrame(url); } else if (isUsingAndroid) { if (nativeState.androidApiLevel < 17) { ZSSEditor.callbackThroughIFrame(url); } else { nativeCallbackHandler.executeCallback(callbackScheme, callbackPath); } } else { console.log(url); } }; /** * @brief Executes a callback by loading it into an IFrame. * @details The reason why we're using this instead of window.location is that window.location * can sometimes fail silently when called multiple times in rapid succession. * Found here: * http://stackoverflow.com/questions/10010342/clicking-on-a-link-inside-a-webview-that-will-trigger-a-native-ios-screen-with/10080969#10080969 * * @param url The callback URL. */ ZSSEditor.callbackThroughIFrame = function(url) { var iframe = document.createElement("IFRAME"); iframe.setAttribute('sandbox', ''); iframe.setAttribute("src", url); // IMPORTANT: the IFrame was showing up as a black box below our text. By setting its borders // to be 0px transparent we make sure it's not shown at all. // // REF BUG: https://github.com/wordpress-mobile/WordPress-iOS-Editor/issues/318 // iframe.style.cssText = "border: 0px transparent;"; document.documentElement.appendChild(iframe); iframe.parentNode.removeChild(iframe); iframe = null; }; ZSSEditor.stylesCallback = function(stylesArray) { var stylesString = ''; if (stylesArray.length > 0) { stylesString = stylesArray.join(defaultCallbackSeparator); } ZSSEditor.callback("callback-selection-style", stylesString); }; // MARK: - Selection ZSSEditor.backupRange = function(){ var selection = window.getSelection(); if (selection.rangeCount < 1) { return; } var range = selection.getRangeAt(0); ZSSEditor.currentSelection = { "startContainer": range.startContainer, "startOffset": range.startOffset, "endContainer": range.endContainer, "endOffset": range.endOffset }; }; ZSSEditor.restoreRange = function(){ if (this.currentSelection) { var selection = window.getSelection(); selection.removeAllRanges(); var range = document.createRange(); range.setStart(this.currentSelection.startContainer, this.currentSelection.startOffset); range.setEnd(this.currentSelection.endContainer, this.currentSelection.endOffset); selection.addRange(range); } }; ZSSEditor.resetSelectionOnField = function(fieldId, offset) { var query = "div#" + fieldId; var field = document.querySelector(query); this.giveFocusToElement(field, offset); }; ZSSEditor.giveFocusToElement = function(element, offset) { offset = typeof offset !== 'undefined' ? offset : 0; var range = document.createRange(); range.setStart(element, offset); range.setEnd(element, offset); var selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); }; ZSSEditor.setFocusAfterElement = function(element) { var selection = window.getSelection(); if (selection.rangeCount) { var range = document.createRange(); range.setStartAfter(element); range.setEndAfter(element); selection.removeAllRanges(); selection.addRange(range); } }; ZSSEditor.getSelectedText = function() { var selection = window.getSelection(); return selection.toString(); }; ZSSEditor.selectWordAroundCursor = function() { var selection = window.getSelection(); // If there is no text selected, try to expand it to the word under the cursor if (selection.rangeCount == 1) { var range = selection.getRangeAt(0); if (range.startOffset == range.endOffset) { while (ZSSEditor.canExpandBackward(range)) { range.setStart(range.startContainer, range.startOffset - 1); } while (ZSSEditor.canExpandForward(range)) { range.setEnd(range.endContainer, range.endOffset + 1); } selection.removeAllRanges(); selection.addRange(range); } } return selection; }; ZSSEditor.canExpandBackward = function(range) { // Can't expand if focus is not a text node if (!range.endContainer.nodeType == 3) { return false; } var caretRange = range.cloneRange(); if (range.startOffset == 0) { return false; } caretRange.setStart(range.startContainer, range.startOffset - 1); caretRange.setEnd(range.startContainer, range.startOffset); if (!caretRange.toString().match(/\w/)) { return false; } return true; }; ZSSEditor.canExpandForward = function(range) { // Can't expand if focus is not a text node if (!range.endContainer.nodeType == 3) { return false; } var caretRange = range.cloneRange(); if (range.endOffset == range.endContainer.length) { return false; } caretRange.setStart(range.endContainer, range.endOffset); if (range.endOffset) { caretRange.setEnd(range.endContainer, range.endOffset + 1); } if (!caretRange.toString().match(/\w/)) { return false; } return true; }; ZSSEditor.getSelectedTextToLinkify = function() { ZSSEditor.selectWordAroundCursor(); return document.getSelection().toString(); }; ZSSEditor.getCaretArguments = function() { var caretInfo = this.getYCaretInfo(); if (caretInfo == null) { return null; } else { this.caretArguments[0] = 'yOffset=' + caretInfo.y; this.caretArguments[1] = 'height=' + caretInfo.height; return this.caretArguments; } }; ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments = function() { var joinedArguments = ZSSEditor.getJoinedCaretArguments(); var idArgument = "id=" + ZSSEditor.getFocusedField().getNodeId(); joinedArguments = idArgument + defaultCallbackSeparator + joinedArguments; return joinedArguments; }; ZSSEditor.getJoinedCaretArguments = function() { var caretArguments = this.getCaretArguments(); var joinedArguments = this.caretArguments.join(defaultCallbackSeparator); return joinedArguments; }; ZSSEditor.getCaretYPosition = function() { var selection = window.getSelection(); if (selection.rangeCount == 0) { return 0; } var range = selection.getRangeAt(0); var span = document.createElement("span"); // Ensure span has dimensions and position by // adding a zero-width space character span.appendChild( document.createTextNode("\u200b") ); range.insertNode(span); var y = span.offsetTop; var spanParent = span.parentNode; spanParent.removeChild(span); // Glue any broken text nodes back together spanParent.normalize(); return y; } ZSSEditor.getYCaretInfo = function() { var selection = window.getSelection(); var noSelectionAvailable = selection.rangeCount == 0; if (noSelectionAvailable) { return null; } var y = 0; var height = 0; var range = selection.getRangeAt(0); var needsToWorkAroundNewlineBug = (range.getClientRects().length == 0); // PROBLEM: iOS seems to have problems getting the offset for some empty nodes and return // 0 (zero) as the selection range top offset. // // WORKAROUND: To fix this problem we use a different method to obtain the Y position instead. // if (needsToWorkAroundNewlineBug) { var closerParentNode = ZSSEditor.closerParentNode(); var closerDiv = ZSSEditor.findParentContenteditableDiv(); var fontSize = $(closerParentNode).css('font-size'); var lineHeight = Math.floor(parseInt(fontSize.replace('px','')) * 1.5); y = this.getCaretYPosition(); height = lineHeight; } else { if (range.getClientRects) { var rects = range.getClientRects(); if (rects.length > 0) { // PROBLEM: some iOS versions differ in what is returned by getClientRects() // Some versions return the offset from the page's top, some other return the // offset from the visible viewport's top. // // WORKAROUND: see if the offset of the body's top is ever negative. If it is // then it means that the offset we have is relative to the body's top, and we // should add the scroll offset. // var addsScrollOffset = document.body.getClientRects()[0].top < 0; if (addsScrollOffset) { y = document.body.scrollTop; } y += rects[0].top; height = rects[0].height; } } } this.caretInfo.y = y; this.caretInfo.height = height; return this.caretInfo; }; // MARK: - Styles ZSSEditor.setBold = function() { ZSSEditor.selectWordAroundCursor(); document.execCommand('bold', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setItalic = function() { ZSSEditor.selectWordAroundCursor(); document.execCommand('italic', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setSubscript = function() { ZSSEditor.selectWordAroundCursor(); document.execCommand('subscript', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setSuperscript = function() { ZSSEditor.selectWordAroundCursor(); document.execCommand('superscript', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setStrikeThrough = function() { ZSSEditor.selectWordAroundCursor(); var commandName = 'strikeThrough'; var isDisablingStrikeThrough = ZSSEditor.isCommandEnabled(commandName); document.execCommand(commandName, false, null); // DRM: WebKit has a problem disabling strikeThrough when the tag <del> is used instead of // <strike>. The code below serves as a way to fix this issue. // var mustHandleWebKitIssue = (isDisablingStrikeThrough && ZSSEditor.isCommandEnabled(commandName)); if (mustHandleWebKitIssue && window.getSelection().rangeCount > 0) { var troublesomeNodeNames = ['del']; var selection = window.getSelection(); var range = selection.getRangeAt(0).cloneRange(); var container = range.commonAncestorContainer; var nodeFound = false; var textNode = null; while (container && !nodeFound) { nodeFound = (container && container.nodeType == document.ELEMENT_NODE && troublesomeNodeNames.indexOf(container.nodeName.toLowerCase()) > -1); if (!nodeFound) { container = container.parentElement; } } if (container) { var newObject = $(container).replaceWith(container.innerHTML); var finalSelection = window.getSelection(); var finalRange = selection.getRangeAt(0).cloneRange(); finalRange.setEnd(finalRange.startContainer, finalRange.startOffset + 1); selection.removeAllRanges(); selection.addRange(finalRange); } } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setUnderline = function() { ZSSEditor.selectWordAroundCursor(); document.execCommand('underline', false, null); ZSSEditor.sendEnabledStyles(); }; /** * @brief Turns blockquote ON or OFF for the current selection. * @details This method makes sure that the contents of the blockquotes are surrounded by the * defaultParagraphSeparator tag (by default '<p>'). This ensures parity with the web * editor. */ ZSSEditor.setBlockquote = function() { var savedSelection = rangy.saveSelection(); var selection = document.getSelection(); var range = selection.getRangeAt(0).cloneRange(); var sendStyles = false; // Make sure text being wrapped in blockquotes is inside paragraph tags // (should be <blockquote><paragraph>contents</paragraph></blockquote>) var currentHtml = ZSSEditor.focusedField.getWrappedDomNode().innerHTML; if (currentHtml.search('<' + ZSSEditor.defaultParagraphSeparator) == -1) { ZSSEditor.focusedField.setHTML(Util.wrapHTMLInTag(currentHtml, ZSSEditor.defaultParagraphSeparator)); } var ancestorElement = this.getAncestorElementForSettingBlockquote(range); if (ancestorElement) { sendStyles = true; var childNodes = this.getChildNodesIntersectingRange(ancestorElement, range); // On older APIs, the rangy selection node is targeted when turning off empty blockquotes at the start of a post // In that case, add the empty DIV element next to the rangy selection to the childNodes array to correctly // turn the blockquote off // https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/401 var nextChildNode = childNodes[childNodes.length-1].nextSibling; if (nextChildNode && nextChildNode.nodeName == NodeName.DIV && nextChildNode.innerHTML == "") { childNodes.push(nextChildNode); } if (childNodes && childNodes.length) { this.toggleBlockquoteForSpecificChildNodes(ancestorElement, childNodes); } } rangy.restoreSelection(savedSelection); // When turning off an empty blockquote in an empty post, ensure there aren't any leftover empty paragraph tags // https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/401 var currentContenteditableDiv = ZSSEditor.focusedField.getWrappedDomNode(); if (currentContenteditableDiv.children.length == 1 && currentContenteditableDiv.firstChild.innerHTML == "") { ZSSEditor.focusedField.emptyFieldIfNoContents(); } if (sendStyles) { ZSSEditor.sendEnabledStyles(); } }; ZSSEditor.removeFormating = function() { document.execCommand('removeFormat', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setHorizontalRule = function() { document.execCommand('insertHorizontalRule', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setHeading = function(heading) { var formatTag = heading; var formatBlock = document.queryCommandValue('formatBlock'); if (formatBlock.length > 0 && formatBlock.toLowerCase() == formatTag) { document.execCommand('formatBlock', false, Util.buildOpeningTag(this.defaultParagraphSeparator)); } else { document.execCommand('formatBlock', false, Util.buildOpeningTag(formatTag)); } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setParagraph = function() { var formatTag = "div"; var formatBlock = document.queryCommandValue('formatBlock'); if (formatBlock.length > 0 && formatBlock.toLowerCase() == formatTag) { document.execCommand('formatBlock', false, Util.buildOpeningTag(this.defaultParagraphSeparator)); } else { document.execCommand('formatBlock', false, Util.buildOpeningTag(formatTag)); } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.undo = function() { document.execCommand('undo', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.redo = function() { document.execCommand('redo', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setOrderedList = function() { document.execCommand('insertOrderedList', false, null); // If the insertOrderedList is no longer enabled after running execCommand, // we can assume the user is turning it off. if (!ZSSEditor.isCommandEnabled('insertOrderedList')) { ZSSEditor.completeListEditing(); } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setUnorderedList = function() { document.execCommand('insertUnorderedList', false, null); // If the insertUnorderedList is no longer enabled after running execCommand, // we can assume the user is turning it off. if (!ZSSEditor.isCommandEnabled('insertUnorderedList')) { ZSSEditor.completeListEditing(); } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setJustifyCenter = function() { document.execCommand('justifyCenter', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setJustifyFull = function() { document.execCommand('justifyFull', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setJustifyLeft = function() { document.execCommand('justifyLeft', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setJustifyRight = function() { document.execCommand('justifyRight', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setIndent = function() { document.execCommand('indent', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setOutdent = function() { document.execCommand('outdent', false, null); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.setTextColor = function(color) { ZSSEditor.selectWordAroundCursor(); ZSSEditor.restoreRange(); document.execCommand("styleWithCSS", null, true); document.execCommand('foreColor', false, color); document.execCommand("styleWithCSS", null, false); ZSSEditor.sendEnabledStyles(); // document.execCommand("removeFormat", false, "foreColor"); // Removes just foreColor }; ZSSEditor.setBackgroundColor = function(color) { ZSSEditor.selectWordAroundCursor(); ZSSEditor.restoreRange(); document.execCommand("styleWithCSS", null, true); document.execCommand('hiliteColor', false, color); document.execCommand("styleWithCSS", null, false); ZSSEditor.sendEnabledStyles(); }; /** * @brief Wraps given HTML in paragraph tags, appends a new line, and inserts it into the field * @details This method makes sure that passed HTML is wrapped in a separate paragraph. * It also appends a new opening paragraph tag and a space. This step is necessary to keep any spans or * divs in the HTML from being read by the WebView as a style and applied to all future paragraphs. */ ZSSEditor.insertHTMLWrappedInParagraphTags = function(html) { var space = '<br>'; var paragraphOpenTag = Util.buildOpeningTag(this.defaultParagraphSeparator); var paragraphCloseTag = Util.buildClosingTag(this.defaultParagraphSeparator); if (this.getFocusedField().getHTML().length == 0) { html = paragraphOpenTag + html; } // Without this line, API<19 WebView will reset the caret to the start of the document, inserting the new line // there instead of under the newly added media item if (nativeState.androidApiLevel < 19) { html = html + '&#x200b;'; } // Due to the way the WebView handles divs, we need to add a new paragraph in a separate insertion - otherwise, // the new paragraph will be nested within the existing paragraph. this.insertHTML(html); this.insertHTML(paragraphOpenTag + space + paragraphCloseTag); }; ZSSEditor.insertLink = function(url, title) { var html = '<a href="' + url + '">' + title + "</a>"; var parentBlockQuoteNode = ZSSEditor.closerParentNodeWithName('blockquote'); var currentRange = document.getSelection().getRangeAt(0); var currentNode = currentRange.startContainer; var currentNodeIsEmpty = (currentNode.innerHTML == '' || currentNode.innerHTML == '<br>'); var selectionIsAtStartOrEnd = Util.rangeIsAtStartOfParent(currentRange) || Util.rangeIsAtEndOfParent(currentRange); if (this.getFocusedField().getHTML().length == 0 || (parentBlockQuoteNode && !currentNodeIsEmpty && selectionIsAtStartOrEnd)) { // Wrap the link tag in paragraph tags when the post is empty, and also when inside a blockquote // The latter is to fix a bug with document.execCommand('insertHTML') inside a blockquote, where the div inside // the blockquote is ignored and the link tag is inserted outside it, on a new line with no wrapping div // Wrapping the link in paragraph tags makes insertHTML join it to the existing div, for some reason // We exclude being on an empty line inside a blockquote and when the selection isn't at the beginning or end // of the line, as the fix is unnecessary in both those cases and causes paragraph formatting issues html = Util.buildOpeningTag(this.defaultParagraphSeparator) + html; } this.insertHTML(html); }; ZSSEditor.updateLink = function(url, title) { ZSSEditor.restoreRange(); var currentLinkNode = ZSSEditor.lastTappedNode; if (currentLinkNode) { currentLinkNode.setAttribute("href", url); currentLinkNode.innerHTML = title; } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.unlink = function() { var savedSelection = rangy.saveSelection(); var currentLinkNode = ZSSEditor.closerParentNodeWithName('a'); if (currentLinkNode) { ZSSEditor.unwrapNode(currentLinkNode); } rangy.restoreSelection(savedSelection); ZSSEditor.sendEnabledStyles(); }; ZSSEditor.unwrapNode = function(node) { $(node).contents().unwrap(); }; ZSSEditor.quickLink = function() { var sel = document.getSelection(); var link_url = ""; var test = new String(sel); var mailregexp = new RegExp("^(.+)(\@)(.+)$", "gi"); if (test.search(mailregexp) == -1) { checkhttplink = new RegExp("^http\:\/\/", "gi"); if (test.search(checkhttplink) == -1) { checkanchorlink = new RegExp("^\#", "gi"); if (test.search(checkanchorlink) == -1) { link_url = "http://" + sel; } else { link_url = sel; } } else { link_url = sel; } } else { checkmaillink = new RegExp("^mailto\:", "gi"); if (test.search(checkmaillink) == -1) { link_url = "mailto:" + sel; } else { link_url = sel; } } var html_code = '<a href="' + link_url + '">' + sel + '</a>'; ZSSEditor.insertHTML(html_code); }; // MARK: - Blockquotes /** * @brief This method toggles blockquote for the specified child nodes. This is useful since * we can toggle blockquote either for some or ALL of the child nodes, depending on * what we need to achieve. * @details CASE 1: If the parent node is a blockquote node, the child nodes will be extracted * from it leaving the remaining siblings untouched (by splitting the parent blockquote * node in two if necessary). * CASE 2: If the parent node is NOT a blockquote node, but the first child is, the * method will make sure all child nodes that are blockquote nodes will be toggled to * non-blockquote nodes. * CASE 3: If both the parent node and the first node are non-blockquote nodes, this * method will turn all child nodes into blockquote nodes. * * @param parentNode The parent node. Can be either a blockquote or non-blockquote node. * Cannot be null. * @param nodes The child nodes. Can be any combination of blockquote and * non-blockquote nodes. Cannot be null. */ ZSSEditor.toggleBlockquoteForSpecificChildNodes = function(parentNode, nodes) { if (nodes && nodes.length > 0) { if (parentNode.nodeName == NodeName.BLOCKQUOTE) { for (var counter = 0; counter < nodes.length; counter++) { this.turnBlockquoteOffForNode(nodes[counter]); } } else { var turnOn = (nodes[0].nodeName != NodeName.BLOCKQUOTE); for (var counter = 0; counter < nodes.length; counter++) { if (turnOn) { this.turnBlockquoteOnForNode(nodes[counter]); } else { this.turnBlockquoteOffForNode(nodes[counter]); } } } } }; /** * @brief Turns blockquote off for the specified node. * * @param node The node to turn the blockquote off for. It can either be a blockquote * node (in which case it will be removed and all child nodes extracted) or * have a parent blockquote node (in which case the node will be extracted * from its parent). */ ZSSEditor.turnBlockquoteOffForNode = function(node) { if (node.nodeName == NodeName.BLOCKQUOTE) { for (var i = 0; i < node.childNodes.length; i++) { this.extractNodeFromAncestorNode(node.childNodes[i], node); } } else { if (node.parentNode.nodeName == NodeName.BLOCKQUOTE) { this.extractNodeFromAncestorNode(node, node.parentNode); } } }; /** * @brief Turns blockquote on for the specified node. * * @param node The node to turn blockquote on for. Will attempt to attach the newly * created blockquote to sibling or uncle blockquote nodes. If the node is * null or it's parent is null, this method will exit without affecting it * (this can actually be caused by this method modifying the surrounding * nodes, if those nodes are stored in an array - and thus are not notified * of DOM hierarchy changes). */ ZSSEditor.turnBlockquoteOnForNode = function(node) { if (!node || !node.parentNode) { return; } var couldJoinBlockquotes = this.joinAdjacentSiblingsOrAncestorBlockquotes(node); if (!couldJoinBlockquotes) { var blockquote = document.createElement(NodeName.BLOCKQUOTE); node.parentNode.insertBefore(blockquote, node); blockquote.appendChild(node); } }; // MARK: - Generic media ZSSEditor.isMediaContainerNode = function(node) { if (node.id === undefined) { return false; } return (node.id.search("img_container_") == 0) || (node.id.search("video_container_") == 0); }; ZSSEditor.extractMediaIdentifier = function(node) { if (node.id.search("img_container_") == 0) { return node.id.replace("img_container_", ""); } else if (node.id.search("video_container_") == 0) { return node.id.replace("video_container_", ""); } return ""; }; ZSSEditor.getMediaNodeWithIdentifier = function(mediaNodeIdentifier) { var imageNode = ZSSEditor.getImageNodeWithIdentifier(mediaNodeIdentifier); if (imageNode.length > 0) { return imageNode; } else { return ZSSEditor.getVideoNodeWithIdentifier(mediaNodeIdentifier); } }; ZSSEditor.getMediaProgressNodeWithIdentifier = function(mediaNodeIdentifier) { var imageProgressNode = ZSSEditor.getImageProgressNodeWithIdentifier(mediaNodeIdentifier); if (imageProgressNode.length > 0) { return imageProgressNode; } else { return ZSSEditor.getVideoProgressNodeWithIdentifier(mediaNodeIdentifier); } }; ZSSEditor.getMediaContainerNodeWithIdentifier = function(mediaNodeIdentifier) { var imageContainerNode = ZSSEditor.getImageContainerNodeWithIdentifier(mediaNodeIdentifier); if (imageContainerNode.length > 0) { return imageContainerNode; } else { return ZSSEditor.getVideoContainerNodeWithIdentifier(mediaNodeIdentifier); } }; /** * @brief Update the progress indicator for the media item identified with the value in progress. * * @param mediaNodeIdentifier This is a unique ID provided by the caller. * @param progress A value between 0 and 1 indicating the progress on the media upload. */ ZSSEditor.setProgressOnMedia = function(mediaNodeIdentifier, progress) { var mediaNode = this.getMediaNodeWithIdentifier(mediaNodeIdentifier); var mediaProgressNode = this.getMediaProgressNodeWithIdentifier(mediaNodeIdentifier); if (progress == 0) { mediaNode.addClass("uploading"); } // Don't allow the progress bar to move backward if (mediaNode.length == 0 || mediaProgressNode.length == 0 || mediaProgressNode.attr("value") > progress) { return; } // Revert to non-compatibility image container once image upload has begun. This centers the overlays on the image // (instead of the screen), while still circumventing the small container bug the compat class was added to fix if (progress > 0) { this.getMediaContainerNodeWithIdentifier(mediaNodeIdentifier).removeClass("compat"); } // Sometimes the progress bar can be stuck at 100% for a long time while further processing happens // From a UX perspective, it's better to just keep the progress bars at 90% until the upload is really complete // and the progress bar is removed entirely if (progress > 0.9) { return; } mediaProgressNode.attr("value", progress); }; ZSSEditor.setupOptimisticProgressUpdate = function(mediaNodeIdentifier, nCall) { setTimeout(ZSSEditor.sendOptimisticProgressUpdate, nCall * 100, mediaNodeIdentifier, nCall); }; ZSSEditor.sendOptimisticProgressUpdate = function(mediaNodeIdentifier, nCall) { if (nCall > 15) { return; } var mediaNode = ZSSEditor.getMediaNodeWithIdentifier(mediaNodeIdentifier); // Don't send progress updates to failed media if (mediaNode.length != 0 && mediaNode[0].classList.contains("failed")) { return; } ZSSEditor.setProgressOnMedia(mediaNodeIdentifier, nCall / 100); ZSSEditor.setupOptimisticProgressUpdate(mediaNodeIdentifier, nCall + 1); }; ZSSEditor.removeAllFailedMediaUploads = function() { console.log("Remove all failed media"); var failedMediaArray = ZSSEditor.getFailedMediaIdArray(); for (var i = 0; i < failedMediaArray.length; i++) { ZSSEditor.removeMedia(failedMediaArray[i]); } }; ZSSEditor.removeMedia = function(mediaNodeIdentifier) { if (this.getImageNodeWithIdentifier(mediaNodeIdentifier).length != 0) { this.removeImage(mediaNodeIdentifier); } else if (this.getVideoNodeWithIdentifier(mediaNodeIdentifier).length != 0) { this.removeVideo(mediaNodeIdentifier); } }; ZSSEditor.sendMediaRemovedCallback = function(mediaNodeIdentifier) { var arguments = ['id=' + encodeURIComponent(mediaNodeIdentifier)]; var joinedArguments = arguments.join(defaultCallbackSeparator); this.callback("callback-media-removed", joinedArguments); }; /** * @brief Marks all in-progress images as failed to upload */ ZSSEditor.markAllUploadingMediaAsFailed = function(message) { var html = ZSSEditor.getField("zss_field_content").getHTML(); var tmp = document.createElement( "div" ); var tmpDom = $( tmp ).html( html ); var matches = tmpDom.find("img.uploading"); for(var i = 0; i < matches.size(); i++) { if (matches[i].hasAttribute('data-wpid')) { var mediaId = matches[i].getAttribute('data-wpid'); ZSSEditor.markImageUploadFailed(mediaId, message); } else if (matches[i].hasAttribute('data-video_wpid')) { var videoId = matches[i].getAttribute('data-video_wpid'); ZSSEditor.markVideoUploadFailed(videoId, message); } } }; ZSSEditor.getFailedMediaIdArray = function() { var html = ZSSEditor.getField("zss_field_content").getHTML(); var tmp = document.createElement( "div" ); var tmpDom = $( tmp ).html( html ); var matches = tmpDom.find("img.failed"); var mediaIdArray = []; for (var i = 0; i < matches.size(); i++) { var mediaId = null; if (matches[i].hasAttribute("data-wpid")) { mediaId = matches[i].getAttribute("data-wpid"); } else if (matches[i].hasAttribute("data-video_wpid")) { mediaId = matches[i].getAttribute("data-video_wpid"); } if (mediaId !== null) { mediaIdArray.push(mediaId); } } return mediaIdArray; }; /** * @brief Sends a callback with a list of failed images */ ZSSEditor.getFailedMedia = function() { var mediaIdArray = ZSSEditor.getFailedMediaIdArray(); for (var i = 0; i < mediaIdArray.length; i++) { // Track pre-existing failed media nodes for manual deletion events ZSSEditor.trackNodeForMutation(this.getMediaContainerNodeWithIdentifier(mediaIdArray[i])); } var functionArgument = "function=getFailedMedia"; var joinedArguments = functionArgument + defaultCallbackSeparator + "ids=" + mediaIdArray.toString(); ZSSEditor.callback('callback-response-string', joinedArguments); }; // MARK: - Images ZSSEditor.updateImage = function(url, alt) { ZSSEditor.restoreRange(); if (ZSSEditor.currentEditingImage) { var c = ZSSEditor.currentEditingImage; c.attr('src', url); c.attr('alt', alt); } ZSSEditor.sendEnabledStyles(); }; ZSSEditor.insertImage = function(url, remoteId, alt) { var html = '<img src="' + url + '" class="wp-image-' + remoteId + ' alignnone size-full'; if (alt) { html += '" alt="' + alt; } html += '"/>'; this.insertHTMLWrappedInParagraphTags(html); this.sendEnabledStyles(); this.callback("callback-action-finished"); }; /** * @brief Inserts a local image URL. Useful for images that need to be uploaded. * @details By inserting a local image URL, we can make sure the image is shown to the user * as soon as it's selected for uploading. Once the image is successfully uploaded * the application should call replaceLocalImageWithRemoteImage(). * * @param imageNodeIdentifier This is a unique ID provided by the caller. It exists as * a mechanism to update the image node with the remote URL * when replaceLocalImageWithRemoteImage() is called. * @param localImageUrl The URL of the local image to display. Please keep in mind * that a remote URL can be used here too, since this method * does not check for that. It would be a mistake. */ ZSSEditor.insertLocalImage = function(imageNodeIdentifier, localImageUrl) { var progressIdentifier = this.getImageProgressIdentifier(imageNodeIdentifier); var imageContainerIdentifier = this.getImageContainerIdentifier(imageNodeIdentifier); if (nativeState.androidApiLevel > 18) { var imgContainerClass = 'img_container'; var progressElement = '<progress id="' + progressIdentifier + '" value=0 class="wp_media_indicator" contenteditable="false"></progress>'; } else { // Before API 19, the WebView didn't support progress tags. Use an upload overlay instead of a progress bar var imgContainerClass = 'img_container compat'; var progressElement = '<span class="upload-overlay" contenteditable="false">' + nativeState.localizedStringUploading + '</span><span class="upload-overlay-bg"></span>'; } var imgContainerStart = '<span id="' + imageContainerIdentifier + '" class="' + imgContainerClass + '" contenteditable="false">'; var imgContainerEnd = '</span>'; var image = '<img data-wpid="' + imageNodeIdentifier + '" src="' + localImageUrl + '" alt="" />'; var html = imgContainerStart + progressElement + image + imgContainerEnd; this.insertHTMLWrappedInParagraphTags(html); ZSSEditor.trackNodeForMutation(this.getImageContainerNodeWithIdentifier(imageNodeIdentifier)); this.setProgressOnMedia(imageNodeIdentifier, 0); if (nativeState.androidApiLevel > 18) { setTimeout(ZSSEditor.setupOptimisticProgressUpdate, 300, imageNodeIdentifier, 1); } this.sendEnabledStyles(); }; ZSSEditor.getImageNodeWithIdentifier = function(imageNodeIdentifier) { return $('img[data-wpid="' + imageNodeIdentifier+'"]'); }; ZSSEditor.getImageProgressIdentifier = function(imageNodeIdentifier) { return 'progress_' + imageNodeIdentifier; }; ZSSEditor.getImageProgressNodeWithIdentifier = function(imageNodeIdentifier) { return $('#'+this.getImageProgressIdentifier(imageNodeIdentifier)); }; ZSSEditor.getImageContainerIdentifier = function(imageNodeIdentifier) { return 'img_container_' + imageNodeIdentifier; }; ZSSEditor.getImageContainerNodeWithIdentifier = function(imageNodeIdentifier) { return $('#'+this.getImageContainerIdentifier(imageNodeIdentifier)); }; /** * @brief Replaces a local image URL with a remote image URL. Useful for images that have * just finished uploading. * @details The remote image can be available after a while, when uploading images. This method * allows for the remote URL to be loaded once the upload completes. * * @param imageNodeIdentifier This is a unique ID provided by the caller. It exists as * a mechanism to update the image node with the remote URL * when replaceLocalImageWithRemoteImage() is called. * @param remoteImageUrl The URL of the remote image to display. */ ZSSEditor.replaceLocalImageWithRemoteImage = function(imageNodeIdentifier, remoteImageId, remoteImageUrl) { var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier); if (imageNode.length == 0) { // even if the image is not present anymore we must do callback this.markImageUploadDone(imageNodeIdentifier); return; } var image = new Image; image.onload = function () { ZSSEditor.finishLocalImageSwap(image, imageNode, imageNodeIdentifier, remoteImageId) image.classList.add("image-loaded"); console.log("Image Loaded!"); } image.onerror = function () { // Add a remoteUrl attribute, remoteUrl and src must be swapped before publishing. image.setAttribute('remoteurl', image.src); // Try to reload the image on error. ZSSEditor.tryToReload(image, imageNode, imageNodeIdentifier, remoteImageId, 1); } image.src = remoteImageUrl; }; ZSSEditor.finishLocalImageSwap = function(image, imageNode, imageNodeIdentifier, remoteImageId) { imageNode.addClass("wp-image-" + remoteImageId); if (image.getAttribute("remoteurl")) { imageNode.attr('remoteurl', image.getAttribute("remoteurl")); } imageNode.attr('src', image.src); // Set extra attributes and classes used by WordPress imageNode.attr({'width': image.width, 'height': image.height}); imageNode.addClass("alignnone size-full"); ZSSEditor.markImageUploadDone(imageNodeIdentifier); var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments(); ZSSEditor.callback("callback-input", joinedArguments); image.onerror = null; } ZSSEditor.reloadImage = function(image, imageNode, imageNodeIdentifier, remoteImageId, nCall) { if (image.classList.contains("image-loaded")) { return; } image.onerror = ZSSEditor.tryToReload(image, imageNode, imageNodeIdentifier, remoteImageId, nCall + 1); // Force reloading by updating image src image.src = image.getAttribute("remoteurl") + "?retry=" + nCall; console.log("Reloading image:" + nCall + " - " + image.src); } ZSSEditor.tryToReload = function (image, imageNode, imageNodeIdentifier, remoteImageId, nCall) { if (nCall > 8) { // 7 tries: 22500 ms total ZSSEditor.finishLocalImageSwap(image, imageNode, imageNodeIdentifier, remoteImageId); return; } image.onerror = null; console.log("Image not loaded"); // reload the image with a variable delay: 500ms, 1000ms, 1500ms, 2000ms, etc. setTimeout(ZSSEditor.reloadImage, nCall * 500, image, imageNode, imageNodeIdentifier, remoteImageId, nCall); } /** * @brief Notifies that the image upload as finished * * @param imageNodeIdentifier The unique image ID for the uploaded image */ ZSSEditor.markImageUploadDone = function(imageNodeIdentifier) { var imageNod