Added support for text-color in Redactor II
authorAlexander Ebert <ebert@woltlab.com>
Thu, 4 Feb 2016 11:27:08 +0000 (12:27 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Thu, 4 Feb 2016 11:27:18 +0000 (12:27 +0100)
com.woltlab.wcf/templates/wysiwyg.tpl
wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabColor.js [new file with mode: 0644]
wcfsetup/install/files/js/3rdParty/redactor2/plugins/WoltLabDropdown.js
wcfsetup/install/files/js/WoltLab/WCF/Dom/Util.js
wcfsetup/install/files/js/WoltLab/WCF/Ui/Redactor/Format.js [new file with mode: 0644]

index e12ed688ddc1483aeb53ead3ac41593d33d8b647..41f4d9626ca89b57eae3e68f0e4cf9ae6818f209 100644 (file)
@@ -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 (file)
index 0000000..8dbc017
--- /dev/null
@@ -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));
+               }
+       };
+};
index 44f7e9af616af31b7c32247164d2f4bbce49c66e..1e21ae552769512cc588f644e5efe03be2d77b16 100644 (file)
@@ -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);
index d96e7c977498a0e7b84ea692eec30a55e6fe8e78..5de5990b68a62dbb97ca2101c8c99a2650d04af6 100644 (file)
@@ -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 <http://opensource.org/licenses/lgpl-license.php>
@@ -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 (file)
index 0000000..36f4b6b
--- /dev/null
@@ -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 <http://opensource.org/licenses/lgpl-license.php>
+ * @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 <strike> 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 <strike> and isolate all relevant ancestors
+                * 
+                *      Format tags can appear both as a child of the <strike> as well as once or multiple
+                *      times as an ancestor.
+                *      
+                *      It uses ranges to select the contents before the <strike> 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 <bar> elements as the tag to remove, the
+                *      pipe ("|") is used to denote the outer node boundaries.
+                *      
+                *      Before:
+                *      |<bar>This is <foo>a <strike>simple <bar>example</bar></strike></foo></bar>|
+                *      After:
+                *      |<bar>This is <foo>a </foo></bar>|<bar><foo>simple <bar>example</bar></strike></foo></bar>|
+                *      
+                *      As a result we can now remove <bar> both inside the <strike> element as well as
+                *      the outer <bar> without harming the effect of <bar> for the preceeding siblings.
+                *      
+                *      This process is repeated for siblings appearing after the <strike> 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 <strike> element itself
+                * 
+                *      Depending on the amount of nested matching nodes, this process will move a lot of
+                *      nodes around. Removing the <bar> element will require all its child nodes to be moved
+                *      in front of <bar>, they will actually become a sibling of <bar>. Afterwards the
+                *      (now empty) <bar> 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 <strike> 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;
+               }
+       };
+});