vendor/assets/javascripts/wysihtml5.js in wysihtml5-rails-0.0.2 vs vendor/assets/javascripts/wysihtml5.js in wysihtml5-rails-0.0.3

- old
+ new

@@ -1,2 +1,9521 @@ -//= require ./wysihtml5/parser_rules/advanced -//= require ./wysihtml5/wysihtml5 +/** + * @license wysihtml5 v0.3.0 + * https://github.com/xing/wysihtml5 + * + * Author: Christopher Blum (https://github.com/tiff) + * + * Copyright (C) 2012 XING AG + * Licensed under the MIT license (MIT) + * + */ +var wysihtml5 = { + version: "0.3.0", + + // namespaces + commands: {}, + dom: {}, + quirks: {}, + toolbar: {}, + lang: {}, + selection: {}, + views: {}, + + INVISIBLE_SPACE: "\uFEFF", + + EMPTY_FUNCTION: function() {}, + + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + BACKSPACE_KEY: 8, + ENTER_KEY: 13, + ESCAPE_KEY: 27, + SPACE_KEY: 32, + DELETE_KEY: 46 +};/** + * @license Rangy, a cross-browser JavaScript range and selection library + * http://code.google.com/p/rangy/ + * + * Copyright 2011, Tim Down + * Licensed under the MIT license. + * Version: 1.2.2 + * Build date: 13 November 2011 + */ +window['rangy'] = (function() { + + + var OBJECT = "object", FUNCTION = "function", UNDEFINED = "undefined"; + + var domRangeProperties = ["startContainer", "startOffset", "endContainer", "endOffset", "collapsed", + "commonAncestorContainer", "START_TO_START", "START_TO_END", "END_TO_START", "END_TO_END"]; + + var domRangeMethods = ["setStart", "setStartBefore", "setStartAfter", "setEnd", "setEndBefore", + "setEndAfter", "collapse", "selectNode", "selectNodeContents", "compareBoundaryPoints", "deleteContents", + "extractContents", "cloneContents", "insertNode", "surroundContents", "cloneRange", "toString", "detach"]; + + var textRangeProperties = ["boundingHeight", "boundingLeft", "boundingTop", "boundingWidth", "htmlText", "text"]; + + // Subset of TextRange's full set of methods that we're interested in + var textRangeMethods = ["collapse", "compareEndPoints", "duplicate", "getBookmark", "moveToBookmark", + "moveToElementText", "parentElement", "pasteHTML", "select", "setEndPoint", "getBoundingClientRect"]; + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Trio of functions taken from Peter Michaux's article: + // http://peter.michaux.ca/articles/feature-detection-state-of-the-art-browser-scripting + function isHostMethod(o, p) { + var t = typeof o[p]; + return t == FUNCTION || (!!(t == OBJECT && o[p])) || t == "unknown"; + } + + function isHostObject(o, p) { + return !!(typeof o[p] == OBJECT && o[p]); + } + + function isHostProperty(o, p) { + return typeof o[p] != UNDEFINED; + } + + // Creates a convenience function to save verbose repeated calls to tests functions + function createMultiplePropertyTest(testFunc) { + return function(o, props) { + var i = props.length; + while (i--) { + if (!testFunc(o, props[i])) { + return false; + } + } + return true; + }; + } + + // Next trio of functions are a convenience to save verbose repeated calls to previous two functions + var areHostMethods = createMultiplePropertyTest(isHostMethod); + var areHostObjects = createMultiplePropertyTest(isHostObject); + var areHostProperties = createMultiplePropertyTest(isHostProperty); + + function isTextRange(range) { + return range && areHostMethods(range, textRangeMethods) && areHostProperties(range, textRangeProperties); + } + + var api = { + version: "1.2.2", + initialized: false, + supported: true, + + util: { + isHostMethod: isHostMethod, + isHostObject: isHostObject, + isHostProperty: isHostProperty, + areHostMethods: areHostMethods, + areHostObjects: areHostObjects, + areHostProperties: areHostProperties, + isTextRange: isTextRange + }, + + features: {}, + + modules: {}, + config: { + alertOnWarn: false, + preferTextRange: false + } + }; + + function fail(reason) { + window.alert("Rangy not supported in your browser. Reason: " + reason); + api.initialized = true; + api.supported = false; + } + + api.fail = fail; + + function warn(msg) { + var warningMessage = "Rangy warning: " + msg; + if (api.config.alertOnWarn) { + window.alert(warningMessage); + } else if (typeof window.console != UNDEFINED && typeof window.console.log != UNDEFINED) { + window.console.log(warningMessage); + } + } + + api.warn = warn; + + if ({}.hasOwnProperty) { + api.util.extend = function(o, props) { + for (var i in props) { + if (props.hasOwnProperty(i)) { + o[i] = props[i]; + } + } + }; + } else { + fail("hasOwnProperty not supported"); + } + + var initListeners = []; + var moduleInitializers = []; + + // Initialization + function init() { + if (api.initialized) { + return; + } + var testRange; + var implementsDomRange = false, implementsTextRange = false; + + // First, perform basic feature tests + + if (isHostMethod(document, "createRange")) { + testRange = document.createRange(); + if (areHostMethods(testRange, domRangeMethods) && areHostProperties(testRange, domRangeProperties)) { + implementsDomRange = true; + } + testRange.detach(); + } + + var body = isHostObject(document, "body") ? document.body : document.getElementsByTagName("body")[0]; + + if (body && isHostMethod(body, "createTextRange")) { + testRange = body.createTextRange(); + if (isTextRange(testRange)) { + implementsTextRange = true; + } + } + + if (!implementsDomRange && !implementsTextRange) { + fail("Neither Range nor TextRange are implemented"); + } + + api.initialized = true; + api.features = { + implementsDomRange: implementsDomRange, + implementsTextRange: implementsTextRange + }; + + // Initialize modules and call init listeners + var allListeners = moduleInitializers.concat(initListeners); + for (var i = 0, len = allListeners.length; i < len; ++i) { + try { + allListeners[i](api); + } catch (ex) { + if (isHostObject(window, "console") && isHostMethod(window.console, "log")) { + window.console.log("Init listener threw an exception. Continuing.", ex); + } + + } + } + } + + // Allow external scripts to initialize this library in case it's loaded after the document has loaded + api.init = init; + + // Execute listener immediately if already initialized + api.addInitListener = function(listener) { + if (api.initialized) { + listener(api); + } else { + initListeners.push(listener); + } + }; + + var createMissingNativeApiListeners = []; + + api.addCreateMissingNativeApiListener = function(listener) { + createMissingNativeApiListeners.push(listener); + }; + + function createMissingNativeApi(win) { + win = win || window; + init(); + + // Notify listeners + for (var i = 0, len = createMissingNativeApiListeners.length; i < len; ++i) { + createMissingNativeApiListeners[i](win); + } + } + + api.createMissingNativeApi = createMissingNativeApi; + + /** + * @constructor + */ + function Module(name) { + this.name = name; + this.initialized = false; + this.supported = false; + } + + Module.prototype.fail = function(reason) { + this.initialized = true; + this.supported = false; + + throw new Error("Module '" + this.name + "' failed to load: " + reason); + }; + + Module.prototype.warn = function(msg) { + api.warn("Module " + this.name + ": " + msg); + }; + + Module.prototype.createError = function(msg) { + return new Error("Error in Rangy " + this.name + " module: " + msg); + }; + + api.createModule = function(name, initFunc) { + var module = new Module(name); + api.modules[name] = module; + + moduleInitializers.push(function(api) { + initFunc(api, module); + module.initialized = true; + module.supported = true; + }); + }; + + api.requireModules = function(modules) { + for (var i = 0, len = modules.length, module, moduleName; i < len; ++i) { + moduleName = modules[i]; + module = api.modules[moduleName]; + if (!module || !(module instanceof Module)) { + throw new Error("Module '" + moduleName + "' not found"); + } + if (!module.supported) { + throw new Error("Module '" + moduleName + "' not supported"); + } + } + }; + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Wait for document to load before running tests + + var docReady = false; + + var loadHandler = function(e) { + + if (!docReady) { + docReady = true; + if (!api.initialized) { + init(); + } + } + }; + + // Test whether we have window and document objects that we will need + if (typeof window == UNDEFINED) { + fail("No window found"); + return; + } + if (typeof document == UNDEFINED) { + fail("No document found"); + return; + } + + if (isHostMethod(document, "addEventListener")) { + document.addEventListener("DOMContentLoaded", loadHandler, false); + } + + // Add a fallback in case the DOMContentLoaded event isn't supported + if (isHostMethod(window, "addEventListener")) { + window.addEventListener("load", loadHandler, false); + } else if (isHostMethod(window, "attachEvent")) { + window.attachEvent("onload", loadHandler); + } else { + fail("Window does not have required addEventListener or attachEvent method"); + } + + return api; +})(); +rangy.createModule("DomUtil", function(api, module) { + + var UNDEF = "undefined"; + var util = api.util; + + // Perform feature tests + if (!util.areHostMethods(document, ["createDocumentFragment", "createElement", "createTextNode"])) { + module.fail("document missing a Node creation method"); + } + + if (!util.isHostMethod(document, "getElementsByTagName")) { + module.fail("document missing getElementsByTagName method"); + } + + var el = document.createElement("div"); + if (!util.areHostMethods(el, ["insertBefore", "appendChild", "cloneNode"] || + !util.areHostObjects(el, ["previousSibling", "nextSibling", "childNodes", "parentNode"]))) { + module.fail("Incomplete Element implementation"); + } + + // innerHTML is required for Range's createContextualFragment method + if (!util.isHostProperty(el, "innerHTML")) { + module.fail("Element is missing innerHTML property"); + } + + var textNode = document.createTextNode("test"); + if (!util.areHostMethods(textNode, ["splitText", "deleteData", "insertData", "appendData", "cloneNode"] || + !util.areHostObjects(el, ["previousSibling", "nextSibling", "childNodes", "parentNode"]) || + !util.areHostProperties(textNode, ["data"]))) { + module.fail("Incomplete Text Node implementation"); + } + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Removed use of indexOf because of a bizarre bug in Opera that is thrown in one of the Acid3 tests. I haven't been + // able to replicate it outside of the test. The bug is that indexOf returns -1 when called on an Array that + // contains just the document as a single element and the value searched for is the document. + var arrayContains = /*Array.prototype.indexOf ? + function(arr, val) { + return arr.indexOf(val) > -1; + }:*/ + + function(arr, val) { + var i = arr.length; + while (i--) { + if (arr[i] === val) { + return true; + } + } + return false; + }; + + // Opera 11 puts HTML elements in the null namespace, it seems, and IE 7 has undefined namespaceURI + function isHtmlNamespace(node) { + var ns; + return typeof node.namespaceURI == UNDEF || ((ns = node.namespaceURI) === null || ns == "http://www.w3.org/1999/xhtml"); + } + + function parentElement(node) { + var parent = node.parentNode; + return (parent.nodeType == 1) ? parent : null; + } + + function getNodeIndex(node) { + var i = 0; + while( (node = node.previousSibling) ) { + i++; + } + return i; + } + + function getNodeLength(node) { + var childNodes; + return isCharacterDataNode(node) ? node.length : ((childNodes = node.childNodes) ? childNodes.length : 0); + } + + function getCommonAncestor(node1, node2) { + var ancestors = [], n; + for (n = node1; n; n = n.parentNode) { + ancestors.push(n); + } + + for (n = node2; n; n = n.parentNode) { + if (arrayContains(ancestors, n)) { + return n; + } + } + + return null; + } + + function isAncestorOf(ancestor, descendant, selfIsAncestor) { + var n = selfIsAncestor ? descendant : descendant.parentNode; + while (n) { + if (n === ancestor) { + return true; + } else { + n = n.parentNode; + } + } + return false; + } + + function getClosestAncestorIn(node, ancestor, selfIsAncestor) { + var p, n = selfIsAncestor ? node : node.parentNode; + while (n) { + p = n.parentNode; + if (p === ancestor) { + return n; + } + n = p; + } + return null; + } + + function isCharacterDataNode(node) { + var t = node.nodeType; + return t == 3 || t == 4 || t == 8 ; // Text, CDataSection or Comment + } + + function insertAfter(node, precedingNode) { + var nextNode = precedingNode.nextSibling, parent = precedingNode.parentNode; + if (nextNode) { + parent.insertBefore(node, nextNode); + } else { + parent.appendChild(node); + } + return node; + } + + // Note that we cannot use splitText() because it is bugridden in IE 9. + function splitDataNode(node, index) { + var newNode = node.cloneNode(false); + newNode.deleteData(0, index); + node.deleteData(index, node.length - index); + insertAfter(newNode, node); + return newNode; + } + + function getDocument(node) { + if (node.nodeType == 9) { + return node; + } else if (typeof node.ownerDocument != UNDEF) { + return node.ownerDocument; + } else if (typeof node.document != UNDEF) { + return node.document; + } else if (node.parentNode) { + return getDocument(node.parentNode); + } else { + throw new Error("getDocument: no document found for node"); + } + } + + function getWindow(node) { + var doc = getDocument(node); + if (typeof doc.defaultView != UNDEF) { + return doc.defaultView; + } else if (typeof doc.parentWindow != UNDEF) { + return doc.parentWindow; + } else { + throw new Error("Cannot get a window object for node"); + } + } + + function getIframeDocument(iframeEl) { + if (typeof iframeEl.contentDocument != UNDEF) { + return iframeEl.contentDocument; + } else if (typeof iframeEl.contentWindow != UNDEF) { + return iframeEl.contentWindow.document; + } else { + throw new Error("getIframeWindow: No Document object found for iframe element"); + } + } + + function getIframeWindow(iframeEl) { + if (typeof iframeEl.contentWindow != UNDEF) { + return iframeEl.contentWindow; + } else if (typeof iframeEl.contentDocument != UNDEF) { + return iframeEl.contentDocument.defaultView; + } else { + throw new Error("getIframeWindow: No Window object found for iframe element"); + } + } + + function getBody(doc) { + return util.isHostObject(doc, "body") ? doc.body : doc.getElementsByTagName("body")[0]; + } + + function getRootContainer(node) { + var parent; + while ( (parent = node.parentNode) ) { + node = parent; + } + return node; + } + + function comparePoints(nodeA, offsetA, nodeB, offsetB) { + // See http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Comparing + var nodeC, root, childA, childB, n; + if (nodeA == nodeB) { + + // Case 1: nodes are the same + return offsetA === offsetB ? 0 : (offsetA < offsetB) ? -1 : 1; + } else if ( (nodeC = getClosestAncestorIn(nodeB, nodeA, true)) ) { + + // Case 2: node C (container B or an ancestor) is a child node of A + return offsetA <= getNodeIndex(nodeC) ? -1 : 1; + } else if ( (nodeC = getClosestAncestorIn(nodeA, nodeB, true)) ) { + + // Case 3: node C (container A or an ancestor) is a child node of B + return getNodeIndex(nodeC) < offsetB ? -1 : 1; + } else { + + // Case 4: containers are siblings or descendants of siblings + root = getCommonAncestor(nodeA, nodeB); + childA = (nodeA === root) ? root : getClosestAncestorIn(nodeA, root, true); + childB = (nodeB === root) ? root : getClosestAncestorIn(nodeB, root, true); + + if (childA === childB) { + // This shouldn't be possible + + throw new Error("comparePoints got to case 4 and childA and childB are the same!"); + } else { + n = root.firstChild; + while (n) { + if (n === childA) { + return -1; + } else if (n === childB) { + return 1; + } + n = n.nextSibling; + } + throw new Error("Should not be here!"); + } + } + } + + function fragmentFromNodeChildren(node) { + var fragment = getDocument(node).createDocumentFragment(), child; + while ( (child = node.firstChild) ) { + fragment.appendChild(child); + } + return fragment; + } + + function inspectNode(node) { + if (!node) { + return "[No node]"; + } + if (isCharacterDataNode(node)) { + return '"' + node.data + '"'; + } else if (node.nodeType == 1) { + var idAttr = node.id ? ' id="' + node.id + '"' : ""; + return "<" + node.nodeName + idAttr + ">[" + node.childNodes.length + "]"; + } else { + return node.nodeName; + } + } + + /** + * @constructor + */ + function NodeIterator(root) { + this.root = root; + this._next = root; + } + + NodeIterator.prototype = { + _current: null, + + hasNext: function() { + return !!this._next; + }, + + next: function() { + var n = this._current = this._next; + var child, next; + if (this._current) { + child = n.firstChild; + if (child) { + this._next = child; + } else { + next = null; + while ((n !== this.root) && !(next = n.nextSibling)) { + n = n.parentNode; + } + this._next = next; + } + } + return this._current; + }, + + detach: function() { + this._current = this._next = this.root = null; + } + }; + + function createIterator(root) { + return new NodeIterator(root); + } + + /** + * @constructor + */ + function DomPosition(node, offset) { + this.node = node; + this.offset = offset; + } + + DomPosition.prototype = { + equals: function(pos) { + return this.node === pos.node & this.offset == pos.offset; + }, + + inspect: function() { + return "[DomPosition(" + inspectNode(this.node) + ":" + this.offset + ")]"; + } + }; + + /** + * @constructor + */ + function DOMException(codeName) { + this.code = this[codeName]; + this.codeName = codeName; + this.message = "DOMException: " + this.codeName; + } + + DOMException.prototype = { + INDEX_SIZE_ERR: 1, + HIERARCHY_REQUEST_ERR: 3, + WRONG_DOCUMENT_ERR: 4, + NO_MODIFICATION_ALLOWED_ERR: 7, + NOT_FOUND_ERR: 8, + NOT_SUPPORTED_ERR: 9, + INVALID_STATE_ERR: 11 + }; + + DOMException.prototype.toString = function() { + return this.message; + }; + + api.dom = { + arrayContains: arrayContains, + isHtmlNamespace: isHtmlNamespace, + parentElement: parentElement, + getNodeIndex: getNodeIndex, + getNodeLength: getNodeLength, + getCommonAncestor: getCommonAncestor, + isAncestorOf: isAncestorOf, + getClosestAncestorIn: getClosestAncestorIn, + isCharacterDataNode: isCharacterDataNode, + insertAfter: insertAfter, + splitDataNode: splitDataNode, + getDocument: getDocument, + getWindow: getWindow, + getIframeWindow: getIframeWindow, + getIframeDocument: getIframeDocument, + getBody: getBody, + getRootContainer: getRootContainer, + comparePoints: comparePoints, + inspectNode: inspectNode, + fragmentFromNodeChildren: fragmentFromNodeChildren, + createIterator: createIterator, + DomPosition: DomPosition + }; + + api.DOMException = DOMException; +});rangy.createModule("DomRange", function(api, module) { + api.requireModules( ["DomUtil"] ); + + + var dom = api.dom; + var DomPosition = dom.DomPosition; + var DOMException = api.DOMException; + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Utility functions + + function isNonTextPartiallySelected(node, range) { + return (node.nodeType != 3) && + (dom.isAncestorOf(node, range.startContainer, true) || dom.isAncestorOf(node, range.endContainer, true)); + } + + function getRangeDocument(range) { + return dom.getDocument(range.startContainer); + } + + function dispatchEvent(range, type, args) { + var listeners = range._listeners[type]; + if (listeners) { + for (var i = 0, len = listeners.length; i < len; ++i) { + listeners[i].call(range, {target: range, args: args}); + } + } + } + + function getBoundaryBeforeNode(node) { + return new DomPosition(node.parentNode, dom.getNodeIndex(node)); + } + + function getBoundaryAfterNode(node) { + return new DomPosition(node.parentNode, dom.getNodeIndex(node) + 1); + } + + function insertNodeAtPosition(node, n, o) { + var firstNodeInserted = node.nodeType == 11 ? node.firstChild : node; + if (dom.isCharacterDataNode(n)) { + if (o == n.length) { + dom.insertAfter(node, n); + } else { + n.parentNode.insertBefore(node, o == 0 ? n : dom.splitDataNode(n, o)); + } + } else if (o >= n.childNodes.length) { + n.appendChild(node); + } else { + n.insertBefore(node, n.childNodes[o]); + } + return firstNodeInserted; + } + + function cloneSubtree(iterator) { + var partiallySelected; + for (var node, frag = getRangeDocument(iterator.range).createDocumentFragment(), subIterator; node = iterator.next(); ) { + partiallySelected = iterator.isPartiallySelectedSubtree(); + + node = node.cloneNode(!partiallySelected); + if (partiallySelected) { + subIterator = iterator.getSubtreeIterator(); + node.appendChild(cloneSubtree(subIterator)); + subIterator.detach(true); + } + + if (node.nodeType == 10) { // DocumentType + throw new DOMException("HIERARCHY_REQUEST_ERR"); + } + frag.appendChild(node); + } + return frag; + } + + function iterateSubtree(rangeIterator, func, iteratorState) { + var it, n; + iteratorState = iteratorState || { stop: false }; + for (var node, subRangeIterator; node = rangeIterator.next(); ) { + //log.debug("iterateSubtree, partially selected: " + rangeIterator.isPartiallySelectedSubtree(), nodeToString(node)); + if (rangeIterator.isPartiallySelectedSubtree()) { + // The node is partially selected by the Range, so we can use a new RangeIterator on the portion of the + // node selected by the Range. + if (func(node) === false) { + iteratorState.stop = true; + return; + } else { + subRangeIterator = rangeIterator.getSubtreeIterator(); + iterateSubtree(subRangeIterator, func, iteratorState); + subRangeIterator.detach(true); + if (iteratorState.stop) { + return; + } + } + } else { + // The whole node is selected, so we can use efficient DOM iteration to iterate over the node and its + // descendant + it = dom.createIterator(node); + while ( (n = it.next()) ) { + if (func(n) === false) { + iteratorState.stop = true; + return; + } + } + } + } + } + + function deleteSubtree(iterator) { + var subIterator; + while (iterator.next()) { + if (iterator.isPartiallySelectedSubtree()) { + subIterator = iterator.getSubtreeIterator(); + deleteSubtree(subIterator); + subIterator.detach(true); + } else { + iterator.remove(); + } + } + } + + function extractSubtree(iterator) { + + for (var node, frag = getRangeDocument(iterator.range).createDocumentFragment(), subIterator; node = iterator.next(); ) { + + + if (iterator.isPartiallySelectedSubtree()) { + node = node.cloneNode(false); + subIterator = iterator.getSubtreeIterator(); + node.appendChild(extractSubtree(subIterator)); + subIterator.detach(true); + } else { + iterator.remove(); + } + if (node.nodeType == 10) { // DocumentType + throw new DOMException("HIERARCHY_REQUEST_ERR"); + } + frag.appendChild(node); + } + return frag; + } + + function getNodesInRange(range, nodeTypes, filter) { + //log.info("getNodesInRange, " + nodeTypes.join(",")); + var filterNodeTypes = !!(nodeTypes && nodeTypes.length), regex; + var filterExists = !!filter; + if (filterNodeTypes) { + regex = new RegExp("^(" + nodeTypes.join("|") + ")$"); + } + + var nodes = []; + iterateSubtree(new RangeIterator(range, false), function(node) { + if ((!filterNodeTypes || regex.test(node.nodeType)) && (!filterExists || filter(node))) { + nodes.push(node); + } + }); + return nodes; + } + + function inspect(range) { + var name = (typeof range.getName == "undefined") ? "Range" : range.getName(); + return "[" + name + "(" + dom.inspectNode(range.startContainer) + ":" + range.startOffset + ", " + + dom.inspectNode(range.endContainer) + ":" + range.endOffset + ")]"; + } + + /*----------------------------------------------------------------------------------------------------------------*/ + + // RangeIterator code partially borrows from IERange by Tim Ryan (http://github.com/timcameronryan/IERange) + + /** + * @constructor + */ + function RangeIterator(range, clonePartiallySelectedTextNodes) { + this.range = range; + this.clonePartiallySelectedTextNodes = clonePartiallySelectedTextNodes; + + + + if (!range.collapsed) { + this.sc = range.startContainer; + this.so = range.startOffset; + this.ec = range.endContainer; + this.eo = range.endOffset; + var root = range.commonAncestorContainer; + + if (this.sc === this.ec && dom.isCharacterDataNode(this.sc)) { + this.isSingleCharacterDataNode = true; + this._first = this._last = this._next = this.sc; + } else { + this._first = this._next = (this.sc === root && !dom.isCharacterDataNode(this.sc)) ? + this.sc.childNodes[this.so] : dom.getClosestAncestorIn(this.sc, root, true); + this._last = (this.ec === root && !dom.isCharacterDataNode(this.ec)) ? + this.ec.childNodes[this.eo - 1] : dom.getClosestAncestorIn(this.ec, root, true); + } + + } + } + + RangeIterator.prototype = { + _current: null, + _next: null, + _first: null, + _last: null, + isSingleCharacterDataNode: false, + + reset: function() { + this._current = null; + this._next = this._first; + }, + + hasNext: function() { + return !!this._next; + }, + + next: function() { + // Move to next node + var current = this._current = this._next; + if (current) { + this._next = (current !== this._last) ? current.nextSibling : null; + + // Check for partially selected text nodes + if (dom.isCharacterDataNode(current) && this.clonePartiallySelectedTextNodes) { + if (current === this.ec) { + + (current = current.cloneNode(true)).deleteData(this.eo, current.length - this.eo); + } + if (this._current === this.sc) { + + (current = current.cloneNode(true)).deleteData(0, this.so); + } + } + } + + return current; + }, + + remove: function() { + var current = this._current, start, end; + + if (dom.isCharacterDataNode(current) && (current === this.sc || current === this.ec)) { + start = (current === this.sc) ? this.so : 0; + end = (current === this.ec) ? this.eo : current.length; + if (start != end) { + current.deleteData(start, end - start); + } + } else { + if (current.parentNode) { + current.parentNode.removeChild(current); + } else { + + } + } + }, + + // Checks if the current node is partially selected + isPartiallySelectedSubtree: function() { + var current = this._current; + return isNonTextPartiallySelected(current, this.range); + }, + + getSubtreeIterator: function() { + var subRange; + if (this.isSingleCharacterDataNode) { + subRange = this.range.cloneRange(); + subRange.collapse(); + } else { + subRange = new Range(getRangeDocument(this.range)); + var current = this._current; + var startContainer = current, startOffset = 0, endContainer = current, endOffset = dom.getNodeLength(current); + + if (dom.isAncestorOf(current, this.sc, true)) { + startContainer = this.sc; + startOffset = this.so; + } + if (dom.isAncestorOf(current, this.ec, true)) { + endContainer = this.ec; + endOffset = this.eo; + } + + updateBoundaries(subRange, startContainer, startOffset, endContainer, endOffset); + } + return new RangeIterator(subRange, this.clonePartiallySelectedTextNodes); + }, + + detach: function(detachRange) { + if (detachRange) { + this.range.detach(); + } + this.range = this._current = this._next = this._first = this._last = this.sc = this.so = this.ec = this.eo = null; + } + }; + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Exceptions + + /** + * @constructor + */ + function RangeException(codeName) { + this.code = this[codeName]; + this.codeName = codeName; + this.message = "RangeException: " + this.codeName; + } + + RangeException.prototype = { + BAD_BOUNDARYPOINTS_ERR: 1, + INVALID_NODE_TYPE_ERR: 2 + }; + + RangeException.prototype.toString = function() { + return this.message; + }; + + /*----------------------------------------------------------------------------------------------------------------*/ + + /** + * Currently iterates through all nodes in the range on creation until I think of a decent way to do it + * TODO: Look into making this a proper iterator, not requiring preloading everything first + * @constructor + */ + function RangeNodeIterator(range, nodeTypes, filter) { + this.nodes = getNodesInRange(range, nodeTypes, filter); + this._next = this.nodes[0]; + this._position = 0; + } + + RangeNodeIterator.prototype = { + _current: null, + + hasNext: function() { + return !!this._next; + }, + + next: function() { + this._current = this._next; + this._next = this.nodes[ ++this._position ]; + return this._current; + }, + + detach: function() { + this._current = this._next = this.nodes = null; + } + }; + + var beforeAfterNodeTypes = [1, 3, 4, 5, 7, 8, 10]; + var rootContainerNodeTypes = [2, 9, 11]; + var readonlyNodeTypes = [5, 6, 10, 12]; + var insertableNodeTypes = [1, 3, 4, 5, 7, 8, 10, 11]; + var surroundNodeTypes = [1, 3, 4, 5, 7, 8]; + + function createAncestorFinder(nodeTypes) { + return function(node, selfIsAncestor) { + var t, n = selfIsAncestor ? node : node.parentNode; + while (n) { + t = n.nodeType; + if (dom.arrayContains(nodeTypes, t)) { + return n; + } + n = n.parentNode; + } + return null; + }; + } + + var getRootContainer = dom.getRootContainer; + var getDocumentOrFragmentContainer = createAncestorFinder( [9, 11] ); + var getReadonlyAncestor = createAncestorFinder(readonlyNodeTypes); + var getDocTypeNotationEntityAncestor = createAncestorFinder( [6, 10, 12] ); + + function assertNoDocTypeNotationEntityAncestor(node, allowSelf) { + if (getDocTypeNotationEntityAncestor(node, allowSelf)) { + throw new RangeException("INVALID_NODE_TYPE_ERR"); + } + } + + function assertNotDetached(range) { + if (!range.startContainer) { + throw new DOMException("INVALID_STATE_ERR"); + } + } + + function assertValidNodeType(node, invalidTypes) { + if (!dom.arrayContains(invalidTypes, node.nodeType)) { + throw new RangeException("INVALID_NODE_TYPE_ERR"); + } + } + + function assertValidOffset(node, offset) { + if (offset < 0 || offset > (dom.isCharacterDataNode(node) ? node.length : node.childNodes.length)) { + throw new DOMException("INDEX_SIZE_ERR"); + } + } + + function assertSameDocumentOrFragment(node1, node2) { + if (getDocumentOrFragmentContainer(node1, true) !== getDocumentOrFragmentContainer(node2, true)) { + throw new DOMException("WRONG_DOCUMENT_ERR"); + } + } + + function assertNodeNotReadOnly(node) { + if (getReadonlyAncestor(node, true)) { + throw new DOMException("NO_MODIFICATION_ALLOWED_ERR"); + } + } + + function assertNode(node, codeName) { + if (!node) { + throw new DOMException(codeName); + } + } + + function isOrphan(node) { + return !dom.arrayContains(rootContainerNodeTypes, node.nodeType) && !getDocumentOrFragmentContainer(node, true); + } + + function isValidOffset(node, offset) { + return offset <= (dom.isCharacterDataNode(node) ? node.length : node.childNodes.length); + } + + function assertRangeValid(range) { + assertNotDetached(range); + if (isOrphan(range.startContainer) || isOrphan(range.endContainer) || + !isValidOffset(range.startContainer, range.startOffset) || + !isValidOffset(range.endContainer, range.endOffset)) { + throw new Error("Range error: Range is no longer valid after DOM mutation (" + range.inspect() + ")"); + } + } + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Test the browser's innerHTML support to decide how to implement createContextualFragment + var styleEl = document.createElement("style"); + var htmlParsingConforms = false; + try { + styleEl.innerHTML = "<b>x</b>"; + htmlParsingConforms = (styleEl.firstChild.nodeType == 3); // Opera incorrectly creates an element node + } catch (e) { + // IE 6 and 7 throw + } + + api.features.htmlParsingConforms = htmlParsingConforms; + + var createContextualFragment = htmlParsingConforms ? + + // Implementation as per HTML parsing spec, trusting in the browser's implementation of innerHTML. See + // discussion and base code for this implementation at issue 67. + // Spec: http://html5.org/specs/dom-parsing.html#extensions-to-the-range-interface + // Thanks to Aleks Williams. + function(fragmentStr) { + // "Let node the context object's start's node." + var node = this.startContainer; + var doc = dom.getDocument(node); + + // "If the context object's start's node is null, raise an INVALID_STATE_ERR + // exception and abort these steps." + if (!node) { + throw new DOMException("INVALID_STATE_ERR"); + } + + // "Let element be as follows, depending on node's interface:" + // Document, Document Fragment: null + var el = null; + + // "Element: node" + if (node.nodeType == 1) { + el = node; + + // "Text, Comment: node's parentElement" + } else if (dom.isCharacterDataNode(node)) { + el = dom.parentElement(node); + } + + // "If either element is null or element's ownerDocument is an HTML document + // and element's local name is "html" and element's namespace is the HTML + // namespace" + if (el === null || ( + el.nodeName == "HTML" + && dom.isHtmlNamespace(dom.getDocument(el).documentElement) + && dom.isHtmlNamespace(el) + )) { + + // "let element be a new Element with "body" as its local name and the HTML + // namespace as its namespace."" + el = doc.createElement("body"); + } else { + el = el.cloneNode(false); + } + + // "If the node's document is an HTML document: Invoke the HTML fragment parsing algorithm." + // "If the node's document is an XML document: Invoke the XML fragment parsing algorithm." + // "In either case, the algorithm must be invoked with fragment as the input + // and element as the context element." + el.innerHTML = fragmentStr; + + // "If this raises an exception, then abort these steps. Otherwise, let new + // children be the nodes returned." + + // "Let fragment be a new DocumentFragment." + // "Append all new children to fragment." + // "Return fragment." + return dom.fragmentFromNodeChildren(el); + } : + + // In this case, innerHTML cannot be trusted, so fall back to a simpler, non-conformant implementation that + // previous versions of Rangy used (with the exception of using a body element rather than a div) + function(fragmentStr) { + assertNotDetached(this); + var doc = getRangeDocument(this); + var el = doc.createElement("body"); + el.innerHTML = fragmentStr; + + return dom.fragmentFromNodeChildren(el); + }; + + /*----------------------------------------------------------------------------------------------------------------*/ + + var rangeProperties = ["startContainer", "startOffset", "endContainer", "endOffset", "collapsed", + "commonAncestorContainer"]; + + var s2s = 0, s2e = 1, e2e = 2, e2s = 3; + var n_b = 0, n_a = 1, n_b_a = 2, n_i = 3; + + function RangePrototype() {} + + RangePrototype.prototype = { + attachListener: function(type, listener) { + this._listeners[type].push(listener); + }, + + compareBoundaryPoints: function(how, range) { + assertRangeValid(this); + assertSameDocumentOrFragment(this.startContainer, range.startContainer); + + var nodeA, offsetA, nodeB, offsetB; + var prefixA = (how == e2s || how == s2s) ? "start" : "end"; + var prefixB = (how == s2e || how == s2s) ? "start" : "end"; + nodeA = this[prefixA + "Container"]; + offsetA = this[prefixA + "Offset"]; + nodeB = range[prefixB + "Container"]; + offsetB = range[prefixB + "Offset"]; + return dom.comparePoints(nodeA, offsetA, nodeB, offsetB); + }, + + insertNode: function(node) { + assertRangeValid(this); + assertValidNodeType(node, insertableNodeTypes); + assertNodeNotReadOnly(this.startContainer); + + if (dom.isAncestorOf(node, this.startContainer, true)) { + throw new DOMException("HIERARCHY_REQUEST_ERR"); + } + + // No check for whether the container of the start of the Range is of a type that does not allow + // children of the type of node: the browser's DOM implementation should do this for us when we attempt + // to add the node + + var firstNodeInserted = insertNodeAtPosition(node, this.startContainer, this.startOffset); + this.setStartBefore(firstNodeInserted); + }, + + cloneContents: function() { + assertRangeValid(this); + + var clone, frag; + if (this.collapsed) { + return getRangeDocument(this).createDocumentFragment(); + } else { + if (this.startContainer === this.endContainer && dom.isCharacterDataNode(this.startContainer)) { + clone = this.startContainer.cloneNode(true); + clone.data = clone.data.slice(this.startOffset, this.endOffset); + frag = getRangeDocument(this).createDocumentFragment(); + frag.appendChild(clone); + return frag; + } else { + var iterator = new RangeIterator(this, true); + clone = cloneSubtree(iterator); + iterator.detach(); + } + return clone; + } + }, + + canSurroundContents: function() { + assertRangeValid(this); + assertNodeNotReadOnly(this.startContainer); + assertNodeNotReadOnly(this.endContainer); + + // Check if the contents can be surrounded. Specifically, this means whether the range partially selects + // no non-text nodes. + var iterator = new RangeIterator(this, true); + var boundariesInvalid = (iterator._first && (isNonTextPartiallySelected(iterator._first, this)) || + (iterator._last && isNonTextPartiallySelected(iterator._last, this))); + iterator.detach(); + return !boundariesInvalid; + }, + + surroundContents: function(node) { + assertValidNodeType(node, surroundNodeTypes); + + if (!this.canSurroundContents()) { + throw new RangeException("BAD_BOUNDARYPOINTS_ERR"); + } + + // Extract the contents + var content = this.extractContents(); + + // Clear the children of the node + if (node.hasChildNodes()) { + while (node.lastChild) { + node.removeChild(node.lastChild); + } + } + + // Insert the new node and add the extracted contents + insertNodeAtPosition(node, this.startContainer, this.startOffset); + node.appendChild(content); + + this.selectNode(node); + }, + + cloneRange: function() { + assertRangeValid(this); + var range = new Range(getRangeDocument(this)); + var i = rangeProperties.length, prop; + while (i--) { + prop = rangeProperties[i]; + range[prop] = this[prop]; + } + return range; + }, + + toString: function() { + assertRangeValid(this); + var sc = this.startContainer; + if (sc === this.endContainer && dom.isCharacterDataNode(sc)) { + return (sc.nodeType == 3 || sc.nodeType == 4) ? sc.data.slice(this.startOffset, this.endOffset) : ""; + } else { + var textBits = [], iterator = new RangeIterator(this, true); + + iterateSubtree(iterator, function(node) { + // Accept only text or CDATA nodes, not comments + + if (node.nodeType == 3 || node.nodeType == 4) { + textBits.push(node.data); + } + }); + iterator.detach(); + return textBits.join(""); + } + }, + + // The methods below are all non-standard. The following batch were introduced by Mozilla but have since + // been removed from Mozilla. + + compareNode: function(node) { + assertRangeValid(this); + + var parent = node.parentNode; + var nodeIndex = dom.getNodeIndex(node); + + if (!parent) { + throw new DOMException("NOT_FOUND_ERR"); + } + + var startComparison = this.comparePoint(parent, nodeIndex), + endComparison = this.comparePoint(parent, nodeIndex + 1); + + if (startComparison < 0) { // Node starts before + return (endComparison > 0) ? n_b_a : n_b; + } else { + return (endComparison > 0) ? n_a : n_i; + } + }, + + comparePoint: function(node, offset) { + assertRangeValid(this); + assertNode(node, "HIERARCHY_REQUEST_ERR"); + assertSameDocumentOrFragment(node, this.startContainer); + + if (dom.comparePoints(node, offset, this.startContainer, this.startOffset) < 0) { + return -1; + } else if (dom.comparePoints(node, offset, this.endContainer, this.endOffset) > 0) { + return 1; + } + return 0; + }, + + createContextualFragment: createContextualFragment, + + toHtml: function() { + assertRangeValid(this); + var container = getRangeDocument(this).createElement("div"); + container.appendChild(this.cloneContents()); + return container.innerHTML; + }, + + // touchingIsIntersecting determines whether this method considers a node that borders a range intersects + // with it (as in WebKit) or not (as in Gecko pre-1.9, and the default) + intersectsNode: function(node, touchingIsIntersecting) { + assertRangeValid(this); + assertNode(node, "NOT_FOUND_ERR"); + if (dom.getDocument(node) !== getRangeDocument(this)) { + return false; + } + + var parent = node.parentNode, offset = dom.getNodeIndex(node); + assertNode(parent, "NOT_FOUND_ERR"); + + var startComparison = dom.comparePoints(parent, offset, this.endContainer, this.endOffset), + endComparison = dom.comparePoints(parent, offset + 1, this.startContainer, this.startOffset); + + return touchingIsIntersecting ? startComparison <= 0 && endComparison >= 0 : startComparison < 0 && endComparison > 0; + }, + + + isPointInRange: function(node, offset) { + assertRangeValid(this); + assertNode(node, "HIERARCHY_REQUEST_ERR"); + assertSameDocumentOrFragment(node, this.startContainer); + + return (dom.comparePoints(node, offset, this.startContainer, this.startOffset) >= 0) && + (dom.comparePoints(node, offset, this.endContainer, this.endOffset) <= 0); + }, + + // The methods below are non-standard and invented by me. + + // Sharing a boundary start-to-end or end-to-start does not count as intersection. + intersectsRange: function(range, touchingIsIntersecting) { + assertRangeValid(this); + + if (getRangeDocument(range) != getRangeDocument(this)) { + throw new DOMException("WRONG_DOCUMENT_ERR"); + } + + var startComparison = dom.comparePoints(this.startContainer, this.startOffset, range.endContainer, range.endOffset), + endComparison = dom.comparePoints(this.endContainer, this.endOffset, range.startContainer, range.startOffset); + + return touchingIsIntersecting ? startComparison <= 0 && endComparison >= 0 : startComparison < 0 && endComparison > 0; + }, + + intersection: function(range) { + if (this.intersectsRange(range)) { + var startComparison = dom.comparePoints(this.startContainer, this.startOffset, range.startContainer, range.startOffset), + endComparison = dom.comparePoints(this.endContainer, this.endOffset, range.endContainer, range.endOffset); + + var intersectionRange = this.cloneRange(); + + if (startComparison == -1) { + intersectionRange.setStart(range.startContainer, range.startOffset); + } + if (endComparison == 1) { + intersectionRange.setEnd(range.endContainer, range.endOffset); + } + return intersectionRange; + } + return null; + }, + + union: function(range) { + if (this.intersectsRange(range, true)) { + var unionRange = this.cloneRange(); + if (dom.comparePoints(range.startContainer, range.startOffset, this.startContainer, this.startOffset) == -1) { + unionRange.setStart(range.startContainer, range.startOffset); + } + if (dom.comparePoints(range.endContainer, range.endOffset, this.endContainer, this.endOffset) == 1) { + unionRange.setEnd(range.endContainer, range.endOffset); + } + return unionRange; + } else { + throw new RangeException("Ranges do not intersect"); + } + }, + + containsNode: function(node, allowPartial) { + if (allowPartial) { + return this.intersectsNode(node, false); + } else { + return this.compareNode(node) == n_i; + } + }, + + containsNodeContents: function(node) { + return this.comparePoint(node, 0) >= 0 && this.comparePoint(node, dom.getNodeLength(node)) <= 0; + }, + + containsRange: function(range) { + return this.intersection(range).equals(range); + }, + + containsNodeText: function(node) { + var nodeRange = this.cloneRange(); + nodeRange.selectNode(node); + var textNodes = nodeRange.getNodes([3]); + if (textNodes.length > 0) { + nodeRange.setStart(textNodes[0], 0); + var lastTextNode = textNodes.pop(); + nodeRange.setEnd(lastTextNode, lastTextNode.length); + var contains = this.containsRange(nodeRange); + nodeRange.detach(); + return contains; + } else { + return this.containsNodeContents(node); + } + }, + + createNodeIterator: function(nodeTypes, filter) { + assertRangeValid(this); + return new RangeNodeIterator(this, nodeTypes, filter); + }, + + getNodes: function(nodeTypes, filter) { + assertRangeValid(this); + return getNodesInRange(this, nodeTypes, filter); + }, + + getDocument: function() { + return getRangeDocument(this); + }, + + collapseBefore: function(node) { + assertNotDetached(this); + + this.setEndBefore(node); + this.collapse(false); + }, + + collapseAfter: function(node) { + assertNotDetached(this); + + this.setStartAfter(node); + this.collapse(true); + }, + + getName: function() { + return "DomRange"; + }, + + equals: function(range) { + return Range.rangesEqual(this, range); + }, + + inspect: function() { + return inspect(this); + } + }; + + function copyComparisonConstantsToObject(obj) { + obj.START_TO_START = s2s; + obj.START_TO_END = s2e; + obj.END_TO_END = e2e; + obj.END_TO_START = e2s; + + obj.NODE_BEFORE = n_b; + obj.NODE_AFTER = n_a; + obj.NODE_BEFORE_AND_AFTER = n_b_a; + obj.NODE_INSIDE = n_i; + } + + function copyComparisonConstants(constructor) { + copyComparisonConstantsToObject(constructor); + copyComparisonConstantsToObject(constructor.prototype); + } + + function createRangeContentRemover(remover, boundaryUpdater) { + return function() { + assertRangeValid(this); + + var sc = this.startContainer, so = this.startOffset, root = this.commonAncestorContainer; + + var iterator = new RangeIterator(this, true); + + // Work out where to position the range after content removal + var node, boundary; + if (sc !== root) { + node = dom.getClosestAncestorIn(sc, root, true); + boundary = getBoundaryAfterNode(node); + sc = boundary.node; + so = boundary.offset; + } + + // Check none of the range is read-only + iterateSubtree(iterator, assertNodeNotReadOnly); + + iterator.reset(); + + // Remove the content + var returnValue = remover(iterator); + iterator.detach(); + + // Move to the new position + boundaryUpdater(this, sc, so, sc, so); + + return returnValue; + }; + } + + function createPrototypeRange(constructor, boundaryUpdater, detacher) { + function createBeforeAfterNodeSetter(isBefore, isStart) { + return function(node) { + assertNotDetached(this); + assertValidNodeType(node, beforeAfterNodeTypes); + assertValidNodeType(getRootContainer(node), rootContainerNodeTypes); + + var boundary = (isBefore ? getBoundaryBeforeNode : getBoundaryAfterNode)(node); + (isStart ? setRangeStart : setRangeEnd)(this, boundary.node, boundary.offset); + }; + } + + function setRangeStart(range, node, offset) { + var ec = range.endContainer, eo = range.endOffset; + if (node !== range.startContainer || offset !== range.startOffset) { + // Check the root containers of the range and the new boundary, and also check whether the new boundary + // is after the current end. In either case, collapse the range to the new position + if (getRootContainer(node) != getRootContainer(ec) || dom.comparePoints(node, offset, ec, eo) == 1) { + ec = node; + eo = offset; + } + boundaryUpdater(range, node, offset, ec, eo); + } + } + + function setRangeEnd(range, node, offset) { + var sc = range.startContainer, so = range.startOffset; + if (node !== range.endContainer || offset !== range.endOffset) { + // Check the root containers of the range and the new boundary, and also check whether the new boundary + // is after the current end. In either case, collapse the range to the new position + if (getRootContainer(node) != getRootContainer(sc) || dom.comparePoints(node, offset, sc, so) == -1) { + sc = node; + so = offset; + } + boundaryUpdater(range, sc, so, node, offset); + } + } + + function setRangeStartAndEnd(range, node, offset) { + if (node !== range.startContainer || offset !== range.startOffset || node !== range.endContainer || offset !== range.endOffset) { + boundaryUpdater(range, node, offset, node, offset); + } + } + + constructor.prototype = new RangePrototype(); + + api.util.extend(constructor.prototype, { + setStart: function(node, offset) { + assertNotDetached(this); + assertNoDocTypeNotationEntityAncestor(node, true); + assertValidOffset(node, offset); + + setRangeStart(this, node, offset); + }, + + setEnd: function(node, offset) { + assertNotDetached(this); + assertNoDocTypeNotationEntityAncestor(node, true); + assertValidOffset(node, offset); + + setRangeEnd(this, node, offset); + }, + + setStartBefore: createBeforeAfterNodeSetter(true, true), + setStartAfter: createBeforeAfterNodeSetter(false, true), + setEndBefore: createBeforeAfterNodeSetter(true, false), + setEndAfter: createBeforeAfterNodeSetter(false, false), + + collapse: function(isStart) { + assertRangeValid(this); + if (isStart) { + boundaryUpdater(this, this.startContainer, this.startOffset, this.startContainer, this.startOffset); + } else { + boundaryUpdater(this, this.endContainer, this.endOffset, this.endContainer, this.endOffset); + } + }, + + selectNodeContents: function(node) { + // This doesn't seem well specified: the spec talks only about selecting the node's contents, which + // could be taken to mean only its children. However, browsers implement this the same as selectNode for + // text nodes, so I shall do likewise + assertNotDetached(this); + assertNoDocTypeNotationEntityAncestor(node, true); + + boundaryUpdater(this, node, 0, node, dom.getNodeLength(node)); + }, + + selectNode: function(node) { + assertNotDetached(this); + assertNoDocTypeNotationEntityAncestor(node, false); + assertValidNodeType(node, beforeAfterNodeTypes); + + var start = getBoundaryBeforeNode(node), end = getBoundaryAfterNode(node); + boundaryUpdater(this, start.node, start.offset, end.node, end.offset); + }, + + extractContents: createRangeContentRemover(extractSubtree, boundaryUpdater), + + deleteContents: createRangeContentRemover(deleteSubtree, boundaryUpdater), + + canSurroundContents: function() { + assertRangeValid(this); + assertNodeNotReadOnly(this.startContainer); + assertNodeNotReadOnly(this.endContainer); + + // Check if the contents can be surrounded. Specifically, this means whether the range partially selects + // no non-text nodes. + var iterator = new RangeIterator(this, true); + var boundariesInvalid = (iterator._first && (isNonTextPartiallySelected(iterator._first, this)) || + (iterator._last && isNonTextPartiallySelected(iterator._last, this))); + iterator.detach(); + return !boundariesInvalid; + }, + + detach: function() { + detacher(this); + }, + + splitBoundaries: function() { + assertRangeValid(this); + + + var sc = this.startContainer, so = this.startOffset, ec = this.endContainer, eo = this.endOffset; + var startEndSame = (sc === ec); + + if (dom.isCharacterDataNode(ec) && eo > 0 && eo < ec.length) { + dom.splitDataNode(ec, eo); + + } + + if (dom.isCharacterDataNode(sc) && so > 0 && so < sc.length) { + + sc = dom.splitDataNode(sc, so); + if (startEndSame) { + eo -= so; + ec = sc; + } else if (ec == sc.parentNode && eo >= dom.getNodeIndex(sc)) { + eo++; + } + so = 0; + + } + boundaryUpdater(this, sc, so, ec, eo); + }, + + normalizeBoundaries: function() { + assertRangeValid(this); + + var sc = this.startContainer, so = this.startOffset, ec = this.endContainer, eo = this.endOffset; + + var mergeForward = function(node) { + var sibling = node.nextSibling; + if (sibling && sibling.nodeType == node.nodeType) { + ec = node; + eo = node.length; + node.appendData(sibling.data); + sibling.parentNode.removeChild(sibling); + } + }; + + var mergeBackward = function(node) { + var sibling = node.previousSibling; + if (sibling && sibling.nodeType == node.nodeType) { + sc = node; + var nodeLength = node.length; + so = sibling.length; + node.insertData(0, sibling.data); + sibling.parentNode.removeChild(sibling); + if (sc == ec) { + eo += so; + ec = sc; + } else if (ec == node.parentNode) { + var nodeIndex = dom.getNodeIndex(node); + if (eo == nodeIndex) { + ec = node; + eo = nodeLength; + } else if (eo > nodeIndex) { + eo--; + } + } + } + }; + + var normalizeStart = true; + + if (dom.isCharacterDataNode(ec)) { + if (ec.length == eo) { + mergeForward(ec); + } + } else { + if (eo > 0) { + var endNode = ec.childNodes[eo - 1]; + if (endNode && dom.isCharacterDataNode(endNode)) { + mergeForward(endNode); + } + } + normalizeStart = !this.collapsed; + } + + if (normalizeStart) { + if (dom.isCharacterDataNode(sc)) { + if (so == 0) { + mergeBackward(sc); + } + } else { + if (so < sc.childNodes.length) { + var startNode = sc.childNodes[so]; + if (startNode && dom.isCharacterDataNode(startNode)) { + mergeBackward(startNode); + } + } + } + } else { + sc = ec; + so = eo; + } + + boundaryUpdater(this, sc, so, ec, eo); + }, + + collapseToPoint: function(node, offset) { + assertNotDetached(this); + + assertNoDocTypeNotationEntityAncestor(node, true); + assertValidOffset(node, offset); + + setRangeStartAndEnd(this, node, offset); + } + }); + + copyComparisonConstants(constructor); + } + + /*----------------------------------------------------------------------------------------------------------------*/ + + // Updates commonAncestorContainer and collapsed after boundary change + function updateCollapsedAndCommonAncestor(range) { + range.collapsed = (range.startContainer === range.endContainer && range.startOffset === range.endOffset); + range.commonAncestorContainer = range.collapsed ? + range.startContainer : dom.getCommonAncestor(range.startContainer, range.endContainer); + } + + function updateBoundaries(range, startContainer, startOffset, endContainer, endOffset) { + var startMoved = (range.startContainer !== startContainer || range.startOffset !== startOffset); + var endMoved = (range.endContainer !== endContainer || range.endOffset !== endOffset); + + range.startContainer = startContainer; + range.startOffset = startOffset; + range.endContainer = endContainer; + range.endOffset = endOffset; + + updateCollapsedAndCommonAncestor(range); + dispatchEvent(range, "boundarychange", {startMoved: startMoved, endMoved: endMoved}); + } + + function detach(range) { + assertNotDetached(range); + range.startContainer = range.startOffset = range.endContainer = range.endOffset = null; + range.collapsed = range.commonAncestorContainer = null; + dispatchEvent(range, "detach", null); + range._listeners = null; + } + + /** + * @constructor + */ + function Range(doc) { + this.startContainer = doc; + this.startOffset = 0; + this.endContainer = doc; + this.endOffset = 0; + this._listeners = { + boundarychange: [], + detach: [] + }; + updateCollapsedAndCommonAncestor(this); + } + + createPrototypeRange(Range, updateBoundaries, detach); + + api.rangePrototype = RangePrototype.prototype; + + Range.rangeProperties = rangeProperties; + Range.RangeIterator = RangeIterator; + Range.copyComparisonConstants = copyComparisonConstants; + Range.createPrototypeRange = createPrototypeRange; + Range.inspect = inspect; + Range.getRangeDocument = getRangeDocument; + Range.rangesEqual = function(r1, r2) { + return r1.startContainer === r2.startContainer && + r1.startOffset === r2.startOffset && + r1.endContainer === r2.endContainer && + r1.endOffset === r2.endOffset; + }; + + api.DomRange = Range; + api.RangeException = RangeException; +});rangy.createModule("WrappedRange", function(api, module) { + api.requireModules( ["DomUtil", "DomRange"] ); + + /** + * @constructor + */ + var WrappedRange; + var dom = api.dom; + var DomPosition = dom.DomPosition; + var DomRange = api.DomRange; + + + + /*----------------------------------------------------------------------------------------------------------------*/ + + /* + This is a workaround for a bug where IE returns the wrong container element from the TextRange's parentElement() + method. For example, in the following (where pipes denote the selection boundaries): + + <ul id="ul"><li id="a">| a </li><li id="b"> b |</li></ul> + + var range = document.selection.createRange(); + alert(range.parentElement().id); // Should alert "ul" but alerts "b" + + This method returns the common ancestor node of the following: + - the parentElement() of the textRange + - the parentElement() of the textRange after calling collapse(true) + - the parentElement() of the textRange after calling collapse(false) + */ + function getTextRangeContainerElement(textRange) { + var parentEl = textRange.parentElement(); + + var range = textRange.duplicate(); + range.collapse(true); + var startEl = range.parentElement(); + range = textRange.duplicate(); + range.collapse(false); + var endEl = range.parentElement(); + var startEndContainer = (startEl == endEl) ? startEl : dom.getCommonAncestor(startEl, endEl); + + return startEndContainer == parentEl ? startEndContainer : dom.getCommonAncestor(parentEl, startEndContainer); + } + + function textRangeIsCollapsed(textRange) { + return textRange.compareEndPoints("StartToEnd", textRange) == 0; + } + + // Gets the boundary of a TextRange expressed as a node and an offset within that node. This function started out as + // an improved version of code found in Tim Cameron Ryan's IERange (http://code.google.com/p/ierange/) but has + // grown, fixing problems with line breaks in preformatted text, adding workaround for IE TextRange bugs, handling + // for inputs and images, plus optimizations. + function getTextRangeBoundaryPosition(textRange, wholeRangeContainerElement, isStart, isCollapsed) { + var workingRange = textRange.duplicate(); + + workingRange.collapse(isStart); + var containerElement = workingRange.parentElement(); + + // Sometimes collapsing a TextRange that's at the start of a text node can move it into the previous node, so + // check for that + // TODO: Find out when. Workaround for wholeRangeContainerElement may break this + if (!dom.isAncestorOf(wholeRangeContainerElement, containerElement, true)) { + containerElement = wholeRangeContainerElement; + + } + + + + // Deal with nodes that cannot "contain rich HTML markup". In practice, this means form inputs, images and + // similar. See http://msdn.microsoft.com/en-us/library/aa703950%28VS.85%29.aspx + if (!containerElement.canHaveHTML) { + return new DomPosition(containerElement.parentNode, dom.getNodeIndex(containerElement)); + } + + var workingNode = dom.getDocument(containerElement).createElement("span"); + var comparison, workingComparisonType = isStart ? "StartToStart" : "StartToEnd"; + var previousNode, nextNode, boundaryPosition, boundaryNode; + + // Move the working range through the container's children, starting at the end and working backwards, until the + // working range reaches or goes past the boundary we're interested in + do { + containerElement.insertBefore(workingNode, workingNode.previousSibling); + workingRange.moveToElementText(workingNode); + } while ( (comparison = workingRange.compareEndPoints(workingComparisonType, textRange)) > 0 && + workingNode.previousSibling); + + // We've now reached or gone past the boundary of the text range we're interested in + // so have identified the node we want + boundaryNode = workingNode.nextSibling; + + if (comparison == -1 && boundaryNode && dom.isCharacterDataNode(boundaryNode)) { + // This is a character data node (text, comment, cdata). The working range is collapsed at the start of the + // node containing the text range's boundary, so we move the end of the working range to the boundary point + // and measure the length of its text to get the boundary's offset within the node. + workingRange.setEndPoint(isStart ? "EndToStart" : "EndToEnd", textRange); + + + var offset; + + if (/[\r\n]/.test(boundaryNode.data)) { + /* + For the particular case of a boundary within a text node containing line breaks (within a <pre> element, + for example), we need a slightly complicated approach to get the boundary's offset in IE. The facts: + + - Each line break is represented as \r in the text node's data/nodeValue properties + - Each line break is represented as \r\n in the TextRange's 'text' property + - The 'text' property of the TextRange does not contain trailing line breaks + + To get round the problem presented by the final fact above, we can use the fact that TextRange's + moveStart() and moveEnd() methods return the actual number of characters moved, which is not necessarily + the same as the number of characters it was instructed to move. The simplest approach is to use this to + store the characters moved when moving both the start and end of the range to the start of the document + body and subtracting the start offset from the end offset (the "move-negative-gazillion" method). + However, this is extremely slow when the document is large and the range is near the end of it. Clearly + doing the mirror image (i.e. moving the range boundaries to the end of the document) has the same + problem. + + Another approach that works is to use moveStart() to move the start boundary of the range up to the end + boundary one character at a time and incrementing a counter with the value returned by the moveStart() + call. However, the check for whether the start boundary has reached the end boundary is expensive, so + this method is slow (although unlike "move-negative-gazillion" is largely unaffected by the location of + the range within the document). + + The method below is a hybrid of the two methods above. It uses the fact that a string containing the + TextRange's 'text' property with each \r\n converted to a single \r character cannot be longer than the + text of the TextRange, so the start of the range is moved that length initially and then a character at + a time to make up for any trailing line breaks not contained in the 'text' property. This has good + performance in most situations compared to the previous two methods. + */ + var tempRange = workingRange.duplicate(); + var rangeLength = tempRange.text.replace(/\r\n/g, "\r").length; + + offset = tempRange.moveStart("character", rangeLength); + while ( (comparison = tempRange.compareEndPoints("StartToEnd", tempRange)) == -1) { + offset++; + tempRange.moveStart("character", 1); + } + } else { + offset = workingRange.text.length; + } + boundaryPosition = new DomPosition(boundaryNode, offset); + } else { + + + // If the boundary immediately follows a character data node and this is the end boundary, we should favour + // a position within that, and likewise for a start boundary preceding a character data node + previousNode = (isCollapsed || !isStart) && workingNode.previousSibling; + nextNode = (isCollapsed || isStart) && workingNode.nextSibling; + + + + if (nextNode && dom.isCharacterDataNode(nextNode)) { + boundaryPosition = new DomPosition(nextNode, 0); + } else if (previousNode && dom.isCharacterDataNode(previousNode)) { + boundaryPosition = new DomPosition(previousNode, previousNode.length); + } else { + boundaryPosition = new DomPosition(containerElement, dom.getNodeIndex(workingNode)); + } + } + + // Clean up + workingNode.parentNode.removeChild(workingNode); + + return boundaryPosition; + } + + // Returns a TextRange representing the boundary of a TextRange expressed as a node and an offset within that node. + // This function started out as an optimized version of code found in Tim Cameron Ryan's IERange + // (http://code.google.com/p/ierange/) + function createBoundaryTextRange(boundaryPosition, isStart) { + var boundaryNode, boundaryParent, boundaryOffset = boundaryPosition.offset; + var doc = dom.getDocument(boundaryPosition.node); + var workingNode, childNodes, workingRange = doc.body.createTextRange(); + var nodeIsDataNode = dom.isCharacterDataNode(boundaryPosition.node); + + if (nodeIsDataNode) { + boundaryNode = boundaryPosition.node; + boundaryParent = boundaryNode.parentNode; + } else { + childNodes = boundaryPosition.node.childNodes; + boundaryNode = (boundaryOffset < childNodes.length) ? childNodes[boundaryOffset] : null; + boundaryParent = boundaryPosition.node; + } + + // Position the range immediately before the node containing the boundary + workingNode = doc.createElement("span"); + + // Making the working element non-empty element persuades IE to consider the TextRange boundary to be within the + // element rather than immediately before or after it, which is what we want + workingNode.innerHTML = "&#feff;"; + + // insertBefore is supposed to work like appendChild if the second parameter is null. However, a bug report + // for IERange suggests that it can crash the browser: http://code.google.com/p/ierange/issues/detail?id=12 + if (boundaryNode) { + boundaryParent.insertBefore(workingNode, boundaryNode); + } else { + boundaryParent.appendChild(workingNode); + } + + workingRange.moveToElementText(workingNode); + workingRange.collapse(!isStart); + + // Clean up + boundaryParent.removeChild(workingNode); + + // Move the working range to the text offset, if required + if (nodeIsDataNode) { + workingRange[isStart ? "moveStart" : "moveEnd"]("character", boundaryOffset); + } + + return workingRange; + } + + /*----------------------------------------------------------------------------------------------------------------*/ + + if (api.features.implementsDomRange && (!api.features.implementsTextRange || !api.config.preferTextRange)) { + // This is a wrapper around the browser's native DOM Range. It has two aims: + // - Provide workarounds for specific browser bugs + // - provide convenient extensions, which are inherited from Rangy's DomRange + + (function() { + var rangeProto; + var rangeProperties = DomRange.rangeProperties; + var canSetRangeStartAfterEnd; + + function updateRangeProperties(range) { + var i = rangeProperties.length, prop; + while (i--) { + prop = rangeProperties[i]; + range[prop] = range.nativeRange[prop]; + } + } + + function updateNativeRange(range, startContainer, startOffset, endContainer,endOffset) { + var startMoved = (range.startContainer !== startContainer || range.startOffset != startOffset); + var endMoved = (range.endContainer !== endContainer || range.endOffset != endOffset); + + // Always set both boundaries for the benefit of IE9 (see issue 35) + if (startMoved || endMoved) { + range.setEnd(endContainer, endOffset); + range.setStart(startContainer, startOffset); + } + } + + function detach(range) { + range.nativeRange.detach(); + range.detached = true; + var i = rangeProperties.length, prop; + while (i--) { + prop = rangeProperties[i]; + range[prop] = null; + } + } + + var createBeforeAfterNodeSetter; + + WrappedRange = function(range) { + if (!range) { + throw new Error("Range must be specified"); + } + this.nativeRange = range; + updateRangeProperties(this); + }; + + DomRange.createPrototypeRange(WrappedRange, updateNativeRange, detach); + + rangeProto = WrappedRange.prototype; + + rangeProto.selectNode = function(node) { + this.nativeRange.selectNode(node); + updateRangeProperties(this); + }; + + rangeProto.deleteContents = function() { + this.nativeRange.deleteContents(); + updateRangeProperties(this); + }; + + rangeProto.extractContents = function() { + var frag = this.nativeRange.extractContents(); + updateRangeProperties(this); + return frag; + }; + + rangeProto.cloneContents = function() { + return this.nativeRange.cloneContents(); + }; + + // TODO: Until I can find a way to programmatically trigger the Firefox bug (apparently long-standing, still + // present in 3.6.8) that throws "Index or size is negative or greater than the allowed amount" for + // insertNode in some circumstances, all browsers will have to use the Rangy's own implementation of + // insertNode, which works but is almost certainly slower than the native implementation. +/* + rangeProto.insertNode = function(node) { + this.nativeRange.insertNode(node); + updateRangeProperties(this); + }; +*/ + + rangeProto.surroundContents = function(node) { + this.nativeRange.surroundContents(node); + updateRangeProperties(this); + }; + + rangeProto.collapse = function(isStart) { + this.nativeRange.collapse(isStart); + updateRangeProperties(this); + }; + + rangeProto.cloneRange = function() { + return new WrappedRange(this.nativeRange.cloneRange()); + }; + + rangeProto.refresh = function() { + updateRangeProperties(this); + }; + + rangeProto.toString = function() { + return this.nativeRange.toString(); + }; + + // Create test range and node for feature detection + + var testTextNode = document.createTextNode("test"); + dom.getBody(document).appendChild(testTextNode); + var range = document.createRange(); + + /*--------------------------------------------------------------------------------------------------------*/ + + // Test for Firefox 2 bug that prevents moving the start of a Range to a point after its current end and + // correct for it + + range.setStart(testTextNode, 0); + range.setEnd(testTextNode, 0); + + try { + range.setStart(testTextNode, 1); + canSetRangeStartAfterEnd = true; + + rangeProto.setStart = function(node, offset) { + this.nativeRange.setStart(node, offset); + updateRangeProperties(this); + }; + + rangeProto.setEnd = function(node, offset) { + this.nativeRange.setEnd(node, offset); + updateRangeProperties(this); + }; + + createBeforeAfterNodeSetter = function(name) { + return function(node) { + this.nativeRange[name](node); + updateRangeProperties(this); + }; + }; + + } catch(ex) { + + + canSetRangeStartAfterEnd = false; + + rangeProto.setStart = function(node, offset) { + try { + this.nativeRange.setStart(node, offset); + } catch (ex) { + this.nativeRange.setEnd(node, offset); + this.nativeRange.setStart(node, offset); + } + updateRangeProperties(this); + }; + + rangeProto.setEnd = function(node, offset) { + try { + this.nativeRange.setEnd(node, offset); + } catch (ex) { + this.nativeRange.setStart(node, offset); + this.nativeRange.setEnd(node, offset); + } + updateRangeProperties(this); + }; + + createBeforeAfterNodeSetter = function(name, oppositeName) { + return function(node) { + try { + this.nativeRange[name](node); + } catch (ex) { + this.nativeRange[oppositeName](node); + this.nativeRange[name](node); + } + updateRangeProperties(this); + }; + }; + } + + rangeProto.setStartBefore = createBeforeAfterNodeSetter("setStartBefore", "setEndBefore"); + rangeProto.setStartAfter = createBeforeAfterNodeSetter("setStartAfter", "setEndAfter"); + rangeProto.setEndBefore = createBeforeAfterNodeSetter("setEndBefore", "setStartBefore"); + rangeProto.setEndAfter = createBeforeAfterNodeSetter("setEndAfter", "setStartAfter"); + + /*--------------------------------------------------------------------------------------------------------*/ + + // Test for and correct Firefox 2 behaviour with selectNodeContents on text nodes: it collapses the range to + // the 0th character of the text node + range.selectNodeContents(testTextNode); + if (range.startContainer == testTextNode && range.endContainer == testTextNode && + range.startOffset == 0 && range.endOffset == testTextNode.length) { + rangeProto.selectNodeContents = function(node) { + this.nativeRange.selectNodeContents(node); + updateRangeProperties(this); + }; + } else { + rangeProto.selectNodeContents = function(node) { + this.setStart(node, 0); + this.setEnd(node, DomRange.getEndOffset(node)); + }; + } + + /*--------------------------------------------------------------------------------------------------------*/ + + // Test for WebKit bug that has the beahviour of compareBoundaryPoints round the wrong way for constants + // START_TO_END and END_TO_START: https://bugs.webkit.org/show_bug.cgi?id=20738 + + range.selectNodeContents(testTextNode); + range.setEnd(testTextNode, 3); + + var range2 = document.createRange(); + range2.selectNodeContents(testTextNode); + range2.setEnd(testTextNode, 4); + range2.setStart(testTextNode, 2); + + if (range.compareBoundaryPoints(range.START_TO_END, range2) == -1 & + range.compareBoundaryPoints(range.END_TO_START, range2) == 1) { + // This is the wrong way round, so correct for it + + + rangeProto.compareBoundaryPoints = function(type, range) { + range = range.nativeRange || range; + if (type == range.START_TO_END) { + type = range.END_TO_START; + } else if (type == range.END_TO_START) { + type = range.START_TO_END; + } + return this.nativeRange.compareBoundaryPoints(type, range); + }; + } else { + rangeProto.compareBoundaryPoints = function(type, range) { + return this.nativeRange.compareBoundaryPoints(type, range.nativeRange || range); + }; + } + + /*--------------------------------------------------------------------------------------------------------*/ + + // Test for existence of createContextualFragment and delegate to it if it exists + if (api.util.isHostMethod(range, "createContextualFragment")) { + rangeProto.createContextualFragment = function(fragmentStr) { + return this.nativeRange.createContextualFragment(fragmentStr); + }; + } + + /*--------------------------------------------------------------------------------------------------------*/ + + // Clean up + dom.getBody(document).removeChild(testTextNode); + range.detach(); + range2.detach(); + })(); + + api.createNativeRange = function(doc) { + doc = doc || document; + return doc.createRange(); + }; + } else if (api.features.implementsTextRange) { + // This is a wrapper around a TextRange, providing full DOM Range functionality using rangy's DomRange as a + // prototype + + WrappedRange = function(textRange) { + this.textRange = textRange; + this.refresh(); + }; + + WrappedRange.prototype = new DomRange(document); + + WrappedRange.prototype.refresh = function() { + var start, end; + + // TextRange's parentElement() method cannot be trusted. getTextRangeContainerElement() works around that. + var rangeContainerElement = getTextRangeContainerElement(this.textRange); + + if (textRangeIsCollapsed(this.textRange)) { + end = start = getTextRangeBoundaryPosition(this.textRange, rangeContainerElement, true, true); + } else { + + start = getTextRangeBoundaryPosition(this.textRange, rangeContainerElement, true, false); + end = getTextRangeBoundaryPosition(this.textRange, rangeContainerElement, false, false); + } + + this.setStart(start.node, start.offset); + this.setEnd(end.node, end.offset); + }; + + DomRange.copyComparisonConstants(WrappedRange); + + // Add WrappedRange as the Range property of the global object to allow expression like Range.END_TO_END to work + var globalObj = (function() { return this; })(); + if (typeof globalObj.Range == "undefined") { + globalObj.Range = WrappedRange; + } + + api.createNativeRange = function(doc) { + doc = doc || document; + return doc.body.createTextRange(); + }; + } + + if (api.features.implementsTextRange) { + WrappedRange.rangeToTextRange = function(range) { + if (range.collapsed) { + var tr = createBoundaryTextRange(new DomPosition(range.startContainer, range.startOffset), true); + + + + return tr; + + //return createBoundaryTextRange(new DomPosition(range.startContainer, range.startOffset), true); + } else { + var startRange = createBoundaryTextRange(new DomPosition(range.startContainer, range.startOffset), true); + var endRange = createBoundaryTextRange(new DomPosition(range.endContainer, range.endOffset), false); + var textRange = dom.getDocument(range.startContainer).body.createTextRange(); + textRange.setEndPoint("StartToStart", startRange); + textRange.setEndPoint("EndToEnd", endRange); + return textRange; + } + }; + } + + WrappedRange.prototype.getName = function() { + return "WrappedRange"; + }; + + api.WrappedRange = WrappedRange; + + api.createRange = function(doc) { + doc = doc || document; + return new WrappedRange(api.createNativeRange(doc)); + }; + + api.createRangyRange = function(doc) { + doc = doc || document; + return new DomRange(doc); + }; + + api.createIframeRange = function(iframeEl) { + return api.createRange(dom.getIframeDocument(iframeEl)); + }; + + api.createIframeRangyRange = function(iframeEl) { + return api.createRangyRange(dom.getIframeDocument(iframeEl)); + }; + + api.addCreateMissingNativeApiListener(function(win) { + var doc = win.document; + if (typeof doc.createRange == "undefined") { + doc.createRange = function() { + return api.createRange(this); + }; + } + doc = win = null; + }); +});rangy.createModule("WrappedSelection", function(api, module) { + // This will create a selection object wrapper that follows the Selection object found in the WHATWG draft DOM Range + // spec (http://html5.org/specs/dom-range.html) + + api.requireModules( ["DomUtil", "DomRange", "WrappedRange"] ); + + api.config.checkSelectionRanges = true; + + var BOOLEAN = "boolean", + windowPropertyName = "_rangySelection", + dom = api.dom, + util = api.util, + DomRange = api.DomRange, + WrappedRange = api.WrappedRange, + DOMException = api.DOMException, + DomPosition = dom.DomPosition, + getSelection, + selectionIsCollapsed, + CONTROL = "Control"; + + + + function getWinSelection(winParam) { + return (winParam || window).getSelection(); + } + + function getDocSelection(winParam) { + return (winParam || window).document.selection; + } + + // Test for the Range/TextRange and Selection features required + // Test for ability to retrieve selection + var implementsWinGetSelection = api.util.isHostMethod(window, "getSelection"), + implementsDocSelection = api.util.isHostObject(document, "selection"); + + var useDocumentSelection = implementsDocSelection && (!implementsWinGetSelection || api.config.preferTextRange); + + if (useDocumentSelection) { + getSelection = getDocSelection; + api.isSelectionValid = function(winParam) { + var doc = (winParam || window).document, nativeSel = doc.selection; + + // Check whether the selection TextRange is actually contained within the correct document + return (nativeSel.type != "None" || dom.getDocument(nativeSel.createRange().parentElement()) == doc); + }; + } else if (implementsWinGetSelection) { + getSelection = getWinSelection; + api.isSelectionValid = function() { + return true; + }; + } else { + module.fail("Neither document.selection or window.getSelection() detected."); + } + + api.getNativeSelection = getSelection; + + var testSelection = getSelection(); + var testRange = api.createNativeRange(document); + var body = dom.getBody(document); + + // Obtaining a range from a selection + var selectionHasAnchorAndFocus = util.areHostObjects(testSelection, ["anchorNode", "focusNode"] && + util.areHostProperties(testSelection, ["anchorOffset", "focusOffset"])); + api.features.selectionHasAnchorAndFocus = selectionHasAnchorAndFocus; + + // Test for existence of native selection extend() method + var selectionHasExtend = util.isHostMethod(testSelection, "extend"); + api.features.selectionHasExtend = selectionHasExtend; + + // Test if rangeCount exists + var selectionHasRangeCount = (typeof testSelection.rangeCount == "number"); + api.features.selectionHasRangeCount = selectionHasRangeCount; + + var selectionSupportsMultipleRanges = false; + var collapsedNonEditableSelectionsSupported = true; + + if (util.areHostMethods(testSelection, ["addRange", "getRangeAt", "removeAllRanges"]) && + typeof testSelection.rangeCount == "number" && api.features.implementsDomRange) { + + (function() { + var iframe = document.createElement("iframe"); + body.appendChild(iframe); + + var iframeDoc = dom.getIframeDocument(iframe); + iframeDoc.open(); + iframeDoc.write("<html><head></head><body>12</body></html>"); + iframeDoc.close(); + + var sel = dom.getIframeWindow(iframe).getSelection(); + var docEl = iframeDoc.documentElement; + var iframeBody = docEl.lastChild, textNode = iframeBody.firstChild; + + // Test whether the native selection will allow a collapsed selection within a non-editable element + var r1 = iframeDoc.createRange(); + r1.setStart(textNode, 1); + r1.collapse(true); + sel.addRange(r1); + collapsedNonEditableSelectionsSupported = (sel.rangeCount == 1); + sel.removeAllRanges(); + + // Test whether the native selection is capable of supporting multiple ranges + var r2 = r1.cloneRange(); + r1.setStart(textNode, 0); + r2.setEnd(textNode, 2); + sel.addRange(r1); + sel.addRange(r2); + + selectionSupportsMultipleRanges = (sel.rangeCount == 2); + + // Clean up + r1.detach(); + r2.detach(); + + body.removeChild(iframe); + })(); + } + + api.features.selectionSupportsMultipleRanges = selectionSupportsMultipleRanges; + api.features.collapsedNonEditableSelectionsSupported = collapsedNonEditableSelectionsSupported; + + // ControlRanges + var implementsControlRange = false, testControlRange; + + if (body && util.isHostMethod(body, "createControlRange")) { + testControlRange = body.createControlRange(); + if (util.areHostProperties(testControlRange, ["item", "add"])) { + implementsControlRange = true; + } + } + api.features.implementsControlRange = implementsControlRange; + + // Selection collapsedness + if (selectionHasAnchorAndFocus) { + selectionIsCollapsed = function(sel) { + return sel.anchorNode === sel.focusNode && sel.anchorOffset === sel.focusOffset; + }; + } else { + selectionIsCollapsed = function(sel) { + return sel.rangeCount ? sel.getRangeAt(sel.rangeCount - 1).collapsed : false; + }; + } + + function updateAnchorAndFocusFromRange(sel, range, backwards) { + var anchorPrefix = backwards ? "end" : "start", focusPrefix = backwards ? "start" : "end"; + sel.anchorNode = range[anchorPrefix + "Container"]; + sel.anchorOffset = range[anchorPrefix + "Offset"]; + sel.focusNode = range[focusPrefix + "Container"]; + sel.focusOffset = range[focusPrefix + "Offset"]; + } + + function updateAnchorAndFocusFromNativeSelection(sel) { + var nativeSel = sel.nativeSelection; + sel.anchorNode = nativeSel.anchorNode; + sel.anchorOffset = nativeSel.anchorOffset; + sel.focusNode = nativeSel.focusNode; + sel.focusOffset = nativeSel.focusOffset; + } + + function updateEmptySelection(sel) { + sel.anchorNode = sel.focusNode = null; + sel.anchorOffset = sel.focusOffset = 0; + sel.rangeCount = 0; + sel.isCollapsed = true; + sel._ranges.length = 0; + } + + function getNativeRange(range) { + var nativeRange; + if (range instanceof DomRange) { + nativeRange = range._selectionNativeRange; + if (!nativeRange) { + nativeRange = api.createNativeRange(dom.getDocument(range.startContainer)); + nativeRange.setEnd(range.endContainer, range.endOffset); + nativeRange.setStart(range.startContainer, range.startOffset); + range._selectionNativeRange = nativeRange; + range.attachListener("detach", function() { + + this._selectionNativeRange = null; + }); + } + } else if (range instanceof WrappedRange) { + nativeRange = range.nativeRange; + } else if (api.features.implementsDomRange && (range instanceof dom.getWindow(range.startContainer).Range)) { + nativeRange = range; + } + return nativeRange; + } + + function rangeContainsSingleElement(rangeNodes) { + if (!rangeNodes.length || rangeNodes[0].nodeType != 1) { + return false; + } + for (var i = 1, len = rangeNodes.length; i < len; ++i) { + if (!dom.isAncestorOf(rangeNodes[0], rangeNodes[i])) { + return false; + } + } + return true; + } + + function getSingleElementFromRange(range) { + var nodes = range.getNodes(); + if (!rangeContainsSingleElement(nodes)) { + throw new Error("getSingleElementFromRange: range " + range.inspect() + " did not consist of a single element"); + } + return nodes[0]; + } + + function isTextRange(range) { + return !!range && typeof range.text != "undefined"; + } + + function updateFromTextRange(sel, range) { + // Create a Range from the selected TextRange + var wrappedRange = new WrappedRange(range); + sel._ranges = [wrappedRange]; + + updateAnchorAndFocusFromRange(sel, wrappedRange, false); + sel.rangeCount = 1; + sel.isCollapsed = wrappedRange.collapsed; + } + + function updateControlSelection(sel) { + // Update the wrapped selection based on what's now in the native selection + sel._ranges.length = 0; + if (sel.docSelection.type == "None") { + updateEmptySelection(sel); + } else { + var controlRange = sel.docSelection.createRange(); + if (isTextRange(controlRange)) { + // This case (where the selection type is "Control" and calling createRange() on the selection returns + // a TextRange) can happen in IE 9. It happens, for example, when all elements in the selected + // ControlRange have been removed from the ControlRange and removed from the document. + updateFromTextRange(sel, controlRange); + } else { + sel.rangeCount = controlRange.length; + var range, doc = dom.getDocument(controlRange.item(0)); + for (var i = 0; i < sel.rangeCount; ++i) { + range = api.createRange(doc); + range.selectNode(controlRange.item(i)); + sel._ranges.push(range); + } + sel.isCollapsed = sel.rangeCount == 1 && sel._ranges[0].collapsed; + updateAnchorAndFocusFromRange(sel, sel._ranges[sel.rangeCount - 1], false); + } + } + } + + function addRangeToControlSelection(sel, range) { + var controlRange = sel.docSelection.createRange(); + var rangeElement = getSingleElementFromRange(range); + + // Create a new ControlRange containing all the elements in the selected ControlRange plus the element + // contained by the supplied range + var doc = dom.getDocument(controlRange.item(0)); + var newControlRange = dom.getBody(doc).createControlRange(); + for (var i = 0, len = controlRange.length; i < len; ++i) { + newControlRange.add(controlRange.item(i)); + } + try { + newControlRange.add(rangeElement); + } catch (ex) { + throw new Error("addRange(): Element within the specified Range could not be added to control selection (does it have layout?)"); + } + newControlRange.select(); + + // Update the wrapped selection based on what's now in the native selection + updateControlSelection(sel); + } + + var getSelectionRangeAt; + + if (util.isHostMethod(testSelection, "getRangeAt")) { + getSelectionRangeAt = function(sel, index) { + try { + return sel.getRangeAt(index); + } catch(ex) { + return null; + } + }; + } else if (selectionHasAnchorAndFocus) { + getSelectionRangeAt = function(sel) { + var doc = dom.getDocument(sel.anchorNode); + var range = api.createRange(doc); + range.setStart(sel.anchorNode, sel.anchorOffset); + range.setEnd(sel.focusNode, sel.focusOffset); + + // Handle the case when the selection was selected backwards (from the end to the start in the + // document) + if (range.collapsed !== this.isCollapsed) { + range.setStart(sel.focusNode, sel.focusOffset); + range.setEnd(sel.anchorNode, sel.anchorOffset); + } + + return range; + }; + } + + /** + * @constructor + */ + function WrappedSelection(selection, docSelection, win) { + this.nativeSelection = selection; + this.docSelection = docSelection; + this._ranges = []; + this.win = win; + this.refresh(); + } + + api.getSelection = function(win) { + win = win || window; + var sel = win[windowPropertyName]; + var nativeSel = getSelection(win), docSel = implementsDocSelection ? getDocSelection(win) : null; + if (sel) { + sel.nativeSelection = nativeSel; + sel.docSelection = docSel; + sel.refresh(win); + } else { + sel = new WrappedSelection(nativeSel, docSel, win); + win[windowPropertyName] = sel; + } + return sel; + }; + + api.getIframeSelection = function(iframeEl) { + return api.getSelection(dom.getIframeWindow(iframeEl)); + }; + + var selProto = WrappedSelection.prototype; + + function createControlSelection(sel, ranges) { + // Ensure that the selection becomes of type "Control" + var doc = dom.getDocument(ranges[0].startContainer); + var controlRange = dom.getBody(doc).createControlRange(); + for (var i = 0, el; i < rangeCount; ++i) { + el = getSingleElementFromRange(ranges[i]); + try { + controlRange.add(el); + } catch (ex) { + throw new Error("setRanges(): Element within the one of the specified Ranges could not be added to control selection (does it have layout?)"); + } + } + controlRange.select(); + + // Update the wrapped selection based on what's now in the native selection + updateControlSelection(sel); + } + + // Selecting a range + if (!useDocumentSelection && selectionHasAnchorAndFocus && util.areHostMethods(testSelection, ["removeAllRanges", "addRange"])) { + selProto.removeAllRanges = function() { + this.nativeSelection.removeAllRanges(); + updateEmptySelection(this); + }; + + var addRangeBackwards = function(sel, range) { + var doc = DomRange.getRangeDocument(range); + var endRange = api.createRange(doc); + endRange.collapseToPoint(range.endContainer, range.endOffset); + sel.nativeSelection.addRange(getNativeRange(endRange)); + sel.nativeSelection.extend(range.startContainer, range.startOffset); + sel.refresh(); + }; + + if (selectionHasRangeCount) { + selProto.addRange = function(range, backwards) { + if (implementsControlRange && implementsDocSelection && this.docSelection.type == CONTROL) { + addRangeToControlSelection(this, range); + } else { + if (backwards && selectionHasExtend) { + addRangeBackwards(this, range); + } else { + var previousRangeCount; + if (selectionSupportsMultipleRanges) { + previousRangeCount = this.rangeCount; + } else { + this.removeAllRanges(); + previousRangeCount = 0; + } + this.nativeSelection.addRange(getNativeRange(range)); + + // Check whether adding the range was successful + this.rangeCount = this.nativeSelection.rangeCount; + + if (this.rangeCount == previousRangeCount + 1) { + // The range was added successfully + + // Check whether the range that we added to the selection is reflected in the last range extracted from + // the selection + if (api.config.checkSelectionRanges) { + var nativeRange = getSelectionRangeAt(this.nativeSelection, this.rangeCount - 1); + if (nativeRange && !DomRange.rangesEqual(nativeRange, range)) { + // Happens in WebKit with, for example, a selection placed at the start of a text node + range = new WrappedRange(nativeRange); + } + } + this._ranges[this.rangeCount - 1] = range; + updateAnchorAndFocusFromRange(this, range, selectionIsBackwards(this.nativeSelection)); + this.isCollapsed = selectionIsCollapsed(this); + } else { + // The range was not added successfully. The simplest thing is to refresh + this.refresh(); + } + } + } + }; + } else { + selProto.addRange = function(range, backwards) { + if (backwards && selectionHasExtend) { + addRangeBackwards(this, range); + } else { + this.nativeSelection.addRange(getNativeRange(range)); + this.refresh(); + } + }; + } + + selProto.setRanges = function(ranges) { + if (implementsControlRange && ranges.length > 1) { + createControlSelection(this, ranges); + } else { + this.removeAllRanges(); + for (var i = 0, len = ranges.length; i < len; ++i) { + this.addRange(ranges[i]); + } + } + }; + } else if (util.isHostMethod(testSelection, "empty") && util.isHostMethod(testRange, "select") && + implementsControlRange && useDocumentSelection) { + + selProto.removeAllRanges = function() { + // Added try/catch as fix for issue #21 + try { + this.docSelection.empty(); + + // Check for empty() not working (issue #24) + if (this.docSelection.type != "None") { + // Work around failure to empty a control selection by instead selecting a TextRange and then + // calling empty() + var doc; + if (this.anchorNode) { + doc = dom.getDocument(this.anchorNode); + } else if (this.docSelection.type == CONTROL) { + var controlRange = this.docSelection.createRange(); + if (controlRange.length) { + doc = dom.getDocument(controlRange.item(0)).body.createTextRange(); + } + } + if (doc) { + var textRange = doc.body.createTextRange(); + textRange.select(); + this.docSelection.empty(); + } + } + } catch(ex) {} + updateEmptySelection(this); + }; + + selProto.addRange = function(range) { + if (this.docSelection.type == CONTROL) { + addRangeToControlSelection(this, range); + } else { + WrappedRange.rangeToTextRange(range).select(); + this._ranges[0] = range; + this.rangeCount = 1; + this.isCollapsed = this._ranges[0].collapsed; + updateAnchorAndFocusFromRange(this, range, false); + } + }; + + selProto.setRanges = function(ranges) { + this.removeAllRanges(); + var rangeCount = ranges.length; + if (rangeCount > 1) { + createControlSelection(this, ranges); + } else if (rangeCount) { + this.addRange(ranges[0]); + } + }; + } else { + module.fail("No means of selecting a Range or TextRange was found"); + return false; + } + + selProto.getRangeAt = function(index) { + if (index < 0 || index >= this.rangeCount) { + throw new DOMException("INDEX_SIZE_ERR"); + } else { + return this._ranges[index]; + } + }; + + var refreshSelection; + + if (useDocumentSelection) { + refreshSelection = function(sel) { + var range; + if (api.isSelectionValid(sel.win)) { + range = sel.docSelection.createRange(); + } else { + range = dom.getBody(sel.win.document).createTextRange(); + range.collapse(true); + } + + + if (sel.docSelection.type == CONTROL) { + updateControlSelection(sel); + } else if (isTextRange(range)) { + updateFromTextRange(sel, range); + } else { + updateEmptySelection(sel); + } + }; + } else if (util.isHostMethod(testSelection, "getRangeAt") && typeof testSelection.rangeCount == "number") { + refreshSelection = function(sel) { + if (implementsControlRange && implementsDocSelection && sel.docSelection.type == CONTROL) { + updateControlSelection(sel); + } else { + sel._ranges.length = sel.rangeCount = sel.nativeSelection.rangeCount; + if (sel.rangeCount) { + for (var i = 0, len = sel.rangeCount; i < len; ++i) { + sel._ranges[i] = new api.WrappedRange(sel.nativeSelection.getRangeAt(i)); + } + updateAnchorAndFocusFromRange(sel, sel._ranges[sel.rangeCount - 1], selectionIsBackwards(sel.nativeSelection)); + sel.isCollapsed = selectionIsCollapsed(sel); + } else { + updateEmptySelection(sel); + } + } + }; + } else if (selectionHasAnchorAndFocus && typeof testSelection.isCollapsed == BOOLEAN && typeof testRange.collapsed == BOOLEAN && api.features.implementsDomRange) { + refreshSelection = function(sel) { + var range, nativeSel = sel.nativeSelection; + if (nativeSel.anchorNode) { + range = getSelectionRangeAt(nativeSel, 0); + sel._ranges = [range]; + sel.rangeCount = 1; + updateAnchorAndFocusFromNativeSelection(sel); + sel.isCollapsed = selectionIsCollapsed(sel); + } else { + updateEmptySelection(sel); + } + }; + } else { + module.fail("No means of obtaining a Range or TextRange from the user's selection was found"); + return false; + } + + selProto.refresh = function(checkForChanges) { + var oldRanges = checkForChanges ? this._ranges.slice(0) : null; + refreshSelection(this); + if (checkForChanges) { + var i = oldRanges.length; + if (i != this._ranges.length) { + return false; + } + while (i--) { + if (!DomRange.rangesEqual(oldRanges[i], this._ranges[i])) { + return false; + } + } + return true; + } + }; + + // Removal of a single range + var removeRangeManually = function(sel, range) { + var ranges = sel.getAllRanges(), removed = false; + sel.removeAllRanges(); + for (var i = 0, len = ranges.length; i < len; ++i) { + if (removed || range !== ranges[i]) { + sel.addRange(ranges[i]); + } else { + // According to the draft WHATWG Range spec, the same range may be added to the selection multiple + // times. removeRange should only remove the first instance, so the following ensures only the first + // instance is removed + removed = true; + } + } + if (!sel.rangeCount) { + updateEmptySelection(sel); + } + }; + + if (implementsControlRange) { + selProto.removeRange = function(range) { + if (this.docSelection.type == CONTROL) { + var controlRange = this.docSelection.createRange(); + var rangeElement = getSingleElementFromRange(range); + + // Create a new ControlRange containing all the elements in the selected ControlRange minus the + // element contained by the supplied range + var doc = dom.getDocument(controlRange.item(0)); + var newControlRange = dom.getBody(doc).createControlRange(); + var el, removed = false; + for (var i = 0, len = controlRange.length; i < len; ++i) { + el = controlRange.item(i); + if (el !== rangeElement || removed) { + newControlRange.add(controlRange.item(i)); + } else { + removed = true; + } + } + newControlRange.select(); + + // Update the wrapped selection based on what's now in the native selection + updateControlSelection(this); + } else { + removeRangeManually(this, range); + } + }; + } else { + selProto.removeRange = function(range) { + removeRangeManually(this, range); + }; + } + + // Detecting if a selection is backwards + var selectionIsBackwards; + if (!useDocumentSelection && selectionHasAnchorAndFocus && api.features.implementsDomRange) { + selectionIsBackwards = function(sel) { + var backwards = false; + if (sel.anchorNode) { + backwards = (dom.comparePoints(sel.anchorNode, sel.anchorOffset, sel.focusNode, sel.focusOffset) == 1); + } + return backwards; + }; + + selProto.isBackwards = function() { + return selectionIsBackwards(this); + }; + } else { + selectionIsBackwards = selProto.isBackwards = function() { + return false; + }; + } + + // Selection text + // This is conformant to the new WHATWG DOM Range draft spec but differs from WebKit and Mozilla's implementation + selProto.toString = function() { + + var rangeTexts = []; + for (var i = 0, len = this.rangeCount; i < len; ++i) { + rangeTexts[i] = "" + this._ranges[i]; + } + return rangeTexts.join(""); + }; + + function assertNodeInSameDocument(sel, node) { + if (sel.anchorNode && (dom.getDocument(sel.anchorNode) !== dom.getDocument(node))) { + throw new DOMException("WRONG_DOCUMENT_ERR"); + } + } + + // No current browsers conform fully to the HTML 5 draft spec for this method, so Rangy's own method is always used + selProto.collapse = function(node, offset) { + assertNodeInSameDocument(this, node); + var range = api.createRange(dom.getDocument(node)); + range.collapseToPoint(node, offset); + this.removeAllRanges(); + this.addRange(range); + this.isCollapsed = true; + }; + + selProto.collapseToStart = function() { + if (this.rangeCount) { + var range = this._ranges[0]; + this.collapse(range.startContainer, range.startOffset); + } else { + throw new DOMException("INVALID_STATE_ERR"); + } + }; + + selProto.collapseToEnd = function() { + if (this.rangeCount) { + var range = this._ranges[this.rangeCount - 1]; + this.collapse(range.endContainer, range.endOffset); + } else { + throw new DOMException("INVALID_STATE_ERR"); + } + }; + + // The HTML 5 spec is very specific on how selectAllChildren should be implemented so the native implementation is + // never used by Rangy. + selProto.selectAllChildren = function(node) { + assertNodeInSameDocument(this, node); + var range = api.createRange(dom.getDocument(node)); + range.selectNodeContents(node); + this.removeAllRanges(); + this.addRange(range); + }; + + selProto.deleteFromDocument = function() { + // Sepcial behaviour required for Control selections + if (implementsControlRange && implementsDocSelection && this.docSelection.type == CONTROL) { + var controlRange = this.docSelection.createRange(); + var element; + while (controlRange.length) { + element = controlRange.item(0); + controlRange.remove(element); + element.parentNode.removeChild(element); + } + this.refresh(); + } else if (this.rangeCount) { + var ranges = this.getAllRanges(); + this.removeAllRanges(); + for (var i = 0, len = ranges.length; i < len; ++i) { + ranges[i].deleteContents(); + } + // The HTML5 spec says nothing about what the selection should contain after calling deleteContents on each + // range. Firefox moves the selection to where the final selected range was, so we emulate that + this.addRange(ranges[len - 1]); + } + }; + + // The following are non-standard extensions + selProto.getAllRanges = function() { + return this._ranges.slice(0); + }; + + selProto.setSingleRange = function(range) { + this.setRanges( [range] ); + }; + + selProto.containsNode = function(node, allowPartial) { + for (var i = 0, len = this._ranges.length; i < len; ++i) { + if (this._ranges[i].containsNode(node, allowPartial)) { + return true; + } + } + return false; + }; + + selProto.toHtml = function() { + var html = ""; + if (this.rangeCount) { + var container = DomRange.getRangeDocument(this._ranges[0]).createElement("div"); + for (var i = 0, len = this._ranges.length; i < len; ++i) { + container.appendChild(this._ranges[i].cloneContents()); + } + html = container.innerHTML; + } + return html; + }; + + function inspect(sel) { + var rangeInspects = []; + var anchor = new DomPosition(sel.anchorNode, sel.anchorOffset); + var focus = new DomPosition(sel.focusNode, sel.focusOffset); + var name = (typeof sel.getName == "function") ? sel.getName() : "Selection"; + + if (typeof sel.rangeCount != "undefined") { + for (var i = 0, len = sel.rangeCount; i < len; ++i) { + rangeInspects[i] = DomRange.inspect(sel.getRangeAt(i)); + } + } + return "[" + name + "(Ranges: " + rangeInspects.join(", ") + + ")(anchor: " + anchor.inspect() + ", focus: " + focus.inspect() + "]"; + + } + + selProto.getName = function() { + return "WrappedSelection"; + }; + + selProto.inspect = function() { + return inspect(this); + }; + + selProto.detach = function() { + this.win[windowPropertyName] = null; + this.win = this.anchorNode = this.focusNode = null; + }; + + WrappedSelection.inspect = inspect; + + api.Selection = WrappedSelection; + + api.selectionPrototype = selProto; + + api.addCreateMissingNativeApiListener(function(win) { + if (typeof win.getSelection == "undefined") { + win.getSelection = function() { + return api.getSelection(this); + }; + } + win = null; + }); +}); +/* + Base.js, version 1.1a + Copyright 2006-2010, Dean Edwards + License: http://www.opensource.org/licenses/mit-license.php +*/ + +var Base = function() { + // dummy +}; + +Base.extend = function(_instance, _static) { // subclass + var extend = Base.prototype.extend; + + // build the prototype + Base._prototyping = true; + var proto = new this; + extend.call(proto, _instance); + proto.base = function() { + // call this method from any other method to invoke that method's ancestor + }; + delete Base._prototyping; + + // create the wrapper for the constructor function + //var constructor = proto.constructor.valueOf(); //-dean + var constructor = proto.constructor; + var klass = proto.constructor = function() { + if (!Base._prototyping) { + if (this._constructing || this.constructor == klass) { // instantiation + this._constructing = true; + constructor.apply(this, arguments); + delete this._constructing; + } else if (arguments[0] != null) { // casting + return (arguments[0].extend || extend).call(arguments[0], proto); + } + } + }; + + // build the class interface + klass.ancestor = this; + klass.extend = this.extend; + klass.forEach = this.forEach; + klass.implement = this.implement; + klass.prototype = proto; + klass.toString = this.toString; + klass.valueOf = function(type) { + //return (type == "object") ? klass : constructor; //-dean + return (type == "object") ? klass : constructor.valueOf(); + }; + extend.call(klass, _static); + // class initialisation + if (typeof klass.init == "function") klass.init(); + return klass; +}; + +Base.prototype = { + extend: function(source, value) { + if (arguments.length > 1) { // extending with a name/value pair + var ancestor = this[source]; + if (ancestor && (typeof value == "function") && // overriding a method? + // the valueOf() comparison is to avoid circular references + (!ancestor.valueOf || ancestor.valueOf() != value.valueOf()) && + /\bbase\b/.test(value)) { + // get the underlying method + var method = value.valueOf(); + // override + value = function() { + var previous = this.base || Base.prototype.base; + this.base = ancestor; + var returnValue = method.apply(this, arguments); + this.base = previous; + return returnValue; + }; + // point to the underlying method + value.valueOf = function(type) { + return (type == "object") ? value : method; + }; + value.toString = Base.toString; + } + this[source] = value; + } else if (source) { // extending with an object literal + var extend = Base.prototype.extend; + // if this object has a customised extend method then use it + if (!Base._prototyping && typeof this != "function") { + extend = this.extend || extend; + } + var proto = {toSource: null}; + // do the "toString" and other methods manually + var hidden = ["constructor", "toString", "valueOf"]; + // if we are prototyping then include the constructor + var i = Base._prototyping ? 0 : 1; + while (key = hidden[i++]) { + if (source[key] != proto[key]) { + extend.call(this, key, source[key]); + + } + } + // copy each of the source object's properties to this object + for (var key in source) { + if (!proto[key]) extend.call(this, key, source[key]); + } + } + return this; + } +}; + +// initialise +Base = Base.extend({ + constructor: function() { + this.extend(arguments[0]); + } +}, { + ancestor: Object, + version: "1.1", + + forEach: function(object, block, context) { + for (var key in object) { + if (this.prototype[key] === undefined) { + block.call(context, object[key], key, object); + } + } + }, + + implement: function() { + for (var i = 0; i < arguments.length; i++) { + if (typeof arguments[i] == "function") { + // if it's a function, call it + arguments[i](this.prototype); + } else { + // add the interface using the extend method + this.prototype.extend(arguments[i]); + } + } + return this; + }, + + toString: function() { + return String(this.valueOf()); + } +});/** + * Detect browser support for specific features + */ +wysihtml5.browser = (function() { + var userAgent = navigator.userAgent, + testElement = document.createElement("div"), + // Browser sniffing is unfortunately needed since some behaviors are impossible to feature detect + isIE = userAgent.indexOf("MSIE") !== -1 && userAgent.indexOf("Opera") === -1, + isGecko = userAgent.indexOf("Gecko") !== -1 && userAgent.indexOf("KHTML") === -1, + isWebKit = userAgent.indexOf("AppleWebKit/") !== -1, + isChrome = userAgent.indexOf("Chrome/") !== -1, + isOpera = userAgent.indexOf("Opera/") !== -1; + + function iosVersion(userAgent) { + return ((/ipad|iphone|ipod/.test(userAgent) && userAgent.match(/ os (\d+).+? like mac os x/)) || [, 0])[1]; + } + + return { + // Static variable needed, publicly accessible, to be able override it in unit tests + USER_AGENT: userAgent, + + /** + * Exclude browsers that are not capable of displaying and handling + * contentEditable as desired: + * - iPhone, iPad (tested iOS 4.2.2) and Android (tested 2.2) refuse to make contentEditables focusable + * - IE < 8 create invalid markup and crash randomly from time to time + * + * @return {Boolean} + */ + supported: function() { + var userAgent = this.USER_AGENT.toLowerCase(), + // Essential for making html elements editable + hasContentEditableSupport = "contentEditable" in testElement, + // Following methods are needed in order to interact with the contentEditable area + hasEditingApiSupport = document.execCommand && document.queryCommandSupported && document.queryCommandState, + // document selector apis are only supported by IE 8+, Safari 4+, Chrome and Firefox 3.5+ + hasQuerySelectorSupport = document.querySelector && document.querySelectorAll, + // contentEditable is unusable in mobile browsers (tested iOS 4.2.2, Android 2.2, Opera Mobile, WebOS 3.05) + isIncompatibleMobileBrowser = (this.isIos() && iosVersion(userAgent) < 5) || userAgent.indexOf("opera mobi") !== -1 || userAgent.indexOf("hpwos/") !== -1; + + return hasContentEditableSupport + && hasEditingApiSupport + && hasQuerySelectorSupport + && !isIncompatibleMobileBrowser; + }, + + isTouchDevice: function() { + return this.supportsEvent("touchmove"); + }, + + isIos: function() { + var userAgent = this.USER_AGENT.toLowerCase(); + return userAgent.indexOf("webkit") !== -1 && userAgent.indexOf("mobile") !== -1; + }, + + /** + * Whether the browser supports sandboxed iframes + * Currently only IE 6+ offers such feature <iframe security="restricted"> + * + * http://msdn.microsoft.com/en-us/library/ms534622(v=vs.85).aspx + * http://blogs.msdn.com/b/ie/archive/2008/01/18/using-frames-more-securely.aspx + * + * HTML5 sandboxed iframes are still buggy and their DOM is not reachable from the outside (except when using postMessage) + */ + supportsSandboxedIframes: function() { + return isIE; + }, + + /** + * IE6+7 throw a mixed content warning when the src of an iframe + * is empty/unset or about:blank + * window.querySelector is implemented as of IE8 + */ + throwsMixedContentWarningWhenIframeSrcIsEmpty: function() { + return !("querySelector" in document); + }, + + /** + * Whether the caret is correctly displayed in contentEditable elements + * Firefox sometimes shows a huge caret in the beginning after focusing + */ + displaysCaretInEmptyContentEditableCorrectly: function() { + return !isGecko; + }, + + /** + * Opera and IE are the only browsers who offer the css value + * in the original unit, thx to the currentStyle object + * All other browsers provide the computed style in px via window.getComputedStyle + */ + hasCurrentStyleProperty: function() { + return "currentStyle" in testElement; + }, + + /** + * Whether the browser inserts a <br> when pressing enter in a contentEditable element + */ + insertsLineBreaksOnReturn: function() { + return isGecko; + }, + + supportsPlaceholderAttributeOn: function(element) { + return "placeholder" in element; + }, + + supportsEvent: function(eventName) { + return "on" + eventName in testElement || (function() { + testElement.setAttribute("on" + eventName, "return;"); + return typeof(testElement["on" + eventName]) === "function"; + })(); + }, + + /** + * Opera doesn't correctly fire focus/blur events when clicking in- and outside of iframe + */ + supportsEventsInIframeCorrectly: function() { + return !isOpera; + }, + + /** + * Chrome & Safari only fire the ondrop/ondragend/... events when the ondragover event is cancelled + * with event.preventDefault + * Firefox 3.6 fires those events anyway, but the mozilla doc says that the dragover/dragenter event needs + * to be cancelled + */ + firesOnDropOnlyWhenOnDragOverIsCancelled: function() { + return isWebKit || isGecko; + }, + + /** + * Whether the browser supports the event.dataTransfer property in a proper way + */ + supportsDataTransfer: function() { + try { + // Firefox doesn't support dataTransfer in a safe way, it doesn't strip script code in the html payload (like Chrome does) + return isWebKit && (window.Clipboard || window.DataTransfer).prototype.getData; + } catch(e) { + return false; + } + }, + + /** + * Everything below IE9 doesn't know how to treat HTML5 tags + * + * @param {Object} context The document object on which to check HTML5 support + * + * @example + * wysihtml5.browser.supportsHTML5Tags(document); + */ + supportsHTML5Tags: function(context) { + var element = context.createElement("div"), + html5 = "<article>foo</article>"; + element.innerHTML = html5; + return element.innerHTML.toLowerCase() === html5; + }, + + /** + * Checks whether a document supports a certain queryCommand + * In particular, Opera needs a reference to a document that has a contentEditable in it's dom tree + * in oder to report correct results + * + * @param {Object} doc Document object on which to check for a query command + * @param {String} command The query command to check for + * @return {Boolean} + * + * @example + * wysihtml5.browser.supportsCommand(document, "bold"); + */ + supportsCommand: (function() { + // Following commands are supported but contain bugs in some browsers + var buggyCommands = { + // formatBlock fails with some tags (eg. <blockquote>) + "formatBlock": isIE, + // When inserting unordered or ordered lists in Firefox, Chrome or Safari, the current selection or line gets + // converted into a list (<ul><li>...</li></ul>, <ol><li>...</li></ol>) + // IE and Opera act a bit different here as they convert the entire content of the current block element into a list + "insertUnorderedList": isIE || isOpera || isWebKit, + "insertOrderedList": isIE || isOpera || isWebKit + }; + + // Firefox throws errors for queryCommandSupported, so we have to build up our own object of supported commands + var supported = { + "insertHTML": isGecko + }; + + return function(doc, command) { + var isBuggy = buggyCommands[command]; + if (!isBuggy) { + // Firefox throws errors when invoking queryCommandSupported or queryCommandEnabled + try { + return doc.queryCommandSupported(command); + } catch(e1) {} + + try { + return doc.queryCommandEnabled(command); + } catch(e2) { + return !!supported[command]; + } + } + return false; + }; + })(), + + /** + * IE: URLs starting with: + * www., http://, https://, ftp://, gopher://, mailto:, new:, snews:, telnet:, wasis:, file://, + * nntp://, newsrc:, ldap://, ldaps://, outlook:, mic:// and url: + * will automatically be auto-linked when either the user inserts them via copy&paste or presses the + * space bar when the caret is directly after such an url. + * This behavior cannot easily be avoided in IE < 9 since the logic is hardcoded in the mshtml.dll + * (related blog post on msdn + * http://blogs.msdn.com/b/ieinternals/archive/2009/09/17/prevent-automatic-hyperlinking-in-contenteditable-html.aspx). + */ + doesAutoLinkingInContentEditable: function() { + return isIE; + }, + + /** + * As stated above, IE auto links urls typed into contentEditable elements + * Since IE9 it's possible to prevent this behavior + */ + canDisableAutoLinking: function() { + return this.supportsCommand(document, "AutoUrlDetect"); + }, + + /** + * IE leaves an empty paragraph in the contentEditable element after clearing it + * Chrome/Safari sometimes an empty <div> + */ + clearsContentEditableCorrectly: function() { + return isGecko || isOpera || isWebKit; + }, + + /** + * IE gives wrong results for getAttribute + */ + supportsGetAttributeCorrectly: function() { + var td = document.createElement("td"); + return td.getAttribute("rowspan") != "1"; + }, + + /** + * When clicking on images in IE, Opera and Firefox, they are selected, which makes it easy to interact with them. + * Chrome and Safari both don't support this + */ + canSelectImagesInContentEditable: function() { + return isGecko || isIE || isOpera; + }, + + /** + * When the caret is in an empty list (<ul><li>|</li></ul>) which is the first child in an contentEditable container + * pressing backspace doesn't remove the entire list as done in other browsers + */ + clearsListsInContentEditableCorrectly: function() { + return isGecko || isIE || isWebKit; + }, + + /** + * All browsers except Safari and Chrome automatically scroll the range/caret position into view + */ + autoScrollsToCaret: function() { + return !isWebKit; + }, + + /** + * Check whether the browser automatically closes tags that don't need to be opened + */ + autoClosesUnclosedTags: function() { + var clonedTestElement = testElement.cloneNode(false), + returnValue, + innerHTML; + + clonedTestElement.innerHTML = "<p><div></div>"; + innerHTML = clonedTestElement.innerHTML.toLowerCase(); + returnValue = innerHTML === "<p></p><div></div>" || innerHTML === "<p><div></div></p>"; + + // Cache result by overwriting current function + this.autoClosesUnclosedTags = function() { return returnValue; }; + + return returnValue; + }, + + /** + * Whether the browser supports the native document.getElementsByClassName which returns live NodeLists + */ + supportsNativeGetElementsByClassName: function() { + return String(document.getElementsByClassName).indexOf("[native code]") !== -1; + }, + + /** + * As of now (19.04.2011) only supported by Firefox 4 and Chrome + * See https://developer.mozilla.org/en/DOM/Selection/modify + */ + supportsSelectionModify: function() { + return "getSelection" in window && "modify" in window.getSelection(); + }, + + /** + * Whether the browser supports the classList object for fast className manipulation + * See https://developer.mozilla.org/en/DOM/element.classList + */ + supportsClassList: function() { + return "classList" in testElement; + }, + + /** + * Opera needs a white space after a <br> in order to position the caret correctly + */ + needsSpaceAfterLineBreak: function() { + return isOpera; + }, + + /** + * Whether the browser supports the speech api on the given element + * See http://mikepultz.com/2011/03/accessing-google-speech-api-chrome-11/ + * + * @example + * var input = document.createElement("input"); + * if (wysihtml5.browser.supportsSpeechApiOn(input)) { + * // ... + * } + */ + supportsSpeechApiOn: function(input) { + var chromeVersion = userAgent.match(/Chrome\/(\d+)/) || [, 0]; + return chromeVersion[1] >= 11 && ("onwebkitspeechchange" in input || "speech" in input); + }, + + /** + * IE9 crashes when setting a getter via Object.defineProperty on XMLHttpRequest or XDomainRequest + * See https://connect.microsoft.com/ie/feedback/details/650112 + * or try the POC http://tifftiff.de/ie9_crash/ + */ + crashesWhenDefineProperty: function(property) { + return isIE && (property === "XMLHttpRequest" || property === "XDomainRequest"); + }, + + /** + * IE is the only browser who fires the "focus" event not immediately when .focus() is called on an element + */ + doesAsyncFocus: function() { + return isIE; + }, + + /** + * In IE it's impssible for the user and for the selection library to set the caret after an <img> when it's the lastChild in the document + */ + hasProblemsSettingCaretAfterImg: function() { + return isIE; + }, + + hasUndoInContextMenu: function() { + return isGecko || isChrome || isOpera; + } + }; +})();wysihtml5.lang.array = function(arr) { + return { + /** + * Check whether a given object exists in an array + * + * @example + * wysihtml5.lang.array([1, 2]).contains(1); + * // => true + */ + contains: function(needle) { + if (arr.indexOf) { + return arr.indexOf(needle) !== -1; + } else { + for (var i=0, length=arr.length; i<length; i++) { + if (arr[i] === needle) { return true; } + } + return false; + } + }, + + /** + * Substract one array from another + * + * @example + * wysihtml5.lang.array([1, 2, 3, 4]).without([3, 4]); + * // => [1, 2] + */ + without: function(arrayToSubstract) { + arrayToSubstract = wysihtml5.lang.array(arrayToSubstract); + var newArr = [], + i = 0, + length = arr.length; + for (; i<length; i++) { + if (!arrayToSubstract.contains(arr[i])) { + newArr.push(arr[i]); + } + } + return newArr; + }, + + /** + * Return a clean native array + * + * Following will convert a Live NodeList to a proper Array + * @example + * var childNodes = wysihtml5.lang.array(document.body.childNodes).get(); + */ + get: function() { + var i = 0, + length = arr.length, + newArray = []; + for (; i<length; i++) { + newArray.push(arr[i]); + } + return newArray; + } + }; +};wysihtml5.lang.Dispatcher = Base.extend( + /** @scope wysihtml5.lang.Dialog.prototype */ { + observe: function(eventName, handler) { + this.events = this.events || {}; + this.events[eventName] = this.events[eventName] || []; + this.events[eventName].push(handler); + return this; + }, + + on: function() { + return this.observe.apply(this, wysihtml5.lang.array(arguments).get()); + }, + + fire: function(eventName, payload) { + this.events = this.events || {}; + var handlers = this.events[eventName] || [], + i = 0; + for (; i<handlers.length; i++) { + handlers[i].call(this, payload); + } + return this; + }, + + stopObserving: function(eventName, handler) { + this.events = this.events || {}; + var i = 0, + handlers, + newHandlers; + if (eventName) { + handlers = this.events[eventName] || [], + newHandlers = []; + for (; i<handlers.length; i++) { + if (handlers[i] !== handler && handler) { + newHandlers.push(handlers[i]); + } + } + this.events[eventName] = newHandlers; + } else { + // Clean up all events + this.events = {}; + } + return this; + } +});wysihtml5.lang.object = function(obj) { + return { + /** + * @example + * wysihtml5.lang.object({ foo: 1, bar: 1 }).merge({ bar: 2, baz: 3 }).get(); + * // => { foo: 1, bar: 2, baz: 3 } + */ + merge: function(otherObj) { + for (var i in otherObj) { + obj[i] = otherObj[i]; + } + return this; + }, + + get: function() { + return obj; + }, + + /** + * @example + * wysihtml5.lang.object({ foo: 1 }).clone(); + * // => { foo: 1 } + */ + clone: function() { + var newObj = {}, + i; + for (i in obj) { + newObj[i] = obj[i]; + } + return newObj; + }, + + /** + * @example + * wysihtml5.lang.object([]).isArray(); + * // => true + */ + isArray: function() { + return Object.prototype.toString.call(obj) === "[object Array]"; + } + }; +};(function() { + var WHITE_SPACE_START = /^\s+/, + WHITE_SPACE_END = /\s+$/; + wysihtml5.lang.string = function(str) { + str = String(str); + return { + /** + * @example + * wysihtml5.lang.string(" foo ").trim(); + * // => "foo" + */ + trim: function() { + return str.replace(WHITE_SPACE_START, "").replace(WHITE_SPACE_END, ""); + }, + + /** + * @example + * wysihtml5.lang.string("Hello #{name}").interpolate({ name: "Christopher" }); + * // => "Hello Christopher" + */ + interpolate: function(vars) { + for (var i in vars) { + str = this.replace("#{" + i + "}").by(vars[i]); + } + return str; + }, + + /** + * @example + * wysihtml5.lang.string("Hello Tom").replace("Tom").with("Hans"); + * // => "Hello Hans" + */ + replace: function(search) { + return { + by: function(replace) { + return str.split(search).join(replace); + } + } + } + }; + }; +})();/** + * Find urls in descendant text nodes of an element and auto-links them + * Inspired by http://james.padolsey.com/javascript/find-and-replace-text-with-javascript/ + * + * @param {Element} element Container element in which to search for urls + * + * @example + * <div id="text-container">Please click here: www.google.com</div> + * <script>wysihtml5.dom.autoLink(document.getElementById("text-container"));</script> + */ +(function(wysihtml5) { + var /** + * Don't auto-link urls that are contained in the following elements: + */ + IGNORE_URLS_IN = wysihtml5.lang.array(["CODE", "PRE", "A", "SCRIPT", "HEAD", "TITLE", "STYLE"]), + /** + * revision 1: + * /(\S+\.{1}[^\s\,\.\!]+)/g + * + * revision 2: + * /(\b(((https?|ftp):\/\/)|(www\.))[-A-Z0-9+&@#\/%?=~_|!:,.;\[\]]*[-A-Z0-9+&@#\/%=~_|])/gim + * + * put this in the beginning if you don't wan't to match within a word + * (^|[\>\(\{\[\s\>]) + */ + URL_REG_EXP = /((https?:\/\/|www\.)[^\s<]{3,})/gi, + TRAILING_CHAR_REG_EXP = /([^\w\/\-](,?))$/i, + MAX_DISPLAY_LENGTH = 100, + BRACKETS = { ")": "(", "]": "[", "}": "{" }; + + function autoLink(element) { + if (_hasParentThatShouldBeIgnored(element)) { + return element; + } + + if (element === element.ownerDocument.documentElement) { + element = element.ownerDocument.body; + } + + return _parseNode(element); + } + + /** + * This is basically a rebuild of + * the rails auto_link_urls text helper + */ + function _convertUrlsToLinks(str) { + return str.replace(URL_REG_EXP, function(match, url) { + var punctuation = (url.match(TRAILING_CHAR_REG_EXP) || [])[1] || "", + opening = BRACKETS[punctuation]; + url = url.replace(TRAILING_CHAR_REG_EXP, ""); + + if (url.split(opening).length > url.split(punctuation).length) { + url = url + punctuation; + punctuation = ""; + } + var realUrl = url, + displayUrl = url; + if (url.length > MAX_DISPLAY_LENGTH) { + displayUrl = displayUrl.substr(0, MAX_DISPLAY_LENGTH) + "..."; + } + // Add http prefix if necessary + if (realUrl.substr(0, 4) === "www.") { + realUrl = "http://" + realUrl; + } + + return '<a href="' + realUrl + '">' + displayUrl + '</a>' + punctuation; + }); + } + + /** + * Creates or (if already cached) returns a temp element + * for the given document object + */ + function _getTempElement(context) { + var tempElement = context._wysihtml5_tempElement; + if (!tempElement) { + tempElement = context._wysihtml5_tempElement = context.createElement("div"); + } + return tempElement; + } + + /** + * Replaces the original text nodes with the newly auto-linked dom tree + */ + function _wrapMatchesInNode(textNode) { + var parentNode = textNode.parentNode, + tempElement = _getTempElement(parentNode.ownerDocument); + + // We need to insert an empty/temporary <span /> to fix IE quirks + // Elsewise IE would strip white space in the beginning + tempElement.innerHTML = "<span></span>" + _convertUrlsToLinks(textNode.data); + tempElement.removeChild(tempElement.firstChild); + + while (tempElement.firstChild) { + // inserts tempElement.firstChild before textNode + parentNode.insertBefore(tempElement.firstChild, textNode); + } + parentNode.removeChild(textNode); + } + + function _hasParentThatShouldBeIgnored(node) { + var nodeName; + while (node.parentNode) { + node = node.parentNode; + nodeName = node.nodeName; + if (IGNORE_URLS_IN.contains(nodeName)) { + return true; + } else if (nodeName === "body") { + return false; + } + } + return false; + } + + function _parseNode(element) { + if (IGNORE_URLS_IN.contains(element.nodeName)) { + return; + } + + if (element.nodeType === wysihtml5.TEXT_NODE && element.data.match(URL_REG_EXP)) { + _wrapMatchesInNode(element); + return; + } + + var childNodes = wysihtml5.lang.array(element.childNodes).get(), + childNodesLength = childNodes.length, + i = 0; + + for (; i<childNodesLength; i++) { + _parseNode(childNodes[i]); + } + + return element; + } + + wysihtml5.dom.autoLink = autoLink; + + // Reveal url reg exp to the outside + wysihtml5.dom.autoLink.URL_REG_EXP = URL_REG_EXP; +})(wysihtml5);(function(wysihtml5) { + var supportsClassList = wysihtml5.browser.supportsClassList(), + api = wysihtml5.dom; + + api.addClass = function(element, className) { + if (supportsClassList) { + return element.classList.add(className); + } + if (api.hasClass(element, className)) { + return; + } + element.className += " " + className; + }; + + api.removeClass = function(element, className) { + if (supportsClassList) { + return element.classList.remove(className); + } + + element.className = element.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), " "); + }; + + api.hasClass = function(element, className) { + if (supportsClassList) { + return element.classList.contains(className); + } + + var elementClassName = element.className; + return (elementClassName.length > 0 && (elementClassName == className || new RegExp("(^|\\s)" + className + "(\\s|$)").test(elementClassName))); + }; +})(wysihtml5); +wysihtml5.dom.contains = (function() { + var documentElement = document.documentElement; + if (documentElement.contains) { + return function(container, element) { + if (element.nodeType !== wysihtml5.ELEMENT_NODE) { + element = element.parentNode; + } + return container !== element && container.contains(element); + }; + } else if (documentElement.compareDocumentPosition) { + return function(container, element) { + // https://developer.mozilla.org/en/DOM/Node.compareDocumentPosition + return !!(container.compareDocumentPosition(element) & 16); + }; + } +})();/** + * Converts an HTML fragment/element into a unordered/ordered list + * + * @param {Element} element The element which should be turned into a list + * @param {String} listType The list type in which to convert the tree (either "ul" or "ol") + * @return {Element} The created list + * + * @example + * <!-- Assume the following dom: --> + * <span id="pseudo-list"> + * eminem<br> + * dr. dre + * <div>50 Cent</div> + * </span> + * + * <script> + * wysihtml5.dom.convertToList(document.getElementById("pseudo-list"), "ul"); + * </script> + * + * <!-- Will result in: --> + * <ul> + * <li>eminem</li> + * <li>dr. dre</li> + * <li>50 Cent</li> + * </ul> + */ +wysihtml5.dom.convertToList = (function() { + function _createListItem(doc, list) { + var listItem = doc.createElement("li"); + list.appendChild(listItem); + return listItem; + } + + function _createList(doc, type) { + return doc.createElement(type); + } + + function convertToList(element, listType) { + if (element.nodeName === "UL" || element.nodeName === "OL" || element.nodeName === "MENU") { + // Already a list + return element; + } + + var doc = element.ownerDocument, + list = _createList(doc, listType), + lineBreaks = element.querySelectorAll("br"), + lineBreaksLength = lineBreaks.length, + childNodes, + childNodesLength, + childNode, + lineBreak, + parentNode, + isBlockElement, + isLineBreak, + currentListItem, + i; + + // First find <br> at the end of inline elements and move them behind them + for (i=0; i<lineBreaksLength; i++) { + lineBreak = lineBreaks[i]; + while ((parentNode = lineBreak.parentNode) && parentNode !== element && parentNode.lastChild === lineBreak) { + if (wysihtml5.dom.getStyle("display").from(parentNode) === "block") { + parentNode.removeChild(lineBreak); + break; + } + wysihtml5.dom.insert(lineBreak).after(lineBreak.parentNode); + } + } + + childNodes = wysihtml5.lang.array(element.childNodes).get(); + childNodesLength = childNodes.length; + + for (i=0; i<childNodesLength; i++) { + currentListItem = currentListItem || _createListItem(doc, list); + childNode = childNodes[i]; + isBlockElement = wysihtml5.dom.getStyle("display").from(childNode) === "block"; + isLineBreak = childNode.nodeName === "BR"; + + if (isBlockElement) { + // Append blockElement to current <li> if empty, otherwise create a new one + currentListItem = currentListItem.firstChild ? _createListItem(doc, list) : currentListItem; + currentListItem.appendChild(childNode); + currentListItem = null; + continue; + } + + if (isLineBreak) { + // Only create a new list item in the next iteration when the current one has already content + currentListItem = currentListItem.firstChild ? null : currentListItem; + continue; + } + + currentListItem.appendChild(childNode); + } + + element.parentNode.replaceChild(list, element); + return list; + } + + return convertToList; +})();/** + * Copy a set of attributes from one element to another + * + * @param {Array} attributesToCopy List of attributes which should be copied + * @return {Object} Returns an object which offers the "from" method which can be invoked with the element where to + * copy the attributes from., this again returns an object which provides a method named "to" which can be invoked + * with the element where to copy the attributes to (see example) + * + * @example + * var textarea = document.querySelector("textarea"), + * div = document.querySelector("div[contenteditable=true]"), + * anotherDiv = document.querySelector("div.preview"); + * wysihtml5.dom.copyAttributes(["spellcheck", "value", "placeholder"]).from(textarea).to(div).andTo(anotherDiv); + * + */ +wysihtml5.dom.copyAttributes = function(attributesToCopy) { + return { + from: function(elementToCopyFrom) { + return { + to: function(elementToCopyTo) { + var attribute, + i = 0, + length = attributesToCopy.length; + for (; i<length; i++) { + attribute = attributesToCopy[i]; + if (typeof(elementToCopyFrom[attribute]) !== "undefined" && elementToCopyFrom[attribute] !== "") { + elementToCopyTo[attribute] = elementToCopyFrom[attribute]; + } + } + return { andTo: arguments.callee }; + } + }; + } + }; +};/** + * Copy a set of styles from one element to another + * Please note that this only works properly across browsers when the element from which to copy the styles + * is in the dom + * + * Interesting article on how to copy styles + * + * @param {Array} stylesToCopy List of styles which should be copied + * @return {Object} Returns an object which offers the "from" method which can be invoked with the element where to + * copy the styles from., this again returns an object which provides a method named "to" which can be invoked + * with the element where to copy the styles to (see example) + * + * @example + * var textarea = document.querySelector("textarea"), + * div = document.querySelector("div[contenteditable=true]"), + * anotherDiv = document.querySelector("div.preview"); + * wysihtml5.dom.copyStyles(["overflow-y", "width", "height"]).from(textarea).to(div).andTo(anotherDiv); + * + */ +(function(dom) { + + /** + * Mozilla, WebKit and Opera recalculate the computed width when box-sizing: boder-box; is set + * So if an element has "width: 200px; -moz-box-sizing: border-box; border: 1px;" then + * its computed css width will be 198px + */ + var BOX_SIZING_PROPERTIES = ["-webkit-box-sizing", "-moz-box-sizing", "-ms-box-sizing", "box-sizing"]; + + var shouldIgnoreBoxSizingBorderBox = function(element) { + if (hasBoxSizingBorderBox(element)) { + return parseInt(dom.getStyle("width").from(element), 10) < element.offsetWidth; + } + return false; + }; + + var hasBoxSizingBorderBox = function(element) { + var i = 0, + length = BOX_SIZING_PROPERTIES.length; + for (; i<length; i++) { + if (dom.getStyle(BOX_SIZING_PROPERTIES[i]).from(element) === "border-box") { + return BOX_SIZING_PROPERTIES[i]; + } + } + }; + + dom.copyStyles = function(stylesToCopy) { + return { + from: function(element) { + if (shouldIgnoreBoxSizingBorderBox(element)) { + stylesToCopy = wysihtml5.lang.array(stylesToCopy).without(BOX_SIZING_PROPERTIES); + } + + var cssText = "", + length = stylesToCopy.length, + i = 0, + property; + for (; i<length; i++) { + property = stylesToCopy[i]; + cssText += property + ":" + dom.getStyle(property).from(element) + ";"; + } + + return { + to: function(element) { + dom.setStyles(cssText).on(element); + return { andTo: arguments.callee }; + } + }; + } + }; + }; +})(wysihtml5.dom);/** + * Event Delegation + * + * @example + * wysihtml5.dom.delegate(document.body, "a", "click", function() { + * // foo + * }); + */ +(function(wysihtml5) { + + wysihtml5.dom.delegate = function(container, selector, eventName, handler) { + return wysihtml5.dom.observe(container, eventName, function(event) { + var target = event.target, + match = wysihtml5.lang.array(container.querySelectorAll(selector)); + + while (target && target !== container) { + if (match.contains(target)) { + handler.call(target, event); + break; + } + target = target.parentNode; + } + }); + }; + +})(wysihtml5);/** + * Returns the given html wrapped in a div element + * + * Fixing IE's inability to treat unknown elements (HTML5 section, article, ...) correctly + * when inserted via innerHTML + * + * @param {String} html The html which should be wrapped in a dom element + * @param {Obejct} [context] Document object of the context the html belongs to + * + * @example + * wysihtml5.dom.getAsDom("<article>foo</article>"); + */ +wysihtml5.dom.getAsDom = (function() { + + var _innerHTMLShiv = function(html, context) { + var tempElement = context.createElement("div"); + tempElement.style.display = "none"; + context.body.appendChild(tempElement); + // IE throws an exception when trying to insert <frameset></frameset> via innerHTML + try { tempElement.innerHTML = html; } catch(e) {} + context.body.removeChild(tempElement); + return tempElement; + }; + + /** + * Make sure IE supports HTML5 tags, which is accomplished by simply creating one instance of each element + */ + var _ensureHTML5Compatibility = function(context) { + if (context._wysihtml5_supportsHTML5Tags) { + return; + } + for (var i=0, length=HTML5_ELEMENTS.length; i<length; i++) { + context.createElement(HTML5_ELEMENTS[i]); + } + context._wysihtml5_supportsHTML5Tags = true; + }; + + + /** + * List of html5 tags + * taken from http://simon.html5.org/html5-elements + */ + var HTML5_ELEMENTS = [ + "abbr", "article", "aside", "audio", "bdi", "canvas", "command", "datalist", "details", "figcaption", + "figure", "footer", "header", "hgroup", "keygen", "mark", "meter", "nav", "output", "progress", + "rp", "rt", "ruby", "svg", "section", "source", "summary", "time", "track", "video", "wbr" + ]; + + return function(html, context) { + context = context || document; + var tempElement; + if (typeof(html) === "object" && html.nodeType) { + tempElement = context.createElement("div"); + tempElement.appendChild(html); + } else if (wysihtml5.browser.supportsHTML5Tags(context)) { + tempElement = context.createElement("div"); + tempElement.innerHTML = html; + } else { + _ensureHTML5Compatibility(context); + tempElement = _innerHTMLShiv(html, context); + } + return tempElement; + }; +})();/** + * Walks the dom tree from the given node up until it finds a match + * Designed for optimal performance. + * + * @param {Element} node The from which to check the parent nodes + * @param {Object} matchingSet Object to match against (possible properties: nodeName, className, classRegExp) + * @param {Number} [levels] How many parents should the function check up from the current node (defaults to 50) + * @return {null|Element} Returns the first element that matched the desiredNodeName(s) + * @example + * var listElement = wysihtml5.dom.getParentElement(document.querySelector("li"), { nodeName: ["MENU", "UL", "OL"] }); + * // ... or ... + * var unorderedListElement = wysihtml5.dom.getParentElement(document.querySelector("li"), { nodeName: "UL" }); + * // ... or ... + * var coloredElement = wysihtml5.dom.getParentElement(myTextNode, { nodeName: "SPAN", className: "wysiwyg-color-red", classRegExp: /wysiwyg-color-[a-z]/g }); + */ +wysihtml5.dom.getParentElement = (function() { + + function _isSameNodeName(nodeName, desiredNodeNames) { + if (!desiredNodeNames || !desiredNodeNames.length) { + return true; + } + + if (typeof(desiredNodeNames) === "string") { + return nodeName === desiredNodeNames; + } else { + return wysihtml5.lang.array(desiredNodeNames).contains(nodeName); + } + } + + function _isElement(node) { + return node.nodeType === wysihtml5.ELEMENT_NODE; + } + + function _hasClassName(element, className, classRegExp) { + var classNames = (element.className || "").match(classRegExp) || []; + if (!className) { + return !!classNames.length; + } + return classNames[classNames.length - 1] === className; + } + + function _getParentElementWithNodeName(node, nodeName, levels) { + while (levels-- && node && node.nodeName !== "BODY") { + if (_isSameNodeName(node.nodeName, nodeName)) { + return node; + } + node = node.parentNode; + } + return null; + } + + function _getParentElementWithNodeNameAndClassName(node, nodeName, className, classRegExp, levels) { + while (levels-- && node && node.nodeName !== "BODY") { + if (_isElement(node) && + _isSameNodeName(node.nodeName, nodeName) && + _hasClassName(node, className, classRegExp)) { + return node; + } + node = node.parentNode; + } + return null; + } + + return function(node, matchingSet, levels) { + levels = levels || 50; // Go max 50 nodes upwards from current node + if (matchingSet.className || matchingSet.classRegExp) { + return _getParentElementWithNodeNameAndClassName( + node, matchingSet.nodeName, matchingSet.className, matchingSet.classRegExp, levels + ); + } else { + return _getParentElementWithNodeName( + node, matchingSet.nodeName, levels + ); + } + }; +})(); +/** + * Get element's style for a specific css property + * + * @param {Element} element The element on which to retrieve the style + * @param {String} property The CSS property to retrieve ("float", "display", "text-align", ...) + * + * @example + * wysihtml5.dom.getStyle("display").from(document.body); + * // => "block" + */ +wysihtml5.dom.getStyle = (function() { + var stylePropertyMapping = { + "float": ("styleFloat" in document.createElement("div").style) ? "styleFloat" : "cssFloat" + }, + REG_EXP_CAMELIZE = /\-[a-z]/g; + + function camelize(str) { + return str.replace(REG_EXP_CAMELIZE, function(match) { + return match.charAt(1).toUpperCase(); + }); + } + + return function(property) { + return { + from: function(element) { + if (element.nodeType !== wysihtml5.ELEMENT_NODE) { + return; + } + + var doc = element.ownerDocument, + camelizedProperty = stylePropertyMapping[property] || camelize(property), + style = element.style, + currentStyle = element.currentStyle, + styleValue = style[camelizedProperty]; + if (styleValue) { + return styleValue; + } + + // currentStyle is no standard and only supported by Opera and IE but it has one important advantage over the standard-compliant + // window.getComputedStyle, since it returns css property values in their original unit: + // If you set an elements width to "50%", window.getComputedStyle will give you it's current width in px while currentStyle + // gives you the original "50%". + // Opera supports both, currentStyle and window.getComputedStyle, that's why checking for currentStyle should have higher prio + if (currentStyle) { + try { + return currentStyle[camelizedProperty]; + } catch(e) { + //ie will occasionally fail for unknown reasons. swallowing exception + } + } + + var win = doc.defaultView || doc.parentWindow, + needsOverflowReset = (property === "height" || property === "width") && element.nodeName === "TEXTAREA", + originalOverflow, + returnValue; + + if (win.getComputedStyle) { + // Chrome and Safari both calculate a wrong width and height for textareas when they have scroll bars + // therfore we remove and restore the scrollbar and calculate the value in between + if (needsOverflowReset) { + originalOverflow = style.overflow; + style.overflow = "hidden"; + } + returnValue = win.getComputedStyle(element, null).getPropertyValue(property); + if (needsOverflowReset) { + style.overflow = originalOverflow || ""; + } + return returnValue; + } + } + }; + }; +})();/** + * High performant way to check whether an element with a specific tag name is in the given document + * Optimized for being heavily executed + * Unleashes the power of live node lists + * + * @param {Object} doc The document object of the context where to check + * @param {String} tagName Upper cased tag name + * @example + * wysihtml5.dom.hasElementWithTagName(document, "IMG"); + */ +wysihtml5.dom.hasElementWithTagName = (function() { + var LIVE_CACHE = {}, + DOCUMENT_IDENTIFIER = 1; + + function _getDocumentIdentifier(doc) { + return doc._wysihtml5_identifier || (doc._wysihtml5_identifier = DOCUMENT_IDENTIFIER++); + } + + return function(doc, tagName) { + var key = _getDocumentIdentifier(doc) + ":" + tagName, + cacheEntry = LIVE_CACHE[key]; + if (!cacheEntry) { + cacheEntry = LIVE_CACHE[key] = doc.getElementsByTagName(tagName); + } + + return cacheEntry.length > 0; + }; +})();/** + * High performant way to check whether an element with a specific class name is in the given document + * Optimized for being heavily executed + * Unleashes the power of live node lists + * + * @param {Object} doc The document object of the context where to check + * @param {String} tagName Upper cased tag name + * @example + * wysihtml5.dom.hasElementWithClassName(document, "foobar"); + */ +(function(wysihtml5) { + var LIVE_CACHE = {}, + DOCUMENT_IDENTIFIER = 1; + + function _getDocumentIdentifier(doc) { + return doc._wysihtml5_identifier || (doc._wysihtml5_identifier = DOCUMENT_IDENTIFIER++); + } + + wysihtml5.dom.hasElementWithClassName = function(doc, className) { + // getElementsByClassName is not supported by IE<9 + // but is sometimes mocked via library code (which then doesn't return live node lists) + if (!wysihtml5.browser.supportsNativeGetElementsByClassName()) { + return !!doc.querySelector("." + className); + } + + var key = _getDocumentIdentifier(doc) + ":" + className, + cacheEntry = LIVE_CACHE[key]; + if (!cacheEntry) { + cacheEntry = LIVE_CACHE[key] = doc.getElementsByClassName(className); + } + + return cacheEntry.length > 0; + }; +})(wysihtml5); +wysihtml5.dom.insert = function(elementToInsert) { + return { + after: function(element) { + element.parentNode.insertBefore(elementToInsert, element.nextSibling); + }, + + before: function(element) { + element.parentNode.insertBefore(elementToInsert, element); + }, + + into: function(element) { + element.appendChild(elementToInsert); + } + }; +};wysihtml5.dom.insertCSS = function(rules) { + rules = rules.join("\n"); + + return { + into: function(doc) { + var head = doc.head || doc.getElementsByTagName("head")[0], + styleElement = doc.createElement("style"); + + styleElement.type = "text/css"; + + if (styleElement.styleSheet) { + styleElement.styleSheet.cssText = rules; + } else { + styleElement.appendChild(doc.createTextNode(rules)); + } + + if (head) { + head.appendChild(styleElement); + } + } + }; +};/** + * Method to set dom events + * + * @example + * wysihtml5.dom.observe(iframe.contentWindow.document.body, ["focus", "blur"], function() { ... }); + */ +wysihtml5.dom.observe = function(element, eventNames, handler) { + eventNames = typeof(eventNames) === "string" ? [eventNames] : eventNames; + + var handlerWrapper, + eventName, + i = 0, + length = eventNames.length; + + for (; i<length; i++) { + eventName = eventNames[i]; + if (element.addEventListener) { + element.addEventListener(eventName, handler, false); + } else { + handlerWrapper = function(event) { + if (!("target" in event)) { + event.target = event.srcElement; + } + event.preventDefault = event.preventDefault || function() { + this.returnValue = false; + }; + event.stopPropagation = event.stopPropagation || function() { + this.cancelBubble = true; + }; + handler.call(element, event); + }; + element.attachEvent("on" + eventName, handlerWrapper); + } + } + + return { + stop: function() { + var eventName, + i = 0, + length = eventNames.length; + for (; i<length; i++) { + eventName = eventNames[i]; + if (element.removeEventListener) { + element.removeEventListener(eventName, handler, false); + } else { + element.detachEvent("on" + eventName, handlerWrapper); + } + } + } + }; +}; +/** + * HTML Sanitizer + * Rewrites the HTML based on given rules + * + * @param {Element|String} elementOrHtml HTML String to be sanitized OR element whose content should be sanitized + * @param {Object} [rules] List of rules for rewriting the HTML, if there's no rule for an element it will + * be converted to a "span". Each rule is a key/value pair where key is the tag to convert, and value the + * desired substitution. + * @param {Object} context Document object in which to parse the html, needed to sandbox the parsing + * + * @return {Element|String} Depends on the elementOrHtml parameter. When html then the sanitized html as string elsewise the element. + * + * @example + * var userHTML = '<div id="foo" onclick="alert(1);"><p><font color="red">foo</font><script>alert(1);</script></p></div>'; + * wysihtml5.dom.parse(userHTML, { + * tags { + * p: "div", // Rename p tags to div tags + * font: "span" // Rename font tags to span tags + * div: true, // Keep them, also possible (same result when passing: "div" or true) + * script: undefined // Remove script elements + * } + * }); + * // => <div><div><span>foo bar</span></div></div> + * + * var userHTML = '<table><tbody><tr><td>I'm a table!</td></tr></tbody></table>'; + * wysihtml5.dom.parse(userHTML); + * // => '<span><span><span><span>I'm a table!</span></span></span></span>' + * + * var userHTML = '<div>foobar<br>foobar</div>'; + * wysihtml5.dom.parse(userHTML, { + * tags: { + * div: undefined, + * br: true + * } + * }); + * // => '' + * + * var userHTML = '<div class="red">foo</div><div class="pink">bar</div>'; + * wysihtml5.dom.parse(userHTML, { + * classes: { + * red: 1, + * green: 1 + * }, + * tags: { + * div: { + * rename_tag: "p" + * } + * } + * }); + * // => '<p class="red">foo</p><p>bar</p>' + */ +wysihtml5.dom.parse = (function() { + + /** + * It's not possible to use a XMLParser/DOMParser as HTML5 is not always well-formed XML + * new DOMParser().parseFromString('<img src="foo.gif">') will cause a parseError since the + * node isn't closed + * + * Therefore we've to use the browser's ordinary HTML parser invoked by setting innerHTML. + */ + var NODE_TYPE_MAPPING = { + "1": _handleElement, + "3": _handleText + }, + // Rename unknown tags to this + DEFAULT_NODE_NAME = "span", + WHITE_SPACE_REG_EXP = /\s+/, + defaultRules = { tags: {}, classes: {} }, + currentRules = {}; + + /** + * Iterates over all childs of the element, recreates them, appends them into a document fragment + * which later replaces the entire body content + */ + function parse(elementOrHtml, rules, context, cleanUp) { + wysihtml5.lang.object(currentRules).merge(defaultRules).merge(rules).get(); + + context = context || elementOrHtml.ownerDocument || document; + var fragment = context.createDocumentFragment(), + isString = typeof(elementOrHtml) === "string", + element, + newNode, + firstChild; + + if (isString) { + element = wysihtml5.dom.getAsDom(elementOrHtml, context); + } else { + element = elementOrHtml; + } + + while (element.firstChild) { + firstChild = element.firstChild; + element.removeChild(firstChild); + newNode = _convert(firstChild, cleanUp); + if (newNode) { + fragment.appendChild(newNode); + } + } + + // Clear element contents + element.innerHTML = ""; + + // Insert new DOM tree + element.appendChild(fragment); + + return isString ? wysihtml5.quirks.getCorrectInnerHTML(element) : element; + } + + function _convert(oldNode, cleanUp) { + var oldNodeType = oldNode.nodeType, + oldChilds = oldNode.childNodes, + oldChildsLength = oldChilds.length, + newNode, + method = NODE_TYPE_MAPPING[oldNodeType], + i = 0; + + newNode = method && method(oldNode); + + if (!newNode) { + return null; + } + + for (i=0; i<oldChildsLength; i++) { + newChild = _convert(oldChilds[i], cleanUp); + if (newChild) { + newNode.appendChild(newChild); + } + } + + // Cleanup senseless <span> elements + if (cleanUp && + newNode.childNodes.length <= 1 && + newNode.nodeName.toLowerCase() === DEFAULT_NODE_NAME && + !newNode.attributes.length) { + return newNode.firstChild; + } + + return newNode; + } + + function _handleElement(oldNode) { + var rule, + newNode, + endTag, + tagRules = currentRules.tags, + nodeName = oldNode.nodeName.toLowerCase(), + scopeName = oldNode.scopeName; + + /** + * We already parsed that element + * ignore it! (yes, this sometimes happens in IE8 when the html is invalid) + */ + if (oldNode._wysihtml5) { + return null; + } + oldNode._wysihtml5 = 1; + + if (oldNode.className === "wysihtml5-temp") { + return null; + } + + /** + * IE is the only browser who doesn't include the namespace in the + * nodeName, that's why we have to prepend it by ourselves + * scopeName is a proprietary IE feature + * read more here http://msdn.microsoft.com/en-us/library/ms534388(v=vs.85).aspx + */ + if (scopeName && scopeName != "HTML") { + nodeName = scopeName + ":" + nodeName; + } + + /** + * Repair node + * IE is a bit bitchy when it comes to invalid nested markup which includes unclosed tags + * A <p> doesn't need to be closed according HTML4-5 spec, we simply replace it with a <div> to preserve its content and layout + */ + if ("outerHTML" in oldNode) { + if (!wysihtml5.browser.autoClosesUnclosedTags() && + oldNode.nodeName === "P" && + oldNode.outerHTML.slice(-4).toLowerCase() !== "</p>") { + nodeName = "div"; + } + } + + if (nodeName in tagRules) { + rule = tagRules[nodeName]; + if (!rule || rule.remove) { + return null; + } + + rule = typeof(rule) === "string" ? { rename_tag: rule } : rule; + } else if (oldNode.firstChild) { + rule = { rename_tag: DEFAULT_NODE_NAME }; + } else { + // Remove empty unknown elements + return null; + } + + newNode = oldNode.ownerDocument.createElement(rule.rename_tag || nodeName); + _handleAttributes(oldNode, newNode, rule); + + oldNode = null; + return newNode; + } + + function _handleAttributes(oldNode, newNode, rule) { + var attributes = {}, // fresh new set of attributes to set on newNode + setClass = rule.set_class, // classes to set + addClass = rule.add_class, // add classes based on existing attributes + setAttributes = rule.set_attributes, // attributes to set on the current node + checkAttributes = rule.check_attributes, // check/convert values of attributes + allowedClasses = currentRules.classes, + i = 0, + classes = [], + newClasses = [], + newUniqueClasses = [], + oldClasses = [], + classesLength, + newClassesLength, + currentClass, + newClass, + attributeName, + newAttributeValue, + method; + + if (setAttributes) { + attributes = wysihtml5.lang.object(setAttributes).clone(); + } + + if (checkAttributes) { + for (attributeName in checkAttributes) { + method = attributeCheckMethods[checkAttributes[attributeName]]; + if (!method) { + continue; + } + newAttributeValue = method(_getAttribute(oldNode, attributeName)); + if (typeof(newAttributeValue) === "string") { + attributes[attributeName] = newAttributeValue; + } + } + } + + if (setClass) { + classes.push(setClass); + } + + if (addClass) { + for (attributeName in addClass) { + method = addClassMethods[addClass[attributeName]]; + if (!method) { + continue; + } + newClass = method(_getAttribute(oldNode, attributeName)); + if (typeof(newClass) === "string") { + classes.push(newClass); + } + } + } + + // make sure that wysihtml5 temp class doesn't get stripped out + allowedClasses["_wysihtml5-temp-placeholder"] = 1; + + // add old classes last + oldClasses = oldNode.getAttribute("class"); + if (oldClasses) { + classes = classes.concat(oldClasses.split(WHITE_SPACE_REG_EXP)); + } + classesLength = classes.length; + for (; i<classesLength; i++) { + currentClass = classes[i]; + if (allowedClasses[currentClass]) { + newClasses.push(currentClass); + } + } + + // remove duplicate entries and preserve class specificity + newClassesLength = newClasses.length; + while (newClassesLength--) { + currentClass = newClasses[newClassesLength]; + if (!wysihtml5.lang.array(newUniqueClasses).contains(currentClass)) { + newUniqueClasses.unshift(currentClass); + } + } + + if (newUniqueClasses.length) { + attributes["class"] = newUniqueClasses.join(" "); + } + + // set attributes on newNode + for (attributeName in attributes) { + // Setting attributes can cause a js error in IE under certain circumstances + // eg. on a <img> under https when it's new attribute value is non-https + // TODO: Investigate this further and check for smarter handling + try { + newNode.setAttribute(attributeName, attributes[attributeName]); + } catch(e) {} + } + + // IE8 sometimes loses the width/height attributes when those are set before the "src" + // so we make sure to set them again + if (attributes.src) { + if (typeof(attributes.width) !== "undefined") { + newNode.setAttribute("width", attributes.width); + } + if (typeof(attributes.height) !== "undefined") { + newNode.setAttribute("height", attributes.height); + } + } + } + + /** + * IE gives wrong results for hasAttribute/getAttribute, for example: + * var td = document.createElement("td"); + * td.getAttribute("rowspan"); // => "1" in IE + * + * Therefore we have to check the element's outerHTML for the attribute + */ + var HAS_GET_ATTRIBUTE_BUG = !wysihtml5.browser.supportsGetAttributeCorrectly(); + function _getAttribute(node, attributeName) { + attributeName = attributeName.toLowerCase(); + var nodeName = node.nodeName; + if (nodeName == "IMG" && attributeName == "src" && _isLoadedImage(node) === true) { + // Get 'src' attribute value via object property since this will always contain the + // full absolute url (http://...) + // this fixes a very annoying bug in firefox (ver 3.6 & 4) and IE 8 where images copied from the same host + // will have relative paths, which the sanitizer strips out (see attributeCheckMethods.url) + return node.src; + } else if (HAS_GET_ATTRIBUTE_BUG && "outerHTML" in node) { + // Don't trust getAttribute/hasAttribute in IE 6-8, instead check the element's outerHTML + var outerHTML = node.outerHTML.toLowerCase(), + // TODO: This might not work for attributes without value: <input disabled> + hasAttribute = outerHTML.indexOf(" " + attributeName + "=") != -1; + + return hasAttribute ? node.getAttribute(attributeName) : null; + } else{ + return node.getAttribute(attributeName); + } + } + + /** + * Check whether the given node is a proper loaded image + * FIXME: Returns undefined when unknown (Chrome, Safari) + */ + function _isLoadedImage(node) { + try { + return node.complete && !node.mozMatchesSelector(":-moz-broken"); + } catch(e) { + if (node.complete && node.readyState === "complete") { + return true; + } + } + } + + function _handleText(oldNode) { + return oldNode.ownerDocument.createTextNode(oldNode.data); + } + + + // ------------ attribute checks ------------ \\ + var attributeCheckMethods = { + url: (function() { + var REG_EXP = /^https?:\/\//i; + return function(attributeValue) { + if (!attributeValue || !attributeValue.match(REG_EXP)) { + return null; + } + return attributeValue.replace(REG_EXP, function(match) { + return match.toLowerCase(); + }); + }; + })(), + + alt: (function() { + var REG_EXP = /[^ a-z0-9_\-]/gi; + return function(attributeValue) { + if (!attributeValue) { + return ""; + } + return attributeValue.replace(REG_EXP, ""); + }; + })(), + + numbers: (function() { + var REG_EXP = /\D/g; + return function(attributeValue) { + attributeValue = (attributeValue || "").replace(REG_EXP, ""); + return attributeValue || null; + }; + })() + }; + + // ------------ class converter (converts an html attribute to a class name) ------------ \\ + var addClassMethods = { + align_img: (function() { + var mapping = { + left: "wysiwyg-float-left", + right: "wysiwyg-float-right" + }; + return function(attributeValue) { + return mapping[String(attributeValue).toLowerCase()]; + }; + })(), + + align_text: (function() { + var mapping = { + left: "wysiwyg-text-align-left", + right: "wysiwyg-text-align-right", + center: "wysiwyg-text-align-center", + justify: "wysiwyg-text-align-justify" + }; + return function(attributeValue) { + return mapping[String(attributeValue).toLowerCase()]; + }; + })(), + + clear_br: (function() { + var mapping = { + left: "wysiwyg-clear-left", + right: "wysiwyg-clear-right", + both: "wysiwyg-clear-both", + all: "wysiwyg-clear-both" + }; + return function(attributeValue) { + return mapping[String(attributeValue).toLowerCase()]; + }; + })(), + + size_font: (function() { + var mapping = { + "1": "wysiwyg-font-size-xx-small", + "2": "wysiwyg-font-size-small", + "3": "wysiwyg-font-size-medium", + "4": "wysiwyg-font-size-large", + "5": "wysiwyg-font-size-x-large", + "6": "wysiwyg-font-size-xx-large", + "7": "wysiwyg-font-size-xx-large", + "-": "wysiwyg-font-size-smaller", + "+": "wysiwyg-font-size-larger" + }; + return function(attributeValue) { + return mapping[String(attributeValue).charAt(0)]; + }; + })() + }; + + return parse; +})();/** + * Checks for empty text node childs and removes them + * + * @param {Element} node The element in which to cleanup + * @example + * wysihtml5.dom.removeEmptyTextNodes(element); + */ +wysihtml5.dom.removeEmptyTextNodes = function(node) { + var childNode, + childNodes = wysihtml5.lang.array(node.childNodes).get(), + childNodesLength = childNodes.length, + i = 0; + for (; i<childNodesLength; i++) { + childNode = childNodes[i]; + if (childNode.nodeType === wysihtml5.TEXT_NODE && childNode.data === "") { + childNode.parentNode.removeChild(childNode); + } + } +}; +/** + * Renames an element (eg. a <div> to a <p>) and keeps its childs + * + * @param {Element} element The list element which should be renamed + * @param {Element} newNodeName The desired tag name + * + * @example + * <!-- Assume the following dom: --> + * <ul id="list"> + * <li>eminem</li> + * <li>dr. dre</li> + * <li>50 Cent</li> + * </ul> + * + * <script> + * wysihtml5.dom.renameElement(document.getElementById("list"), "ol"); + * </script> + * + * <!-- Will result in: --> + * <ol> + * <li>eminem</li> + * <li>dr. dre</li> + * <li>50 Cent</li> + * </ol> + */ +wysihtml5.dom.renameElement = function(element, newNodeName) { + var newElement = element.ownerDocument.createElement(newNodeName), + firstChild; + while (firstChild = element.firstChild) { + newElement.appendChild(firstChild); + } + wysihtml5.dom.copyAttributes(["align", "className"]).from(element).to(newElement); + element.parentNode.replaceChild(newElement, element); + return newElement; +};/** + * Takes an element, removes it and replaces it with it's childs + * + * @param {Object} node The node which to replace with it's child nodes + * @example + * <div id="foo"> + * <span>hello</span> + * </div> + * <script> + * // Remove #foo and replace with it's children + * wysihtml5.dom.replaceWithChildNodes(document.getElementById("foo")); + * </script> + */ +wysihtml5.dom.replaceWithChildNodes = function(node) { + if (!node.parentNode) { + return; + } + + if (!node.firstChild) { + node.parentNode.removeChild(node); + return; + } + + var fragment = node.ownerDocument.createDocumentFragment(); + while (node.firstChild) { + fragment.appendChild(node.firstChild); + } + node.parentNode.replaceChild(fragment, node); + node = fragment = null; +}; +/** + * Unwraps an unordered/ordered list + * + * @param {Element} element The list element which should be unwrapped + * + * @example + * <!-- Assume the following dom: --> + * <ul id="list"> + * <li>eminem</li> + * <li>dr. dre</li> + * <li>50 Cent</li> + * </ul> + * + * <script> + * wysihtml5.dom.resolveList(document.getElementById("list")); + * </script> + * + * <!-- Will result in: --> + * eminem<br> + * dr. dre<br> + * 50 Cent<br> + */ +(function(dom) { + function _isBlockElement(node) { + return dom.getStyle("display").from(node) === "block"; + } + + function _isLineBreak(node) { + return node.nodeName === "BR"; + } + + function _appendLineBreak(element) { + var lineBreak = element.ownerDocument.createElement("br"); + element.appendChild(lineBreak); + } + + function resolveList(list) { + if (list.nodeName !== "MENU" && list.nodeName !== "UL" && list.nodeName !== "OL") { + return; + } + + var doc = list.ownerDocument, + fragment = doc.createDocumentFragment(), + previousSibling = list.previousElementSibling || list.previousSibling, + firstChild, + lastChild, + isLastChild, + shouldAppendLineBreak, + listItem; + + if (previousSibling && !_isBlockElement(previousSibling)) { + _appendLineBreak(fragment); + } + + while (listItem = list.firstChild) { + lastChild = listItem.lastChild; + while (firstChild = listItem.firstChild) { + isLastChild = firstChild === lastChild; + // This needs to be done before appending it to the fragment, as it otherwise will loose style information + shouldAppendLineBreak = isLastChild && !_isBlockElement(firstChild) && !_isLineBreak(firstChild); + fragment.appendChild(firstChild); + if (shouldAppendLineBreak) { + _appendLineBreak(fragment); + } + } + + listItem.parentNode.removeChild(listItem); + } + list.parentNode.replaceChild(fragment, list); + } + + dom.resolveList = resolveList; +})(wysihtml5.dom);/** + * Sandbox for executing javascript, parsing css styles and doing dom operations in a secure way + * + * Browser Compatibility: + * - Secure in MSIE 6+, but only when the user hasn't made changes to his security level "restricted" + * - Partially secure in other browsers (Firefox, Opera, Safari, Chrome, ...) + * + * Please note that this class can't benefit from the HTML5 sandbox attribute for the following reasons: + * - sandboxing doesn't work correctly with inlined content (src="javascript:'<html>...</html>'") + * - sandboxing of physical documents causes that the dom isn't accessible anymore from the outside (iframe.contentWindow, ...) + * - setting the "allow-same-origin" flag would fix that, but then still javascript and dom events refuse to fire + * - therefore the "allow-scripts" flag is needed, which then would deactivate any security, as the js executed inside the iframe + * can do anything as if the sandbox attribute wasn't set + * + * @param {Function} [readyCallback] Method that gets invoked when the sandbox is ready + * @param {Object} [config] Optional parameters + * + * @example + * new wysihtml5.dom.Sandbox(function(sandbox) { + * sandbox.getWindow().document.body.innerHTML = '<img src=foo.gif onerror="alert(document.cookie)">'; + * }); + */ +(function(wysihtml5) { + var /** + * Default configuration + */ + doc = document, + /** + * Properties to unset/protect on the window object + */ + windowProperties = [ + "parent", "top", "opener", "frameElement", "frames", + "localStorage", "globalStorage", "sessionStorage", "indexedDB" + ], + /** + * Properties on the window object which are set to an empty function + */ + windowProperties2 = [ + "open", "close", "openDialog", "showModalDialog", + "alert", "confirm", "prompt", + "openDatabase", "postMessage", + "XMLHttpRequest", "XDomainRequest" + ], + /** + * Properties to unset/protect on the document object + */ + documentProperties = [ + "referrer", + "write", "open", "close" + ]; + + wysihtml5.dom.Sandbox = Base.extend( + /** @scope wysihtml5.dom.Sandbox.prototype */ { + + constructor: function(readyCallback, config) { + this.callback = readyCallback || wysihtml5.EMPTY_FUNCTION; + this.config = wysihtml5.lang.object({}).merge(config).get(); + this.iframe = this._createIframe(); + }, + + insertInto: function(element) { + if (typeof(element) === "string") { + element = doc.getElementById(element); + } + + element.appendChild(this.iframe); + }, + + getIframe: function() { + return this.iframe; + }, + + getWindow: function() { + this._readyError(); + }, + + getDocument: function() { + this._readyError(); + }, + + destroy: function() { + var iframe = this.getIframe(); + iframe.parentNode.removeChild(iframe); + }, + + _readyError: function() { + throw new Error("wysihtml5.Sandbox: Sandbox iframe isn't loaded yet"); + }, + + /** + * Creates the sandbox iframe + * + * Some important notes: + * - We can't use HTML5 sandbox for now: + * setting it causes that the iframe's dom can't be accessed from the outside + * Therefore we need to set the "allow-same-origin" flag which enables accessing the iframe's dom + * But then there's another problem, DOM events (focus, blur, change, keypress, ...) aren't fired. + * In order to make this happen we need to set the "allow-scripts" flag. + * A combination of allow-scripts and allow-same-origin is almost the same as setting no sandbox attribute at all. + * - Chrome & Safari, doesn't seem to support sandboxing correctly when the iframe's html is inlined (no physical document) + * - IE needs to have the security="restricted" attribute set before the iframe is + * inserted into the dom tree + * - Believe it or not but in IE "security" in document.createElement("iframe") is false, even + * though it supports it + * - When an iframe has security="restricted", in IE eval() & execScript() don't work anymore + * - IE doesn't fire the onload event when the content is inlined in the src attribute, therefore we rely + * on the onreadystatechange event + */ + _createIframe: function() { + var that = this, + iframe = doc.createElement("iframe"); + iframe.className = "wysihtml5-sandbox"; + wysihtml5.dom.setAttributes({ + "security": "restricted", + "allowtransparency": "true", + "frameborder": 0, + "width": 0, + "height": 0, + "marginwidth": 0, + "marginheight": 0 + }).on(iframe); + + // Setting the src like this prevents ssl warnings in IE6 + if (wysihtml5.browser.throwsMixedContentWarningWhenIframeSrcIsEmpty()) { + iframe.src = "javascript:'<html></html>'"; + } + + iframe.onload = function() { + iframe.onreadystatechange = iframe.onload = null; + that._onLoadIframe(iframe); + }; + + iframe.onreadystatechange = function() { + if (/loaded|complete/.test(iframe.readyState)) { + iframe.onreadystatechange = iframe.onload = null; + that._onLoadIframe(iframe); + } + }; + + return iframe; + }, + + /** + * Callback for when the iframe has finished loading + */ + _onLoadIframe: function(iframe) { + // don't resume when the iframe got unloaded (eg. by removing it from the dom) + if (!wysihtml5.dom.contains(doc.documentElement, iframe)) { + return; + } + + var that = this, + iframeWindow = iframe.contentWindow, + iframeDocument = iframe.contentWindow.document, + charset = doc.characterSet || doc.charset || "utf-8", + sandboxHtml = this._getHtml({ + charset: charset, + stylesheets: this.config.stylesheets + }); + + // Create the basic dom tree including proper DOCTYPE and charset + iframeDocument.open("text/html", "replace"); + iframeDocument.write(sandboxHtml); + iframeDocument.close(); + + this.getWindow = function() { return iframe.contentWindow; }; + this.getDocument = function() { return iframe.contentWindow.document; }; + + // Catch js errors and pass them to the parent's onerror event + // addEventListener("error") doesn't work properly in some browsers + // TODO: apparently this doesn't work in IE9! + iframeWindow.onerror = function(errorMessage, fileName, lineNumber) { + throw new Error("wysihtml5.Sandbox: " + errorMessage, fileName, lineNumber); + }; + + if (!wysihtml5.browser.supportsSandboxedIframes()) { + // Unset a bunch of sensitive variables + // Please note: This isn't hack safe! + // It more or less just takes care of basic attacks and prevents accidental theft of sensitive information + // IE is secure though, which is the most important thing, since IE is the only browser, who + // takes over scripts & styles into contentEditable elements when copied from external websites + // or applications (Microsoft Word, ...) + var i, length; + for (i=0, length=windowProperties.length; i<length; i++) { + this._unset(iframeWindow, windowProperties[i]); + } + for (i=0, length=windowProperties2.length; i<length; i++) { + this._unset(iframeWindow, windowProperties2[i], wysihtml5.EMPTY_FUNCTION); + } + for (i=0, length=documentProperties.length; i<length; i++) { + this._unset(iframeDocument, documentProperties[i]); + } + // This doesn't work in Safari 5 + // See http://stackoverflow.com/questions/992461/is-it-possible-to-override-document-cookie-in-webkit + this._unset(iframeDocument, "cookie", "", true); + } + + this.loaded = true; + + // Trigger the callback + setTimeout(function() { that.callback(that); }, 0); + }, + + _getHtml: function(templateVars) { + var stylesheets = templateVars.stylesheets, + html = "", + i = 0, + length; + stylesheets = typeof(stylesheets) === "string" ? [stylesheets] : stylesheets; + if (stylesheets) { + length = stylesheets.length; + for (; i<length; i++) { + html += '<link rel="stylesheet" href="' + stylesheets[i] + '">'; + } + } + templateVars.stylesheets = html; + + return wysihtml5.lang.string( + '<!DOCTYPE html><html><head>' + + '<meta charset="#{charset}">#{stylesheets}</head>' + + '<body></body></html>' + ).interpolate(templateVars); + }, + + /** + * Method to unset/override existing variables + * @example + * // Make cookie unreadable and unwritable + * this._unset(document, "cookie", "", true); + */ + _unset: function(object, property, value, setter) { + try { object[property] = value; } catch(e) {} + + try { object.__defineGetter__(property, function() { return value; }); } catch(e) {} + if (setter) { + try { object.__defineSetter__(property, function() {}); } catch(e) {} + } + + if (!wysihtml5.browser.crashesWhenDefineProperty(property)) { + try { + var config = { + get: function() { return value; } + }; + if (setter) { + config.set = function() {}; + } + Object.defineProperty(object, property, config); + } catch(e) {} + } + } + }); +})(wysihtml5); +(function() { + var mapping = { + "className": "class" + }; + wysihtml5.dom.setAttributes = function(attributes) { + return { + on: function(element) { + for (var i in attributes) { + element.setAttribute(mapping[i] || i, attributes[i]); + } + } + } + }; +})();wysihtml5.dom.setStyles = function(styles) { + return { + on: function(element) { + var style = element.style; + if (typeof(styles) === "string") { + style.cssText += ";" + styles; + return; + } + for (var i in styles) { + if (i === "float") { + style.cssFloat = styles[i]; + style.styleFloat = styles[i]; + } else { + style[i] = styles[i]; + } + } + } + }; +};/** + * Simulate HTML5 placeholder attribute + * + * Needed since + * - div[contentEditable] elements don't support it + * - older browsers (such as IE8 and Firefox 3.6) don't support it at all + * + * @param {Object} parent Instance of main wysihtml5.Editor class + * @param {Element} view Instance of wysihtml5.views.* class + * @param {String} placeholderText + * + * @example + * wysihtml.dom.simulatePlaceholder(this, composer, "Foobar"); + */ +(function(dom) { + dom.simulatePlaceholder = function(editor, view, placeholderText) { + var CLASS_NAME = "placeholder", + unset = function() { + if (view.hasPlaceholderSet()) { + view.clear(); + } + dom.removeClass(view.element, CLASS_NAME); + }, + set = function() { + if (view.isEmpty()) { + view.setValue(placeholderText); + dom.addClass(view.element, CLASS_NAME); + } + }; + + editor + .observe("set_placeholder", set) + .observe("unset_placeholder", unset) + .observe("focus:composer", unset) + .observe("paste:composer", unset) + .observe("blur:composer", set); + + set(); + }; +})(wysihtml5.dom); +(function(dom) { + var documentElement = document.documentElement; + if ("textContent" in documentElement) { + dom.setTextContent = function(element, text) { + element.textContent = text; + }; + + dom.getTextContent = function(element) { + return element.textContent; + }; + } else if ("innerText" in documentElement) { + dom.setTextContent = function(element, text) { + element.innerText = text; + }; + + dom.getTextContent = function(element) { + return element.innerText; + }; + } else { + dom.setTextContent = function(element, text) { + element.nodeValue = text; + }; + + dom.getTextContent = function(element) { + return element.nodeValue; + }; + } +})(wysihtml5.dom); + +/** + * Fix most common html formatting misbehaviors of browsers implementation when inserting + * content via copy & paste contentEditable + * + * @author Christopher Blum + */ +wysihtml5.quirks.cleanPastedHTML = (function() { + // TODO: We probably need more rules here + var defaultRules = { + // When pasting underlined links <a> into a contentEditable, IE thinks, it has to insert <u> to keep the styling + "a u": wysihtml5.dom.replaceWithChildNodes + }; + + function cleanPastedHTML(elementOrHtml, rules, context) { + rules = rules || defaultRules; + context = context || elementOrHtml.ownerDocument || document; + + var element, + isString = typeof(elementOrHtml) === "string", + method, + matches, + matchesLength, + i, + j = 0; + if (isString) { + element = wysihtml5.dom.getAsDom(elementOrHtml, context); + } else { + element = elementOrHtml; + } + + for (i in rules) { + matches = element.querySelectorAll(i); + method = rules[i]; + matchesLength = matches.length; + for (; j<matchesLength; j++) { + method(matches[j]); + } + } + + matches = elementOrHtml = rules = null; + + return isString ? element.innerHTML : element; + } + + return cleanPastedHTML; +})();/** + * IE and Opera leave an empty paragraph in the contentEditable element after clearing it + * + * @param {Object} contentEditableElement The contentEditable element to observe for clearing events + * @exaple + * wysihtml5.quirks.ensureProperClearing(myContentEditableElement); + */ +(function(wysihtml5) { + var dom = wysihtml5.dom; + + wysihtml5.quirks.ensureProperClearing = (function() { + var clearIfNecessary = function(event) { + var element = this; + setTimeout(function() { + var innerHTML = element.innerHTML.toLowerCase(); + if (innerHTML == "<p>&nbsp;</p>" || + innerHTML == "<p>&nbsp;</p><p>&nbsp;</p>") { + element.innerHTML = ""; + } + }, 0); + }; + + return function(composer) { + dom.observe(composer.element, ["cut", "keydown"], clearIfNecessary); + }; + })(); + + + + /** + * In Opera when the caret is in the first and only item of a list (<ul><li>|</li></ul>) and the list is the first child of the contentEditable element, it's impossible to delete the list by hitting backspace + * + * @param {Object} contentEditableElement The contentEditable element to observe for clearing events + * @exaple + * wysihtml5.quirks.ensureProperClearing(myContentEditableElement); + */ + wysihtml5.quirks.ensureProperClearingOfLists = (function() { + var ELEMENTS_THAT_CONTAIN_LI = ["OL", "UL", "MENU"]; + + var clearIfNecessary = function(element, contentEditableElement) { + if (!contentEditableElement.firstChild || !wysihtml5.lang.array(ELEMENTS_THAT_CONTAIN_LI).contains(contentEditableElement.firstChild.nodeName)) { + return; + } + + var list = dom.getParentElement(element, { nodeName: ELEMENTS_THAT_CONTAIN_LI }); + if (!list) { + return; + } + + var listIsFirstChildOfContentEditable = list == contentEditableElement.firstChild; + if (!listIsFirstChildOfContentEditable) { + return; + } + + var hasOnlyOneListItem = list.childNodes.length <= 1; + if (!hasOnlyOneListItem) { + return; + } + + var onlyListItemIsEmpty = list.firstChild ? list.firstChild.innerHTML === "" : true; + if (!onlyListItemIsEmpty) { + return; + } + + list.parentNode.removeChild(list); + }; + + return function(composer) { + dom.observe(composer.element, "keydown", function(event) { + if (event.keyCode !== wysihtml5.BACKSPACE_KEY) { + return; + } + + var element = composer.selection.getSelectedNode(); + clearIfNecessary(element, composer.element); + }); + }; + })(); + +})(wysihtml5); +// See https://bugzilla.mozilla.org/show_bug.cgi?id=664398 +// +// In Firefox this: +// var d = document.createElement("div"); +// d.innerHTML ='<a href="~"></a>'; +// d.innerHTML; +// will result in: +// <a href="%7E"></a> +// which is wrong +(function(wysihtml5) { + var TILDE_ESCAPED = "%7E"; + wysihtml5.quirks.getCorrectInnerHTML = function(element) { + var innerHTML = element.innerHTML; + if (innerHTML.indexOf(TILDE_ESCAPED) === -1) { + return innerHTML; + } + + var elementsWithTilde = element.querySelectorAll("[href*='~'], [src*='~']"), + url, + urlToSearch, + length, + i; + for (i=0, length=elementsWithTilde.length; i<length; i++) { + url = elementsWithTilde[i].href || elementsWithTilde[i].src; + urlToSearch = wysihtml5.lang.string(url).replace("~").by(TILDE_ESCAPED); + innerHTML = wysihtml5.lang.string(innerHTML).replace(urlToSearch).by(url); + } + return innerHTML; + }; +})(wysihtml5);/** + * Some browsers don't insert line breaks when hitting return in a contentEditable element + * - Opera & IE insert new <p> on return + * - Chrome & Safari insert new <div> on return + * - Firefox inserts <br> on return (yippie!) + * + * @param {Element} element + * + * @example + * wysihtml5.quirks.insertLineBreakOnReturn(element); + */ +(function(wysihtml5) { + var dom = wysihtml5.dom, + USE_NATIVE_LINE_BREAK_WHEN_CARET_INSIDE_TAGS = ["LI", "P", "H1", "H2", "H3", "H4", "H5", "H6"], + LIST_TAGS = ["UL", "OL", "MENU"]; + + wysihtml5.quirks.insertLineBreakOnReturn = function(composer) { + function unwrap(selectedNode) { + var parentElement = dom.getParentElement(selectedNode, { nodeName: ["P", "DIV"] }, 2); + if (!parentElement) { + return; + } + + var invisibleSpace = document.createTextNode(wysihtml5.INVISIBLE_SPACE); + dom.insert(invisibleSpace).before(parentElement); + dom.replaceWithChildNodes(parentElement); + composer.selection.selectNode(invisibleSpace); + } + + function keyDown(event) { + var keyCode = event.keyCode; + if (event.shiftKey || (keyCode !== wysihtml5.ENTER_KEY && keyCode !== wysihtml5.BACKSPACE_KEY)) { + return; + } + + var element = event.target, + selectedNode = composer.selection.getSelectedNode(), + blockElement = dom.getParentElement(selectedNode, { nodeName: USE_NATIVE_LINE_BREAK_WHEN_CARET_INSIDE_TAGS }, 4); + if (blockElement) { + // Some browsers create <p> elements after leaving a list + // check after keydown of backspace and return whether a <p> got inserted and unwrap it + if (blockElement.nodeName === "LI" && (keyCode === wysihtml5.ENTER_KEY || keyCode === wysihtml5.BACKSPACE_KEY)) { + setTimeout(function() { + var selectedNode = composer.selection.getSelectedNode(), + list, + div; + if (!selectedNode) { + return; + } + + list = dom.getParentElement(selectedNode, { + nodeName: LIST_TAGS + }, 2); + + if (list) { + return; + } + + unwrap(selectedNode); + }, 0); + } else if (blockElement.nodeName.match(/H[1-6]/) && keyCode === wysihtml5.ENTER_KEY) { + setTimeout(function() { + unwrap(composer.selection.getSelectedNode()); + }, 0); + } + return; + } + + if (keyCode === wysihtml5.ENTER_KEY && !wysihtml5.browser.insertsLineBreaksOnReturn()) { + composer.commands.exec("insertLineBreak"); + event.preventDefault(); + } + } + + // keypress doesn't fire when you hit backspace + dom.observe(composer.element.ownerDocument, "keydown", keyDown); + }; +})(wysihtml5);/** + * Force rerendering of a given element + * Needed to fix display misbehaviors of IE + * + * @param {Element} element The element object which needs to be rerendered + * @example + * wysihtml5.quirks.redraw(document.body); + */ +(function(wysihtml5) { + var CLASS_NAME = "wysihtml5-quirks-redraw"; + + wysihtml5.quirks.redraw = function(element) { + wysihtml5.dom.addClass(element, CLASS_NAME); + wysihtml5.dom.removeClass(element, CLASS_NAME); + + // Following hack is needed for firefox to make sure that image resize handles are properly removed + try { + var doc = element.ownerDocument; + doc.execCommand("italic", false, null); + doc.execCommand("italic", false, null); + } catch(e) {} + }; +})(wysihtml5);/** + * Selection API + * + * @example + * var selection = new wysihtml5.Selection(editor); + */ +(function(wysihtml5) { + var dom = wysihtml5.dom; + + function _getCumulativeOffsetTop(element) { + var top = 0; + if (element.parentNode) { + do { + top += element.offsetTop || 0; + element = element.offsetParent; + } while (element); + } + return top; + } + + wysihtml5.Selection = Base.extend( + /** @scope wysihtml5.Selection.prototype */ { + constructor: function(editor) { + // Make sure that our external range library is initialized + window.rangy.init(); + + this.editor = editor; + this.composer = editor.composer; + this.doc = this.composer.doc; + }, + + /** + * Get the current selection as a bookmark to be able to later restore it + * + * @return {Object} An object that represents the current selection + */ + getBookmark: function() { + var range = this.getRange(); + return range && range.cloneRange(); + }, + + /** + * Restore a selection retrieved via wysihtml5.Selection.prototype.getBookmark + * + * @param {Object} bookmark An object that represents the current selection + */ + setBookmark: function(bookmark) { + if (!bookmark) { + return; + } + + this.setSelection(bookmark); + }, + + /** + * Set the caret in front of the given node + * + * @param {Object} node The element or text node where to position the caret in front of + * @example + * selection.setBefore(myElement); + */ + setBefore: function(node) { + var range = rangy.createRange(this.doc); + range.setStartBefore(node); + range.setEndBefore(node); + return this.setSelection(range); + }, + + /** + * Set the caret after the given node + * + * @param {Object} node The element or text node where to position the caret in front of + * @example + * selection.setBefore(myElement); + */ + setAfter: function(node) { + var range = rangy.createRange(this.doc); + range.setStartAfter(node); + range.setEndAfter(node); + return this.setSelection(range); + }, + + /** + * Ability to select/mark nodes + * + * @param {Element} node The node/element to select + * @example + * selection.selectNode(document.getElementById("my-image")); + */ + selectNode: function(node) { + var range = rangy.createRange(this.doc), + isElement = node.nodeType === wysihtml5.ELEMENT_NODE, + canHaveHTML = "canHaveHTML" in node ? node.canHaveHTML : (node.nodeName !== "IMG"), + content = isElement ? node.innerHTML : node.data, + isEmpty = (content === "" || content === wysihtml5.INVISIBLE_SPACE), + displayStyle = dom.getStyle("display").from(node), + isBlockElement = (displayStyle === "block" || displayStyle === "list-item"); + + if (isEmpty && isElement && canHaveHTML) { + // Make sure that caret is visible in node by inserting a zero width no breaking space + try { node.innerHTML = wysihtml5.INVISIBLE_SPACE; } catch(e) {} + } + + if (canHaveHTML) { + range.selectNodeContents(node); + } else { + range.selectNode(node); + } + + if (canHaveHTML && isEmpty && isElement) { + range.collapse(isBlockElement); + } else if (canHaveHTML && isEmpty) { + range.setStartAfter(node); + range.setEndAfter(node); + } + + this.setSelection(range); + }, + + /** + * Get the node which contains the selection + * + * @param {Boolean} [controlRange] (only IE) Whether it should return the selected ControlRange element when the selection type is a "ControlRange" + * @return {Object} The node that contains the caret + * @example + * var nodeThatContainsCaret = selection.getSelectedNode(); + */ + getSelectedNode: function(controlRange) { + var selection, + range; + + if (controlRange && this.doc.selection && this.doc.selection.type === "Control") { + range = this.doc.selection.createRange(); + if (range && range.length) { + return range.item(0); + } + } + + selection = this.getSelection(this.doc); + if (selection.focusNode === selection.anchorNode) { + return selection.focusNode; + } else { + range = this.getRange(this.doc); + return range ? range.commonAncestorContainer : this.doc.body; + } + }, + + executeAndRestore: function(method, restoreScrollPosition) { + var body = this.doc.body, + oldScrollTop = restoreScrollPosition && body.scrollTop, + oldScrollLeft = restoreScrollPosition && body.scrollLeft, + className = "_wysihtml5-temp-placeholder", + placeholderHTML = '<span class="' + className + '">' + wysihtml5.INVISIBLE_SPACE + '</span>', + range = this.getRange(this.doc), + newRange; + + // Nothing selected, execute and say goodbye + if (!range) { + method(body, body); + return; + } + + var node = range.createContextualFragment(placeholderHTML); + range.insertNode(node); + + // Make sure that a potential error doesn't cause our placeholder element to be left as a placeholder + try { + method(range.startContainer, range.endContainer); + } catch(e3) { + setTimeout(function() { throw e3; }, 0); + } + + caretPlaceholder = this.doc.querySelector("." + className); + if (caretPlaceholder) { + newRange = rangy.createRange(this.doc); + newRange.selectNode(caretPlaceholder); + newRange.deleteContents(); + this.setSelection(newRange); + } else { + // fallback for when all hell breaks loose + body.focus(); + } + + if (restoreScrollPosition) { + body.scrollTop = oldScrollTop; + body.scrollLeft = oldScrollLeft; + } + + // Remove it again, just to make sure that the placeholder is definitely out of the dom tree + try { + caretPlaceholder.parentNode.removeChild(caretPlaceholder); + } catch(e4) {} + }, + + /** + * Different approach of preserving the selection (doesn't modify the dom) + * Takes all text nodes in the selection and saves the selection position in the first and last one + */ + executeAndRestoreSimple: function(method) { + var range = this.getRange(), + body = this.doc.body, + newRange, + firstNode, + lastNode, + textNodes, + rangeBackup; + + // Nothing selected, execute and say goodbye + if (!range) { + method(body, body); + return; + } + + textNodes = range.getNodes([3]); + firstNode = textNodes[0] || range.startContainer; + lastNode = textNodes[textNodes.length - 1] || range.endContainer; + + rangeBackup = { + collapsed: range.collapsed, + startContainer: firstNode, + startOffset: firstNode === range.startContainer ? range.startOffset : 0, + endContainer: lastNode, + endOffset: lastNode === range.endContainer ? range.endOffset : lastNode.length + }; + + try { + method(range.startContainer, range.endContainer); + } catch(e) { + setTimeout(function() { throw e; }, 0); + } + + newRange = rangy.createRange(this.doc); + try { newRange.setStart(rangeBackup.startContainer, rangeBackup.startOffset); } catch(e1) {} + try { newRange.setEnd(rangeBackup.endContainer, rangeBackup.endOffset); } catch(e2) {} + try { this.setSelection(newRange); } catch(e3) {} + }, + + /** + * Insert html at the caret position and move the cursor after the inserted html + * + * @param {String} html HTML string to insert + * @example + * selection.insertHTML("<p>foobar</p>"); + */ + insertHTML: function(html) { + var range = rangy.createRange(this.doc), + node = range.createContextualFragment(html), + lastChild = node.lastChild; + this.insertNode(node); + if (lastChild) { + this.setAfter(lastChild); + } + }, + + /** + * Insert a node at the caret position and move the cursor behind it + * + * @param {Object} node HTML string to insert + * @example + * selection.insertNode(document.createTextNode("foobar")); + */ + insertNode: function(node) { + var range = this.getRange(); + if (range) { + range.insertNode(node); + } + }, + + /** + * Wraps current selection with the given node + * + * @param {Object} node The node to surround the selected elements with + */ + surround: function(node) { + var range = this.getRange(); + if (!range) { + return; + } + + try { + // This only works when the range boundaries are not overlapping other elements + range.surroundContents(node); + this.selectNode(node); + } catch(e) { + // fallback + node.appendChild(range.extractContents()); + range.insertNode(node); + } + }, + + /** + * Scroll the current caret position into the view + * FIXME: This is a bit hacky, there might be a smarter way of doing this + * + * @example + * selection.scrollIntoView(); + */ + scrollIntoView: function() { + var doc = this.doc, + hasScrollBars = doc.documentElement.scrollHeight > doc.documentElement.offsetHeight, + tempElement = doc._wysihtml5ScrollIntoViewElement = doc._wysihtml5ScrollIntoViewElement || (function() { + var element = doc.createElement("span"); + // The element needs content in order to be able to calculate it's position properly + element.innerHTML = wysihtml5.INVISIBLE_SPACE; + return element; + })(), + offsetTop; + + if (hasScrollBars) { + this.insertNode(tempElement); + offsetTop = _getCumulativeOffsetTop(tempElement); + tempElement.parentNode.removeChild(tempElement); + if (offsetTop > doc.body.scrollTop) { + doc.body.scrollTop = offsetTop; + } + } + }, + + /** + * Select line where the caret is in + */ + selectLine: function() { + if (wysihtml5.browser.supportsSelectionModify()) { + this._selectLine_W3C(); + } else if (this.doc.selection) { + this._selectLine_MSIE(); + } + }, + + /** + * See https://developer.mozilla.org/en/DOM/Selection/modify + */ + _selectLine_W3C: function() { + var win = this.doc.defaultView, + selection = win.getSelection(); + selection.modify("extend", "left", "lineboundary"); + selection.modify("extend", "right", "lineboundary"); + }, + + _selectLine_MSIE: function() { + var range = this.doc.selection.createRange(), + rangeTop = range.boundingTop, + rangeHeight = range.boundingHeight, + scrollWidth = this.doc.body.scrollWidth, + rangeBottom, + rangeEnd, + measureNode, + i, + j; + + if (!range.moveToPoint) { + return; + } + + if (rangeTop === 0) { + // Don't know why, but when the selection ends at the end of a line + // range.boundingTop is 0 + measureNode = this.doc.createElement("span"); + this.insertNode(measureNode); + rangeTop = measureNode.offsetTop; + measureNode.parentNode.removeChild(measureNode); + } + + rangeTop += 1; + + for (i=-10; i<scrollWidth; i+=2) { + try { + range.moveToPoint(i, rangeTop); + break; + } catch(e1) {} + } + + // Investigate the following in order to handle multi line selections + // rangeBottom = rangeTop + (rangeHeight ? (rangeHeight - 1) : 0); + rangeBottom = rangeTop; + rangeEnd = this.doc.selection.createRange(); + for (j=scrollWidth; j>=0; j--) { + try { + rangeEnd.moveToPoint(j, rangeBottom); + break; + } catch(e2) {} + } + + range.setEndPoint("EndToEnd", rangeEnd); + range.select(); + }, + + getText: function() { + var selection = this.getSelection(); + return selection ? selection.toString() : ""; + }, + + getNodes: function(nodeType, filter) { + var range = this.getRange(); + if (range) { + return range.getNodes([nodeType], filter); + } else { + return []; + } + }, + + getRange: function() { + var selection = this.getSelection(); + return selection && selection.rangeCount && selection.getRangeAt(0); + }, + + getSelection: function() { + return rangy.getSelection(this.doc.defaultView || this.doc.parentWindow); + }, + + setSelection: function(range) { + var win = this.doc.defaultView || this.doc.parentWindow, + selection = rangy.getSelection(win); + return selection.setSingleRange(range); + } + }); + +})(wysihtml5); +/** + * Inspired by the rangy CSS Applier module written by Tim Down and licensed under the MIT license. + * http://code.google.com/p/rangy/ + * + * changed in order to be able ... + * - to use custom tags + * - to detect and replace similar css classes via reg exp + */ +(function(wysihtml5, rangy) { + var defaultTagName = "span"; + + var REG_EXP_WHITE_SPACE = /\s+/g; + + function hasClass(el, cssClass, regExp) { + if (!el.className) { + return false; + } + + var matchingClassNames = el.className.match(regExp) || []; + return matchingClassNames[matchingClassNames.length - 1] === cssClass; + } + + function addClass(el, cssClass, regExp) { + if (el.className) { + removeClass(el, regExp); + el.className += " " + cssClass; + } else { + el.className = cssClass; + } + } + + function removeClass(el, regExp) { + if (el.className) { + el.className = el.className.replace(regExp, ""); + } + } + + function hasSameClasses(el1, el2) { + return el1.className.replace(REG_EXP_WHITE_SPACE, " ") == el2.className.replace(REG_EXP_WHITE_SPACE, " "); + } + + function replaceWithOwnChildren(el) { + var parent = el.parentNode; + while (el.firstChild) { + parent.insertBefore(el.firstChild, el); + } + parent.removeChild(el); + } + + function elementsHaveSameNonClassAttributes(el1, el2) { + if (el1.attributes.length != el2.attributes.length) { + return false; + } + for (var i = 0, len = el1.attributes.length, attr1, attr2, name; i < len; ++i) { + attr1 = el1.attributes[i]; + name = attr1.name; + if (name != "class") { + attr2 = el2.attributes.getNamedItem(name); + if (attr1.specified != attr2.specified) { + return false; + } + if (attr1.specified && attr1.nodeValue !== attr2.nodeValue) { + return false; + } + } + } + return true; + } + + function isSplitPoint(node, offset) { + if (rangy.dom.isCharacterDataNode(node)) { + if (offset == 0) { + return !!node.previousSibling; + } else if (offset == node.length) { + return !!node.nextSibling; + } else { + return true; + } + } + + return offset > 0 && offset < node.childNodes.length; + } + + function splitNodeAt(node, descendantNode, descendantOffset) { + var newNode; + if (rangy.dom.isCharacterDataNode(descendantNode)) { + if (descendantOffset == 0) { + descendantOffset = rangy.dom.getNodeIndex(descendantNode); + descendantNode = descendantNode.parentNode; + } else if (descendantOffset == descendantNode.length) { + descendantOffset = rangy.dom.getNodeIndex(descendantNode) + 1; + descendantNode = descendantNode.parentNode; + } else { + newNode = rangy.dom.splitDataNode(descendantNode, descendantOffset); + } + } + if (!newNode) { + newNode = descendantNode.cloneNode(false); + if (newNode.id) { + newNode.removeAttribute("id"); + } + var child; + while ((child = descendantNode.childNodes[descendantOffset])) { + newNode.appendChild(child); + } + rangy.dom.insertAfter(newNode, descendantNode); + } + return (descendantNode == node) ? newNode : splitNodeAt(node, newNode.parentNode, rangy.dom.getNodeIndex(newNode)); + } + + function Merge(firstNode) { + this.isElementMerge = (firstNode.nodeType == wysihtml5.ELEMENT_NODE); + this.firstTextNode = this.isElementMerge ? firstNode.lastChild : firstNode; + this.textNodes = [this.firstTextNode]; + } + + Merge.prototype = { + doMerge: function() { + var textBits = [], textNode, parent, text; + for (var i = 0, len = this.textNodes.length; i < len; ++i) { + textNode = this.textNodes[i]; + parent = textNode.parentNode; + textBits[i] = textNode.data; + if (i) { + parent.removeChild(textNode); + if (!parent.hasChildNodes()) { + parent.parentNode.removeChild(parent); + } + } + } + this.firstTextNode.data = text = textBits.join(""); + return text; + }, + + getLength: function() { + var i = this.textNodes.length, len = 0; + while (i--) { + len += this.textNodes[i].length; + } + return len; + }, + + toString: function() { + var textBits = []; + for (var i = 0, len = this.textNodes.length; i < len; ++i) { + textBits[i] = "'" + this.textNodes[i].data + "'"; + } + return "[Merge(" + textBits.join(",") + ")]"; + } + }; + + function HTMLApplier(tagNames, cssClass, similarClassRegExp, normalize) { + this.tagNames = tagNames || [defaultTagName]; + this.cssClass = cssClass || ""; + this.similarClassRegExp = similarClassRegExp; + this.normalize = normalize; + this.applyToAnyTagName = false; + } + + HTMLApplier.prototype = { + getAncestorWithClass: function(node) { + var cssClassMatch; + while (node) { + cssClassMatch = this.cssClass ? hasClass(node, this.cssClass, this.similarClassRegExp) : true; + if (node.nodeType == wysihtml5.ELEMENT_NODE && rangy.dom.arrayContains(this.tagNames, node.tagName.toLowerCase()) && cssClassMatch) { + return node; + } + node = node.parentNode; + } + return false; + }, + + // Normalizes nodes after applying a CSS class to a Range. + postApply: function(textNodes, range) { + var firstNode = textNodes[0], lastNode = textNodes[textNodes.length - 1]; + + var merges = [], currentMerge; + + var rangeStartNode = firstNode, rangeEndNode = lastNode; + var rangeStartOffset = 0, rangeEndOffset = lastNode.length; + + var textNode, precedingTextNode; + + for (var i = 0, len = textNodes.length; i < len; ++i) { + textNode = textNodes[i]; + precedingTextNode = this.getAdjacentMergeableTextNode(textNode.parentNode, false); + if (precedingTextNode) { + if (!currentMerge) { + currentMerge = new Merge(precedingTextNode); + merges.push(currentMerge); + } + currentMerge.textNodes.push(textNode); + if (textNode === firstNode) { + rangeStartNode = currentMerge.firstTextNode; + rangeStartOffset = rangeStartNode.length; + } + if (textNode === lastNode) { + rangeEndNode = currentMerge.firstTextNode; + rangeEndOffset = currentMerge.getLength(); + } + } else { + currentMerge = null; + } + } + + // Test whether the first node after the range needs merging + var nextTextNode = this.getAdjacentMergeableTextNode(lastNode.parentNode, true); + if (nextTextNode) { + if (!currentMerge) { + currentMerge = new Merge(lastNode); + merges.push(currentMerge); + } + currentMerge.textNodes.push(nextTextNode); + } + + // Do the merges + if (merges.length) { + for (i = 0, len = merges.length; i < len; ++i) { + merges[i].doMerge(); + } + // Set the range boundaries + range.setStart(rangeStartNode, rangeStartOffset); + range.setEnd(rangeEndNode, rangeEndOffset); + } + }, + + getAdjacentMergeableTextNode: function(node, forward) { + var isTextNode = (node.nodeType == wysihtml5.TEXT_NODE); + var el = isTextNode ? node.parentNode : node; + var adjacentNode; + var propName = forward ? "nextSibling" : "previousSibling"; + if (isTextNode) { + // Can merge if the node's previous/next sibling is a text node + adjacentNode = node[propName]; + if (adjacentNode && adjacentNode.nodeType == wysihtml5.TEXT_NODE) { + return adjacentNode; + } + } else { + // Compare element with its sibling + adjacentNode = el[propName]; + if (adjacentNode && this.areElementsMergeable(node, adjacentNode)) { + return adjacentNode[forward ? "firstChild" : "lastChild"]; + } + } + return null; + }, + + areElementsMergeable: function(el1, el2) { + return rangy.dom.arrayContains(this.tagNames, (el1.tagName || "").toLowerCase()) + && rangy.dom.arrayContains(this.tagNames, (el2.tagName || "").toLowerCase()) + && hasSameClasses(el1, el2) + && elementsHaveSameNonClassAttributes(el1, el2); + }, + + createContainer: function(doc) { + var el = doc.createElement(this.tagNames[0]); + if (this.cssClass) { + el.className = this.cssClass; + } + return el; + }, + + applyToTextNode: function(textNode) { + var parent = textNode.parentNode; + if (parent.childNodes.length == 1 && rangy.dom.arrayContains(this.tagNames, parent.tagName.toLowerCase())) { + if (this.cssClass) { + addClass(parent, this.cssClass, this.similarClassRegExp); + } + } else { + var el = this.createContainer(rangy.dom.getDocument(textNode)); + textNode.parentNode.insertBefore(el, textNode); + el.appendChild(textNode); + } + }, + + isRemovable: function(el) { + return rangy.dom.arrayContains(this.tagNames, el.tagName.toLowerCase()) && wysihtml5.lang.string(el.className).trim() == this.cssClass; + }, + + undoToTextNode: function(textNode, range, ancestorWithClass) { + if (!range.containsNode(ancestorWithClass)) { + // Split out the portion of the ancestor from which we can remove the CSS class + var ancestorRange = range.cloneRange(); + ancestorRange.selectNode(ancestorWithClass); + + if (ancestorRange.isPointInRange(range.endContainer, range.endOffset) && isSplitPoint(range.endContainer, range.endOffset)) { + splitNodeAt(ancestorWithClass, range.endContainer, range.endOffset); + range.setEndAfter(ancestorWithClass); + } + if (ancestorRange.isPointInRange(range.startContainer, range.startOffset) && isSplitPoint(range.startContainer, range.startOffset)) { + ancestorWithClass = splitNodeAt(ancestorWithClass, range.startContainer, range.startOffset); + } + } + + if (this.similarClassRegExp) { + removeClass(ancestorWithClass, this.similarClassRegExp); + } + if (this.isRemovable(ancestorWithClass)) { + replaceWithOwnChildren(ancestorWithClass); + } + }, + + applyToRange: function(range) { + var textNodes = range.getNodes([wysihtml5.TEXT_NODE]); + if (!textNodes.length) { + try { + var node = this.createContainer(range.endContainer.ownerDocument); + range.surroundContents(node); + this.selectNode(range, node); + return; + } catch(e) {} + } + + range.splitBoundaries(); + textNodes = range.getNodes([wysihtml5.TEXT_NODE]); + + if (textNodes.length) { + var textNode; + + for (var i = 0, len = textNodes.length; i < len; ++i) { + textNode = textNodes[i]; + if (!this.getAncestorWithClass(textNode)) { + this.applyToTextNode(textNode); + } + } + + range.setStart(textNodes[0], 0); + textNode = textNodes[textNodes.length - 1]; + range.setEnd(textNode, textNode.length); + + if (this.normalize) { + this.postApply(textNodes, range); + } + } + }, + + undoToRange: function(range) { + var textNodes = range.getNodes([wysihtml5.TEXT_NODE]), textNode, ancestorWithClass; + if (textNodes.length) { + range.splitBoundaries(); + textNodes = range.getNodes([wysihtml5.TEXT_NODE]); + } else { + var doc = range.endContainer.ownerDocument, + node = doc.createTextNode(wysihtml5.INVISIBLE_SPACE); + range.insertNode(node); + range.selectNode(node); + textNodes = [node]; + } + + for (var i = 0, len = textNodes.length; i < len; ++i) { + textNode = textNodes[i]; + ancestorWithClass = this.getAncestorWithClass(textNode); + if (ancestorWithClass) { + this.undoToTextNode(textNode, range, ancestorWithClass); + } + } + + if (len == 1) { + this.selectNode(range, textNodes[0]); + } else { + range.setStart(textNodes[0], 0); + textNode = textNodes[textNodes.length - 1]; + range.setEnd(textNode, textNode.length); + + if (this.normalize) { + this.postApply(textNodes, range); + } + } + }, + + selectNode: function(range, node) { + var isElement = node.nodeType === wysihtml5.ELEMENT_NODE, + canHaveHTML = "canHaveHTML" in node ? node.canHaveHTML : true, + content = isElement ? node.innerHTML : node.data, + isEmpty = (content === "" || content === wysihtml5.INVISIBLE_SPACE); + + if (isEmpty && isElement && canHaveHTML) { + // Make sure that caret is visible in node by inserting a zero width no breaking space + try { node.innerHTML = wysihtml5.INVISIBLE_SPACE; } catch(e) {} + } + range.selectNodeContents(node); + if (isEmpty && isElement) { + range.collapse(false); + } else if (isEmpty) { + range.setStartAfter(node); + range.setEndAfter(node); + } + }, + + getTextSelectedByRange: function(textNode, range) { + var textRange = range.cloneRange(); + textRange.selectNodeContents(textNode); + + var intersectionRange = textRange.intersection(range); + var text = intersectionRange ? intersectionRange.toString() : ""; + textRange.detach(); + + return text; + }, + + isAppliedToRange: function(range) { + var ancestors = [], + ancestor, + textNodes = range.getNodes([wysihtml5.TEXT_NODE]); + if (!textNodes.length) { + ancestor = this.getAncestorWithClass(range.startContainer); + return ancestor ? [ancestor] : false; + } + + for (var i = 0, len = textNodes.length, selectedText; i < len; ++i) { + selectedText = this.getTextSelectedByRange(textNodes[i], range); + ancestor = this.getAncestorWithClass(textNodes[i]); + if (selectedText != "" && !ancestor) { + return false; + } else { + ancestors.push(ancestor); + } + } + return ancestors; + }, + + toggleRange: function(range) { + if (this.isAppliedToRange(range)) { + this.undoToRange(range); + } else { + this.applyToRange(range); + } + } + }; + + wysihtml5.selection.HTMLApplier = HTMLApplier; + +})(wysihtml5, rangy);/** + * Rich Text Query/Formatting Commands + * + * @example + * var commands = new wysihtml5.Commands(editor); + */ +wysihtml5.Commands = Base.extend( + /** @scope wysihtml5.Commands.prototype */ { + constructor: function(editor) { + this.editor = editor; + this.composer = editor.composer; + this.doc = this.composer.doc; + }, + + /** + * Check whether the browser supports the given command + * + * @param {String} command The command string which to check (eg. "bold", "italic", "insertUnorderedList") + * @example + * commands.supports("createLink"); + */ + support: function(command) { + return wysihtml5.browser.supportsCommand(this.doc, command); + }, + + /** + * Check whether the browser supports the given command + * + * @param {String} command The command string which to execute (eg. "bold", "italic", "insertUnorderedList") + * @param {String} [value] The command value parameter, needed for some commands ("createLink", "insertImage", ...), optional for commands that don't require one ("bold", "underline", ...) + * @example + * commands.exec("insertImage", "http://a1.twimg.com/profile_images/113868655/schrei_twitter_reasonably_small.jpg"); + */ + exec: function(command, value) { + var obj = wysihtml5.commands[command], + args = wysihtml5.lang.array(arguments).get(), + method = obj && obj.exec, + result = null; + + this.editor.fire("beforecommand:composer"); + + if (method) { + args.unshift(this.composer); + result = method.apply(obj, args); + } else { + try { + // try/catch for buggy firefox + result = this.doc.execCommand(command, false, value); + } catch(e) {} + } + + this.editor.fire("aftercommand:composer"); + return result; + }, + + /** + * Check whether the current command is active + * If the caret is within a bold text, then calling this with command "bold" should return true + * + * @param {String} command The command string which to check (eg. "bold", "italic", "insertUnorderedList") + * @param {String} [commandValue] The command value parameter (eg. for "insertImage" the image src) + * @return {Boolean} Whether the command is active + * @example + * var isCurrentSelectionBold = commands.state("bold"); + */ + state: function(command, commandValue) { + var obj = wysihtml5.commands[command], + args = wysihtml5.lang.array(arguments).get(), + method = obj && obj.state; + if (method) { + args.unshift(this.composer); + return method.apply(obj, args); + } else { + try { + // try/catch for buggy firefox + return this.doc.queryCommandState(command); + } catch(e) { + return false; + } + } + }, + + /** + * Get the current command's value + * + * @param {String} command The command string which to check (eg. "formatBlock") + * @return {String} The command value + * @example + * var currentBlockElement = commands.value("formatBlock"); + */ + value: function(command) { + var obj = wysihtml5.commands[command], + method = obj && obj.value; + if (method) { + return method.call(obj, this.composer, command); + } else { + try { + // try/catch for buggy firefox + return this.doc.queryCommandValue(command); + } catch(e) { + return null; + } + } + } +}); +(function(wysihtml5) { + var undef; + + wysihtml5.commands.bold = { + exec: function(composer, command) { + return wysihtml5.commands.formatInline.exec(composer, command, "b"); + }, + + state: function(composer, command, color) { + // element.ownerDocument.queryCommandState("bold") results: + // firefox: only <b> + // chrome: <b>, <strong>, <h1>, <h2>, ... + // ie: <b>, <strong> + // opera: <b>, <strong> + return wysihtml5.commands.formatInline.state(composer, command, "b"); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5); + +(function(wysihtml5) { + var undef, + NODE_NAME = "A", + dom = wysihtml5.dom; + + function _removeFormat(composer, anchors) { + var length = anchors.length, + i = 0, + anchor, + codeElement, + textContent; + for (; i<length; i++) { + anchor = anchors[i]; + codeElement = dom.getParentElement(anchor, { nodeName: "code" }); + textContent = dom.getTextContent(anchor); + + // if <a> contains url-like text content, rename it to <code> to prevent re-autolinking + // else replace <a> with its childNodes + if (textContent.match(dom.autoLink.URL_REG_EXP) && !codeElement) { + // <code> element is used to prevent later auto-linking of the content + codeElement = dom.renameElement(anchor, "code"); + } else { + dom.replaceWithChildNodes(anchor); + } + } + } + + function _format(composer, attributes) { + var doc = composer.doc, + tempClass = "_wysihtml5-temp-" + (+new Date()), + tempClassRegExp = /non-matching-class/g, + i = 0, + length, + anchors, + anchor, + hasElementChild, + isEmpty, + elementToSetCaretAfter, + textContent, + whiteSpace, + j; + wysihtml5.commands.formatInline.exec(composer, undef, NODE_NAME, tempClass, tempClassRegExp); + anchors = doc.querySelectorAll(NODE_NAME + "." + tempClass); + length = anchors.length; + for (; i<length; i++) { + anchor = anchors[i]; + anchor.removeAttribute("class"); + for (j in attributes) { + anchor.setAttribute(j, attributes[j]); + } + } + + elementToSetCaretAfter = anchor; + if (length === 1) { + textContent = dom.getTextContent(anchor); + hasElementChild = !!anchor.querySelector("*"); + isEmpty = textContent === "" || textContent === wysihtml5.INVISIBLE_SPACE; + if (!hasElementChild && isEmpty) { + dom.setTextContent(anchor, attributes.text || anchor.href); + whiteSpace = doc.createTextNode(" "); + composer.selection.setAfter(anchor); + composer.selection.insertNode(whiteSpace); + elementToSetCaretAfter = whiteSpace; + } + } + composer.selection.setAfter(elementToSetCaretAfter); + } + + wysihtml5.commands.createLink = { + /** + * TODO: Use HTMLApplier or formatInline here + * + * Turns selection into a link + * If selection is already a link, it removes the link and wraps it with a <code> element + * The <code> element is needed to avoid auto linking + * + * @example + * // either ... + * wysihtml5.commands.createLink.exec(composer, "createLink", "http://www.google.de"); + * // ... or ... + * wysihtml5.commands.createLink.exec(composer, "createLink", { href: "http://www.google.de", target: "_blank" }); + */ + exec: function(composer, command, value) { + var anchors = this.state(composer, command); + if (anchors) { + // Selection contains links + composer.selection.executeAndRestore(function() { + _removeFormat(composer, anchors); + }); + } else { + // Create links + value = typeof(value) === "object" ? value : { href: value }; + _format(composer, value); + } + }, + + state: function(composer, command) { + return wysihtml5.commands.formatInline.state(composer, command, "A"); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);/** + * document.execCommand("fontSize") will create either inline styles (firefox, chrome) or use font tags + * which we don't want + * Instead we set a css class + */ +(function(wysihtml5) { + var undef, + REG_EXP = /wysiwyg-font-size-[a-z\-]+/g; + + wysihtml5.commands.fontSize = { + exec: function(composer, command, size) { + return wysihtml5.commands.formatInline.exec(composer, command, "span", "wysiwyg-font-size-" + size, REG_EXP); + }, + + state: function(composer, command, size) { + return wysihtml5.commands.formatInline.state(composer, command, "span", "wysiwyg-font-size-" + size, REG_EXP); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5); +/** + * document.execCommand("foreColor") will create either inline styles (firefox, chrome) or use font tags + * which we don't want + * Instead we set a css class + */ +(function(wysihtml5) { + var undef, + REG_EXP = /wysiwyg-color-[a-z]+/g; + + wysihtml5.commands.foreColor = { + exec: function(composer, command, color) { + return wysihtml5.commands.formatInline.exec(composer, command, "span", "wysiwyg-color-" + color, REG_EXP); + }, + + state: function(composer, command, color) { + return wysihtml5.commands.formatInline.state(composer, command, "span", "wysiwyg-color-" + color, REG_EXP); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef, + dom = wysihtml5.dom, + DEFAULT_NODE_NAME = "DIV", + // Following elements are grouped + // when the caret is within a H1 and the H4 is invoked, the H1 should turn into H4 + // instead of creating a H4 within a H1 which would result in semantically invalid html + BLOCK_ELEMENTS_GROUP = ["H1", "H2", "H3", "H4", "H5", "H6", "P", "BLOCKQUOTE", DEFAULT_NODE_NAME]; + + /** + * Remove similiar classes (based on classRegExp) + * and add the desired class name + */ + function _addClass(element, className, classRegExp) { + if (element.className) { + _removeClass(element, classRegExp); + element.className += " " + className; + } else { + element.className = className; + } + } + + function _removeClass(element, classRegExp) { + element.className = element.className.replace(classRegExp, ""); + } + + /** + * Check whether given node is a text node and whether it's empty + */ + function _isBlankTextNode(node) { + return node.nodeType === wysihtml5.TEXT_NODE && !wysihtml5.lang.string(node.data).trim(); + } + + /** + * Returns previous sibling node that is not a blank text node + */ + function _getPreviousSiblingThatIsNotBlank(node) { + var previousSibling = node.previousSibling; + while (previousSibling && _isBlankTextNode(previousSibling)) { + previousSibling = previousSibling.previousSibling; + } + return previousSibling; + } + + /** + * Returns next sibling node that is not a blank text node + */ + function _getNextSiblingThatIsNotBlank(node) { + var nextSibling = node.nextSibling; + while (nextSibling && _isBlankTextNode(nextSibling)) { + nextSibling = nextSibling.nextSibling; + } + return nextSibling; + } + + /** + * Adds line breaks before and after the given node if the previous and next siblings + * aren't already causing a visual line break (block element or <br>) + */ + function _addLineBreakBeforeAndAfter(node) { + var doc = node.ownerDocument, + nextSibling = _getNextSiblingThatIsNotBlank(node), + previousSibling = _getPreviousSiblingThatIsNotBlank(node); + + if (nextSibling && !_isLineBreakOrBlockElement(nextSibling)) { + node.parentNode.insertBefore(doc.createElement("br"), nextSibling); + } + if (previousSibling && !_isLineBreakOrBlockElement(previousSibling)) { + node.parentNode.insertBefore(doc.createElement("br"), node); + } + } + + /** + * Removes line breaks before and after the given node + */ + function _removeLineBreakBeforeAndAfter(node) { + var nextSibling = _getNextSiblingThatIsNotBlank(node), + previousSibling = _getPreviousSiblingThatIsNotBlank(node); + + if (nextSibling && _isLineBreak(nextSibling)) { + nextSibling.parentNode.removeChild(nextSibling); + } + if (previousSibling && _isLineBreak(previousSibling)) { + previousSibling.parentNode.removeChild(previousSibling); + } + } + + function _removeLastChildIfLineBreak(node) { + var lastChild = node.lastChild; + if (lastChild && _isLineBreak(lastChild)) { + lastChild.parentNode.removeChild(lastChild); + } + } + + function _isLineBreak(node) { + return node.nodeName === "BR"; + } + + /** + * Checks whether the elment causes a visual line break + * (<br> or block elements) + */ + function _isLineBreakOrBlockElement(element) { + if (_isLineBreak(element)) { + return true; + } + + if (dom.getStyle("display").from(element) === "block") { + return true; + } + + return false; + } + + /** + * Execute native query command + * and if necessary modify the inserted node's className + */ + function _execCommand(doc, command, nodeName, className) { + if (className) { + var eventListener = dom.observe(doc, "DOMNodeInserted", function(event) { + var target = event.target, + displayStyle; + if (target.nodeType !== wysihtml5.ELEMENT_NODE) { + return; + } + displayStyle = dom.getStyle("display").from(target); + if (displayStyle.substr(0, 6) !== "inline") { + // Make sure that only block elements receive the given class + target.className += " " + className; + } + }); + } + doc.execCommand(command, false, nodeName); + if (eventListener) { + eventListener.stop(); + } + } + + function _selectLineAndWrap(composer, element) { + composer.selection.selectLine(); + composer.selection.surround(element); + _removeLineBreakBeforeAndAfter(element); + _removeLastChildIfLineBreak(element); + composer.selection.selectNode(element); + } + + function _hasClasses(element) { + return !!wysihtml5.lang.string(element.className).trim(); + } + + wysihtml5.commands.formatBlock = { + exec: function(composer, command, nodeName, className, classRegExp) { + var doc = composer.doc, + blockElement = this.state(composer, command, nodeName, className, classRegExp), + selectedNode; + + nodeName = typeof(nodeName) === "string" ? nodeName.toUpperCase() : nodeName; + + if (blockElement) { + composer.selection.executeAndRestoreSimple(function() { + if (classRegExp) { + _removeClass(blockElement, classRegExp); + } + var hasClasses = _hasClasses(blockElement); + if (!hasClasses && blockElement.nodeName === (nodeName || DEFAULT_NODE_NAME)) { + // Insert a line break afterwards and beforewards when there are siblings + // that are not of type line break or block element + _addLineBreakBeforeAndAfter(blockElement); + dom.replaceWithChildNodes(blockElement); + } else if (hasClasses) { + // Make sure that styling is kept by renaming the element to <div> and copying over the class name + dom.renameElement(blockElement, DEFAULT_NODE_NAME); + } + }); + return; + } + + // Find similiar block element and rename it (<h2 class="foo"></h2> => <h1 class="foo"></h1>) + if (nodeName === null || wysihtml5.lang.array(BLOCK_ELEMENTS_GROUP).contains(nodeName)) { + selectedNode = composer.selection.getSelectedNode(); + blockElement = dom.getParentElement(selectedNode, { + nodeName: BLOCK_ELEMENTS_GROUP + }); + + if (blockElement) { + composer.selection.executeAndRestoreSimple(function() { + // Rename current block element to new block element and add class + if (nodeName) { + blockElement = dom.renameElement(blockElement, nodeName); + } + if (className) { + _addClass(blockElement, className, classRegExp); + } + }); + return; + } + } + + if (composer.commands.support(command)) { + _execCommand(doc, command, nodeName || DEFAULT_NODE_NAME, className); + return; + } + + blockElement = doc.createElement(nodeName || DEFAULT_NODE_NAME); + if (className) { + blockElement.className = className; + } + _selectLineAndWrap(composer, blockElement); + }, + + state: function(composer, command, nodeName, className, classRegExp) { + nodeName = typeof(nodeName) === "string" ? nodeName.toUpperCase() : nodeName; + var selectedNode = composer.selection.getSelectedNode(); + return dom.getParentElement(selectedNode, { + nodeName: nodeName, + className: className, + classRegExp: classRegExp + }); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);/** + * formatInline scenarios for tag "B" (| = caret, |foo| = selected text) + * + * #1 caret in unformatted text: + * abcdefg| + * output: + * abcdefg<b>|</b> + * + * #2 unformatted text selected: + * abc|deg|h + * output: + * abc<b>|deg|</b>h + * + * #3 unformatted text selected across boundaries: + * ab|c <span>defg|h</span> + * output: + * ab<b>|c </b><span><b>defg</b>|h</span> + * + * #4 formatted text entirely selected + * <b>|abc|</b> + * output: + * |abc| + * + * #5 formatted text partially selected + * <b>ab|c|</b> + * output: + * <b>ab</b>|c| + * + * #6 formatted text selected across boundaries + * <span>ab|c</span> <b>de|fgh</b> + * output: + * <span>ab|c</span> de|<b>fgh</b> + */ +(function(wysihtml5) { + var undef, + // Treat <b> as <strong> and vice versa + ALIAS_MAPPING = { + "strong": "b", + "em": "i", + "b": "strong", + "i": "em" + }, + htmlApplier = {}; + + function _getTagNames(tagName) { + var alias = ALIAS_MAPPING[tagName]; + return alias ? [tagName.toLowerCase(), alias.toLowerCase()] : [tagName.toLowerCase()]; + } + + function _getApplier(tagName, className, classRegExp) { + var identifier = tagName + ":" + className; + if (!htmlApplier[identifier]) { + htmlApplier[identifier] = new wysihtml5.selection.HTMLApplier(_getTagNames(tagName), className, classRegExp, true); + } + return htmlApplier[identifier]; + } + + wysihtml5.commands.formatInline = { + exec: function(composer, command, tagName, className, classRegExp) { + var range = composer.selection.getRange(); + if (!range) { + return false; + } + _getApplier(tagName, className, classRegExp).toggleRange(range); + composer.selection.setSelection(range); + }, + + state: function(composer, command, tagName, className, classRegExp) { + var doc = composer.doc, + aliasTagName = ALIAS_MAPPING[tagName] || tagName, + range; + + // Check whether the document contains a node with the desired tagName + if (!wysihtml5.dom.hasElementWithTagName(doc, tagName) && + !wysihtml5.dom.hasElementWithTagName(doc, aliasTagName)) { + return false; + } + + // Check whether the document contains a node with the desired className + if (className && !wysihtml5.dom.hasElementWithClassName(doc, className)) { + return false; + } + + range = composer.selection.getRange(); + if (!range) { + return false; + } + + return _getApplier(tagName, className, classRegExp).isAppliedToRange(range); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef; + + wysihtml5.commands.insertHTML = { + exec: function(composer, command, html) { + if (composer.commands.support(command)) { + composer.doc.execCommand(command, false, html); + } else { + composer.selection.insertHTML(html); + } + }, + + state: function() { + return false; + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var NODE_NAME = "IMG"; + + wysihtml5.commands.insertImage = { + /** + * Inserts an <img> + * If selection is already an image link, it removes it + * + * @example + * // either ... + * wysihtml5.commands.insertImage.exec(composer, "insertImage", "http://www.google.de/logo.jpg"); + * // ... or ... + * wysihtml5.commands.insertImage.exec(composer, "insertImage", { src: "http://www.google.de/logo.jpg", title: "foo" }); + */ + exec: function(composer, command, value) { + value = typeof(value) === "object" ? value : { src: value }; + + var doc = composer.doc, + image = this.state(composer), + textNode, + i, + parent; + + if (image) { + // Image already selected, set the caret before it and delete it + composer.selection.setBefore(image); + parent = image.parentNode; + parent.removeChild(image); + + // and it's parent <a> too if it hasn't got any other relevant child nodes + wysihtml5.dom.removeEmptyTextNodes(parent); + if (parent.nodeName === "A" && !parent.firstChild) { + composer.selection.setAfter(parent); + parent.parentNode.removeChild(parent); + } + + // firefox and ie sometimes don't remove the image handles, even though the image got removed + wysihtml5.quirks.redraw(composer.element); + return; + } + + image = doc.createElement(NODE_NAME); + + for (i in value) { + image[i] = value[i]; + } + + composer.selection.insertNode(image); + if (wysihtml5.browser.hasProblemsSettingCaretAfterImg()) { + textNode = doc.createTextNode(wysihtml5.INVISIBLE_SPACE); + composer.selection.insertNode(textNode); + composer.selection.setAfter(textNode); + } else { + composer.selection.setAfter(image); + } + }, + + state: function(composer) { + var doc = composer.doc, + selectedNode, + text, + imagesInSelection; + + if (!wysihtml5.dom.hasElementWithTagName(doc, NODE_NAME)) { + return false; + } + + selectedNode = composer.selection.getSelectedNode(); + if (!selectedNode) { + return false; + } + + if (selectedNode.nodeName === NODE_NAME) { + // This works perfectly in IE + return selectedNode; + } + + if (selectedNode.nodeType !== wysihtml5.ELEMENT_NODE) { + return false; + } + + text = composer.selection.getText(); + text = wysihtml5.lang.string(text).trim(); + if (text) { + return false; + } + + imagesInSelection = composer.selection.getNodes(wysihtml5.ELEMENT_NODE, function(node) { + return node.nodeName === "IMG"; + }); + + if (imagesInSelection.length !== 1) { + return false; + } + + return imagesInSelection[0]; + }, + + value: function(composer) { + var image = this.state(composer); + return image && image.src; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef, + LINE_BREAK = "<br>" + (wysihtml5.browser.needsSpaceAfterLineBreak() ? " " : ""); + + wysihtml5.commands.insertLineBreak = { + exec: function(composer, command) { + if (composer.commands.support(command)) { + composer.doc.execCommand(command, false, null); + if (!wysihtml5.browser.autoScrollsToCaret()) { + composer.selection.scrollIntoView(); + } + } else { + composer.commands.exec("insertHTML", LINE_BREAK); + } + }, + + state: function() { + return false; + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef; + + wysihtml5.commands.insertOrderedList = { + exec: function(composer, command) { + var doc = composer.doc, + selectedNode = composer.selection.getSelectedNode(), + list = wysihtml5.dom.getParentElement(selectedNode, { nodeName: "OL" }), + otherList = wysihtml5.dom.getParentElement(selectedNode, { nodeName: "UL" }), + tempClassName = "_wysihtml5-temp-" + new Date().getTime(), + isEmpty, + tempElement; + + if (composer.commands.support(command)) { + doc.execCommand(command, false, null); + return; + } + + if (list) { + // Unwrap list + // <ol><li>foo</li><li>bar</li></ol> + // becomes: + // foo<br>bar<br> + composer.selection.executeAndRestoreSimple(function() { + wysihtml5.dom.resolveList(list); + }); + } else if (otherList) { + // Turn an unordered list into an ordered list + // <ul><li>foo</li><li>bar</li></ul> + // becomes: + // <ol><li>foo</li><li>bar</li></ol> + composer.selection.executeAndRestoreSimple(function() { + wysihtml5.dom.renameElement(otherList, "ol"); + }); + } else { + // Create list + composer.commands.exec("formatBlock", "div", tempClassName); + tempElement = doc.querySelector("." + tempClassName); + isEmpty = tempElement.innerHTML === "" || tempElement.innerHTML === wysihtml5.INVISIBLE_SPACE; + composer.selection.executeAndRestoreSimple(function() { + list = wysihtml5.dom.convertToList(tempElement, "ol"); + }); + if (isEmpty) { + composer.selection.selectNode(list.querySelector("li")); + } + } + }, + + state: function(composer) { + var selectedNode = composer.selection.getSelectedNode(); + return wysihtml5.dom.getParentElement(selectedNode, { nodeName: "OL" }); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef; + + wysihtml5.commands.insertUnorderedList = { + exec: function(composer, command) { + var doc = composer.doc, + selectedNode = composer.selection.getSelectedNode(), + list = wysihtml5.dom.getParentElement(selectedNode, { nodeName: "UL" }), + otherList = wysihtml5.dom.getParentElement(selectedNode, { nodeName: "OL" }), + tempClassName = "_wysihtml5-temp-" + new Date().getTime(), + isEmpty, + tempElement; + + if (composer.commands.support(command)) { + doc.execCommand(command, false, null); + return; + } + + if (list) { + // Unwrap list + // <ul><li>foo</li><li>bar</li></ul> + // becomes: + // foo<br>bar<br> + composer.selection.executeAndRestoreSimple(function() { + wysihtml5.dom.resolveList(list); + }); + } else if (otherList) { + // Turn an ordered list into an unordered list + // <ol><li>foo</li><li>bar</li></ol> + // becomes: + // <ul><li>foo</li><li>bar</li></ul> + composer.selection.executeAndRestoreSimple(function() { + wysihtml5.dom.renameElement(otherList, "ul"); + }); + } else { + // Create list + composer.commands.exec("formatBlock", "div", tempClassName); + tempElement = doc.querySelector("." + tempClassName); + isEmpty = tempElement.innerHTML === "" || tempElement.innerHTML === wysihtml5.INVISIBLE_SPACE; + composer.selection.executeAndRestoreSimple(function() { + list = wysihtml5.dom.convertToList(tempElement, "ul"); + }); + if (isEmpty) { + composer.selection.selectNode(list.querySelector("li")); + } + } + }, + + state: function(composer) { + var selectedNode = composer.selection.getSelectedNode(); + return wysihtml5.dom.getParentElement(selectedNode, { nodeName: "UL" }); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef; + + wysihtml5.commands.italic = { + exec: function(composer, command) { + return wysihtml5.commands.formatInline.exec(composer, command, "i"); + }, + + state: function(composer, command, color) { + // element.ownerDocument.queryCommandState("italic") results: + // firefox: only <i> + // chrome: <i>, <em>, <blockquote>, ... + // ie: <i>, <em> + // opera: only <i> + return wysihtml5.commands.formatInline.state(composer, command, "i"); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef, + CLASS_NAME = "wysiwyg-text-align-center", + REG_EXP = /wysiwyg-text-align-[a-z]+/g; + + wysihtml5.commands.justifyCenter = { + exec: function(composer, command) { + return wysihtml5.commands.formatBlock.exec(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + state: function(composer, command) { + return wysihtml5.commands.formatBlock.state(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef, + CLASS_NAME = "wysiwyg-text-align-left", + REG_EXP = /wysiwyg-text-align-[a-z]+/g; + + wysihtml5.commands.justifyLeft = { + exec: function(composer, command) { + return wysihtml5.commands.formatBlock.exec(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + state: function(composer, command) { + return wysihtml5.commands.formatBlock.state(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef, + CLASS_NAME = "wysiwyg-text-align-right", + REG_EXP = /wysiwyg-text-align-[a-z]+/g; + + wysihtml5.commands.justifyRight = { + exec: function(composer, command) { + return wysihtml5.commands.formatBlock.exec(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + state: function(composer, command) { + return wysihtml5.commands.formatBlock.state(composer, "formatBlock", null, CLASS_NAME, REG_EXP); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);(function(wysihtml5) { + var undef; + wysihtml5.commands.underline = { + exec: function(composer, command) { + return wysihtml5.commands.formatInline.exec(composer, command, "u"); + }, + + state: function(composer, command) { + return wysihtml5.commands.formatInline.state(composer, command, "u"); + }, + + value: function() { + return undef; + } + }; +})(wysihtml5);/** + * Undo Manager for wysihtml5 + * slightly inspired by http://rniwa.com/editing/undomanager.html#the-undomanager-interface + */ +(function(wysihtml5) { + var Z_KEY = 90, + Y_KEY = 89, + BACKSPACE_KEY = 8, + DELETE_KEY = 46, + MAX_HISTORY_ENTRIES = 40, + UNDO_HTML = '<span id="_wysihtml5-undo" class="_wysihtml5-temp">' + wysihtml5.INVISIBLE_SPACE + '</span>', + REDO_HTML = '<span id="_wysihtml5-redo" class="_wysihtml5-temp">' + wysihtml5.INVISIBLE_SPACE + '</span>', + dom = wysihtml5.dom; + + function cleanTempElements(doc) { + var tempElement; + while (tempElement = doc.querySelector("._wysihtml5-temp")) { + tempElement.parentNode.removeChild(tempElement); + } + } + + wysihtml5.UndoManager = wysihtml5.lang.Dispatcher.extend( + /** @scope wysihtml5.UndoManager.prototype */ { + constructor: function(editor) { + this.editor = editor; + this.composer = editor.composer; + this.element = this.composer.element; + this.history = [this.composer.getValue()]; + this.position = 1; + + // Undo manager currently only supported in browsers who have the insertHTML command (not IE) + if (this.composer.commands.support("insertHTML")) { + this._observe(); + } + }, + + _observe: function() { + var that = this, + doc = this.composer.sandbox.getDocument(), + lastKey; + + // Catch CTRL+Z and CTRL+Y + dom.observe(this.element, "keydown", function(event) { + if (event.altKey || (!event.ctrlKey && !event.metaKey)) { + return; + } + + var keyCode = event.keyCode, + isUndo = keyCode === Z_KEY && !event.shiftKey, + isRedo = (keyCode === Z_KEY && event.shiftKey) || (keyCode === Y_KEY); + + if (isUndo) { + that.undo(); + event.preventDefault(); + } else if (isRedo) { + that.redo(); + event.preventDefault(); + } + }); + + // Catch delete and backspace + dom.observe(this.element, "keydown", function(event) { + var keyCode = event.keyCode; + if (keyCode === lastKey) { + return; + } + + lastKey = keyCode; + + if (keyCode === BACKSPACE_KEY || keyCode === DELETE_KEY) { + that.transact(); + } + }); + + // Now this is very hacky: + // These days browsers don't offer a undo/redo event which we could hook into + // to be notified when the user hits undo/redo in the contextmenu. + // Therefore we simply insert two elements as soon as the contextmenu gets opened. + // The last element being inserted will be immediately be removed again by a exexCommand("undo") + // => When the second element appears in the dom tree then we know the user clicked "redo" in the context menu + // => When the first element disappears from the dom tree then we know the user clicked "undo" in the context menu + if (wysihtml5.browser.hasUndoInContextMenu()) { + var interval, observed, cleanUp = function() { + cleanTempElements(doc); + clearInterval(interval); + }; + + dom.observe(this.element, "contextmenu", function() { + cleanUp(); + that.composer.selection.executeAndRestoreSimple(function() { + if (that.element.lastChild) { + that.composer.selection.setAfter(that.element.lastChild); + } + + // enable undo button in context menu + doc.execCommand("insertHTML", false, UNDO_HTML); + // enable redo button in context menu + doc.execCommand("insertHTML", false, REDO_HTML); + doc.execCommand("undo", false, null); + }); + + interval = setInterval(function() { + if (doc.getElementById("_wysihtml5-redo")) { + cleanUp(); + that.redo(); + } else if (!doc.getElementById("_wysihtml5-undo")) { + cleanUp(); + that.undo(); + } + }, 400); + + if (!observed) { + observed = true; + dom.observe(document, "mousedown", cleanUp); + dom.observe(doc, ["mousedown", "paste", "cut", "copy"], cleanUp); + } + }); + } + + this.editor + .observe("newword:composer", function() { + that.transact(); + }) + + .observe("beforecommand:composer", function() { + that.transact(); + }); + }, + + transact: function() { + var previousHtml = this.history[this.position - 1], + currentHtml = this.composer.getValue(); + + if (currentHtml == previousHtml) { + return; + } + + var length = this.history.length = this.position; + if (length > MAX_HISTORY_ENTRIES) { + this.history.shift(); + this.position--; + } + + this.position++; + this.history.push(currentHtml); + }, + + undo: function() { + this.transact(); + + if (this.position <= 1) { + return; + } + + this.set(this.history[--this.position - 1]); + this.editor.fire("undo:composer"); + }, + + redo: function() { + if (this.position >= this.history.length) { + return; + } + + this.set(this.history[++this.position - 1]); + this.editor.fire("redo:composer"); + }, + + set: function(html) { + this.composer.setValue(html); + this.editor.focus(true); + } + }); +})(wysihtml5); +/** + * TODO: the following methods still need unit test coverage + */ +wysihtml5.views.View = Base.extend( + /** @scope wysihtml5.views.View.prototype */ { + constructor: function(parent, textareaElement, config) { + this.parent = parent; + this.element = textareaElement; + this.config = config; + + this._observeViewChange(); + }, + + _observeViewChange: function() { + var that = this; + this.parent.observe("beforeload", function() { + that.parent.observe("change_view", function(view) { + if (view === that.name) { + that.parent.currentView = that; + that.show(); + // Using tiny delay here to make sure that the placeholder is set before focusing + setTimeout(function() { that.focus(); }, 0); + } else { + that.hide(); + } + }); + }); + }, + + focus: function() { + if (this.element.ownerDocument.querySelector(":focus") === this.element) { + return; + } + + try { this.element.focus(); } catch(e) {} + }, + + hide: function() { + this.element.style.display = "none"; + }, + + show: function() { + this.element.style.display = ""; + }, + + disable: function() { + this.element.setAttribute("disabled", "disabled"); + }, + + enable: function() { + this.element.removeAttribute("disabled"); + } +});(function(wysihtml5) { + var dom = wysihtml5.dom, + browser = wysihtml5.browser; + + wysihtml5.views.Composer = wysihtml5.views.View.extend( + /** @scope wysihtml5.views.Composer.prototype */ { + name: "composer", + + // Needed for firefox in order to display a proper caret in an empty contentEditable + CARET_HACK: "<br>", + + constructor: function(parent, textareaElement, config) { + this.base(parent, textareaElement, config); + this.textarea = this.parent.textarea; + this._initSandbox(); + }, + + clear: function() { + this.element.innerHTML = browser.displaysCaretInEmptyContentEditableCorrectly() ? "" : this.CARET_HACK; + }, + + getValue: function(parse) { + var value = this.isEmpty() ? "" : wysihtml5.quirks.getCorrectInnerHTML(this.element); + + if (parse) { + value = this.parent.parse(value); + } + + // Replace all "zero width no breaking space" chars + // which are used as hacks to enable some functionalities + // Also remove all CARET hacks that somehow got left + value = wysihtml5.lang.string(value).replace(wysihtml5.INVISIBLE_SPACE).by(""); + + return value; + }, + + setValue: function(html, parse) { + if (parse) { + html = this.parent.parse(html); + } + this.element.innerHTML = html; + }, + + show: function() { + this.iframe.style.display = this._displayStyle || ""; + + // Firefox needs this, otherwise contentEditable becomes uneditable + this.disable(); + this.enable(); + }, + + hide: function() { + this._displayStyle = dom.getStyle("display").from(this.iframe); + if (this._displayStyle === "none") { + this._displayStyle = null; + } + this.iframe.style.display = "none"; + }, + + disable: function() { + this.element.removeAttribute("contentEditable"); + this.base(); + }, + + enable: function() { + this.element.setAttribute("contentEditable", "true"); + this.base(); + }, + + focus: function(setToEnd) { + // IE 8 fires the focus event after .focus() + // This is needed by our simulate_placeholder.js to work + // therefore we clear it ourselves this time + if (wysihtml5.browser.doesAsyncFocus() && this.hasPlaceholderSet()) { + this.clear(); + } + + this.base(); + + var lastChild = this.element.lastChild; + if (setToEnd && lastChild) { + if (lastChild.nodeName === "BR") { + this.selection.setBefore(this.element.lastChild); + } else { + this.selection.setAfter(this.element.lastChild); + } + } + }, + + getTextContent: function() { + return dom.getTextContent(this.element); + }, + + hasPlaceholderSet: function() { + return this.getTextContent() == this.textarea.element.getAttribute("placeholder"); + }, + + isEmpty: function() { + var innerHTML = this.element.innerHTML, + elementsWithVisualValue = "blockquote, ul, ol, img, embed, object, table, iframe, svg, video, audio, button, input, select, textarea"; + return innerHTML === "" || + innerHTML === this.CARET_HACK || + this.hasPlaceholderSet() || + (this.getTextContent() === "" && !this.element.querySelector(elementsWithVisualValue)); + }, + + _initSandbox: function() { + var that = this; + + this.sandbox = new dom.Sandbox(function() { + that._create(); + }, { + stylesheets: this.config.stylesheets + }); + this.iframe = this.sandbox.getIframe(); + + // Create hidden field which tells the server after submit, that the user used an wysiwyg editor + var hiddenField = document.createElement("input"); + hiddenField.type = "hidden"; + hiddenField.name = "_wysihtml5_mode"; + hiddenField.value = 1; + + // Store reference to current wysihtml5 instance on the textarea element + var textareaElement = this.textarea.element; + dom.insert(this.iframe).after(textareaElement); + dom.insert(hiddenField).after(textareaElement); + }, + + _create: function() { + var that = this; + + this.doc = this.sandbox.getDocument(); + this.element = this.doc.body; + this.textarea = this.parent.textarea; + this.element.innerHTML = this.textarea.getValue(true); + this.enable(); + + // Make sure our selection handler is ready + this.selection = new wysihtml5.Selection(this.parent); + + // Make sure commands dispatcher is ready + this.commands = new wysihtml5.Commands(this.parent); + + dom.copyAttributes([ + "className", "spellcheck", "title", "lang", "dir", "accessKey" + ]).from(this.textarea.element).to(this.element); + + dom.addClass(this.element, this.config.composerClassName); + + // Make the editor look like the original textarea, by syncing styles + if (this.config.style) { + this.style(); + } + + this.observe(); + + var name = this.config.name; + if (name) { + dom.addClass(this.element, name); + dom.addClass(this.iframe, name); + } + + // Simulate html5 placeholder attribute on contentEditable element + var placeholderText = typeof(this.config.placeholder) === "string" + ? this.config.placeholder + : this.textarea.element.getAttribute("placeholder"); + if (placeholderText) { + dom.simulatePlaceholder(this.parent, this, placeholderText); + } + + // Make sure that the browser avoids using inline styles whenever possible + this.commands.exec("styleWithCSS", false); + + this._initAutoLinking(); + this._initObjectResizing(); + this._initUndoManager(); + + // Simulate html5 autofocus on contentEditable element + if (this.textarea.element.hasAttribute("autofocus") || document.querySelector(":focus") == this.textarea.element) { + setTimeout(function() { that.focus(); }, 100); + } + + wysihtml5.quirks.insertLineBreakOnReturn(this); + + // IE sometimes leaves a single paragraph, which can't be removed by the user + if (!browser.clearsContentEditableCorrectly()) { + wysihtml5.quirks.ensureProperClearing(this); + } + + if (!browser.clearsListsInContentEditableCorrectly()) { + wysihtml5.quirks.ensureProperClearingOfLists(this); + } + + // Set up a sync that makes sure that textarea and editor have the same content + if (this.initSync && this.config.sync) { + this.initSync(); + } + + // Okay hide the textarea, we are ready to go + this.textarea.hide(); + + // Fire global (before-)load event + this.parent.fire("beforeload").fire("load"); + }, + + _initAutoLinking: function() { + var that = this, + supportsDisablingOfAutoLinking = browser.canDisableAutoLinking(), + supportsAutoLinking = browser.doesAutoLinkingInContentEditable(); + if (supportsDisablingOfAutoLinking) { + this.commands.exec("autoUrlDetect", false); + } + + if (!this.config.autoLink) { + return; + } + + // Only do the auto linking by ourselves when the browser doesn't support auto linking + // OR when he supports auto linking but we were able to turn it off (IE9+) + if (!supportsAutoLinking || (supportsAutoLinking && supportsDisablingOfAutoLinking)) { + this.parent.observe("newword:composer", function() { + that.selection.executeAndRestore(function(startContainer, endContainer) { + dom.autoLink(endContainer.parentNode); + }); + }); + } + + // Assuming we have the following: + // <a href="http://www.google.de">http://www.google.de</a> + // If a user now changes the url in the innerHTML we want to make sure that + // it's synchronized with the href attribute (as long as the innerHTML is still a url) + var // Use a live NodeList to check whether there are any links in the document + links = this.sandbox.getDocument().getElementsByTagName("a"), + // The autoLink helper method reveals a reg exp to detect correct urls + urlRegExp = dom.autoLink.URL_REG_EXP, + getTextContent = function(element) { + var textContent = wysihtml5.lang.string(dom.getTextContent(element)).trim(); + if (textContent.substr(0, 4) === "www.") { + textContent = "http://" + textContent; + } + return textContent; + }; + + dom.observe(this.element, "keydown", function(event) { + if (!links.length) { + return; + } + + var selectedNode = that.selection.getSelectedNode(event.target.ownerDocument), + link = dom.getParentElement(selectedNode, { nodeName: "A" }, 4), + textContent; + + if (!link) { + return; + } + + textContent = getTextContent(link); + // keydown is fired before the actual content is changed + // therefore we set a timeout to change the href + setTimeout(function() { + var newTextContent = getTextContent(link); + if (newTextContent === textContent) { + return; + } + + // Only set href when new href looks like a valid url + if (newTextContent.match(urlRegExp)) { + link.setAttribute("href", newTextContent); + } + }, 0); + }); + }, + + _initObjectResizing: function() { + var properties = ["width", "height"], + propertiesLength = properties.length, + element = this.element; + + this.commands.exec("enableObjectResizing", this.config.allowObjectResizing); + + if (this.config.allowObjectResizing) { + // IE sets inline styles after resizing objects + // The following lines make sure that the width/height css properties + // are copied over to the width/height attributes + if (browser.supportsEvent("resizeend")) { + dom.observe(element, "resizeend", function(event) { + var target = event.target || event.srcElement, + style = target.style, + i = 0, + property; + for(; i<propertiesLength; i++) { + property = properties[i]; + if (style[property]) { + target.setAttribute(property, parseInt(style[property], 10)); + style[property] = ""; + } + } + // After resizing IE sometimes forgets to remove the old resize handles + wysihtml5.quirks.redraw(element); + }); + } + } else { + if (browser.supportsEvent("resizestart")) { + dom.observe(element, "resizestart", function(event) { event.preventDefault(); }); + } + } + }, + + _initUndoManager: function() { + new wysihtml5.UndoManager(this.parent); + } + }); +})(wysihtml5);(function(wysihtml5) { + var dom = wysihtml5.dom, + doc = document, + win = window, + HOST_TEMPLATE = doc.createElement("div"), + /** + * Styles to copy from textarea to the composer element + */ + TEXT_FORMATTING = [ + "background-color", + "color", "cursor", + "font-family", "font-size", "font-style", "font-variant", "font-weight", + "line-height", "letter-spacing", + "text-align", "text-decoration", "text-indent", "text-rendering", + "word-break", "word-wrap", "word-spacing" + ], + /** + * Styles to copy from textarea to the iframe + */ + BOX_FORMATTING = [ + "background-color", + "border-collapse", + "border-bottom-color", "border-bottom-style", "border-bottom-width", + "border-left-color", "border-left-style", "border-left-width", + "border-right-color", "border-right-style", "border-right-width", + "border-top-color", "border-top-style", "border-top-width", + "clear", "display", "float", + "margin-bottom", "margin-left", "margin-right", "margin-top", + "outline-color", "outline-offset", "outline-width", "outline-style", + "padding-left", "padding-right", "padding-top", "padding-bottom", + "position", "top", "left", "right", "bottom", "z-index", + "vertical-align", "text-align", + "-webkit-box-sizing", "-moz-box-sizing", "-ms-box-sizing", "box-sizing", + "-webkit-box-shadow", "-moz-box-shadow", "-ms-box-shadow","box-shadow", + "-webkit-border-top-right-radius", "-moz-border-radius-topright", "border-top-right-radius", + "-webkit-border-bottom-right-radius", "-moz-border-radius-bottomright", "border-bottom-right-radius", + "-webkit-border-bottom-left-radius", "-moz-border-radius-bottomleft", "border-bottom-left-radius", + "-webkit-border-top-left-radius", "-moz-border-radius-topleft", "border-top-left-radius", + "width", "height" + ], + /** + * Styles to sync while the window gets resized + */ + RESIZE_STYLE = [ + "width", "height", + "top", "left", "right", "bottom" + ], + ADDITIONAL_CSS_RULES = [ + "html { height: 100%; }", + "body { min-height: 100%; padding: 0; margin: 0; margin-top: -1px; padding-top: 1px; }", + "._wysihtml5-temp { display: none; }", + wysihtml5.browser.isGecko ? + "body.placeholder { color: graytext !important; }" : + "body.placeholder { color: #a9a9a9 !important; }", + "body[disabled] { background-color: #eee !important; color: #999 !important; cursor: default !important; }", + // Ensure that user see's broken images and can delete them + "img:-moz-broken { -moz-force-broken-image-icon: 1; height: 24px; width: 24px; }" + ]; + + /** + * With "setActive" IE offers a smart way of focusing elements without scrolling them into view: + * http://msdn.microsoft.com/en-us/library/ms536738(v=vs.85).aspx + * + * Other browsers need a more hacky way: (pssst don't tell my mama) + * In order to prevent the element being scrolled into view when focusing it, we simply + * move it out of the scrollable area, focus it, and reset it's position + */ + var focusWithoutScrolling = function(element) { + if (element.setActive) { + // Following line could cause a js error when the textarea is invisible + // See https://github.com/xing/wysihtml5/issues/9 + try { element.setActive(); } catch(e) {} + } else { + var elementStyle = element.style, + originalScrollTop = doc.documentElement.scrollTop || doc.body.scrollTop, + originalScrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft, + originalStyles = { + position: elementStyle.position, + top: elementStyle.top, + left: elementStyle.left, + WebkitUserSelect: elementStyle.WebkitUserSelect + }; + + dom.setStyles({ + position: "absolute", + top: "-99999px", + left: "-99999px", + // Don't ask why but temporarily setting -webkit-user-select to none makes the whole thing performing smoother + WebkitUserSelect: "none" + }).on(element); + + element.focus(); + + dom.setStyles(originalStyles).on(element); + + if (win.scrollTo) { + // Some browser extensions unset this method to prevent annoyances + // "Better PopUp Blocker" for Chrome http://code.google.com/p/betterpopupblocker/source/browse/trunk/blockStart.js#100 + // Issue: http://code.google.com/p/betterpopupblocker/issues/detail?id=1 + win.scrollTo(originalScrollLeft, originalScrollTop); + } + } + }; + + + wysihtml5.views.Composer.prototype.style = function() { + var that = this, + originalActiveElement = doc.querySelector(":focus"), + textareaElement = this.textarea.element, + hasPlaceholder = textareaElement.hasAttribute("placeholder"), + originalPlaceholder = hasPlaceholder && textareaElement.getAttribute("placeholder"); + this.focusStylesHost = this.focusStylesHost || HOST_TEMPLATE.cloneNode(false); + this.blurStylesHost = this.blurStylesHost || HOST_TEMPLATE.cloneNode(false); + + // Remove placeholder before copying (as the placeholder has an affect on the computed style) + if (hasPlaceholder) { + textareaElement.removeAttribute("placeholder"); + } + + if (textareaElement === originalActiveElement) { + textareaElement.blur(); + } + + // --------- iframe styles (has to be set before editor styles, otherwise IE9 sets wrong fontFamily on blurStylesHost) --------- + dom.copyStyles(BOX_FORMATTING).from(textareaElement).to(this.iframe).andTo(this.blurStylesHost); + + // --------- editor styles --------- + dom.copyStyles(TEXT_FORMATTING).from(textareaElement).to(this.element).andTo(this.blurStylesHost); + + // --------- apply standard rules --------- + dom.insertCSS(ADDITIONAL_CSS_RULES).into(this.element.ownerDocument); + + // --------- :focus styles --------- + focusWithoutScrolling(textareaElement); + dom.copyStyles(BOX_FORMATTING).from(textareaElement).to(this.focusStylesHost); + dom.copyStyles(TEXT_FORMATTING).from(textareaElement).to(this.focusStylesHost); + + // Make sure that we don't change the display style of the iframe when copying styles oblur/onfocus + // this is needed for when the change_view event is fired where the iframe is hidden and then + // the blur event fires and re-displays it + var boxFormattingStyles = wysihtml5.lang.array(BOX_FORMATTING).without(["display"]); + + // --------- restore focus --------- + if (originalActiveElement) { + originalActiveElement.focus(); + } else { + textareaElement.blur(); + } + + // --------- restore placeholder --------- + if (hasPlaceholder) { + textareaElement.setAttribute("placeholder", originalPlaceholder); + } + + // When copying styles, we only get the computed style which is never returned in percent unit + // Therefore we've to recalculate style onresize + if (!wysihtml5.browser.hasCurrentStyleProperty()) { + var winObserver = dom.observe(win, "resize", function() { + // Remove event listener if composer doesn't exist anymore + if (!dom.contains(document.documentElement, that.iframe)) { + winObserver.stop(); + return; + } + var originalTextareaDisplayStyle = dom.getStyle("display").from(textareaElement), + originalComposerDisplayStyle = dom.getStyle("display").from(that.iframe); + textareaElement.style.display = ""; + that.iframe.style.display = "none"; + dom.copyStyles(RESIZE_STYLE) + .from(textareaElement) + .to(that.iframe) + .andTo(that.focusStylesHost) + .andTo(that.blurStylesHost); + that.iframe.style.display = originalComposerDisplayStyle; + textareaElement.style.display = originalTextareaDisplayStyle; + }); + } + + // --------- Sync focus/blur styles --------- + this.parent.observe("focus:composer", function() { + dom.copyStyles(boxFormattingStyles) .from(that.focusStylesHost).to(that.iframe); + dom.copyStyles(TEXT_FORMATTING) .from(that.focusStylesHost).to(that.element); + }); + + this.parent.observe("blur:composer", function() { + dom.copyStyles(boxFormattingStyles) .from(that.blurStylesHost).to(that.iframe); + dom.copyStyles(TEXT_FORMATTING) .from(that.blurStylesHost).to(that.element); + }); + + return this; + }; +})(wysihtml5);/** + * Taking care of events + * - Simulating 'change' event on contentEditable element + * - Handling drag & drop logic + * - Catch paste events + * - Dispatch proprietary newword:composer event + * - Keyboard shortcuts + */ +(function(wysihtml5) { + var dom = wysihtml5.dom, + browser = wysihtml5.browser, + /** + * Map keyCodes to query commands + */ + shortcuts = { + "66": "bold", // B + "73": "italic", // I + "85": "underline" // U + }; + + wysihtml5.views.Composer.prototype.observe = function() { + var that = this, + state = this.getValue(), + iframe = this.sandbox.getIframe(), + element = this.element, + focusBlurElement = browser.supportsEventsInIframeCorrectly() ? element : this.sandbox.getWindow(), + // Firefox < 3.5 doesn't support the drop event, instead it supports a so called "dragdrop" event which behaves almost the same + pasteEvents = browser.supportsEvent("drop") ? ["drop", "paste"] : ["dragdrop", "paste"]; + + // --------- destroy:composer event --------- + dom.observe(iframe, "DOMNodeRemoved", function() { + clearInterval(domNodeRemovedInterval); + that.parent.fire("destroy:composer"); + }); + + // DOMNodeRemoved event is not supported in IE 8 + var domNodeRemovedInterval = setInterval(function() { + if (!dom.contains(document.documentElement, iframe)) { + clearInterval(domNodeRemovedInterval); + that.parent.fire("destroy:composer"); + } + }, 250); + + + // --------- Focus & blur logic --------- + dom.observe(focusBlurElement, "focus", function() { + that.parent.fire("focus").fire("focus:composer"); + + // Delay storing of state until all focus handler are fired + // especially the one which resets the placeholder + setTimeout(function() { state = that.getValue(); }, 0); + }); + + dom.observe(focusBlurElement, "blur", function() { + if (state !== that.getValue()) { + that.parent.fire("change").fire("change:composer"); + } + that.parent.fire("blur").fire("blur:composer"); + }); + + if (wysihtml5.browser.isIos()) { + // When on iPad/iPhone/IPod after clicking outside of editor, the editor loses focus + // but the UI still acts as if the editor has focus (blinking caret and onscreen keyboard visible) + // We prevent that by focusing a temporary input element which immediately loses focus + dom.observe(element, "blur", function() { + var input = element.ownerDocument.createElement("input"), + originalScrollTop = document.documentElement.scrollTop || document.body.scrollTop, + originalScrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; + try { + that.selection.insertNode(input); + } catch(e) { + element.appendChild(input); + } + input.focus(); + input.parentNode.removeChild(input); + + window.scrollTo(originalScrollLeft, originalScrollTop); + }); + } + + // --------- Drag & Drop logic --------- + dom.observe(element, "dragenter", function() { + that.parent.fire("unset_placeholder"); + }); + + if (browser.firesOnDropOnlyWhenOnDragOverIsCancelled()) { + dom.observe(element, ["dragover", "dragenter"], function(event) { + event.preventDefault(); + }); + } + + dom.observe(element, pasteEvents, function(event) { + var dataTransfer = event.dataTransfer, + data; + + if (dataTransfer && browser.supportsDataTransfer()) { + data = dataTransfer.getData("text/html") || dataTransfer.getData("text/plain"); + } + if (data) { + element.focus(); + that.commands.exec("insertHTML", data); + that.parent.fire("paste").fire("paste:composer"); + event.stopPropagation(); + event.preventDefault(); + } else { + setTimeout(function() { + that.parent.fire("paste").fire("paste:composer"); + }, 0); + } + }); + + // --------- neword event --------- + dom.observe(element, "keyup", function(event) { + var keyCode = event.keyCode; + if (keyCode === wysihtml5.SPACE_KEY || keyCode === wysihtml5.ENTER_KEY) { + that.parent.fire("newword:composer"); + } + }); + + this.parent.observe("paste:composer", function() { + setTimeout(function() { that.parent.fire("newword:composer"); }, 0); + }); + + // --------- Make sure that images are selected when clicking on them --------- + if (!browser.canSelectImagesInContentEditable()) { + dom.observe(element, "mousedown", function(event) { + var target = event.target; + if (target.nodeName === "IMG") { + that.selection.selectNode(target); + event.preventDefault(); + } + }); + } + + // --------- Shortcut logic --------- + dom.observe(element, "keydown", function(event) { + var keyCode = event.keyCode, + command = shortcuts[keyCode]; + if ((event.ctrlKey || event.metaKey) && !event.altKey && command) { + that.commands.exec(command); + event.preventDefault(); + } + }); + + // --------- Make sure that when pressing backspace/delete on selected images deletes the image and it's anchor --------- + dom.observe(element, "keydown", function(event) { + var target = that.selection.getSelectedNode(true), + keyCode = event.keyCode, + parent; + if (target && target.nodeName === "IMG" && (keyCode === wysihtml5.BACKSPACE_KEY || keyCode === wysihtml5.DELETE_KEY)) { // 8 => backspace, 46 => delete + parent = target.parentNode; + // delete the <img> + parent.removeChild(target); + // and it's parent <a> too if it hasn't got any other child nodes + if (parent.nodeName === "A" && !parent.firstChild) { + parent.parentNode.removeChild(parent); + } + + setTimeout(function() { wysihtml5.quirks.redraw(element); }, 0); + event.preventDefault(); + } + }); + + // --------- Show url in tooltip when hovering links or images --------- + var titlePrefixes = { + IMG: "Image: ", + A: "Link: " + }; + + dom.observe(element, "mouseover", function(event) { + var target = event.target, + nodeName = target.nodeName, + title; + if (nodeName !== "A" && nodeName !== "IMG") { + return; + } + var hasTitle = target.hasAttribute("title"); + if(!hasTitle){ + title = titlePrefixes[nodeName] + (target.getAttribute("href") || target.getAttribute("src")); + target.setAttribute("title", title); + } + }); + }; +})(wysihtml5);/** + * Class that takes care that the value of the composer and the textarea is always in sync + */ +(function(wysihtml5) { + var INTERVAL = 400; + + wysihtml5.views.Synchronizer = Base.extend( + /** @scope wysihtml5.views.Synchronizer.prototype */ { + + constructor: function(editor, textarea, composer) { + this.editor = editor; + this.textarea = textarea; + this.composer = composer; + + this._observe(); + }, + + /** + * Sync html from composer to textarea + * Takes care of placeholders + * @param {Boolean} shouldParseHtml Whether the html should be sanitized before inserting it into the textarea + */ + fromComposerToTextarea: function(shouldParseHtml) { + this.textarea.setValue(wysihtml5.lang.string(this.composer.getValue()).trim(), shouldParseHtml); + }, + + /** + * Sync value of textarea to composer + * Takes care of placeholders + * @param {Boolean} shouldParseHtml Whether the html should be sanitized before inserting it into the composer + */ + fromTextareaToComposer: function(shouldParseHtml) { + var textareaValue = this.textarea.getValue(); + if (textareaValue) { + this.composer.setValue(textareaValue, shouldParseHtml); + } else { + this.composer.clear(); + this.editor.fire("set_placeholder"); + } + }, + + /** + * Invoke syncing based on view state + * @param {Boolean} shouldParseHtml Whether the html should be sanitized before inserting it into the composer/textarea + */ + sync: function(shouldParseHtml) { + if (this.editor.currentView.name === "textarea") { + this.fromTextareaToComposer(shouldParseHtml); + } else { + this.fromComposerToTextarea(shouldParseHtml); + } + }, + + /** + * Initializes interval-based syncing + * also makes sure that on-submit the composer's content is synced with the textarea + * immediately when the form gets submitted + */ + _observe: function() { + var interval, + that = this, + form = this.textarea.element.form, + startInterval = function() { + interval = setInterval(function() { that.fromComposerToTextarea(); }, INTERVAL); + }, + stopInterval = function() { + clearInterval(interval); + interval = null; + }; + + startInterval(); + + if (form) { + // If the textarea is in a form make sure that after onreset and onsubmit the composer + // has the correct state + wysihtml5.dom.observe(form, "submit", function() { + that.sync(true); + }); + wysihtml5.dom.observe(form, "reset", function() { + setTimeout(function() { that.fromTextareaToComposer(); }, 0); + }); + } + + this.editor.observe("change_view", function(view) { + if (view === "composer" && !interval) { + that.fromTextareaToComposer(true); + startInterval(); + } else if (view === "textarea") { + that.fromComposerToTextarea(true); + stopInterval(); + } + }); + + this.editor.observe("destroy:composer", stopInterval); + } + }); +})(wysihtml5); +wysihtml5.views.Textarea = wysihtml5.views.View.extend( + /** @scope wysihtml5.views.Textarea.prototype */ { + name: "textarea", + + constructor: function(parent, textareaElement, config) { + this.base(parent, textareaElement, config); + + this._observe(); + }, + + clear: function() { + this.element.value = ""; + }, + + getValue: function(parse) { + var value = this.isEmpty() ? "" : this.element.value; + if (parse) { + value = this.parent.parse(value); + } + return value; + }, + + setValue: function(html, parse) { + if (parse) { + html = this.parent.parse(html); + } + this.element.value = html; + }, + + hasPlaceholderSet: function() { + var supportsPlaceholder = wysihtml5.browser.supportsPlaceholderAttributeOn(this.element), + placeholderText = this.element.getAttribute("placeholder") || null, + value = this.element.value, + isEmpty = !value; + return (supportsPlaceholder && isEmpty) || (value === placeholderText); + }, + + isEmpty: function() { + return !wysihtml5.lang.string(this.element.value).trim() || this.hasPlaceholderSet(); + }, + + _observe: function() { + var element = this.element, + parent = this.parent, + eventMapping = { + focusin: "focus", + focusout: "blur" + }, + /** + * Calling focus() or blur() on an element doesn't synchronously trigger the attached focus/blur events + * This is the case for focusin and focusout, so let's use them whenever possible, kkthxbai + */ + events = wysihtml5.browser.supportsEvent("focusin") ? ["focusin", "focusout", "change"] : ["focus", "blur", "change"]; + + parent.observe("beforeload", function() { + wysihtml5.dom.observe(element, events, function(event) { + var eventName = eventMapping[event.type] || event.type; + parent.fire(eventName).fire(eventName + ":textarea"); + }); + + wysihtml5.dom.observe(element, ["paste", "drop"], function() { + setTimeout(function() { parent.fire("paste").fire("paste:textarea"); }, 0); + }); + }); + } +});/** + * Toolbar Dialog + * + * @param {Element} link The toolbar link which causes the dialog to show up + * @param {Element} container The dialog container + * + * @example + * <!-- Toolbar link --> + * <a data-wysihtml5-command="insertImage">insert an image</a> + * + * <!-- Dialog --> + * <div data-wysihtml5-dialog="insertImage" style="display: none;"> + * <label> + * URL: <input data-wysihtml5-dialog-field="src" value="http://"> + * </label> + * <label> + * Alternative text: <input data-wysihtml5-dialog-field="alt" value=""> + * </label> + * </div> + * + * <script> + * var dialog = new wysihtml5.toolbar.Dialog( + * document.querySelector("[data-wysihtml5-command='insertImage']"), + * document.querySelector("[data-wysihtml5-dialog='insertImage']") + * ); + * dialog.observe("save", function(attributes) { + * // do something + * }); + * </script> + */ +(function(wysihtml5) { + var dom = wysihtml5.dom, + CLASS_NAME_OPENED = "wysihtml5-command-dialog-opened", + SELECTOR_FORM_ELEMENTS = "input, select, textarea", + SELECTOR_FIELDS = "[data-wysihtml5-dialog-field]", + ATTRIBUTE_FIELDS = "data-wysihtml5-dialog-field"; + + + wysihtml5.toolbar.Dialog = wysihtml5.lang.Dispatcher.extend( + /** @scope wysihtml5.toolbar.Dialog.prototype */ { + constructor: function(link, container) { + this.link = link; + this.container = container; + }, + + _observe: function() { + if (this._observed) { + return; + } + + var that = this, + callbackWrapper = function(event) { + var attributes = that._serialize(); + if (attributes == that.elementToChange) { + that.fire("edit", attributes); + } else { + that.fire("save", attributes); + } + that.hide(); + event.preventDefault(); + event.stopPropagation(); + }; + + dom.observe(that.link, "click", function(event) { + if (dom.hasClass(that.link, CLASS_NAME_OPENED)) { + setTimeout(function() { that.hide(); }, 0); + } + }); + + dom.observe(this.container, "keydown", function(event) { + var keyCode = event.keyCode; + if (keyCode === wysihtml5.ENTER_KEY) { + callbackWrapper(event); + } + if (keyCode === wysihtml5.ESCAPE_KEY) { + that.hide(); + } + }); + + dom.delegate(this.container, "[data-wysihtml5-dialog-action=save]", "click", callbackWrapper); + + dom.delegate(this.container, "[data-wysihtml5-dialog-action=cancel]", "click", function(event) { + that.fire("cancel"); + that.hide(); + event.preventDefault(); + event.stopPropagation(); + }); + + var formElements = this.container.querySelectorAll(SELECTOR_FORM_ELEMENTS), + i = 0, + length = formElements.length, + _clearInterval = function() { clearInterval(that.interval); }; + for (; i<length; i++) { + dom.observe(formElements[i], "change", _clearInterval); + } + + this._observed = true; + }, + + /** + * Grabs all fields in the dialog and puts them in key=>value style in an object which + * then gets returned + */ + _serialize: function() { + var data = this.elementToChange || {}, + fields = this.container.querySelectorAll(SELECTOR_FIELDS), + length = fields.length, + i = 0; + for (; i<length; i++) { + data[fields[i].getAttribute(ATTRIBUTE_FIELDS)] = fields[i].value; + } + return data; + }, + + /** + * Takes the attributes of the "elementToChange" + * and inserts them in their corresponding dialog input fields + * + * Assume the "elementToChange" looks like this: + * <a href="http://www.google.com" target="_blank">foo</a> + * + * and we have the following dialog: + * <input type="text" data-wysihtml5-dialog-field="href" value=""> + * <input type="text" data-wysihtml5-dialog-field="target" value=""> + * + * after calling _interpolate() the dialog will look like this + * <input type="text" data-wysihtml5-dialog-field="href" value="http://www.google.com"> + * <input type="text" data-wysihtml5-dialog-field="target" value="_blank"> + * + * Basically it adopted the attribute values into the corresponding input fields + * + */ + _interpolate: function(avoidHiddenFields) { + var field, + fieldName, + newValue, + focusedElement = document.querySelector(":focus"), + fields = this.container.querySelectorAll(SELECTOR_FIELDS), + length = fields.length, + i = 0; + for (; i<length; i++) { + field = fields[i]; + + // Never change elements where the user is currently typing in + if (field === focusedElement) { + continue; + } + + // Don't update hidden fields + // See https://github.com/xing/wysihtml5/pull/14 + if (avoidHiddenFields && field.type === "hidden") { + continue; + } + + fieldName = field.getAttribute(ATTRIBUTE_FIELDS); + newValue = this.elementToChange ? (this.elementToChange[fieldName] || "") : field.defaultValue; + field.value = newValue; + } + }, + + /** + * Show the dialog element + */ + show: function(elementToChange) { + var that = this, + firstField = this.container.querySelector(SELECTOR_FORM_ELEMENTS); + this.elementToChange = elementToChange; + this._observe(); + this._interpolate(); + if (elementToChange) { + this.interval = setInterval(function() { that._interpolate(true); }, 500); + } + dom.addClass(this.link, CLASS_NAME_OPENED); + this.container.style.display = ""; + this.fire("show"); + if (firstField && !elementToChange) { + try { + firstField.focus(); + } catch(e) {} + } + }, + + /** + * Hide the dialog element + */ + hide: function() { + clearInterval(this.interval); + this.elementToChange = null; + dom.removeClass(this.link, CLASS_NAME_OPENED); + this.container.style.display = "none"; + this.fire("hide"); + } + }); +})(wysihtml5); +/** + * Converts speech-to-text and inserts this into the editor + * As of now (2011/03/25) this only is supported in Chrome >= 11 + * + * Note that it sends the recorded audio to the google speech recognition api: + * http://stackoverflow.com/questions/4361826/does-chrome-have-buil-in-speech-recognition-for-input-type-text-x-webkit-speec + * + * Current HTML5 draft can be found here + * http://lists.w3.org/Archives/Public/public-xg-htmlspeech/2011Feb/att-0020/api-draft.html + * + * "Accessing Google Speech API Chrome 11" + * http://mikepultz.com/2011/03/accessing-google-speech-api-chrome-11/ + */ +(function(wysihtml5) { + var dom = wysihtml5.dom; + + var linkStyles = { + position: "relative" + }; + + var wrapperStyles = { + left: 0, + margin: 0, + opacity: 0, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 0, + zIndex: 1 + }; + + var inputStyles = { + cursor: "inherit", + fontSize: "50px", + height: "50px", + marginTop: "-25px", + outline: 0, + padding: 0, + position: "absolute", + right: "-4px", + top: "50%" + }; + + var inputAttributes = { + "x-webkit-speech": "", + "speech": "" + }; + + wysihtml5.toolbar.Speech = function(parent, link) { + var input = document.createElement("input"); + if (!wysihtml5.browser.supportsSpeechApiOn(input)) { + link.style.display = "none"; + return; + } + + var wrapper = document.createElement("div"); + + wysihtml5.lang.object(wrapperStyles).merge({ + width: link.offsetWidth + "px", + height: link.offsetHeight + "px" + }); + + dom.insert(input).into(wrapper); + dom.insert(wrapper).into(link); + + dom.setStyles(inputStyles).on(input); + dom.setAttributes(inputAttributes).on(input) + + dom.setStyles(wrapperStyles).on(wrapper); + dom.setStyles(linkStyles).on(link); + + var eventName = "onwebkitspeechchange" in input ? "webkitspeechchange" : "speechchange"; + dom.observe(input, eventName, function() { + parent.execCommand("insertText", input.value); + input.value = ""; + }); + + dom.observe(input, "click", function(event) { + if (dom.hasClass(link, "wysihtml5-command-disabled")) { + event.preventDefault(); + } + + event.stopPropagation(); + }); + }; +})(wysihtml5);/** + * Toolbar + * + * @param {Object} parent Reference to instance of Editor instance + * @param {Element} container Reference to the toolbar container element + * + * @example + * <div id="toolbar"> + * <a data-wysihtml5-command="createLink">insert link</a> + * <a data-wysihtml5-command="formatBlock" data-wysihtml5-command-value="h1">insert h1</a> + * </div> + * + * <script> + * var toolbar = new wysihtml5.toolbar.Toolbar(editor, document.getElementById("toolbar")); + * </script> + */ +(function(wysihtml5) { + var CLASS_NAME_COMMAND_DISABLED = "wysihtml5-command-disabled", + CLASS_NAME_COMMANDS_DISABLED = "wysihtml5-commands-disabled", + CLASS_NAME_COMMAND_ACTIVE = "wysihtml5-command-active", + CLASS_NAME_ACTION_ACTIVE = "wysihtml5-action-active", + dom = wysihtml5.dom; + + wysihtml5.toolbar.Toolbar = Base.extend( + /** @scope wysihtml5.toolbar.Toolbar.prototype */ { + constructor: function(editor, container) { + this.editor = editor; + this.container = typeof(container) === "string" ? document.getElementById(container) : container; + this.composer = editor.composer; + + this._getLinks("command"); + this._getLinks("action"); + + this._observe(); + this.show(); + + var speechInputLinks = this.container.querySelectorAll("[data-wysihtml5-command=insertSpeech]"), + length = speechInputLinks.length, + i = 0; + for (; i<length; i++) { + new wysihtml5.toolbar.Speech(this, speechInputLinks[i]); + } + }, + + _getLinks: function(type) { + var links = this[type + "Links"] = wysihtml5.lang.array(this.container.querySelectorAll("[data-wysihtml5-" + type + "]")).get(), + length = links.length, + i = 0, + mapping = this[type + "Mapping"] = {}, + link, + group, + name, + value, + dialog; + for (; i<length; i++) { + link = links[i]; + name = link.getAttribute("data-wysihtml5-" + type); + value = link.getAttribute("data-wysihtml5-" + type + "-value"); + group = this.container.querySelector("[data-wysihtml5-" + type + "-group='" + name + "']"); + dialog = this._getDialog(link, name); + + mapping[name + ":" + value] = { + link: link, + group: group, + name: name, + value: value, + dialog: dialog, + state: false + }; + } + }, + + _getDialog: function(link, command) { + var that = this, + dialogElement = this.container.querySelector("[data-wysihtml5-dialog='" + command + "']"), + dialog, + caretBookmark; + + if (dialogElement) { + dialog = new wysihtml5.toolbar.Dialog(link, dialogElement); + + dialog.observe("show", function() { + caretBookmark = that.composer.selection.getBookmark(); + + that.editor.fire("show:dialog", { command: command, dialogContainer: dialogElement, commandLink: link }); + }); + + dialog.observe("save", function(attributes) { + if (caretBookmark) { + that.composer.selection.setBookmark(caretBookmark); + } + that._execCommand(command, attributes); + + that.editor.fire("save:dialog", { command: command, dialogContainer: dialogElement, commandLink: link }); + }); + + dialog.observe("cancel", function() { + that.editor.focus(false); + that.editor.fire("cancel:dialog", { command: command, dialogContainer: dialogElement, commandLink: link }); + }); + } + return dialog; + }, + + /** + * @example + * var toolbar = new wysihtml5.Toolbar(); + * // Insert a <blockquote> element or wrap current selection in <blockquote> + * toolbar.execCommand("formatBlock", "blockquote"); + */ + execCommand: function(command, commandValue) { + if (this.commandsDisabled) { + return; + } + + var commandObj = this.commandMapping[command + ":" + commandValue]; + + // Show dialog when available + if (commandObj && commandObj.dialog && !commandObj.state) { + commandObj.dialog.show(); + } else { + this._execCommand(command, commandValue); + } + }, + + _execCommand: function(command, commandValue) { + // Make sure that composer is focussed (false => don't move caret to the end) + this.editor.focus(false); + + this.composer.commands.exec(command, commandValue); + this._updateLinkStates(); + }, + + execAction: function(action) { + var editor = this.editor; + switch(action) { + case "change_view": + if (editor.currentView === editor.textarea) { + editor.fire("change_view", "composer"); + } else { + editor.fire("change_view", "textarea"); + } + break; + } + }, + + _observe: function() { + var that = this, + editor = this.editor, + container = this.container, + links = this.commandLinks.concat(this.actionLinks), + length = links.length, + i = 0; + + for (; i<length; i++) { + // 'javascript:;' and unselectable=on Needed for IE, but done in all browsers to make sure that all get the same css applied + // (you know, a:link { ... } doesn't match anchors with missing href attribute) + dom.setAttributes({ + href: "javascript:;", + unselectable: "on" + }).on(links[i]); + } + + // Needed for opera + dom.delegate(container, "[data-wysihtml5-command]", "mousedown", function(event) { event.preventDefault(); }); + + dom.delegate(container, "[data-wysihtml5-command]", "click", function(event) { + var link = this, + command = link.getAttribute("data-wysihtml5-command"), + commandValue = link.getAttribute("data-wysihtml5-command-value"); + that.execCommand(command, commandValue); + event.preventDefault(); + }); + + dom.delegate(container, "[data-wysihtml5-action]", "click", function(event) { + var action = this.getAttribute("data-wysihtml5-action"); + that.execAction(action); + event.preventDefault(); + }); + + editor.observe("focus:composer", function() { + that.bookmark = null; + clearInterval(that.interval); + that.interval = setInterval(function() { that._updateLinkStates(); }, 500); + }); + + editor.observe("blur:composer", function() { + clearInterval(that.interval); + }); + + editor.observe("destroy:composer", function() { + clearInterval(that.interval); + }); + + editor.observe("change_view", function(currentView) { + // Set timeout needed in order to let the blur event fire first + setTimeout(function() { + that.commandsDisabled = (currentView !== "composer"); + that._updateLinkStates(); + if (that.commandsDisabled) { + dom.addClass(container, CLASS_NAME_COMMANDS_DISABLED); + } else { + dom.removeClass(container, CLASS_NAME_COMMANDS_DISABLED); + } + }, 0); + }); + }, + + _updateLinkStates: function() { + var element = this.composer.element, + commandMapping = this.commandMapping, + actionMapping = this.actionMapping, + i, + state, + action, + command; + // every millisecond counts... this is executed quite often + for (i in commandMapping) { + command = commandMapping[i]; + if (this.commandsDisabled) { + state = false; + dom.removeClass(command.link, CLASS_NAME_COMMAND_ACTIVE); + if (command.group) { + dom.removeClass(command.group, CLASS_NAME_COMMAND_ACTIVE); + } + if (command.dialog) { + command.dialog.hide(); + } + } else { + state = this.composer.commands.state(command.name, command.value); + if (wysihtml5.lang.object(state).isArray()) { + // Grab first and only object/element in state array, otherwise convert state into boolean + // to avoid showing a dialog for multiple selected elements which may have different attributes + // eg. when two links with different href are selected, the state will be an array consisting of both link elements + // but the dialog interface can only update one + state = state.length === 1 ? state[0] : true; + } + dom.removeClass(command.link, CLASS_NAME_COMMAND_DISABLED); + if (command.group) { + dom.removeClass(command.group, CLASS_NAME_COMMAND_DISABLED); + } + } + + if (command.state === state) { + continue; + } + + command.state = state; + if (state) { + dom.addClass(command.link, CLASS_NAME_COMMAND_ACTIVE); + if (command.group) { + dom.addClass(command.group, CLASS_NAME_COMMAND_ACTIVE); + } + if (command.dialog) { + if (typeof(state) === "object") { + command.dialog.show(state); + } else { + command.dialog.hide(); + } + } + } else { + dom.removeClass(command.link, CLASS_NAME_COMMAND_ACTIVE); + if (command.group) { + dom.removeClass(command.group, CLASS_NAME_COMMAND_ACTIVE); + } + if (command.dialog) { + command.dialog.hide(); + } + } + } + + for (i in actionMapping) { + action = actionMapping[i]; + + if (action.name === "change_view") { + action.state = this.editor.currentView === this.editor.textarea; + if (action.state) { + dom.addClass(action.link, CLASS_NAME_ACTION_ACTIVE); + } else { + dom.removeClass(action.link, CLASS_NAME_ACTION_ACTIVE); + } + } + } + }, + + show: function() { + this.container.style.display = ""; + }, + + hide: function() { + this.container.style.display = "none"; + } + }); + +})(wysihtml5); +/** + * WYSIHTML5 Editor + * + * @param {Element} textareaElement Reference to the textarea which should be turned into a rich text interface + * @param {Object} [config] See defaultConfig object below for explanation of each individual config option + * + * @events + * load + * beforeload (for internal use only) + * focus + * focus:composer + * focus:textarea + * blur + * blur:composer + * blur:textarea + * change + * change:composer + * change:textarea + * paste + * paste:composer + * paste:textarea + * newword:composer + * destroy:composer + * undo:composer + * redo:composer + * beforecommand:composer + * aftercommand:composer + * change_view + */ +(function(wysihtml5) { + var undef; + + var defaultConfig = { + // Give the editor a name, the name will also be set as class name on the iframe and on the iframe's body + name: undef, + // Whether the editor should look like the textarea (by adopting styles) + style: true, + // Id of the toolbar element, pass falsey value if you don't want any toolbar logic + toolbar: undef, + // Whether urls, entered by the user should automatically become clickable-links + autoLink: true, + // Object which includes parser rules to apply when html gets inserted via copy & paste + // See parser_rules/*.js for examples + parserRules: { tags: { br: {}, span: {}, div: {}, p: {} }, classes: {} }, + // Parser method to use when the user inserts content via copy & paste + parser: wysihtml5.dom.parse, + // Class name which should be set on the contentEditable element in the created sandbox iframe, can be styled via the 'stylesheets' option + composerClassName: "wysihtml5-editor", + // Class name to add to the body when the wysihtml5 editor is supported + bodyClassName: "wysihtml5-supported", + // Array (or single string) of stylesheet urls to be loaded in the editor's iframe + stylesheets: [], + // Placeholder text to use, defaults to the placeholder attribute on the textarea element + placeholderText: undef, + // Whether the composer should allow the user to manually resize images, tables etc. + allowObjectResizing: true, + // Whether the rich text editor should be rendered on touch devices (wysihtml5 >= 0.3.0 comes with basic support for iOS 5) + supportTouchDevices: true + }; + + wysihtml5.Editor = wysihtml5.lang.Dispatcher.extend( + /** @scope wysihtml5.Editor.prototype */ { + constructor: function(textareaElement, config) { + this.textareaElement = typeof(textareaElement) === "string" ? document.getElementById(textareaElement) : textareaElement; + this.config = wysihtml5.lang.object({}).merge(defaultConfig).merge(config).get(); + this.textarea = new wysihtml5.views.Textarea(this, this.textareaElement, this.config); + this.currentView = this.textarea; + this._isCompatible = wysihtml5.browser.supported(); + + // Sort out unsupported/unwanted browsers here + if (!this._isCompatible || (!this.config.supportTouchDevices && wysihtml5.browser.isTouchDevice())) { + var that = this; + setTimeout(function() { that.fire("beforeload").fire("load"); }, 0); + return; + } + + // Add class name to body, to indicate that the editor is supported + wysihtml5.dom.addClass(document.body, this.config.bodyClassName); + + this.composer = new wysihtml5.views.Composer(this, this.textareaElement, this.config); + this.currentView = this.composer; + + if (typeof(this.config.parser) === "function") { + this._initParser(); + } + + this.observe("beforeload", function() { + this.synchronizer = new wysihtml5.views.Synchronizer(this, this.textarea, this.composer); + if (this.config.toolbar) { + this.toolbar = new wysihtml5.toolbar.Toolbar(this, this.config.toolbar); + } + }); + + try { + console.log("Heya! This page is using wysihtml5 for rich text editing. Check out https://github.com/xing/wysihtml5"); + } catch(e) {} + }, + + isCompatible: function() { + return this._isCompatible; + }, + + clear: function() { + this.currentView.clear(); + return this; + }, + + getValue: function(parse) { + return this.currentView.getValue(parse); + }, + + setValue: function(html, parse) { + if (!html) { + return this.clear(); + } + this.currentView.setValue(html, parse); + return this; + }, + + focus: function(setToEnd) { + this.currentView.focus(setToEnd); + return this; + }, + + /** + * Deactivate editor (make it readonly) + */ + disable: function() { + this.currentView.disable(); + return this; + }, + + /** + * Activate editor + */ + enable: function() { + this.currentView.enable(); + return this; + }, + + isEmpty: function() { + return this.currentView.isEmpty(); + }, + + hasPlaceholderSet: function() { + return this.currentView.hasPlaceholderSet(); + }, + + parse: function(htmlOrElement) { + var returnValue = this.config.parser(htmlOrElement, this.config.parserRules, this.composer.sandbox.getDocument(), true); + if (typeof(htmlOrElement) === "object") { + wysihtml5.quirks.redraw(htmlOrElement); + } + return returnValue; + }, + + /** + * Prepare html parser logic + * - Observes for paste and drop + */ + _initParser: function() { + this.observe("paste:composer", function() { + var keepScrollPosition = true, + that = this; + that.composer.selection.executeAndRestore(function() { + wysihtml5.quirks.cleanPastedHTML(that.composer.element); + that.parse(that.composer.element); + }, keepScrollPosition); + }); + + this.observe("paste:textarea", function() { + var value = this.textarea.getValue(), + newValue; + newValue = this.parse(value); + this.textarea.setValue(newValue); + }); + } + }); +})(wysihtml5);