mirror of
https://github.com/UnickSoft/graphonline.git
synced 2025-07-03 08:15:38 +00:00
1925 lines
61 KiB
JavaScript
Executable File
1925 lines
61 KiB
JavaScript
Executable File
(function () {
|
|
|
|
// fomatting for the tooltips
|
|
function padString(s, length, padWith, bSuffix) {
|
|
if (null === s || (typeof(s) == "undefined")) {
|
|
s = "";
|
|
}
|
|
else {
|
|
s = String(s);
|
|
}
|
|
padWith = String(padWith);
|
|
var padLength = padWith.length;
|
|
for (var i = s.length; i < length; i += padLength) {
|
|
if (bSuffix) {
|
|
s += padWidth;
|
|
}
|
|
else {
|
|
s = padWith + s;
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function padNumber(s, length) {
|
|
return padString(s, length, '0');
|
|
}
|
|
|
|
var exports = this,
|
|
defaults, InlineChangeEditor;
|
|
|
|
defaults = {
|
|
// ice node attribute names:
|
|
changeIdAttribute: 'data-cid',
|
|
userIdAttribute: 'data-userid',
|
|
userNameAttribute: 'data-username',
|
|
timeAttribute: 'data-time',
|
|
changeDataAttribute: 'data-changedata', // dfl, 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: ['p', 'ol', 'ul', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'],
|
|
|
|
// Unique style prefix, prepended to a digit, incremented for each encountered user, and stored
|
|
// in ice node class attributes - cts1, cts2, cts3, ...
|
|
stylePrefix: 'cts',
|
|
currentUser: {
|
|
id: null,
|
|
name: null
|
|
},
|
|
|
|
// Default change types are insert and delete. Plugins or outside apps should extend this
|
|
// if they want to manage new change types. The changeType name is used as a primary
|
|
// reference for ice nodes; the `alias`, is dropped in the class attribute and is the
|
|
// primary method of identifying ice nodes; and `tag` is used for construction only.
|
|
// Invoking `this.getCleanContent()` will remove all delete type nodes and remove the tags
|
|
// for the other types, leaving the html content in place.
|
|
changeTypes: {
|
|
insertType: {
|
|
tag: 'span',
|
|
alias: 'ins',
|
|
action: 'Inserted'
|
|
},
|
|
deleteType: {
|
|
tag: 'span',
|
|
alias: 'del',
|
|
action: 'Deleted'
|
|
}
|
|
},
|
|
|
|
// If `true`, setup event listeners on `this.element` and handle events - good option for a basic
|
|
// setup without a text editor. Otherwise, when set to `false`, events need to be manually passed
|
|
// to `handleEvent`, which is good for a text editor with an event callback handler, like tinymce.
|
|
handleEvents: false,
|
|
|
|
// Sets this.element with the contentEditable element
|
|
contentEditable: undefined,//dfl, start with a neutral value
|
|
|
|
// Switch for toggling track changes on/off - when `false` events will be ignored.
|
|
isTracking: true,
|
|
|
|
// NOT IMPLEMENTED - Selector for elements that will not get track changes
|
|
noTrack: '.ice-no-track',
|
|
|
|
// Selector for elements to avoid - move range before or after - similar handling to deletes
|
|
avoid: '.ice-avoid',
|
|
|
|
// Switch for whether paragraph breaks should be removed when the user is deleting over a
|
|
// paragraph break while changes are tracked.
|
|
mergeBlocks: true,
|
|
|
|
titleTemplate : null, // dfl, no title by default
|
|
|
|
isVisible : true, // dfl, state of change tracking visibility
|
|
|
|
changeData : null //dfl, a string you can associate with the current change set, e.g. version
|
|
|
|
|
|
};
|
|
|
|
InlineChangeEditor = function (options) {
|
|
|
|
// Data structure for modelling changes in the element according to the following model:
|
|
// [changeid] => {`type`, `time`, `userid`, `username`}
|
|
this._changes = {};
|
|
this._refreshInterval = null; // dfl
|
|
|
|
options || (options = {});
|
|
if (!options.element) {
|
|
throw Error("`options.element` must be defined for ice construction.");
|
|
}
|
|
|
|
ice.dom.extend(true, this, defaults, options);
|
|
|
|
this.pluginsManager = new ice.IcePluginManager(this);
|
|
if (options.plugins) this.pluginsManager.usePlugins('ice-init', options.plugins);
|
|
};
|
|
|
|
InlineChangeEditor.prototype = {
|
|
// Tracks all of the styles for users according to the following model:
|
|
// [userId] => styleId; where style is "this.stylePrefix" + "this.uniqueStyleIndex"
|
|
_userStyles: {},
|
|
_styles: {},
|
|
|
|
// Incremented for each new user and appended to they style prefix, and dropped in the
|
|
// ice node class attribute.
|
|
_uniqueStyleIndex: 0,
|
|
|
|
_browserType: null,
|
|
|
|
// One change may create multiple ice nodes, so this keeps track of the current batch id.
|
|
_batchChangeid: null,
|
|
|
|
// Incremented for each new change, dropped in the changeIdAttribute.
|
|
_uniqueIDIndex: 1,
|
|
|
|
// Temporary bookmark tags for deletes, when delete placeholding is active.
|
|
_delBookmark: 'tempdel',
|
|
isPlaceHoldingDeletes: false,
|
|
|
|
/**
|
|
* Turns on change tracking - sets up events, if needed, and initializes the environment,
|
|
* range, and editor.
|
|
*/
|
|
startTracking: function () {
|
|
// dfl:set contenteditable only if it has been explicitly set
|
|
if (typeof(this.contentEditable) == "boolean") {
|
|
this.element.setAttribute('contentEditable', this.contentEditable);
|
|
}
|
|
|
|
// If we are handling events setup the delegate to handle various events on `this.element`.
|
|
if (this.handleEvents) {
|
|
var self = this;
|
|
ice.dom.bind(self.element, 'keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice', function (e) {
|
|
return self.handleEvent(e);
|
|
});
|
|
}
|
|
|
|
this.initializeEnvironment();
|
|
this.initializeEditor();
|
|
this.initializeRange();
|
|
this._setInterval(); //dfl
|
|
|
|
this.pluginsManager.fireEnabled(this.element);
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Removes contenteditability and stops event handling.
|
|
* Changed by dfl to have the option of not setting contentEditable
|
|
*/
|
|
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`.
|
|
if (this.element) {
|
|
ice.dom.unbind(this.element, 'keyup.ice keydown.ice keypress.ice mousedown.ice mouseup.ice');
|
|
}
|
|
|
|
// dfl:reset contenteditable unless requested not to do so
|
|
if (! onlyICE && (typeof(this.contentEditable) != "undefined")) {
|
|
this.element.setAttribute('contentEditable', !this.contentEditable);
|
|
}
|
|
}
|
|
catch (e){}
|
|
try { // dfl added try/catch for ie8
|
|
this.pluginsManager.fireDisabled(this.element);
|
|
}
|
|
catch(e){}
|
|
this._setInterval();
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Initializes the `env` object with pointers to key objects of the page.
|
|
*/
|
|
initializeEnvironment: function () {
|
|
this.env || (this.env = {});
|
|
this.env.element = this.element;
|
|
this.env.document = this.element.ownerDocument;
|
|
this.env.window = this.env.document.defaultView || this.env.document.parentWindow || window;
|
|
this.env.frame = this.env.window.frameElement;
|
|
this.env.selection = this.selection = new ice.Selection(this.env);
|
|
// Hack for using custom tags in IE 8/7
|
|
this.env.document.createElement(this.changeTypes.insertType.tag);
|
|
this.env.document.createElement(this.changeTypes.deleteType.tag);
|
|
},
|
|
|
|
/**
|
|
* Initializes the internal range object and sets focus to the editing element.
|
|
*/
|
|
initializeRange: function () {
|
|
var range = this.selection.createRange();
|
|
range.setStart(ice.dom.find(this.element, this.blockEls.join(', '))[0], 0);
|
|
range.collapse(true);
|
|
this.selection.addRange(range);
|
|
if (this.env.frame) this.env.frame.contentWindow.focus();
|
|
else this.element.focus();
|
|
},
|
|
|
|
/**
|
|
* Initializes the content in the editor - cleans non-block nodes found between blocks and
|
|
* initializes the editor with any tracking tags found in the editing element.
|
|
*/
|
|
initializeEditor: function () {
|
|
// Clean the element html body - add an empty block if there is no body, or remove any
|
|
// content between elements.
|
|
var self = this,
|
|
body = this.env.document.createElement('div');
|
|
if (this.element.childNodes.length) {
|
|
body.innerHTML = this.element.innerHTML;
|
|
ice.dom.removeWhitespace(body);
|
|
if (body.innerHTML === '') body.appendChild(ice.dom.create('<' + this.blockEl + ' ><br/></' + this.blockEl + '>'));
|
|
} else {
|
|
body.appendChild(ice.dom.create('<' + this.blockEl + ' ><br/></' + this.blockEl + '>'));
|
|
}
|
|
this.element.innerHTML = body.innerHTML;
|
|
this._loadFromDom(); // refactored by dfl
|
|
this._setInterval(); // dfl
|
|
|
|
},
|
|
|
|
/**
|
|
* Turn on change tracking and event handling.
|
|
*/
|
|
enableChangeTracking: function () {
|
|
this.isTracking = true;
|
|
this.pluginsManager.fireEnabled(this.element);
|
|
},
|
|
|
|
/**
|
|
* Turn off change tracking and event handling.
|
|
*/
|
|
disableChangeTracking: function () {
|
|
this.isTracking = false;
|
|
this.pluginsManager.fireDisabled(this.element);
|
|
},
|
|
|
|
/**
|
|
* Set the user to be tracked. A user object has the following properties:
|
|
* {`id`, `name`}
|
|
*/
|
|
setCurrentUser: function (user) {
|
|
this.currentUser = user;
|
|
this._updateUserData(user); // dfl, update data dependant on the user details
|
|
},
|
|
|
|
/**
|
|
* If tracking is on, handles event e when it is one of the following types:
|
|
* mouseup, mousedown, keypress, keydown, and keyup. Each event type is
|
|
* propagated to all of the plugins. Prevents default handling if the event
|
|
* was fully handled.
|
|
*/
|
|
handleEvent: function (e) {
|
|
if (!this.isTracking) return;
|
|
if (e.type == 'mouseup') {
|
|
var self = this;
|
|
setTimeout(function () {
|
|
self.mouseUp(e);
|
|
}, 200);
|
|
} else if (e.type == 'mousedown') {
|
|
return this.mouseDown(e);
|
|
} else if (e.type == 'keypress') {
|
|
var needsToBubble = this.keyPress(e);
|
|
if (!needsToBubble) e.preventDefault();
|
|
return needsToBubble;
|
|
} else if (e.type == 'keydown') {
|
|
var needsToBubble = this.keyDown(e);
|
|
if (!needsToBubble) e.preventDefault();
|
|
return needsToBubble;
|
|
} else if (e.type == 'keyup') {
|
|
this.pluginsManager.fireCaretUpdated();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns a tracking tag for the given `changeType`, with the optional `childNode` appended.
|
|
*/
|
|
createIceNode: function (changeType, childNode) {
|
|
var node = this.env.document.createElement(this.changeTypes[changeType].tag);
|
|
ice.dom.addClass(node, this._getIceNodeClass(changeType));
|
|
|
|
node.appendChild(childNode ? childNode : this.env.document.createTextNode(''));
|
|
this.addChange(this.changeTypes[changeType].alias, [node]);
|
|
|
|
this.pluginsManager.fireNodeCreated(node, {
|
|
'action': this.changeTypes[changeType].action
|
|
});
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* Inserts the given string/node into the given range with tracking tags, collapsing (deleting)
|
|
* the range first if needed. If range is undefined, then the range from the Selection object
|
|
* is used. If the range is in a parent delete node, then the range is positioned after the delete.
|
|
*/
|
|
insert: function (node, range) {
|
|
// If the node is not defined, then we need to insert an
|
|
// invisible space and force propagation to the browser.
|
|
var isPropagating = !node;
|
|
node || (node = '\uFEFF');
|
|
|
|
if (range) this.selection.addRange(range);
|
|
else range = this.getCurrentRange();
|
|
|
|
if (typeof node === "string") {
|
|
node = document.createTextNode(node);
|
|
}
|
|
|
|
// If we have any nodes selected, then we want to delete them before inserting the new text.
|
|
if (!range.collapsed) {
|
|
this.deleteContents();
|
|
// Update the range
|
|
range = this.getCurrentRange();
|
|
if (range.startContainer === range.endContainer && this.element === range.startContainer) {
|
|
// The whole editable element is selected. Need to remove everything and init its contents.
|
|
ice.dom.empty(this.element);
|
|
var firstSelectable = range.getLastSelectableChild(this.element);
|
|
range.setStartAfter(firstSelectable);
|
|
range.collapse(true);
|
|
}
|
|
}
|
|
|
|
// If we are in a non-tracking/void element, move the range to the end/outside.
|
|
this._moveRangeToValidTrackingPos(range);
|
|
|
|
var changeid = this.startBatchChange();
|
|
// Send a dummy node to be inserted, if node is undefined
|
|
this._insertNode(node, range, isPropagating);
|
|
this.pluginsManager.fireNodeInserted(node, range);
|
|
this.endBatchChange(changeid);
|
|
return isPropagating;
|
|
},
|
|
|
|
/**
|
|
* This command will drop placeholders in place of delete tags in the element
|
|
* body and store references in the `_deletes` array to the original delete nodes.
|
|
*
|
|
* A placeholder tag is of the following structure:
|
|
* <tempdel data-allocation="[NUM]" />
|
|
* Where [NUM] is the referenced allocation in the `_deletes` array where the
|
|
* original delete node is stored.
|
|
*/
|
|
placeholdDeletes: function () {
|
|
var self = this;
|
|
if (this.isPlaceholdingDeletes) {
|
|
this.revertDeletePlaceholders();
|
|
}
|
|
this.isPlaceholdingDeletes = true;
|
|
this._deletes = [];
|
|
var deleteSelector = '.' + this._getIceNodeClass('deleteType');
|
|
ice.dom.each(ice.dom.find(this.element, deleteSelector), function (i, el) {
|
|
self._deletes.push(ice.dom.cloneNode(el));
|
|
ice.dom.replaceWith(el, '<' + self._delBookmark + ' data-allocation="' + (self._deletes.length - 1) + '"/>');
|
|
});
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Replaces all delete placeholders in the element body with the referenced
|
|
* delete nodes in the `_deletes` array.
|
|
*
|
|
* A placeholder tag is of the following structure:
|
|
* <tempdel data-allocation="[NUM]" />
|
|
* Where [NUM] is the referenced allocation in the `_deletes` array where the
|
|
* original delete node is stored.
|
|
*/
|
|
revertDeletePlaceholders: function () {
|
|
var self = this;
|
|
if (!this.isPlaceholdingDeletes) {
|
|
return false;
|
|
}
|
|
ice.dom.each(this._deletes, function (i, el) {
|
|
ice.dom.find(self.element, self._delBookmark + '[data-allocation=' + i + ']').replaceWith(el);
|
|
});
|
|
this.isPlaceholdingDeletes = false;
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Deletes the contents in the given range or the range from the Selection object. If the range
|
|
* is not collapsed, then a selection delete is handled; otherwise, it deletes one character
|
|
* to the left or right if the right parameter is false or true, respectively.
|
|
*
|
|
* @return true if deletion was handled.
|
|
*/
|
|
deleteContents: function (right, range) {
|
|
var prevent = true;
|
|
if (range) {
|
|
this.selection.addRange(range);
|
|
} else {
|
|
range = this.getCurrentRange();
|
|
}
|
|
var changeid = this.startBatchChange(this.changeTypes['deleteType'].alias);
|
|
if (range.collapsed === false) {
|
|
this._deleteSelection(range);
|
|
} else {
|
|
if (right) prevent = this._deleteRight(range);
|
|
else prevent = this._deleteLeft(range);
|
|
}
|
|
this.selection.addRange(range);
|
|
this.endBatchChange(changeid);
|
|
return prevent;
|
|
},
|
|
|
|
/**
|
|
* Returns the changes - a hash of objects with the following properties:
|
|
* [changeid] => {`type`, `time`, `userid`, `username`}
|
|
*/
|
|
getChanges: function () {
|
|
return this._changes;
|
|
},
|
|
|
|
/**
|
|
* Returns an array with the user ids who made the changes
|
|
*/
|
|
getChangeUserids: function () {
|
|
var result = [];
|
|
var keys = Object.keys(this._changes);
|
|
|
|
for (var key in keys)
|
|
result.push(this._changes[keys[key]].userid);
|
|
|
|
return result.sort().filter(function (el, i, a) {
|
|
if (i == a.indexOf(el)) return 1;
|
|
return 0;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Returns the html contents for the tracked element.
|
|
*/
|
|
getElementContent: function () {
|
|
return this.element.innerHTML;
|
|
},
|
|
|
|
/**
|
|
* Returns the html contents, without tracking tags, for `this.element` or
|
|
* the optional `body` param which can be of either type string or node.
|
|
* Delete tags, and their html content, are completely removed; all other
|
|
* change type tags are removed, leaving the html content in place. After
|
|
* cleaning, the optional `callback` is executed, which should further
|
|
* modify and return the element body.
|
|
*
|
|
* prepare gets run before the body is cleaned by ice.
|
|
*/
|
|
getCleanContent: function (body, callback, prepare) {
|
|
var classList = '';
|
|
var self = this;
|
|
ice.dom.each(this.changeTypes, function (type, i) {
|
|
if (type != 'deleteType') {
|
|
if (i > 0) classList += ',';
|
|
classList += '.' + self._getIceNodeClass(type);
|
|
}
|
|
});
|
|
if (body) {
|
|
if (typeof body === 'string') body = ice.dom.create('<div>' + body + '</div>');
|
|
else body = ice.dom.cloneNode(body, false)[0];
|
|
} else {
|
|
body = ice.dom.cloneNode(this.element, false)[0];
|
|
}
|
|
body = prepare ? prepare.call(this, body) : body;
|
|
var changes = ice.dom.find(body, classList);
|
|
ice.dom.each(changes, function (el, i) {
|
|
ice.dom.replaceWith(this, ice.dom.contents(this));
|
|
});
|
|
var deletes = ice.dom.find(body, '.' + this._getIceNodeClass('deleteType'));
|
|
ice.dom.remove(deletes);
|
|
|
|
body = callback ? callback.call(this, body) : body;
|
|
|
|
return body.innerHTML;
|
|
},
|
|
|
|
/**
|
|
* Accepts all changes in the element body - removes delete nodes, and removes outer
|
|
* insert tags keeping the inner content in place.
|
|
* dfl:added support for filtering
|
|
*/
|
|
acceptAll: function (options) {
|
|
if (options) {
|
|
return this._acceptRejectSome(options, true);
|
|
}
|
|
else {
|
|
this.element.innerHTML = this.getCleanContent();
|
|
this._changes = {}; // dfl, reset the changes table
|
|
this._triggerChange(); // 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.*
|
|
* dfl:added support for filtering
|
|
*/
|
|
rejectAll: function (options) {
|
|
if (options) {
|
|
return this._acceptRejectSome(options, false);
|
|
}
|
|
else {
|
|
var insSel = '.' + this._getIceNodeClass('insertType');
|
|
var delSel = '.' + this._getIceNodeClass('deleteType');
|
|
|
|
ice.dom.remove(ice.dom.find(this.element, insSel));
|
|
ice.dom.each(ice.dom.find(this.element, delSel), function (i, el) {
|
|
ice.dom.replaceWith(el, ice.dom.contents(el));
|
|
});
|
|
this._changes = {}; // dfl, reset the changes table
|
|
this._triggerChange(); // 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, true);
|
|
},
|
|
|
|
/**
|
|
* Rejects the change at the given, or first tracking parent node of, `node`. If
|
|
* `node` is undefined then the startContainer of the current collapsed range will be used.
|
|
* In the case of delete, inner content will be used to replace the containing tag; and in
|
|
* the case of insert, the node will be removed.
|
|
*/
|
|
rejectChange: function (node) {
|
|
this.acceptRejectChange(node, false);
|
|
},
|
|
|
|
/**
|
|
* Handles accepting or rejecting tracking changes
|
|
*/
|
|
acceptRejectChange: function (node, isAccept) {
|
|
var delSel, insSel, selector, removeSel, replaceSel, trackNode, changes, dom = ice.dom;
|
|
|
|
if (!node) {
|
|
var range = this.getCurrentRange();
|
|
if (!range.collapsed) return;
|
|
else node = range.startContainer;
|
|
}
|
|
|
|
delSel = removeSel = '.' + this._getIceNodeClass('deleteType');
|
|
insSel = replaceSel = '.' + this._getIceNodeClass('insertType');
|
|
selector = delSel + ',' + insSel;
|
|
trackNode = dom.getNode(node, selector);
|
|
var changeId = dom.attr(trackNode, this.changeIdAttribute); //dfl
|
|
// Some changes are done in batches so there may be other tracking
|
|
// nodes with the same `changeIdAttribute` batch number.
|
|
changes = dom.find(this.element, '[' + this.changeIdAttribute + '=' + changeId + ']');
|
|
|
|
if (!isAccept) {
|
|
removeSel = insSel;
|
|
replaceSel = delSel;
|
|
}
|
|
|
|
if (ice.dom.is(trackNode, replaceSel)) {
|
|
dom.each(changes, function (i, node) {
|
|
dom.replaceWith(node, ice.dom.contents(node));
|
|
});
|
|
} else if (dom.is(trackNode, removeSel)) {
|
|
dom.remove(changes);
|
|
}
|
|
else { // dfl: this is not an ICE node
|
|
return;
|
|
}
|
|
/* begin dfl: remove change if no more nodes with this changeid, trigger change event */
|
|
if (changes.length <= 1) {
|
|
delete this._changes[changeId];
|
|
}
|
|
this._triggerChange();
|
|
/* end dfl */
|
|
},
|
|
|
|
/**
|
|
* Returns true if the given `node`, or the current collapsed range is in a tracking
|
|
* node; otherwise, false.
|
|
*/
|
|
isInsideChange: function (node) {
|
|
try {
|
|
return !! this.currentChangeNode(node); // refactored by dfl
|
|
}
|
|
catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Add a new change tracking typeName with the given tag and alias.
|
|
*/
|
|
addChangeType: function (typeName, tag, alias, action) {
|
|
var changeType = {
|
|
tag: tag,
|
|
alias: alias
|
|
};
|
|
|
|
if (action) changeType.action = action;
|
|
|
|
this.changeTypes[typeName] = changeType;
|
|
},
|
|
|
|
/**
|
|
* Returns this `node` or the first parent tracking node with the given `changeType`.
|
|
*/
|
|
getIceNode: function (node, changeType) {
|
|
var selector = '.' + this._getIceNodeClass(changeType);
|
|
return ice.dom.getNode(node, selector);
|
|
},
|
|
|
|
/**
|
|
* Sets the given `range` to the first position, to the right, where it is outside of
|
|
* void elements.
|
|
*/
|
|
_moveRangeToValidTrackingPos: function (range) {
|
|
var onEdge = false;
|
|
var voidEl = this._getVoidElement(range.endContainer);
|
|
while (voidEl) {
|
|
// Move end of range to position it inside of any potential adjacent containers
|
|
// E.G.: test|<em>text</em> -> test<em>|text</em>
|
|
try {
|
|
range.moveEnd(ice.dom.CHARACTER_UNIT, 1);
|
|
range.moveEnd(ice.dom.CHARACTER_UNIT, -1);
|
|
} catch (e) {
|
|
// Moving outside of the element and nothing is left on the page
|
|
onEdge = true;
|
|
}
|
|
if (onEdge || ice.dom.onBlockBoundary(range.endContainer, range.startContainer, this.blockEls)) {
|
|
range.setStartAfter(voidEl);
|
|
range.collapse(true);
|
|
break;
|
|
}
|
|
voidEl = this._getVoidElement(range.endContainer);
|
|
if (voidEl) {
|
|
range.setEnd(range.endContainer, 0);
|
|
range.moveEnd(ice.dom.CHARACTER_UNIT, ice.dom.getNodeCharacterLength(range.endContainer));
|
|
range.collapse();
|
|
} else {
|
|
range.setStart(range.endContainer, 0);
|
|
range.collapse(true);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns the given `node` or the first parent node that matches against the list of no track elements.
|
|
*/
|
|
_getNoTrackElement: function (node) {
|
|
var noTrackSelector = this._getNoTrackSelector();
|
|
var parent = ice.dom.is(node, noTrackSelector) ? node : (ice.dom.parents(node, noTrackSelector)[0] || null);
|
|
return parent;
|
|
},
|
|
|
|
/**
|
|
* Returns a selector for not tracking changes
|
|
*/
|
|
_getNoTrackSelector: function () {
|
|
return this.noTrack;
|
|
},
|
|
|
|
/**
|
|
* Returns the given `node` or the first parent node that matches against the list of void elements.
|
|
* dfl: added try/catch
|
|
*/
|
|
_getVoidElement: function (node) {
|
|
try {
|
|
var voidSelector = this._getVoidElSelector();
|
|
return ice.dom.is(node, voidSelector) ? node : (ice.dom.parents(node, voidSelector)[0] || null);
|
|
}
|
|
catch(e) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns a combined selector for delete and void elements.
|
|
*/
|
|
_getVoidElSelector: function () {
|
|
return '.' + this._getIceNodeClass('deleteType') + ',' + this.avoid;
|
|
},
|
|
|
|
/**
|
|
* Returns true if node has a user id attribute that matches the current user id.
|
|
*/
|
|
_currentUserIceNode: function (node) {
|
|
return ice.dom.attr(node, this.userIdAttribute) == this.currentUser.id;
|
|
},
|
|
|
|
/**
|
|
* With the given alias, searches the changeTypes objects and returns the
|
|
* associated key for the alias.
|
|
*/
|
|
_getChangeTypeFromAlias: function (alias) {
|
|
var type, ctnType = null;
|
|
for (type in this.changeTypes) {
|
|
if (this.changeTypes.hasOwnProperty(type)) {
|
|
if (this.changeTypes[type].alias == alias) {
|
|
ctnType = type;
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctnType;
|
|
},
|
|
|
|
_getIceNodeClass: function (changeType) {
|
|
return this.attrValuePrefix + this.changeTypes[changeType].alias;
|
|
},
|
|
|
|
getUserStyle: function (userid) {
|
|
var styleIndex = null;
|
|
if (this._userStyles[userid]) styleIndex = this._userStyles[userid];
|
|
else styleIndex = this.setUserStyle(userid, this.getNewStyleId());
|
|
return styleIndex;
|
|
},
|
|
|
|
setUserStyle: function (userid, styleIndex) {
|
|
var style = this.stylePrefix + '-' + styleIndex;
|
|
if (!this._styles[styleIndex]) this._styles[styleIndex] = true;
|
|
return this._userStyles[userid] = style;
|
|
},
|
|
|
|
getNewStyleId: function () {
|
|
var id = ++this._uniqueStyleIndex;
|
|
if (this._styles[id]) {
|
|
// Dupe.. create another..
|
|
return this.getNewStyleId();
|
|
} else {
|
|
this._styles[id] = true;
|
|
return id;
|
|
}
|
|
},
|
|
|
|
addChange: function (ctnType, ctNodes) {
|
|
var changeid = this._batchChangeid || this.getNewChangeId();
|
|
if (!this._changes[changeid]) {
|
|
// Create the change object.
|
|
this._changes[changeid] = {
|
|
type: this._getChangeTypeFromAlias(ctnType),
|
|
time: (new Date()).getTime(),
|
|
userid: 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(); //dfl
|
|
}
|
|
var self = this;
|
|
ice.dom.foreach(ctNodes, function (i) {
|
|
self.addNodeToChange(changeid, ctNodes[i]);
|
|
});
|
|
|
|
return changeid;
|
|
},
|
|
|
|
/**
|
|
* Adds tracking attributes from the change with changeid to the ctNode.
|
|
* @param changeid Id of an existing change.
|
|
* @param ctNode The element to add for the change.
|
|
*/
|
|
addNodeToChange: function (changeid, ctNode) {
|
|
if (this._batchChangeid !== null) changeid = this._batchChangeid;
|
|
|
|
var change = this.getChange(changeid);
|
|
|
|
if (!ctNode.getAttribute(this.changeIdAttribute)) ctNode.setAttribute(this.changeIdAttribute, changeid);
|
|
// modified by dfl, handle missing userid, try to set username according to userid
|
|
var userId = ctNode.getAttribute(this.userIdAttribute);
|
|
if (! userId) {
|
|
ctNode.setAttribute(this.userIdAttribute, userId = change.userid);
|
|
}
|
|
if (userId == change.userid) {
|
|
ctNode.setAttribute(this.userNameAttribute, change.username);
|
|
}
|
|
|
|
// dfl add change data
|
|
var changeData = ctNode.getAttribute(this.changeDataAttribute);
|
|
if (null == changeData) {
|
|
ctNode.setAttribute(this.changeDataAttribute, this.changeData || "");
|
|
}
|
|
|
|
if (!ctNode.getAttribute(this.timeAttribute)) ctNode.setAttribute(this.timeAttribute, change.time);
|
|
|
|
if (!ice.dom.hasClass(ctNode, this._getIceNodeClass(change.type))) ice.dom.addClass(ctNode, this._getIceNodeClass(change.type));
|
|
|
|
var style = this.getUserStyle(change.userid);
|
|
if (!ice.dom.hasClass(ctNode, style)) ice.dom.addClass(ctNode, style);
|
|
/* Added by dfl */
|
|
this._setNodeTitle(ctNode, change);
|
|
},
|
|
|
|
getChange: function (changeid) {
|
|
var change = null;
|
|
if (this._changes[changeid]) {
|
|
change = this._changes[changeid];
|
|
}
|
|
return change;
|
|
},
|
|
|
|
getNewChangeId: function () {
|
|
var id = ++this._uniqueIDIndex;
|
|
if (this._changes[id]) {
|
|
// Dupe.. create another..
|
|
id = this.getNewChangeId();
|
|
}
|
|
return id;
|
|
},
|
|
|
|
startBatchChange: function () {
|
|
this._batchChangeid = this.getNewChangeId();
|
|
return this._batchChangeid;
|
|
},
|
|
|
|
endBatchChange: function (changeid) {
|
|
if (changeid !== this._batchChangeid) return;
|
|
this._batchChangeid = null;
|
|
},
|
|
|
|
getCurrentRange: function () {
|
|
try {
|
|
return this.selection.getRangeAt(0);
|
|
}
|
|
catch (e) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
_insertNode: function (node, range, insertingDummy) {
|
|
var origNode = node;
|
|
if (!ice.dom.isBlockElement(range.startContainer) && !ice.dom.canContainTextElement(ice.dom.getBlockParent(range.startContainer, this.element)) && range.startContainer.previousSibling) {
|
|
range.setStart(range.startContainer.previousSibling, 0);
|
|
|
|
}
|
|
var startContainer = range.startContainer;
|
|
var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null;
|
|
if (parentBlock === this.element) {
|
|
var firstPar = document.createElement(this.blockEl);
|
|
parentBlock.appendChild(firstPar);
|
|
range.setStart(firstPar, 0);
|
|
range.collapse();
|
|
return this._insertNode(node, range, insertingDummy);
|
|
}
|
|
if (ice.dom.hasNoTextOrStubContent(parentBlock)) {
|
|
ice.dom.empty(parentBlock);
|
|
ice.dom.append(parentBlock, '<br>');
|
|
range.setStart(parentBlock, 0);
|
|
}
|
|
|
|
var ctNode = this.getIceNode(range.startContainer, 'insertType');
|
|
var inCurrentUserInsert = this._currentUserIceNode(ctNode);
|
|
|
|
// Do nothing, let this bubble-up to insertion handler.
|
|
if (insertingDummy && inCurrentUserInsert) return;
|
|
// If we aren't in an insert node which belongs to the current user, then create a new ins node
|
|
else if (!inCurrentUserInsert) node = this.createIceNode('insertType', node);
|
|
|
|
range.insertNode(node);
|
|
range.setEnd(node, 1);
|
|
|
|
if (insertingDummy) {
|
|
// Create a selection of the dummy character we inserted
|
|
// which will be removed after it bubbles up to the final handler.
|
|
range.setStart(node, 0);
|
|
} else {
|
|
range.collapse();
|
|
}
|
|
|
|
this.selection.addRange(range);
|
|
},
|
|
|
|
_handleVoidEl: function(el, range) {
|
|
// If `el` is or is in a void element, but not a delete
|
|
// then collapse the `range` and return `true`.
|
|
var voidEl = this._getVoidElement(el);
|
|
if (voidEl && !this.getIceNode(voidEl, 'deleteType')) {
|
|
range.collapse(true);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
_deleteSelection: function (range) {
|
|
|
|
// Bookmark the range and get elements between.
|
|
var bookmark = new ice.Bookmark(this.env, range),
|
|
elements = ice.dom.getElementsBetween(bookmark.start, bookmark.end),
|
|
b1 = ice.dom.parents(range.startContainer, this.blockEls.join(', '))[0],
|
|
b2 = ice.dom.parents(range.endContainer, this.blockEls.join(', '))[0],
|
|
betweenBlocks = new Array();
|
|
|
|
for (var i = 0; i < elements.length; i++) {
|
|
var elem = elements[i];
|
|
if (ice.dom.isBlockElement(elem)) {
|
|
betweenBlocks.push(elem);
|
|
if (!ice.dom.canContainTextElement(elem)) {
|
|
// Ignore containers that are not supposed to contain text. Check children instead.
|
|
for (var k = 0; k < elem.childNodes.length; k++) {
|
|
elements.push(elem.childNodes[k]);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
// Ignore empty space nodes
|
|
if (elem.nodeType === ice.dom.TEXT_NODE && ice.dom.getNodeTextContent(elem).length === 0) continue;
|
|
|
|
if (!this._getVoidElement(elem)) {
|
|
// If the element is not a text or stub node, go deeper and check the children.
|
|
if (elem.nodeType !== ice.dom.TEXT_NODE) {
|
|
// Browsers like to insert breaks into empty paragraphs - remove them
|
|
if (ice.dom.BREAK_ELEMENT == ice.dom.getTagName(elem)) {
|
|
continue;
|
|
}
|
|
|
|
if (ice.dom.isStubElement(elem)) {
|
|
this._addNodeTracking(elem, false, true);
|
|
continue;
|
|
}
|
|
if (ice.dom.hasNoTextOrStubContent(elem)) {
|
|
ice.dom.remove(elem);
|
|
}
|
|
|
|
for (j = 0; j < elem.childNodes.length; j++) {
|
|
var child = elem.childNodes[j];
|
|
elements.push(child);
|
|
}
|
|
continue;
|
|
}
|
|
var parentBlock = ice.dom.getBlockParent(elem);
|
|
this._addNodeTracking(elem, false, true, true);
|
|
if (ice.dom.hasNoTextOrStubContent(parentBlock)) {
|
|
ice.dom.remove(parentBlock);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.mergeBlocks && b1 !== b2) {
|
|
while (betweenBlocks.length)
|
|
ice.dom.mergeContainers(betweenBlocks.shift(), b1);
|
|
ice.dom.removeBRFromChild(b2);
|
|
ice.dom.removeBRFromChild(b1);
|
|
ice.dom.mergeContainers(b2, b1);
|
|
}
|
|
|
|
bookmark.selectBookmark();
|
|
range.collapse(false);
|
|
},
|
|
|
|
// Delete
|
|
_deleteRight: function (range) {
|
|
|
|
var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null,
|
|
isEmptyBlock = parentBlock ? (ice.dom.hasNoTextOrStubContent(parentBlock)) : false,
|
|
nextBlock = parentBlock && ice.dom.getNextContentNode(parentBlock, this.element),
|
|
nextBlockIsEmpty = nextBlock ? (ice.dom.hasNoTextOrStubContent(nextBlock)) : false,
|
|
initialContainer = range.endContainer,
|
|
initialOffset = range.endOffset,
|
|
commonAncestor = range.commonAncestorContainer,
|
|
nextContainer, returnValue;
|
|
|
|
|
|
// If the current block is empty then let the browser handle the delete/event.
|
|
if (isEmptyBlock) return false;
|
|
|
|
// Some bugs in Firefox and Webkit make the caret disappear out of text nodes, so we try to put them back in.
|
|
if (commonAncestor.nodeType !== ice.dom.TEXT_NODE) {
|
|
|
|
// If placed at the beginning of a container that cannot contain text, such as an ul element, place the caret at the beginning of the first item.
|
|
if (initialOffset === 0 && ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) {
|
|
var firstItem = commonAncestor.firstElementChild;
|
|
if (firstItem) {
|
|
range.setStart(firstItem, 0);
|
|
range.collapse();
|
|
return this._deleteRight(range);
|
|
}
|
|
}
|
|
|
|
if (commonAncestor.childNodes.length > initialOffset) {
|
|
var tempTextContainer = document.createTextNode(' ');
|
|
commonAncestor.insertBefore(tempTextContainer, commonAncestor.childNodes[initialOffset]);
|
|
range.setStart(tempTextContainer, 1);
|
|
range.collapse(true);
|
|
returnValue = this._deleteRight(range);
|
|
ice.dom.remove(tempTextContainer);
|
|
return returnValue;
|
|
} else {
|
|
nextContainer = ice.dom.getNextContentNode(commonAncestor, this.element);
|
|
range.setEnd(nextContainer, 0);
|
|
range.collapse();
|
|
return this._deleteRight(range);
|
|
}
|
|
}
|
|
|
|
// Move range to position the cursor on the inside of any adjacent container that it is going
|
|
// to potentially delete into or after a stub element. E.G.: test|<em>text</em> -> test<em>|text</em> or
|
|
// text1 |<img> text2 -> text1 <img>| text2
|
|
|
|
// Merge blocks: If mergeBlocks is enabled, merge the previous and current block.
|
|
range.moveEnd(ice.dom.CHARACTER_UNIT, 1);
|
|
range.moveEnd(ice.dom.CHARACTER_UNIT, -1);
|
|
|
|
// Handle cases of the caret is at the end of a container or placed directly in a block element
|
|
if (initialOffset === initialContainer.data.length && (!ice.dom.hasNoTextOrStubContent(initialContainer))) {
|
|
nextContainer = ice.dom.getNextNode(initialContainer, this.element);
|
|
|
|
// If the next container is outside of ICE then do nothing.
|
|
if (!nextContainer) {
|
|
range.selectNodeContents(initialContainer);
|
|
range.collapse();
|
|
return false;
|
|
}
|
|
|
|
// If the next container is <br> element find the next node
|
|
if (ice.dom.BREAK_ELEMENT == ice.dom.getTagName(nextContainer)) {
|
|
nextContainer = ice.dom.getNextNode(nextContainer, this.element);
|
|
}
|
|
|
|
// If the next container is a text node, look at the parent node instead.
|
|
if (nextContainer.nodeType === ice.dom.TEXT_NODE) {
|
|
nextContainer = nextContainer.parentNode;
|
|
}
|
|
|
|
// If the next container is non-editable, enclose it with a delete ice node and add an empty text node after it to position the caret.
|
|
if (!nextContainer.isContentEditable) {
|
|
returnValue = this._addNodeTracking(nextContainer, false, false);
|
|
var emptySpaceNode = document.createTextNode('');
|
|
nextContainer.parentNode.insertBefore(emptySpaceNode, nextContainer.nextSibling);
|
|
range.selectNode(emptySpaceNode);
|
|
range.collapse(true);
|
|
return returnValue;
|
|
}
|
|
|
|
if (this._handleVoidEl(nextContainer, range)) return true;
|
|
|
|
// If the caret was placed directly before a stub element, enclose the element with a delete ice node.
|
|
if (ice.dom.isChildOf(nextContainer, parentBlock) && ice.dom.isStubElement(nextContainer)) {
|
|
return this._addNodeTracking(nextContainer, range, false);
|
|
}
|
|
|
|
}
|
|
|
|
if (this._handleVoidEl(nextContainer, range)) return true;
|
|
|
|
// If we are deleting into a no tracking containiner, then remove the content
|
|
if (this._getNoTrackElement(range.endContainer.parentElement)) {
|
|
range.deleteContents();
|
|
return false;
|
|
}
|
|
|
|
if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) {
|
|
if (this.mergeBlocks && ice.dom.is(ice.dom.getBlockParent(nextContainer, this.element), this.blockEl)) {
|
|
// Since the range is moved by character, it may have passed through empty blocks.
|
|
// <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 (var i = 0; i < elements.length; i++) {
|
|
ice.dom.remove(elements[i]);
|
|
}
|
|
var startContainer = range.startContainer;
|
|
var endContainer = range.endContainer;
|
|
ice.dom.remove(ice.dom.find(startContainer, 'br'));
|
|
ice.dom.remove(ice.dom.find(endContainer, 'br'));
|
|
return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock);
|
|
} else {
|
|
// If the next block is empty, remove the next block.
|
|
if (nextBlockIsEmpty) {
|
|
ice.dom.remove(nextBlock);
|
|
range.collapse(true);
|
|
return true;
|
|
}
|
|
|
|
// Place the caret at the start of the next block.
|
|
range.setStart(nextBlock, 0);
|
|
range.collapse(true);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
var entireTextNode = range.endContainer;
|
|
var deletedCharacter = entireTextNode.splitText(range.endOffset);
|
|
var remainingTextNode = deletedCharacter.splitText(1);
|
|
|
|
return this._addNodeTracking(deletedCharacter, range, false);
|
|
|
|
},
|
|
|
|
// Backspace
|
|
_deleteLeft: function (range) {
|
|
|
|
var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null,
|
|
isEmptyBlock = parentBlock ? ice.dom.hasNoTextOrStubContent(parentBlock) : false,
|
|
prevBlock = parentBlock && ice.dom.getPrevContentNode(parentBlock, this.element), // || ice.dom.getBlockParent(parentBlock, this.element) || null,
|
|
prevBlockIsEmpty = prevBlock ? ice.dom.hasNoTextOrStubContent(prevBlock) : false,
|
|
initialContainer = range.startContainer,
|
|
initialOffset = range.startOffset,
|
|
commonAncestor = range.commonAncestorContainer,
|
|
lastSelectable, prevContainer;
|
|
|
|
// If the current block is empty, then let the browser handle the key/event.
|
|
if (isEmptyBlock) return false;
|
|
|
|
// Handle cases of the caret is at the start of a container or outside a text node
|
|
if (initialOffset === 0 || commonAncestor.nodeType !== ice.dom.TEXT_NODE) {
|
|
// If placed at the end of a container that cannot contain text, such as an ul element, place the caret at the end of the last item.
|
|
if (ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) {
|
|
if (initialOffset === 0) {
|
|
var firstItem = commonAncestor.firstElementChild;
|
|
if (firstItem) {
|
|
range.setStart(firstItem, 0);
|
|
range.collapse();
|
|
return this._deleteLeft(range);
|
|
}
|
|
|
|
} else {
|
|
var lastItem = commonAncestor.lastElementChild;
|
|
if (lastItem) {
|
|
|
|
lastSelectable = range.getLastSelectableChild(lastItem);
|
|
if (lastSelectable) {
|
|
range.setStart(lastSelectable, lastSelectable.data.length);
|
|
range.collapse();
|
|
return this._deleteLeft(range);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (initialOffset === 0) {
|
|
prevContainer = ice.dom.getPrevContentNode(initialContainer, this.element);
|
|
} else {
|
|
prevContainer = commonAncestor.childNodes[initialOffset - 1];
|
|
}
|
|
|
|
// If the previous container is outside of ICE then do nothing.
|
|
if (!prevContainer) {
|
|
return false;
|
|
}
|
|
|
|
// Firefox finds an ice node wrapped around an image instead of the image itself sometimes, so we make sure to look at the image instead.
|
|
if (ice.dom.is(prevContainer, '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType')) && prevContainer.childNodes.length > 0 && prevContainer.lastChild) {
|
|
prevContainer = prevContainer.lastChild;
|
|
}
|
|
|
|
// If the previous container is a text node, look at the parent node instead.
|
|
if (prevContainer.nodeType === ice.dom.TEXT_NODE) {
|
|
prevContainer = prevContainer.parentNode;
|
|
}
|
|
|
|
// If the previous container is non-editable, enclose it with a delete ice node and add an empty text node before it to position the caret.
|
|
if (!prevContainer.isContentEditable) {
|
|
var returnValue = this._addNodeTracking(prevContainer, false, true);
|
|
var emptySpaceNode = document.createTextNode('');
|
|
prevContainer.parentNode.insertBefore(emptySpaceNode, prevContainer);
|
|
range.selectNode(emptySpaceNode);
|
|
range.collapse(true);
|
|
return returnValue;
|
|
}
|
|
|
|
if (this._handleVoidEl(prevContainer, range)) return true;
|
|
|
|
// If the caret was placed directly after a stub element, enclose the element with a delete ice node.
|
|
if (ice.dom.isStubElement(prevContainer) && ice.dom.isChildOf(prevContainer, parentBlock) || !prevContainer.isContentEditable) {
|
|
return this._addNodeTracking(prevContainer, range, true);
|
|
}
|
|
|
|
// If the previous container is a stub element between blocks
|
|
// then just delete and leave the range/cursor in place.
|
|
if (ice.dom.isStubElement(prevContainer)) {
|
|
ice.dom.remove(prevContainer);
|
|
range.collapse(true);
|
|
return false;
|
|
}
|
|
|
|
if (prevContainer !== parentBlock && !ice.dom.isChildOf(prevContainer, parentBlock)) {
|
|
|
|
if (!ice.dom.canContainTextElement(prevContainer)) {
|
|
prevContainer = prevContainer.lastElementChild;
|
|
}
|
|
// Before putting the caret into the last selectable child, lets see if the last element is a stub element. If it is, we need to put the caret there manually.
|
|
if (prevContainer.lastChild && prevContainer.lastChild.nodeType !== ice.dom.TEXT_NODE && ice.dom.isStubElement(prevContainer.lastChild) && prevContainer.lastChild.tagName !== 'BR') {
|
|
range.setStartAfter(prevContainer.lastChild);
|
|
range.collapse(true);
|
|
return true;
|
|
}
|
|
// Find the last selectable part of the prevContainer. If it exists, put the caret there.
|
|
lastSelectable = range.getLastSelectableChild(prevContainer);
|
|
|
|
if (lastSelectable && !ice.dom.isOnBlockBoundary(range.startContainer, lastSelectable, this.element)) {
|
|
range.selectNodeContents(lastSelectable);
|
|
range.collapse();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Firefox: If an image is at the start of the paragraph and the user has just deleted the image using backspace, an empty text node is created in the delete node before
|
|
// the image, but the caret is placed with the image. We move the caret to the empty text node and execute deleteFromLeft again.
|
|
if (initialOffset === 1 && !ice.dom.isBlockElement(commonAncestor) && range.startContainer.childNodes.length > 1 && range.startContainer.childNodes[0].nodeType === ice.dom.TEXT_NODE && range.startContainer.childNodes[0].data.length === 0) {
|
|
range.setStart(range.startContainer, 0);
|
|
return this._deleteLeft(range);
|
|
}
|
|
|
|
// Move range to position the cursor on the inside of any adjacent container that it is going
|
|
// to potentially delete into or before a stub element. E.G.: <em>text</em>| test -> <em>text|</em> test or
|
|
// text1 <img>| text2 -> text1 |<img> text2
|
|
range.moveStart(ice.dom.CHARACTER_UNIT, -1);
|
|
range.moveStart(ice.dom.CHARACTER_UNIT, 1);
|
|
|
|
// If we are deleting into a no tracking containiner, then remove the content
|
|
if (this._getNoTrackElement(range.startContainer.parentElement)) {
|
|
range.deleteContents();
|
|
return false;
|
|
}
|
|
|
|
// Handles cases in which the caret is at the start of the block.
|
|
if (ice.dom.isOnBlockBoundary(range.startContainer, range.endContainer, this.element)) {
|
|
|
|
// If the previous block is empty, remove the previous block.
|
|
if (prevBlockIsEmpty) {
|
|
ice.dom.remove(prevBlock);
|
|
range.collapse();
|
|
return true;
|
|
}
|
|
|
|
// Merge blocks: If mergeBlocks is enabled, merge the previous and current block.
|
|
if (this.mergeBlocks && ice.dom.is(ice.dom.getBlockParent(prevContainer, this.element), this.blockEl)) {
|
|
// Since the range is moved by character, it may have passed through empty blocks.
|
|
// <p>text {RANGE.START}</p><p></p><p>{RANGE.END} text</p>
|
|
if (prevBlock !== ice.dom.getBlockParent(range.startContainer, this.element)) {
|
|
range.setStart(prevBlock, prevBlock.childNodes.length);
|
|
}
|
|
// The browsers like to auto-insert breaks into empty paragraphs - remove them.
|
|
var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer)
|
|
for (var i = 0; i < elements.length; i++) {
|
|
ice.dom.remove(elements[i]);
|
|
}
|
|
var startContainer = range.startContainer;
|
|
var endContainer = range.endContainer;
|
|
ice.dom.remove(ice.dom.find(startContainer, 'br'));
|
|
ice.dom.remove(ice.dom.find(endContainer, 'br'));
|
|
return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock);
|
|
}
|
|
|
|
// If the previous Block ends with a stub element, set the caret behind it.
|
|
if (prevBlock && prevBlock.lastChild && ice.dom.isStubElement(prevBlock.lastChild)) {
|
|
range.setStartAfter(prevBlock.lastChild);
|
|
range.collapse(true);
|
|
return true;
|
|
}
|
|
|
|
// Place the caret at the end of the previous block.
|
|
lastSelectable = range.getLastSelectableChild(prevBlock);
|
|
if (lastSelectable) {
|
|
range.setStart(lastSelectable, lastSelectable.data.length);
|
|
range.collapse(true);
|
|
} else if (prevBlock) {
|
|
range.setStart(prevBlock, prevBlock.childNodes.length);
|
|
range.collapse(true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
var entireTextNode = range.startContainer;
|
|
var deletedCharacter = entireTextNode.splitText(range.startOffset - 1);
|
|
var remainingTextNode = deletedCharacter.splitText(1);
|
|
|
|
return this._addNodeTracking(deletedCharacter, range, true);
|
|
|
|
},
|
|
|
|
// Marks text and other nodes for deletion
|
|
_addNodeTracking: function (contentNode, range, moveLeft) {
|
|
|
|
var contentAddNode = this.getIceNode(contentNode, 'insertType');
|
|
|
|
if (contentAddNode && this._currentUserIceNode(contentAddNode)) {
|
|
if (range && moveLeft) {
|
|
range.selectNode(contentNode);
|
|
}
|
|
contentNode.parentNode.removeChild(contentNode);
|
|
var cleanNode = ice.dom.cloneNode(contentAddNode);
|
|
ice.dom.remove(ice.dom.find(cleanNode, '.iceBookmark'));
|
|
// Remove a potential empty tracking container
|
|
if (contentAddNode !== null && (ice.dom.hasNoTextOrStubContent(cleanNode[0]))) {
|
|
var newstart = this.env.document.createTextNode('');
|
|
ice.dom.insertBefore(contentAddNode, newstart);
|
|
if (range) {
|
|
range.setStart(newstart, 0);
|
|
range.collapse(true);
|
|
}
|
|
ice.dom.replaceWith(contentAddNode, ice.dom.contents(contentAddNode));
|
|
}
|
|
|
|
return true;
|
|
|
|
} else if (range && this.getIceNode(contentNode, 'deleteType')) {
|
|
// It if the contentNode a text node, unite it with text nodes before and after it.
|
|
contentNode.normalize();
|
|
|
|
var found = false;
|
|
if (moveLeft) {
|
|
// Move to the left until there is valid sibling.
|
|
var previousSibling = ice.dom.getPrevContentNode(contentNode, this.element);
|
|
while (!found) {
|
|
ctNode = this.getIceNode(previousSibling, 'deleteType');
|
|
if (!ctNode) {
|
|
found = true;
|
|
} else {
|
|
previousSibling = ice.dom.getPrevContentNode(previousSibling, this.element);
|
|
}
|
|
}
|
|
if (previousSibling) {
|
|
var lastSelectable = range.getLastSelectableChild(previousSibling);
|
|
if (lastSelectable) {
|
|
previousSibling = lastSelectable;
|
|
}
|
|
range.setStart(previousSibling, ice.dom.getNodeCharacterLength(previousSibling));
|
|
range.collapse(true);
|
|
}
|
|
return true;
|
|
} else {
|
|
// Move the range to the right until there is valid sibling.
|
|
|
|
var nextSibling = ice.dom.getNextContentNode(contentNode, this.element);
|
|
while (!found) {
|
|
ctNode = this.getIceNode(nextSibling, 'deleteType');
|
|
if (!ctNode) {
|
|
found = true;
|
|
} else {
|
|
nextSibling = ice.dom.getNextContentNode(nextSibling, this.element);
|
|
}
|
|
}
|
|
|
|
if (nextSibling) {
|
|
range.selectNodeContents(nextSibling);
|
|
range.collapse(true);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
}
|
|
// Webkit likes to insert empty text nodes next to elements. We remove them here.
|
|
if (contentNode.previousSibling && contentNode.previousSibling.nodeType === ice.dom.TEXT_NODE && contentNode.previousSibling.length === 0) {
|
|
contentNode.parentNode.removeChild(contentNode.previousSibling);
|
|
}
|
|
if (contentNode.nextSibling && contentNode.nextSibling.nodeType === ice.dom.TEXT_NODE && contentNode.nextSibling.length === 0) {
|
|
contentNode.parentNode.removeChild(contentNode.nextSibling);
|
|
}
|
|
var prevDelNode = this.getIceNode(contentNode.previousSibling, 'deleteType');
|
|
var nextDelNode = this.getIceNode(contentNode.nextSibling, 'deleteType');
|
|
var ctNode;
|
|
|
|
if (prevDelNode && this._currentUserIceNode(prevDelNode)) {
|
|
ctNode = prevDelNode;
|
|
ctNode.appendChild(contentNode);
|
|
if (nextDelNode && this._currentUserIceNode(nextDelNode)) {
|
|
var nextDelContents = ice.dom.extractContent(nextDelNode);
|
|
ice.dom.append(ctNode, nextDelContents);
|
|
nextDelNode.parentNode.removeChild(nextDelNode);
|
|
}
|
|
} else if (nextDelNode && this._currentUserIceNode(nextDelNode)) {
|
|
ctNode = nextDelNode;
|
|
ctNode.insertBefore(contentNode, ctNode.firstChild);
|
|
} else {
|
|
ctNode = this.createIceNode('deleteType');
|
|
contentNode.parentNode.insertBefore(ctNode, contentNode);
|
|
ctNode.appendChild(contentNode);
|
|
}
|
|
|
|
if (range) {
|
|
if (ice.dom.isStubElement(contentNode)) {
|
|
range.selectNode(contentNode);
|
|
} else {
|
|
range.selectNodeContents(contentNode);
|
|
}
|
|
if (moveLeft) {
|
|
range.collapse(true);
|
|
} else {
|
|
range.collapse();
|
|
}
|
|
contentNode.normalize();
|
|
}
|
|
return true;
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
* Handles arrow, delete key events, and others.
|
|
*
|
|
* @param {event} e The event object.
|
|
* return {void|boolean} Returns false if default event needs to be blocked.
|
|
*/
|
|
_handleAncillaryKey: function (e) {
|
|
var key = e.keyCode;
|
|
var preventDefault = true;
|
|
var shiftKey = e.shiftKey;
|
|
|
|
switch (key) {
|
|
case ice.dom.DOM_VK_DELETE:
|
|
preventDefault = this.deleteContents();
|
|
this.pluginsManager.fireKeyPressed(e);
|
|
break;
|
|
|
|
case 46:
|
|
// Key 46 is the DELETE key.
|
|
preventDefault = this.deleteContents(true);
|
|
this.pluginsManager.fireKeyPressed(e);
|
|
break;
|
|
|
|
case ice.dom.DOM_VK_DOWN:
|
|
case ice.dom.DOM_VK_UP:
|
|
case ice.dom.DOM_VK_LEFT:
|
|
case ice.dom.DOM_VK_RIGHT:
|
|
this.pluginsManager.fireCaretPositioned();
|
|
preventDefault = false;
|
|
break;
|
|
|
|
default:
|
|
// Ignore key.
|
|
preventDefault = false;
|
|
break;
|
|
} //end switch
|
|
|
|
if (preventDefault === true) {
|
|
ice.dom.preventDefault(e);
|
|
return false;
|
|
}
|
|
return true;
|
|
|
|
},
|
|
|
|
keyDown: function (e) {
|
|
if (!this.pluginsManager.fireKeyDown(e)) {
|
|
ice.dom.preventDefault(e);
|
|
return false;
|
|
}
|
|
|
|
var preventDefault = false;
|
|
|
|
if (this._handleSpecialKey(e) === false) {
|
|
if (ice.dom.isBrowser('msie') !== true) {
|
|
this._preventKeyPress = true;
|
|
}
|
|
|
|
return false;
|
|
} else if ((e.ctrlKey === true || e.metaKey === true) && (ice.dom.isBrowser('msie') === true || ice.dom.isBrowser('chrome') === true)) {
|
|
// IE does not fire keyPress event if ctrl is also pressed.
|
|
// E.g. CTRL + B (Bold) will not fire keyPress so this.plugins
|
|
// needs to be notified here for IE.
|
|
if (!this.pluginsManager.fireKeyPressed(e)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
switch (e.keyCode) {
|
|
case 27:
|
|
// ESC
|
|
break;
|
|
default:
|
|
// If not Firefox then check if event is special arrow key etc.
|
|
// Firefox will handle this in keyPress event.
|
|
if (/Firefox/.test(navigator.userAgent) !== true) {
|
|
preventDefault = !(this._handleAncillaryKey(e));
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (preventDefault) {
|
|
ice.dom.preventDefault(e);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
keyPress: function (e) {
|
|
if (this._preventKeyPress === true) {
|
|
this._preventKeyPress = false;
|
|
return;
|
|
}
|
|
var c = null;
|
|
if (e.which == null) {
|
|
// IE.
|
|
c = String.fromCharCode(e.keyCode);
|
|
} else if (e.which > 0) {
|
|
c = String.fromCharCode(e.which);
|
|
}
|
|
|
|
if (!this.pluginsManager.fireKeyPress(e)) { return false; }
|
|
if (e.ctrlKey || e.metaKey) {
|
|
return true;
|
|
}
|
|
|
|
// Inside a br - most likely in a placeholder of a new block - delete before handling.
|
|
var range = this.getCurrentRange();
|
|
var br = range && ice.dom.parents(range.startContainer, 'br')[0] || null;
|
|
if (br) {
|
|
range.moveToNextEl(br);
|
|
br.parentNode.removeChild(br);
|
|
}
|
|
|
|
// Ice will ignore the keyPress event if CMD or CTRL key is also pressed
|
|
if (c !== null && e.ctrlKey !== true && e.metaKey !== true) {
|
|
switch (e.keyCode) {
|
|
case ice.dom.DOM_VK_DELETE:
|
|
// Handle delete key for Firefox.
|
|
return this._handleAncillaryKey(e);
|
|
case ice.dom.DOM_VK_ENTER:
|
|
return this._handleEnter();
|
|
default:
|
|
// If we are in a deletion, move the range to the end/outside.
|
|
this._moveRangeToValidTrackingPos(range, range.startContainer);
|
|
return this.insert(c);
|
|
}
|
|
}
|
|
|
|
return this._handleAncillaryKey(e);
|
|
},
|
|
|
|
_handleEnter: function () {
|
|
var range = this.getCurrentRange();
|
|
if (range && !range.collapsed) this.deleteContents();
|
|
return true;
|
|
},
|
|
|
|
_handleSpecialKey: function (e) {
|
|
var keyCode = e.which;
|
|
if (keyCode === null) {
|
|
// IE.
|
|
keyCode = e.keyCode;
|
|
}
|
|
|
|
var preventDefault = false;
|
|
switch (keyCode) {
|
|
case 65:
|
|
// Check for CTRL/CMD + A (select all).
|
|
if (e.ctrlKey === true || e.metaKey === true) {
|
|
preventDefault = true;
|
|
var range = this.getCurrentRange();
|
|
|
|
if (ice.dom.isBrowser('msie') === true) {
|
|
var selStart = this.env.document.createTextNode('');
|
|
var selEnd = this.env.document.createTextNode('');
|
|
|
|
if (this.element.firstChild) {
|
|
ice.dom.insertBefore(this.element.firstChild, selStart);
|
|
} else {
|
|
this.element.appendChild(selStart);
|
|
}
|
|
|
|
this.element.appendChild(selEnd);
|
|
|
|
range.setStart(selStart, 0);
|
|
range.setEnd(selEnd, 0);
|
|
} else {
|
|
range.setStart(range.getFirstSelectableChild(this.element), 0);
|
|
var lastSelectable = range.getLastSelectableChild(this.element);
|
|
range.setEnd(lastSelectable, lastSelectable.length);
|
|
} //end if
|
|
|
|
this.selection.addRange(range);
|
|
} //end if
|
|
break;
|
|
|
|
default:
|
|
// Not a special key.
|
|
break;
|
|
} //end switch
|
|
|
|
if (preventDefault === true) {
|
|
ice.dom.preventDefault(e);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
mouseUp: function (e, target) {
|
|
if (!this.pluginsManager.fireClicked(e)) return false;
|
|
this.pluginsManager.fireSelectionChanged(this.getCurrentRange());
|
|
return true;
|
|
},
|
|
|
|
mouseDown: function (e, target) {
|
|
if (!this.pluginsManager.fireMouseDown(e)) {
|
|
return false;
|
|
}
|
|
this.pluginsManager.fireCaretUpdated();
|
|
return true;
|
|
},
|
|
|
|
/* Added by dfl */
|
|
|
|
getContentElement : function() {
|
|
return this.element;
|
|
},
|
|
|
|
getIceNodes : function() {
|
|
var classList = [];
|
|
var self = this;
|
|
ice.dom.each(this.changeTypes,
|
|
function (type, i) {
|
|
classList.push('.' + self._getIceNodeClass(type));
|
|
});
|
|
classList = classList.join(',');
|
|
return jQuery(this.element).find(classList);
|
|
},
|
|
|
|
/**
|
|
* Returns the first ice node in the hierarchy of the given node, or the current collapsed range.
|
|
* null if not in a track changes hierarchy
|
|
*/
|
|
currentChangeNode: function (node) {
|
|
var selector = '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType');
|
|
if (!node) {
|
|
var range = this.getCurrentRange();
|
|
if (!range || !range.collapsed) {
|
|
return false;
|
|
}
|
|
else {
|
|
node = range.startContainer;
|
|
}
|
|
}
|
|
return ice.dom.getNode(node, selector);
|
|
},
|
|
|
|
setShowChanges : function(bShow) {
|
|
bShow = !! bShow;
|
|
this._isVisible = bShow;
|
|
var $body = jQuery(this.element);
|
|
$body.toggleClass("ICE-Tracking", bShow);
|
|
this._showTitles(bShow);
|
|
this._setInterval();
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
_triggerChange : function() {
|
|
jQuery(this).trigger("change");
|
|
},
|
|
|
|
_setNodeTitle : function(node, change) {
|
|
if (! change || ! this.titleTemplate) {
|
|
return null;
|
|
}
|
|
var title = this.titleTemplate;
|
|
var time = change ? change.time : parseInt(node.getAttribute(this.timeAttribute) || 0);
|
|
time = new Date(time);
|
|
var userName = (change ? change.username : (node.getAttribute(this.userNameAttribute) || "")) || "(Unknown)";
|
|
title = title.replace(/%t/g, this._relativeDateFormat(time));
|
|
title = title.replace(/%u/g, userName);
|
|
title = title.replace(/%dd/g, padNumber(time.getDate(), 2));
|
|
title = title.replace(/%d/g, time.getDate());
|
|
title = title.replace(/%mm/g, padNumber(time.getMonth() + 1, 2));
|
|
title = title.replace(/%m/g, time.getMonth() + 1);
|
|
title = title.replace(/%yy/g, padNumber(time.getYear() - 100, 2));
|
|
title = title.replace(/%y/g, time.getFullYear());
|
|
title = title.replace(/%nn/g, padNumber(time.getMinutes(), 2));
|
|
title = title.replace(/%n/g, time.getMinutes());
|
|
title = title.replace(/%hh/g, padNumber(time.getHours(), 2));
|
|
title = title.replace(/%h/g, time.getHours());
|
|
node.setAttribute("title", title);
|
|
|
|
return title;
|
|
},
|
|
|
|
_acceptRejectSome : function(options, isAccept) {
|
|
var f = (function(index, node) {
|
|
this.acceptRejectChange(node, isAccept);
|
|
}).bind(this);
|
|
var changes = this._filterChanges(options);
|
|
for (var id in changes.changes) {
|
|
var nodes = ice.dom.find(this.element, '[' + this.changeIdAttribute + '=' + id + ']');
|
|
nodes.each(f);
|
|
}
|
|
if (changes.count) {
|
|
this._triggerChange();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Filters the current change set based on options
|
|
* @param options may contain one of:
|
|
* exclude: an array of user ids to exclude, include: an array of user ids to include
|
|
* and
|
|
* filter: a filter function of the form function({userid, time, data}):boolean
|
|
* @return an object with two members: count, changes (map of id:changeObject)
|
|
*/
|
|
_filterChanges : function(options) {
|
|
var count = 0, changes = {};
|
|
var filter = options && options.filter;
|
|
var exclude = options && options.exclude ? jQuery.map(options.exclude, function(e) { return String(e); }) : null;
|
|
var include = options && options.include ? jQuery.map(options.include, function(e) { return String(e); }) : null;
|
|
for (var key in this._changes) {
|
|
var 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) {
|
|
++count;
|
|
changes[key] = change;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { count : count, changes : changes };
|
|
},
|
|
|
|
_loadFromDom : function() {
|
|
this._changes = {};
|
|
this._userStyles = {};
|
|
var myUserId = this.currentUser && this.currentUser.id;
|
|
var myUserName = (this.currentUser && this.currentUser.name) || "";
|
|
var now = (new Date()).getTime();
|
|
// Grab class for each changeType
|
|
var changeTypeClasses = [];
|
|
for (var changeType in this.changeTypes) {
|
|
changeTypeClasses.push(this._getIceNodeClass(changeType));
|
|
}
|
|
|
|
var nodes = this.getIceNodes();
|
|
function f(i, el) {
|
|
var styleIndex = 0;
|
|
var ctnType = '';
|
|
var classList = el.className.split(' ');
|
|
//TODO optimize this - create a map of regexp
|
|
for (var i = 0; i < classList.length; i++) {
|
|
var styleReg = new RegExp(this.stylePrefix + '-(\\d+)').exec(classList[i]);
|
|
if (styleReg) styleIndex = styleReg[1];
|
|
var ctnReg = new RegExp('(' + changeTypeClasses.join('|') + ')').exec(classList[i]);
|
|
if (ctnReg) ctnType = this._getChangeTypeFromAlias(ctnReg[1]);
|
|
}
|
|
var userid = ice.dom.attr(el, this.userIdAttribute);
|
|
var userName;
|
|
if (myUserId && (userid == myUserId)) {
|
|
userName = myUserName;
|
|
el.setAttribute(this.userNameAttribute, myUserName)
|
|
}
|
|
else {
|
|
userName = el.getAttribute(this.userNameAttribute);
|
|
}
|
|
this.setUserStyle(userid, Number(styleIndex));
|
|
var changeid = parseInt(ice.dom.attr(el, this.changeIdAttribute) || "");
|
|
if (isNaN(changeid)) {
|
|
changeid = this.getNewChangeId();
|
|
el.setAttribute(this.changeIdAttribute, changeid);
|
|
}
|
|
var timeStamp = parseInt(el.getAttribute(this.timeAttribute) || "");
|
|
if (isNaN(timeStamp)) {
|
|
timeStamp = now;
|
|
}
|
|
var changeData = ice.dom.attr(el, this.changeDataAttribute) || "";
|
|
var change = {
|
|
type: ctnType,
|
|
userid: String(userid),// dfl: must stringify for consistency - when we read the props from dom attrs they are strings
|
|
username: userName,
|
|
time: timeStamp,
|
|
data : changeData
|
|
};
|
|
this._changes[changeid] = change;
|
|
this._setNodeTitle(el, change);
|
|
}
|
|
nodes.each(f.bind(this));
|
|
this._triggerChange();
|
|
},
|
|
|
|
_updateUserData : function(user) {
|
|
if (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();
|
|
nodes.each((function(i,node) {
|
|
var match = (! user) || (user.id == node.getAttribute(this.userIdAttribute));
|
|
if (user && match) {
|
|
node.setAttribute(this.userNameAttribute, user.name);
|
|
}
|
|
if (match && this._isVisible) {
|
|
var change = this._changes[node.getAttribute(this.changeIdAttribute)];
|
|
if (change) {
|
|
this._setNodeTitle(node, change);
|
|
}
|
|
}
|
|
}).bind(this))
|
|
},
|
|
|
|
_showTitles : function(bShow) {
|
|
var nodes = this.getIceNodes();
|
|
if (bShow) {
|
|
jQuery(nodes).each((function(i, node) {
|
|
var changeId = node.getAttribute(this.changeIdAttribute);
|
|
var change = changeId && this._changes[changeId];
|
|
if (change) {
|
|
this._setNodeTitle(node, change)
|
|
}
|
|
}).bind(this));
|
|
}
|
|
else {
|
|
jQuery(nodes).removeAttr("title");
|
|
}
|
|
},
|
|
|
|
_setInterval : function() {
|
|
if (this.isTracking && this.isVisible) {
|
|
if (! this._refreshInterval) {
|
|
this._refreshInterval = setInterval((function() {
|
|
this._updateUserData(null);
|
|
}).bind(this), 60000);
|
|
}
|
|
}
|
|
else {
|
|
if (this._refreshInterval) {
|
|
clearInterval(this._refreshInterval);
|
|
this._refreshInterval = null;
|
|
}
|
|
}
|
|
},
|
|
|
|
_relativeDateFormat : function(date, now) {
|
|
if (!date) {
|
|
return "";
|
|
}
|
|
now = now || new Date();
|
|
var today = now.getDate();
|
|
var month = now.getMonth();
|
|
var year = now.getFullYear();
|
|
|
|
var t = typeof(date);
|
|
if (t == "string" || t == "number") {
|
|
date = new Date(date);
|
|
}
|
|
|
|
var format = "";
|
|
var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
|
|
if (today == date.getDate() && month == date.getMonth() && year == date.getFullYear()) {
|
|
var minutes = Math.floor((now.getTime() - date.getTime()) / 60000);
|
|
if (minutes < 1) {
|
|
return "now";
|
|
}
|
|
else if (minutes < 2) {
|
|
return "1 minute ago";
|
|
}
|
|
else if (minutes < 60) {
|
|
return (minutes + " minutes ago");
|
|
}
|
|
else {
|
|
var hours = date.getHours();
|
|
var minutes = date.getMinutes();
|
|
return "on " + padNumber(hours, 2) + ":" + padNumber(minutes, 2, "0");
|
|
}
|
|
} else if (year == date.getFullYear()) {
|
|
return "on " + months[date.getMonth()] + " " + date.getDate();
|
|
} else {
|
|
return "on " + months[date.getMonth()] + " " + date.getDate() + ", " + date.getFullYear();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
exports.ice = this.ice || {};
|
|
exports.ice.InlineChangeEditor = InlineChangeEditor;
|
|
|
|
}).call(this);
|