(function (ice, $) {
/**
* TODO
* 1. Each time an ice node is removed, refresh change set
*/
"use strict";
var exports = ice,
rangy = ice.rangy,
defaults, InlineChangeEditor;
/* constants */
var BREAK_ELEMENT = "br",
PARAGRAPH_ELEMENT = "p",
INSERT_TYPE = "insertType",
DELETE_TYPE = "deleteType",
ignoreKeyCodes = [
{start: 0, end: 31}, // everything below space, special cases handled separately
{start: 33, end: 40}, // nav keys
{start: 45, end: 45}, // insert
{start: 91, end: 93}, // windows keys
{start: 112, end: 123}, // function keys
{start: 144, end: 145}
];
defaults = {
// ice node attribute names:
attributes: {
changeId: "data-cid",
userId: "data-userid",
userName: "data-username",
sessionId: "data-session-id",
time: "data-time",
lastTime: "data-last-change-time",
changeData: "data-changedata" // arbitrary data to associate with the node, e.g. version
},
// Prepended to `changeType.alias` for classname uniqueness, if needed
attrValuePrefix: '',
// Block element tagname, which wrap text and other inline nodes in `this.element`
blockEl: 'p',
// All permitted block element tagnames
blockEls: ['div','p', 'ol', 'ul', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'],
// Unique style prefix, prepended to a digit, incremented for each encountered user, and stored
// in ice node class attributes - cts1, cts2, cts3, ...
stylePrefix: 'cts',
currentUser: {
id: null,
name: null
},
// Default change types are insert and delete. Plugins or outside apps should extend this
// if they want to manage new change types. The changeType name is used as a primary
// reference for ice nodes; the `alias`, is dropped in the class attribute and is the
// primary method of identifying ice nodes; and `tag` is used for construction only.
// Invoking `this.getCleanContent()` will remove all delete type nodes and remove the tags
// for the other types, leaving the html content in place.
changeTypes: {
insertType: {
tag: 'ins',
alias: 'ins',
action: 'Inserted'
},
deleteType: {
tag: 'del',
alias: 'del',
action: 'Deleted'
}
},
// Sets this.element with the contentEditable element
contentEditable: undefined,//dfl, start with a neutral value
// Switch for toggling track changes on/off - when `false` events will be ignored.
_isTracking: true,
tooltips: false,
tooltipsDelay: 1,
_isVisible : true, // state of change tracking visibility
_changeData : null, // a string you can associate with the current change set, e.g. version
_handleSelectAll: false, // if true, handle ctrl/cmd-A in the change tracker
_sessionId: null
};
function isIgnoredKeyCode(key) {
if (! key) {
return true;
}
var i, len = ignoreKeyCodes.length, rec;
for (i = 0; i < len; ++i) {
rec = ignoreKeyCodes[i];
if (key >= rec.start && key <= rec.end) {
return true;
}
}
return false;
}
/**
* @class ice.InlineChangeEditor
* The change tracking engine
* interacts with a <code>contenteditable</code> DOM element
*/
InlineChangeEditor = function (options) {
// Data structure for modelling changes in the element according to the following model:
// [changeid] => {`type`, `time`, `userid`, `username`}
options || (options = {});
if (!options.element) {
throw new Error("options.element must be defined for ice construction.");
}
this._changes = {};
// Tracks all of the styles for users according to the following model:
// [userId] => styleId; where style is "this.stylePrefix" + "this.uniqueStyleIndex"
this._userStyles = {};
this.currentUser = {name: '', id: ''};
this._styles = {}; // dfl, moved from prototype
this._savedNodesMap = {};
this.$this = $(this);
this._browser = ice.dom.browser();
this._tooltipMouseOver = this._tooltipMouseOver.bind(this);
this._tooltipMouseOut = this._tooltipMouseOut.bind(this);
$.extend(true, this, defaults, options);
if (options.tooltips && (! $.isFunction(options.hostMethods.showTooltip) || ! $.isFunction(options.hostMethods.hideTooltip))) {
throw new Error("hostMethods.showTooltip and hostMethods.hideTooltip must be defined if tooltips is true");
}
var us = options.userStyles || {}; // dfl, moved from prototype, allow preconfig
for (var id in us) {
if (us.hasOwnProperty(id)) {
var st = us[id];
if (! isNaN(st)) {
this._userStyles[id] = this.stylePrefix + '-' + st;
this._uniqueStyleIndex = Math.max(st, this._uniqueStyleIndex);
this._styles[st] = true;
}
}
}
logError = options.hostMethods.logError || function(){ return undefined; };
// cache css selectors
this._insertSelector = '.' + this._getIceNodeClass(INSERT_TYPE);
this._deleteSelector = '.' + this._getIceNodeClass(DELETE_TYPE);
this._iceSelector = this._insertSelector + ',' + this._deleteSelector;
/* this._domObserver = new window.MutationObserver(this._onDomMutation.bind(this));
this._domObserverConfig = {
// attributes: true,
childList: true,
characterData: false,
subtree: true
};
this._domObserverTimeout = null; */
};
InlineChangeEditor.prototype = {
// Incremented for each new user and appended to they style prefix, and dropped in the
// ice node class attribute.
_uniqueStyleIndex: 0,
_browserType: null,
// One change may create multiple ice nodes, so this keeps track of the current batch id.
_batchChangeId: null,
// Incremented for each new change, dropped in the changeIdAttribute.
_uniqueIDIndex: 1,
// Temporary bookmark tags for deletes, when delete placeholding is active.
_delBookmark: 'tempdel',
isPlaceHoldingDeletes: false,
/**
* Turns on change tracking - sets up events, if needed, and initializes the environment,
* range, and editor.
*/
startTracking: function (options) {
// dfl:set contenteditable only if it has been explicitly set
if (typeof(this.contentEditable) == "boolean") {
this.element.setAttribute('contentEditable', this.contentEditable);
}
this.initializeEnvironment();
this.initializeEditor();
this.initializeRange();
this._updateTooltipsState(); //dfl
return this;
},
/**
* Removes contenteditability and stops event handling.
* @param {Boolean} onlyICE if true, stop tracking but don't remove the contenteditable property of the tracked element
*/
stopTracking: function (onlyICE) {
this._isTracking = false;
try { // dfl added try/catch for ie
// If we are handling events setup the delegate to handle various events on `this.element`.
var e = this.element;
if (e) {
this.unlistenToEvents();
}
// dfl:reset contenteditable unless requested not to do so
if (! onlyICE && (typeof(this.contentEditable) !== "undefined")) {
this.element.setAttribute('contentEditable', !this.contentEditable);
}
}
catch (e){
logError(e, "While trying to stop tracking");
}
this._updateTooltipsState();
return this;
},
listenToEvents: function() {
if (this.element && ! this._boundEventHandler) {
this.unlistenToEvents();
this._boundEventHandler = this.handleEvent.bind(this);
this.element.addEventListener("keydown", this._boundEventHandler, true);
}
},
unlistenToEvents: function() {
if (this.element && this._boundEventHandler) {
this.element.removeEventListener("keydown", this._boundEventHandler, true);
}
this._boundEventHandler = null;
},
/**
* Initializes the `env` object with pointers to key objects of the page.
*/
initializeEnvironment: function () {
this.env || (this.env = {});
this.env.element = this.element;
this.env.document = this.element.ownerDocument;
this.env.window = this.env.document.defaultView || this.env.document.parentWindow || window;
this.env.frame = this.env.window.frameElement;
this.env.selection = this.selection = new ice.Selection(this.env);
},
/**
* Initializes the internal range object and sets focus to the editing element.
*/
initializeRange: function () {
},
/**
* Initializes the content in the editor - cleans non-block nodes found between blocks and
* initializes the editor with any tracking tags found in the editing element.
*/
initializeEditor: function () {
this._loadFromDom(); // refactored by dfl
this._updateTooltipsState(); // dfl
},
/**
* Check whether or not this tracker is tracking changes.
* @return {Boolean} Is this tracker tracking?
*/
isTracking: function() {
return this._isTracking;
},
/**
* Turn on change tracking and event handling.
*/
enableChangeTracking: function () {
this._isTracking = true;
},
/**
* Turn off change tracking and event handling.
*/
disableChangeTracking: function () {
this._isTracking = false;
},
/**
* Sets or toggles the tracking and event handling state.
* @param {Boolean} bTrack if undefined, the tracking state is toggled, otherwise set to the parameter
*/
toggleChangeTracking: function (bTrack) {
bTrack = (undefined === bTrack) ? ! this._isTracking : Boolean(bTrack);
this._isTracking = bTrack;
},
/**
* Gets the current user
* @return {Object} an object with the properties id, name
*/
getCurrentUser: function() {
var u = this.currentUser || {},
id = (u.id === null || u.id === undefined) ? "" : String(u.id);
return {name: u.name || "", id: id};
},
/**
* Set the user to be tracked.
* @param {Object} inUser and object has the following properties:
* {`id`, `name`}
*/
setCurrentUser: function (inUser) {
var user = {};
inUser = inUser || {};
user.name = inUser.name? String(inUser.name) : "";
if (inUser.id !== undefined && inUser.id !== null) {
user.id = String(inUser.id);
}
else {
user.id = "";
}
this.currentUser = user;
for (var key in this._changes) {
var change = this._changes[key];
if (change.userid == user.id) {
change.username = user.name;
}
}
var nodes = this.getIceNodes(),
userId,
userIdAttr = this.attributes.userId;
nodes.each((function(i,node) {
userId = node.getAttribute(userIdAttr);
if (userId === null || userId === user.id) {
node.setAttribute(this.attributes.userName, user.name);
}
}).bind(this));
},
/**
* Set the session id. If the session id is not null, the tracker aggregates change span
* from the same user only if they have the same session id
*/
setSessionId: function (sid) {
this._sessionId = sid;
},
/**
* Sets or toggles the tooltips state.
* @param {Boolean} bTooltips if undefined, the tracking state is toggled, otherwise set to the parameter
*/
toggleTooltips: function(bTooltips) {
bTooltips = (undefined === bTooltips) ? ! this.tooltips : Boolean(bTooltips);
this.tooltips = bTooltips;
this._updateTooltipsState();
},
visible: function(el) {
if(el.nodeType === ice.dom.TEXT_NODE) el = el.parentNode;
var rect = el.getBoundingClientRect();
return ( rect.top > 0 && rect.left > 0);
},
/**
* Returns a tracking tag for the given `changeType`, with the optional `childNode` appended.
* @private
*/
_createIceNode: function (changeType, childNode, changeId) {
var node = this.env.document.createElement(this.changeTypes[changeType].tag);
node.setAttribute("class", this._getIceNodeClass(changeType));
if (childNode) {
node.appendChild(childNode);
}
this._addChange(changeType, [node], changeId);
return node;
},
/**
* Inserts the given string/node into the given range with tracking tags, collapsing (deleting)
* the range first if needed. If range is undefined, then the range from the Selection object
* is used. If the range is in a parent delete node, then the range is positioned after the delete.
* @param options may contain <strong>nodes</strong> (DOM element or array of dom elements) or <strong>text</strong> (string).
* @return {Boolean} true if the action should continue, false if the action was finished in the insert sequence
*/
insert: function (options) {
this.hostMethods.beforeInsert && this.hostMethods.beforeInsert();
var _rng = this.getCurrentRange(),
range = this._isRangeInElement(_rng, this.element),
hostRange = range ? null : this.hostMethods.getHostRange(),
changeid = this._startBatchChange(),
hadSelection = Boolean(range && !range.collapsed),
ret = false;
options = options || {};
// If we have any nodes selected, then we want to delete them before inserting the new text.
try {
if (hadSelection) {
this._deleteContents(false, range);
// Update the range
range = this.getCurrentRange();
}
if (range || hostRange) {
var nodes = options.nodes;
if (nodes && ! $.isArray(nodes)) {
nodes = [nodes];
}
// If we are in a non-tracking/void element, move the range to the end/outside.
this._moveRangeToValidTrackingPos(range, hostRange);
// insertnodes returns true if the text was inserted
ret = this._insertNodes(range, hostRange, {nodes: nodes, text: options.text, insertStubText: options.insertStubText !== false});
}
}
catch(e) {
logError(e, "while trying to insert nodes");
}
finally {
this._endBatchChange(changeid, nodes || options.text || ret);
}
return ret;//isPropagating;
},
/**
* Deletes the contents in the given range or the range from the Selection object. If the range
* is not collapsed, then a selection delete is handled; otherwise, it deletes one character
* to the left or right if the right parameter is false or true, respectively.
* @return true if deletion was handled.
* @private
*/
_deleteContents: function (right, range) {
var prevent = true, changeid,
browser = this._browser;
this.hostMethods.beforeDelete && this.hostMethods.beforeDelete();
if (range) {
this.selection.addRange(range);
}
else {
range = this.getCurrentRange();
}
changeid = this._startBatchChange();
try {
if (range.collapsed === false) {
range = this._deleteSelection(range);
/* if(this._browser.mozilla){
if(range.startContainer.parentNode.previousSibling){
range.setEnd(range.startContainer.parentNode.previousSibling, 0);
range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer));
}
else {
range.setEndAfter(range.startContainer.parentNode);
}
range.collapse(false);
}
else { */
if(range && ! this.visible(range.endContainer)) {
range.setEnd(range.endContainer, Math.max(0, range.endOffset - 1));
range.collapse(false);
}
// }
}
else {
this._cleanupSelection(range, false, true);
// if we're inside a current insert range, let the editor take care of the deletion
if (this._isCurrentUserIceNode(this._getIceNode(range.startContainer, INSERT_TYPE))) {
return false;
}
if (right) {
// RIGHT DELETE
if(browser["type"] === "mozilla"){
prevent = this._deleteRight(range);
// Handling track change show/hide
if(!this.visible(range.endContainer)){
if(range.endContainer.parentNode.nextSibling){
// range.setEnd(range.endContainer.parentNode.nextSibling, 0);
range.setEndBefore(range.endContainer.parentNode.nextSibling);
} else {
range.setEndAfter(range.endContainer);
}
range.collapse(false);
}
}
else {
// Calibrate Cursor before deleting
if(range.endOffset === ice.dom.getNodeCharacterLength(range.endContainer)){
var next = range.startContainer.nextSibling;
if ($(next).is(this._deleteSelector)) {
while(next){
if ($(next).is(this._deleteSelector)) {
next = next.nextSibling;
continue;
}
range.setStart(next, 0);
range.collapse(true);
break;
}
}
}
// Delete
prevent = this._deleteRight(range);
// Calibrate Cursor after deleting
if(!this.visible(range.endContainer)){
if ($(range.endContainer.parentNode).is(this._iceSelector)) {
// range.setStart(range.endContainer.parentNode.nextSibling, 0);
range.setStartAfter(range.endContainer.parentNode);
range.collapse(true);
}
}
}
}
else {
// LEFT DELETE
if(browser.mozilla){
prevent = this._deleteLeft(range);
// Handling track change show/hide
if(!this.visible(range.startContainer)){
if(range.startContainer.parentNode.previousSibling){
range.setEnd(range.startContainer.parentNode.previousSibling, 0);
} else {
range.setEnd(range.startContainer.parentNode, 0);
}
range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer));
range.collapse(false);
}
}
else {
if(!this.visible(range.startContainer)){
if(range.endOffset === ice.dom.getNodeCharacterLength(range.endContainer)){
var prev = range.startContainer.previousSibling;
if ($(prev).is(this._deleteSelector)) {
while(prev){
if ($(prev).is(this._deleteSelector)) {
prev = prev.prevSibling;
continue;
}
range.setEndBefore(prev.nextSibling, 0);
range.collapse(false);
break;
}
}
}
}
prevent = this._deleteLeft(range);
}
}
}
range && this.selection.addRange(range);
}
finally {
this._endBatchChange(changeid, prevent);
}
return prevent;
},
/**
* Returns the changes - a hash of objects with the following properties:
* [changeid] => {`type`, `time`, `userid`, `username`, `lastTime`, `data`}
* @param {LITE.AcceptRejectOptions} [options=null] filtering options for the changes to be accepted
*/
getChanges: function (options) {
var changes = options ? this._filterChanges(options) : this._changes;
return $.extend({}, changes);
},
/**
* Returns an array with the user ids who made the changes
*/
getChangeUserids: function () {
var self = this,
keys = Object.keys(this._changes),
result = keys.map(function(key) {
return self._changes[keys[key]].userid
});
// probably makes the list unique
return result.sort().filter(function (el, i, a) {
if (i === a.indexOf(el)) return 1;
return 0;
});
},
/**
* Returns the html contents for the tracked element.
*/
getElementContent: function () {
return this.element.innerHTML;
},
/**
* Returns the html contents, without tracking tags, for `this.element` or
* the optional `body` param which can be of either type string or node.
* Delete tags, and their html content, are completely removed; all other
* change type tags are removed, leaving the html content in place. After
* cleaning, the optional `callback` is executed, which should further
* modify and return the element body.
*
* prepare gets run before the body is cleaned by ice.
*/
getCleanContent: function (body, callback, prepare) {
var newBody = this.getCleanDOM(body, {callback:callback, prepare: prepare, clone: true});
return (newBody && newBody.innerHTML) || "";
},
/**
* Returns a clone of the DOM, without tracking tags, for `this.element` or
* the optional `body` param which can be of either type string or node.
* Delete tags, and their html content, are completely removed; all other
* change type tags are removed, leaving the html content in place.
* @param body If not null, the node or html to process
* @param options may contain:
* <ul><li>callback - executed after cleaning, should return the processed body</li>
* <li>clone If true, process a clone of the target element</li>
* <li>prepare function to run on body before the cleaning</li>
*/
getCleanDOM : function(body, options) {
var classList = '',
self = this;
options = options || {};
$.each(this.changeTypes, function (type, i) {
if (type !== DELETE_TYPE) {
if (i > 0) {
classList += ',';
}
classList += '.' + self._getIceNodeClass(type);
}
});
if (body) {
if (typeof body === 'string') {
body = $('<div>' + body + '</div>');
}
else if (options.clone){
body = $(body).clone()[0];
}
}
else {
body = options.clone? $(this.element).clone()[0] : this.element;
}
return this._cleanBody(body, classList, options);
},
_cleanBody: function(body, classList, options) {
body = options.prepare ? options.prepare.call(this, body) : body;
var $body = $(body), deletes,
changes = $body.find(classList);
$.each(changes, function (i,el) {
while (el.firstChild) {
el.parentNode.insertBefore(el.firstChild, el);
}
el.parentNode.removeChild(el);
});
$body.find(this._deleteSelector).remove();
body = options.callback ? options.callback.call(this, body) : body;
return body;
},
/**
* Accepts all changes in the element body - removes delete nodes, and removes outer
* insert tags keeping the inner content in place.
* @param {LITE.AcceptRejectOptions} options=null filtering options for the changes to be accepted
*/
acceptAll: function (options) {
if (options) {
return this._acceptRejectSome(options, true);
}
else {
this.getCleanDOM(this.element, {
clone: false
});
this._changes = {}; // dfl, reset the changes table
this._triggerChange({ isText: true }); // notify the world that our change count has changed
}
},
/**
* Rejects all changes in the element body - removes insert nodes, and removes outer
* delete tags keeping the inner content in place.*
* @param {LITE.AcceptRejectOptions} options=null filtering options for the changes to be accepted
*/
rejectAll: function (options) {
if (options) {
return this._acceptRejectSome(options, false);
}
else {
var insSel = this._insertSelector,
delSel = this._deleteSelector,
content, self = this,
$element = $(this.element);
$element.find(insSel).each(function(i,e) {
self._removeNode(e);
});
$element.find(delSel).each(
function (i, el) {
content = ice.dom.contents(el);
ice.dom.replaceWith(el, content);
$.each(content, function(i,e) {
var parent = e && e.parentNode;
self._normalizeNode(parent);
});
});
this._changes = {}; // dfl, reset the changes table
this._triggerChange({ isText: true }); // notify the world that our change count has changed
}
},
/**
* Accepts the change at the given, or first tracking parent node of, `node`. If
* `node` is undefined then the startContainer of the current collapsed range will be used.
* In the case of insert, inner content will be used to replace the containing tag; and in
* the case of delete, the node will be removed.
*/
acceptChange: function (node) {
this.acceptRejectChange(node, { isAccept: true });
},
/**
* Rejects the change at the given, or first tracking parent node of, `node`. If
* `node` is undefined then the startContainer of the current collapsed range will be used.
* In the case of delete, inner content will be used to replace the containing tag; and in
* the case of insert, the node will be removed.
*/
rejectChange: function (node) {
this.acceptRejectChange(node, { isAccept: false });
},
/**
* Handles accepting or rejecting tracking changes
*/
acceptRejectChange: function (node, options) {
var delSel, insSel, selector, removeSel, replaceSel,
trackNode, changes, dom = ice.dom, nChanges,
self = this, changeId, content, userStyle,
$element = $(this.element),
userStyles = this._userStyles,
userId, userAttr = this.attributes.userId,
delClass = this._getIceNodeClass(DELETE_TYPE),
insClass = this._getIceNodeClass(INSERT_TYPE),
isAccept = options && options.isAccept,
dontNotify = options && (options.notify === false);
if (!node) {
var range = this.getCurrentRange();
if (! range || !range.collapsed) {
return;
}
node = range.startContainer;
}
delSel = removeSel = '.' + delClass;
insSel = replaceSel = '.' + insClass;
if (!isAccept) {
removeSel = insSel;
replaceSel = delSel;
}
selector = delSel + ',' + insSel;
trackNode = dom.getNode(node, selector);
changeId = trackNode.getAttribute(this.attributes.changeId);
// Some changes are done in batches so there may be other tracking
// nodes with the same `changeIdAttribute` batch number.
changes = $element.find(removeSel + '[' + this.attributes.changeId + '=' + changeId + ']');
nChanges = changes.length;
changes.each(function(i, changeNode) {
self._removeNode(changeNode);
});
// we handle the replaced nodes after the deleted nodes because, well, the engine may b buggy, resulting in some nesting
changes = $element.find(replaceSel + '[' + this.attributes.changeId + '=' + changeId + ']');
nChanges += changes.length;
$.each(changes, function (i, node) {
if (isNewlineNode(node)) {
return stripNode(node);
}
userId = node.getAttribute(userAttr);
userStyle = userId !== null ? userStyles[userId] || "" :"";
content = ice.dom.contents(node);
// work around a situation where the browser extracts the node style and applies it to the content
$(node).removeClass(insClass + ' ' + delClass + ' ' + userStyle);
dom.replaceWith(node, content);
$.each(content, function(i,e) {
var txt = ice.dom.TEXT_NODE == e.nodeType && e.nodeValue;
if (txt) {
var found = false;
while (txt.indexOf(" ") >= 0) {
found = true;
txt = txt.replace(" ", " \u00a0"); // replace two spaces with space+nbsp
}
if (found) {
e.nodeValue = txt;
}
}
var parent = e && e.parentNode;
self._normalizeNode(parent);
});
});
/* begin dfl: if changes were accepted/rejected, remove change trigger change event */
delete this._changes[changeId];
if (nChanges > 0 && ! dontNotify) {
this._triggerChange({ isText: true });
}
/* end dfl */
},
/**
* Returns true if the given `node`, or the current collapsed range is in a tracking
* node; otherwise, false.
* @param node The node to test or null to test the selection
* @param onlyNode if true, test only the node
* @param cleanupDOM - if false, don't mess with the selection, just test
*/
isInsideChange: function (node, onlyNode, cleanupDOM) {
try {
return Boolean(this.currentChangeNode(node, onlyNode, cleanupDOM));
}
catch (e) {
logError(e, "While testing if isInsideChange");
return false;
}
},
/**
* Returns a jquery list of all the tracking nodes in the current editable element
*/
getIceNodes : function() {
var classList = [];
var self = this;
$.each(this.changeTypes, // iterate over type map
function (type) {
classList.push('.' + self._getIceNodeClass(type));
});
classList = classList.join(',');
return $(this.element).find(classList);
},
/**
* Returns this `node` or the first parent tracking node with the given `changeType`.
* @private
*/
_getIceNode: function (node, changeType) {
var selector = this.changeTypes[changeType].tag + '.' + this._getIceNodeClass(changeType);
return ice.dom.getNode((node && node.$) || node, selector);
},
_isNodeOfChangeType: function(node, changeType) {
if (! node) {
return false;
}
var selector = '.' + this._getIceNodeClass(changeType);
return $(node.$ || node).is(selector);
},
_isInsertNode: function(node) {
return this._isNodeOfChangeType(node, INSERT_TYPE);
},
_isDeleteNode: function(node) {
return this._isNodeOfChangeType(node, DELETE_TYPE);
},
_normalizeNode: function(node) {
return ice.dom.normalizeNode(node, this._browser.msie);
},
/**
* Sets the given `range` to the first position, to the right, where it is outside of
* void elements.
* @private
*/
_moveRangeToValidTrackingPos: function (range, hostRange) {
// set range to hostRange if available
if (! (range = (hostRange || range))) {
return;
}
var voidEl,
el, searchBack = -1, elNode,
visited = [], newEdge, edgeNode,
fnode = hostRange ? this.hostMethods.getHostNode : nativeElement,
found = false;
while (! found) {
el = range.startContainer;
if (! el || visited.indexOf(el) >= 0) {
return; // loop
}
elNode = fnode(el);
visited.push(el);
voidEl = this._getVoidElement(elNode);
if (voidEl) {
if ((voidEl !== el) && (visited.indexOf(voidEl) >= 0)) {
return; // loop
}
visited.push(voidEl);
}
else {
found = ice.dom.isTextContainer(elNode);
}
if (! found) { // in void element or non text container
if (-1 == searchBack) {
searchBack = ! isOnRightEdge(fnode(range.startContainer), range.startOffset);
}
newEdge = searchBack ? ice.dom.findPrevTextContainer(voidEl || elNode, this.element) :
ice.dom.findNextTextContainer(voidEl || elNode, this.element);
edgeNode = newEdge.node;
// we have a new edge node
if (hostRange) {
edgeNode = this.hostMethods.makeHostElement(edgeNode);
}
try {
if (searchBack) {
range.setStart(edgeNode, newEdge.offset);
}
else {
range.setEnd(edgeNode, newEdge.offset);
}
range.collapse(searchBack);
}
catch (e) { // if we can't set the selection for whatever reason, end of document etc., break
logError(e, "While trying to move range to valid tracking position");
break;
}
}
}
},
/**
* Utility function
* Returns the range if its startcontainer is a descendant of (or equal to) the given top element
* @private
*/
_isRangeInElement: function(range, top) {
var start = range && range.startContainer;
while (start) {
if (start == top) {
return range;
}
start = start.parentNode;
}
return null;
},
/**
* Returns the given `node` or the first parent node that matches against the list of void elements.
* dfl: added try/catch
* @private
*/
_getVoidElement: function (node) {
try {
var voidParent = this._getIceNode(node, DELETE_TYPE);
if (! voidParent) {
if (3 == node.nodeType && node.nodeValue == '\u200B') {
return node;
}
}
return voidParent;
}
catch(e) {
logError(e, "While trying to get void element of", node);
return null;
}
},
/**
* @private
* If the range is collapsed, removes empty nodes around the caret
* @param range the range to clean up
* @param isHostRange if true, the range is a ckeditor range
* @param changeSelection if true, the selected node can also be cleaned up
*/
_cleanupSelection: function(range, isHostRange, changeSelection) {
var start;
if (! range || ! range.collapsed || ! (start = range.startContainer)) {
return;
}
if (isHostRange) {
start = this.hostMethods.getHostNode(start);
}
var nt = start.nodeType;
if (ice.dom.TEXT_NODE == nt) {
return this._cleanupTextSelection(range, start, isHostRange, changeSelection);
}
else {
return this._cleanupElementSelection(range, isHostRange);
}
},
/**
* @private
* assumes range is valid for this operation
*/
_cleanupTextSelection: function(range, start, isHostRange, changeSelection) {
this._cleanupAroundNode(start);
if (changeSelection && ice.dom.isEmptyTextNode(start)) {
var parent = start.parentNode,
ind = ice.dom.getNodeIndex(start),
f = isHostRange ? this.hostMethods.makeHostElement : nativeElement;
parent.removeChild(start);
ind = Math.max(0, ind);
range.setStart(f(parent), ind);
range.setEnd(f(parent), ind);
}
},
/**
* @private
* assumes range is valid for this operation
*/
_cleanupElementSelection: function(range, isHostRange) {
var start, includeStart = false,
parent = isHostRange ? this.hostMethods.getHostNode(range.startContainer) : range.startContainer,
childCount = parent.childNodes.length;
if (childCount < 1) {
return;
}
try {
if (range.startOffset > 0) {
start = parent.childNodes[range.startOffset - 1];
}
else {
start = parent.firstChild;
includeStart = true;
}
if (! start) {
return;
}
}
catch(e) {
return;
}
this._cleanupAroundNode(start, includeStart);
if (range.startOffset === 0) {
return;
}
var ind = ice.dom.getNodeIndex(start) + 1;
if (ice.dom.isEmptyTextNode(start)) {
ind = Math.max(0, ind - 1);
parent.removeChild(start);
}
if (parent.childNodes.length !== childCount) {
var f = isHostRange ? this.hostMethods.makeHostElement : nativeElement;
range.setStart(f(parent), ind);
range.setEnd(f(parent), ind);
}
},
_cleanupAroundNode: function(node, includeNode) {
var parent = node.parentNode,
anchor = node.nextSibling,
isEmpty,
tmp;
while (anchor) {
isEmpty = ($(anchor).is(this._iceSelector) && ice.dom.hasNoTextOrStubContent(anchor))
|| ice.dom.isEmptyTextNode(anchor);
if (isEmpty) {
tmp = anchor;
anchor = anchor.nextSibling;
parent.removeChild(tmp);
}
else {
anchor = anchor.nextSibling;
}
}
anchor = node.previousSibling;
while (anchor) {
isEmpty = ($(anchor).is(this._iceSelector) && ice.dom.hasNoTextOrStubContent(anchor))
|| ice.dom.isEmptyTextNode(anchor);
if (isEmpty) {
tmp = anchor;
anchor = anchor.previousSibling;
parent.removeChild(tmp);
}
else {
anchor = anchor.previousSibling;
}
}
if (includeNode && ice.dom.isEmptyTextNode(node)) {
parent.removeChild(node);
}
},
/**
* Returns true if node has a user id attribute that matches the current user id.
* @private
*/
_isCurrentUserIceNode: function (node) {
var ret = Boolean(node && $(node).attr(this.attributes.userId) === this.currentUser.id);
if (ret && this._sessionId) {
ret = node.getAttribute(this.attributes.sessionId) === this._sessionId;
}
return ret;
},
/**
* With the given alias, searches the changeTypes objects and returns the
* associated key for the alias.
* @private
*/
_getChangeTypeFromAlias: function (alias) {
var type, ctnType = null;
for (type in this.changeTypes) {
if (this.changeTypes.hasOwnProperty(type)) {
if (this.changeTypes[type].alias == alias) {
ctnType = type;
}
}
}
return ctnType;
},
/**
* @private
*/
_getIceNodeClass: function (changeType) {
return this.attrValuePrefix + this.changeTypes[changeType].alias;
},
/**
* @private
*/
_getUserStyle: function (userid) {
if (userid === null || userid === "" || "undefined" == typeof userid) {
return this.stylePrefix;
}
var styleIndex = null;
if (this._userStyles[userid]) {
styleIndex = this._userStyles[userid];
}
else {
styleIndex = this._setUserStyle(userid, this._getNewStyleId());
}
return styleIndex;
},
/**
* @private
*/
_setUserStyle: function (userid, styleIndex) {
var style = this.stylePrefix + '-' + styleIndex;
if (!this._styles[styleIndex]) {
this._styles[styleIndex] = true;
}
return this._userStyles[userid] = style;
},
_getNewStyleId: function () {
var id = ++this._uniqueStyleIndex;
if (this._styles[id]) {
// Dupe.. create another..
return this._getNewStyleId();
}
else {
this._styles[id] = true;
return id;
}
},
_addChange: function (ctnType, ctNodes, changeIdToUse) {
var changeid = changeIdToUse || this._batchChangeId || this.getNewChangeId(),
self = this;
if (!this._changes[changeid]) {
var now = (new Date()).getTime();
// Create the change object.
this._changes[changeid] = {
type: ctnType,
time: now,
lastTime: now,
sessionId: this._sessionId,
userid: String(this.currentUser.id),// dfl: must stringify for consistency - when we read the props from dom attrs they are strings
username: this.currentUser.name,
data : this._changeData || ""
};
this._triggerChange({ text: false }); //dfl
}
$.each(ctNodes, function (i) {
self._addNodeToChange(changeid, ctNodes[i]);
});
return changeid;
},
/**
* Adds tracking attributes from the change with changeid to the ctNode.
* @param changeid Id of an existing change.
* @param ctNode The element to add for the change.
* @private
*/
_addNodeToChange: function (changeid, ctNode) {
var change = this.getChange(changeid),
attributes = {};
if (!ctNode.getAttribute(this.attributes.changeId)) {
attributes[this.attributes.changeId] = changeid;
}
// handle missing userid, try to set username according to userid
var userId = ctNode.getAttribute(this.attributes.userId);
if (! userId) {
userId = change.userid;
attributes[this.attributes.userId] = userId;
}
if (userId == change.userid) {
attributes[this.attributes.userName] = change.username;
}
// add change data
var changeData = ctNode.getAttribute(this.attributes.changeData);
if (null === changeData) {
attributes[this.attributes.changeData] = this._changeData || "";
}
if (!ctNode.getAttribute(this.attributes.time)) {
attributes[this.attributes.time] = change.time;
}
if (!ctNode.getAttribute(this.attributes.lastTime)) {
attributes[this.attributes.lastTime] = change.lastTime;
}
if (change.sessionId && ! ctNode.getAttribute(this.attributes.sessionId)) {
attributes[this.attributes.sessionId] = change.sessionId;
}
if (! change.style) {
change.style = this._getUserStyle(change.userid);
}
$(ctNode).attr(attributes).addClass(change.style);
/* Added by dfl */
this._updateNodeTooltip(ctNode);
},
getChange: function (changeid) {
return this._changes[changeid] || null;
},
getNewChangeId: function () {
var id = ++this._uniqueIDIndex;
if (this._changes[id]) {
// Dupe.. create another..
id = this.getNewChangeId();
}
return id;
},
/**
* @private
* Start a batch change if none is already underway
* @return a change id if a new batch has been started, otherwise null
*/
_startBatchChange: function () {
return this._batchChangeId ? null :
(this._batchChangeId = this.getNewChangeId());
},
/**
* Returns the top level DOM element handled by this change tracker
*/
getContentElement: function() {
return this.element;
},
/**
* @private
* End the batch change
* @param changeid If not identical to the current change id, no action is taken
* this allows callers to start a batch change but end it only if the change was really started by the caller
* @param wasTextChanged if true, notify that text was changed in this batch
*/
_endBatchChange: function (changeid, wasTextChanged) {
if (changeid && (changeid === this._batchChangeId)) {
this._batchChangeId = null;
if (wasTextChanged) {
this._triggerChange({ isText: true });
}
}
},
getCurrentRange: function () {
try {
return this.selection.getRangeAt(0);
}
catch (e) {
logError(e, "While trying to get current range");
return null;
}
},
_insertNodes: function (_range, hostRange, _data) {
var range = hostRange || _range,
data = _data || {},
_start = range.startContainer,
start = (_start && _start.$) || _start,
f = hostRange ? this.hostMethods.makeHostElement : nativeElement,
nodes = data.nodes,
insertStubText = data.insertStubText !== false,
text = data.text, i, len,
doc= this.env.document,
inserted = false;
var ctNode = this._getIceNode(start, INSERT_TYPE),
inCurrentUserInsert = this._isCurrentUserIceNode(ctNode);
this._cleanupSelection(range, Boolean(hostRange), true);
if (inCurrentUserInsert) {
var head = nodes && nodes[0],
changeId = ctNode.getAttribute(this.attributes.changeId);
if (head) {
inserted = true;
range.insertNode(f(head));
var parent = head.parentNode,
sibling = head.nextSibling;
len = nodes.length;
for (i = 1; i < len; ++i) {
if (sibling) {
parent.insertBefore(nodes[i], sibling);
}
else {
parent.appendChild(nodes[i]);
}
}
/* Now move the caret to the end of the last node inserted */
var tail = nodes[len - 1];
if (ice.dom.TEXT_NODE == tail.nodeType) {
range.setEnd(tail, (tail.nodeValue && tail.nodeValue.length) || 0);
}
else {
range.setEndAfter(tail);
}
range.collapse();
if (hostRange) {
this.hostMethods.setHostRange(hostRange);
}
else {
this.selection.addRange(range);
}
}
else {
prepareSelectionForInsert(null, range, doc, true);
}
// even if there was no data to insert, we are probably setting up for a char insertion
this._updateChangeTime(changeId);
}
else {
// If we aren't in an insert node which belongs to the current user, then create a new ins node
var node = this._createIceNode(INSERT_TYPE);
if (ctNode) {
var nChildren = ctNode.childNodes.length;
this._normalizeNode(ctNode);
if (nChildren !== ctNode.childNodes.length) { // normalization removed nodes, refresh range
if (hostRange) {
hostRange = range = this.hostMethods.getHostRange();
}
else {
range.refresh();
}
}
if (ctNode) {
var end = (hostRange && this.hostMethods.getHostNode(hostRange.endContainer)) || range.endContainer;
// if inserting before the end of a tracked node by another user
if ((end.nodeType == 3 && range.endOffset < range.endContainer.length) || (end !== ctNode.lastChild)) {
ctNode = this._splitNode(ctNode, range.endContainer, range.endOffset);
}
}
}
if (ctNode) {
range.setStartAfter(f(ctNode));
range.collapse(true);
}
range.insertNode(f(node));
len = (nodes && nodes.length) || 0;
if (len) {
inserted = true;
for (i = 0; i < len; ++i) {
node.appendChild(nodes[i]);
}
range.setEndAfter(f(node.lastChild));
range.collapse();
}
else if (text) {
inserted = true;
var tn = doc.createTextNode(text);
node.appendChild(tn);
range.setEnd(tn, 1);
range.collapse();
}
else {
prepareSelectionForInsert(node, range, doc, insertStubText);
}
if (hostRange) {
this.hostMethods.setHostRange(hostRange);
}
else {
this.selection.addRange(range);
}
}
return inserted;
},
/**
* @private
* updates the change with the current time stamp and copies to change nodes
*/
_updateChangeTime: function(changeId) {
var change = this._changes[changeId];
if (change) {
var now = (new Date()).getTime(),
nodes = $(this.element).find('[' + this.attributes.changeId + '=' + changeId + ']'),
attr = this.attributes.lastTime;
change.lastTime = now;
nodes.each(function(index, node) {
node.setAttribute(attr, now);
});
}
},
_handleVoidEl: function(el, range) {
// If `el` is or is in a void element, but not a delete
// then collapse the `range` and return `true`.
var voidEl = el && this._getVoidElement(el);
if (voidEl && !this._getIceNode(voidEl, DELETE_TYPE)) {
range.collapse(true);
return true;
}
return false;
},
_deleteSelection: function (range) {
// Bookmark the range and get elements between.
var bookmark = new ice.Bookmark(this.env, range),
elements = ice.dom.getElementsBetween(bookmark.start, bookmark.end),
betweenBlocks = [],
deleteNodes = [], // used to collect the new deletion nodes
addDeleteOptions = { deleteNodesCollection: deleteNodes, moveLeft: true, range: null };
// elements length may change during the loop so don't optimize
for (var i = 0; i < elements.length; i++) {
var elem = elements[i];
if (! elem || ! elem.parentNode) { // maybe removed as a side effect of removing other stuff
continue;
}
if (ice.dom.isBlockElement(elem)) {
betweenBlocks.push(elem);
if (!ice.dom.canContainTextElement(elem)) {
// Ignore containers that are not supposed to contain text. Check children instead.
for (var k = 0; k < elem.childNodes.length; k++) {
elements.push(elem.childNodes[k]);
}
continue;
}
}
// Ignore empty space nodes
if (ice.dom.isEmptyTextNode(elem)) {
this._removeNode(elem);
continue;
}
if (!this._getVoidElement(elem)) {
// If the element is not a text or stub node, go deeper and check the children.
if (elem.nodeType !== ice.dom.TEXT_NODE) {
// Browsers like to insert breaks into empty paragraphs - remove them
if (isBRNode(elem)) {
this._addDeleteTrackingToBreak(elem, addDeleteOptions);
continue;
}
if (ice.dom.isStubElement(elem)) {
this._addDeleteTracking(elem, addDeleteOptions);
continue;
}
if (ice.dom.hasNoTextOrStubContent(elem)) {
this._removeNode(elem);
continue;
}
// if (isParagraphNode(elem)) {
// this._addDeleteTrackingToBreak(elem, addDeleteOptions);
// }
for (var j = 0; j < elem.childNodes.length; j++) {
var child = elem.childNodes[j];
elements.push(child);
}
continue;
}
var parentBlock = ice.dom.getBlockParent(elem);
this._addDeleteTracking(elem, addDeleteOptions);
if (ice.dom.hasNoTextOrStubContent(parentBlock)) {
ice.dom.remove(parentBlock);
}
}
}
if (deleteNodes.length) {
bookmark.remove();
this._cleanupAroundNode(deleteNodes[0]);
range.setStartBefore(deleteNodes[0]);
range.collapse(true);
this.selection.addRange(range);
}
else {
bookmark.selectStartAndCollapse();
if (range = this.getCurrentRange()) {
this._cleanupSelection(range, false, false);
range = this.getCurrentRange();
}
}
return range;
},
/**
* Deletes to the right (delete key)
* @private
*/
_deleteRight: function (range) {
var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null,
isEmptyBlock = parentBlock ? (ice.dom.hasNoTextOrStubContent(parentBlock)) : false,
nextBlock = parentBlock && ice.dom.getNextContentNode(parentBlock, this.element),
nextBlockIsEmpty = nextBlock ? (ice.dom.hasNoTextOrStubContent(nextBlock)) : false,
initialContainer = range.endContainer,
initialOffset = range.endOffset, i,
commonAncestor = range.commonAncestorContainer,
nextContainer, returnValue = false;
// If the current block is empty then let the browser handle the delete/event.
if (isEmptyBlock) {
return false;
}
// Some bugs in Firefox and Webkit make the caret disappear out of text nodes, so we try to put them back in.
if (isBRNode(commonAncestor)) {
this._addDeleteTrackingToBreak(commonAncestor, {range: range});
return true;
}
if (commonAncestor.nodeType !== ice.dom.TEXT_NODE) {
// If placed at the beginning of a container that cannot contain text, such as an ul element, place the caret at the beginning of the first item.
if (initialOffset === 0 && ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) {
var firstItem = commonAncestor.firstElementChild;
if (firstItem) {
range.setStart(firstItem, 0);
range.collapse();
return this._deleteRight(range);
}
}
if (commonAncestor.childNodes.length > initialOffset) {
var next = commonAncestor.childNodes[initialOffset];
if (isBRNode(next)) {
this._addDeleteTrackingToBreak(next, {range: range});
return true;
}
range.setStart(commonAncestor.childNodes[initialOffset], 0);
range.collapse(true);
returnValue = this._deleteRight(range);
range.refresh();
return returnValue;
}
else {
nextContainer = ice.dom.getNextContentNode(commonAncestor, this.element);
if (nextContainer) {
if (isBRNode(nextContainer)) {
this._addDeleteTrackingToBreak(nextContainer, { range: range });
return true;
}
range.setEnd(nextContainer, 0);
}
range.collapse();
return this._deleteRight(range);
}
}
// Move range to position the cursor on the inside of any adjacent container that it is going
// to potentially delete into or after a stub element. E.G.: test|<em>text</em> -> test<em>|text</em> or
// text1 |<img> text2 -> text1 <img>| text2
try {
range.moveEnd(ice.dom.CHARACTER_UNIT, 1);
range.moveEnd(ice.dom.CHARACTER_UNIT, -1);
}
catch (ignore){}
// Handle cases of the caret is at the end of a container or placed directly in a block element
if (initialOffset === initialContainer.data.length && (!ice.dom.hasNoTextOrStubContent(initialContainer))) {
nextContainer = ice.dom.getNextNode(initialContainer, this.element);
// If the next container is outside of ICE then do nothing.
if (!nextContainer) {
range.selectNodeContents(initialContainer);
range.collapse();
return false;
}
// If the next container is <br> element find the next node
if (isBRNode(nextContainer)) {
this._addDeleteTrackingToBreak(nextContainer, { range: range });
return true;
// nextContainer = ice.dom.getNextNode(nextContainer, this.element);
}
// If the next container is a text node, look at the parent node instead.
if (nextContainer.nodeType === ice.dom.TEXT_NODE) {
nextContainer = nextContainer.parentNode;
}
// If the next container is non-editable, enclose it with a delete ice node and add an empty text node after it to position the caret.
if (!nextContainer.isContentEditable) {
returnValue = this._addDeleteTracking(nextContainer, {range:null, moveLeft:false, merge: true});
var emptySpaceNode = this.env.document.createTextNode('');
nextContainer.parentNode.insertBefore(emptySpaceNode, nextContainer.nextSibling);
range.selectNode(emptySpaceNode);
range.collapse(true);
return returnValue;
}
if (this._handleVoidEl(nextContainer, range)) {
return true;
}
// If the caret was placed directly before a stub element, enclose the element with a delete ice node.
if (ice.dom.isChildOf(nextContainer, parentBlock) && ice.dom.isStubElement(nextContainer)) {
return this._addDeleteTracking(nextContainer, {range:range, moveLeft:false, merge:true});
}
}
if (this._handleVoidEl(nextContainer, range)) {
return true;
}
if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) {
if (this.mergeBlocks && $(ice.dom.getBlockParent(nextContainer, this.element)).is(this.blockEl)) {
// Since the range is moved by character, it may have passed through empty blocks.
// <p>text {RANGE.START}</p><p></p><p>{RANGE.END} text</p>
if (nextBlock !== ice.dom.getBlockParent(range.endContainer, this.element)) {
range.setEnd(nextBlock, 0);
}
// The browsers like to auto-insert breaks into empty paragraphs - remove them.
var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer);
for (i = 0; i < elements.length; i++) {
ice.dom.remove(elements[i]);
}
return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock);
}
else {
// If the next block is empty, remove the next block.
if (nextBlockIsEmpty) {
ice.dom.remove(nextBlock);
range.collapse(true);
return true;
}
// Place the caret at the start of the next block.
range.setStart(nextBlock, 0);
range.collapse(true);
return true;
}
}
var entireTextNode = range.endContainer,
deletedCharacter = splitTextAt(entireTextNode, range.endOffset, 1);
return this._addDeleteTracking(deletedCharacter, {range:range, moveLeft:false, merge:true});
},
/**
* Deletes to the left (backspace)
* @private
*/
_deleteLeft: function (range) {
var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null,
isEmptyBlock = parentBlock ? ice.dom.hasNoTextOrStubContent(parentBlock) : false,
prevBlock = parentBlock && ice.dom.getPrevContentNode(parentBlock, this.element), // || ice.dom.getBlockParent(parentBlock, this.element) || null,
prevBlockIsEmpty = prevBlock ? ice.dom.hasNoTextOrStubContent(prevBlock) : false,
initialContainer = range.startContainer,
initialOffset = range.startOffset,
commonAncestor = range.commonAncestorContainer,
lastSelectable, prevContainer;
// If the current block is empty, then let the browser handle the key/event.
if (isEmptyBlock) {
return false;
}
if (isBRNode(commonAncestor)) {
this._addDeleteTrackingToBreak(commonAncestor, {range: range, moveLeft: true});
return true;
}
// Handle cases of the caret is at the start of a container or outside a text node
if (initialOffset === 0 || commonAncestor.nodeType !== ice.dom.TEXT_NODE) {
// If placed at the end of a container that cannot contain text, such as an ul element, place the caret at the end of the last item.
if (ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) {
if (initialOffset === 0) {
var firstItem = commonAncestor.firstElementChild;
if (firstItem) {
range.setStart(firstItem, 0);
range.collapse();
return this._deleteLeft(range);
}
}
else {
var lastItem = commonAncestor.lastElementChild;
if (lastItem) {
lastSelectable = range.getLastSelectableChild(lastItem);
if (lastSelectable) {
range.setStart(lastSelectable, lastSelectable.data.length);
range.collapse();
return this._deleteLeft(range);
}
}
}
}
if (initialOffset === 0) {
prevContainer = ice.dom.getPrevContentNode(initialContainer, this.element);
}
else {
prevContainer = commonAncestor.childNodes[initialOffset - 1];
}
// If the previous container is outside of ICE then do nothing.
if (!prevContainer) {
return false;
}
// Firefox finds an ice node wrapped around an image instead of the image itself sometimes, so we make sure to look at the image instead.
if ($(prevContainer).is(this._iceSelector) && prevContainer.childNodes.length > 0 && prevContainer.lastChild) {
prevContainer = prevContainer.lastChild;
}
if (isBRNode(prevContainer)) {
this._addDeleteTrackingToBreak(prevContainer, { range: range, moveLeft: true });
return true;
}
// If the previous container is a text node, look at the parent node instead.
if (prevContainer.nodeType === ice.dom.TEXT_NODE) {
prevContainer = prevContainer.parentNode;
}
// If the previous container is non-editable, enclose it with a delete ice node and add an empty text node before it to position the caret.
if (!prevContainer.isContentEditable) {
var returnValue = this._addDeleteTracking(prevContainer, {range:null, moveLeft:true, merge:true});
var emptySpaceNode = document.createTextNode('');
prevContainer.parentNode.insertBefore(emptySpaceNode, prevContainer);
range.selectNode(emptySpaceNode);
range.collapse(true);
return returnValue;
}
if (this._handleVoidEl(prevContainer, range)) {
return true;
}
// If the caret was placed directly after a stub element, enclose the element with a delete ice node.
if (ice.dom.isStubElement(prevContainer) && ice.dom.isChildOf(prevContainer, parentBlock) || !prevContainer.isContentEditable) {
this._addDeleteTracking(prevContainer, {range:range, moveLeft:true, merge:true});
return true;
}
// If the previous container is a stub element between blocks
// then just delete and leave the range/cursor in place.
if (ice.dom.isStubElement(prevContainer)) {
ice.dom.remove(prevContainer);
range.collapse(true);
return false;
}
if (prevContainer !== parentBlock && !ice.dom.isChildOf(prevContainer, parentBlock)) {
if (!ice.dom.canContainTextElement(prevContainer)) {
prevContainer = prevContainer.lastElementChild;
}
// Before putting the caret into the last selectable child, lets see if the last element is a stub element. If it is, we need to put the caret there manually.
if (prevContainer.lastChild && prevContainer.lastChild.nodeType !== ice.dom.TEXT_NODE && ice.dom.isStubElement(prevContainer.lastChild) && prevContainer.lastChild.tagName !== 'BR') {
range.setStartAfter(prevContainer.lastChild);
range.collapse(true);
return true;
}
// Find the last selectable part of the prevContainer. If it exists, put the caret there.
lastSelectable = range.getLastSelectableChild(prevContainer);
if (lastSelectable && !ice.dom.isOnBlockBoundary(range.startContainer, lastSelectable, this.element)) {
range.selectNodeContents(lastSelectable);
range.collapse();
return true;
}
}
}
// Firefox: If an image is at the start of the paragraph and the user has just deleted the image using backspace, an empty text node is created in the delete node before
// the image, but the caret is placed with the image. We move the caret to the empty text node and execute deleteFromLeft again.
if (initialOffset === 1 && !ice.dom.isBlockElement(commonAncestor) && range.startContainer.childNodes.length > 1 && range.startContainer.childNodes[0].nodeType === ice.dom.TEXT_NODE && range.startContainer.childNodes[0].data.length === 0) {
range.setStart(range.startContainer, 0);
return this._deleteLeft(range);
}
// Move range to position the cursor on the inside of any adjacent container that it is going
// to potentially delete into or before a stub element. E.G.: <em>text</em>| test -> <em>text|</em> test or
// text1 <img>| text2 -> text1 |<img> text2
try {
range.moveStart(ice.dom.CHARACTER_UNIT, -1);
range.moveStart(ice.dom.CHARACTER_UNIT, 1);
}
catch(ignore){}
// Handles cases in which the caret is at the start of the block.
if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) {
// If the previous block is empty, remove the previous block.
if (prevBlockIsEmpty) {
ice.dom.remove(prevBlock);
range.collapse();
return true;
}
// If the previous Block ends with a stub element, set the caret behind it.
if (prevBlock && prevBlock.lastChild && ice.dom.isStubElement(prevBlock.lastChild)) {
range.setStartAfter(prevBlock.lastChild);
range.collapse(true);
return true;
}
// Place the caret at the end of the previous block.
lastSelectable = range.getLastSelectableChild(prevBlock);
if (lastSelectable) {
range.setStart(lastSelectable, lastSelectable.data.length);
range.collapse(true);
}
else if (prevBlock) {
range.setStart(prevBlock, prevBlock.childNodes.length);
range.collapse(true);
}
return true;
}
var entireTextNode = range.startContainer;
if (entireTextNode && (entireTextNode.nodeType === ice.dom.TEXT_NODE)) {
var deletedCharacter = splitTextAt(entireTextNode, range.startOffset - 1, 1);
this._addDeleteTracking(deletedCharacter, {range:range, moveLeft:true, merge:true});
return true;
}
return false;
},
_removeNode: function(node) {
var parent = node && node.parentNode;
if (parent) {
parent.removeChild(node);
if (parent !== this.element && ice.dom.hasNoTextOrStubContent(parent)) {
this._removeNode(parent);
}
}
},
/**
* @private
* Adds delete tracking to the provided node. The node is checked for containment in various tracking contexts
* (e.g. inside an insert block, delete block)
*/
_addDeleteTracking: function (contentNode, options) {
var moveLeft = options && options.moveLeft,
contentAddNode = this._getIceNode(contentNode, INSERT_TYPE),
ctNode, range;
options = options || {};
if (contentAddNode) {
return this._addDeletionInInsertNode(contentNode, contentAddNode, options);
}
range = options.range;
if (range && this._getIceNode(contentNode, DELETE_TYPE)) {
return this._deleteInDeleted(contentNode, options);
}
// Webkit likes to insert empty text nodes next to elements. We remove them here.
if (contentNode.previousSibling && ice.dom.isEmptyTextNode(contentNode.previousSibling)) {
contentNode.parentNode.removeChild(contentNode.previousSibling);
}
if (contentNode.nextSibling && ice.dom.isEmptyTextNode(contentNode.nextSibling)) {
contentNode.parentNode.removeChild(contentNode.nextSibling);
}
var prevDelNode = this._getIceNode(contentNode.previousSibling, DELETE_TYPE),
nextDelNode = this._getIceNode(contentNode.nextSibling, DELETE_TYPE);
if (prevDelNode && this._isCurrentUserIceNode(prevDelNode)) {
ctNode = prevDelNode;
ctNode.appendChild(contentNode);
if (nextDelNode && this._isCurrentUserIceNode(nextDelNode)) {
var nextDelContents = ice.dom.extractContent(nextDelNode);
ctNode.appendChild(nextDelContents);
nextDelNode.parentNode.removeChild(nextDelNode);
}
}
else if (nextDelNode && this._isCurrentUserIceNode(nextDelNode)) {
ctNode = nextDelNode;
ctNode.insertBefore(contentNode, ctNode.firstChild);
}
else { // not in the neighborhood of a delete node
var changeId = this.getAdjacentChangeId(contentNode, moveLeft);
ctNode = this._createIceNode(DELETE_TYPE, null, changeId);
if (options.deleteNodesCollection) {
options.deleteNodesCollection.push(ctNode);
}
contentNode.parentNode.insertBefore(ctNode, contentNode);
ctNode.appendChild(contentNode);
}
if (range) {
if (ice.dom.isStubElement(contentNode)) {
range.selectNode(contentNode);
}
else {
range.selectNodeContents(contentNode);
}
if (moveLeft) {
range.collapse(true);
}
else {
range.collapse();
}
}
if (ctNode) {
this._normalizeNode(ctNode);
range && range.refresh();
}
return true;
},
/**
* @private
* Adds delete tracking to a BR node
*/
_addDeleteTrackingToBreak: function (brNode, options) {
var moveLeft = Boolean(options && options.moveLeft);
function move() {
var range = options && options.range;
if (range) {
if (isBRNode(brNode) || ice.dom.hasNoTextOrStubContent(brNode) || moveLeft) {
if (moveLeft) {
range.setStartBefore(brNode);
range.setEndBefore(brNode);
}
else {
range.setStartAfter(brNode);
range.setEndAfter(brNode);
}
}
else if (brNode.firstChild) {
range.setStartBefore(brNode.firstChild);
range.setEndBefore(brNode.firstChild);
}
range.collapse();
}
}
if (! isBRNode(brNode)) {
logError("addDeleteTracking to BR: not a break element");
return;
}
// if this is a delete node, just move the caret
if (this._isDeleteNode(brNode)) {
return move();
}
// remove all attrs and classes from the node'
stripNode(brNode);
var type = DELETE_TYPE;
ice.dom.addClass(brNode, this._getIceNodeClass(type));
var changeId = this.getAdjacentChangeId(brNode, moveLeft);
this._addChange(type, [brNode], changeId);
move();
},
/**
* Handle the case of deletion inside a delete element
* @private
*/
_deleteInDeleted: function(contentNode, options) {
var range = options.range,
moveLeft = options.moveLeft,
ctNode;
// It if the contentNode a text node, merge it with text nodes before and after it.
this._normalizeNode(contentNode);// dfl - support ie8
var found = false;
if (moveLeft) {
// Move to the left until there is valid sibling.
var previousSibling = ice.dom.getPrevContentNode(contentNode, this.element);
while (!found) {
ctNode = this._getIceNode(previousSibling, DELETE_TYPE);
if (!ctNode) {
found = true;
}
else {
previousSibling = ice.dom.getPrevContentNode(previousSibling, this.element);
}
}
if (previousSibling) {
var lastSelectable = range.getLastSelectableChild(previousSibling);
if (lastSelectable) {
previousSibling = lastSelectable;
}
range.setStart(previousSibling, ice.dom.getNodeCharacterLength(previousSibling));
range.collapse(true);
}
}
else {
// Move the range to the right until there is valid sibling.
var nextSibling = ice.dom.getNextContentNode(contentNode, this.element);
while (!found) {
ctNode = this._getIceNode(nextSibling, DELETE_TYPE);
if (!ctNode) {
found = true;
}
else {
nextSibling = ice.dom.getNextContentNode(nextSibling, this.element);
}
}
if (nextSibling) {
range.selectNodeContents(nextSibling);
range.collapse(true);
}
}
return true;
},
/**
* @private
* Adds delete tracking markup around a content node
* @param contentNode the content to be marked as deleted
* @param contentAddNode the insert node surrounding the content
* @param options may contain range, moveLeft, deleteNodesCollection, merge
*/
_addDeletionInInsertNode: function(contentNode, contentAddNode, options) {
var range = options && options.range,
moveLeft = options && options.moveLeft;
options = options || {};
if (this._isCurrentUserIceNode(contentAddNode)) {
if (range) {
if (moveLeft) {
range.setStartBefore(contentNode);
}
else {
range.setStartAfter(contentNode);
}
range.collapse(moveLeft);
}
contentNode.parentNode.removeChild(contentNode);
if (! this._browser.msie) {
this._normalizeNode(contentAddNode);
}
var $can = $(contentAddNode),
bmCount = $can.find(".iceBookmark").length,
cleanNode;
if (bmCount > 0) {
cleanNode = $can.clone();
cleanNode.find('.iceBookmark').remove();
cleanNode = cleanNode[0];
}
else {
cleanNode = contentAddNode;
}
// Remove a potential empty tracking container
if (ice.dom.hasNoTextOrStubContent(cleanNode)) {
if (range) {
range.setStartBefore(contentAddNode);
range.collapse(true);
}
ice.dom.replaceWith(contentAddNode, ice.dom.contents(contentAddNode));
}
}
else { // other user insert
var cInd = rangy.dom.getNodeIndex(contentNode),
parent = contentNode.parentNode,
nChildren = parent.childNodes.length,
ctNode;
parent.removeChild(contentNode);
ctNode = this._createIceNode(DELETE_TYPE);
if (options.deleteNodesCollection) {
options.deleteNodesCollection.push(ctNode);
}
ctNode.appendChild(contentNode);
if (cInd > 0 && cInd >= (nChildren - 1)) {
ice.dom.insertAfter(contentAddNode, ctNode);
}
else {
if (cInd > 0) {
var splitNode = this._splitNode(contentAddNode, parent, cInd);
this._deleteEmptyNode(splitNode);
}
contentAddNode.parentNode.insertBefore(ctNode, contentAddNode);
}
this._deleteEmptyNode(contentAddNode);
if (range && moveLeft) {
range.setStartBefore(ctNode);
range.collapse(true);
this.selection.addRange(range);
}
if (options && options.merge) {
this._mergeDeleteNode(ctNode);
}
if (range) {
range.refresh();
}
}
return true;
},
/**
* @private
* Deletes a node if it does not contain anything
*/
_deleteEmptyNode: function(node) {
var parent = node && node.parentNode;
if (parent && ice.dom.hasNoTextOrStubContent(node)) {
parent.removeChild(node);
}
},
/**
* Merges a delete node with its siblings if they belong to the same user
* @private
*/
_mergeDeleteNode: function(delNode) {
var siblingDel,
content;
if (this._isCurrentUserIceNode(siblingDel = this._getIceNode(delNode.previousSibling, DELETE_TYPE))) {
content = ice.dom.extractContent(delNode);
delNode.parentNode.removeChild(delNode);
siblingDel.appendChild(content);
this._mergeDeleteNode(siblingDel);
}
else if (this._isCurrentUserIceNode(siblingDel = this._getIceNode(delNode.nextSibling, DELETE_TYPE))) {
content = ice.dom.extractContent(siblingDel);
delNode.parentNode.removeChild(siblingDel);
delNode.appendChild(content);
this._mergeDeleteNode(delNode);
}
},
/**
* If tracking is on, handles event e when it is one of the following types:
* keypress, keydown. Prevents default handling if the event
* was fully handled.
*/
handleEvent: function (e) {
if (!this._isTracking) {
return true;
}
var preventEvent = false;
if ('keypress' == e.type) {
preventEvent = this.keyPress(e);
}
else if ('keydown' == e.type) {
preventEvent = ! this.handleKeyDown(e);
}
if (preventEvent) {
e.stopImmediatePropagation();
e.preventDefault();
}
return ! preventEvent;
},
/**
* @private
* Handles arrow, delete key events, and others.
* @param {Event} e Event object.
* @return {void|boolean} Returns true if default event needs to be blocked.
*/
_handleAncillaryKey: function (key) {
var browser = this._browser,
preventDefault = false,
self = this,
range = self.getCurrentRange();
switch (key) {
case ice.dom.DOM_VK_DELETE:
preventDefault = this._deleteContents();
break;
case 46:
// Key 46 is the DELETE key.
preventDefault = this._deleteContents(true);
break;
/* ***********************************************************************************/
/* BEGIN: Handling of caret movements inside hidden .ins/.del elements on Firefox **/
/* *Fix for carets getting stuck in .del elements when track changes are hidden **/
case ice.dom.DOM_VK_DOWN:
case ice.dom.DOM_VK_UP:
case ice.dom.DOM_VK_LEFT:
if(browser["type"] === "mozilla"){
if(!this.visible(range.startContainer)){
// if Previous sibling exists in the paragraph, jump to the previous sibling
if(range.startContainer.parentNode.previousSibling){
// When moving left and moving into a hidden element, skip it and go to the previousSibling
range.setEnd(range.startContainer.parentNode.previousSibling, 0);
range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer));
range.collapse(false);
}
// if Previous sibling doesn't exist, get out of the hidden zone by moving to the right
else {
range.setEnd(range.startContainer.parentNode.nextSibling, 0);
range.collapse(false);
}
}
}
preventDefault = false;
break;
case ice.dom.DOM_VK_RIGHT:
if(browser["type"] === "mozilla"){
if(!this.visible(range.startContainer)){
if(range.startContainer.parentNode.nextSibling){
// When moving right and moving into a hidden element, skip it and go to the nextSibling
range.setStart(range.startContainer.parentNode.nextSibling,0);
range.collapse(true);
}
}
}
break;
/* END: Handling of caret movements inside hidden .ins/.del elements ***************/
default:
// Ignore key.
break;
} //end switch
return preventDefault;
},
/**
* Returns false if the event should be cancelled
*/
handleKeyDown: function (e) {
if (this._handleSpecialKey(e)) {
return true;
}
return ! this.keyPress(e);
},
/**
* @private
* @param e event
* returns true if the event needs to be prevented
*/
onKeyDown: function (e) {
if (this._handleSpecialKey(e)) {
return false;
}
return this._handleAncillaryKey(e);
},
/**
* Returns true if the event should be cancelled
*/
keyPress: function (e) {
var c = null;
if (e.ctrlKey || e.metaKey) {
return false;
}
// Inside a br - most likely in a placeholder of a new block - delete before handling.
var range = this.getCurrentRange(), text,
br = range && ice.dom.parents(range.startContainer, 'br')[0] || null;
if (br) {
range.moveToNextEl(br);
}
// if (c !== null) {
var key = e.keyCode ? e.keyCode : e.which;
switch (key) {
case 32: //ckeditor does funny stuff with spaces, so insert it ourselves
return this.insert({ text: ' ' });
case ice.dom.DOM_VK_DELETE:
case 46:
case ice.dom.DOM_VK_DOWN:
case ice.dom.DOM_VK_UP:
case ice.dom.DOM_VK_LEFT:
case ice.dom.DOM_VK_RIGHT:
return this._handleAncillaryKey(key);
case ice.dom.DOM_VK_ENTER:
this._handleEnter();
return false;
default:
if (isIgnoredKeyCode(key)) {
return false;
}
c = e["char"] || String.fromCharCode(key);
if (c) { // covers null and empty string
var text = this._browser.msie ? {text: c} : null;
return this.insert(text);
}
return false;
}
// }
// return false; //this._handleAncillaryKey(e);
},
_handleEnter: function () {
var range = this.getCurrentRange();
if (range && !range.collapsed) {
this._deleteContents();
}
/*
this._domObserver.observe(this.element, this._domObserverConfig);
this._setDomObserverTimeout();
*/
},
/**
* @private
* returns true if the keytcombination was handled. This does not mean that the event should
* be preventDefault()ed, just that we don't need further processing
*/
_handleSpecialKey: function (e) {
var keyCode = e.which;
if (keyCode === null) {
// IE.
keyCode = e.keyCode;
}
switch (keyCode) {
case 120:
case 88:
if (true === e.ctrlKey || true === e.metaKey) {
this.prepareToCut();
return true;
}
break;
case 67:
case 99:
if (true === e.ctrlKey || true === e.metaKey) {
this.prepareToCopy();
return true;
}
break;
default:
// Not a special key.
break;
} //end switch
return false;
},
/**
* Returns the first ice node in the hierarchy of the given node, or the current collapsed range.
* @param node if null, check the current selection
* @param onlyNode if true, check only the node, not its parents
* @param cleanup if false, don't clean up empty nodes around selection
* null if not in a track changes hierarchy
*/
currentChangeNode: function (node, onlyNode, cleanup) {
var selector = this._iceSelector,
range = null;
if (!node) {
range = this.getCurrentRange();
if (! range) {
return false;
}
if (cleanup !== false && range.collapsed) {
this._cleanupSelection(range, false, false);
node = range.startContainer;
}
else {
node = range.commonAncestorContainer;
}
}
var ret = onlyNode ? $(node).is(selector) && node : ice.dom.getNode(node, selector);
if ((! ret) && range && range.collapsed) {
var end = range.endContainer,
endOffset = range.endOffset,
nextNode = null;
if (end.nodeType === ice.dom.TEXT_NODE) {
if (endOffset === end.length) {
nextNode = ice.dom.getNextNode(end);
}
else if (endOffset === 0) {
nextNode = ice.dom.getPrevNode(end, this.element);
}
}
else if (end.nodeType === ice.dom.ELEMENT_NODE) {
if (endOffset === 0) {
nextNode = ice.dom.getPrevNode(end, this.element);
}
else if (end.childNodes.length > endOffset) {
end = end.childNodes[endOffset - 1];
if ($(end).is(selector)) {
return end;
}
nextNode = ice.dom.getNextNode(end);
}
}
if (nextNode) {
ret = $(nextNode).is(selector);
}
}
return ret;
},
setShowChanges: function(bShow) {
var $body = $(this.element);
bShow = Boolean(bShow);
this._isVisible = bShow;
$body.toggleClass("ICE-Tracking", bShow);
this._showTitles(bShow);
this._updateTooltipsState();
},
reload: function() {
this._loadFromDom();
},
hasChanges: function() {
for (var key in this._changes) {
var change = this._changes[key];
if (change && change.type) {
return true;
}
}
return false;
},
countChanges: function(options) {
var changes = this._filterChanges(options);
return changes.count;
},
setChangeData: function(data) {
if (null == data || (typeof data == "undefined")) {
data = "";
}
this._changeData = String(data);
},
getDeleteClass: function() {
return this._getIceNodeClass(DELETE_TYPE);
},
/**
* called before a copy operation.
* This function processes the current selection to remove the tracking style.
* The tracking is restored immediately after the copy operation
*/
prepareToCopy: function() {
var range = this.getCurrentRange();
if (range && ! range.collapsed) {
this._removeTrackingInRange(range);
}
},
/**
* Preprocesses the document selection so that a deleted span is left after the browser cut
* @return true if there's a selection
*/
prepareToCut: function() {
var range = this.getCurrentRange(),
hostRange = this.hostMethods.getHostRange();
if (range && hostRange && range.collapsed && ! hostRange.collapsed) {
// special case of IE showing collapsed selection when ckeditor thinks otherwise
try {
var data = this.hostMethods.getHostRangeData(hostRange);
range.setStart(data.startContainer, data.startOffset);
range.setEnd(data.endContainer, data.endOffset);
}
catch (e) {
return;
}
}
if (! range || range.collapsed) {
return false;
}
fixSelection(range, this.element);
var frag = range.cloneContents(),
origRange = range.cloneRange(),
head = frag.firstChild,tail = frag.lastChild;
// printRange(range, "before cut");
this.hostMethods.beforeEdit();
range.collapse(false);
range.insertNode(frag);
range.setStartBefore(head);
// printRange(range, "after set start before the head");
range.setEndAfter(tail);
// printRange(range, "after set end after the tail");
var cid = this._startBatchChange();
try {
this._deleteSelection(range);
}
catch (e) {
logError(e, "While trying to delete selection");
}
finally {
this._endBatchChange(cid);
this.selection.addRange(origRange);
this._removeTrackingInRange(origRange, false);
// printRange(this.selection.getRangeAt(0), "range after deletion");
}
return true;
},
toString: function() {
return "ICE " + ((this.element && this.element.id) || "(no element id)");
},
_splitNode: function(node, atNode, atOffset) {
var parent = node.parentNode,
parentOffset = rangy.dom.getNodeIndex(node),
doc = atNode.ownerDocument,
leftRange = doc.createRange(),
left;
leftRange.setStart(parent, parentOffset);
leftRange.setEnd(atNode, atOffset);
left = leftRange.extractContents();
parent.insertBefore(left, node);
if (this.isInsideChange(node, true)) {
this._updateNodeTooltip(node.previousSibling);
}
return node.previousSibling;
},
/**
* Notify that the DOM has changed
* if options.isText === true, also notify that text has changed
*/
_triggerChange: function(options) {
if (this._isTracking) {
this.$this.trigger("change");
if (options && options.isText) {
this.$this.trigger("textChange");
}
}
},
_updateNodeTooltip: function(node) {
if (this.tooltips && this._isVisible) {
this._addTooltip(node);
}
},
_acceptRejectSome: function(options, isAccept) {
var f = (function(index, node) {
this.acceptRejectChange(node, { isAccept: isAccept, notify: false });
}).bind(this);
var changes = this._filterChanges(options);
for (var id in changes.changes) {
var nodes = $(this.element).find('[' + this.attributes.changeId + '=' + id + ']');
nodes.each(f);
}
if (changes.count) {
this._triggerChange({ isText: true });
}
},
/**
* Filters the current change set based on options
* @param _options may contain one of:<ul>
* <li>exclude: an array of user ids to exclude
* <li>include: an array of user ids to include
* <li>filter: a filter function of the form function({userid, time, data}):boolean
* <li>verify: a boolean indicating whether or not to verify that there are matching dom nodes for each matching change
* </ul>
* @return {Object} an object with two members: count, changes (map of id:changeObject)
* @private
*/
_filterChanges: function(_options) {
var count = 0, changes = {},
change,
options = _options || {},
filter = options.filter,
exclude = options.exclude ? $.map(options.exclude, function(e) { return String(e); }) : null,
include = options.include ? $.map(options.include, function(e) { return String(e); }) : null,
verify = options.verify,
elements = null;
for (var key in this._changes) {
change = this._changes[key];
if (change && change.type) {
var skip = (filter && ! filter({userid: change.userid, time: change.time, data:change.data})) ||
(exclude && exclude.indexOf(change.userid) >= 0) ||
(include && include.indexOf(change.userid) < 0);
if (! skip) {
if (verify) {
elements = $(this.element).find("[" + this.attributes.changeId + "]");
skip = ! elements.length;
}
if (! skip) {
++count;
changes[key] = change;
}
}
}
}
return { count : count, changes : changes };
},
_loadFromDom : function() {
this._changes = {};
this._uniqueStyleIndex = 0;
var myUserId = this.currentUser && this.currentUser.id,
myUserName = (this.currentUser && this.currentUser.name) || "",
now = (new Date()).getTime(),
styleMatch,
styleRegex = new RegExp(this.stylePrefix + '-(\\d+)'),
// Grab class for each changeType
changeTypeClasses = [];
for (var changeType in this.changeTypes) {
changeTypeClasses.push(this._getIceNodeClass(changeType));
}
var nodes = this.getIceNodes();
var f = function(i, el) {
var styleIndex = 0,
styleName,
ctnType = '', i,
classList = el.className.split(' ');
//TODO optimize this - create a map of regexp
for (i = 0; i < classList.length; i++) {
styleMatch = styleRegex.exec(classList[i]);
if (styleMatch) {
styleName = styleMatch[0];
styleIndex = styleMatch[1];
}
var ctnReg = new RegExp('(' + changeTypeClasses.join('|') + ')').exec(classList[i]);
if (ctnReg) {
ctnType = this._getChangeTypeFromAlias(ctnReg[1]);
}
}
var userid = el.getAttribute(this.attributes.userId);
var userName;
if (myUserId && (userid == myUserId)) {
userName = myUserName;
el.setAttribute(this.attributes.userName, myUserName);
}
else {
userName = el.getAttribute(this.attributes.userName);
}
this._setUserStyle(userid, Number(styleIndex));
var changeid = parseInt(el.getAttribute(this.attributes.changeId) || "");
if (isNaN(changeid)) {
changeid = this.getNewChangeId();
el.setAttribute(this.attributes.changeId, changeid);
}
var timeStamp = parseInt(el.getAttribute(this.attributes.time) || "");
if (isNaN(timeStamp)) {
timeStamp = now;
}
var lastTimeStamp = parseInt(el.getAttribute(this.attributes.lastTime) || "");
if (isNaN(lastTimeStamp)) {
lastTimeStamp = timeStamp;
}
var sessionId = el.getAttribute(this.attributes.sessionId);
var changeData = el.getAttribute(this.attributes.changeData) || "";
this._changes[changeid] = {
type: ctnType,
style: styleName,
userid: String(userid),// dfl: must stringify for consistency - when we read the props from dom attrs they are strings
username: userName,
time: timeStamp,
lastTime: lastTimeStamp,
sessionId: sessionId,
data : changeData
};
this._updateNodeTooltip(el);
}.bind(this);
nodes.each(f);
this._triggerChange();
},
_showTitles : function(bShow) {
var nodes = this.getIceNodes();
if (bShow) {
$(nodes).each((function(i, node) {
this._updateNodeTooltip(node);
}).bind(this));
}
else {
$(nodes).removeAttr("title");
}
},
_updateTooltipsState: function() {
var $nodes,
self = this;
// show tooltips if they are enabled and change tracking is on
if (this.tooltips && this._isVisible) {
if (! this._showingTips) {
this._showingTips = true;
$nodes = this.getIceNodes();
$nodes.each(function(i, node) {
self._addTooltip(node);
});
}
}
else if (this._showingTips) {
this._showingTips = false;
$nodes = this.getIceNodes();
$nodes.each(function(i, node) {
$(node).unbind("mouseover").unbind("mouseout");
});
}
},
_addTooltip: function(node) {
$(node).unbind("mouseover").unbind("mouseout").mouseover(this._tooltipMouseOver).mouseout(this._tooltipMouseOut);
},
_tooltipMouseOver: function(event) {
var node = event.currentTarget,
$node = $(node), to,
self = this;
if (event.buttons || $node.data("_tooltip_t")) {
return;
}
to = setTimeout(function() {
var iceNode = self.currentChangeNode(node),
cid = iceNode && iceNode.getAttribute(self.attributes.changeId),
change = cid && self.getChange(cid);
if (change) {
var type = ice.dom.hasClass(iceNode, self._getIceNodeClass(INSERT_TYPE)) ? "insert" : "delete";
$node.removeData("_tooltip_t");
self.hostMethods.showTooltip(node, {
userName: change.username,
changeId: cid,
userId: change.userid,
time: change.time,
lastTime: change.lastTime,
type: type
});
}
}, this.tooltipsDelay);
$node.data("_tooltip_t", to);
},
_tooltipMouseOut: function(event) {
var node = event.currentTarget,
$node = $(node),
to = $node.data("_tooltip_t");
$node.removeData("_tooltip_t");
if (to) {
clearTimeout(to);
}
else {
this.hostMethods.hideTooltip(node);
}
},
/**
* Finds all the tracking nodes involved in the range and removes their tracking classes.
* A timeout is set to restore the tracking classes immediately.
* This allows the editor to copy tracked text without its style
* @private
*/
_removeTrackingInRangeOld: function (range) {
var insClass = this._getIceNodeClass(INSERT_TYPE),
delClass = this._getIceNodeClass(DELETE_TYPE),
clsSelector = '.' + insClass+",."+delClass,
clsAttr = "data-ice-class",
filter = function(node) {
var iceNode,
$iceNode = null;
if (node.nodeType == ice.dom.TEXT_NODE) {
$iceNode = $(node).parents(clsSelector);
}
else {
var $node = $(node);
if ($node.is(clsSelector)) {
$iceNode = $node;
}
else {
$iceNode = $node.parents(clsSelector);
}
}
iceNode = ($iceNode && $iceNode[0]);
if (iceNode) {
var cls = iceNode.className;
iceNode.setAttribute(clsAttr, cls);
iceNode.setAttribute("class", "ice-no-decoration");
return true;
}
return false;
};
range.getNodes(null, filter);
var el = this.element;
setTimeout(function() {
var nodes = $(el).find('['+ clsAttr + ']');
nodes.each(function(i, node) {
var cls = node.getAttribute(clsAttr);
if (cls) {
node.setAttribute("class", cls);
node.removeAttribute(clsAttr);
}
});
}, 10);
},
/**
* Finds all the tracking nodes involved in the range and removes their tracking classes.
* A timeout is set to restore the tracking classes immediately.
* This allows the editor to copy tracked text without its style
* @private
*/
_removeTrackingInRange: function (range) {
var insClass = this._getIceNodeClass(INSERT_TYPE),
delClass = this._getIceNodeClass(DELETE_TYPE),
clsSelector = '.' + insClass+",."+delClass,
saveMap = this._savedNodesMap,
clsAttr = "data-ice-class",
base = Date.now() % 1000000,
filter = function(node) {
var $node,iceNode,
$iceNode = null;
if (node.nodeType == ice.dom.TEXT_NODE) {
$iceNode = $(node).parents(clsSelector);
}
else {
$node = $(node);
if ($node.is(clsSelector)) {
$iceNode = $node;
}
else {
$iceNode = $node.parents(clsSelector);
}
}
if (iceNode = ($iceNode && $iceNode[0])) {
var attrs = getNodeAttributes(iceNode),
cls = iceNode.className,
dataId = String(base++);
saveMap[dataId] = {
attributes: attrs,
className: cls
};
removeAllAttributes(iceNode);
iceNode.setAttribute(clsAttr, dataId);
iceNode.setAttribute("class", "ice-no-decoration");
return true;
}
return false;
};
range.getNodes(null, filter);
var el = this.element;
setTimeout(function() {
var nodes = $(el).find('['+ clsAttr + ']');
nodes.each(function(i, node) {
var dataId = node.getAttribute(clsAttr),
nodeData = saveMap[dataId];
if (dataId) {
delete saveMap[dataId];
Object.keys(nodeData.attributes).forEach(function(key) {
node.setAttribute(key, nodeData.attributes[key]);
});
node.setAttribute("class", nodeData.className);
node.removeAttribute(clsAttr);
}
else {
logError("missing save data for node");
}
});
}, 10);
},
_onDomMutation: function(mutations) {
var i, len = mutations.length, m,
nodeIndex, lst,
node;
for (i = 0; i < len; ++i) {
m = mutations[i];
switch (m.type) {
case "childList":
lst = m.addedNodes;
for (nodeIndex = lst.length - 1; nodeIndex >= 0; --nodeIndex) {
node = lst[nodeIndex];
console.log("mutation: added node", node.tagName);
}
break;
}
}
},
_setDomObserverTimeout: function() {
var self = this;
if (this._domObserverTimeout) {
window.clearTimeout(this._domObserverTimeout);
}
this._domObserverTimeout = window.setTimeout(function() {
self._domObserverTimeout = null;
self._domObserver.disconnect();
}, 1);
},
getAdjacentChangeId: function(node, left) {
var next = left ? ice.dom.getNextNode(node) : ice.dom.getPrevNode(node),
nextChange,
changeId = null;
nextChange = this._getIceNode(next, INSERT_TYPE) || this._getIceNode(next, DELETE_TYPE);
if (! nextChange) {
if (this._isInsertNode(next) || this._isDeleteNode(next)) {
nextChange = next;
}
}
if (nextChange && this._isCurrentUserIceNode(nextChange)) {
changeId = nextChange.getAttribute(this.attributes.changeId);
}
return changeId;
}
};
var console = (window && window.console) || {
log: function(){},
error: function(){},
info: function(){},
assert:function(){},
count: function(){}
} ;
/** Utility functions **/
function getNodeAttributes(node) {
var attrs = node.attributes,
attr,
len = attrs && attrs.length,
ret = {};
for (var i = 0; i < len; ++i) {
attr = attrs[i];
ret[attr.name] = attr.value;
}
return ret;
}
function removeAllAttributes(node) {
var last = null,
next;
try {
while (node.attributes.length > 0) {
next = node.attributes[0];
if (next === last) {
return;
}
last = next;
node.removeAttribute(next.name);
}
}
catch(ignore){}
}
function nativeElement(e) {
return e;
}
/**
* Strip all attributes and classes from a node
* @param node
*/
function stripNode(node) {
// remove all attrs and classes from the node
var attributes = $.map(node.attributes, function(attr) {
return attr.name;
});
$(node).removeClass(); // remove all classes
$.each(attributes, function(i, item) {
node.removeAttribute(item);
});
}
function isBRNode(node) {
return BREAK_ELEMENT == ice.dom.getTagName(node);
}
function isNewlineNode(node) {
var tag = ice.dom.getTagName(node);
return BREAK_ELEMENT === tag || PARAGRAPH_ELEMENT === tag;
}
function isOnRightEdge(el, offset) {
if (! el) {
return false;
}
var type = el.nodeType;
if (ice.dom.TEXT_NODE == type) {
return offset && el.nodeValue && (offset >= el.nodeValue.length - 1);
}
if (ice.dom.ELEMENT_NODE == type) {
return el.childNodes && el.childNodes.length && (offset >= el.childNodes.length);
}
return false;
}
var logError = null;
function fixSelection(range, top) {
if (! range || ! top || range.collapsed) {
return range;
}
var current;
// fix end
try {
while ((current = range.endContainer) && (current !== top) && (range.endOffset == 0) && ! range.collapsed) {
if (current.previousSibling) {
range.setEndBefore(current);
}
else if (current.parentNode && current.parentNode !== top) {
range.setEndBefore(current.parentNode);
}
if (range.endContainer == current) {
break;
}
}
}
catch (e) {
logError(e, "fixSelection, while trying to set end");
}
try {
while ((current = range.startContainer) && (current !== top) && ! range.collapsed) {
current = range.startContainer;
if (current.nodeType == ice.dom.TEXT_NODE) {
if (range.startOffset >= current.nodeValue.length) {
range.setStartAfter(current);
}
}
else { // element
if (range.startOffset >= current.childNodes.length) {
range.setStartAfter(current);
}
}
if (range.startContainer == current) {
break;
}
}
}
catch (e) {
logError(e, "fixSelection, while trying to set start");
}
}
function splitTextAt(textNode, at, count) {
var textLength = textNode.length,
splitText;
if (at < 0 || at >= textLength) {
return textNode;
}
if (at + count >= textLength) {
count = textLength - at;
}
if (count === textLength) {
return textNode;
}
splitText = at > 0 ? textNode.splitText(at) : textNode;
if (splitText.length > count) {
splitText.splitText(count);
}
return splitText;
}
function prepareSelectionForInsert(node, range, doc, insertStub) {
if (insertStub) {
if (range.collapsed && range.startContainer && range.startContainer.nodeType === ice.dom.TEXT_NODE && range.startContainer.length) {
return;
}
// create empty node and select it, to be replaced with the typed char
var tn = doc.createTextNode('\uFEFF');
if (node) {
node.appendChild(tn);
}
else {
range.insertNode(tn);
}
range.selectNode(tn);
}
else if (node) {
range.selectNodeContents(node);
}
}
function printRange(range, message) {
if (! range || ! range.startContainer || ! range.endContainer) {
return;
}
var parts = [];
function printText(txt) {
if (! txt) {
return "";
}
txt = txt.replace('/\n/g', "\\n").replace('/\r/g', "").replace('\u200B', "{filler}").replace('\uFEFF', "{filler}");
if (txt.length <= 15) {
return txt;
}
return txt.substring(0, 5)+ "..." + txt.substring(txt.length - 5);
}
function addNode(node) {
var str;
if (node.nodeType === 3) {
str = "Text:" + printText(node.nodeValue);
}
else {
var txt = node.innerText;
str = node.nodeName + (txt ? "(" + printText(txt) + ")" :'');
}
parts.push("<" + str + " />");
}
function printNode(node, offset1, offset2) {
if ("number" !== typeof offset2) {
offset2 = -1;
}
if (3 == node.nodeType) { // text
var txt = node.nodeValue;
parts.push(printText(txt.substring(0, offset1)));
parts.push("|");
if (offset2 > offset1) {
parts.push(printText(txt.substring(offset1, offset2)));
parts.push("|");
parts.push(printText(txt.substring(offset2)));
}
else {
parts.push(printText(txt.substring(offset1)));
}
}
else if (1 == node.nodeType) {
var i = 0,
children = node.childNodes,
start = 0;
addNode(node);
for (i = start; i < offset1; ++i) {
addNode(children[i]);
}
parts.push("|");
if (offset2 > offset1) {
for (i = offset1; i < offset2; ++i) {
addNode(children[i]);
}
parts.push('|');
}
if (offset2 > 0 && offset2 < children.length){
var child = children[offset2];
while (child) {
addNode(child);
child = child.nextSibling;
}
}
}
}
if (range.startContainer === range.endContainer) {
printNode(range.startContainer, range.startOffset, range.endOffset);
}
else {
printNode(range.startContainer, range.startOffset);
printNode(range.endContainer, range.endOffset);
}
var ret = parts.join(' ');
if (message) {
console.log(message + ":" + ret);
}
return ret;
}
ice.printRange = printRange;
ice.InlineChangeEditor = InlineChangeEditor;
}(this.ice || window.ice, window.jQuery));