(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 + ' >
')); } else { body.appendChild(ice.dom.create('<' + 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: * * 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: * * 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('
' + body + '
'); 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|text -> test|text 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, '
'); 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|text -> test|text or // text1 | text2 -> text1 | 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
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. //

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 // text1 | text2 -> text1 | 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. //

text {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);