blob: d273a4c153bb2ff45f3954059332cd97001a7cdc [file] [log] [blame]
// 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.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');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.plugins.UndoRedoManager');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.events');
goog.require('goog.events.EventHandler');
goog.require('goog.log');
goog.require('goog.object');
/**
* 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.log.Logger}
* @protected
* @override
*/
goog.editor.plugins.UndoRedo.prototype.logger =
goog.log.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 {goog.events.Key}
* @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_ =
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.
goog.editor.node.replaceInnerHtml(fieldObj.getElement(), 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) {
goog.log.error(this.logger, '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;
};