diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 000000000000..63ecc8b97bb5 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Automattic Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 288318e98350..5cc4524ae94b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # WordPress-Common-Editor Common code of Android and iOS rich text editor component. + +## LICENSE +This library is licensed under[MIT](LICENSE-MIT) diff --git a/assets/ZSSRichTextEditor.js b/assets/ZSSRichTextEditor.js new file mode 100644 index 000000000000..052c44f8f622 --- /dev/null +++ b/assets/ZSSRichTextEditor.js @@ -0,0 +1,1863 @@ +/*! + * + * ZSSRichTextEditor v1.0 + * http://www.zedsaid.com + * + * Copyright 2013 Zed Said Studio + * + */ + +// If we are using iOS or desktop +var isUsingiOS = true; + +// THe default callback parameter separator +var defaultCallbackSeparator = '~'; + +// 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 link +ZSSEditor.currentEditingLink; + +ZSSEditor.focusedField = null; + +// The objects that are enabled +ZSSEditor.enabledItems = {}; + +ZSSEditor.editableFields = {}; + +ZSSEditor.lastTappedNode = null; + +// The default paragraph separator +ZSSEditor.defaultParagraphSeparator = 'p'; + +/** + * The initializer function that must be called onLoad + */ +ZSSEditor.init = function() { + + // 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); + document.execCommand('defaultParagraphSeparator', false, this.defaultParagraphSeparator); + + 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); + +}; //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.getField = function(fieldId) { + + var field = this.editableFields[fieldId]; + + return field; +}; + +ZSSEditor.getFocusedField = function() { + var currentField = $(this.closerParentNodeWithName('div')); + var currentFieldId = currentField.attr('id'); + + while (currentField + && (!currentFieldId || this.editableFields[currentFieldId] == null)) { + currentField = this.closerParentNodeStartingAtNode('div', currentField); + currentFieldId = currentField.attr('id'); + + } + + return this.editableFields[currentFieldId]; +}; + +// 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 { + 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("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(); + 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.getSelectedText = function() { + var selection = window.getSelection(); + + return selection.toString(); +}; + +ZSSEditor.getCaretArguments = function() { + var caretInfo = this.getYCaretInfo(); + + 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.getYCaretInfo = function() { + var y = 0, height = 0; + var sel = window.getSelection(); + if (sel.rangeCount) { + + var range = sel.getRangeAt(0); + var needsToWorkAroundNewlineBug = (range.startOffset == 0 || 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 just get the node's offset instead. + // + if (needsToWorkAroundNewlineBug) { + var closerParentNode = ZSSEditor.closerParentNode(); + var closerDiv = ZSSEditor.closerParentNodeWithName('div'); + + var fontSize = $(closerParentNode).css('font-size'); + var lineHeight = Math.floor(parseInt(fontSize.replace('px','')) * 1.5); + + y = closerParentNode.offsetTop; + 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: - Default paragraph separator + +ZSSEditor.defaultParagraphSeparatorTag = function() { + return '<' + this.defaultParagraphSeparator + '>'; +}; + +// MARK: - Styles + +ZSSEditor.setBold = function() { + document.execCommand('bold', false, null); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setItalic = function() { + document.execCommand('italic', false, null); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setSubscript = function() { + document.execCommand('subscript', false, null); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setSuperscript = function() { + document.execCommand('superscript', false, null); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setStrikeThrough = function() { + var commandName = 'strikeThrough'; + var isDisablingStrikeThrough = ZSSEditor.isCommandEnabled(commandName); + + document.execCommand(commandName, false, null); + + // DRM: WebKit has a problem disabling strikeThrough when the tag is used instead of + // . The code below serves as a way to fix this issue. + // + var mustHandleWebKitIssue = (isDisablingStrikeThrough + && ZSSEditor.isCommandEnabled(commandName)); + + if (mustHandleWebKitIssue) { + 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() { + document.execCommand('underline', false, null); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setBlockquote = function() { + var formatTag = "blockquote"; + var formatBlock = document.queryCommandValue('formatBlock'); + + if (formatBlock.length > 0 && formatBlock.toLowerCase() == formatTag) { + document.execCommand('formatBlock', false, this.defaultParagraphSeparatorTag()); + } else { + document.execCommand('formatBlock', false, '<' + formatTag + '>'); + } + + 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, this.defaultParagraphSeparatorTag()); + } else { + document.execCommand('formatBlock', false, '<' + formatTag + '>'); + } + + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setParagraph = function() { + var formatTag = "p"; + var formatBlock = document.queryCommandValue('formatBlock'); + + if (formatBlock.length > 0 && formatBlock.toLowerCase() == formatTag) { + document.execCommand('formatBlock', false, this.defaultParagraphSeparatorTag()); + } else { + document.execCommand('formatBlock', false, '<' + 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); + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.setUnorderedList = function() { + document.execCommand('insertUnorderedList', false, null); + 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.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.restoreRange(); + document.execCommand("styleWithCSS", null, true); + document.execCommand('hiliteColor', false, color); + document.execCommand("styleWithCSS", null, false); + ZSSEditor.sendEnabledStyles(); +}; + +// Needs addClass method + +ZSSEditor.insertLink = function(url, title) { + + ZSSEditor.restoreRange(); + + var sel = document.getSelection(); + if (sel.rangeCount) { + + var el = document.createElement("a"); + el.setAttribute("href", url); + + var range = sel.getRangeAt(0).cloneRange(); + range.surroundContents(el); + el.innerHTML = title; + sel.removeAllRanges(); + sel.addRange(range); + } + + ZSSEditor.sendEnabledStyles(); +}; + +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 currentLinkNode = ZSSEditor.closerParentNodeWithName('a'); + + if (currentLinkNode) { + ZSSEditor.unwrapNode(currentLinkNode); + } + + ZSSEditor.sendEnabledStyles(); +}; + +ZSSEditor.unwrapNode = function(node) { + var newObject = $(node).replaceWith(node.innerHTML); +}; + +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 = '' + sel + ''; + ZSSEditor.insertHTML(html_code); +}; + +// 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, alt) { + var html = ''+alt+''; + + this.insertHTML(html); + this.sendEnabledStyles(); +}; + +/** + * @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) { + // this hiddenChar helps with editing the content around images: http://stackoverflow.com/questions/18985261/cursor-in-wrong-place-in-contenteditable + var hiddenChar = '\ufeff'; + var progressIdentifier = this.getImageProgressIdentifier(imageNodeIdentifier); + var imageContainerIdentifier = this.getImageContainerIdentifier(imageNodeIdentifier); + var imgContainerStart = ''; + var imgContainerEnd = ''; + var progress = ''; + var image = ''; + var html = imgContainerStart + progress+image + imgContainerEnd; + html = hiddenChar + html + hiddenChar; + + this.insertHTML(html); + 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, remoteImageUrl) { + + var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier); + + if (imageNode.length == 0) { + return; + } + //when we decide to put the final url we can remove this from the node. + imageNode.removeAttr('data-wpid'); + + var image = new Image; + + image.onload = function () { + imageNode.attr('src', image.src); + var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments(); + ZSSEditor.callback("callback-input", joinedArguments); + } + + image.onerror = function () { + // Even on an error, we swap the image for the time being. This is because private + // blogs are currently failing to download images due to access privilege issues. + // + imageNode.attr('src', image.src); + + var joinedArguments = ZSSEditor.getJoinedFocusedFieldIdAndCaretArguments(); + ZSSEditor.callback("callback-input", joinedArguments); + } + + image.src = remoteImageUrl; +}; + +/** + * @brief Update the progress indicator for the image identified with the value in progress. + * + * @param imageNodeIdentifier This is a unique ID provided by the caller. + * @param progress A value between 0 and 1 indicating the progress on the image. + */ +ZSSEditor.setProgressOnImage = function(imageNodeIdentifier, progress) { + var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier); + if (imageNode.length == 0){ + return; + } + if (progress >=1){ + imageNode.removeClass("uploading"); + imageNode.removeAttr("class"); + } else { + imageNode.addClass("uploading"); + } + + var imageProgressNode = this.getImageProgressNodeWithIdentifier(imageNodeIdentifier); + if (imageProgressNode.length == 0){ + return; + } + imageProgressNode.attr("value",progress); + // if progress is finished remove all extra nodes. + if (progress >=1 && + (imageNode.parent().attr("id") == this.getImageContainerIdentifier(imageNodeIdentifier))) + { + imageNode.parent().replaceWith(imageNode); + } +}; + +/** + * @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; + } + + imageNode.addClass('failed'); + + var imageContainerNode = this.getImageContainerNodeWithIdentifier(imageNodeIdentifier); + if(imageContainerNode.length != 0){ + imageContainerNode.attr("data-failed", message); + imageContainerNode.addClass('failed'); + } + + var imageProgressNode = this.getImageProgressNodeWithIdentifier(imageNodeIdentifier); + if (imageProgressNode.length != 0){ + imageProgressNode.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, message) { + var imageNode = this.getImageNodeWithIdentifier(imageNodeIdentifier); + if (imageNode.length != 0){ + imageNode.removeClass('failed'); + imageNode.attr("contenteditable","false"); + } + + 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'); + } +}; + +/** + * @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){ + 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 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 ); + node.insertAdjacentHTML( 'afterend', html ); + node.remove(); + + ZSSEditor.currentEditingImage = null; +} + +ZSSEditor.applyImageSelectionFormatting = function( imageNode ) { + var node = ZSSEditor.findImageCaptionNode( imageNode ); + + var sizeClass = ''; + if ( imageNode.width > 200 && imageNode.height > 240 ) { + sizeClass = " large"; + } else if ( imageNode.width < 100 || imageNode.height < 100 ) { + sizeClass = " small"; + } + + var overlay = 'Edit'; + var html = '' + overlay + ''; + node.insertAdjacentHTML( 'beforebegin', html ); + var selectionNode = node.previousSibling; + selectionNode.appendChild( node ); +} + +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; + container.insertBefore( node, parentNode ); + parentNode.remove(); +} + + +/** + * @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; + } + + if (props.captionClassName) { + shortcode.class = props.captionClassName; + } + + html = wp.shortcode.string({ + tag: 'caption', + attrs: shortcode, + content: html + ' ' + props.caption + }); + + html = ZSSEditor.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 + }; + + // populate metadata with values of matched attributes + metadata.src = $( imageNode ).attr( 'src' ) || ''; + metadata.alt = $( imageNode ).attr( 'alt' ) || ''; + metadata.title = $( imageNode ).attr( 'title' ) || ''; + + 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 ) + 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 Adds visual formatting to a caption shortcodes. + * + * @param html The markup containing caption shortcodes to process. + * + * @return The html with caption shortcodes replaced with editor specific markup. + * See shortcode.js::next or details + */ +ZSSEditor.applyCaptionFormatting = function( match ) { + var attrs = match.attrs.named; + var out = '