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
JavaScript
/*!
*
* 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 + '​';
}
// 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