(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 + ' >
' + this.blockEl + '>'));
} else {
body.appendChild(ice.dom.create('<' + this.blockEl + ' >
' + 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:
*
text {RANGE.START}
{RANGE.END} text
if (nextBlock !== ice.dom.getBlockParent(range.endContainer, this.element)) { range.setEnd(nextBlock, 0); } // The browsers like to auto-insert breaks into empty paragraphs - remove them. var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer); for (var i = 0; i < elements.length; i++) { ice.dom.remove(elements[i]); } var startContainer = range.startContainer; var endContainer = range.endContainer; ice.dom.remove(ice.dom.find(startContainer, 'br')); ice.dom.remove(ice.dom.find(endContainer, 'br')); return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock); } else { // If the next block is empty, remove the next block. if (nextBlockIsEmpty) { ice.dom.remove(nextBlock); range.collapse(true); return true; } // Place the caret at the start of the next block. range.setStart(nextBlock, 0); range.collapse(true); return true; } } var entireTextNode = range.endContainer; var deletedCharacter = entireTextNode.splitText(range.endOffset); var remainingTextNode = deletedCharacter.splitText(1); return this._addNodeTracking(deletedCharacter, range, false); }, // Backspace _deleteLeft: function (range) { var parentBlock = ice.dom.isBlockElement(range.startContainer) && range.startContainer || ice.dom.getBlockParent(range.startContainer, this.element) || null, isEmptyBlock = parentBlock ? ice.dom.hasNoTextOrStubContent(parentBlock) : false, prevBlock = parentBlock && ice.dom.getPrevContentNode(parentBlock, this.element), // || ice.dom.getBlockParent(parentBlock, this.element) || null, prevBlockIsEmpty = prevBlock ? ice.dom.hasNoTextOrStubContent(prevBlock) : false, initialContainer = range.startContainer, initialOffset = range.startOffset, commonAncestor = range.commonAncestorContainer, lastSelectable, prevContainer; // If the current block is empty, then let the browser handle the key/event. if (isEmptyBlock) return false; // Handle cases of the caret is at the start of a container or outside a text node if (initialOffset === 0 || commonAncestor.nodeType !== ice.dom.TEXT_NODE) { // If placed at the end of a container that cannot contain text, such as an ul element, place the caret at the end of the last item. if (ice.dom.isBlockElement(commonAncestor) && (!ice.dom.canContainTextElement(commonAncestor))) { if (initialOffset === 0) { var firstItem = commonAncestor.firstElementChild; if (firstItem) { range.setStart(firstItem, 0); range.collapse(); return this._deleteLeft(range); } } else { var lastItem = commonAncestor.lastElementChild; if (lastItem) { lastSelectable = range.getLastSelectableChild(lastItem); if (lastSelectable) { range.setStart(lastSelectable, lastSelectable.data.length); range.collapse(); return this._deleteLeft(range); } } } } if (initialOffset === 0) { prevContainer = ice.dom.getPrevContentNode(initialContainer, this.element); } else { prevContainer = commonAncestor.childNodes[initialOffset - 1]; } // If the previous container is outside of ICE then do nothing. if (!prevContainer) { return false; } // Firefox finds an ice node wrapped around an image instead of the image itself sometimes, so we make sure to look at the image instead. if (ice.dom.is(prevContainer, '.' + this._getIceNodeClass('insertType') + ', .' + this._getIceNodeClass('deleteType')) && prevContainer.childNodes.length > 0 && prevContainer.lastChild) { prevContainer = prevContainer.lastChild; } // If the previous container is a text node, look at the parent node instead. if (prevContainer.nodeType === ice.dom.TEXT_NODE) { prevContainer = prevContainer.parentNode; } // If the previous container is non-editable, enclose it with a delete ice node and add an empty text node before it to position the caret. if (!prevContainer.isContentEditable) { var returnValue = this._addNodeTracking(prevContainer, false, true); var emptySpaceNode = document.createTextNode(''); prevContainer.parentNode.insertBefore(emptySpaceNode, prevContainer); range.selectNode(emptySpaceNode); range.collapse(true); return returnValue; } if (this._handleVoidEl(prevContainer, range)) return true; // If the caret was placed directly after a stub element, enclose the element with a delete ice node. if (ice.dom.isStubElement(prevContainer) && ice.dom.isChildOf(prevContainer, parentBlock) || !prevContainer.isContentEditable) { return this._addNodeTracking(prevContainer, range, true); } // If the previous container is a stub element between blocks // then just delete and leave the range/cursor in place. if (ice.dom.isStubElement(prevContainer)) { ice.dom.remove(prevContainer); range.collapse(true); return false; } if (prevContainer !== parentBlock && !ice.dom.isChildOf(prevContainer, parentBlock)) { if (!ice.dom.canContainTextElement(prevContainer)) { prevContainer = prevContainer.lastElementChild; } // Before putting the caret into the last selectable child, lets see if the last element is a stub element. If it is, we need to put the caret there manually. if (prevContainer.lastChild && prevContainer.lastChild.nodeType !== ice.dom.TEXT_NODE && ice.dom.isStubElement(prevContainer.lastChild) && prevContainer.lastChild.tagName !== 'BR') { range.setStartAfter(prevContainer.lastChild); range.collapse(true); return true; } // Find the last selectable part of the prevContainer. If it exists, put the caret there. lastSelectable = range.getLastSelectableChild(prevContainer); if (lastSelectable && !ice.dom.isOnBlockBoundary(range.startContainer, lastSelectable, this.element)) { range.selectNodeContents(lastSelectable); range.collapse(); return true; } } } // Firefox: If an image is at the start of the paragraph and the user has just deleted the image using backspace, an empty text node is created in the delete node before // the image, but the caret is placed with the image. We move the caret to the empty text node and execute deleteFromLeft again. if (initialOffset === 1 && !ice.dom.isBlockElement(commonAncestor) && range.startContainer.childNodes.length > 1 && range.startContainer.childNodes[0].nodeType === ice.dom.TEXT_NODE && range.startContainer.childNodes[0].data.length === 0) { range.setStart(range.startContainer, 0); return this._deleteLeft(range); } // Move range to position the cursor on the inside of any adjacent container that it is going // to potentially delete into or before a stub element. E.G.: text| test -> text| test or // text1text {RANGE.START}
{RANGE.END} text
if (prevBlock !== ice.dom.getBlockParent(range.startContainer, this.element)) { range.setStart(prevBlock, prevBlock.childNodes.length); } // The browsers like to auto-insert breaks into empty paragraphs - remove them. var elements = ice.dom.getElementsBetween(range.startContainer, range.endContainer) for (var i = 0; i < elements.length; i++) { ice.dom.remove(elements[i]); } var startContainer = range.startContainer; var endContainer = range.endContainer; ice.dom.remove(ice.dom.find(startContainer, 'br')); ice.dom.remove(ice.dom.find(endContainer, 'br')); return ice.dom.mergeBlockWithSibling(range, ice.dom.getBlockParent(range.endContainer, this.element) || parentBlock); } // If the previous Block ends with a stub element, set the caret behind it. if (prevBlock && prevBlock.lastChild && ice.dom.isStubElement(prevBlock.lastChild)) { range.setStartAfter(prevBlock.lastChild); range.collapse(true); return true; } // Place the caret at the end of the previous block. lastSelectable = range.getLastSelectableChild(prevBlock); if (lastSelectable) { range.setStart(lastSelectable, lastSelectable.data.length); range.collapse(true); } else if (prevBlock) { range.setStart(prevBlock, prevBlock.childNodes.length); range.collapse(true); } return true; } var entireTextNode = range.startContainer; var deletedCharacter = entireTextNode.splitText(range.startOffset - 1); var remainingTextNode = deletedCharacter.splitText(1); return this._addNodeTracking(deletedCharacter, range, true); }, // Marks text and other nodes for deletion _addNodeTracking: function (contentNode, range, moveLeft) { var contentAddNode = this.getIceNode(contentNode, 'insertType'); if (contentAddNode && this._currentUserIceNode(contentAddNode)) { if (range && moveLeft) { range.selectNode(contentNode); } contentNode.parentNode.removeChild(contentNode); var cleanNode = ice.dom.cloneNode(contentAddNode); ice.dom.remove(ice.dom.find(cleanNode, '.iceBookmark')); // Remove a potential empty tracking container if (contentAddNode !== null && (ice.dom.hasNoTextOrStubContent(cleanNode[0]))) { var newstart = this.env.document.createTextNode(''); ice.dom.insertBefore(contentAddNode, newstart); if (range) { range.setStart(newstart, 0); range.collapse(true); } ice.dom.replaceWith(contentAddNode, ice.dom.contents(contentAddNode)); } return true; } else if (range && this.getIceNode(contentNode, 'deleteType')) { // It if the contentNode a text node, unite it with text nodes before and after it. contentNode.normalize(); var found = false; if (moveLeft) { // Move to the left until there is valid sibling. var previousSibling = ice.dom.getPrevContentNode(contentNode, this.element); while (!found) { ctNode = this.getIceNode(previousSibling, 'deleteType'); if (!ctNode) { found = true; } else { previousSibling = ice.dom.getPrevContentNode(previousSibling, this.element); } } if (previousSibling) { var lastSelectable = range.getLastSelectableChild(previousSibling); if (lastSelectable) { previousSibling = lastSelectable; } range.setStart(previousSibling, ice.dom.getNodeCharacterLength(previousSibling)); range.collapse(true); } return true; } else { // Move the range to the right until there is valid sibling. var nextSibling = ice.dom.getNextContentNode(contentNode, this.element); while (!found) { ctNode = this.getIceNode(nextSibling, 'deleteType'); if (!ctNode) { found = true; } else { nextSibling = ice.dom.getNextContentNode(nextSibling, this.element); } } if (nextSibling) { range.selectNodeContents(nextSibling); range.collapse(true); } return true; } } // Webkit likes to insert empty text nodes next to elements. We remove them here. if (contentNode.previousSibling && contentNode.previousSibling.nodeType === ice.dom.TEXT_NODE && contentNode.previousSibling.length === 0) { contentNode.parentNode.removeChild(contentNode.previousSibling); } if (contentNode.nextSibling && contentNode.nextSibling.nodeType === ice.dom.TEXT_NODE && contentNode.nextSibling.length === 0) { contentNode.parentNode.removeChild(contentNode.nextSibling); } var prevDelNode = this.getIceNode(contentNode.previousSibling, 'deleteType'); var nextDelNode = this.getIceNode(contentNode.nextSibling, 'deleteType'); var ctNode; if (prevDelNode && this._currentUserIceNode(prevDelNode)) { ctNode = prevDelNode; ctNode.appendChild(contentNode); if (nextDelNode && this._currentUserIceNode(nextDelNode)) { var nextDelContents = ice.dom.extractContent(nextDelNode); ice.dom.append(ctNode, nextDelContents); nextDelNode.parentNode.removeChild(nextDelNode); } } else if (nextDelNode && this._currentUserIceNode(nextDelNode)) { ctNode = nextDelNode; ctNode.insertBefore(contentNode, ctNode.firstChild); } else { ctNode = this.createIceNode('deleteType'); contentNode.parentNode.insertBefore(ctNode, contentNode); ctNode.appendChild(contentNode); } if (range) { if (ice.dom.isStubElement(contentNode)) { range.selectNode(contentNode); } else { range.selectNodeContents(contentNode); } if (moveLeft) { range.collapse(true); } else { range.collapse(); } contentNode.normalize(); } return true; }, /** * Handles arrow, delete key events, and others. * * @param {event} e The event object. * return {void|boolean} Returns false if default event needs to be blocked. */ _handleAncillaryKey: function (e) { var key = e.keyCode; 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);