9522 lines
324 KiB
JavaScript
9522 lines
324 KiB
JavaScript
/**
|
|
* @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> </p>" ||
|
|
innerHTML == "<p> </p><p> </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);
|