From 16764d0d1cba59ba93c8b06f4c221cf686751a9f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 4 Feb 2016 12:27:08 +0100 Subject: [PATCH] Added support for text-color in Redactor II --- com.woltlab.wcf/templates/wysiwyg.tpl | 3 +- .../redactor2/plugins/WoltLabColor.js | 57 +++++ .../redactor2/plugins/WoltLabDropdown.js | 5 +- .../install/files/js/WoltLab/WCF/Dom/Util.js | 86 ++++++- .../js/WoltLab/WCF/Ui/Redactor/Format.js | 223 ++++++++++++++++++ 5 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabColor.js create mode 100644 wcfsetup/install/files/js/WoltLab/WCF/Ui/Redactor/Format.js diff --git a/com.woltlab.wcf/templates/wysiwyg.tpl b/com.woltlab.wcf/templates/wysiwyg.tpl index e12ed688dd..41f4d9626c 100644 --- a/com.woltlab.wcf/templates/wysiwyg.tpl +++ b/com.woltlab.wcf/templates/wysiwyg.tpl @@ -27,7 +27,7 @@ var config = { buttons: buttons, minHeight: 200, - plugins: ['WoltLabButton', 'WoltLabDropdown', 'WoltLabEvent', 'WoltLabLink', 'WoltLabQuote'], + plugins: ['WoltLabButton', 'WoltLabColor', 'WoltLabDropdown', 'WoltLabEvent', 'WoltLabLink', 'WoltLabQuote'], woltlab: { autosave: autosave } @@ -51,6 +51,7 @@ {* WoltLab *} '{@$__wcf->getPath()}js/3rdParty/redactor2/plugins/WoltLabButton.js?v={@LAST_UPDATE_TIME}', + '{@$__wcf->getPath()}js/3rdParty/redactor2/plugins/WoltLabColor.js?v={@LAST_UPDATE_TIME}', '{@$__wcf->getPath()}js/3rdParty/redactor2/plugins/WoltLabDropdown.js?v={@LAST_UPDATE_TIME}', '{@$__wcf->getPath()}js/3rdParty/redactor2/plugins/WoltLabEvent.js?v={@LAST_UPDATE_TIME}', '{@$__wcf->getPath()}js/3rdParty/redactor2/plugins/WoltLabLink.js?v={@LAST_UPDATE_TIME}', diff --git a/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabColor.js b/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabColor.js new file mode 100644 index 0000000000..8dbc017a99 --- /dev/null +++ b/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabColor.js @@ -0,0 +1,57 @@ +$.Redactor.prototype.WoltLabColor = function() { + "use strict"; + + return { + init: function() { + // these are hex values, but the '#' was left out for convenience + var colors = [ + '000000', '800000', '8B4513', '2F4F4F', '008080', '000080', '4B0082', '696969', + 'B22222', 'A52A2A', 'DAA520', '006400', '40E0D0', '0000CD', '800080', '808080', + 'FF0000', 'FF8C00', 'FFD700', '008000', '00FFFF', '0000FF', 'EE82EE', 'A9A9A9', + 'FFA07A', 'FFA500', 'FFFF00', '00FF00', 'AFEEEE', 'ADD8E6', 'DDA0DD', 'D3D3D3', + 'FFF0F5', 'FAEBD7', 'FFFFE0', 'F0FFF0', 'F0FFFF', 'F0F8FF', 'E6E6FA', 'FFFFFF' + ]; + + var callback = this.WoltLabColor.setColor.bind(this), color; + var dropdown = { + 'removeColor': { + title: 'remove color', + func: this.WoltLabColor.removeColor.bind(this) + } + }; + for (var i = 0, length = colors.length; i < length; i++) { + color = colors[i]; + + dropdown['color_' + color] = { + title: '#' + color, + func: callback + }; + } + + var button = this.button.add('woltlabColor', 'Color'); + this.button.addDropdown(button, dropdown); + }, + + setColor: function(key) { + key = key.replace(/^color_/, ''); + + require(['WoltLab/WCF/Ui/Redactor/Format'], (function(UiRedactorFormat) { + this.selection.save(); + + UiRedactorFormat.format(this.$editor[0], 'woltlab-color', 'woltlab-color' + key); + + this.selection.restore(); + }).bind(this)); + }, + + removeColor: function() { + require(['WoltLab/WCF/Ui/Redactor/Format'], (function(UiRedactorFormat) { + this.selection.save(); + + UiRedactorFormat.removeFormat(this.$editor[0], 'woltlab-color'); + + this.selection.restore(); + }).bind(this)); + } + }; +}; diff --git a/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabDropdown.js b/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabDropdown.js index 44f7e9af61..1e21ae5527 100644 --- a/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabDropdown.js +++ b/wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabDropdown.js @@ -34,11 +34,8 @@ $.Redactor.prototype.WoltLabDropdown = function() { var list = elCreate('ul'); list.className = 'dropdownMenu'; - var listItem; while ($dropdown[0].childElementCount) { - listItem = elCreate('li'); - listItem.appendChild($dropdown[0].children[0]); - list.appendChild(listItem); + list.appendChild($dropdown[0].children[0]); } $dropdown[0].appendChild(list); diff --git a/wcfsetup/install/files/js/WoltLab/WCF/Dom/Util.js b/wcfsetup/install/files/js/WoltLab/WCF/Dom/Util.js index d96e7c9774..5de5990b68 100644 --- a/wcfsetup/install/files/js/WoltLab/WCF/Dom/Util.js +++ b/wcfsetup/install/files/js/WoltLab/WCF/Dom/Util.js @@ -1,6 +1,6 @@ /** * Provides helper functions to work with DOM nodes. - * + * * @author Alexander Ebert * @copyright 2001-2015 WoltLab GmbH * @license GNU Lesser General Public License @@ -18,6 +18,33 @@ define(['StringUtil'], function(StringUtil) { } } + function _isBoundaryNode(element, ancestor, position) { + if (!ancestor.contains(element)) { + throw new Error("Ancestor element does not contain target element."); + } + + var node, whichSibling = position + 'Sibling'; + while (element !== null && element !== ancestor) { + if (element[position + 'ElementSibling'] !== null) { + return false; + } + else if (element[whichSibling]) { + node = element[whichSibling]; + while (node) { + if (node.textContent.trim() !== '') { + return false; + } + + node = node[whichSibling]; + } + } + + element = element.parentNode; + } + + return true; + } + var _idCounter = 0; /** @@ -350,6 +377,63 @@ define(['StringUtil'], function(StringUtil) { } return attributes; + }, + + /** + * Unwraps contained nodes by moving them out of `element` while + * preserving their previous order. Target element will be removed + * at the end of the operation. + * + * @param {Element} element target element + */ + unwrapChildNodes: function(element) { + var parent = element.parentNode; + while (element.childNodes.length) { + parent.insertBefore(element.childNodes[0], element); + } + + elRemove(element); + }, + + /** + * Replaces an element by moving all child nodes into the new element + * while preserving their previous order. The old element will be removed + * at the end of the operation. + * + * @param {Element} oldElement old element + * @param {Element} newElement old element + */ + replaceElement: function(oldElement, newElement) { + while (oldElement.childNodes.length) { + newElement.appendChild(oldElement.childNodes[0]); + } + + oldElement.parentNode.insertBefore(newElement, oldElement); + elRemove(oldElement); + }, + + /** + * Returns true if given element is the most left node of the ancestor, that is + * a node without any content nor elements before it or its parent nodes. + * + * @param {Element} element target element + * @param {Element} ancestor ancestor element, must contain the target element + * @returns {boolean} true if target element is the most left node + */ + isAtNodeStart: function(element, ancestor) { + return _isBoundaryNode(element, ancestor, 'previous'); + }, + + /** + * Returns true if given element is the most right node of the ancestor, that is + * a node without any content nor elements after it or its parent nodes. + * + * @param {Element} element target element + * @param {Element} ancestor ancestor element, must contain the target element + * @returns {boolean} true if target element is the most right node + */ + isAtNodeEnd: function(element, ancestor) { + return _isBoundaryNode(element, ancestor, 'next'); } }; diff --git a/wcfsetup/install/files/js/WoltLab/WCF/Ui/Redactor/Format.js b/wcfsetup/install/files/js/WoltLab/WCF/Ui/Redactor/Format.js new file mode 100644 index 0000000000..36f4b6b646 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLab/WCF/Ui/Redactor/Format.js @@ -0,0 +1,223 @@ +/** + * Provides helper methods to add and remove format elements. These methods should in + * theory work with non-editor elements but has not been tested and any usage outside + * the editor is not recommended. + * + * @author Alexander Ebert + * @copyright 2001-2016 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLab/WCF/Ui/Redactor/Format + */ +define(['Dom/Util'], function(DomUtil) { + "use strict"; + + /** + * @exports WoltLab/WCF/Ui/Redactor/Format + */ + return { + /** + * Applies format elements to the selected text. + * + * @param {Element} editorElement editor element + * @param {string} tagName format tag name + * @param {string=} className optional CSS class for the format tag + * @param {Object=} attributes optional list of attributes for the format tag + */ + format: function(editorElement, tagName, className, attributes) { + document.execCommand('strikethrough'); + + var elements = elBySelAll('strike', editorElement), formatElement, property, strike; + for (var i = 0, length = elements.length; i < length; i++) { + strike = elements[i]; + + formatElement = elCreate(tagName); + if (className) formatElement.className = className; + if (typeof attributes === 'object') { + for (property in attributes) { + if (attributes.hasOwnProperty(property)) { + elAttr(formatElement, key, attributes[key]); + } + } + } + + DomUtil.replaceElement(strike, formatElement); + } + }, + + /** + * Removes a format element from the current selection. + * + * The removal uses a few techniques to remove the target element(s) without harming + * nesting nor any other formatting present. The steps taken are described below: + * + * 1. The browser will wrap all parts of the selection into tags + * + * This isn't the most efficient way to isolate each selected node, but is the + * most reliable way to accomplish this because the browser will insert them + * exactly where the range spans without harming the node nesting. + * + * Basically it is a trade-off between efficiency and reliability, the performance + * is still excellent but could be better at the expense of an increased complexity, + * which simply doesn't exactly pay off. + * + * 2. Iterate over each inserted and isolate all relevant ancestors + * + * Format tags can appear both as a child of the as well as once or multiple + * times as an ancestor. + * + * It uses ranges to select the contents before the element up to the start + * of the last matching ancestor and cuts out the nodes. The browser will ensure that + * the resulting fragment will include all relevant ancestors that were present before. + * + * The example below will use the fictional elements as the tag to remove, the + * pipe ("|") is used to denote the outer node boundaries. + * + * Before: + * |This is a simple example| + * After: + * |This is a |simple example| + * + * As a result we can now remove both inside the element as well as + * the outer without harming the effect of for the preceeding siblings. + * + * This process is repeated for siblings appearing after the element too, it + * works as described above but flipped. This is an expensive operation and will only + * take place if there are any matching ancestors that need to be considered. + * + * Inspired by http://stackoverflow.com/a/12899461 + * + * 3. Remove all matching ancestors, child elements and last the element itself + * + * Depending on the amount of nested matching nodes, this process will move a lot of + * nodes around. Removing the element will require all its child nodes to be moved + * in front of , they will actually become a sibling of . Afterwards the + * (now empty) element can be safely removed without losing any nodes. + * + * + * One last hint: This method will not check if the selection at some point contains at + * least one target element, it assumes that the user will not take any action that invokes + * this method for no reason (unless they want to waste CPU cycles, in that case they're + * welcome). + * + * This is especially important for developers as this method shouldn't be called for + * no good reason. Even though it is super fast, it still comes with expensive DOM operations + * and especially low-end devices (such as cheap smartphones) might not exactly like executing + * this method on large documents. + * + * If you fell the need to invoke this method anyway, go ahead. I'm a comment, not a cop. + * + * @param {Element} editorElement editor element + * @param {string} tagName format tag name that should be removed + */ + removeFormat: function(editorElement, tagName) { + tagName = tagName.toUpperCase(); + + var strikeElements = elByTag('strike', editorElement); + + // remove any element first, all though there shouldn't be any at all + while (strikeElements.length) { + DomUtil.unwrapChildNodes(strikeElements[0]); + } + + document.execCommand('strikethrough'); + + var elements, lastMatchingParent, strikeElement; + while (strikeElements.length) { + strikeElement = strikeElements[0]; + lastMatchingParent = this._getLastMatchingParent(strikeElement, editorElement, tagName); + + if (lastMatchingParent !== null) { + this._handleParentNodes(strikeElement, lastMatchingParent, tagName) + } + + // remove offending elements from child nodes + elements = elByTag(tagName.toLowerCase(), strikeElement); + while (elements.length) { + DomUtil.unwrapChildNodes(elements[0]); + } + + // remove strike element itself + DomUtil.unwrapChildNodes(strikeElement); + } + }, + + /** + * Slices relevant parent nodes and removes matching ancestors. + * + * @param {Element} strikeElement strike element representing the text selection + * @param {Element} lastMatchingParent last matching ancestor element + * @param {string} tagName format tag name that should be removed + * @protected + */ + _handleParentNodes: function(strikeElement, lastMatchingParent, tagName) { + var range; + + // selection does not begin at parent node start, slice all relevant parent + // nodes to ensure that selection is then at the beginning while preserving + // all proper ancestor elements + // + // before: (the pipe represents the node boundary) + // |otherContent <-- selection --> + // after: + // |otherContent| |<-- selection --> + if (!DomUtil.isAtNodeStart(strikeElement, lastMatchingParent)) { + range = document.createRange(); + range.setStartBefore(lastMatchingParent); + range.setEndBefore(strikeElement); + + var fragment = range.extractContents(); + lastMatchingParent.parentNode.insertBefore(fragment, lastMatchingParent); + } + + // selection does not end at parent node end, slice all relevant parent nodes + // to ensure that selection is then at the end while preserving all proper + // ancestor elements + // + // before: (the pipe represents the node boundary) + // <-- selection --> otherContent| + // after: + // <-- selection -->| |otherContent| + if (!DomUtil.isAtNodeEnd(strikeElement, lastMatchingParent)) { + range = document.createRange(); + range.setStartAfter(strikeElement); + range.setEndAfter(lastMatchingParent); + + fragment = range.extractContents(); + lastMatchingParent.parentNode.insertBefore(fragment, lastMatchingParent.nextSibling); + } + + // the strike element is now some kind of isolated, meaning we can now safely + // remove all offending parent nodes without influcing formatting of any content + // before or after the element + var elements = elByTag(tagName, lastMatchingParent); + while (elements.length) { + DomUtil.unwrapChildNodes(elements[0]); + } + + // finally remove the parent itself + DomUtil.unwrapChildNodes(lastMatchingParent); + }, + + /** + * Finds the last matching ancestor until it reaches the editor element. + * + * @param {Element} strikeElement strike element representing the text selection + * @param {Element} editorElement editor element + * @param {string} tagName format tag name that should be removed + * @returns {(Element|null)} last matching ancestor element or null if there is none + * @protected + */ + _getLastMatchingParent: function(strikeElement, editorElement, tagName) { + var parent = strikeElement.parentNode, match = null; + while (parent !== editorElement) { + if (parent.nodeName === tagName) { + match = parent; + } + + parent = parent.parentNode; + } + + return match; + } + }; +}); -- 2.20.1