blob: 52dabe1e87366b6616a6c3a8dd8133064371399b [file] [log] [blame]
/*!
*
* 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.moveCaretToCoords = function(x, y) {
if (document.caretRangeFromPoint) {
var range = document.caretRangeFromPoint(x, y);
var selection = window.getSelection();
if (range && selection.rangeCount) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
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 imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier);
if (imageNode.length == 0){
return;
}
// remove identifier attributed from image
imageNode.removeAttr('data-wpid');
// remove uploading style
imageNode.removeClass("uploading");
// Remove all extra formatting nodes for progress
if (imageNode.parent().attr("id") == this.getImageContainerIdentifier(imageNodeIdentifier)) {
// Reset id before removal to avoid triggering the manual media removal callback
imageNode.parent().attr("id", "");
imageNode.parent().replaceWith(imageNode);
}
// Wrap link around image
var link = $('<a>', { href: imageNode.attr("src") } );
imageNode.wrap(link);
// We invoke the sendImageReplacedCallback with a delay to avoid for
// it to be ignored by the webview because of the previous callback being done.
var thisObj = this;
setTimeout(function() { thisObj.sendImageReplacedCallback(imageNodeIdentifier);}, 500);
};
/**
* @brief Callbacks to native that the image upload as finished and the local url was replaced by the remote url
*
* @param imageNodeIdentifier The unique image ID for the uploaded image
*/
ZSSEditor.sendImageReplacedCallback = function( imageNodeIdentifier ) {
var arguments = ['id=' + encodeURIComponent( imageNodeIdentifier )];
var joinedArguments = arguments.join( defaultCallbackSeparator );
this.callback("callback-image-replaced", joinedArguments);
};
/**
* @brief Marks the image as failed to upload
*
* @param imageNodeIdentifier This is a unique ID provided by the caller.
* @param message A message to show to the user, overlayed on the image
*/
ZSSEditor.markImageUploadFailed = function(imageNodeIdentifier, message) {
var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier);
if (imageNode.length == 0){
return;
}
var sizeClass = '';
if ( imageNode[0].width > 480 && imageNode[0].height > 240 ) {
sizeClass = "largeFail";
} else if ( imageNode[0].width < 100 || imageNode[0].height < 100 ) {
sizeClass = "smallFail";
}
imageNode.addClass('failed');
var imageContainerNode = this.getImageContainerNodeWithIdentifier(imageNodeIdentifier);
if(imageContainerNode.length != 0){
imageContainerNode.attr("data-failed", message);
imageNode.removeClass("uploading");
imageContainerNode.addClass('failed');
imageContainerNode.addClass(sizeClass);
}
var imageProgressNode = this.getImageProgressNodeWithIdentifier(imageNodeIdentifier);
if (imageProgressNode.length != 0){
imageProgressNode.addClass('failed');
imageProgressNode.attr("value", 0);
}
// Delete the compatibility overlay if present
imageContainerNode.find("span.upload-overlay").addClass("failed");
};
/**
* @brief Unmarks the image as failed to upload
*
* @param imageNodeIdentifier This is a unique ID provided by the caller.
*/
ZSSEditor.unmarkImageUploadFailed = function(imageNodeIdentifier) {
var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier);
if (imageNode.length != 0){
imageNode.removeClass('failed');
}
var imageContainerNode = this.getImageContainerNodeWithIdentifier(imageNodeIdentifier);
if(imageContainerNode.length != 0){
imageContainerNode.removeAttr("data-failed");
imageContainerNode.removeClass('failed');
}
var imageProgressNode = this.getImageProgressNodeWithIdentifier(imageNodeIdentifier);
if (imageProgressNode.length != 0){
imageProgressNode.removeClass('failed');
}
// Display the compatibility overlay again if present
imageContainerNode.find("span.upload-overlay").removeClass("failed");
this.setProgressOnMedia(imageNodeIdentifier, 0);
if (nativeState.androidApiLevel > 18) {
setTimeout(ZSSEditor.setupOptimisticProgressUpdate, 300, imageNodeIdentifier, 1);
}
};
/**
* @brief Remove the image from the DOM.
*
* @param imageNodeIdentifier This is a unique ID provided by the caller.
*/
ZSSEditor.removeImage = function(imageNodeIdentifier) {
var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier);
if (imageNode.length != 0){
// Reset id before removal to avoid triggering the manual media removal callback
imageNode.attr("id","");
imageNode.remove();
}
// if image is inside options container we need to remove the container
var imageContainerNode = this.getImageContainerNodeWithIdentifier(imageNodeIdentifier);
if (imageContainerNode.length != 0){
imageContainerNode.remove();
}
};
/**
* @brief Inserts a video tag using the videoURL as source and posterURL as the
* image to show while video is loading.
*
* @param videoURL the url of the video
* @param posterURL the url of an image to show while the video is loading
* @param videoPressID the VideoPress ID of the video, when applicable
*
*/
ZSSEditor.insertVideo = function(videoURL, posterURL, videopressID) {
var videoId = Date.now();
var html = '<video id=' + videoId + ' webkit-playsinline src="' + videoURL + '" onclick="" controls="controls" preload="metadata"';
if (posterURL != '') {
html += ' poster="' + posterURL + '"';
}
if (videopressID != '') {
html += ' data-wpvideopress="' + videopressID + '"';
}
html += '></video>';
this.insertHTMLWrappedInParagraphTags('&#x200b;' + html);
// Wrap video in edit-container node for a permanent delete button overlay
var videoNode = $('video[id=' + videoId + ']')[0];
var selectionNode = this.applyEditContainer(videoNode);
videoNode.removeAttribute('id');
// Remove the zero-width space node (it's not needed now that the paragraph-wrapped video is in place)
var zeroWidthNode = selectionNode.previousSibling;
if (zeroWidthNode != null && zeroWidthNode.nodeType == 3) {
zeroWidthNode.parentNode.removeChild(zeroWidthNode);
}
ZSSEditor.trackNodeForMutation($(selectionNode));
this.sendEnabledStyles();
this.callback("callback-action-finished");
};
/**
* @brief Inserts a placeholder image tag for in-progress video uploads, marked with an identifier.
* @details The image shown can be the video's poster if available - otherwise the default poster image is used.
* Using an image instead of a video placeholder is a departure from iOS, necessary because the original
* method caused occasional WebView freezes on Android.
* Once the video is successfully uploaded, the application should call replaceLocalVideoWithRemoteVideo().
*
* @param videoNodeIdentifier This is a unique ID provided by the caller. It exists as
* a mechanism to update the video node with the remote URL
* when replaceLocalVideoWithRemoteVideo() is called.
* @param posterURL The URL of a poster image to display while the video is being uploaded.
*/
ZSSEditor.insertLocalVideo = function(videoNodeIdentifier, posterURL) {
var progressIdentifier = this.getVideoProgressIdentifier(videoNodeIdentifier);
var videoContainerIdentifier = this.getVideoContainerIdentifier(videoNodeIdentifier);
if (nativeState.androidApiLevel > 18) {
var videoContainerClass = 'video_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 videoContainerClass = 'video_container compat';
var progressElement = '<span class="upload-overlay" contenteditable="false">' + nativeState.localizedStringUploading
+ '</span><span class="upload-overlay-bg"></span>';
}
var videoContainerStart = '<span id="' + videoContainerIdentifier + '" class="' + videoContainerClass
+ '" contenteditable="false">';
var videoContainerEnd = '</span>';
if (posterURL == '') {
posterURL = "svg/wpposter.svg";
}
var image = '<img data-video_wpid="' + videoNodeIdentifier + '" src="' + posterURL + '" alt="" />';
var html = videoContainerStart + progressElement + image + videoContainerEnd;
this.insertHTMLWrappedInParagraphTags(html);
ZSSEditor.trackNodeForMutation(this.getVideoContainerNodeWithIdentifier(videoNodeIdentifier));
this.setProgressOnMedia(videoNodeIdentifier, 0);
if (nativeState.androidApiLevel > 18) {
setTimeout(ZSSEditor.setupOptimisticProgressUpdate, 300, videoNodeIdentifier, 1);
}
this.sendEnabledStyles();
};
ZSSEditor.getVideoNodeWithIdentifier = function(videoNodeIdentifier) {
var videoNode = $('img[data-video_wpid="' + videoNodeIdentifier+'"]');
if (videoNode.length == 0) {
videoNode = $('video[data-wpid="' + videoNodeIdentifier+'"]');
}
return videoNode;
};
ZSSEditor.getVideoProgressIdentifier = function(videoNodeIdentifier) {
return 'progress_' + videoNodeIdentifier;
};
ZSSEditor.getVideoProgressNodeWithIdentifier = function(videoNodeIdentifier) {
return $('#'+this.getVideoProgressIdentifier(videoNodeIdentifier));
};
ZSSEditor.getVideoContainerIdentifier = function(videoNodeIdentifier) {
return 'video_container_' + videoNodeIdentifier;
};
ZSSEditor.getVideoContainerNodeWithIdentifier = function(videoNodeIdentifier) {
return $('#'+this.getVideoContainerIdentifier(videoNodeIdentifier));
};
/**
* @brief Replaces the image placeholder with a video element containing the uploaded video's attributes,
* and removes the upload container.
*
* @param videoNodeIdentifier The unique id of the video upload
* @param remoteVideoUrl The URL of the remote video to display
* @param remotePosterUrl The URL of the remote poster image to display
* @param videopressID The VideoPress ID of the video, where applicable
*/
ZSSEditor.replaceLocalVideoWithRemoteVideo = function(videoNodeIdentifier, remoteVideoUrl, remotePosterUrl, videopressID) {
var imagePlaceholderNode = this.getVideoNodeWithIdentifier(videoNodeIdentifier);
if (imagePlaceholderNode.length != 0) {
var videoNode = document.createElement("video");
videoNode.setAttribute('webkit-playsinline', '');
videoNode.setAttribute('onclick', '');
videoNode.setAttribute('src', remoteVideoUrl);
videoNode.setAttribute('controls', 'controls');
videoNode.setAttribute('preload', 'metadata');
if (videopressID != '') {
videoNode.setAttribute('data-wpvideopress', videopressID);
}
videoNode.setAttribute('poster', remotePosterUrl);
// Replace upload container and placeholder image with the uploaded video node
var containerNode = imagePlaceholderNode.parent();
containerNode.replaceWith(videoNode);
}
var selectionNode = this.applyEditContainer(videoNode);
ZSSEditor.trackNodeForMutation($(selectionNode));
var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments();
ZSSEditor.callback("callback-input", joinedArguments);
// We invoke the sendVideoReplacedCallback with a delay to avoid for
// it to be ignored by the webview because of the previous callback being done.
var thisObj = this;
setTimeout(function() { thisObj.sendVideoReplacedCallback(videoNodeIdentifier);}, 500);
};
/**
* @brief Callbacks to native that the video upload as finished and the local url was replaced by the remote url
*
* @param videoNodeIdentifier the unique video ID for the uploaded Video
*/
ZSSEditor.sendVideoReplacedCallback = function( videoNodeIdentifier ) {
var arguments = ['id=' + encodeURIComponent( videoNodeIdentifier )];
var joinedArguments = arguments.join( defaultCallbackSeparator );
this.callback("callback-video-replaced", joinedArguments);
};
/**
* @brief Callbacks to native that the video upload as finished and the local url was replaced by the remote url
*
* @param videoNodeIdentifier the unique video ID for the uploaded Video
*/
ZSSEditor.sendVideoPressInfoRequest = function( videoPressID ) {
var arguments = ['id=' + encodeURIComponent( videoPressID )];
var joinedArguments = arguments.join( defaultCallbackSeparator );
this.callback("callback-videopress-info-request", joinedArguments);
};
/**
* @brief Marks the Video as failed to upload
*
* @param VideoNodeIdentifier This is a unique ID provided by the caller.
* @param message A message to show to the user, overlayed on the Video
*/
ZSSEditor.markVideoUploadFailed = function(videoNodeIdentifier, message) {
var videoNode = this.getVideoNodeWithIdentifier(videoNodeIdentifier);
if (videoNode.length == 0){
return;
}
var sizeClass = '';
if ( videoNode[0].width > 480 && videoNode[0].height > 240 ) {
sizeClass = "largeFail";
} else if ( videoNode[0].width < 100 || videoNode[0].height < 100 ) {
sizeClass = "smallFail";
}
videoNode.addClass('failed');
var videoContainerNode = this.getVideoContainerNodeWithIdentifier(videoNodeIdentifier);
if(videoContainerNode.length != 0){
videoContainerNode.attr("data-failed", message);
videoNode.removeClass("uploading");
videoContainerNode.addClass('failed');
videoContainerNode.addClass(sizeClass);
}
var videoProgressNode = this.getVideoProgressNodeWithIdentifier(videoNodeIdentifier);
if (videoProgressNode.length != 0){
videoProgressNode.addClass('failed');
videoProgressNode.attr("value", 0);
}
// Delete the compatibility overlay if present
videoContainerNode.find("span.upload-overlay").addClass("failed");
};
/**
* @brief Unmarks the Video as failed to upload
*
* @param VideoNodeIdentifier This is a unique ID provided by the caller.
*/
ZSSEditor.unmarkVideoUploadFailed = function(videoNodeIdentifier) {
var videoNode = this.getVideoNodeWithIdentifier(videoNodeIdentifier);
if (videoNode.length != 0){
videoNode.removeClass('failed');
}
var videoContainerNode = this.getVideoContainerNodeWithIdentifier(videoNodeIdentifier);
if(videoContainerNode.length != 0){
videoContainerNode.removeAttr("data-failed");
videoContainerNode.removeClass('failed');
}
var videoProgressNode = this.getVideoProgressNodeWithIdentifier(videoNodeIdentifier);
if (videoProgressNode.length != 0){
videoProgressNode.removeClass('failed');
}
// Display the compatibility overlay again if present
videoContainerNode.find("span.upload-overlay").removeClass("failed");
this.setProgressOnMedia(videoNodeIdentifier, 0);
if (nativeState.androidApiLevel > 18) {
setTimeout(ZSSEditor.setupOptimisticProgressUpdate, 300, videoNodeIdentifier, 1);
}
};
/**
* @brief Remove the Video from the DOM.
*
* @param videoNodeIdentifier This is a unique ID provided by the caller.
*/
ZSSEditor.removeVideo = function(videoNodeIdentifier) {
var videoNode = this.getVideoNodeWithIdentifier(videoNodeIdentifier);
if (videoNode.length != 0){
videoNode.remove();
}
// if Video is inside options container we need to remove the container
var videoContainerNode = this.getVideoContainerNodeWithIdentifier(videoNodeIdentifier);
if (videoContainerNode.length != 0){
// Reset id before removal to avoid triggering the manual media removal callback
videoContainerNode.attr("id","");
videoContainerNode.remove();
}
};
/**
* @brief Wrap the video in an edit-container with a delete button overlay.
*/
ZSSEditor.applyEditContainer = function(videoNode) {
var containerHtml = '<span class="edit-container" contenteditable="false"><span class="delete-overlay"></span></span>';
videoNode.insertAdjacentHTML('beforebegin', containerHtml);
var selectionNode = videoNode.previousSibling;
selectionNode.appendChild(videoNode);
return selectionNode;
}
ZSSEditor.replaceVideoPressVideosForShortcode = function ( html) {
// call methods to restore any transformed content from its visual presentation to its source code.
var regex = /<video[^>]*data-wpvideopress="([\s\S]+?)"[^>]*>*<\/video>/g;
var str = html.replace( regex, ZSSEditor.removeVideoPressVisualFormattingCallback );
return str;
}
ZSSEditor.replaceVideosForShortcode = function ( html) {
var regex = /<video(?:(?!data-wpvideopress).)*><\/video>/g;
var str = html.replace( regex, ZSSEditor.removeVideoVisualFormattingCallback );
return str;
}
ZSSEditor.removeVideoContainers = function(html) {
var containerRegex = /<span class="edit-container" contenteditable="false">(?:<span class="delete-overlay"[^<>]*><\/span>)?(\[[^<>]*)<\/span>/g;
var str = html.replace(containerRegex, ZSSEditor.removeVideoContainerCallback);
return str;
}
ZSSEditor.removeVideoPressVisualFormattingCallback = function( match, content ) {
return "[wpvideo " + content + "]";
}
ZSSEditor.removeVideoVisualFormattingCallback = function( match, content ) {
var videoElement = $.parseHTML(match)[0];
// Remove editor playback attributes
videoElement.removeAttribute("onclick");
videoElement.removeAttribute("controls");
videoElement.removeAttribute("webkit-playsinline");
if (videoElement.getAttribute("preload") == "metadata") {
// The "metadata" setting is the WP default and is usually automatically stripped from the shortcode.
// If it's present, it was probably set by this editor and we should remove it. Even if it wasn't, removing it
// won't affect anything as it's the default setting for the preload attribute.
videoElement.removeAttribute("preload");
}
// If filetype attributes exist, the src attribute wasn't there originally and we should remove it
for (var i = 0; i < Formatter.videoShortcodeFormats.length; i++) {
var format = Formatter.videoShortcodeFormats[i];
if (videoElement.hasAttribute(format)) {
videoElement.removeAttribute("src");
break;
}
}
var shortcode = videoElement.outerHTML.replace(/</g, "[");
shortcode = shortcode.replace(/>/g, "]");
return shortcode;
}
ZSSEditor.removeVideoContainerCallback = function( match, content ) {
return content;
}
/**
* @brief Sets the VideoPress video URL and poster URL on a video tag.
* @details When switching from HTML to visual mode, wpvideo shortcodes are replaced by video tags.
* A request is sent using ZSSEditor.sendVideoPressInfoRequest() to obtain the video url matching each
* wpvideo shortcode. This function must be called to set the url for each videopress id.
*
* @param videopressID VideoPress identifier of the video.
* @param videoURL URL of the video file to display.
* @param posterURL URL of the poster image to display
*/
ZSSEditor.setVideoPressLinks = function(videopressID, videoURL, posterURL ) {
var videoNode = $('video[data-wpvideopress="' + videopressID + '"]');
if (videoNode.length == 0) {
return;
}
// It's safest to drop the onError now, to avoid endless calls if the video can't be loaded
// Even if sendVideoPressInfoRequest failed, it's still possible to request a reload by tapping the video
videoNode.attr('onError', '');
if (videoURL.length == 0) {
return;
}
videoNode.attr('src', videoURL);
videoNode.attr('controls', '');
videoNode.attr('poster', posterURL);
var thisObj = this;
videoNode.load();
};
/**
* @brief Stops all video of playing
*
*/
ZSSEditor.pauseAllVideos = function () {
$('video').each(function() {
this.pause();
});
}
ZSSEditor.clearCurrentEditingImage = function() {
ZSSEditor.currentEditingImage = null;
};
/**
* @brief Updates the currently selected image, replacing its markup with
* new markup based on the specified meta data string.
*
* @param imageMetaString A JSON string representing the updated meta data.
*/
ZSSEditor.updateCurrentImageMeta = function( imageMetaString ) {
if ( !ZSSEditor.currentEditingImage ) {
return;
}
var imageMeta = JSON.parse( imageMetaString );
var html = ZSSEditor.createImageFromMeta( imageMeta );
// Insert the updated html and remove the outdated node.
// This approach is preferred to selecting the current node via a range,
// and then replacing it when calling insertHTML. The insertHTML call can,
// in certain cases, modify the current and inserted markup depending on what
// elements surround the targeted node. This approach is safer.
var node = ZSSEditor.findImageCaptionNode( ZSSEditor.currentEditingImage );
var parent = node.parentNode;
node.insertAdjacentHTML( 'afterend', html );
// Use {node}.{parent}.removeChild() instead of {node}.remove(), since Android API<19 doesn't support Node.remove()
node.parentNode.removeChild(node);
ZSSEditor.currentEditingImage = null;
ZSSEditor.setFocusAfterElement(parent);
}
ZSSEditor.applyImageSelectionFormatting = function( imageNode ) {
var node = ZSSEditor.findImageCaptionNode( imageNode );
var overlay = '<span class="edit-overlay" contenteditable="false"><span class="edit-icon"></span>'
+ '<span class="edit-content">' + nativeState.localizedStringEdit + '</span></span>';
var sizeClass = "";
if ( imageNode.width < 100 || imageNode.height < 100 ) {
sizeClass = " small";
} else {
overlay = '<span class="delete-overlay" contenteditable="false"></span>' + overlay;
}
if (document.body.style.filter == null) {
// CSS Filters (including blur) are not supported
// Use dark semi-transparent background for edit overlay instead of blur in this case
overlay = overlay + '<div class="edit-overlay-bg"></div>';
}
var html = '<span class="edit-container' + sizeClass + '">' + overlay + '</span>';
node.insertAdjacentHTML( 'beforebegin', html );
var selectionNode = node.previousSibling;
selectionNode.appendChild( node );
this.trackNodeForMutation($(selectionNode));
return selectionNode;
}
ZSSEditor.removeImageSelectionFormatting = function( imageNode ) {
var node = ZSSEditor.findImageCaptionNode( imageNode );
if ( !node.parentNode || node.parentNode.className.indexOf( "edit-container" ) == -1 ) {
return;
}
var parentNode = node.parentNode;
var container = parentNode.parentNode;
if (container != null) {
container.insertBefore( node, parentNode );
// Use {node}.{parent}.removeChild() instead of {node}.remove(), since Android API<19 doesn't support Node.remove()
container.removeChild(parentNode);
}
}
ZSSEditor.removeImageSelectionFormattingFromHTML = function( html ) {
var tmp = document.createElement( "div" );
var tmpDom = $( tmp ).html( html );
var matches = tmpDom.find( "span.edit-container img" );
if ( matches.length == 0 ) {
return html;
}
for ( var i = 0; i < matches.length; i++ ) {
ZSSEditor.removeImageSelectionFormatting( matches[i] );
}
return tmpDom.html();
}
ZSSEditor.removeImageRemoteUrl = function(html) {
var tmp = document.createElement("div");
var tmpDom = $(tmp).html(html);
var matches = tmpDom.find("img");
if (matches.length == 0) {
return html;
}
for (var i = 0; i < matches.length; i++) {
if (matches[i].getAttribute('remoteurl')) {
if (matches[i].parentNode && matches[i].parentNode.href === matches[i].src) {
matches[i].parentNode.href = matches[i].getAttribute('remoteurl')
}
matches[i].src = matches[i].getAttribute('remoteurl');
matches[i].removeAttribute('remoteurl');
}
}
return tmpDom.html();
}
/**
* @brief Finds all related caption nodes for the specified image node.
*
* @param imageNode An image node in the DOM to inspect.
*/
ZSSEditor.findImageCaptionNode = function( imageNode ) {
var node = imageNode;
if ( node.parentNode && node.parentNode.nodeName === 'A' ) {
node = node.parentNode;
}
if ( node.parentNode && node.parentNode.className.indexOf( 'wp-caption' ) != -1 ) {
node = node.parentNode;
}
if ( node.parentNode && (node.parentNode.className.indexOf( 'wp-temp' ) != -1 ) ) {
node = node.parentNode;
}
return node;
}
/**
* Modified from wp-includes/js/media-editor.js
* see `image`
*
* @brief Construct html markup for an image, and optionally a link an caption shortcode.
*
* @param props A dictionary of properties used to compose the markup. See comments in extractImageMeta.
*
* @return Returns the html mark up as a string
*/
ZSSEditor.createImageFromMeta = function( props ) {
var img = {},
options, classes, shortcode, html;
classes = props.classes || [];
if ( ! ( classes instanceof Array ) ) {
classes = classes.split( ' ' );
}
_.extend( img, _.pick( props, 'width', 'height', 'alt', 'src', 'title' ) );
// Only assign the align class to the image if we're not printing
// a caption, since the alignment is sent to the shortcode.
if ( props.align && ! props.caption ) {
classes.push( 'align' + props.align );
}
if ( props.size ) {
classes.push( 'size-' + props.size );
}
if ( props.attachment_id ) {
classes.push( 'wp-image-' + props.attachment_id );
}
img['class'] = _.compact( classes ).join(' ');
// Generate `img` tag options.
options = {
tag: 'img',
attrs: img,
single: true
};
// Generate the `a` element options, if they exist.
if ( props.linkUrl ) {
options = {
tag: 'a',
attrs: {
href: props.linkUrl
},
content: options
};
if ( props.linkClassName ) {
options.attrs.class = props.linkClassName;
}
if ( props.linkRel ) {
options.attrs.rel = props.linkRel;
}
if ( props.linkTargetBlank ) { // expects a boolean
options.attrs.target = "_blank";
}
}
html = wp.html.string( options );
// Generate the caption shortcode.
if ( props.caption ) {
shortcode = {};
if ( img.width ) {
shortcode.width = img.width;
}
if ( props.captionId ) {
shortcode.id = props.captionId;
}
if ( props.align ) {
shortcode.align = 'align' + props.align;
} else {
shortcode.align = 'alignnone';
}
if (props.captionClassName) {
shortcode.class = props.captionClassName;
}
html = wp.shortcode.string({
tag: 'caption',
attrs: shortcode,
content: html + props.caption
});
html = Formatter.applyVisualFormatting( html );
}
return html;
};
/**
* Modified from wp-includes/js/tinymce/plugins/wpeditimage/plugin.js
* see `extractImageData`
*
* @brief Extracts properties and meta data from an image, and optionally its link and caption.
*
* @param imageNode An image node in the DOM to inspect.
*
* @return Returns an object containing the extracted properties and meta data.
*/
ZSSEditor.extractImageMeta = function( imageNode ) {
var classes, extraClasses, metadata, captionBlock, caption, link, width, height,
captionClassName = [],
isIntRegExp = /^\d+$/;
// Default attributes. All values are strings, except linkTargetBlank
metadata = {
align: 'none', // Accepted values: center, left, right or empty string.
alt: '', // Image alt attribute
attachment_id: '', // Numeric attachment id of the image in the site's media library
caption: '', // The text of the caption for the image (if any)
captionClassName: '', // The classes for the caption shortcode (if any).
captionId: '', // The caption shortcode's ID attribute. The numeric value should match the value of attachment_id
classes: '', // The class attribute for the image. Does not include editor generated classes
height: '', // The image height attribute
linkClassName: '', // The class attribute for the link
linkRel: '', // The rel attribute for the link (if any)
linkTargetBlank: false, // true if the link should open in a new window.
linkUrl: '', // The href attribute of the link
size: 'custom', // Accepted values: custom, medium, large, thumbnail, or empty string
src: '', // The src attribute of the image
title: '', // The title attribute of the image (if any)
width: '', // The image width attribute
naturalWidth:'', // The natural width of the image.
naturalHeight:'' // The natural height of the image.
};
// populate metadata with values of matched attributes
metadata.src = $( imageNode ).attr( 'src' ) || '';
metadata.alt = $( imageNode ).attr( 'alt' ) || '';
metadata.title = $( imageNode ).attr( 'title' ) || '';
metadata.naturalWidth = imageNode.naturalWidth;
metadata.naturalHeight = imageNode.naturalHeight;
width = $(imageNode).attr( 'width' );
height = $(imageNode).attr( 'height' );
if ( ! isIntRegExp.test( width ) || parseInt( width, 10 ) < 1 ) {
width = imageNode.naturalWidth || imageNode.width;
}
if ( ! isIntRegExp.test( height ) || parseInt( height, 10 ) < 1 ) {
height = imageNode.naturalHeight || imageNode.height;
}
metadata.width = width;
metadata.height = height;
classes = imageNode.className.split( /\s+/ );
extraClasses = [];
$.each( classes, function( index, value ) {
if ( /^wp-image/.test( value ) ) {
metadata.attachment_id = parseInt( value.replace( 'wp-image-', '' ), 10 );
} else if ( /^align/.test( value ) ) {
metadata.align = value.replace( 'align', '' );
} else if ( /^size/.test( value ) ) {
metadata.size = value.replace( 'size-', '' );
} else {
extraClasses.push( value );
}
} );
metadata.classes = extraClasses.join( ' ' );
// Extract caption
var captionMeta = ZSSEditor.captionMetaForImage( imageNode )
if (captionMeta.caption != '') {
metadata = $.extend( metadata, captionMeta );
}
// Extract linkTo
if ( imageNode.parentNode && imageNode.parentNode.nodeName === 'A' ) {
link = imageNode.parentNode;
metadata.linkClassName = link.className;
metadata.linkRel = $( link ).attr( 'rel' ) || '';
metadata.linkTargetBlank = $( link ).attr( 'target' ) === '_blank' ? true : false;
metadata.linkUrl = $( link ).attr( 'href' ) || '';
}
return metadata;
};
/**
* @brief Extracts the caption shortcode for an image.
*
* @param imageNode An image node in the DOM to inspect.
*
* @return Returns a shortcode match (if any) for the passed image node.
* See shortcode.js::next for details
*/
ZSSEditor.getCaptionForImage = function( imageNode ) {
var node = ZSSEditor.findImageCaptionNode( imageNode );
// Ensure we're working with the formatted caption
if ( node.className.indexOf( 'wp-temp' ) == -1 ) {
return;
}
var html = node.outerHTML;
html = ZSSEditor.removeVisualFormatting( html );
return wp.shortcode.next( "caption", html, 0 );
};
/**
* @brief Extracts meta data for the caption (if any) for the passed image node.
*
* @param imageNode An image node in the DOM to inspect.
*
* @return Returns an object containing the extracted meta data.
* See shortcode.js::next or details
*/
ZSSEditor.captionMetaForImage = function( imageNode ) {
var attrs,
meta = {
align: '',
caption: '',
captionClassName: '',
captionId: ''
};
var caption = ZSSEditor.getCaptionForImage( imageNode );
if ( !caption ) {
return meta;
}
attrs = caption.shortcode.attrs.named;
if ( attrs.align ) {
meta.align = attrs.align.replace( 'align', '' );
}
if ( attrs.class ) {
meta.captionClassName = attrs.class;
}
if ( attrs.id ) {
meta.captionId = attrs.id;
}
meta.caption = caption.shortcode.content.substr( caption.shortcode.content.lastIndexOf( ">" ) + 1 );
return meta;
}
/**
* @brief Removes custom visual formatting for caption shortcodes.
*
* @param html The markup to process
*
* @return The html with formatted captions restored to the original shortcode markup.
*/
ZSSEditor.removeCaptionFormatting = function( html ) {
// call methods to restore any transformed content from its visual presentation to its source code.
var regex = /<label class="wp-temp" data-wp-temp="caption"[^>]*>([\s\S]+?)<\/label>/g;
var str = html.replace( regex, ZSSEditor.removeCaptionFormattingCallback );
return str;
}
ZSSEditor.removeCaptionFormattingCallback = function( match, content ) {
// TODO: check is a visual temp node
var out = '';
if ( content.indexOf('<img ') === -1 ) {
// Broken caption. The user managed to drag the image out?
// Try to return the caption text as a paragraph.
out = content.match( /\s*<span [^>]*>([\s\S]+?)<\/span>/gi );
if ( out && out[1] ) {
return '<p>' + out[1] + '</p>';
}
return '';
}
out = content.replace( /\s*<span ([^>]*)>([\s\S]+?)<\/span>/gi, function( ignoreMatch, attrStr, content ) {
if ( ! content ) {
return '';
}
var id, classes, align, width, attrs = {};
width = attrStr.match( /data-caption-width="([0-9]*)"/ );
width = ( width && width[1] ) ? width[1] : '';
if ( width ) {
attrs.width = width;
}
id = attrStr.match( /data-caption-id="([^"]*)"/ );
id = ( id && id[1] ) ? id[1] : '';
if ( id ) {
attrs.id = id;
}
classes = attrStr.match( /data-caption-class="([^"]*)"/ );
classes = ( classes && classes[1] ) ? classes[1] : '';
if ( classes ) {
attrs.class = classes;
}
align = attrStr.match( /data-caption-align="([^"]*)"/ );
align = ( align && align[1] ) ? align[1] : '';
if ( align ) {
attrs.align = align;
}
var options = {
'tag':'caption',
'attrs':attrs,
'type':'closed',
'content':content
};
return wp.shortcode.string( options );
});
return out;
}
// MARK: - Galleries
ZSSEditor.insertGallery = function( imageIds, type, columns ) {
var shortcode;
if (type) {
shortcode = '[gallery type="' + type + '" ids="' + imageIds + '"]';
} else {
shortcode = '[gallery columns="' + columns + '" ids="' + imageIds + '"]';
}
this.insertHTMLWrappedInParagraphTags(shortcode);
}
ZSSEditor.insertLocalGallery = function( placeholderId ) {
var container = '<span id="' + placeholderId + '" class="gallery_container">['
+ nativeState.localizedStringUploadingGallery + ']</span>';
this.insertHTMLWrappedInParagraphTags(container);
}
ZSSEditor.replacePlaceholderGallery = function( placeholderId, imageIds, type, columns ) {
var span = 'span#' + placeholderId + '.gallery_container';
var shortcode;
if (type) {
shortcode = '[gallery type="' + type + '" ids="' + imageIds + '"]';
} else {
shortcode = '[gallery columns="' + columns + '" ids="' + imageIds + '"]';
}
$(span).replaceWith(shortcode);
}
// MARK: - Commands
/**
* @brief Removes editor specific visual formatting
*
* @param html The markup to remove formatting
*
* @return Returns the string with the visual formatting removed.
*/
ZSSEditor.removeVisualFormatting = function( html ) {
var str = html;
str = ZSSEditor.removeImageRemoteUrl( str );
str = ZSSEditor.removeImageSelectionFormattingFromHTML( str );
str = ZSSEditor.removeCaptionFormatting( str );
str = ZSSEditor.replaceVideoPressVideosForShortcode( str );
str = ZSSEditor.replaceVideosForShortcode( str );
str = ZSSEditor.removeVideoContainers( str );
// More tag
str = str.replace(/<hr class="more-tag" wp-more-data="(.*?)">/igm, "<!--more$1-->")
str = str.replace(/<hr class="nextpage-tag">/igm, "<!--nextpage-->")
return str;
};
ZSSEditor.insertHTML = function(html) {
document.execCommand('insertHTML', false, html);
this.sendEnabledStyles();
};
ZSSEditor.insertText = function(text, reformatVisually) {
var focusedField = ZSSEditor.getFocusedField();
if (focusedField.isMultiline() && focusedField.getHTML().length == 0) {
// when the text field is empty, we need to add an initial paragraph tag
text = Util.wrapHTMLInTag(text, ZSSEditor.defaultParagraphSeparator);
}
if (reformatVisually) {
text = wp.loadText(text);
}
ZSSEditor.insertHTML(text);
};
ZSSEditor.isCommandEnabled = function(commandName) {
return document.queryCommandState(commandName);
};
ZSSEditor.sendEnabledStyles = function(e) {
var items = [];
var focusedField = this.getFocusedField();
if (!focusedField.hasNoStyle) {
// Find all relevant parent tags
var parentTags = ZSSEditor.parentTags();
if (parentTags != null) {
for (var i = 0; i < parentTags.length; i++) {
var currentNode = parentTags[i];
if (currentNode.nodeName.toLowerCase() == 'a') {
ZSSEditor.currentEditingLink = currentNode;
var title = encodeURIComponent(currentNode.text);
var href = encodeURIComponent(currentNode.href);
items.push('link-title:' + title);
items.push('link:' + href);
} else if (currentNode.nodeName == NodeName.BLOCKQUOTE) {
items.push('blockquote');
}
}
}
if (ZSSEditor.isCommandEnabled('bold')) {
items.push('bold');
}
if (ZSSEditor.isCommandEnabled('createLink')) {
items.push('createLink');
}
if (ZSSEditor.isCommandEnabled('italic')) {
items.push('italic');
}
if (ZSSEditor.isCommandEnabled('subscript')) {
items.push('subscript');
}
if (ZSSEditor.isCommandEnabled('superscript')) {
items.push('superscript');
}
if (ZSSEditor.isCommandEnabled('strikeThrough')) {
items.push('strikeThrough');
}
if (ZSSEditor.isCommandEnabled('underline')) {
var isUnderlined = false;
// DRM: 'underline' gets highlighted if it's inside of a link... so we need a special test
// in that case.
if (!ZSSEditor.currentEditingLink) {
items.push('underline');
}
}
if (ZSSEditor.isCommandEnabled('insertOrderedList')) {
items.push('orderedList');
}
if (ZSSEditor.isCommandEnabled('insertUnorderedList')) {
items.push('unorderedList');
}
if (ZSSEditor.isCommandEnabled('justifyCenter')) {
items.push('justifyCenter');
}
if (ZSSEditor.isCommandEnabled('justifyFull')) {
items.push('justifyFull');
}
if (ZSSEditor.isCommandEnabled('justifyLeft')) {
items.push('justifyLeft');
}
if (ZSSEditor.isCommandEnabled('justifyRight')) {
items.push('justifyRight');
}
if (ZSSEditor.isCommandEnabled('insertHorizontalRule')) {
items.push('horizontalRule');
}
var formatBlock = document.queryCommandValue('formatBlock');
if (formatBlock.length > 0) {
items.push(formatBlock);
}
// Use jQuery to figure out those that are not supported
if (typeof(e) != "undefined") {
// The target element
var t = $(e.target);
var nodeName = e.target.nodeName.toLowerCase();
// Background Color
try
{
var bgColor = t.css('backgroundColor');
if (bgColor && bgColor.length != 0 && bgColor != 'rgba(0, 0, 0, 0)' && bgColor != 'rgb(0, 0, 0)' && bgColor != 'transparent') {
items.push('backgroundColor');
}
}
catch(e)
{
// DRM: I had to add these stupid try-catch blocks to solve an issue with t.css throwing
// exceptions for no reason.
}
// Text Color
try
{
var textColor = t.css('color');
if (textColor && textColor.length != 0 && textColor != 'rgba(0, 0, 0, 0)' && textColor != 'rgb(0, 0, 0)' && textColor != 'transparent') {
items.push('textColor');
}
}
catch(e)
{
// DRM: I had to add these stupid try-catch blocks to solve an issue with t.css throwing
// exceptions for no reason.
}
// Image
if (nodeName == 'img') {
ZSSEditor.currentEditingImage = t;
items.push('image:'+t.attr('src'));
if (t.attr('alt') !== undefined) {
items.push('image-alt:'+t.attr('alt'));
}
}
}
}
ZSSEditor.stylesCallback(items);
};
ZSSEditor.storeInlineStylesAsFunctions = function() {
var styles = [];
if (ZSSEditor.isCommandEnabled('bold')) {
styles.push(ZSSEditor.setBold);
}
if (ZSSEditor.isCommandEnabled('italic')) {
styles.push(ZSSEditor.setItalic);
}
if (ZSSEditor.isCommandEnabled('strikeThrough')) {
styles.push(ZSSEditor.setStrikeThrough);
}
if (ZSSEditor.isCommandEnabled('underline')) {
styles.push(ZSSEditor.setUnderline);
}
if (ZSSEditor.isCommandEnabled('subscript')) {
styles.push(ZSSEditor.setSubscript);
}
if (ZSSEditor.isCommandEnabled('superscript')) {
styles.push(ZSSEditor.setSuperscript);
}
return styles;
};
// MARK: - Commands: High Level Editing
/**
* @brief Inserts a br tag at the caret position.
*/
ZSSEditor.insertBreakTagAtCaretPosition = function() {
// iOS IMPORTANT: we were adding <br> tags with range.insertNode() before using
// this method. Unfortunately this was causing issues with the first <br> tag
// being completely ignored under iOS:
//
// https://bugs.webkit.org/show_bug.cgi?id=23474
//
// The following line seems to work fine under iOS, so please be careful if this
// needs to be changed for any reason.
//
document.execCommand("insertLineBreak");
};
// MARK: - Advanced Node Manipulation
/**
* @brief Given the specified node, find the previous node in the DOM.
*
* @param node The node used as a starting point for the "previous" search.
*
* @returns If a previous node is found, it will be returned otherwise null;
*/
ZSSEditor.previousNode = function(node) {
if (!node) {
return null;
}
var previous = node.previousSibling;
if (previous) {
node = previous;
while (node.hasChildNodes()) {
node = node.lastChild;
}
return node;
}
var parent = node.parentNode;
if (parent && parent.hasChildNodes()) {
return parent;
}
return null;
};
/**
* @brief Ends the editing of a list (either UL or OL).
*
* @details This function finds the list node, inserts a new paragraph as a sibling to the list node
* then scrubs any <br> tags created as part of the insertParagraph command.
*/
ZSSEditor.completeListEditing = function() {
// Get the current selection
var sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
var range = sel.getRangeAt(0);
var node = range.startContainer;
if (node.hasChildNodes() && range.startOffset > 0) {
node = node.childNodes[range.startOffset - 1];
}
// Walk backwards through the DOM until we find an ul or ol
while (node) {
if (node.nodeType == 1 &&
(node.tagName.toUpperCase() == NodeName.UL
|| node.tagName.toUpperCase() == NodeName.OL)) {
var focusedNode = document.getSelection().getRangeAt(0).startContainer;
if (focusedNode.nodeType == 3) {
// If the focused node is a text node, the list item was not empty when toggled off
// Wrap the text in a div and attach it as a sibling to the div wrapping the list
var parentParagraph = focusedNode.parentNode;
var paragraph = document.createElement('div');
paragraph.appendChild(focusedNode);
parentParagraph.insertAdjacentElement('afterEnd', paragraph);
ZSSEditor.giveFocusToElement(paragraph, 1);
} else {
// Attach a new paragraph node as a sibling to the parent node
document.execCommand('insertParagraph', false);
}
// Remove any superfluous <br> tags that are created
ZSSEditor.scrubBRFromNode(node.parentNode);
break;
}
node = ZSSEditor.previousNode(node);
}
}
}
/**
* @brief Given the specified node, remove all instances of <br> from it and it's children.
*
* @param node The node to scrub
*/
ZSSEditor.scrubBRFromNode = function(node) {
if (!node) {
return;
}
$(node).contents().filter(NodeName.BR).remove();
};
/**
* @brief Extracts a node from a parent node, and from all nodes in between the two.
*/
ZSSEditor.extractNodeFromAncestorNode = function(descendant, ancestor) {
while (ancestor.contains(descendant)) {
this.extractNodeFromParent(descendant);
break;
}
};
/**
* @brief Extract the specified node from its direct parent node.
* @details If the node has siblings, before or after it, the parent node is split accordingly
* into two new clones of it.
*/
ZSSEditor.extractNodeFromParent = function(node) {
var parentNode = node.parentNode;
var grandParentNode = parentNode.parentNode;
var clonedParentForPreviousSiblings = null;
var clonedParentForNextSiblings = null;
if (node.previousSibling != null) {
var clonedParentForPreviousSiblings = parentNode.cloneNode();
while (parentNode.firstChild != node) {
clonedParentForPreviousSiblings.appendChild(parentNode.firstChild);
}
}
if (node.nextSibling != null) {
var clonedParentForNextSiblings = parentNode.cloneNode();
while (node.nextSibling != null) {
clonedParentForNextSiblings.appendChild(node.nextSibling);
}
}
if (clonedParentForPreviousSiblings) {
grandParentNode.insertBefore(clonedParentForPreviousSiblings, parentNode);
}
grandParentNode.insertBefore(node, parentNode);
if (clonedParentForNextSiblings) {
grandParentNode.insertBefore(clonedParentForNextSiblings, parentNode);
}
grandParentNode.removeChild(parentNode);
};
ZSSEditor.getChildNodesIntersectingRange = function(parentNode, range) {
var nodes = new Array();
if (parentNode) {
var currentNode = parentNode.firstChild;
var pushNodes = false;
var exit = false;
while (currentNode) {
if (range.intersectsNode(currentNode)) {
nodes.push(currentNode);
}
currentNode = currentNode.nextSibling;
}
}
return nodes;
};
/**
* @brief Given the specified range, find the ancestor element that will be used to set the
* blockquote ON or OFF.
*
* @param range The range we want to set the blockquote ON or OFF for.
*
* @returns If a parent BLOCKQUOTE element is found, it will be return. Otherwise the closest
* parent element will be returned.
*/
ZSSEditor.getAncestorElementForSettingBlockquote = function(range) {
var nodes = new Array();
var parentElement = range.commonAncestorContainer;
while (parentElement
&& (parentElement.nodeType != document.ELEMENT_NODE
|| parentElement.nodeName == NodeName.PARAGRAPH
|| parentElement.nodeName == NodeName.STRONG
|| parentElement.nodeName == NodeName.EM
|| parentElement.nodeName == NodeName.DEL
|| parentElement.nodeName == NodeName.A
|| parentElement.nodeName == NodeName.UL
|| parentElement.nodeName == NodeName.OL
|| parentElement.nodeName == NodeName.LI
|| parentElement.nodeName == NodeName.CODE
|| parentElement.nodeName == NodeName.SPAN
// Include nested divs, but ignore the parent contenteditable field div
|| (parentElement.nodeName == NodeName.DIV && parentElement.parentElement.nodeName != NodeName.BODY))) {
parentElement = parentElement.parentNode;
}
var currentElement = parentElement;
while (currentElement
&& currentElement.nodeName != NodeName.BLOCKQUOTE) {
currentElement = currentElement.parentElement;
}
var result = currentElement ? currentElement : parentElement;
return result;
};
/**
* @brief Joins any adjacent blockquote siblings.
* @details You probably want to call joinAdjacentSiblingsOrAncestorBlockquotes() instead of
* this.
*
* @returns true if a sibling was joined. false otherwise.
*/
ZSSEditor.joinAdjacentSiblingsBlockquotes = function(node) {
var shouldJoinToPreviousSibling = this.hasPreviousSiblingWithName(node, NodeName.BLOCKQUOTE);
var shouldJoinToNextSibling = this.hasNextSiblingWithName(node, NodeName.BLOCKQUOTE);
var joinedASibling = (shouldJoinToPreviousSibling || shouldJoinToNextSibling);
var previousSibling = node.previousSibling;
var nextSibling = node.nextSibling;
if (shouldJoinToPreviousSibling) {
previousSibling.appendChild(node);
if (shouldJoinToNextSibling) {
while (nextSibling.firstChild) {
previousSibling.appendChild(nextSibling.firstChild);
}
nextSibling.parentNode.removeChild(nextSibling);
}
} else if (shouldJoinToNextSibling) {
nextSibling.insertBefore(node, nextSibling.firstChild);
}
return joinedASibling;
};
/**
* @brief Joins any adjacent blockquote siblings, or the blockquote siblings of any ancestor.
* @details When turning blockquotes back on, this method makes sure that we attach new
* blockquotes to exiting ones.
*
* @returns true if a sibling or ancestor sibling was joined. false otherwise.
*/
ZSSEditor.joinAdjacentSiblingsOrAncestorBlockquotes = function(node) {
var currentNode = node;
var rootNode = this.getFocusedField().getWrappedDomNode();
var joined = false;
while (currentNode
&& currentNode != rootNode
&& !joined) {
joined = this.joinAdjacentSiblingsBlockquotes(currentNode);
currentNode = currentNode.parentNode;
};
return joined;
};
/**
* @brief Surrounds a node's contents into another node
* @details When creating new nodes that should force paragraphs inside of them, this method
* should be called.
*
* @param node The node that will have its contents wrapped into a new node.
* @param wrapperNodeName The nodeName of the node that will created to wrap the contents.
*
* @returns The newly created wrapper node.
*/
ZSSEditor.surroundNodeContentsWithNode = function(node, wrapperNodeName) {
var range = document.createRange();
var wrapperNode = document.createElement(wrapperNodeName);
range.selectNodeContents(node);
range.surroundContents(wrapperNode);
return wrapperNode;
};
/**
* @brief Surrounds a node's contents with a paragraph node.
* @details When creating new nodes that should force paragraphs inside of them, this method
* should be called.
*
* @returns The paragraph node.
*/
ZSSEditor.surroundNodeContentsWithAParagraphNode = function(node) {
return this.surroundNodeContentsWithNode(node, this.defaultParagraphSeparator);
};
// MARK: - Sibling nodes
ZSSEditor.hasNextSiblingWithName = function(node, siblingNodeName) {
return node.nextSibling && node.nextSibling.nodeName == siblingNodeName;
};
ZSSEditor.hasPreviousSiblingWithName = function(node, siblingNodeName) {
return node.previousSibling && node.previousSibling.nodeName == siblingNodeName;
};
// MARK: - Parent nodes & tags
ZSSEditor.findParentContenteditableDiv = function() {
var parentNode = null;
var selection = window.getSelection();
if (selection.rangeCount < 1) {
return null;
}
var range = selection.getRangeAt(0).cloneRange();
var referenceNode = this.closerParentNodeWithNameRelativeToNode('div', range.commonAncestorContainer);
while (referenceNode.parentNode.nodeName != NodeName.BODY) {
referenceNode = this.closerParentNodeWithNameRelativeToNode('div', referenceNode.parentNode);
}
return referenceNode;
};
ZSSEditor.closerParentNode = function() {
var parentNode = null;
var selection = window.getSelection();
if (selection.rangeCount < 1) {
return null;
}
var range = selection.getRangeAt(0).cloneRange();
var currentNode = range.commonAncestorContainer;
while (currentNode) {
if (currentNode.nodeType == document.ELEMENT_NODE) {
parentNode = currentNode;
break;
}
currentNode = currentNode.parentElement;
}
return parentNode;
};
ZSSEditor.closerParentNodeStartingAtNode = function(nodeName, startingNode) {
nodeName = nodeName.toLowerCase();
var parentNode = null;
var currentNode = startingNode.parentElement;
while (currentNode) {
if (currentNode.nodeName == document.body.nodeName) {
break;
}
if (currentNode.nodeName && currentNode.nodeName.toLowerCase() == nodeName
&& currentNode.nodeType == document.ELEMENT_NODE) {
parentNode = currentNode;
break;
}
currentNode = currentNode.parentElement;
}
return parentNode;
};
ZSSEditor.closerParentNodeWithName = function(nodeName) {
nodeName = nodeName.toLowerCase();
var parentNode = null;
var selection = window.getSelection();
if (selection.rangeCount < 1) {
return null;
}
var range = selection.getRangeAt(0).cloneRange();
var referenceNode = range.commonAncestorContainer;
return this.closerParentNodeWithNameRelativeToNode(nodeName, referenceNode);
};
ZSSEditor.closerParentNodeWithNameRelativeToNode = function(nodeName, referenceNode) {
nodeName = nodeName.toUpperCase();
var parentNode = null;
var currentNode = referenceNode;
while (currentNode) {
if (currentNode.nodeName == document.body.nodeName) {
break;
}
if (currentNode.nodeName == nodeName
&& currentNode.nodeType == document.ELEMENT_NODE) {
parentNode = currentNode;
break;
}
currentNode = currentNode.parentElement;
}
return parentNode;
};
ZSSEditor.isCloserParentNodeABlockquote = function() {
return this.closerParentNode().nodeName == NodeName.BLOCKQUOTE;
};
ZSSEditor.parentTags = function() {
var parentTags = [];
var selection = window.getSelection();
if (selection.rangeCount < 1) {
return null;
}
var range = selection.getRangeAt(0);
var currentNode = range.commonAncestorContainer;
while (currentNode) {
if (currentNode.nodeName == document.body.nodeName) {
break;
}
if (currentNode.nodeType == document.ELEMENT_NODE) {
parentTags.push(currentNode);
}
currentNode = currentNode.parentElement;
}
return parentTags;
};
// MARK: - Range handling
ZSSEditor.getParentRangeOfFocusedNode = function() {
var selection = window.getSelection();
if (selection.focusNode == null) {
return null;
}
return selection.getRangeAt(selection.focusNode.parentNode);
};
ZSSEditor.setRange = function(range) {
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
};
// MARK: - ZSSField Constructor
function ZSSField(wrappedObject) {
// When this bool is true, we are going to restrict input and certain callbacks
// so IME keyboards behave properly when composing.
this.isComposing = false;
this.multiline = false;
this.wrappedObject = wrappedObject;
if (this.getWrappedDomNode().hasAttribute('nostyle')) {
this.hasNoStyle = true;
}
this.useVisualFormatting = (this.wrappedObject.data("wpUseVisualFormatting") === "true")
this.bindListeners();
};
ZSSField.prototype.bindListeners = function() {
var thisObj = this;
this.wrappedObject.bind('tap', function(e) { thisObj.handleTapEvent(e); });
this.wrappedObject.bind('focus', function(e) { thisObj.handleFocusEvent(e); });
this.wrappedObject.bind('blur', function(e) { thisObj.handleBlurEvent(e); });
this.wrappedObject.bind('keydown', function(e) { thisObj.handleKeyDownEvent(e); });
this.wrappedObject.bind('input', function(e) { thisObj.handleInputEvent(e); });
this.wrappedObject.bind('compositionstart', function(e) { thisObj.handleCompositionStartEvent(e); });
this.wrappedObject.bind('compositionend', function(e) { thisObj.handleCompositionEndEvent(e); });
// Only supported on API19+
this.wrappedObject.on('paste', function(e) { thisObj.handlePasteEvent(e); });
};
// MARK: - Emptying the field when it should be, well... empty (HTML madness)
/**
* @brief Sometimes HTML leaves some <br> tags or &nbsp; when the user deletes all
* text from a contentEditable field. This code makes sure no such 'garbage' survives.
* @details If the node contains child image nodes, then the content is left untouched.
*/
ZSSField.prototype.emptyFieldIfNoContents = function() {
var nbsp = '\xa0';
var text = this.wrappedObject.text().replace(nbsp, '');
if (text.length == 0 || text == '\u000A') {
var hasChildImages = (this.wrappedObject.find('img').length > 0);
var hasChildVideos = (this.wrappedObject.find('video').length > 0);
var hasUnorderedList = (this.wrappedObject.find('ul').length > 0);
var hasOrderedList = (this.wrappedObject.find('ol').length > 0);
if (!hasChildImages && !hasChildVideos && !hasUnorderedList && !hasOrderedList) {
this.wrappedObject.empty();
}
}
};
// MARK: - Handle event listeners
ZSSField.prototype.handleBlurEvent = function(e) {
ZSSEditor.focusedField = null;
this.emptyFieldIfNoContents();
this.callback("callback-focus-out");
};
ZSSField.prototype.handleFocusEvent = function(e) {
ZSSEditor.focusedField = this;
this.callback("callback-focus-in");
};
ZSSField.prototype.handleKeyDownEvent = function(e) {
var wasEnterPressed = (e.keyCode == '13');
var isHardwareKeyboardPaste = (e.ctrlKey && e.keyCode == '86');
// Handle keyDownEvent actions that need to happen after the event has completed (and the field has been modified)
setTimeout(this.afterKeyDownEvent, 20, e.target.innerHTML, e);
if (this.isComposing) {
e.stopPropagation();
} else if (wasEnterPressed && !this.isMultiline()) {
e.preventDefault();
} else if (this.isMultiline()) {
// For hardware keyboards, don't do any paragraph handling for non-printable keyCodes
// https://css-tricks.com/snippets/javascript/javascript-keycodes/
// This avoids the filler zero-width space character from being inserted and displayed in the content field
// when special keys are pressed in new posts
var wasTabPressed = (e.keyCode == '9');
var intKeyCode = parseInt(e.keyCode, 10);
if (wasTabPressed || (intKeyCode > 13 && intKeyCode < 46) || intKeyCode == 192) {
return;
}
// The containsParagraphSeparators check is intended to work around three bugs:
// 1. On API19 only, paragraph wrapping the first character in a post will display a zero-width space character
// (from ZSSField.wrapCaretInParagraphIfNecessary)
// We can drop the if statement wrapping wrapCaretInParagraphIfNecessary() if we find a way to stop using
// zero-width space characters (e.g., autocorrect issues are fixed and we switch back to p tags)
//
// 2. On all APIs, software pasting (long press -> paste) doesn't automatically wrap the paste in paragraph
// tags in new posts. On API19+ this is corrected by ZSSField.handlePasteEvent(), but earlier APIs don't support
// it. So, instead, we allow the editor not to wrap the paste in paragraph tags and it's automatically corrected
// after adding a newline. But allowing wrapCaretInParagraphIfNecessary() to go through will wrap the paragraph
// incorrectly, so we skip it in this case.
//
// 3. On all APIs, hardware pasting (CTRL + V) doesn't automatically wrap the paste in paragraph tags in
// new posts. ZSSField.handlePasteEvent() won't fix the wrapping for hardware pastes if
// wrapCaretInParagraphIfNecessary() goes through first, so we need to skip it in that case.
// For API < 19, this is fixed implicitly by the 'containsParagraphSeparators' check, but for newer APIs we
// specifically detect hardware keyboard pastes and skip paragraph wrapping in that case
// case skip calling wrapCaretInParagraphIfNecessary().
//
// Previously, the check was 'if (containsParagraphSeparators)' for all API levels, but this turns out to cause
// an autocorrect issue for new posts on API 23+:
// https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/389
// That issue can be fixed by allowing wrapCaretInParagraphIfNecessary() to go through when adding text to an
// empty post on those API levels, as long as we exclude the special case of hardware keyboard pasting
//
// We're using 'nativeState.androidApiLevel>19' rather than >22 because, even though the bug only appears on
// 23+ at the time of writing, it's entirely possible a future System WebView or Keyboard update will introduce
// the bug on 21+.
var containsParagraphSeparators = this.getWrappedDomNode().innerHTML.search(
'<' + ZSSEditor.defaultParagraphSeparator) > -1;
if (containsParagraphSeparators || (nativeState.androidApiLevel > 19 && !isHardwareKeyboardPaste)) {
this.wrapCaretInParagraphIfNecessary();
}
if (wasEnterPressed) {
// Wrap the existing text in paragraph tags if necessary (this should only be needed if
// wrapCaretInParagraphIfNecessary() was skipped earlier)
var currentHtml = this.getWrappedDomNode().innerHTML;
if (currentHtml.search('<' + ZSSEditor.defaultParagraphSeparator) == -1) {
ZSSEditor.focusedField.setHTML(Util.wrapHTMLInTag(currentHtml, ZSSEditor.defaultParagraphSeparator));
ZSSEditor.resetSelectionOnField(this.getWrappedDomNode().id, 1);
}
var sel = window.getSelection();
if (sel.rangeCount < 1) {
return null;
}
var node = $(sel.anchorNode);
var children = $(sel.anchorNode.childNodes);
var parentNode = rangy.getSelection().anchorNode.parentNode;
// If enter was pressed to end a UL or OL, let's double check and handle it accordingly if so
if (sel.isCollapsed && node.is(NodeName.LI) && (!children.length ||
(children.length == 1 && children.first().is(NodeName.BR)))) {
e.preventDefault();
if (parentNode && parentNode.nodeName === NodeName.OL) {
ZSSEditor.setOrderedList();
} else if (parentNode && parentNode.nodeName === NodeName.UL) {
ZSSEditor.setUnorderedList();
}
// Exit blockquote when the user presses Enter inside a blockquote on a new line
// (main use case is to allow double Enter to exit blockquote)
} else if (sel.isCollapsed && sel.baseOffset == 0 && parentNode && parentNode.nodeName == 'BLOCKQUOTE') {
e.preventDefault();
ZSSEditor.setBlockquote();
// When pressing enter inside an image caption, clear the caption styling from the new line
} else if (parentNode.nodeName == NodeName.SPAN && $(parentNode).hasClass('wp-caption')) {
setTimeout(this.handleCaptionNewLine, 100);
}
}
}
};
ZSSField.prototype.handleInputEvent = function(e) {
// Skip this if we are composing on an IME keyboard
if (this.isComposing ) { return; }
// IMPORTANT: we want the placeholder to come up if there's no text, so we clear the field if
// there's no real content in it. It's important to do this here and not on keyDown or keyUp
// as the field could become empty because of a cut or paste operation as well as a key press.
// This event takes care of all cases.
//
this.emptyFieldIfNoContents();
var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments();
ZSSEditor.callback('callback-selection-changed', joinedArguments);
this.callback("callback-input", joinedArguments);
};
ZSSField.prototype.handleCompositionStartEvent = function(e) {
this.isComposing = true;
};
ZSSField.prototype.handleCompositionEndEvent = function(e) {
this.isComposing = false;
};
ZSSField.prototype.handleTapEvent = function(e) {
var targetNode = e.target;
if (targetNode) {
ZSSEditor.lastTappedNode = targetNode;
if (targetNode.nodeName.toLowerCase() == 'a') {
var arguments = ['url=' + encodeURIComponent(targetNode.href),
'title=' + encodeURIComponent(targetNode.innerHTML)];
var joinedArguments = arguments.join(defaultCallbackSeparator);
this.callback('callback-link-tap', joinedArguments);
}
if (targetNode.nodeName.toLowerCase() == 'img') {
// If the image is uploading, or is a local image do not select it.
if ( targetNode.dataset.wpid || targetNode.dataset.video_wpid ) {
this.sendImageTappedCallback(targetNode);
return;
}
// If we're not currently editing just return. No need to apply styles
// or acknowledge the tap
if ( this.wrappedObject.attr('contenteditable') != "true" ) {
return;
}
// Is the tapped image the image we're editing?
if ( targetNode == ZSSEditor.currentEditingImage ) {
ZSSEditor.removeImageSelectionFormatting(targetNode);
this.sendImageTappedCallback(targetNode);
return;
}
// If there is a selected image, deselect it. A different image was tapped.
if ( ZSSEditor.currentEditingImage ) {
ZSSEditor.removeImageSelectionFormatting(ZSSEditor.currentEditingImage);
}
// Format and flag the image as selected.
ZSSEditor.currentEditingImage = targetNode;
var containerNode = ZSSEditor.applyImageSelectionFormatting(targetNode);
// Move the cursor to the tapped image, to prevent scrolling to the bottom of the document when the
// keyboard comes up. On API 19 and below does not work properly, with the image sometimes getting removed
// from the post instead of the edit overlay being displayed
if (nativeState.androidApiLevel > 19) {
ZSSEditor.setFocusAfterElement(containerNode);
}
return;
}
if (targetNode.className.indexOf('edit-overlay') != -1 || targetNode.className.indexOf('edit-content') != -1
|| targetNode.className.indexOf('edit-icon') != -1) {
ZSSEditor.removeImageSelectionFormatting( ZSSEditor.currentEditingImage );
this.sendImageTappedCallback( ZSSEditor.currentEditingImage );
return;
}
if (targetNode.className.indexOf('upload-overlay') != -1 ||
targetNode.className.indexOf('upload-overlay-bg') != -1 ) {
// Select the image node associated with the selected upload overlay
var imageNode = targetNode.parentNode.getElementsByTagName('img')[0];
this.sendImageTappedCallback( imageNode );
return;
}
if (targetNode.className.indexOf('delete-overlay') != -1) {
var parentEditContainer = targetNode.parentElement;
var parentDiv = parentEditContainer.parentElement;
// If the delete button was tapped, removing the media item and its container from the document
if (parentEditContainer.classList.contains('edit-container')) {
parentEditContainer.parentElement.removeChild(parentEditContainer);
} else {
parentEditContainer.removeChild(targetNode);
}
this.emptyFieldIfNoContents();
ZSSEditor.currentEditingImage = null;
return;
}
if ( ZSSEditor.currentEditingImage ) {
ZSSEditor.removeImageSelectionFormatting( ZSSEditor.currentEditingImage );
ZSSEditor.currentEditingImage = null;
}
if (targetNode.nodeName.toLowerCase() == 'video') {
// If the video is uploading, or is a local image do not select it.
if (targetNode.dataset.wpvideopress) {
if (targetNode.src.length == 0 || targetNode.src == 'file:///android_asset/') {
// If the tapped video is a placeholder for a VideoPress video, send out an update request.
// This provides a way to load the video for Android API<19, where the onError property function in
// the placeholder video isn't being triggered, and sendVideoPressInfoRequest is never called.
// This is also used to manually retry loading a VideoPress video after the onError attribute has
// been stripped for the video tag.
targetNode.setAttribute("onerror", "");
ZSSEditor.sendVideoPressInfoRequest(targetNode.dataset.wpvideopress);
return;
}
}
if (targetNode.dataset.wpid) {
this.sendVideoTappedCallback( targetNode );
return;
}
}
}
};
ZSSField.prototype.handlePasteEvent = function(e) {
if (this.isMultiline() && this.getHTML().length == 0) {
ZSSEditor.insertHTML(Util.wrapHTMLInTag('&#x200b;', ZSSEditor.defaultParagraphSeparator));
}
};
/**
* @brief Fires after 'keydown' events, when the field contents have already been modified
*/
ZSSField.prototype.afterKeyDownEvent = function(beforeHTML, e) {
var afterHTML = e.target.innerHTML;
var htmlWasModified = (beforeHTML != afterHTML);
var selection = document.getSelection();
var range = selection.getRangeAt(0).cloneRange();
var focusedNode = range.startContainer;
// Correct situation where autocorrect can remove blockquotes at start of document, either when pressing enter
// inside a blockquote, or pressing backspace immediately after one
// https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/385
if (htmlWasModified) {
var blockquoteMatch = beforeHTML.match('^<blockquote><div>(.*)</div></blockquote>');
if (blockquoteMatch != null && afterHTML.match('<blockquote>') == null) {
// Blockquote at start of post was removed
var newParagraphMatch = afterHTML.match('^<div>(.*?)</div><div><br></div>');
if (newParagraphMatch != null) {
// The blockquote was removed in a newline operation
var originalText = blockquoteMatch[1];
var newText = newParagraphMatch[1];
if (originalText != newText) {
// Blockquote was removed and its inner text changed - this points to autocorrect removing the
// blockquote when changing the text in the previous paragraph, so we replace the blockquote
ZSSEditor.turnBlockquoteOnForNode(focusedNode.parentNode.firstChild);
}
} else if (afterHTML.match('^<div>(.*?)</div>') != null) {
// The blockquote was removed in a backspace operation
ZSSEditor.turnBlockquoteOnForNode(focusedNode.parentNode);
ZSSEditor.setFocusAfterElement(focusedNode);
}
}
}
var focusedNodeIsEmpty = (focusedNode.innerHTML != undefined
&& (focusedNode.innerHTML.length == 0 || focusedNode.innerHTML == '<br>'));
// Blockquote handling
if (focusedNode.nodeName == NodeName.BLOCKQUOTE && focusedNodeIsEmpty) {
if (!htmlWasModified) {
// We only want to handle this if the last character inside a blockquote was just deleted - if the HTML
// is unchanged, it might be that afterKeyDownEvent was called too soon, and we should avoid doing anything
return;
}
// When using backspace to delete the contents of a blockquote, the div within the blockquote is deleted
// This makes the blockquote unable to be deleted using backspace, and also causes autocorrect issues on API19+
range.startContainer.innerHTML = Util.wrapHTMLInTag('<br>', ZSSEditor.defaultParagraphSeparator);
// Give focus to new div
var newFocusElement = focusedNode.firstChild;
ZSSEditor.giveFocusToElement(newFocusElement, 1);
} else if (focusedNode.nodeName == NodeName.DIV && focusedNode.parentNode.nodeName == NodeName.BLOCKQUOTE) {
if (focusedNode.parentNode.previousSibling == null && focusedNode.parentNode.childNodes.length == 1
&& focusedNodeIsEmpty) {
// When a post begins with a blockquote, and there's content after that blockquote, backspacing inside that
// blockquote will work until the blockquote is empty. After that, backspace will have no effect
// This fix identifies that situation and makes the call to setBlockquote() to toggle off the blockquote
ZSSEditor.setBlockquote();
} else {
// Remove extraneous break tags sometimes added to blockquotes by autocorrect actions
// https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/385
var blockquoteChildNodes = focusedNode.parentNode.childNodes;
for (var i = 0; i < blockquoteChildNodes.length; i++) {
var childNode = blockquoteChildNodes[i];
if (childNode.nodeName == NodeName.BR) {
childNode.parentNode.removeChild(childNode);
}
}
}
}
};
ZSSField.prototype.sendImageTappedCallback = function(imageNode) {
var meta = JSON.stringify(ZSSEditor.extractImageMeta(imageNode));
var imageId = "", mediaType = "image";
if (imageNode.hasAttribute('data-wpid')){
imageId = imageNode.getAttribute('data-wpid');
} else if (imageNode.hasAttribute('data-video_wpid')){
imageId = imageNode.getAttribute('data-video_wpid');
mediaType = "video";
}
var arguments = ['id=' + encodeURIComponent(imageId),
'url=' + encodeURIComponent(imageNode.src),
'meta=' + encodeURIComponent(meta),
'type=' + mediaType];
var joinedArguments = arguments.join(defaultCallbackSeparator);
var thisObj = this;
// WORKAROUND: force the event to become sort of "after-tap" through setTimeout()
//
setTimeout(function() { thisObj.callback('callback-image-tap', joinedArguments);}, 500);
}
ZSSField.prototype.sendVideoTappedCallback = function( videoNode ) {
var videoId = "";
if ( videoNode.hasAttribute( 'data-wpid' ) ){
videoId = videoNode.getAttribute( 'data-wpid' )
}
var arguments = ['id=' + encodeURIComponent( videoId ),
'url=' + encodeURIComponent( videoNode.src )];
var joinedArguments = arguments.join( defaultCallbackSeparator );
ZSSEditor.callback('callback-video-tap', joinedArguments);
}
// MARK: - Callback Execution
ZSSField.prototype.callback = function(callbackScheme, callbackPath) {
var url = callbackScheme + ":";
url = url + "id=" + this.getNodeId();
if (callbackPath) {
url = url + defaultCallbackSeparator + 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);
}
};
// MARK: - Focus
ZSSField.prototype.isFocused = function() {
return this.wrappedObject.is(':focus');
};
ZSSField.prototype.focus = function() {
if (!this.isFocused()) {
this.wrappedObject.focus();
}
};
ZSSField.prototype.blur = function() {
if (this.isFocused()) {
this.wrappedObject.blur();
}
};
// MARK: - Multiline support
ZSSField.prototype.isMultiline = function() {
return this.multiline;
};
ZSSField.prototype.setMultiline = function(multiline) {
this.multiline = multiline;
};
// MARK: - NodeId
ZSSField.prototype.getNodeId = function() {
return this.wrappedObject.attr('id');
};
// MARK: - Editing
ZSSField.prototype.enableEditing = function () {
this.wrappedObject.attr('contenteditable', true);
if (!ZSSEditor.focusedField) {
ZSSEditor.focusFirstEditableField();
}
};
ZSSField.prototype.disableEditing = function () {
// IMPORTANT: we're blurring the field before making it non-editable since that ensures
// that the iOS keyboard is dismissed through an animation, as opposed to being immediately
// removed from the screen.
//
this.blur();
this.wrappedObject.attr('contenteditable', false);
};
// MARK: - Caret
/**
* @brief Whenever this method is called, a check will be performed on the caret position
* to figure out if it needs to be wrapped in a paragraph node.
* @details A parent paragraph node should be added if the current parent is either the field
* node itself, or a blockquote node.
*/
ZSSField.prototype.wrapCaretInParagraphIfNecessary = function() {
var closerParentNode = ZSSEditor.closerParentNode();
if (closerParentNode == null) {
return;
}
var parentNodeShouldBeParagraph = (closerParentNode == this.getWrappedDomNode()
|| closerParentNode.nodeName == NodeName.BLOCKQUOTE);
// When starting a post with a blockquote (before any text is entered), the paragraph tags inside the blockquote
// won't properly wrap the text once it's entered
// In that case, remove the paragraph tags and re-apply them, wrapping around the newly entered text
var fixNewPostBlockquoteBug = (closerParentNode.nodeName == NodeName.DIV
&& closerParentNode.parentNode.nodeName == NodeName.BLOCKQUOTE
&& closerParentNode.innerHTML.length == 0);
// On API 19 and below, identifying the situation where the blockquote bug for new posts occurs works a little
// differently than above, with the focused node being the parent blockquote rather than the empty div inside it.
// We still remove the empty div so it can be re-applied correctly to the newly entered text, but we select it
// differently
// https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/398
var fixNewPostBlockquoteBugOldApi = (closerParentNode.nodeName == NodeName.BLOCKQUOTE
&& closerParentNode.parentNode.nodeName == NodeName.DIV
&& closerParentNode.innerHTML == '<div></div>');
if (parentNodeShouldBeParagraph || fixNewPostBlockquoteBug || fixNewPostBlockquoteBugOldApi) {
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
if (range.startContainer == range.endContainer) {
if (fixNewPostBlockquoteBug) {
closerParentNode.parentNode.removeChild(closerParentNode);
} else if (fixNewPostBlockquoteBugOldApi) {
closerParentNode.removeChild(closerParentNode.firstChild);
}
var storedStyles = [];
if (this.getWrappedDomNode().innerHTML.length == 0) {
// If the post is empty, store any active in-line formatting so it can be re-applied after the
// DOM manipulations are completed
// (Fix for https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/204)
storedStyles = ZSSEditor.storeInlineStylesAsFunctions();
}
var paragraph = document.createElement("div");
var textNode = document.createTextNode("&#x200b;");
paragraph.appendChild(textNode);
range.insertNode(paragraph);
range.selectNode(textNode);
selection.removeAllRanges();
selection.addRange(range);
// Re-apply inline styles that were cleared
storedStyles.map(function(styleFunction) {
styleFunction();
});
}
}
}
};
/**
* @brief Called when enter is pressed inside an image caption. Clears away the span and label tags the new line
* inherits from the caption styling.
*/
ZSSField.prototype.handleCaptionNewLine = function() {
var selectedNode = document.getSelection().baseNode;
var contentsNode;
if (selectedNode.firstChild != null) {
contentsNode = selectedNode.firstChild.cloneNode();
} else {
contentsNode = selectedNode.cloneNode();
}
var parentSpan = selectedNode.parentNode.parentNode;
var parentDiv = parentSpan.parentNode;
var paragraph = document.createElement("div");
paragraph.appendChild(contentsNode);
parentDiv.insertBefore(paragraph, parentSpan);
parentDiv.removeChild(parentSpan);
ZSSEditor.giveFocusToElement(contentsNode);
};
// MARK: - i18n
ZSSField.prototype.isRightToLeftTextEnabled = function() {
var textDir = this.wrappedObject.attr('dir');
var isRTL = (textDir != "undefined" && textDir == 'rtl');
return isRTL;
};
ZSSField.prototype.enableRightToLeftText = function(isRTL) {
var textDirectionString = isRTL ? "rtl" : "ltr";
this.wrappedObject.attr('dir', textDirectionString);
this.wrappedObject.css('direction', textDirectionString);
};
// MARK: - HTML contents
ZSSField.prototype.isEmpty = function() {
var html = this.getHTML();
var isEmpty = (html.length == 0 || html == "<br>");
return isEmpty;
};
ZSSField.prototype.getHTML = function() {
var html = this.wrappedObject.html();
if (ZSSEditor.defaultParagraphSeparator == 'div') {
html = Formatter.convertDivToP(html);
}
html = Formatter.visualToHtml(html);
html = ZSSEditor.removeVisualFormatting( html );
return html;
};
ZSSField.prototype.getHTMLForCallback = function() {
var functionArgument = "function=getHTMLForCallback";
var idArgument = "id=" + this.getNodeId();
var contentsArgument;
if (this.hasNoStyle) {
contentsArgument = "contents=" + this.strippedHTML();
} else {
var html;
if (nativeState.androidApiLevel < 17) {
// URI Encode HTML on API < 17 because of the use of WebViewClient.shouldOverrideUrlLoading. Data must
// be decoded in shouldOverrideUrlLoading.
html = encodeURIComponent(this.getHTML());
} else {
html = this.getHTML();
}
contentsArgument = "contents=" + html;
}
var joinedArguments = functionArgument + defaultCallbackSeparator + idArgument + defaultCallbackSeparator +
contentsArgument;
ZSSEditor.callback('callback-response-string', joinedArguments);
};
ZSSField.prototype.strippedHTML = function() {
return this.wrappedObject.text();
};
ZSSField.prototype.setPlainText = function(text) {
ZSSEditor.currentEditingImage = null;
this.wrappedObject.text(text);
};
ZSSField.prototype.setHTML = function(html) {
ZSSEditor.currentEditingImage = null;
var mutatedHTML = Formatter.htmlToVisual(html);
if (ZSSEditor.defaultParagraphSeparator == 'div') {
mutatedHTML = Formatter.convertPToDiv(mutatedHTML);
}
this.wrappedObject.html(mutatedHTML);
// Track video container nodes for mutation
var videoNodes = $('span.edit-container > video');
for (var i = 0; i < videoNodes.length; i++) {
ZSSEditor.trackNodeForMutation($(videoNodes[i].parentNode));
}
};
// MARK: - Placeholder
ZSSField.prototype.hasPlaceholderText = function() {
return this.wrappedObject.attr('placeholderText') != null;
};
ZSSField.prototype.setPlaceholderText = function(placeholder) {
this.wrappedObject.attr('placeholderText', placeholder);
};
// MARK: - Wrapped Object
ZSSField.prototype.getWrappedDomNode = function() {
return this.wrappedObject[0];
};