diff --git a/Gruntfile.js b/Gruntfile.js index d3ad948f..94fda317 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -84,12 +84,12 @@ module.exports = function(grunt) { }, tinyice: { files: { - 'dist/ice_editor_plugin.js': 'lib/tinymce/jscripts/tiny_mce/plugins/ice/editor_plugin.js' + 'dist/ice_editor_plugin.js': 'lib/tinymce/js/tinymce/plugins/ice/plugin.min.js' } }, tinysr: { files: { - 'dist/sr_editor_plugin.js': 'lib/tinymce/jscripts/tiny_mce/plugins/icesearchreplace/editor_plugin.js' + 'dist/sr_editor_plugin.js': 'lib/tinymce/js/tinymce/plugins/icesearchreplace/plugin.min.js' } } }, @@ -139,7 +139,7 @@ module.exports = function(grunt) { }); var cpTinyDir = function(dir) { - grunt.file.recurse('lib/tinymce/jscripts/tiny_mce/plugins/' + dir + '/', function(abspath, rootdir, subdir, filename) { + grunt.file.recurse('lib/tinymce/js/tinymce/plugins/' + dir + '/', function(abspath, rootdir, subdir, filename) { grunt.file.copy(rootdir + '/' + (subdir ? subdir + '/' : '') + filename,'dist/tinymce/plugins/' + dir + '/' + (subdir ? subdir + '/' : '') + '/' + filename); }); }; diff --git a/demo/ice/bookmark.js b/demo/ice/bookmark.js new file mode 100644 index 00000000..73690efe --- /dev/null +++ b/demo/ice/bookmark.js @@ -0,0 +1,166 @@ +(function() { + + var exports = this, Bookmark; + + Bookmark = function(env, range, keepOldBookmarks) { + + this.env = env; + this.element = env.element; + this.selection = this.env.selection; + + // Remove all bookmarks? + if (!keepOldBookmarks) { + this.removeBookmarks(this.element); + } + + var currRange = range || this.selection.getRangeAt(0); + range = currRange.cloneRange(); + var startContainer = range.startContainer; + var endContainer = range.endContainer; + var startOffset = range.startOffset; + var endOffset = range.endOffset; + var tmp; + + // Collapse to the end of range. + range.collapse(false); + + var endBookmark = this.env.document.createElement('span'); + endBookmark.style.display = 'none'; + ice.dom.setHtml(endBookmark, ' '); + ice.dom.addClass(endBookmark, 'iceBookmark iceBookmark_end'); + endBookmark.setAttribute('iceBookmark', 'end'); + range.insertNode(endBookmark); + if(!ice.dom.isChildOf(endBookmark, this.element)) { + this.element.appendChild(endBookmark); + } + + // Move the range to where it was before. + range.setStart(startContainer, startOffset); + range.collapse(true); + + // Create the start bookmark. + var startBookmark = this.env.document.createElement('span'); + startBookmark.style.display = 'none'; + ice.dom.addClass(startBookmark, 'iceBookmark iceBookmark_start'); + ice.dom.setHtml(startBookmark, ' '); + startBookmark.setAttribute('iceBookmark', 'start'); + try { + range.insertNode(startBookmark); + + // Make sure start and end are in correct position. + if (startBookmark.previousSibling === endBookmark) { + // Reverse.. + tmp = startBookmark; + startBookmark = endBookmark; + endBookmark = tmp; + } + } catch (e) { + // NS_ERROR_UNEXPECTED: I believe this is a Firefox bug. + // It seems like if the range is collapsed and the text node is empty + // (i.e. length = 0) then Firefox tries to split the node for no reason and fails... + ice.dom.insertBefore(endBookmark, startBookmark); + } + + if (ice.dom.isChildOf(startBookmark, this.element) === false) { + if (this.element.firstChild) { + ice.dom.insertBefore(this.element.firstChild, startBookmark); + } else { + // Should not happen... + this.element.appendChild(startBookmark); + } + } + + if (!endBookmark.previousSibling) { + tmp = this.env.document.createTextNode(''); + ice.dom.insertBefore(endBookmark, tmp); + } + + // The original range object must be changed. + if (!startBookmark.nextSibling) { + tmp = this.env.document.createTextNode(''); + ice.dom.insertAfter(startBookmark, tmp); + } + + currRange.setStart(startBookmark.nextSibling, 0); + currRange.setEnd(endBookmark.previousSibling, (endBookmark.previousSibling.length || 0)); + + this.start = startBookmark; + this.end = endBookmark; + }; + + Bookmark.prototype = { + + selectBookmark: function() { + var range = this.selection.getRangeAt(0); + var startPos = null; + var endPos = null; + var startOffset = 0; + var endOffset = null; + if (this.start.nextSibling === this.end || ice.dom.getElementsBetween(this.start, this.end).length === 0) { + // Bookmark is collapsed. + if (this.end.nextSibling) { + startPos = ice.dom.getFirstChild(this.end.nextSibling); + } else if (this.start.previousSibling) { + startPos = ice.dom.getFirstChild(this.start.previousSibling); + if (startPos.nodeType === ice.dom.TEXT_NODE) { + startOffset = startPos.length; + } + } else { + // Create a text node in parent. + this.end.parentNode.appendChild(this.env.document.createTextNode('')); + startPos = ice.dom.getFirstChild(this.end.nextSibling); + } + } else { + if (this.start.nextSibling) { + startPos = ice.dom.getFirstChild(this.start.nextSibling); + } else { + if (!this.start.previousSibling) { + var tmp = this.env.document.createTextNode(''); + ice.dom.insertBefore(this.start, tmp); + } + + startPos = ice.dom.getLastChild(this.start.previousSibling); + startOffset = startPos.length; + } + + if (this.end.previousSibling) { + endPos = ice.dom.getLastChild(this.end.previousSibling); + } else { + endPos = ice.dom.getFirstChild(this.end.nextSibling || this.end); + endOffset = 0; + } + } + + ice.dom.remove([this.start, this.end]); + + if (endPos === null) { + range.setEnd(startPos, startOffset); + range.collapse(false); + } else { + range.setStart(startPos, startOffset); + if (endOffset === null) { + endOffset = (endPos.length || 0); + } + range.setEnd(endPos, endOffset); + } + + try { + this.selection.addRange(range); + } catch (e) { + // IE may throw exception for hidden elements.. + } + }, + + getBookmark: function(parent, type) { + var elem = ice.dom.getClass('iceBookmark_' + type, parent)[0]; + return elem; + }, + + removeBookmarks: function(elem) { + ice.dom.remove(ice.dom.getClass('iceBookmark', elem, 'span')); + } + }; + + exports.Bookmark = Bookmark; + +}).call(this.ice); \ No newline at end of file diff --git a/demo/ice/dom.js b/demo/ice/dom.js new file mode 100644 index 00000000..c1d4eb2f --- /dev/null +++ b/demo/ice/dom.js @@ -0,0 +1,998 @@ +(function () { + var exports = this, + dom = {}; + + dom.DOM_VK_DELETE = 8; + dom.DOM_VK_LEFT = 37; + dom.DOM_VK_UP = 38; + dom.DOM_VK_RIGHT = 39; + dom.DOM_VK_DOWN = 40; + dom.DOM_VK_ENTER = 13; + dom.ELEMENT_NODE = 1; + dom.ATTRIBUTE_NODE = 2; + dom.TEXT_NODE = 3; + dom.CDATA_SECTION_NODE = 4; + dom.ENTITY_REFERENCE_NODE = 5; + dom.ENTITY_NODE = 6; + dom.PROCESSING_INSTRUCTION_NODE = 7; + dom.COMMENT_NODE = 8; + dom.DOCUMENT_NODE = 9; + dom.DOCUMENT_TYPE_NODE = 10; + dom.DOCUMENT_FRAGMENT_NODE = 11; + dom.NOTATION_NODE = 12; + dom.CHARACTER_UNIT = 'character'; + dom.WORD_UNIT = 'word'; + dom.BREAK_ELEMENT = 'br'; + dom.CONTENT_STUB_ELEMENTS = ['img', 'hr', 'iframe', 'param', 'link', 'meta', 'input', 'frame', 'col', 'base', 'area']; + dom.BLOCK_ELEMENTS = ['p', 'div', 'pre', 'ul', 'ol', 'li', 'table', 'tbody', 'td', 'th', 'fieldset', 'form', 'blockquote', 'dl', 'dt', 'dd', 'dir', 'center', 'address', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + dom.TEXT_CONTAINER_ELEMENTS = ['p', 'div', 'pre', 'li', 'td', 'th', 'blockquote', 'dt', 'dd', 'center', 'address', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + + dom.STUB_ELEMENTS = dom.CONTENT_STUB_ELEMENTS.slice(); + dom.STUB_ELEMENTS.push(dom.BREAK_ELEMENT); + + dom.getKeyChar = function (e) { + return String.fromCharCode(e.which); + }; + dom.getClass = function (className, startElement, tagName) { + if (!startElement) { + startElement = document.body; + } + className = '.' + className.split(' ').join('.'); + if (tagName) { + className = tagName + className; + } + return jQuery.makeArray(jQuery(startElement).find(className)); + }; + dom.getId = function (id, startElement) { + if (!startElement) { + startElement = document; + } + element = startElement.getElementById(id); + return element; + }; + dom.getTag = function (tagName, startElement) { + if (!startElement) { + startElement = document; + } + return jQuery.makeArray(jQuery(startElement).find(tagName)); + }; + dom.getElementWidth = function (element) { + return element.offsetWidth; + }; + dom.getElementHeight = function (element) { + return element.offsetHeight; + }; + dom.getElementDimensions = function (element) { + var result = { + 'width': dom.getElementWidth(element), + 'height': dom.getElementHeight(element) + }; + return result; + }; + dom.trim = function (string) { + return jQuery.trim(string); + }; + dom.empty = function (element) { + if (element) { + return jQuery(element).empty(); + } + }; + dom.remove = function (element) { + if (element) { + return jQuery(element).remove(); + } + }; + dom.prepend = function (parent, elem) { + jQuery(parent).prepend(elem); + }; + dom.append = function (parent, elem) { + jQuery(parent).append(elem); + }; + dom.insertBefore = function (before, elem) { + jQuery(before).before(elem); + }; + dom.insertAfter = function (after, elem) { + jQuery(after).after(elem); + }; + dom.getHtml = function (element) { + return jQuery(element).html(); + }; + dom.setHtml = function (element, content) { + if (element) { + jQuery(element).html(content); + } + }; + // Remove whitespace/newlines between nested block elements + // that are supported by ice. + // For example the following element with innerHTML: + //

para

+ // Will be converted to the following: + //

para

+ dom.removeWhitespace = function(element) { + jQuery(element).contents().filter(function() { + // Ice supports UL and OL, so recurse in these blocks to + // make sure that spaces don't exist between inner LI. + if (this.nodeType != ice.dom.TEXT_NODE && this.nodeName == 'UL' || this.nodeName == 'OL') { + dom.removeWhitespace(this); + return false; + } else if (this.nodeType != ice.dom.TEXT_NODE) { + return false; + } else { + return !/\S/.test(this.nodeValue); + } + }).remove(); + }; + dom.contents = function (el) { + return jQuery.makeArray(jQuery(el).contents()); + }; + /** + * Returns the inner contents of `el` as a DocumentFragment. + */ + dom.extractContent = function (el) { + var frag = document.createDocumentFragment(), + child; + while ((child = el.firstChild)) { + frag.appendChild(child); + } + return frag; + }; + + /** + * Returns this `node` or the first parent tracking node that matches the given `selector`. + */ + dom.getNode = function (node, selector) { + return dom.is(node, selector) ? node : dom.parents(node, selector)[0] || null; + }; + + dom.getParents = function (elements, filter, stopEl) { + var res = jQuery(elements).parents(filter); + var ln = res.length; + var ar = []; + for (var i = 0; i < ln; i++) { + if (res[i] === stopEl) { + break; + } + ar.push(res[i]); + } + return ar; + }; + dom.hasBlockChildren = function (parent) { + var c = parent.childNodes.length; + for (var i = 0; i < c; i++) { + if (parent.childNodes[i].nodeType === dom.ELEMENT_NODE) { + if (dom.isBlockElement(parent.childNodes[i]) === true) { + return true; + } + } + } + return false; + }; + dom.removeTag = function (element, selector) { + jQuery(element).find(selector).replaceWith(function () { + return jQuery(this).contents(); + }); + return element; + }; + dom.stripEnclosingTags = function (content, allowedTags) { + var c = jQuery(content); + c.find('*').not(allowedTags).replaceWith(function () { + var ret = jQuery(); + var $this; + try{ + $this = jQuery(this); + ret = $this.contents(); + } catch(e){} + + // Handling jQuery bug (which may be fixed in the official release later) + // http://bugs.jquery.com/ticket/13401 + if(ret.length === 0){ + $this.remove(); + } + return ret; + }); + return c[0]; + }; + dom.getSiblings = function (element, dir, elementNodesOnly, stopElem) { + if (elementNodesOnly === true) { + if (dir === 'prev') { + return jQuery(element).prevAll(); + } else { + return jQuery(element).nextAll(); + } + } else { + var elems = []; + if (dir === 'prev') { + while (element.previousSibling) { + element = element.previousSibling; + if (element === stopElem) { + break; + } + elems.push(element); + } + } else { + while (element.nextSibling) { + element = element.nextSibling; + if (element === stopElem) { + break; + } + elems.push(element); + } + } + return elems; + } + }; + dom.getNodeTextContent = function (node) { + return jQuery(node).text(); + }; + dom.getNodeStubContent = function (node) { + return jQuery(node).find(dom.CONTENT_STUB_ELEMENTS.join(', ')); + }; + dom.hasNoTextOrStubContent = function (node) { + if (dom.getNodeTextContent(node).length > 0) return false; + if (jQuery(node).find(dom.CONTENT_STUB_ELEMENTS.join(', ')).length > 0) return false; + return true; + }; + dom.getNodeCharacterLength = function (node) { + return dom.getNodeTextContent(node).length + jQuery(node).find(dom.STUB_ELEMENTS.join(', ')).length; + }; + dom.setNodeTextContent = function (node, txt) { + return jQuery(node).text(txt); + }; + dom.getTagName = function (node) { + return node.tagName && node.tagName.toLowerCase() || null; + }; + dom.getIframeDocument = function (iframe) { + var doc = null; + if (iframe.contentDocument) { + doc = iframe.contentDocument; + } else if (iframe.contentWindow) { + doc = iframe.contentWindow.document; + } else if (iframe.document) { + doc = iframe.document; + } + return doc; + }; + dom.isBlockElement = function (element) { + return dom.BLOCK_ELEMENTS.lastIndexOf(element.nodeName.toLowerCase()) != -1; + }; + dom.isStubElement = function (element) { + return dom.STUB_ELEMENTS.lastIndexOf(element.nodeName.toLowerCase()) != -1; + }; + dom.removeBRFromChild = function (node) { + if (node && node.hasChildNodes()) { + for(var z=0; z < node.childNodes.length ; z++) { + var child = node.childNodes[z]; + if (child && (ice.dom.BREAK_ELEMENT == ice.dom.getTagName(child))) { + child.parentNode.removeChild(child); + } + } + } + }; + dom.isChildOf = function (el, parent) { + try { + while (el && el.parentNode) { + if (el.parentNode === parent) { + return true; + } + el = el.parentNode; + } + } catch (e) {} + return false; + }; + dom.isChildOfTagName = function (el, name) { + try { + while (el && el.parentNode) { + if (el.parentNode && el.parentNode.tagName && el.parentNode.tagName.toLowerCase() === name) { + return el.parentNode; + } + el = el.parentNode; + } + } catch (e) {} + return false; + }; + + + dom.isChildOfTagNames = function (el, names) { + try { + while (el && el.parentNode) { + if (el.parentNode && el.parentNode.tagName) { + tagName = el.parentNode.tagName.toLowerCase(); + for (var i = 0; i < names.length; i++) { + if (tagName === names[i]) { + return el.parentNode; + } + } + } + el = el.parentNode; + } + } catch (e) {} + return null; + }; + + dom.isChildOfClassName = function (el, name) { + try { + while (el && el.parentNode) { + if (jQuery(el.parentNode).hasClass(name)) return el.parentNode; + el = el.parentNode; + } + } catch (e) {} + return null; + }; + dom.cloneNode = function (elems, cloneEvents) { + if (cloneEvents === undefined) { + cloneEvents = true; + } + return jQuery(elems).clone(cloneEvents); + }; + + dom.bind = function (element, event, callback) { + return jQuery(element).bind(event, callback); + }; + + dom.unbind = function (element, event, callback) { + return jQuery(element).unbind(event, callback); + }; + + dom.attr = function (elements, key, val) { + if (val) return jQuery(elements).attr(key, val); + else return jQuery(elements).attr(key); + }; + dom.replaceWith = function (node, replacement) { + return jQuery(node).replaceWith(replacement); + }; + dom.removeAttr = function (elements, name) { + jQuery(elements).removeAttr(name); + }; + dom.getElementsBetween = function (fromElem, toElem) { + var elements = []; + if (fromElem === toElem) { + return elements; + } + if (dom.isChildOf(toElem, fromElem) === true) { + var fElemLen = fromElem.childNodes.length; + for (var i = 0; i < fElemLen; i++) { + if (fromElem.childNodes[i] === toElem) { + break; + } else if (dom.isChildOf(toElem, fromElem.childNodes[i]) === true) { + return dom.arrayMerge(elements, dom.getElementsBetween(fromElem.childNodes[i], toElem)); + } else { + elements.push(fromElem.childNodes[i]); + } + } + return elements; + } + var startEl = fromElem.nextSibling; + while (startEl) { + if (dom.isChildOf(toElem, startEl) === true) { + elements = dom.arrayMerge(elements, dom.getElementsBetween(startEl, toElem)); + return elements; + } else if (startEl === toElem) { + return elements; + } else { + elements.push(startEl); + startEl = startEl.nextSibling; + } + } + var fromParents = dom.getParents(fromElem); + var toParents = dom.getParents(toElem); + var parentElems = dom.arrayDiff(fromParents, toParents, true); + var pElemLen = parentElems.length; + for (var j = 0; j < (pElemLen - 1); j++) { + elements = dom.arrayMerge(elements, dom.getSiblings(parentElems[j], 'next')); + } + var lastParent = parentElems[(parentElems.length - 1)]; + elements = dom.arrayMerge(elements, dom.getElementsBetween(lastParent, toElem)); + return elements; + }; + dom.getCommonAncestor = function (a, b) { + var node = a; + while (node) { + if (dom.isChildOf(b, node) === true) { + return node; + } + node = node.parentNode; + } + return null; + }; + dom.getNextNode = function (node, container) { + if (node) { + while (node.parentNode) { + if (node === container) { + return null; + } + + if (node.nextSibling) { + // if next sibling is an empty text node, look further + if (node.nextSibling.nodeType === dom.TEXT_NODE && node.nextSibling.length === 0) { + node = node.nextSibling; + continue; + } + + return dom.getFirstChild(node.nextSibling); + } + node = node.parentNode; + } + } + return null; + }; + dom.getNextContentNode = function (node, container) { + if (node) { + while (node.parentNode) { + if (node === container) { + return null; + } + + if (node.nextSibling && dom.canContainTextElement(dom.getBlockParent(node))) { + // if next sibling is an empty text node, look further + if (node.nextSibling.nodeType === dom.TEXT_NODE && node.nextSibling.length === 0) { + node = node.nextSibling; + continue; + } + + return node.nextSibling; + } else if (node.nextElementSibling) { + return node.nextElementSibling; + } + + node = node.parentNode; + } + } + return null; + }; + + + dom.getPrevNode = function (node, container) { + if (node) { + while (node.parentNode) { + if (node === container) { + return null; + } + + if (node.previousSibling) { + // if previous sibling is an empty text node, look further + if (node.previousSibling.nodeType === dom.TEXT_NODE && node.previousSibling.length === 0) { + node = node.previousSibling; + continue; + } + + return dom.getLastChild(node.previousSibling); + } + node = node.parentNode; + } + } + return null; + }; + dom.getPrevContentNode = function (node, container) { + if (node) { + while (node.parentNode) { + if (node === container) { + return null; + } + if (node.previousSibling && dom.canContainTextElement(dom.getBlockParent(node))) { + + // if previous sibling is an empty text node, look further + if (node.previousSibling.nodeType === dom.TEXT_NODE && node.previousSibling.length === 0) { + node = node.previousSibling; + + continue; + } + return node.previousSibling; + } else if (node.previousElementSibling) { + return node.previousElementSibling; + } + + node = node.parentNode; + } + } + return null; + }; + + dom.canContainTextElement = function (element) { + if (element && element.nodeName) { + return dom.TEXT_CONTAINER_ELEMENTS.lastIndexOf(element.nodeName.toLowerCase()) != -1; + } else { + return false; + } + }; + + dom.getFirstChild = function (node) { + if (node.firstChild) { + if (node.firstChild.nodeType === dom.ELEMENT_NODE) { + return dom.getFirstChild(node.firstChild); + } else { + return node.firstChild; + } + } + return node; + }; + dom.getLastChild = function (node) { + if (node.lastChild) { + if (node.lastChild.nodeType === dom.ELEMENT_NODE) { + return dom.getLastChild(node.lastChild); + } else { + return node.lastChild; + } + } + return node; + }; + dom.removeEmptyNodes = function (parent, callback) { + var elems = jQuery(parent).find(':empty'); + var i = elems.length; + while (i > 0) { + i--; + if (dom.isStubElement(elems[i]) === false) { + if (!callback || callback.call(this, elems[i]) !== false) { + dom.remove(elems[i]); + } + } + } + }; + dom.create = function (html) { + return jQuery(html)[0]; + }; + dom.find = function (parent, exp) { + return jQuery(parent).find(exp); + }; + dom.children = function (parent, exp) { + return jQuery(parent).children(exp); + }; + dom.parent = function (child, exp) { + return jQuery(child).parent(exp)[0]; + }; + dom.parents = function (child, exp) { + return jQuery(child).parents(exp); + }; + dom.is = function (node, exp) { + return jQuery(node).is(exp); + }; + dom.extend = function (deep, target, object1, object2) { + return jQuery.extend.apply(this, arguments); + }; + dom.walk = function (elem, callback, lvl) { + if (!elem) { + return; + } + if (!lvl) { + lvl = 0; + } + var retVal = callback.call(this, elem, lvl); + if (retVal === false) { + return; + } + if (elem.childNodes && elem.childNodes.length > 0) { + dom.walk(elem.firstChild, callback, (lvl + 1)); + } else if (elem.nextSibling) { + dom.walk(elem.nextSibling, callback, lvl); + } else if (elem.parentNode && elem.parentNode.nextSibling) { + dom.walk(elem.parentNode.nextSibling, callback, (lvl - 1)); + } + }; + dom.revWalk = function (elem, callback) { + if (!elem) { + return; + } + var retVal = callback.call(this, elem); + if (retVal === false) { + return; + } + if (elem.childNodes && elem.childNodes.length > 0) { + dom.walk(elem.lastChild, callback); + } else if (elem.previousSibling) { + dom.walk(elem.previousSibling, callback); + } else if (elem.parentNode && elem.parentNode.previousSibling) { + dom.walk(elem.parentNode.previousSibling, callback); + } + }; + dom.setStyle = function (element, property, value) { + if (element) { + jQuery(element).css(property, value); + } + }; + dom.getStyle = function (element, property) { + return jQuery(element).css(property); + }; + dom.hasClass = function (element, className) { + return jQuery(element).hasClass(className); + }; + dom.addClass = function (element, classNames) { + jQuery(element).addClass(classNames); + }; + dom.removeClass = function (element, classNames) { + jQuery(element).removeClass(classNames); + }; + dom.preventDefault = function (e) { + e.preventDefault(); + dom.stopPropagation(e); + }; + dom.stopPropagation = function (e) { + e.stopPropagation(); + }; + dom.noInclusionInherits = function (child, parent) { + if (parent instanceof String || typeof parent === 'string') { + parent = window[parent]; + } + if (child instanceof String || typeof child === 'string') { + child = window[child]; + } + var above = function () {}; + if (dom.isset(parent) === true) { + for (value in parent.prototype) { + if (child.prototype[value]) { + above.prototype[value] = parent.prototype[value]; + continue; + } + child.prototype[value] = parent.prototype[value]; + } + } + if (child.prototype) { + above.prototype.constructor = parent; + child.prototype['super'] = new above(); + } + }; + + dom.each = function (val, callback) { + jQuery.each(val, function (i, el) { + callback.call(this, i, el); + }); + }; + + dom.foreach = function (value, cb) { + if (value instanceof Array || value instanceof NodeList || typeof value.length != 'undefined' && typeof value.item != 'undefined') { + var len = value.length; + for (var i = 0; i < len; i++) { + var res = cb.call(this, i, value[i]); + if (res === false) { + break; + } + } + } else { + for (var id in value) { + if (value.hasOwnProperty(id) === true) { + var res = cb.call(this, id); + if (res === false) { + break; + } + } + } + } + }; + dom.isBlank = function (value) { + if (!value || /^\s*$/.test(value)) { + return true; + } + return false; + }; + dom.isFn = function (f) { + if (typeof f === 'function') { + return true; + } + return false; + }; + dom.isObj = function (v) { + if (v !== null && typeof v === 'object') { + return true; + } + return false; + }; + dom.isset = function (v) { + if (typeof v !== 'undefined' && v !== null) { + return true; + } + return false; + }; + dom.isArray = function (v) { + return jQuery.isArray(v); + }; + dom.isNumeric = function (str) { + var result = str.match(/^\d+$/); + if (result !== null) { + return true; + } + return false; + }; + dom.getUniqueId = function () { + var timestamp = (new Date()).getTime(); + var random = Math.ceil(Math.random() * 1000000); + var id = timestamp + '' + random; + return id.substr(5, 18).replace(/,/, ''); + }; + dom.inArray = function (needle, haystack) { + var hln = haystack.length; + for (var i = 0; i < hln; i++) { + if (needle === haystack[i]) { + return true; + } + } + return false; + }; + dom.arrayDiff = function (array1, array2, firstOnly) { + var al = array1.length; + var res = []; + for (var i = 0; i < al; i++) { + if (dom.inArray(array1[i], array2) === false) { + res.push(array1[i]); + } + } + if (firstOnly !== true) { + al = array2.length; + for (var i = 0; i < al; i++) { + if (dom.inArray(array2[i], array1) === false) { + res.push(array2[i]); + } + } + } + return res; + }; + dom.arrayMerge = function (array1, array2) { + var c = array2.length; + for (var i = 0; i < c; i++) { + array1.push(array2[i]); + } + return array1; + }; + /** + * Removes allowedTags from the given content html string. If allowedTags is a string, then it + * is expected to be a selector; otherwise, it is expected to be array of string tag names. + */ + dom.stripTags = function (content, allowedTags) { + if (typeof allowedTags === "string") { + var c = jQuery('
' + content + '
'); + c.find('*').not(allowedTags).remove(); + return c.html(); + } else { + var match; + var re = new RegExp(/<\/?(\w+)((\s+\w+(\s*=\s*(?:".*?"|'.*?'|[^'">\s]+))?)+\s*|\s*)\/?>/gim); + var resCont = content; + while ((match = re.exec(content)) != null) { + if (dom.isset(allowedTags) === false || dom.inArray(match[1], allowedTags) !== true) { + resCont = resCont.replace(match[0], ''); + } + } + return resCont; + } + }; + dom.browser = function () { + var result = {}; + result.version = jQuery.browser.version; + if (jQuery.browser.mozilla === true) { + result.type = 'mozilla'; + } else if (jQuery.browser.msie === true) { + result.type = 'msie'; + } else if (jQuery.browser.opera === true) { + result.type = 'opera'; + } else if (jQuery.browser.webkit === true) { + result.type = 'webkit'; + } + return result; + }; + dom.getBrowserType = function () { + if (this._browserType === null) { + var tests = ['msie', 'firefox', 'chrome', 'safari']; + var tln = tests.length; + for (var i = 0; i < tln; i++) { + var r = new RegExp(tests[i], 'i'); + if (r.test(navigator.userAgent) === true) { + this._browserType = tests[i]; + return this._browserType; + } + } + + this._browserType = 'other'; + } + return this._browserType; + }; + dom.getWebkitType = function(){ + if(dom.browser().type !== "webkit") { + console.log("Not a webkit!"); + return false; + } + var isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; + if(isSafari) return "safari"; + return "chrome"; + }; + dom.isBrowser = function (browser) { + return (dom.browser().type === browser); + }; + + dom.getBlockParent = function (node, container) { + if (dom.isBlockElement(node) === true) { + return node; + } + if (node) { + while (node.parentNode) { + node = node.parentNode; + if (node === container) { + return null; + } + + if (dom.isBlockElement(node) === true) { + return node; + } + } + } + return null; + }; + dom.findNodeParent = function (node, selector, container) { + if (node) { + while (node.parentNode) { + if (node === container) { + return null; + } + + if (dom.is(node, selector) === true) { + return node; + } + node = node.parentNode; + } + } + return null; + }; + dom.onBlockBoundary = function (leftContainer, rightContainer, blockEls) { + if (!leftContainer || !rightContainer) return false + var bleft = dom.isChildOfTagNames(leftContainer, blockEls) || dom.is(leftContainer, blockEls.join(', ')) && leftContainer || null; + var bright = dom.isChildOfTagNames(rightContainer, blockEls) || dom.is(rightContainer, blockEls.join(', ')) && rightContainer || null; + return (bleft !== bright); + }; + + dom.isOnBlockBoundary = function (leftContainer, rightContainer, container) { + if (!leftContainer || !rightContainer) return false + var bleft = dom.getBlockParent(leftContainer, container) || dom.isBlockElement(leftContainer, container) && leftContainer || null; + var bright = dom.getBlockParent(rightContainer, container) || dom.isBlockElement(rightContainer, container) && rightContainer || null; + return (bleft !== bright); + }; + + dom.mergeContainers = function (node, mergeToNode) { + if (!node || !mergeToNode) return false; + + if (node.nodeType === dom.TEXT_NODE || dom.isStubElement(node)) { + // Move only this node. + mergeToNode.appendChild(node); + } else if (node.nodeType === dom.ELEMENT_NODE) { + // Move all the child nodes to the new parent. + while (node.firstChild) { + mergeToNode.appendChild(node.firstChild); + } + + dom.remove(node); + } + return true; + }; + + dom.mergeBlockWithSibling = function (range, block, next) { + var siblingBlock = next ? jQuery(block).next().get(0) : jQuery(block).prev().get(0); // block['nextSibling'] : block['previousSibling']; + if (next) dom.mergeContainers(siblingBlock, block); + else dom.mergeContainers(block, siblingBlock); + range.collapse(true); + return true; + }; + + dom.date = function (format, timestamp, tsIso8601) { + if (timestamp === null && tsIso8601) { + timestamp = dom.tsIso8601ToTimestamp(tsIso8601); + if (!timestamp) { + return; + } + } + var date = new Date(timestamp); + var formats = format.split(''); + var fc = formats.length; + var dateStr = ''; + for (var i = 0; i < fc; i++) { + var r = ''; + var f = formats[i]; + switch (f) { + case 'D': + case 'l': + var names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + r = names[date.getDay()]; + if (f === 'D') { + r = r.substring(0, 3); + } + break; + case 'F': + case 'm': + r = date.getMonth() + 1; + if (r < 10) r = '0' + r; + break; + case 'M': + months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + r = months[date.getMonth()]; + if (f === 'M') { + r = r.substring(0, 3); + } + break; + case 'd': + r = date.getDate(); + break; + case 'S': + r = dom.getOrdinalSuffix(date.getDate()); + break; + case 'Y': + r = date.getFullYear(); + break; + case 'y': + r = date.getFullYear(); + r = r.toString().substring(2); + break; + case 'H': + r = date.getHours(); + break; + case 'h': + r = date.getHours(); + if (r === 0) { + r = 12; + } else if (r > 12) { + r -= 12; + } + break; + case 'i': + r = dom.addNumberPadding(date.getMinutes()); + break; + case 'a': + r = 'am'; + if (date.getHours() >= 12) { + r = 'pm'; + } + break; + default: + r = f; + break; + } + dateStr += r; + } + return dateStr; + }; + dom.getOrdinalSuffix = function (number) { + var suffix = ''; + var tmp = (number % 100); + if (tmp >= 4 && tmp <= 20) { + suffix = 'th'; + } else { + switch (number % 10) { + case 1: + suffix = 'st'; + break; + case 2: + suffix = 'nd'; + break; + case 3: + suffix = 'rd'; + break; + default: + suffix = 'th'; + break; + } + } + return suffix; + }; + dom.addNumberPadding = function (number) { + if (number < 10) { + number = '0' + number; + } + return number; + }; + dom.tsIso8601ToTimestamp = function (tsIso8601) { + var regexp = /(\d\d\d\d)(?:-?(\d\d)(?:-?(\d\d)(?:[T ](\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(?:Z|(?:([-+])(\d\d)(?::?(\d\d))?)?)?)?)?)?/; + var d = tsIso8601.match(new RegExp(regexp)); + if (d) { + var date = new Date(); + date.setDate(d[3]); + date.setFullYear(d[1]); + date.setMonth(d[2] - 1); + date.setHours(d[4]); + date.setMinutes(d[5]); + date.setSeconds(d[6]); + var offset = (d[9] * 60); + if (d[8] === '+') { + offset *= -1; + } + offset -= date.getTimezoneOffset(); + var timestamp = (date.getTime() + (offset * 60 * 1000)); + return timestamp; + } + return null; + }; + + exports.dom = dom; + +}).call(this.ice); diff --git a/demo/ice/ice.js b/demo/ice/ice.js new file mode 100644 index 00000000..e22be15b --- /dev/null +++ b/demo/ice/ice.js @@ -0,0 +1,1691 @@ +(function () { + + var exports = this, + defaults, InlineChangeEditor; + + defaults = { + // ice node attribute names: + changeIdAttribute: 'data-cid', + userIdAttribute: 'data-userid', + userNameAttribute: 'data-username', + timeAttribute: 'data-time', + + // Prepended to `changeType.alias` for classname uniqueness, if needed + attrValuePrefix: '', + + // Block element tagname, which wrap text and other inline nodes in `this.element` + blockEl: 'p', + + // All permitted block element tagnames + blockEls: ['p', 'ol', 'ul', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'], + + // Unique style prefix, prepended to a digit, incremented for each encountered user, and stored + // in ice node class attributes - cts1, cts2, cts3, ... + stylePrefix: 'cts', + currentUser: { + id: null, + name: null + }, + + // Default change types are insert and delete. Plugins or outside apps should extend this + // if they want to manage new change types. The changeType name is used as a primary + // reference for ice nodes; the `alias`, is dropped in the class attribute and is the + // primary method of identifying ice nodes; and `tag` is used for construction only. + // Invoking `this.getCleanContent()` will remove all delete type nodes and remove the tags + // for the other types, leaving the html content in place. + changeTypes: { + insertType: { + tag: 'span', + alias: 'ins', + action: 'Inserted' + }, + deleteType: { + tag: 'span', + alias: 'del', + action: 'Deleted' + } + }, + + // If `true`, setup event listeners on `this.element` and handle events - good option for a basic + // setup without a text editor. Otherwise, when set to `false`, events need to be manually passed + // to `handleEvent`, which is good for a text editor with an event callback handler, like tinymce. + handleEvents: false, + + // Sets this.element with the contentEditable element + contentEditable: true, + + // Switch for toggling track changes on/off - when `false` events will be ignored. + isTracking: true, + + // NOT IMPLEMENTED - Selector for elements that will not get track changes + noTrack: '.ice-no-track', + + // Selector for elements to avoid - move range before or after - similar handling to deletes + avoid: '.ice-avoid', + + // Switch for whether paragraph breaks should be removed when the user is deleting over a + // paragraph break while changes are tracked. + mergeBlocks: true + }; + + InlineChangeEditor = function (options) { + + // Data structure for modelling changes in the element according to the following model: + // [changeid] => {`type`, `time`, `userid`, `username`} + this._changes = {}; + + options || (options = {}); + if (!options.element) throw Error("`options.element` must be defined for ice construction."); + + ice.dom.extend(true, this, defaults, options); + + this.pluginsManager = new ice.IcePluginManager(this); + if (options.plugins) this.pluginsManager.usePlugins('ice-init', options.plugins); + }; + + InlineChangeEditor.prototype = { + // Tracks all of the styles for users according to the following model: + // [userId] => styleId; where style is "this.stylePrefix" + "this.uniqueStyleIndex" + _userStyles: {}, + _styles: {}, + + // Incremented for each new user and appended to they style prefix, and dropped in the + // ice node class attribute. + _uniqueStyleIndex: 0, + + _browserType: null, + + // One change may create multiple ice nodes, so this keeps track of the current batch id. + _batchChangeid: null, + + // Incremented for each new change, dropped in the changeIdAttribute. + _uniqueIDIndex: 1, + + // Temporary bookmark tags for deletes, when delete placeholding is active. + _delBookmark: 'tempdel', + isPlaceHoldingDeletes: false, + + /** + * Turns on change tracking - sets up events, if needed, and initializes the environment, + * range, and editor. + */ + startTracking: function () { + this.element.setAttribute('contentEditable', this.contentEditable); + + // If we are handling events setup the delegate to handle various events on `this.element`. + if (this.handleEvents) { + var self = this; + ice.dom.bind(self.element, 'keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice', function (e) { + return self.handleEvent(e); + }); + } + + this.initializeEnvironment(); + this.initializeEditor(); + this.findTrackTags(); + this.initializeRange(); + + this.pluginsManager.fireEnabled(this.element); + return this; + }, + + /** + * Removes contenteditability and stops event handling. + */ + stopTracking: function () { + this.element.setAttribute('contentEditable', !this.contentEditable); + + // If we are handling events setup the delegate to handle various events on `this.element`. + if (this.handleEvents) { + var self = this; + ice.dom.unbind(self.element, 'keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice'); + } + + this.pluginsManager.fireDisabled(this.element); + return this; + }, + + /** + * Initializes the `env` object with pointers to key objects of the page. + */ + initializeEnvironment: function () { + this.env || (this.env = {}); + this.env.element = this.element; + this.env.document = this.element.ownerDocument; + this.env.window = this.env.document.defaultView || this.env.document.parentWindow || window; + this.env.frame = this.env.window.frameElement; + this.env.selection = this.selection = new ice.Selection(this.env); + // Hack for using custom tags in IE 8/7 + this.env.document.createElement(this.changeTypes.insertType.tag); + this.env.document.createElement(this.changeTypes.deleteType.tag); + }, + + /** + * Initializes the internal range object and sets focus to the editing element. + */ + initializeRange: function () { + var range = this.selection.createRange(); + range.setStart(ice.dom.find(this.element, this.blockEls.join(', '))[0], 0); + range.collapse(true); + this.selection.addRange(range); + if (this.env.frame) this.env.frame.contentWindow.focus(); + else this.element.focus(); + }, + + /** + * Initializes the content in the editor - cleans non-block nodes found between blocks. + */ + initializeEditor: function () { + // Clean the element html body - add an empty block if there is no body, or remove any + // content between elements. + var body = this.env.document.createElement('div'); + if (this.element.childNodes.length) { + body.innerHTML = this.element.innerHTML; + ice.dom.removeWhitespace(body); + if (body.innerHTML === '') body.appendChild(ice.dom.create('<' + this.blockEl + ' >
')); + } else { + body.appendChild(ice.dom.create('<' + this.blockEl + ' >
')); + } + if (this.element.innerHTML != body.innerHTML) { + this.element.innerHTML = body.innerHTML; + } + + }, + + /* + * Updates the list of changes to include all track tags found inside the element. + */ + findTrackTags: function () { + + // Grab class for each changeType + var self = this, changeTypeClasses = []; + for (var changeType in this.changeTypes) { + changeTypeClasses.push(this._getIceNodeClass(changeType)); + } + + ice.dom.each(ice.dom.find(this.element, '.' + changeTypeClasses.join(', .')), function (i, el) { + var styleIndex = 0; + var ctnType = ''; + var classList = el.className.split(' '); + for (var i = 0; i < classList.length; i++) { + var styleReg = new RegExp(self.stylePrefix + '-(\\d+)').exec(classList[i]); + if (styleReg) styleIndex = styleReg[1]; + var ctnReg = new RegExp('(' + changeTypeClasses.join('|') + ')').exec(classList[i]); + if (ctnReg) ctnType = self._getChangeTypeFromAlias(ctnReg[1]); + } + var userid = ice.dom.attr(el, self.userIdAttribute); + self.setUserStyle(userid, Number(styleIndex)); + var changeid = ice.dom.attr(el, self.changeIdAttribute); + self._changes[changeid] = { + type: ctnType, + userid: userid, + username: ice.dom.attr(el, self.userNameAttribute), + time: ice.dom.attr(el, self.timeAttribute) + }; + }); + }, + + /** + * Turn on change tracking and event handling. + */ + enableChangeTracking: function () { + this.isTracking = true; + this.pluginsManager.fireEnabled(this.element); + }, + + /** + * Turn off change tracking and event handling. + */ + disableChangeTracking: function () { + this.isTracking = false; + this.pluginsManager.fireDisabled(this.element); + }, + + /** + * Set the user to be tracked. A user object has the following properties: + * {`id`, `name`} + */ + setCurrentUser: function (user) { + this.currentUser = user; + }, + + /** + * If tracking is on, handles event e when it is one of the following types: + * mouseup, mousedown, keypress, keydown, and keyup. Each event type is + * propagated to all of the plugins. Prevents default handling if the event + * was fully handled. + */ + handleEvent: function (e) { + if (!this.isTracking) return; + if (e.type == 'mouseup') { + var self = this; + setTimeout(function () { + self.mouseUp(e); + }, 200); + } else if (e.type == 'mousedown') { + return this.mouseDown(e); + } else if (e.type == 'keypress') { + var needsToBubble = this.keyPress(e); + if (!needsToBubble) e.preventDefault(); + return needsToBubble; + } else if (e.type == 'keydown') { + var needsToBubble = this.keyDown(e); + if (!needsToBubble) e.preventDefault(); + return needsToBubble; + } else if (e.type == 'keyup') { + this.pluginsManager.fireCaretUpdated(); + } + }, + visible: function(el) { + if(el.nodeType === ice.dom.TEXT_NODE) el = el.parentNode; + var rect = el.getBoundingClientRect(); + return ( rect.top > 0 && rect.left > 0); + }, + + /** + * Returns a tracking tag for the given `changeType`, with the optional `childNode` appended. + */ + createIceNode: function (changeType, childNode) { + var node = this.env.document.createElement(this.changeTypes[changeType].tag); + ice.dom.addClass(node, this._getIceNodeClass(changeType)); + + node.appendChild(childNode ? childNode : this.env.document.createTextNode('')); + this.addChange(this.changeTypes[changeType].alias, [node]); + + this.pluginsManager.fireNodeCreated(node, { + 'action': this.changeTypes[changeType].action + }); + return node; + }, + + /** + * Inserts the given string/node into the given range with tracking tags, collapsing (deleting) + * the range first if needed. If range is undefined, then the range from the Selection object + * is used. If the range is in a parent delete node, then the range is positioned after the delete. + */ + insert: function (node, range) { + // If the node is not defined, then we need to insert an + // invisible space and force propagation to the browser. + var isPropagating = !node; + node || (node = '\uFEFF'); + + if (range) this.selection.addRange(range); + else range = this.getCurrentRange(); + + if (typeof node === "string") node = document.createTextNode(node); + + // If we have any nodes selected, then we want to delete them before inserting the new text. + if (!range.collapsed) { + this.deleteContents(); + // Update the range + range = this.getCurrentRange(); + if (range.startContainer === range.endContainer && this.element === range.startContainer) { + // The whole editable element is selected. Need to remove everything and init its contents. + ice.dom.empty(this.element); + var firstSelectable = range.getLastSelectableChild(this.element); + range.setStartAfter(firstSelectable); + range.collapse(true); + } + } + // If we are in a non-tracking/void element, move the range to the end/outside. + this._moveRangeToValidTrackingPos(range); + + var changeid = this.startBatchChange(); + // Send a dummy node to be inserted, if node is undefined + this._insertNode(node, range, isPropagating); + this.pluginsManager.fireNodeInserted(node, range); + this.endBatchChange(changeid); + return isPropagating; + }, + + /** + * This command will drop placeholders in place of delete tags in the element + * body and store references in the `_deletes` array to the original delete nodes. + * + * A placeholder tag is of the following structure: + * + * Where [NUM] is the referenced allocation in the `_deletes` array where the + * original delete node is stored. + */ + placeholdDeletes: function () { + var self = this; + if (this.isPlaceholdingDeletes) { + this.revertDeletePlaceholders(); + } + this.isPlaceholdingDeletes = true; + this._deletes = []; + var deleteSelector = '.' + this._getIceNodeClass('deleteType'); + ice.dom.each(ice.dom.find(this.element, deleteSelector), function (i, el) { + self._deletes.push(ice.dom.cloneNode(el)); + ice.dom.replaceWith(el, '<' + self._delBookmark + ' data-allocation="' + (self._deletes.length - 1) + '"/>'); + }); + return true; + }, + + /** + * Replaces all delete placeholders in the element body with the referenced + * delete nodes in the `_deletes` array. + * + * A placeholder tag is of the following structure: + * + * Where [NUM] is the referenced allocation in the `_deletes` array where the + * original delete node is stored. + */ + revertDeletePlaceholders: function () { + var self = this; + if (!this.isPlaceholdingDeletes) { + return false; + } + ice.dom.each(this._deletes, function (i, el) { + ice.dom.find(self.element, self._delBookmark + '[data-allocation=' + i + ']').replaceWith(el); + }); + this.isPlaceholdingDeletes = false; + return true; + }, + /** + * Deletes the contents in the given range or the range from the Selection object. If the range + * is not collapsed, then a selection delete is handled; otherwise, it deletes one character + * to the left or right if the right parameter is false or true, respectively. + * + * @return true if deletion was handled. + */ + deleteContents: function (right, range) { + var prevent = true; + var browser = ice.dom.browser(); + + if (range) { + this.selection.addRange(range); + } else { + range = this.getCurrentRange(); + } + + var changeid = this.startBatchChange(this.changeTypes['deleteType'].alias); + if (range.collapsed === false) { + if(this._currentUserIceNode(range.startContainer.parentNode)){ + this._deleteSelection(range); + } else { + this._deleteSelection(range); + if(browser["type"] === "mozilla"){ + if(range.startContainer.parentNode.previousSibling){ + range.setEnd(range.startContainer.parentNode.previousSibling, 0); + range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer)); + } else { + range.setEndAfter(range.startContainer.parentNode); + } + range.collapse(false); + } else { + if(!this.visible(range.endContainer)){ + range.setEnd(range.endContainer, range.endOffset - 1); + range.collapse(false); + } + } + } + } else { + if (right) { + // RIGHT DELETE + if(browser["type"] === "mozilla"){ + prevent = this._deleteRight(range); + // Handling track change show/hide + if(!this.visible(range.endContainer)){ + if(range.endContainer.parentNode.nextSibling){ +// range.setEnd(range.endContainer.parentNode.nextSibling, 0); + range.setEndBefore(range.endContainer.parentNode.nextSibling); + } else { + range.setEndAfter(range.endContainer); + } + range.collapse(false); + } + } + else { + // Calibrate Cursor before deleting + if(range.endOffset === ice.dom.getNodeCharacterLength(range.endContainer)){ + var next = range.startContainer.nextSibling; + if (ice.dom.is(next, '.' + this._getIceNodeClass('deleteType'))) { + while(next){ + if (ice.dom.is(next, '.' + this._getIceNodeClass('deleteType'))) { + next = next.nextSibling; + continue; + } + range.setStart(next, 0); + range.collapse(true); + break; + } + } + } + + // Delete + prevent = this._deleteRight(range); + + // Calibrate Cursor after deleting + if(!this.visible(range.endContainer)){ + if (ice.dom.is(range.endContainer.parentNode, '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType'))) { +// range.setStart(range.endContainer.parentNode.nextSibling, 0); + range.setStartAfter(range.endContainer.parentNode); + range.collapse(true); + } + } + } + } + else { + // LEFT DELETE + if(browser["type"] === "mozilla"){ + prevent = this._deleteLeft(range); + // Handling track change show/hide + if(!this.visible(range.startContainer)){ + if(range.startContainer.parentNode.previousSibling){ + range.setEnd(range.startContainer.parentNode.previousSibling, 0); + } else { + range.setEnd(range.startContainer.parentNode, 0); + } + range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer)); + range.collapse(false); + } + } + else { + if(!this.visible(range.startContainer)){ + if(range.endOffset === ice.dom.getNodeCharacterLength(range.endContainer)){ + var prev = range.startContainer.previousSibling; + if (ice.dom.is(prev, '.' + this._getIceNodeClass('deleteType'))) { + while(prev){ + if (ice.dom.is(prev, '.' + this._getIceNodeClass('deleteType'))) { + prev = prev.prevSibling; + continue; + } + range.setEndBefore(prev.nextSibling, 0); + range.collapse(false); + break; + } + } + } + } + prevent = this._deleteLeft(range); + } + } + } + + this.selection.addRange(range); + this.endBatchChange(changeid); + return prevent; + }, + + /** + * Returns the changes - a hash of objects with the following properties: + * [changeid] => {`type`, `time`, `userid`, `username`} + */ + getChanges: function () { + return this._changes; + }, + + /** + * Returns an array with the user ids who made the changes + */ + getChangeUserids: function () { + var result = []; + var keys = Object.keys(this._changes); + + for (var key in keys) + result.push(this._changes[keys[key]].userid); + + return result.sort().filter(function (el, i, a) { + if (i == a.indexOf(el)) return 1; + return 0; + }); + }, + + /** + * Returns the html contents for the tracked element. + */ + getElementContent: function () { + return this.element.innerHTML; + }, + + /** + * Returns the html contents, without tracking tags, for `this.element` or + * the optional `body` param which can be of either type string or node. + * Delete tags, and their html content, are completely removed; all other + * change type tags are removed, leaving the html content in place. After + * cleaning, the optional `callback` is executed, which should further + * modify and return the element body. + * + * prepare gets run before the body is cleaned by ice. + */ + getCleanContent: function (body, callback, prepare) { + var classList = ''; + var self = this; + ice.dom.each(this.changeTypes, function (type, i) { + if (type != 'deleteType') { + if (i > 0) classList += ','; + classList += '.' + self._getIceNodeClass(type); + } + }); + if (body) { + if (typeof body === 'string') body = ice.dom.create('
' + body + '
'); + else body = ice.dom.cloneNode(body, false)[0]; + } else { + body = ice.dom.cloneNode(this.element, false)[0]; + } + body = prepare ? prepare.call(this, body) : body; + var changes = ice.dom.find(body, classList); + ice.dom.each(changes, function (el, i) { + ice.dom.replaceWith(this, ice.dom.contents(this)); + }); + var deletes = ice.dom.find(body, '.' + this._getIceNodeClass('deleteType')); + ice.dom.remove(deletes); + + body = callback ? callback.call(this, body) : body; + + return body.innerHTML; + }, + + /** + * Accepts all changes in the element body - removes delete nodes, and removes outer + * insert tags keeping the inner content in place. + */ + acceptAll: function () { + this.element.innerHTML = this.getCleanContent(); + }, + + /** + * Rejects all changes in the element body - removes insert nodes, and removes outer + * delete tags keeping the inner content in place.* + */ + rejectAll: function () { + var insSel = '.' + this._getIceNodeClass('insertType'); + var delSel = '.' + this._getIceNodeClass('deleteType'); + + ice.dom.remove(ice.dom.find(this.element, insSel)); + ice.dom.each(ice.dom.find(this.element, delSel), function (i, el) { + ice.dom.replaceWith(el, ice.dom.contents(el)); + }); + }, + + /** + * Accepts the change at the given, or first tracking parent node of, `node`. If + * `node` is undefined then the startContainer of the current collapsed range will be used. + * In the case of insert, inner content will be used to replace the containing tag; and in + * the case of delete, the node will be removed. + */ + acceptChange: function (node) { + this.acceptRejectChange(node, true); + }, + + /** + * Rejects the change at the given, or first tracking parent node of, `node`. If + * `node` is undefined then the startContainer of the current collapsed range will be used. + * In the case of delete, inner content will be used to replace the containing tag; and in + * the case of insert, the node will be removed. + */ + rejectChange: function (node) { + this.acceptRejectChange(node, false); + }, + + /** + * Handles accepting or rejecting tracking changes + */ + acceptRejectChange: function (node, isAccept) { + var delSel, insSel, selector, removeSel, replaceSel, trackNode, changes, dom = ice.dom; + + if (!node) { + var range = this.getCurrentRange(); + if (!range.collapsed) return; + else node = range.startContainer; + } + + delSel = removeSel = '.' + this._getIceNodeClass('deleteType'); + insSel = replaceSel = '.' + this._getIceNodeClass('insertType'); + selector = delSel + ',' + insSel; + trackNode = dom.getNode(node, selector); + // Some changes are done in batches so there may be other tracking + // nodes with the same `changeIdAttribute` batch number. + changes = dom.find(this.element, '[' + this.changeIdAttribute + '=' + dom.attr(trackNode, this.changeIdAttribute) + ']'); + + if (!isAccept) { + removeSel = insSel; + replaceSel = delSel; + } + + if (ice.dom.is(trackNode, replaceSel)) { + dom.each(changes, function (i, node) { + dom.replaceWith(node, ice.dom.contents(node)); + }); + } else if (dom.is(trackNode, removeSel)) { + dom.remove(changes); + } + }, + + /** + * Returns true if the given `node`, or the current collapsed range is in a tracking + * node; otherwise, false. + */ + isInsideChange: function (node) { + var selector = '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType'); + if (!node) { + range = this.getCurrentRange(); + if (!range.collapsed) return false; + else node = range.startContainer; + } + return !!ice.dom.getNode(node, selector); + }, + + /** + * Add a new change tracking typeName with the given tag and alias. + */ + addChangeType: function (typeName, tag, alias, action) { + var changeType = { + tag: tag, + alias: alias + }; + + if (action) changeType.action = action; + + this.changeTypes[typeName] = changeType; + }, + + /** + * Returns this `node` or the first parent tracking node with the given `changeType`. + */ + getIceNode: function (node, changeType) { + var selector = '.' + this._getIceNodeClass(changeType); + return ice.dom.getNode(node, selector); + }, + + /** + * Sets the given `range` to the first position, to the right, where it is outside of + * void elements. + */ + _moveRangeToValidTrackingPos: function (range) { + var onEdge = false; + var voidEl = this._getVoidElement(range.endContainer); + while (voidEl) { + // Move end of range to position it inside of any potential adjacent containers + // E.G.: test|text -> test|text + try { + range.moveEnd(ice.dom.CHARACTER_UNIT, 1); + range.moveEnd(ice.dom.CHARACTER_UNIT, -1); + } catch (e) { + // Moving outside of the element and nothing is left on the page + onEdge = true; + } + if (onEdge || ice.dom.onBlockBoundary(range.endContainer, range.startContainer, this.blockEls)) { + range.setStartAfter(voidEl); + range.collapse(true); + break; + } + voidEl = this._getVoidElement(range.endContainer); + if (voidEl) { + range.setEnd(range.endContainer, 0); + range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer)); + range.collapse(); + } else { + range.setStart(range.endContainer, 0); + range.collapse(true); + } + } + }, + + /** + * Returns the given `node` or the first parent node that matches against the list of no track elements. + */ + _getNoTrackElement: function (node) { + var noTrackSelector = this._getNoTrackSelector(); + var parent = ice.dom.is(node, noTrackSelector) ? node : (ice.dom.parents(node, noTrackSelector)[0] || null); + return parent; + }, + + /** + * Returns a selector for not tracking changes + */ + _getNoTrackSelector: function () { + return this.noTrack; + }, + + /** + * Returns the given `node` or the first parent node that matches against the list of void elements. + */ + _getVoidElement: function (node) { + var voidSelector = this._getVoidElSelector(); + return ice.dom.is(node, voidSelector) ? node : (ice.dom.parents(node, voidSelector)[0] || null); + }, + + /** + * Returns a combined selector for delete and void elements. + */ + _getVoidElSelector: function () { + return '.' + this._getIceNodeClass('deleteType') + ',' + this.avoid; + }, + + /** + * Returns true if node has a user id attribute that matches the current user id. + */ + _currentUserIceNode: function (node) { + return ice.dom.attr(node, this.userIdAttribute) == this.currentUser.id; + }, + + /** + * With the given alias, searches the changeTypes objects and returns the + * associated key for the alias. + */ + _getChangeTypeFromAlias: function (alias) { + var type, ctnType = null; + for (type in this.changeTypes) { + if (this.changeTypes.hasOwnProperty(type)) { + if (this.changeTypes[type].alias == alias) { + ctnType = type; + } + } + } + + return ctnType; + }, + + _getIceNodeClass: function (changeType) { + return this.attrValuePrefix + this.changeTypes[changeType].alias; + }, + + getUserStyle: function (userid) { + var styleIndex = null; + if (this._userStyles[userid]) styleIndex = this._userStyles[userid]; + else styleIndex = this.setUserStyle(userid, this.getNewStyleId()); + return styleIndex; + }, + + setUserStyle: function (userid, styleIndex) { + var style = this.stylePrefix + '-' + styleIndex; + if (!this._styles[styleIndex]) this._styles[styleIndex] = true; + return this._userStyles[userid] = style; + }, + + getNewStyleId: function () { + var id = ++this._uniqueStyleIndex; + if (this._styles[id]) { + // Dupe.. create another.. + return this.getNewStyleId(); + } else { + this._styles[id] = true; + return id; + } + }, + + addChange: function (ctnType, ctNodes) { + var changeid = this._batchChangeid || this.getNewChangeId(); + if (!this._changes[changeid]) { + // Create the change object. + this._changes[changeid] = { + type: this._getChangeTypeFromAlias(ctnType), + time: (new Date()).getTime(), + userid: this.currentUser.id, + username: this.currentUser.name + }; + } + var self = this; + ice.dom.foreach(ctNodes, function (i) { + self.addNodeToChange(changeid, ctNodes[i]); + }); + + return changeid; + }, + + /** + * Adds tracking attributes from the change with changeid to the ctNode. + * @param changeid Id of an existing change. + * @param ctNode The element to add for the change. + */ + addNodeToChange: function (changeid, ctNode) { + if (this._batchChangeid !== null) changeid = this._batchChangeid; + + var change = this.getChange(changeid); + + if (!ctNode.getAttribute(this.changeIdAttribute)) ctNode.setAttribute(this.changeIdAttribute, changeid); + + if (!ctNode.getAttribute(this.userIdAttribute)) ctNode.setAttribute(this.userIdAttribute, change.userid); + + if (!ctNode.getAttribute(this.userNameAttribute)) ctNode.setAttribute(this.userNameAttribute, change.username); + + if (!ctNode.getAttribute(this.timeAttribute)) ctNode.setAttribute(this.timeAttribute, change.time); + + if (!ice.dom.hasClass(ctNode, this._getIceNodeClass(change.type))) ice.dom.addClass(ctNode, this._getIceNodeClass(change.type)); + + var style = this.getUserStyle(change.userid); + if (!ice.dom.hasClass(ctNode, style)) ice.dom.addClass(ctNode, style); + }, + + getChange: function (changeid) { + var change = null; + if (this._changes[changeid]) { + change = this._changes[changeid]; + } + return change; + }, + + getNewChangeId: function () { + var id = ++this._uniqueIDIndex; + if (this._changes[id]) { + // Dupe.. create another.. + id = this.getNewChangeId(); + } + return id; + }, + + startBatchChange: function () { + this._batchChangeid = this.getNewChangeId(); + return this._batchChangeid; + }, + + endBatchChange: function (changeid) { + if (changeid !== this._batchChangeid) return; + this._batchChangeid = null; + }, + + getCurrentRange: function () { + return this.selection.getRangeAt(0); + }, + + _insertNode: function (node, range, insertingDummy) { + var origNode = node; + if (!ice.dom.isBlockElement(range.startContainer) && !ice.dom.canContainTextElement(ice.dom.getBlockParent(range.startContainer, this.element)) && range.startContainer.previousSibling) { + range.setStart(range.startContainer.previousSibling, 0); + + } + var startContainer = range.startContainer; + var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null; + if (parentBlock === this.element) { + var firstPar = document.createElement(this.blockEl); + parentBlock.appendChild(firstPar); + range.setStart(firstPar, 0); + range.collapse(); + return this._insertNode(node, range, insertingDummy); + } + if (ice.dom.hasNoTextOrStubContent(parentBlock)) { + ice.dom.empty(parentBlock); + ice.dom.append(parentBlock, '
'); + range.setStart(parentBlock, 0); + } + + var ctNode = this.getIceNode(range.startContainer, 'insertType'); + var inCurrentUserInsert = this._currentUserIceNode(ctNode); + + // Do nothing, let this bubble-up to insertion handler. + if (insertingDummy && inCurrentUserInsert) return; + // If we aren't in an insert node which belongs to the current user, then create a new ins node + else if (!inCurrentUserInsert) node = this.createIceNode('insertType', node); + + range.insertNode(node); + range.setEnd(node, 1); + + if (insertingDummy) { + // Create a selection of the dummy character we inserted + // which will be removed after it bubbles up to the final handler. + range.setStart(node, 0); + } else { + range.collapse(); + } + + this.selection.addRange(range); + }, + + _handleVoidEl: function(el, range) { + // If `el` is or is in a void element, but not a delete + // then collapse the `range` and return `true`. + var voidEl = this._getVoidElement(el); + if (voidEl && !this.getIceNode(voidEl, 'deleteType')) { + range.collapse(true); + return true; + } + return false; + }, + + _deleteSelection: function (range) { + + // Bookmark the range and get elements between. + var bookmark = new ice.Bookmark(this.env, range), + elements = ice.dom.getElementsBetween(bookmark.start, bookmark.end), + b1 = ice.dom.parents(range.startContainer, this.blockEls.join(', '))[0], + b2 = ice.dom.parents(range.endContainer, this.blockEls.join(', '))[0], + betweenBlocks = new Array(); + + for (var i = 0; i < elements.length; i++) { + var elem = elements[i]; + if (ice.dom.isBlockElement(elem)) { + betweenBlocks.push(elem); + if (!ice.dom.canContainTextElement(elem)) { + // Ignore containers that are not supposed to contain text. Check children instead. + for (var k = 0; k < elem.childNodes.length; k++) { + elements.push(elem.childNodes[k]); + } + continue; + } + } + // Ignore empty space nodes + if (elem.nodeType === ice.dom.TEXT_NODE && ice.dom.getNodeTextContent(elem).length === 0) continue; + + if (!this._getVoidElement(elem)) { + // If the element is not a text or stub node, go deeper and check the children. + if (elem.nodeType !== ice.dom.TEXT_NODE) { + // Browsers like to insert breaks into empty paragraphs - remove them + if (ice.dom.BREAK_ELEMENT == ice.dom.getTagName(elem)) { + continue; + } + + if (ice.dom.isStubElement(elem)) { + this._addNodeTracking(elem, false, true); + continue; + } + if (ice.dom.hasNoTextOrStubContent(elem)) { + ice.dom.remove(elem); + } + + for (j = 0; j < elem.childNodes.length; j++) { + var child = elem.childNodes[j]; + elements.push(child); + } + continue; + } + var parentBlock = ice.dom.getBlockParent(elem); + this._addNodeTracking(elem, false, true, true); + if (ice.dom.hasNoTextOrStubContent(parentBlock)) { + ice.dom.remove(parentBlock); + } + } + } + + if (this.mergeBlocks && b1 !== b2) { + while (betweenBlocks.length) + ice.dom.mergeContainers(betweenBlocks.shift(), b1); + ice.dom.removeBRFromChild(b2); + ice.dom.removeBRFromChild(b1); + ice.dom.mergeContainers(b2, b1); + } + + bookmark.selectBookmark(); +// range.collapse(false); + range.collapse(true); + }, + + // Delete + _deleteRight: function (range) { + + var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null, + isEmptyBlock = parentBlock ? (ice.dom.hasNoTextOrStubContent(parentBlock)) : false, + nextBlock = parentBlock && ice.dom.getNextContentNode(parentBlock, this.element), + nextBlockIsEmpty = nextBlock ? (ice.dom.hasNoTextOrStubContent(nextBlock)) : false, + initialContainer = range.endContainer, + initialOffset = range.endOffset, + commonAncestor = range.commonAncestorContainer, + nextContainer, returnValue; + + // If the current block is empty then let the browser handle the delete/event. + if (isEmptyBlock) return false; + + // Some bugs in Firefox and Webkit make the caret disappear out of text nodes, so we try to put them back in. + if (commonAncestor.nodeType !== ice.dom.TEXT_NODE) { + + // If placed at the beginning of a container that cannot contain text, such as an ul element, place the caret at the beginning of the first item. + if (initialOffset === 0 && ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) { + var firstItem = commonAncestor.firstElementChild; + if (firstItem) { + range.setStart(firstItem, 0); + range.collapse(); + return this._deleteRight(range); + } + } + + if (commonAncestor.childNodes.length > initialOffset) { + var tempTextContainer = document.createTextNode(' '); + commonAncestor.insertBefore(tempTextContainer, commonAncestor.childNodes[initialOffset]); + range.setStart(tempTextContainer, 1); + range.collapse(true); + returnValue = this._deleteRight(range); + ice.dom.remove(tempTextContainer); + return returnValue; + } else { + nextContainer = ice.dom.getNextContentNode(commonAncestor, this.element); + range.setEnd(nextContainer, 0); + range.collapse(); + return this._deleteRight(range); + } + } + + // Move range to position the cursor on the inside of any adjacent container that it is going + // to potentially delete into or after a stub element. E.G.: test|text -> test|text or + // text1 | text2 -> text1 | text2 + + // Merge blocks: If mergeBlocks is enabled, merge the previous and current block. + range.moveEnd(ice.dom.CHARACTER_UNIT, 1); + range.moveEnd(ice.dom.CHARACTER_UNIT, -1); + + // Handle cases of the caret is at the end of a container or placed directly in a block element + if (initialOffset === initialContainer.data.length && (!ice.dom.hasNoTextOrStubContent(initialContainer))) { + nextContainer = ice.dom.getNextNode(initialContainer, this.element); + + // If the next container is outside of ICE then do nothing. + if (!nextContainer) { + range.selectNodeContents(initialContainer); + range.collapse(); + return false; + } + + // If the next container is
element find the next node + if (ice.dom.BREAK_ELEMENT == ice.dom.getTagName(nextContainer)) { + nextContainer = ice.dom.getNextNode(nextContainer, this.element); + } + // If the next container is a text node, look at the parent node instead. + if (nextContainer.nodeType === ice.dom.TEXT_NODE) { + nextContainer = nextContainer.parentNode; + } + + // If the next container is non-editable, enclose it with a delete ice node and add an empty text node after it to position the caret. + if (!nextContainer.isContentEditable) { + returnValue = this._addNodeTracking(nextContainer, false, false); + var emptySpaceNode = document.createTextNode(''); + nextContainer.parentNode.insertBefore(emptySpaceNode, nextContainer.nextSibling); + range.selectNode(emptySpaceNode); + range.collapse(true); + return returnValue; + } + + if (this._handleVoidEl(nextContainer, range)) return true; + + // If the caret was placed directly before a stub element, enclose the element with a delete ice node. + if (ice.dom.isChildOf(nextContainer, parentBlock) && ice.dom.isStubElement(nextContainer)) { + return this._addNodeTracking(nextContainer, range, false); + } + + } + + if (this._handleVoidEl(nextContainer, range)) return true; + + // If we are deleting into a no tracking containiner, then remove the content + if (this._getNoTrackElement(range.endContainer.parentElement)) { + range.deleteContents(); + return false; + } + + if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) { + if (this.mergeBlocks && ice.dom.is(ice.dom.getBlockParent(nextContainer, this.element), this.blockEl)) { + // Since the range is moved by character, it may have passed through empty blocks. + //

text {RANGE.START}

{RANGE.END} text

+ if (nextBlock !== ice.dom.getBlockParent(range.endContainer, this.element)) { + range.setEnd(nextBlock, 0); + } + // The browsers like to auto-insert breaks into empty paragraphs - remove them. + var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer); + for (var i = 0; i < elements.length; i++) { + ice.dom.remove(elements[i]); + } + var startContainer = range.startContainer; + var endContainer = range.endContainer; + ice.dom.remove(ice.dom.find(startContainer, 'br')); + ice.dom.remove(ice.dom.find(endContainer, 'br')); + return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock); + } else { + // If the next block is empty, remove the next block. + if (nextBlockIsEmpty) { + ice.dom.remove(nextBlock); + range.collapse(true); + return true; + } + + // Place the caret at the start of the next block. + range.setStart(nextBlock, 0); + range.collapse(true); + return true; + } + } + + var entireTextNode = range.endContainer; + var deletedCharacter = entireTextNode.splitText(range.endOffset); + var remainingTextNode = deletedCharacter.splitText(1); + + return this._addNodeTracking(deletedCharacter, range, false); + + }, + + // Backspace + _deleteLeft: function (range) { + + var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null, + isEmptyBlock = parentBlock ? ice.dom.hasNoTextOrStubContent(parentBlock) : false, + prevBlock = parentBlock && ice.dom.getPrevContentNode(parentBlock, this.element), // || ice.dom.getBlockParent(parentBlock, this.element) || null, + prevBlockIsEmpty = prevBlock ? ice.dom.hasNoTextOrStubContent(prevBlock) : false, + initialContainer = range.startContainer, + initialOffset = range.startOffset, + commonAncestor = range.commonAncestorContainer, + lastSelectable, prevContainer; + // If the current block is empty, then let the browser handle the key/event. + if (isEmptyBlock) return false; + + // Handle cases of the caret is at the start of a container or outside a text node + if (initialOffset === 0 || commonAncestor.nodeType !== ice.dom.TEXT_NODE) { + // If placed at the end of a container that cannot contain text, such as an ul element, place the caret at the end of the last item. + if (ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) { + if (initialOffset === 0) { + var firstItem = commonAncestor.firstElementChild; + if (firstItem) { + range.setStart(firstItem, 0); + range.collapse(); + return this._deleteLeft(range); + } + + } else { + var lastItem = commonAncestor.lastElementChild; + if (lastItem) { + + lastSelectable = range.getLastSelectableChild(lastItem); + if (lastSelectable) { + range.setStart(lastSelectable, lastSelectable.data.length); + range.collapse(); + return this._deleteLeft(range); + } + } + } + } + if (initialOffset === 0) { + prevContainer = ice.dom.getPrevContentNode(initialContainer, this.element); + } else { + var newOffset = initialOffset; + var style; +// while(newOffset > 0){ +// prevContainer = commonAncestor.childNodes[--newOffset]; +// if(!ice.dom.hasClass(prevContainer, "del")) break; +// prevContainer = null; +// } + prevContainer = commonAncestor.childNodes[initialOffset-1]; + } + + // If the previous container is outside of ICE then do nothing. + if (!prevContainer) { + return false; + } + // Firefox finds an ice node wrapped around an image instead of the image itself sometimes, so we make sure to look at the image instead. + if (ice.dom.is(prevContainer, '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType')) && prevContainer.childNodes.length > 0 && prevContainer.lastChild) { + prevContainer = prevContainer.lastChild; + } + + // If the previous container is a text node, look at the parent node instead. + if (prevContainer.nodeType === ice.dom.TEXT_NODE) { + prevContainer = prevContainer.parentNode; + } + + // If the previous container is non-editable, enclose it with a delete ice node and add an empty text node before it to position the caret. + if (!prevContainer.isContentEditable) { + var returnValue = this._addNodeTracking(prevContainer, false, true); + var emptySpaceNode = document.createTextNode(''); + prevContainer.parentNode.insertBefore(emptySpaceNode, prevContainer); + range.selectNode(emptySpaceNode); + range.collapse(true); + return returnValue; + } + + if (this._handleVoidEl(prevContainer, range)) return true; + + // If the caret was placed directly after a stub element, enclose the element with a delete ice node. + if (ice.dom.isStubElement(prevContainer) && ice.dom.isChildOf(prevContainer, parentBlock) || !prevContainer.isContentEditable) { + return this._addNodeTracking(prevContainer, range, true); + } + + // If the previous container is a stub element between blocks + // then just delete and leave the range/cursor in place. + if (ice.dom.isStubElement(prevContainer)) { + ice.dom.remove(prevContainer); + range.collapse(true); + return false; + } + + if (prevContainer !== parentBlock && !ice.dom.isChildOf(prevContainer, parentBlock)) { + + if (!ice.dom.canContainTextElement(prevContainer)) { + prevContainer = prevContainer.lastElementChild; + } + // Before putting the caret into the last selectable child, lets see if the last element is a stub element. If it is, we need to put the caret there manually. + if (prevContainer.lastChild && prevContainer.lastChild.nodeType !== ice.dom.TEXT_NODE && ice.dom.isStubElement(prevContainer.lastChild) && prevContainer.lastChild.tagName !== 'BR') { + range.setStartAfter(prevContainer.lastChild); + range.collapse(true); + return true; + } + // Find the last selectable part of the prevContainer. If it exists, put the caret there. + lastSelectable = range.getLastSelectableChild(prevContainer); + + if (lastSelectable && !ice.dom.isOnBlockBoundary(range.startContainer, lastSelectable, this.element)) { + range.selectNodeContents(lastSelectable); + range.collapse(); + return true; + } + } + } + + // Firefox: If an image is at the start of the paragraph and the user has just deleted the image using backspace, an empty text node is created in the delete node before + // the image, but the caret is placed with the image. We move the caret to the empty text node and execute deleteFromLeft again. + if (initialOffset === 1 && !ice.dom.isBlockElement(commonAncestor) && range.startContainer.childNodes.length > 1 && range.startContainer.childNodes[0].nodeType === ice.dom.TEXT_NODE && range.startContainer.childNodes[0].data.length === 0) { + range.setStart(range.startContainer, 0); + return this._deleteLeft(range); + } + + // Move range to position the cursor on the inside of any adjacent container that it is going + // to potentially delete into or before a stub element. E.G.: text| test -> text| test or + // text1 | text2 -> text1 | text2 + range.moveStart(ice.dom.CHARACTER_UNIT, -1); + range.moveStart(ice.dom.CHARACTER_UNIT, 1); + + // If we are deleting into a no tracking containiner, then remove the content + if (this._getNoTrackElement(range.startContainer.parentElement)) { + range.deleteContents(); + return false; + } + + // Handles cases in which the caret is at the start of the block. + if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) { + + // If the previous block is empty, remove the previous block. + if (prevBlockIsEmpty) { + ice.dom.remove(prevBlock); + range.collapse(); + return true; + } + + // Merge blocks: If mergeBlocks is enabled, merge the previous and current block. + if (this.mergeBlocks && ice.dom.is(ice.dom.getBlockParent(prevContainer, this.element), this.blockEl)) { + // Since the range is moved by character, it may have passed through empty blocks. + //

text {RANGE.START}

{RANGE.END} text

+ if (prevBlock !== ice.dom.getBlockParent(range.startContainer, this.element)) { + range.setStart(prevBlock, prevBlock.childNodes.length); + } + // The browsers like to auto-insert breaks into empty paragraphs - remove them. + var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer) + for (var i = 0; i < elements.length; i++) { + ice.dom.remove(elements[i]); + } + var startContainer = range.startContainer; + var endContainer = range.endContainer; + ice.dom.remove(ice.dom.find(startContainer, 'br')); + ice.dom.remove(ice.dom.find(endContainer, 'br')); + return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock); + } + + // If the previous Block ends with a stub element, set the caret behind it. + if (prevBlock && prevBlock.lastChild && ice.dom.isStubElement(prevBlock.lastChild)) { + range.setStartAfter(prevBlock.lastChild); + range.collapse(true); + return true; + } + + // Place the caret at the end of the previous block. + lastSelectable = range.getLastSelectableChild(prevBlock); + if (lastSelectable) { + range.setStart(lastSelectable, lastSelectable.data.length); + range.collapse(true); + } else if (prevBlock) { + range.setStart(prevBlock, prevBlock.childNodes.length); + range.collapse(true); + } + + return true; + } + + var entireTextNode = range.startContainer; + var deletedCharacter = entireTextNode.splitText(range.startOffset - 1); + var remainingTextNode = deletedCharacter.splitText(1); + + return this._addNodeTracking(deletedCharacter, range, true); + + }, + + // Marks text and other nodes for deletion + _addNodeTracking: function (contentNode, range, moveLeft) { + + var contentAddNode = this.getIceNode(contentNode, 'insertType'); + + if (contentAddNode && this._currentUserIceNode(contentAddNode)) { + if (range && moveLeft) { + range.selectNode(contentNode); + } + contentNode.parentNode.removeChild(contentNode); + var cleanNode = ice.dom.cloneNode(contentAddNode); + ice.dom.remove(ice.dom.find(cleanNode, '.iceBookmark')); + // Remove a potential empty tracking container + if (contentAddNode !== null && (ice.dom.hasNoTextOrStubContent(cleanNode[0]))) { + var newstart = this.env.document.createTextNode(''); + ice.dom.insertBefore(contentAddNode, newstart); + if (range) { + range.setStart(newstart, 0); + range.collapse(true); + } + ice.dom.replaceWith(contentAddNode, ice.dom.contents(contentAddNode)); + } + + return true; + + } else if (range && this.getIceNode(contentNode, 'deleteType')) { + // It if the contentNode a text node, unite it with text nodes before and after it. + contentNode.normalize(); + + var found = false; + if (moveLeft) { + // Move to the left until there is valid sibling. + var previousSibling = ice.dom.getPrevContentNode(contentNode, this.element); + while (!found) { + ctNode = this.getIceNode(previousSibling, 'deleteType'); + if (!ctNode) { + found = true; + } else { + previousSibling = ice.dom.getPrevContentNode(previousSibling, this.element); + } + } + if (previousSibling) { + var lastSelectable = range.getLastSelectableChild(previousSibling); + if (lastSelectable) { + previousSibling = lastSelectable; + } + range.setStart(previousSibling, ice.dom.getNodeCharacterLength(previousSibling)); + range.collapse(true); + } + return true; + } else { + // Move the range to the right until there is valid sibling. + + var nextSibling = ice.dom.getNextContentNode(contentNode, this.element); + while (!found) { + ctNode = this.getIceNode(nextSibling, 'deleteType'); + if (!ctNode) { + found = true; + } else { + nextSibling = ice.dom.getNextContentNode(nextSibling, this.element); + } + } + + if (nextSibling) { + range.selectNodeContents(nextSibling); + range.collapse(true); + } + return true; + } + + } + // Webkit likes to insert empty text nodes next to elements. We remove them here. + if (contentNode.previousSibling && contentNode.previousSibling.nodeType === ice.dom.TEXT_NODE && contentNode.previousSibling.length === 0) { + contentNode.parentNode.removeChild(contentNode.previousSibling); + } + if (contentNode.nextSibling && contentNode.nextSibling.nodeType === ice.dom.TEXT_NODE && contentNode.nextSibling.length === 0) { + contentNode.parentNode.removeChild(contentNode.nextSibling); + } + var prevDelNode = this.getIceNode(contentNode.previousSibling, 'deleteType'); + var nextDelNode = this.getIceNode(contentNode.nextSibling, 'deleteType'); + var ctNode; + + if (prevDelNode && this._currentUserIceNode(prevDelNode)) { + ctNode = prevDelNode; + ctNode.appendChild(contentNode); + if (nextDelNode && this._currentUserIceNode(nextDelNode)) { + var nextDelContents = ice.dom.extractContent(nextDelNode); + ice.dom.append(ctNode, nextDelContents); + nextDelNode.parentNode.removeChild(nextDelNode); + } + } else if (nextDelNode && this._currentUserIceNode(nextDelNode)) { + ctNode = nextDelNode; + ctNode.insertBefore(contentNode, ctNode.firstChild); + } else { + ctNode = this.createIceNode('deleteType'); + contentNode.parentNode.insertBefore(ctNode, contentNode); + ctNode.appendChild(contentNode); + } + + if (range) { + if (ice.dom.isStubElement(contentNode)) { + range.selectNode(contentNode); + } else { + range.selectNodeContents(contentNode); + } + if (moveLeft) { + range.collapse(true); + } else { + range.collapse(); + } + contentNode.normalize(); + } + return true; + + }, + + + /** + * Handles arrow, delete key events, and others. + * + * @param {event} e The event object. + * return {void|boolean} Returns false if default event needs to be blocked. + */ + _handleAncillaryKey: function (e) { + var key = e.keyCode ? e.keyCode : e.which; + var browser = ice.dom.browser(); + var preventDefault = true; + var shiftKey = e.shiftKey; + var self = this; + var range = self.getCurrentRange(); + switch (key) { + case ice.dom.DOM_VK_DELETE: + preventDefault = this.deleteContents(); + this.pluginsManager.fireKeyPressed(e); + break; + case 46: + // Key 46 is the DELETE key. + preventDefault = this.deleteContents(true); + this.pluginsManager.fireKeyPressed(e); + break; + + /************************************************************************************/ + /** BEGIN: Handling of caret movements inside hidden .ins/.del elements on Firefox **/ + /** *Fix for carets getting stuck in .del elements when track changes are hidden **/ + case ice.dom.DOM_VK_DOWN: + case ice.dom.DOM_VK_UP: + case ice.dom.DOM_VK_LEFT: + this.pluginsManager.fireCaretPositioned(); + if (browser["type"] === "mozilla") { + if (!this.visible(range.startContainer)) { + // if Previous sibling exists in the paragraph, jump to the previous sibling + if(range.startContainer.parentNode.previousSibling) { + // When moving left and moving into a hidden element, skip it and go to the previousSibling + range.setEnd(range.startContainer.parentNode.previousSibling, 0); + range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer)); + range.collapse(false); + } + // if Previous sibling doesn't exist, get out of the hidden zone by moving to the right + else { + range.setEnd(range.startContainer.parentNode.nextSibling, 0); + range.collapse(false); + } + } + } + preventDefault = false; + break; + case ice.dom.DOM_VK_RIGHT: + this.pluginsManager.fireCaretPositioned(); + if (browser["type"] === "mozilla") { + if (!this.visible(range.startContainer)) { + if(range.startContainer.parentNode.nextSibling) { + // When moving right and moving into a hidden element, skip it and go to the nextSibling + range.setStart(range.startContainer.parentNode.nextSibling,0); + range.collapse(true); + } + } + } + preventDefault = false; + break; + /** END: Handling of caret movements inside hidden .ins/.del elements ***************/ + /************************************************************************************/ + + case 32: + preventDefault = true; + var range = this.getCurrentRange(); + this._moveRangeToValidTrackingPos(range, range.startContainer); + this.insert('\u00A0' , range); + break; + default: + // Ignore key. + preventDefault = false; + break; + } //end switch + + if (preventDefault === true) { + ice.dom.preventDefault(e); + return false; + } + return true; + }, + + keyDown: function (e) { + if (!this.pluginsManager.fireKeyDown(e)) { + ice.dom.preventDefault(e); + return false; + } + var preventDefault = false; + + if (this._handleSpecialKey(e) === false) { + if (ice.dom.isBrowser('msie') !== true) { + this._preventKeyPress = true; + } + + return false; + } else if ((e.ctrlKey === true || e.metaKey === true) && (ice.dom.isBrowser('msie') === true || ice.dom.isBrowser('chrome') === true)) { + // IE does not fire keyPress event if ctrl is also pressed. + // E.g. CTRL + B (Bold) will not fire keyPress so this.plugins + // needs to be notified here for IE. + if (!this.pluginsManager.fireKeyPressed(e)) { + return false; + } + } + switch (e.keyCode) { + case 27: + // ESC + break; + default: + // If not Firefox then check if event is special arrow key etc. + // Firefox will handle this in keyPress event. + if (/Firefox/.test(navigator.userAgent) !== true) { + preventDefault = !(this._handleAncillaryKey(e)); + } + break; + } + + if (preventDefault) { + ice.dom.preventDefault(e); + return false; + } + return true; + }, + + keyPress: function (e) { + if (this._preventKeyPress === true) { + this._preventKeyPress = false; + return; + } + + if (!this.pluginsManager.fireKeyPress(e)) return false; + + var c = null; + if (e.which == null) { + // IE. + c = String.fromCharCode(e.keyCode); + } else if (e.which > 0) { + c = String.fromCharCode(e.which); + } + + // Inside a br - most likely in a placeholder of a new block - delete before handling. + var range = this.getCurrentRange(); + var br = ice.dom.parents(range.startContainer, 'br')[0] || null; + if (br) { + range.moveToNextEl(br); + br.parentNode.removeChild(br); + } + + // Ice will ignore the keyPress event if CMD or CTRL key is also pressed + if (c !== null && e.ctrlKey !== true && e.metaKey !== true) { + var key = e.keyCode ? e.keyCode : e.which; + switch (key) { + case ice.dom.DOM_VK_DELETE: + // Handle delete key for Firefox. + return this._handleAncillaryKey(e); + case ice.dom.DOM_VK_ENTER: + return this._handleEnter(); + case 32: + return this._handleAncillaryKey(e); + default: + // If we are in a deletion, move the range to the end/outside. + this._moveRangeToValidTrackingPos(range, range.startContainer); + return this.insert(); + } + } + + return this._handleAncillaryKey(e); + }, + + _handleEnter: function () { + var range = this.getCurrentRange(); + if (!range.collapsed) this.deleteContents(); + return true; + }, + + _handleSpecialKey: function (e) { + var keyCode = e.which; + if (keyCode === null) { + // IE. + keyCode = e.keyCode; + } + + var preventDefault = false; + switch (keyCode) { + case 65: + // Check for CTRL/CMD + A (select all). + if (e.ctrlKey === true || e.metaKey === true) { + preventDefault = true; + var range = this.getCurrentRange(); + + if (ice.dom.isBrowser('msie') === true) { + var selStart = this.env.document.createTextNode(''); + var selEnd = this.env.document.createTextNode(''); + + if (this.element.firstChild) { + ice.dom.insertBefore(this.element.firstChild, selStart); + } else { + this.element.appendChild(selStart); + } + + this.element.appendChild(selEnd); + + range.setStart(selStart, 0); + range.setEnd(selEnd, 0); + } else { + range.setStart(range.getFirstSelectableChild(this.element), 0); + var lastSelectable = range.getLastSelectableChild(this.element); + range.setEnd(lastSelectable, lastSelectable.length); + } //end if + + this.selection.addRange(range); + } //end if + break; + + default: + // Not a special key. + break; + } //end switch + + if (preventDefault === true) { + ice.dom.preventDefault(e); + return false; + } + + return true; + }, + + mouseUp: function (e, target) { + if (!this.pluginsManager.fireClicked(e)) return false; + this.pluginsManager.fireSelectionChanged(this.getCurrentRange()); + }, + + mouseDown: function (e, target) { + if (!this.pluginsManager.fireMouseDown(e)) return false; + this.pluginsManager.fireCaretUpdated(); + } + }; + + exports.ice = this.ice || {}; + exports.ice.InlineChangeEditor = InlineChangeEditor; + +}).call(this); diff --git a/demo/ice/icePlugin.js b/demo/ice/icePlugin.js new file mode 100644 index 00000000..4f1ca9bd --- /dev/null +++ b/demo/ice/icePlugin.js @@ -0,0 +1,39 @@ +(function() { + +var exports = this; + +var IcePlugin = function(ice_instance) { + this._ice = ice_instance; +}; + +IcePlugin.prototype = { + + start: function() {}, + clicked: function(e) { + return true; + }, + mouseDown: function(e) { + return true; + }, + keyDown: function(e) { + return true; + }, + keyPress: function(e) { + return true; + }, + selectionChanged: function(range) {}, + setEnabled: function(enabled) {}, + setDisabled: function(enabled) {}, + caretUpdated: function() {}, + nodeInserted: function(node, range) {}, + nodeCreated: function(node, options) {}, + caretPositioned: function() {}, + remove: function() { + this._ice.removeKeyPressListener(this); + }, + setSettings: function(settings) {} +}; + +exports.IcePlugin = IcePlugin; + +}).call(this.ice); diff --git a/demo/ice/icePluginManager.js b/demo/ice/icePluginManager.js new file mode 100644 index 00000000..24730158 --- /dev/null +++ b/demo/ice/icePluginManager.js @@ -0,0 +1,336 @@ +(function() { + +var exports = this; + +var IcePluginManager = function(ice_instance) { + + this.plugins = {}, + this.pluginConstructors = {}, + this.keyPressListeners = {}, + this.activePlugin = null, + this.pluginSets = {}, + this.activePluginSet = null, + + this._ice = ice_instance; +}; + +IcePluginManager.prototype = { + + getPluginNames: function() { + var plugins = []; + for (var name in this.plugins) { + plugins.push(name); + } + return plugins; + }, + + addPluginObject: function(pluginName, pluginObj) { + this.plugins[pluginName] = pluginObj; + }, + + addPlugin: function(name, pluginConstructor) { + if (typeof pluginConstructor !== 'function') { + throw Error('IcePluginException: plugin must be a constructor function'); + } + + if (ice.dom.isset(this.pluginConstructors[name]) === false) { + this.pluginConstructors[name] = pluginConstructor; + } + }, + + loadPlugins: function(plugins, callback) { + if (plugins.length === 0) { + callback.call(this); + } else { + var plugin = plugins.shift(); + if (typeof plugin === 'object') { + plugin = plugin.name; + } + + if (ice.dom.isset(ice._plugin[plugin]) === true) { + this.addPlugin(plugin, ice._plugin[plugin]); + this.loadPlugins(plugins, callback); + } else { + throw new Error('plugin was not included in the page: ' + plugin); + } + } + }, + + _enableSet: function(name) { + this.activePluginSet = name; + var pSetLen = this.pluginSets[name].length; + for (var i = 0; i < pSetLen; i++) { + var plugin = this.pluginSets[name][i]; + var pluginName = ''; + if (typeof plugin === 'object') { + pluginName = plugin.name; + } else { + pluginName = plugin; + } + + var pluginConstructor = this.pluginConstructors[pluginName]; + if (pluginConstructor) { + var pluginObj = new pluginConstructor(this._ice); + this.plugins[pluginName] = pluginObj; + + if (ice.dom.isset(plugin.settings) === true) { + pluginObj.setSettings(plugin.settings); + } + + pluginObj.start(); + } + } + }, + + setActivePlugin: function(name) { + this.activePlugin = name; + }, + + getActivePlugin: function() { + return this.activePlugin; + }, + + _getPluginName: function(pluginConstructor) { + var fn = pluginConstructor.toString(); + var start = 'function '.length; + var name = fn.substr(start, (fn.indexOf('(') - start)); + return name; + }, + + /** + * Removes specified plugin. + */ + removePlugin: function(plugin) { + if (this.plugins[plugin]) { + // Call the remove fn of the plugin incase it needs to do cleanup. + this.plugins[plugin].remove(); + } + }, + + /** + * Returns the plugin object for specified plugin name. + */ + getPlugin: function(name) { + return this.plugins[name]; + + }, + + /** + * Add a new set of plugins. + */ + usePlugins: function(name, plugins, callback) { + var self = this; + if (ice.dom.isset(plugins) === true) { + this.pluginSets[name] = plugins; + } else { + this.pluginSets[name] = []; + } + var clone = this.pluginSets[name].concat([]); + this.loadPlugins(clone, function() { + self._enableSet(name); + if(callback) callback.call(this); + }); + }, + + disablePlugin: function(name) { + this.plugins[name].disable(); + }, + + isPluginElement: function(element) { + for (var i in this.plugins) { + if (this.plugins[i].isPluginElement) { + if (this.plugins[i].isPluginElement(element) === true) { + return true; + } + } + } + return false; + }, + + fireKeyPressed: function(e) { + if (this._fireKeyPressFns(e, 'all_keys') === false) { + return false; + } + + var eKeys = []; + if (e.ctrlKey === true || e.metaKey === true) { + eKeys.push('ctrl'); + } + + if (e.shiftKey === true) { + eKeys.push('shift'); + } + + if (e.altKey === true) { + eKeys.push('alt'); + } + + switch (e.keyCode) { + case 13: + eKeys.push('enter'); + break; + + case ice.dom.DOM_VK_LEFT: + eKeys.push('left'); + break; + + case ice.dom.DOM_VK_RIGHT: + eKeys.push('right'); + break; + + case ice.dom.DOM_VK_UP: + eKeys.push('up'); + break; + + case ice.dom.DOM_VK_DOWN: + eKeys.push('down'); + break; + + case 9: + eKeys.push('tab'); + break; + + case ice.dom.DOM_VK_DELETE: + eKeys.push('delete'); + break; + + default: + var code; + if (e.keyCode) { + code = e.keyCode; + } else if (e.which) { + code = e.which; + } + + // Other characters (a-z0-9..). + if (code) { + eKeys.push(String.fromCharCode(code).toLowerCase()); + } + break; + }//end switch + + var eKeysStr = eKeys.sort().join('+'); + + return this._fireKeyPressFns(e, eKeysStr); + + }, + + _fireKeyPressFns: function(e, eKeysStr) { + if (this.keyPressListeners[eKeysStr]) { + var ln = this.keyPressListeners[eKeysStr].length; + for (var i = 0; i < ln; i++) { + var listener = this.keyPressListeners[eKeysStr][i]; + var eventFn = listener.fn; + var plugin = listener.plugin; + var data = listener.data; + + if (eventFn) { + if (ice.dom.isFn(eventFn) === true) { + if (eventFn.call(plugin, e, data) === true) { + ice.dom.preventDefault(e); + return false; + } + } else if (plugin[eventFn] && plugin[eventFn].call(plugin, e, data) === true) { + ice.dom.preventDefault(e); + return false; + } + } + } + } + + return true; + }, + + fireSelectionChanged: function(range) { + for (var i in this.plugins) { + this.plugins[i].selectionChanged(range); + } + }, + + fireNodeInserted: function(node, range) { + for (var i in this.plugins) { + if (this.plugins[i].nodeInserted(node, range) === false) { + return false; + } + } + }, + + fireNodeCreated: function(node, option) { + for (var i in this.plugins) { + if (this.plugins[i].nodeCreated(node, option) === false) { + return false; + } + } + }, + + fireCaretPositioned: function() { + for (var i in this.plugins) { + this.plugins[i].caretPositioned() + } + }, + + fireClicked: function(e) { + var val = true; + for (var i in this.plugins) { + if (this.plugins[i].clicked(e) === false) { + val = false; + } + } + return val; + }, + + fireMouseDown: function(e) { + var val = true; + for (var i in this.plugins) { + if (this.plugins[i].mouseDown(e) === false) { + val = false; + } + } + return val; + }, + + fireKeyDown: function(e) { + var val = true; + for (var i in this.plugins) { + if (this.plugins[i].keyDown(e) === false) { + val = false; + } + } + return val; + }, + + fireKeyPress: function(e) { + var val = true; + for (var i in this.plugins) { + if (this.plugins[i].keyPress(e) === false) { + val = false; + } + } + return val; + }, + + fireEnabled: function(enabled) { + for (var i in this.plugins) { + this.plugins[i].setEnabled(enabled); + } + }, + + fireDisabled: function(disabled) { + for (var i in this.plugins) { + this.plugins[i].setDisabled(disabled); + } + }, + + fireCaretUpdated: function() { + for (var i in this.plugins) { + if (this.plugins[i].caretUpdated) { + this.plugins[i].caretUpdated(); + } + } + } +}; + +exports._plugin = {}; +exports.IcePluginManager = IcePluginManager; + +}).call(this.ice); diff --git a/demo/ice/plugins/IceAddTitlePlugin/IceAddTitlePlugin.js b/demo/ice/plugins/IceAddTitlePlugin/IceAddTitlePlugin.js new file mode 100644 index 00000000..94955a8e --- /dev/null +++ b/demo/ice/plugins/IceAddTitlePlugin/IceAddTitlePlugin.js @@ -0,0 +1,19 @@ +(function() { + +var exports = this, IceAddTitlePlugin; + +IceAddTitlePlugin = function(ice_instance) { + this._ice = ice_instance; +}; + +IceAddTitlePlugin.prototype = { + nodeCreated: function(node, option) { + node.setAttribute('title', (option.action || 'Modified') + ' by ' + node.getAttribute(this._ice.userNameAttribute) + + ' - ' + ice.dom.date('m/d/Y h:ia', parseInt(node.getAttribute(this._ice.timeAttribute)))); + } +}; + +ice.dom.noInclusionInherits(IceAddTitlePlugin, ice.IcePlugin); +exports._plugin.IceAddTitlePlugin = IceAddTitlePlugin; + +}).call(this.ice); diff --git a/demo/ice/plugins/IceCopyPastePlugin/IceCopyPastePlugin.js b/demo/ice/plugins/IceCopyPastePlugin/IceCopyPastePlugin.js new file mode 100644 index 00000000..d33b5fe7 --- /dev/null +++ b/demo/ice/plugins/IceCopyPastePlugin/IceCopyPastePlugin.js @@ -0,0 +1,446 @@ +(function() { + +var exports = this, IceCopyPastePlugin; + +IceCopyPastePlugin = function(ice_instance) { + this._ice = ice_instance; + this._tmpNode = null; + this._tmpNodeTagName = 'icepaste'; + this._pasteId = 'icepastediv'; + var self = this; + + // API + + // 'formatted' - paste will be MS Word cleaned. + // 'formattedClean' - paste will be MS Word cleaned, insert and + // delete tags will be removed keeping insert content in place, + // and tags not found in `preserve` will be stripped. + this.pasteType = 'formattedClean'; + + // Subset of tags that will not be stripped when pasteType + // is set to 'formattedClean'. Parameter is of type string with + // comma delimited tag and attribute definitions. For example: + // 'p,a[href],i[style|title],span[*]' + // Would allow `p`, `a`, `i` and `span` tags. The attributes for + // each one of these tags would be cleaned as follows: `p` tags + // would have all attributes removed, `a` tags will have all but + // `href` attributes removed, `i` tags will have all but `style` + // and `title` attributes removed, and `span` tags will keep all attributes. + this.preserve = 'p'; + + // Callback triggered before any paste cleaning happens + this.beforePasteClean = function(body) { return body; }; + + // Callback triggered at the end of the paste cleaning + this.afterPasteClean = function(body) { return body; }; + + // Event Listener for copying + ice_instance.element.oncopy = function() { return self.handleCopy.apply(self); }; +}; + +IceCopyPastePlugin.prototype = { + + setSettings: function(settings) { + settings = settings || {}; + ice.dom.extend(this, settings); + + this.preserve += ',' + this._tmpNodeTagName; + this.setupPreserved(); + }, + + keyDown: function(e) { + if (e.metaKey !== true && e.ctrlKey !== true) + return; + if (e.keyCode == 86) + this.handlePaste(); + else if (e.keyCode == 88) + this.handleCut(); + return true; + }, + + handleCopy: function(e) {}, + + // Inserts a temporary placeholder for the current range and removes + // the contents of the ice element body and calls a paste handler. + handlePaste: function(e) { + + var range = this._ice.getCurrentRange(); + + if (!range.collapsed) { + if (this._ice.isTracking) { + this._ice.deleteContents(); + range = range.cloneRange(); + } else { + range.deleteContents(); + range.collapse(true); + } + } + + if (this._ice.isTracking) + this._ice._moveRangeToValidTrackingPos(range); + + if (range.startContainer == this._ice.element) { + // Fix a potentially empty body with a bad selection + var firstBlock = ice.dom.find(this._ice.element, this._ice.blockEl)[0]; + if(!firstBlock) { + firstBlock = ice.dom.create('<' + this._ice.blockEl + ' >
'); + this._ice.element.appendChild(firstBlock); + } + range.setStart(firstBlock, 0); + range.collapse(true); + this._ice.env.selection.addRange(range); + } + + this._tmpNode = this._ice.env.document.createElement(this._tmpNodeTagName); + range.insertNode(this._tmpNode); + + switch (this.pasteType) { + case 'formatted': + this.setupPaste(); + break; + case 'formattedClean': + this.setupPaste(true); + break; + } + + return true; + }, + + // Create a temporary div and set focus to it so that the browser can paste into it. + // Set a timeout to push a paste handler on to the end of the execution stack. + setupPaste: function(stripTags) { + var div = this.createDiv(this._pasteId), + self = this, + range = this._ice.getCurrentRange(); + + range.selectNodeContents(div); + this._ice.selection.addRange(range); + + div.onpaste = function(event) { + setTimeout(function(){ + self.handlePasteValue(stripTags); + },0); + event.stopPropagation(); + }; + + div.focus(); + return true; + }, + + // By the time we get here, the pasted content will already be in the body. Extract the + // paste, format it, remove any Microsoft or extraneous tags outside of `this.preserve` + // and merge the pasted content into the original fragment body. + handlePasteValue: function(stripTags) { + // Get the pasted content. + var doc = this._ice.env.document, + pasteDiv = doc.getElementById(this._pasteId), + html = ice.dom.getHtml(pasteDiv), + childBlocks = ice.dom.children('
' + html + '
', this._ice.blockEl); + if(childBlocks.length === 1 && ice.dom.getNodeTextContent('
' + html + '
') === ice.dom.getNodeTextContent(childBlocks)) { + html = ice.dom.getHtml(html); + } + + html = this.beforePasteClean.call(this, html); + + if(stripTags) { + + // Strip out change tracking tags. + html = this._ice.getCleanContent(html); + html = this.stripPaste(html); + } + + html = this.afterPasteClean.call(this, html); + html = ice.dom.trim(html); + var range = this._ice.getCurrentRange(); + range.setStartAfter(this._tmpNode); + range.collapse(true); + + var innerBlock = null, lastEl = null, newEl = null; + var fragment = range.createContextualFragment(html); + var changeid = this._ice.startBatchChange(); + + // If fragment contains block level elements, most likely we will need to + // do some splitting so we do not have P tags in P tags, etc. Split the + // container from current selection and then insert paste contents after it. + if(ice.dom.hasBlockChildren(fragment)) { + // Split from current selection. + var block = ice.dom.isChildOfTagName(this._tmpNode, this._ice.blockEl); + range.setEndAfter(block.lastChild); + this._ice.selection.addRange(range); + var contents = range.extractContents(); + var newblock = doc.createElement(this._ice.blockEl); + newblock.appendChild(contents); + ice.dom.insertAfter(block, newblock); + + range.setStart(newblock, 0); + range.collapse(true); + this._ice.selection.addRange(range); + var prevBlock = range.startContainer; + + // Paste all of the children in the fragment. + while(fragment.firstChild) { + if(fragment.firstChild.nodeType === 3 && !jQuery.trim(fragment.firstChild.nodeValue)) { + fragment.removeChild(fragment.firstChild); + continue; + } + // We may have blocks with text nodes at the beginning or end. For example, this paste: + // textnode

blocktext

blocktext

moretext + // In which case we wrap the leading or trailing text nodes in blocks. + if(ice.dom.isBlockElement(fragment.firstChild)) { + if(fragment.firstChild.textContent !== "") { + innerBlock = null; + var insert = null; + if(this._ice.isTracking) { + insert = this._ice.createIceNode('insertType'); + this._ice.addChange('insertType', [insert]); + newEl = doc.createElement(fragment.firstChild.tagName); + insert.innerHTML = fragment.firstChild.innerHTML; + newEl.appendChild(insert); + } else { + insert = newEl = doc.createElement(fragment.firstChild.tagName); + newEl.innerHTML = fragment.firstChild.innerHTML; + } + lastEl = insert; + ice.dom.insertBefore(prevBlock, newEl); + } + fragment.removeChild(fragment.firstChild); + } else { + if(!innerBlock) { + // Create a new block and append an insert + newEl = doc.createElement(this._ice.blockEl); + ice.dom.insertBefore(prevBlock, newEl); + if(this._ice.isTracking) { + innerBlock = this._ice.createIceNode('insertType'); + this._ice.addChange('insertType', [innerBlock]); + newEl.appendChild(innerBlock); + } else { + innerBlock = newEl; + } + } + lastEl = innerBlock; + innerBlock.appendChild(fragment.removeChild(fragment.firstChild)); + } + } + if (!newblock.textContent) { + newblock.parentNode.removeChild(newblock); + } + + } else { + if(this._ice.isTracking) { + newEl = this._ice.createIceNode('insertType', fragment); + this._ice.addChange('insertType', [newEl]); + range.insertNode(newEl); + lastEl = newEl; + } else { + var child; + while((child = fragment.firstChild)) { + range.insertNode(child); + range.setStartAfter(child); + range.collapse(true); + lastEl = child; + } + } + } + this._ice.endBatchChange(changeid); + pasteDiv.parentNode.removeChild(pasteDiv); + this._cleanup(lastEl); + }, + + + createDiv: function(id) { + var doc = this._ice.env.document, // Document object of window or tinyMCE iframe + oldEl = doc.getElementById(id); + if(oldEl) { + ice.dom.remove(oldEl); + } + + var div = doc.createElement('div'); + div.id = id; + div.setAttribute('contentEditable', true); + ice.dom.setStyle(div, 'width', '1px'); + ice.dom.setStyle(div, 'height', '1px'); + ice.dom.setStyle(div, 'overflow', 'hidden'); + ice.dom.setStyle(div, 'position', 'fixed'); + ice.dom.setStyle(div, 'top', '10px'); + ice.dom.setStyle(div, 'left', '10px'); + + div.appendChild(doc.createElement('br')); + doc.body.appendChild(div); + return div; + }, + + // Intercepts cut operation and handles by creating an editable div, copying the current selection + // into it, deleting the current selection with track changes, and selecting the contents in the + // editable div. + handleCut: function() { + var self = this, + range = this._ice.getCurrentRange(); + if (range.collapsed) return; // If nothing is selected, there's nothing to mark deleted + + this.cutElement = this.createDiv('icecut'); + // Chrome strips out spaces between text nodes and elements node during cut + this.cutElement.innerHTML = range.getHTMLContents().replace(/ /g, '> '); + + if (this._ice.isTracking) this._ice.deleteContents(); + else range.deleteContents(); + + var crange = this._ice.env.document.createRange(); + crange.setStart(this.cutElement.firstChild, 0); + crange.setEndAfter(this.cutElement.lastChild); + + setTimeout(function() { + self.cutElement.focus(); + + // After the browser cuts out of the `cutElement`, reset the range and remove the cut element. + setTimeout(function() { + ice.dom.remove(self.cutElement); + range.setStart(range.startContainer, range.startOffset); + range.collapse(false); + self._ice.env.selection.addRange(range); + }, 100); + }, 0); + + self._ice.env.selection.addRange(crange); + }, + + + // Strips ice change tracking tags, Microsoft Word styling/content, and any + // tags and attributes not found in `preserve` from the given `content`. + stripPaste: function(content) { + // Clean word stuff out and strip tags that are not in `this.preserve`. + content = this._cleanWordPaste(content); + content = this.cleanPreserved(content); + return content; + }, + + // Parses `preserve` to setup `_tags` with a comma delimited list of all of the + // defined tags, and the `_attributesMap` with a mapping between the allowed tags and + // an array of their allowed attributes. For example, given this value: + // `preserve` = 'p,a[href|class],span[*]' + // The following will result: + // `_tags` = 'p,a,span' + // `_attributesMap` = ['p' => [], 'a' => ['href', 'class'], 'span' => ['*']] + setupPreserved: function() { + var self = this; + this._tags = ''; + this._attributesMap = []; + + ice.dom.each(this.preserve.split(','), function(i, tagAttr) { + // Extract the tag and attributes list + tagAttr.match(/(\w+)(\[(.+)\])?/); + var tag = RegExp.$1; + var attr = RegExp.$3; + + if(self._tags) self._tags += ','; + self._tags += tag.toLowerCase(); + self._attributesMap[tag] = attr.split('|'); + }); + }, + + // Cleans the given `body` by removing any tags not found in `_tags` and replacing them with + // their inner contents, and removes attributes from any tags that aren't mapped in `_attributesMap`. + cleanPreserved: function(body) { + var self = this; + var bodyel = this._ice.env.document.createElement('div'); + bodyel.innerHTML = body; + + // Strip out any tags not found in `this._tags`, replacing the tags with their inner contents. + bodyel = ice.dom.stripEnclosingTags(bodyel, this._tags); + + // Strip out any attributes from the allowed set of tags that don't match what is in the `_attributesMap` + ice.dom.each(ice.dom.find(bodyel, this._tags), function(i, el) { + if (ice.dom.hasClass(el, 'skip-clean')) { + return true; + } + var tag = el.tagName.toLowerCase(); + var attrMatches = self._attributesMap[tag]; + + // Kleene star - keep all of the attributes for this tag. + if(attrMatches[0] && attrMatches[0] === '*') + return true; + + // Remove any foreign attributes that do not match the map. + if(el.hasAttributes()) { + var attributes = el.attributes; + for(var i = attributes.length - 1; i >= 0; i--) { + if(!ice.dom.inArray(attributes[i].name, attrMatches)) { + el.removeAttribute(attributes[i].name); + } + } + } + }); + return bodyel.innerHTML; + }, + + _cleanWordPaste: function(content) { + // Meta and link tags. + content = content.replace(/<(meta|link)[^>]+>/g, ""); + + // Comments. + content = content.replace(//g, ''); + + // Remove style tags. + content = content.replace(/