// Copyright 2005 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Code for handling edit history (undo/redo). * */ goog.provide('goog.editor.plugins.UndoRedo'); goog.require('goog.debug.Logger'); goog.require('goog.dom'); goog.require('goog.dom.NodeOffset'); goog.require('goog.dom.Range'); goog.require('goog.editor.BrowserFeature'); goog.require('goog.editor.Command'); goog.require('goog.editor.Field.EventType'); goog.require('goog.editor.Plugin'); goog.require('goog.editor.plugins.UndoRedoManager'); goog.require('goog.editor.plugins.UndoRedoState'); goog.require('goog.events'); goog.require('goog.events.EventHandler'); /** * Encapsulates undo/redo logic using a custom undo stack (i.e. not browser * built-in). Browser built-in undo stacks are too flaky (e.g. IE's gets * clobbered on DOM modifications). Also, this allows interleaving non-editing * commands into the undo stack via the UndoRedoManager. * * @param {goog.editor.plugins.UndoRedoManager=} opt_manager An undo redo * manager to be used by this plugin. If none is provided one is created. * @constructor * @extends {goog.editor.Plugin} */ goog.editor.plugins.UndoRedo = function(opt_manager) { goog.editor.Plugin.call(this); this.setUndoRedoManager(opt_manager || new goog.editor.plugins.UndoRedoManager()); // Map of goog.editor.Field hashcode to goog.events.EventHandler this.eventHandlers_ = {}; this.currentStates_ = {}; /** * @type {?string} * @private */ this.initialFieldChange_ = null; /** * A copy of {@code goog.editor.plugins.UndoRedo.restoreState} bound to this, * used by undo-redo state objects to restore the state of an editable field. * @type {Function} * @see goog.editor.plugins.UndoRedo#restoreState * @private */ this.boundRestoreState_ = goog.bind(this.restoreState, this); }; goog.inherits(goog.editor.plugins.UndoRedo, goog.editor.Plugin); /** * The logger for this class. * @type {goog.debug.Logger} * @protected * @override */ goog.editor.plugins.UndoRedo.prototype.logger = goog.debug.Logger.getLogger('goog.editor.plugins.UndoRedo'); /** * The {@code UndoState_} whose change is in progress, null if an undo or redo * is not in progress. * * @type {goog.editor.plugins.UndoRedo.UndoState_?} * @private */ goog.editor.plugins.UndoRedo.prototype.inProgressUndo_ = null; /** * The undo-redo stack manager used by this plugin. * @type {goog.editor.plugins.UndoRedoManager} * @private */ goog.editor.plugins.UndoRedo.prototype.undoManager_; /** * The key for the event listener handling state change events from the * undo-redo manager. * @type {number} * @private */ goog.editor.plugins.UndoRedo.prototype.managerStateChangeKey_; /** * Commands implemented by this plugin. * @enum {string} */ goog.editor.plugins.UndoRedo.COMMAND = { UNDO: '+undo', REDO: '+redo' }; /** * Inverse map of execCommand strings to * {@link goog.editor.plugins.UndoRedo.COMMAND} constants. Used to determine * whether a string corresponds to a command this plugin handles in O(1) time. * @type {Object} * @private */ goog.editor.plugins.UndoRedo.SUPPORTED_COMMANDS_ = goog.object.transpose(goog.editor.plugins.UndoRedo.COMMAND); /** * Set the max undo stack depth (not the real memory usage). * @param {number} depth Depth of the stack. */ goog.editor.plugins.UndoRedo.prototype.setMaxUndoDepth = function(depth) { this.undoManager_.setMaxUndoDepth(depth); }; /** * Set the undo-redo manager used by this plugin. Any state on a previous * undo-redo manager is lost. * @param {goog.editor.plugins.UndoRedoManager} manager The undo-redo manager. */ goog.editor.plugins.UndoRedo.prototype.setUndoRedoManager = function(manager) { if (this.managerStateChangeKey_) { goog.events.unlistenByKey(this.managerStateChangeKey_); } this.undoManager_ = manager; this.managerStateChangeKey_ = /** @type {number} */ ( goog.events.listen(this.undoManager_, goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE, this.dispatchCommandValueChange_, false, this)); }; /** * Whether the string corresponds to a command this plugin handles. * @param {string} command Command string to check. * @return {boolean} Whether the string corresponds to a command * this plugin handles. * @override */ goog.editor.plugins.UndoRedo.prototype.isSupportedCommand = function(command) { return command in goog.editor.plugins.UndoRedo.SUPPORTED_COMMANDS_; }; /** * Unregisters and disables the fieldObject with this plugin. Thie does *not* * clobber the undo stack for the fieldObject though. * TODO(user): For the multifield version, we really should add a way to * ignore undo actions on field's that have been made uneditable. * This is probably as simple as skipping over entries in the undo stack * that have a hashcode of an uneditable field. * @param {goog.editor.Field} fieldObject The field to register with the plugin. * @override */ goog.editor.plugins.UndoRedo.prototype.unregisterFieldObject = function( fieldObject) { this.disable(fieldObject); this.setFieldObject(null); }; /** * This is so subclasses can deal with multifield undo-redo. * @return {goog.editor.Field} The active field object for this field. This is * the one registered field object for the single-plugin case and the * focused field for the multi-field plugin case. */ goog.editor.plugins.UndoRedo.prototype.getCurrentFieldObject = function() { return this.getFieldObject(); }; /** * This is so subclasses can deal with multifield undo-redo. * @param {string} fieldHashCode The Field's hashcode. * @return {goog.editor.Field} The field object with the hashcode. */ goog.editor.plugins.UndoRedo.prototype.getFieldObjectForHash = function( fieldHashCode) { // With single field undoredo, there's only one Field involved. return this.getFieldObject(); }; /** * This is so subclasses can deal with multifield undo-redo. * @return {goog.editor.Field} Target for COMMAND_VALUE_CHANGE events. */ goog.editor.plugins.UndoRedo.prototype.getCurrentEventTarget = function() { return this.getFieldObject(); }; /** @override */ goog.editor.plugins.UndoRedo.prototype.enable = function(fieldObject) { if (this.isEnabled(fieldObject)) { return; } // Don't want pending delayed changes from when undo-redo was disabled // firing after undo-redo is enabled since they might cause undo-redo stack // updates. fieldObject.clearDelayedChange(); var eventHandler = new goog.events.EventHandler(this); // TODO(user): From ojan during a code review: // The beforechange handler is meant to be there so you can grab the cursor // position *before* the change is made as that's where you want the cursor to // be after an undo. // // It kinda looks like updateCurrentState_ doesn't do that correctly right // now, but it really should be fixed to do so. The cursor position stored in // the state should be the cursor position before any changes are made, not // the cursor position when the change finishes. // // It also seems like the if check below is just a bad one. We should do this // for browsers that use mutation events as well even though the beforechange // happens too late...maybe not. I don't know about this. if (!goog.editor.BrowserFeature.USE_MUTATION_EVENTS) { // We don't listen to beforechange in mutation-event browsers because // there we fire beforechange, then syncronously file change. The point // of before change is to capture before the user has changed anything. eventHandler.listen(fieldObject, goog.editor.Field.EventType.BEFORECHANGE, this.handleBeforeChange_); } eventHandler.listen(fieldObject, goog.editor.Field.EventType.DELAYEDCHANGE, this.handleDelayedChange_); eventHandler.listen(fieldObject, goog.editor.Field.EventType.BLUR, this.handleBlur_); this.eventHandlers_[fieldObject.getHashCode()] = eventHandler; // We want to capture the initial state of a Trogedit field before any // editing has happened. This is necessary so that we can undo the first // change to a field, even if we don't handle beforeChange. this.updateCurrentState_(fieldObject); }; /** @override */ goog.editor.plugins.UndoRedo.prototype.disable = function(fieldObject) { // Process any pending changes so we don't lose any undo-redo states that we // want prior to disabling undo-redo. fieldObject.clearDelayedChange(); var eventHandler = this.eventHandlers_[fieldObject.getHashCode()]; if (eventHandler) { eventHandler.dispose(); delete this.eventHandlers_[fieldObject.getHashCode()]; } // We delete the current state of the field on disable. When we re-enable // the state will be re-fetched. In most cases the content will be the same, // but this allows us to pick up changes while not editable. That way, when // undoing after starting an editable session, you can always undo to the // state you started in. Given this sequence of events: // Make editable // Type 'anakin' // Make not editable // Set HTML to be 'padme' // Make editable // Type 'dark side' // Undo // Without re-snapshoting current state on enable, the undo would go from // 'dark-side' -> 'anakin', rather than 'dark-side' -> 'padme'. You couldn't // undo the field to the state that existed immediately after it was made // editable for the second time. if (this.currentStates_[fieldObject.getHashCode()]) { delete this.currentStates_[fieldObject.getHashCode()]; } }; /** @override */ goog.editor.plugins.UndoRedo.prototype.isEnabled = function(fieldObject) { // All enabled plugins have a eventHandler so reuse that map rather than // storing additional enabled state. return !!this.eventHandlers_[fieldObject.getHashCode()]; }; /** @override */ goog.editor.plugins.UndoRedo.prototype.disposeInternal = function() { goog.editor.plugins.UndoRedo.superClass_.disposeInternal.call(this); for (var hashcode in this.eventHandlers_) { this.eventHandlers_[hashcode].dispose(); delete this.eventHandlers_[hashcode]; } this.setFieldObject(null); if (this.undoManager_) { this.undoManager_.dispose(); delete this.undoManager_; } }; /** @override */ goog.editor.plugins.UndoRedo.prototype.getTrogClassId = function() { return 'UndoRedo'; }; /** @override */ goog.editor.plugins.UndoRedo.prototype.execCommand = function(command, var_args) { if (command == goog.editor.plugins.UndoRedo.COMMAND.UNDO) { this.undoManager_.undo(); } else if (command == goog.editor.plugins.UndoRedo.COMMAND.REDO) { this.undoManager_.redo(); } }; /** @override */ goog.editor.plugins.UndoRedo.prototype.queryCommandValue = function(command) { var state = null; if (command == goog.editor.plugins.UndoRedo.COMMAND.UNDO) { state = this.undoManager_.hasUndoState(); } else if (command == goog.editor.plugins.UndoRedo.COMMAND.REDO) { state = this.undoManager_.hasRedoState(); } return state; }; /** * Dispatches the COMMAND_VALUE_CHANGE event on the editable field or the field * manager, as appropriate. * Note: Really, people using multi field mode should be listening directly * to the undo-redo manager for events. * @private */ goog.editor.plugins.UndoRedo.prototype.dispatchCommandValueChange_ = function() { var eventTarget = this.getCurrentEventTarget(); eventTarget.dispatchEvent({ type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE, commands: [goog.editor.plugins.UndoRedo.COMMAND.REDO, goog.editor.plugins.UndoRedo.COMMAND.UNDO]}); }; /** * Restores the state of the editable field. * @param {goog.editor.plugins.UndoRedo.UndoState_} state The state initiating * the restore. * @param {string} content The content to restore. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition * The cursor position within the content. */ goog.editor.plugins.UndoRedo.prototype.restoreState = function( state, content, cursorPosition) { // Fire any pending changes to get the current field state up to date and // then stop listening to changes while doing the undo/redo. var fieldObj = this.getFieldObjectForHash(state.fieldHashCode); if (!fieldObj) { return; } // Fires any pending changes, and stops the change events. Still want to // dispatch before change, as a change is being made and the change event // will be manually dispatched below after the new content has been restored // (also restarting change events). fieldObj.stopChangeEvents(true, true); // To prevent the situation where we stop change events and then an exception // happens before we can restart change events, the following code must be in // a try-finally block. try { fieldObj.dispatchBeforeChange(); // Restore the state fieldObj.execCommand(goog.editor.Command.CLEAR_LOREM, true); // We specifically set the raw innerHTML of the field here as that's what // we get from the field when we save an undo/redo state. There's // no need to clean/unclean the contents in either direction. fieldObj.getElement().innerHTML = content; if (cursorPosition) { cursorPosition.select(); } var previousFieldObject = this.getCurrentFieldObject(); fieldObj.focus(); // Apps that integrate their undo-redo with Trogedit may be // in a state where there is no previous field object (no field focused at // the time of undo), so check for existence first. if (previousFieldObject && previousFieldObject.getHashCode() != state.fieldHashCode) { previousFieldObject.execCommand(goog.editor.Command.UPDATE_LOREM); } // We need to update currentState_ to reflect the change. this.currentStates_[state.fieldHashCode].setUndoState( content, cursorPosition); } catch (e) { this.logger.severe('Error while restoring undo state', e); } finally { // Clear the delayed change event, set flag so we know not to act on it. this.inProgressUndo_ = state; // Notify the editor that we've changed (fire autosave). // Note that this starts up change events again, so we don't have to // manually do so even though we stopped change events above. fieldObj.dispatchChange(); fieldObj.dispatchSelectionChangeEvent(); } }; /** * @override */ goog.editor.plugins.UndoRedo.prototype.handleKeyboardShortcut = function(e, key, isModifierPressed) { if (isModifierPressed) { var command; if (key == 'z') { command = e.shiftKey ? goog.editor.plugins.UndoRedo.COMMAND.REDO : goog.editor.plugins.UndoRedo.COMMAND.UNDO; } else if (key == 'y') { command = goog.editor.plugins.UndoRedo.COMMAND.REDO; } if (command) { // In the case where Trogedit shares its undo redo stack with another // application it's possible that an undo or redo will not be for an // goog.editor.Field. In this case we don't want to go through the // goog.editor.Field execCommand flow which stops and restarts events on // the current field. Only Trogedit UndoState's have a fieldHashCode so // use that to distinguish between Trogedit and other states. var state = command == goog.editor.plugins.UndoRedo.COMMAND.UNDO ? this.undoManager_.undoPeek() : this.undoManager_.redoPeek(); if (state && state.fieldHashCode) { this.getCurrentFieldObject().execCommand(command); } else { this.execCommand(command); } return true; } } return false; }; /** * Clear the undo/redo stack. */ goog.editor.plugins.UndoRedo.prototype.clearHistory = function() { // Fire all pending change events, so that they don't come back // asynchronously to fill the queue. this.getFieldObject().stopChangeEvents(true, true); this.undoManager_.clearHistory(); this.getFieldObject().startChangeEvents(); }; /** * Refreshes the current state of the editable field as maintained by undo-redo, * without adding any undo-redo states to the stack. * @param {goog.editor.Field} fieldObject The editable field. */ goog.editor.plugins.UndoRedo.prototype.refreshCurrentState = function( fieldObject) { if (this.isEnabled(fieldObject)) { if (this.currentStates_[fieldObject.getHashCode()]) { delete this.currentStates_[fieldObject.getHashCode()]; } this.updateCurrentState_(fieldObject); } }; /** * Before the field changes, we want to save the state. * @param {goog.events.Event} e The event. * @private */ goog.editor.plugins.UndoRedo.prototype.handleBeforeChange_ = function(e) { if (this.inProgressUndo_) { // We are in between a previous undo and its delayed change event. // Continuing here clobbers the redo stack. // This does mean that if you are trying to undo/redo really quickly, it // will be gated by the speed of delayed change events. return; } var fieldObj = /** @type {goog.editor.Field} */ (e.target); var fieldHashCode = fieldObj.getHashCode(); if (this.initialFieldChange_ != fieldHashCode) { this.initialFieldChange_ = fieldHashCode; this.updateCurrentState_(fieldObj); } }; /** * After some idle time, we want to save the state. * @param {goog.events.Event} e The event. * @private */ goog.editor.plugins.UndoRedo.prototype.handleDelayedChange_ = function(e) { // This was undo making a change, don't add it BACK into the history if (this.inProgressUndo_) { // Must clear this.inProgressUndo_ before dispatching event because the // dispatch can cause another, queued undo that should be allowed to go // through. var state = this.inProgressUndo_; this.inProgressUndo_ = null; state.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED); return; } this.updateCurrentState_(/** @type {goog.editor.Field} */ (e.target)); }; /** * When the user blurs away, we need to save the state on that field. * @param {goog.events.Event} e The event. * @private */ goog.editor.plugins.UndoRedo.prototype.handleBlur_ = function(e) { var fieldObj = /** @type {goog.editor.Field} */ (e.target); if (fieldObj) { fieldObj.clearDelayedChange(); } }; /** * Returns the goog.editor.plugins.UndoRedo.CursorPosition_ for the current * selection in the given Field. * @param {goog.editor.Field} fieldObj The field object. * @return {goog.editor.plugins.UndoRedo.CursorPosition_} The CursorPosition_ or * null if there is no valid selection. * @private */ goog.editor.plugins.UndoRedo.prototype.getCursorPosition_ = function(fieldObj) { var cursorPos = new goog.editor.plugins.UndoRedo.CursorPosition_(fieldObj); if (!cursorPos.isValid()) { return null; } return cursorPos; }; /** * Helper method for saving state. * @param {goog.editor.Field} fieldObj The field object. * @private */ goog.editor.plugins.UndoRedo.prototype.updateCurrentState_ = function( fieldObj) { var fieldHashCode = fieldObj.getHashCode(); // We specifically grab the raw innerHTML of the field here as that's what // we would set on the field in the case of an undo/redo operation. There's // no need to clean/unclean the contents in either direction. In the case of // lorem ipsum being used, we want to capture the effective state (empty, no // cursor position) rather than capturing the lorem html. var content, cursorPos; if (fieldObj.queryCommandValue(goog.editor.Command.USING_LOREM)) { content = ''; cursorPos = null; } else { content = fieldObj.getElement().innerHTML; cursorPos = this.getCursorPosition_(fieldObj); } var currentState = this.currentStates_[fieldHashCode]; if (currentState) { // Don't create states if the content hasn't changed (spurious // delayed change). This can happen when lorem is cleared, for example. if (currentState.undoContent_ == content) { return; } else if (content == '' || currentState.undoContent_ == '') { // If lorem ipsum is on we say the contents are the empty string. However, // for an empty text shape with focus, the empty contents might not be // the same, depending on plugins. We want these two empty states to be // considered identical because to the user they are indistinguishable, // so we use fieldObj.getInjectableContents to map between them. // We cannot use getInjectableContents when first creating the undo // content for a field with lorem, because on enable when this is first // called we can't guarantee plugin registration order, so the // injectableContents at that time might not match the final // injectableContents. var emptyContents = fieldObj.getInjectableContents('', {}); if (content == emptyContents && currentState.undoContent_ == '' || currentState.undoContent_ == emptyContents && content == '') { return; } } currentState.setRedoState(content, cursorPos); this.undoManager_.addState(currentState); } this.currentStates_[fieldHashCode] = new goog.editor.plugins.UndoRedo.UndoState_(fieldHashCode, content, cursorPos, this.boundRestoreState_); }; /** * This object encapsulates the state of an editable field. * * @param {string} fieldHashCode String the id of the field we're saving the * content of. * @param {string} content String the actual text we're saving. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition * CursorPosLite object for the cursor position in the field. * @param {Function} restore The function used to restore editable field state. * @private * @constructor * @extends {goog.editor.plugins.UndoRedoState} */ goog.editor.plugins.UndoRedo.UndoState_ = function(fieldHashCode, content, cursorPosition, restore) { goog.editor.plugins.UndoRedoState.call(this, true); /** * The hash code for the field whose content is being saved. * @type {string} */ this.fieldHashCode = fieldHashCode; /** * The bound copy of {@code goog.editor.plugins.UndoRedo.restoreState} used by * this state. * @type {Function} * @private */ this.restore_ = restore; this.setUndoState(content, cursorPosition); }; goog.inherits(goog.editor.plugins.UndoRedo.UndoState_, goog.editor.plugins.UndoRedoState); /** * The content to restore on undo. * @type {string} * @private */ goog.editor.plugins.UndoRedo.UndoState_.prototype.undoContent_; /** * The cursor position to restore on undo. * @type {goog.editor.plugins.UndoRedo.CursorPosition_?} * @private */ goog.editor.plugins.UndoRedo.UndoState_.prototype.undoCursorPosition_; /** * The content to restore on redo, undefined until the state is pushed onto the * undo stack. * @type {string|undefined} * @private */ goog.editor.plugins.UndoRedo.UndoState_.prototype.redoContent_; /** * The cursor position to restore on redo, undefined until the state is pushed * onto the undo stack. * @type {goog.editor.plugins.UndoRedo.CursorPosition_|null|undefined} * @private */ goog.editor.plugins.UndoRedo.UndoState_.prototype.redoCursorPosition_; /** * Performs the undo operation represented by this state. * @override */ goog.editor.plugins.UndoRedo.UndoState_.prototype.undo = function() { this.restore_(this, this.undoContent_, this.undoCursorPosition_); }; /** * Performs the redo operation represented by this state. * @override */ goog.editor.plugins.UndoRedo.UndoState_.prototype.redo = function() { this.restore_(this, this.redoContent_, this.redoCursorPosition_); }; /** * Updates the undo portion of this state. Should only be used to update the * current state of an editable field, which is not yet on the undo stack after * an undo or redo operation. You should never be modifying states on the stack! * @param {string} content The current content. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition * The current cursor position. */ goog.editor.plugins.UndoRedo.UndoState_.prototype.setUndoState = function( content, cursorPosition) { this.undoContent_ = content; this.undoCursorPosition_ = cursorPosition; }; /** * Adds redo information to this state. This method should be called before the * state is added onto the undo stack. * * @param {string} content The content to restore on a redo. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition * The cursor position to restore on a redo. */ goog.editor.plugins.UndoRedo.UndoState_.prototype.setRedoState = function( content, cursorPosition) { this.redoContent_ = content; this.redoCursorPosition_ = cursorPosition; }; /** * Checks if the *contents* of two * {@code goog.editor.plugins.UndoRedo.UndoState_}s are the same. We don't * bother checking the cursor position (that's not something we'd want to save * anyway). * @param {goog.editor.plugins.UndoRedoState} rhs The state to compare. * @return {boolean} Whether the contents are the same. * @override */ goog.editor.plugins.UndoRedo.UndoState_.prototype.equals = function(rhs) { return this.fieldHashCode == rhs.fieldHashCode && this.undoContent_ == rhs.undoContent_ && this.redoContent_ == rhs.redoContent_; }; /** * Stores the state of the selection in a way the survives DOM modifications * that don't modify the user-interactable content (e.g. making something bold * vs. typing a character). * * TODO(user): Completely get rid of this and use goog.dom.SavedCaretRange. * * @param {goog.editor.Field} field The field the selection is in. * @private * @constructor */ goog.editor.plugins.UndoRedo.CursorPosition_ = function(field) { this.field_ = field; var win = field.getEditableDomHelper().getWindow(); var range = field.getRange(); var isValidRange = !!range && range.isRangeInDocument() && range.getWindow() == win; range = isValidRange ? range : null; if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { this.initW3C_(range); } else if (goog.editor.BrowserFeature.HAS_IE_RANGES) { this.initIE_(range); } }; /** * The standards compliant version keeps a list of childNode offsets. * @param {goog.dom.AbstractRange?} range The range to save. * @private */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.initW3C_ = function( range) { this.isValid_ = false; // TODO: Check if the range is in the field before trying to save it // for FF 3 contentEditable. if (!range) { return; } var anchorNode = range.getAnchorNode(); var focusNode = range.getFocusNode(); if (!anchorNode || !focusNode) { return; } var anchorOffset = range.getAnchorOffset(); var anchor = new goog.dom.NodeOffset(anchorNode, this.field_.getElement()); var focusOffset = range.getFocusOffset(); var focus = new goog.dom.NodeOffset(focusNode, this.field_.getElement()); // Test range direction. if (range.isReversed()) { this.startOffset_ = focus; this.startChildOffset_ = focusOffset; this.endOffset_ = anchor; this.endChildOffset_ = anchorOffset; } else { this.startOffset_ = anchor; this.startChildOffset_ = anchorOffset; this.endOffset_ = focus; this.endChildOffset_ = focusOffset; } this.isValid_ = true; }; /** * In IE, we just keep track of the text offset (number of characters). * @param {goog.dom.AbstractRange?} range The range to save. * @private */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.initIE_ = function( range) { this.isValid_ = false; if (!range) { return; } var ieRange = range.getTextRange(0).getBrowserRangeObject(); if (!goog.dom.contains(this.field_.getElement(), ieRange.parentElement())) { return; } // Create a range that encompasses the contentEditable region to serve // as a reference to form ranges below. var contentEditableRange = this.field_.getEditableDomHelper().getDocument().body.createTextRange(); contentEditableRange.moveToElementText(this.field_.getElement()); // startMarker is a range from the start of the contentEditable node to the // start of the current selection. var startMarker = ieRange.duplicate(); startMarker.collapse(true); startMarker.setEndPoint('StartToStart', contentEditableRange); this.startOffset_ = goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_( startMarker); // endMarker is a range from the start of teh contentEditable node to the // end of the current selection. var endMarker = ieRange.duplicate(); endMarker.setEndPoint('StartToStart', contentEditableRange); this.endOffset_ = goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_( endMarker); this.isValid_ = true; }; /** * @return {boolean} Whether this object is valid. */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.isValid = function() { return this.isValid_; }; /** * @return {string} A string representation of this object. * @override */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.toString = function() { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { return 'W3C:' + this.startOffset_.toString() + '\n' + this.startChildOffset_ + ':' + this.endOffset_.toString() + '\n' + this.endChildOffset_; } return 'IE:' + this.startOffset_ + ',' + this.endOffset_; }; /** * Makes the browser's selection match the cursor position. */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.select = function() { var range = this.getRange_(this.field_.getElement()); if (range) { if (goog.editor.BrowserFeature.HAS_IE_RANGES) { this.field_.getElement().focus(); } goog.dom.Range.createFromBrowserRange(range).select(); } }; /** * Get the range that encompases the the cursor position relative to a given * base node. * @param {Element} baseNode The node to get the cursor position relative to. * @return {Range|TextRange|null} The browser range for this position. * @private */ goog.editor.plugins.UndoRedo.CursorPosition_.prototype.getRange_ = function(baseNode) { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { var startNode = this.startOffset_.findTargetNode(baseNode); var endNode = this.endOffset_.findTargetNode(baseNode); if (!startNode || !endNode) { return null; } // Create range. return /** @type {Range} */ ( goog.dom.Range.createFromNodes(startNode, this.startChildOffset_, endNode, this.endChildOffset_).getBrowserRangeObject()); } // Create a collapsed selection at the start of the contentEditable region, // which the offsets were calculated relative to before. Note that we force // a text range here so we can use moveToElementText. var sel = baseNode.ownerDocument.body.createTextRange(); sel.moveToElementText(baseNode); sel.collapse(true); sel.moveEnd('character', this.endOffset_); sel.moveStart('character', this.startOffset_); return sel; }; /** * Compute the number of characters to the end of the range in IE. * @param {TextRange} range The range to compute an offset for. * @return {number} The number of characters to the end of the range. * @private */ goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_ = function(range) { var testRange = range.duplicate(); // The number of offset characters is a little off depending on // what type of block elements happen to be between the start of the // textedit and the cursor position. We fudge the offset until the // two ranges match. var text = range.text; var guess = text.length; testRange.collapse(true); testRange.moveEnd('character', guess); // Adjust the range until the end points match. This doesn't quite // work if we're at the end of the field so we give up after a few // iterations. var diff; var numTries = 10; while (diff = testRange.compareEndPoints('EndToEnd', range)) { guess -= diff; testRange.moveEnd('character', -diff); --numTries; if (0 == numTries) { break; } } // When we set innerHTML, blank lines become a single space, causing // the cursor position to be off by one. So we accommodate for blank // lines. var offset = 0; var pos = text.indexOf('\n\r'); while (pos != -1) { ++offset; pos = text.indexOf('\n\r', pos + 1); } return guess + offset; };