undoredo.js 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  1. // Copyright 2005 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. /**
  15. * @fileoverview Code for handling edit history (undo/redo).
  16. *
  17. */
  18. goog.provide('goog.editor.plugins.UndoRedo');
  19. goog.require('goog.dom');
  20. goog.require('goog.dom.NodeOffset');
  21. goog.require('goog.dom.Range');
  22. goog.require('goog.editor.BrowserFeature');
  23. goog.require('goog.editor.Command');
  24. goog.require('goog.editor.Field');
  25. goog.require('goog.editor.Plugin');
  26. goog.require('goog.editor.node');
  27. goog.require('goog.editor.plugins.UndoRedoManager');
  28. goog.require('goog.editor.plugins.UndoRedoState');
  29. goog.require('goog.events');
  30. goog.require('goog.events.EventHandler');
  31. goog.require('goog.log');
  32. goog.require('goog.object');
  33. /**
  34. * Encapsulates undo/redo logic using a custom undo stack (i.e. not browser
  35. * built-in). Browser built-in undo stacks are too flaky (e.g. IE's gets
  36. * clobbered on DOM modifications). Also, this allows interleaving non-editing
  37. * commands into the undo stack via the UndoRedoManager.
  38. *
  39. * @param {goog.editor.plugins.UndoRedoManager=} opt_manager An undo redo
  40. * manager to be used by this plugin. If none is provided one is created.
  41. * @constructor
  42. * @extends {goog.editor.Plugin}
  43. */
  44. goog.editor.plugins.UndoRedo = function(opt_manager) {
  45. goog.editor.Plugin.call(this);
  46. this.setUndoRedoManager(
  47. opt_manager || new goog.editor.plugins.UndoRedoManager());
  48. // Map of goog.editor.Field hashcode to goog.events.EventHandler
  49. this.eventHandlers_ = {};
  50. this.currentStates_ = {};
  51. /**
  52. * @type {?string}
  53. * @private
  54. */
  55. this.initialFieldChange_ = null;
  56. /**
  57. * A copy of {@code goog.editor.plugins.UndoRedo.restoreState} bound to this,
  58. * used by undo-redo state objects to restore the state of an editable field.
  59. * @type {Function}
  60. * @see goog.editor.plugins.UndoRedo#restoreState
  61. * @private
  62. */
  63. this.boundRestoreState_ = goog.bind(this.restoreState, this);
  64. };
  65. goog.inherits(goog.editor.plugins.UndoRedo, goog.editor.Plugin);
  66. /**
  67. * The logger for this class.
  68. * @type {goog.log.Logger}
  69. * @protected
  70. * @override
  71. */
  72. goog.editor.plugins.UndoRedo.prototype.logger =
  73. goog.log.getLogger('goog.editor.plugins.UndoRedo');
  74. /**
  75. * The {@code UndoState_} whose change is in progress, null if an undo or redo
  76. * is not in progress.
  77. *
  78. * @type {goog.editor.plugins.UndoRedo.UndoState_?}
  79. * @private
  80. */
  81. goog.editor.plugins.UndoRedo.prototype.inProgressUndo_ = null;
  82. /**
  83. * The undo-redo stack manager used by this plugin.
  84. * @type {goog.editor.plugins.UndoRedoManager}
  85. * @private
  86. */
  87. goog.editor.plugins.UndoRedo.prototype.undoManager_;
  88. /**
  89. * The key for the event listener handling state change events from the
  90. * undo-redo manager.
  91. * @type {goog.events.Key}
  92. * @private
  93. */
  94. goog.editor.plugins.UndoRedo.prototype.managerStateChangeKey_;
  95. /**
  96. * Commands implemented by this plugin.
  97. * @enum {string}
  98. */
  99. goog.editor.plugins.UndoRedo.COMMAND = {
  100. UNDO: '+undo',
  101. REDO: '+redo'
  102. };
  103. /**
  104. * Inverse map of execCommand strings to
  105. * {@link goog.editor.plugins.UndoRedo.COMMAND} constants. Used to determine
  106. * whether a string corresponds to a command this plugin handles in O(1) time.
  107. * @type {Object}
  108. * @private
  109. */
  110. goog.editor.plugins.UndoRedo.SUPPORTED_COMMANDS_ =
  111. goog.object.transpose(goog.editor.plugins.UndoRedo.COMMAND);
  112. /**
  113. * Set the max undo stack depth (not the real memory usage).
  114. * @param {number} depth Depth of the stack.
  115. */
  116. goog.editor.plugins.UndoRedo.prototype.setMaxUndoDepth = function(depth) {
  117. this.undoManager_.setMaxUndoDepth(depth);
  118. };
  119. /**
  120. * Set the undo-redo manager used by this plugin. Any state on a previous
  121. * undo-redo manager is lost.
  122. * @param {goog.editor.plugins.UndoRedoManager} manager The undo-redo manager.
  123. */
  124. goog.editor.plugins.UndoRedo.prototype.setUndoRedoManager = function(manager) {
  125. if (this.managerStateChangeKey_) {
  126. goog.events.unlistenByKey(this.managerStateChangeKey_);
  127. }
  128. this.undoManager_ = manager;
  129. this.managerStateChangeKey_ = goog.events.listen(
  130. this.undoManager_,
  131. goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE,
  132. this.dispatchCommandValueChange_, false, this);
  133. };
  134. /**
  135. * Whether the string corresponds to a command this plugin handles.
  136. * @param {string} command Command string to check.
  137. * @return {boolean} Whether the string corresponds to a command
  138. * this plugin handles.
  139. * @override
  140. */
  141. goog.editor.plugins.UndoRedo.prototype.isSupportedCommand = function(command) {
  142. return command in goog.editor.plugins.UndoRedo.SUPPORTED_COMMANDS_;
  143. };
  144. /**
  145. * Unregisters and disables the fieldObject with this plugin. Thie does *not*
  146. * clobber the undo stack for the fieldObject though.
  147. * TODO(user): For the multifield version, we really should add a way to
  148. * ignore undo actions on field's that have been made uneditable.
  149. * This is probably as simple as skipping over entries in the undo stack
  150. * that have a hashcode of an uneditable field.
  151. * @param {goog.editor.Field} fieldObject The field to register with the plugin.
  152. * @override
  153. */
  154. goog.editor.plugins.UndoRedo.prototype.unregisterFieldObject = function(
  155. fieldObject) {
  156. this.disable(fieldObject);
  157. this.setFieldObject(null);
  158. };
  159. /**
  160. * This is so subclasses can deal with multifield undo-redo.
  161. * @return {goog.editor.Field} The active field object for this field. This is
  162. * the one registered field object for the single-plugin case and the
  163. * focused field for the multi-field plugin case.
  164. */
  165. goog.editor.plugins.UndoRedo.prototype.getCurrentFieldObject = function() {
  166. return this.getFieldObject();
  167. };
  168. /**
  169. * This is so subclasses can deal with multifield undo-redo.
  170. * @param {string} fieldHashCode The Field's hashcode.
  171. * @return {goog.editor.Field} The field object with the hashcode.
  172. */
  173. goog.editor.plugins.UndoRedo.prototype.getFieldObjectForHash = function(
  174. fieldHashCode) {
  175. // With single field undoredo, there's only one Field involved.
  176. return this.getFieldObject();
  177. };
  178. /**
  179. * This is so subclasses can deal with multifield undo-redo.
  180. * @return {goog.editor.Field} Target for COMMAND_VALUE_CHANGE events.
  181. */
  182. goog.editor.plugins.UndoRedo.prototype.getCurrentEventTarget = function() {
  183. return this.getFieldObject();
  184. };
  185. /** @override */
  186. goog.editor.plugins.UndoRedo.prototype.enable = function(fieldObject) {
  187. if (this.isEnabled(fieldObject)) {
  188. return;
  189. }
  190. // Don't want pending delayed changes from when undo-redo was disabled
  191. // firing after undo-redo is enabled since they might cause undo-redo stack
  192. // updates.
  193. fieldObject.clearDelayedChange();
  194. var eventHandler = new goog.events.EventHandler(this);
  195. // TODO(user): From ojan during a code review:
  196. // The beforechange handler is meant to be there so you can grab the cursor
  197. // position *before* the change is made as that's where you want the cursor to
  198. // be after an undo.
  199. //
  200. // It kinda looks like updateCurrentState_ doesn't do that correctly right
  201. // now, but it really should be fixed to do so. The cursor position stored in
  202. // the state should be the cursor position before any changes are made, not
  203. // the cursor position when the change finishes.
  204. //
  205. // It also seems like the if check below is just a bad one. We should do this
  206. // for browsers that use mutation events as well even though the beforechange
  207. // happens too late...maybe not. I don't know about this.
  208. if (!goog.editor.BrowserFeature.USE_MUTATION_EVENTS) {
  209. // We don't listen to beforechange in mutation-event browsers because
  210. // there we fire beforechange, then syncronously file change. The point
  211. // of before change is to capture before the user has changed anything.
  212. eventHandler.listen(
  213. fieldObject, goog.editor.Field.EventType.BEFORECHANGE,
  214. this.handleBeforeChange_);
  215. }
  216. eventHandler.listen(
  217. fieldObject, goog.editor.Field.EventType.DELAYEDCHANGE,
  218. this.handleDelayedChange_);
  219. eventHandler.listen(
  220. fieldObject, goog.editor.Field.EventType.BLUR, this.handleBlur_);
  221. this.eventHandlers_[fieldObject.getHashCode()] = eventHandler;
  222. // We want to capture the initial state of a Trogedit field before any
  223. // editing has happened. This is necessary so that we can undo the first
  224. // change to a field, even if we don't handle beforeChange.
  225. this.updateCurrentState_(fieldObject);
  226. };
  227. /** @override */
  228. goog.editor.plugins.UndoRedo.prototype.disable = function(fieldObject) {
  229. // Process any pending changes so we don't lose any undo-redo states that we
  230. // want prior to disabling undo-redo.
  231. fieldObject.clearDelayedChange();
  232. var eventHandler = this.eventHandlers_[fieldObject.getHashCode()];
  233. if (eventHandler) {
  234. eventHandler.dispose();
  235. delete this.eventHandlers_[fieldObject.getHashCode()];
  236. }
  237. // We delete the current state of the field on disable. When we re-enable
  238. // the state will be re-fetched. In most cases the content will be the same,
  239. // but this allows us to pick up changes while not editable. That way, when
  240. // undoing after starting an editable session, you can always undo to the
  241. // state you started in. Given this sequence of events:
  242. // Make editable
  243. // Type 'anakin'
  244. // Make not editable
  245. // Set HTML to be 'padme'
  246. // Make editable
  247. // Type 'dark side'
  248. // Undo
  249. // Without re-snapshoting current state on enable, the undo would go from
  250. // 'dark-side' -> 'anakin', rather than 'dark-side' -> 'padme'. You couldn't
  251. // undo the field to the state that existed immediately after it was made
  252. // editable for the second time.
  253. if (this.currentStates_[fieldObject.getHashCode()]) {
  254. delete this.currentStates_[fieldObject.getHashCode()];
  255. }
  256. };
  257. /** @override */
  258. goog.editor.plugins.UndoRedo.prototype.isEnabled = function(fieldObject) {
  259. // All enabled plugins have a eventHandler so reuse that map rather than
  260. // storing additional enabled state.
  261. return !!this.eventHandlers_[fieldObject.getHashCode()];
  262. };
  263. /** @override */
  264. goog.editor.plugins.UndoRedo.prototype.disposeInternal = function() {
  265. goog.editor.plugins.UndoRedo.superClass_.disposeInternal.call(this);
  266. for (var hashcode in this.eventHandlers_) {
  267. this.eventHandlers_[hashcode].dispose();
  268. delete this.eventHandlers_[hashcode];
  269. }
  270. this.setFieldObject(null);
  271. if (this.undoManager_) {
  272. this.undoManager_.dispose();
  273. delete this.undoManager_;
  274. }
  275. };
  276. /** @override */
  277. goog.editor.plugins.UndoRedo.prototype.getTrogClassId = function() {
  278. return 'UndoRedo';
  279. };
  280. /** @override */
  281. goog.editor.plugins.UndoRedo.prototype.execCommand = function(
  282. command, var_args) {
  283. if (command == goog.editor.plugins.UndoRedo.COMMAND.UNDO) {
  284. this.undoManager_.undo();
  285. } else if (command == goog.editor.plugins.UndoRedo.COMMAND.REDO) {
  286. this.undoManager_.redo();
  287. }
  288. };
  289. /** @override */
  290. goog.editor.plugins.UndoRedo.prototype.queryCommandValue = function(command) {
  291. var state = null;
  292. if (command == goog.editor.plugins.UndoRedo.COMMAND.UNDO) {
  293. state = this.undoManager_.hasUndoState();
  294. } else if (command == goog.editor.plugins.UndoRedo.COMMAND.REDO) {
  295. state = this.undoManager_.hasRedoState();
  296. }
  297. return state;
  298. };
  299. /**
  300. * Dispatches the COMMAND_VALUE_CHANGE event on the editable field or the field
  301. * manager, as appropriate.
  302. * Note: Really, people using multi field mode should be listening directly
  303. * to the undo-redo manager for events.
  304. * @private
  305. */
  306. goog.editor.plugins.UndoRedo.prototype.dispatchCommandValueChange_ =
  307. function() {
  308. var eventTarget = this.getCurrentEventTarget();
  309. eventTarget.dispatchEvent({
  310. type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE,
  311. commands: [
  312. goog.editor.plugins.UndoRedo.COMMAND.REDO,
  313. goog.editor.plugins.UndoRedo.COMMAND.UNDO
  314. ]
  315. });
  316. };
  317. /**
  318. * Restores the state of the editable field.
  319. * @param {goog.editor.plugins.UndoRedo.UndoState_} state The state initiating
  320. * the restore.
  321. * @param {string} content The content to restore.
  322. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition
  323. * The cursor position within the content.
  324. */
  325. goog.editor.plugins.UndoRedo.prototype.restoreState = function(
  326. state, content, cursorPosition) {
  327. // Fire any pending changes to get the current field state up to date and
  328. // then stop listening to changes while doing the undo/redo.
  329. var fieldObj = this.getFieldObjectForHash(state.fieldHashCode);
  330. if (!fieldObj) {
  331. return;
  332. }
  333. // Fires any pending changes, and stops the change events. Still want to
  334. // dispatch before change, as a change is being made and the change event
  335. // will be manually dispatched below after the new content has been restored
  336. // (also restarting change events).
  337. fieldObj.stopChangeEvents(true, true);
  338. // To prevent the situation where we stop change events and then an exception
  339. // happens before we can restart change events, the following code must be in
  340. // a try-finally block.
  341. try {
  342. fieldObj.dispatchBeforeChange();
  343. // Restore the state
  344. fieldObj.execCommand(goog.editor.Command.CLEAR_LOREM, true);
  345. // We specifically set the raw innerHTML of the field here as that's what
  346. // we get from the field when we save an undo/redo state. There's
  347. // no need to clean/unclean the contents in either direction.
  348. goog.editor.node.replaceInnerHtml(fieldObj.getElement(), content);
  349. if (cursorPosition) {
  350. cursorPosition.select();
  351. }
  352. var previousFieldObject = this.getCurrentFieldObject();
  353. fieldObj.focus();
  354. // Apps that integrate their undo-redo with Trogedit may be
  355. // in a state where there is no previous field object (no field focused at
  356. // the time of undo), so check for existence first.
  357. if (previousFieldObject &&
  358. previousFieldObject.getHashCode() != state.fieldHashCode) {
  359. previousFieldObject.execCommand(goog.editor.Command.UPDATE_LOREM);
  360. }
  361. // We need to update currentState_ to reflect the change.
  362. this.currentStates_[state.fieldHashCode].setUndoState(
  363. content, cursorPosition);
  364. } catch (e) {
  365. goog.log.error(this.logger, 'Error while restoring undo state', e);
  366. } finally {
  367. // Clear the delayed change event, set flag so we know not to act on it.
  368. this.inProgressUndo_ = state;
  369. // Notify the editor that we've changed (fire autosave).
  370. // Note that this starts up change events again, so we don't have to
  371. // manually do so even though we stopped change events above.
  372. fieldObj.dispatchChange();
  373. fieldObj.dispatchSelectionChangeEvent();
  374. }
  375. };
  376. /**
  377. * @override
  378. */
  379. goog.editor.plugins.UndoRedo.prototype.handleKeyboardShortcut = function(
  380. e, key, isModifierPressed) {
  381. if (isModifierPressed) {
  382. var command;
  383. if (key == 'z') {
  384. command = e.shiftKey ? goog.editor.plugins.UndoRedo.COMMAND.REDO :
  385. goog.editor.plugins.UndoRedo.COMMAND.UNDO;
  386. } else if (key == 'y') {
  387. command = goog.editor.plugins.UndoRedo.COMMAND.REDO;
  388. }
  389. if (command) {
  390. // In the case where Trogedit shares its undo redo stack with another
  391. // application it's possible that an undo or redo will not be for an
  392. // goog.editor.Field. In this case we don't want to go through the
  393. // goog.editor.Field execCommand flow which stops and restarts events on
  394. // the current field. Only Trogedit UndoState's have a fieldHashCode so
  395. // use that to distinguish between Trogedit and other states.
  396. var state = command == goog.editor.plugins.UndoRedo.COMMAND.UNDO ?
  397. this.undoManager_.undoPeek() :
  398. this.undoManager_.redoPeek();
  399. if (state && state.fieldHashCode) {
  400. this.getCurrentFieldObject().execCommand(command);
  401. } else {
  402. this.execCommand(command);
  403. }
  404. return true;
  405. }
  406. }
  407. return false;
  408. };
  409. /**
  410. * Clear the undo/redo stack.
  411. */
  412. goog.editor.plugins.UndoRedo.prototype.clearHistory = function() {
  413. // Fire all pending change events, so that they don't come back
  414. // asynchronously to fill the queue.
  415. this.getFieldObject().stopChangeEvents(true, true);
  416. this.undoManager_.clearHistory();
  417. this.getFieldObject().startChangeEvents();
  418. };
  419. /**
  420. * Refreshes the current state of the editable field as maintained by undo-redo,
  421. * without adding any undo-redo states to the stack.
  422. * @param {goog.editor.Field} fieldObject The editable field.
  423. */
  424. goog.editor.plugins.UndoRedo.prototype.refreshCurrentState = function(
  425. fieldObject) {
  426. if (this.isEnabled(fieldObject)) {
  427. if (this.currentStates_[fieldObject.getHashCode()]) {
  428. delete this.currentStates_[fieldObject.getHashCode()];
  429. }
  430. this.updateCurrentState_(fieldObject);
  431. }
  432. };
  433. /**
  434. * Before the field changes, we want to save the state.
  435. * @param {goog.events.Event} e The event.
  436. * @private
  437. */
  438. goog.editor.plugins.UndoRedo.prototype.handleBeforeChange_ = function(e) {
  439. if (this.inProgressUndo_) {
  440. // We are in between a previous undo and its delayed change event.
  441. // Continuing here clobbers the redo stack.
  442. // This does mean that if you are trying to undo/redo really quickly, it
  443. // will be gated by the speed of delayed change events.
  444. return;
  445. }
  446. var fieldObj = /** @type {goog.editor.Field} */ (e.target);
  447. var fieldHashCode = fieldObj.getHashCode();
  448. if (this.initialFieldChange_ != fieldHashCode) {
  449. this.initialFieldChange_ = fieldHashCode;
  450. this.updateCurrentState_(fieldObj);
  451. }
  452. };
  453. /**
  454. * After some idle time, we want to save the state.
  455. * @param {goog.events.Event} e The event.
  456. * @private
  457. */
  458. goog.editor.plugins.UndoRedo.prototype.handleDelayedChange_ = function(e) {
  459. // This was undo making a change, don't add it BACK into the history
  460. if (this.inProgressUndo_) {
  461. // Must clear this.inProgressUndo_ before dispatching event because the
  462. // dispatch can cause another, queued undo that should be allowed to go
  463. // through.
  464. var state = this.inProgressUndo_;
  465. this.inProgressUndo_ = null;
  466. state.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
  467. return;
  468. }
  469. this.updateCurrentState_(/** @type {goog.editor.Field} */ (e.target));
  470. };
  471. /**
  472. * When the user blurs away, we need to save the state on that field.
  473. * @param {goog.events.Event} e The event.
  474. * @private
  475. */
  476. goog.editor.plugins.UndoRedo.prototype.handleBlur_ = function(e) {
  477. var fieldObj = /** @type {goog.editor.Field} */ (e.target);
  478. if (fieldObj) {
  479. fieldObj.clearDelayedChange();
  480. }
  481. };
  482. /**
  483. * Returns the goog.editor.plugins.UndoRedo.CursorPosition_ for the current
  484. * selection in the given Field.
  485. * @param {goog.editor.Field} fieldObj The field object.
  486. * @return {goog.editor.plugins.UndoRedo.CursorPosition_} The CursorPosition_ or
  487. * null if there is no valid selection.
  488. * @private
  489. */
  490. goog.editor.plugins.UndoRedo.prototype.getCursorPosition_ = function(fieldObj) {
  491. var cursorPos = new goog.editor.plugins.UndoRedo.CursorPosition_(fieldObj);
  492. if (!cursorPos.isValid()) {
  493. return null;
  494. }
  495. return cursorPos;
  496. };
  497. /**
  498. * Helper method for saving state.
  499. * @param {goog.editor.Field} fieldObj The field object.
  500. * @private
  501. */
  502. goog.editor.plugins.UndoRedo.prototype.updateCurrentState_ = function(
  503. fieldObj) {
  504. var fieldHashCode = fieldObj.getHashCode();
  505. // We specifically grab the raw innerHTML of the field here as that's what
  506. // we would set on the field in the case of an undo/redo operation. There's
  507. // no need to clean/unclean the contents in either direction. In the case of
  508. // lorem ipsum being used, we want to capture the effective state (empty, no
  509. // cursor position) rather than capturing the lorem html.
  510. var content, cursorPos;
  511. if (fieldObj.queryCommandValue(goog.editor.Command.USING_LOREM)) {
  512. content = '';
  513. cursorPos = null;
  514. } else {
  515. content = fieldObj.getElement().innerHTML;
  516. cursorPos = this.getCursorPosition_(fieldObj);
  517. }
  518. var currentState = this.currentStates_[fieldHashCode];
  519. if (currentState) {
  520. // Don't create states if the content hasn't changed (spurious
  521. // delayed change). This can happen when lorem is cleared, for example.
  522. if (currentState.undoContent_ == content) {
  523. return;
  524. } else if (content == '' || currentState.undoContent_ == '') {
  525. // If lorem ipsum is on we say the contents are the empty string. However,
  526. // for an empty text shape with focus, the empty contents might not be
  527. // the same, depending on plugins. We want these two empty states to be
  528. // considered identical because to the user they are indistinguishable,
  529. // so we use fieldObj.getInjectableContents to map between them.
  530. // We cannot use getInjectableContents when first creating the undo
  531. // content for a field with lorem, because on enable when this is first
  532. // called we can't guarantee plugin registration order, so the
  533. // injectableContents at that time might not match the final
  534. // injectableContents.
  535. var emptyContents = fieldObj.getInjectableContents('', {});
  536. if (content == emptyContents && currentState.undoContent_ == '' ||
  537. currentState.undoContent_ == emptyContents && content == '') {
  538. return;
  539. }
  540. }
  541. currentState.setRedoState(content, cursorPos);
  542. this.undoManager_.addState(currentState);
  543. }
  544. this.currentStates_[fieldHashCode] =
  545. new goog.editor.plugins.UndoRedo.UndoState_(
  546. fieldHashCode, content, cursorPos, this.boundRestoreState_);
  547. };
  548. /**
  549. * This object encapsulates the state of an editable field.
  550. *
  551. * @param {string} fieldHashCode String the id of the field we're saving the
  552. * content of.
  553. * @param {string} content String the actual text we're saving.
  554. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition
  555. * CursorPosLite object for the cursor position in the field.
  556. * @param {Function} restore The function used to restore editable field state.
  557. * @private
  558. * @constructor
  559. * @extends {goog.editor.plugins.UndoRedoState}
  560. */
  561. goog.editor.plugins.UndoRedo.UndoState_ = function(
  562. fieldHashCode, content, cursorPosition, restore) {
  563. goog.editor.plugins.UndoRedoState.call(this, true);
  564. /**
  565. * The hash code for the field whose content is being saved.
  566. * @type {string}
  567. */
  568. this.fieldHashCode = fieldHashCode;
  569. /**
  570. * The bound copy of {@code goog.editor.plugins.UndoRedo.restoreState} used by
  571. * this state.
  572. * @type {Function}
  573. * @private
  574. */
  575. this.restore_ = restore;
  576. this.setUndoState(content, cursorPosition);
  577. };
  578. goog.inherits(
  579. goog.editor.plugins.UndoRedo.UndoState_, goog.editor.plugins.UndoRedoState);
  580. /**
  581. * The content to restore on undo.
  582. * @type {string}
  583. * @private
  584. */
  585. goog.editor.plugins.UndoRedo.UndoState_.prototype.undoContent_;
  586. /**
  587. * The cursor position to restore on undo.
  588. * @type {goog.editor.plugins.UndoRedo.CursorPosition_?}
  589. * @private
  590. */
  591. goog.editor.plugins.UndoRedo.UndoState_.prototype.undoCursorPosition_;
  592. /**
  593. * The content to restore on redo, undefined until the state is pushed onto the
  594. * undo stack.
  595. * @type {string|undefined}
  596. * @private
  597. */
  598. goog.editor.plugins.UndoRedo.UndoState_.prototype.redoContent_;
  599. /**
  600. * The cursor position to restore on redo, undefined until the state is pushed
  601. * onto the undo stack.
  602. * @type {goog.editor.plugins.UndoRedo.CursorPosition_|null|undefined}
  603. * @private
  604. */
  605. goog.editor.plugins.UndoRedo.UndoState_.prototype.redoCursorPosition_;
  606. /**
  607. * Performs the undo operation represented by this state.
  608. * @override
  609. */
  610. goog.editor.plugins.UndoRedo.UndoState_.prototype.undo = function() {
  611. this.restore_(this, this.undoContent_, this.undoCursorPosition_);
  612. };
  613. /**
  614. * Performs the redo operation represented by this state.
  615. * @override
  616. */
  617. goog.editor.plugins.UndoRedo.UndoState_.prototype.redo = function() {
  618. this.restore_(this, this.redoContent_, this.redoCursorPosition_);
  619. };
  620. /**
  621. * Updates the undo portion of this state. Should only be used to update the
  622. * current state of an editable field, which is not yet on the undo stack after
  623. * an undo or redo operation. You should never be modifying states on the stack!
  624. * @param {string} content The current content.
  625. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition
  626. * The current cursor position.
  627. */
  628. goog.editor.plugins.UndoRedo.UndoState_.prototype.setUndoState = function(
  629. content, cursorPosition) {
  630. this.undoContent_ = content;
  631. this.undoCursorPosition_ = cursorPosition;
  632. };
  633. /**
  634. * Adds redo information to this state. This method should be called before the
  635. * state is added onto the undo stack.
  636. *
  637. * @param {string} content The content to restore on a redo.
  638. * @param {goog.editor.plugins.UndoRedo.CursorPosition_?} cursorPosition
  639. * The cursor position to restore on a redo.
  640. */
  641. goog.editor.plugins.UndoRedo.UndoState_.prototype.setRedoState = function(
  642. content, cursorPosition) {
  643. this.redoContent_ = content;
  644. this.redoCursorPosition_ = cursorPosition;
  645. };
  646. /**
  647. * Checks if the *contents* of two
  648. * {@code goog.editor.plugins.UndoRedo.UndoState_}s are the same. We don't
  649. * bother checking the cursor position (that's not something we'd want to save
  650. * anyway).
  651. * @param {goog.editor.plugins.UndoRedoState} rhs The state to compare.
  652. * @return {boolean} Whether the contents are the same.
  653. * @override
  654. */
  655. goog.editor.plugins.UndoRedo.UndoState_.prototype.equals = function(rhs) {
  656. return this.fieldHashCode == rhs.fieldHashCode &&
  657. this.undoContent_ == rhs.undoContent_ &&
  658. this.redoContent_ == rhs.redoContent_;
  659. };
  660. /**
  661. * Stores the state of the selection in a way the survives DOM modifications
  662. * that don't modify the user-interactable content (e.g. making something bold
  663. * vs. typing a character).
  664. *
  665. * TODO(user): Completely get rid of this and use goog.dom.SavedCaretRange.
  666. *
  667. * @param {goog.editor.Field} field The field the selection is in.
  668. * @private
  669. * @constructor
  670. */
  671. goog.editor.plugins.UndoRedo.CursorPosition_ = function(field) {
  672. this.field_ = field;
  673. var win = field.getEditableDomHelper().getWindow();
  674. var range = field.getRange();
  675. var isValidRange =
  676. !!range && range.isRangeInDocument() && range.getWindow() == win;
  677. range = isValidRange ? range : null;
  678. if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
  679. this.initW3C_(range);
  680. } else if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
  681. this.initIE_(range);
  682. }
  683. };
  684. /**
  685. * The standards compliant version keeps a list of childNode offsets.
  686. * @param {goog.dom.AbstractRange?} range The range to save.
  687. * @private
  688. */
  689. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.initW3C_ = function(
  690. range) {
  691. this.isValid_ = false;
  692. // TODO: Check if the range is in the field before trying to save it
  693. // for FF 3 contentEditable.
  694. if (!range) {
  695. return;
  696. }
  697. var anchorNode = range.getAnchorNode();
  698. var focusNode = range.getFocusNode();
  699. if (!anchorNode || !focusNode) {
  700. return;
  701. }
  702. var anchorOffset = range.getAnchorOffset();
  703. var anchor = new goog.dom.NodeOffset(anchorNode, this.field_.getElement());
  704. var focusOffset = range.getFocusOffset();
  705. var focus = new goog.dom.NodeOffset(focusNode, this.field_.getElement());
  706. // Test range direction.
  707. if (range.isReversed()) {
  708. this.startOffset_ = focus;
  709. this.startChildOffset_ = focusOffset;
  710. this.endOffset_ = anchor;
  711. this.endChildOffset_ = anchorOffset;
  712. } else {
  713. this.startOffset_ = anchor;
  714. this.startChildOffset_ = anchorOffset;
  715. this.endOffset_ = focus;
  716. this.endChildOffset_ = focusOffset;
  717. }
  718. this.isValid_ = true;
  719. };
  720. /**
  721. * In IE, we just keep track of the text offset (number of characters).
  722. * @param {goog.dom.AbstractRange?} range The range to save.
  723. * @private
  724. */
  725. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.initIE_ = function(
  726. range) {
  727. this.isValid_ = false;
  728. if (!range) {
  729. return;
  730. }
  731. var ieRange = range.getTextRange(0).getBrowserRangeObject();
  732. if (!goog.dom.contains(this.field_.getElement(), ieRange.parentElement())) {
  733. return;
  734. }
  735. // Create a range that encompasses the contentEditable region to serve
  736. // as a reference to form ranges below.
  737. var contentEditableRange =
  738. this.field_.getEditableDomHelper().getDocument().body.createTextRange();
  739. contentEditableRange.moveToElementText(this.field_.getElement());
  740. // startMarker is a range from the start of the contentEditable node to the
  741. // start of the current selection.
  742. var startMarker = ieRange.duplicate();
  743. startMarker.collapse(true);
  744. startMarker.setEndPoint('StartToStart', contentEditableRange);
  745. this.startOffset_ =
  746. goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_(
  747. startMarker);
  748. // endMarker is a range from the start of the contentEditable node to the
  749. // end of the current selection.
  750. var endMarker = ieRange.duplicate();
  751. endMarker.setEndPoint('StartToStart', contentEditableRange);
  752. this.endOffset_ =
  753. goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_(
  754. endMarker);
  755. this.isValid_ = true;
  756. };
  757. /**
  758. * @return {boolean} Whether this object is valid.
  759. */
  760. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.isValid = function() {
  761. return this.isValid_;
  762. };
  763. /**
  764. * @return {string} A string representation of this object.
  765. * @override
  766. */
  767. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.toString = function() {
  768. if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
  769. return 'W3C:' + this.startOffset_.toString() + '\n' +
  770. this.startChildOffset_ + ':' + this.endOffset_.toString() + '\n' +
  771. this.endChildOffset_;
  772. }
  773. return 'IE:' + this.startOffset_ + ',' + this.endOffset_;
  774. };
  775. /**
  776. * Makes the browser's selection match the cursor position.
  777. */
  778. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.select = function() {
  779. var range = this.getRange_(this.field_.getElement());
  780. if (range) {
  781. if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
  782. this.field_.getElement().focus();
  783. }
  784. goog.dom.Range.createFromBrowserRange(range).select();
  785. }
  786. };
  787. /**
  788. * Get the range that encompases the the cursor position relative to a given
  789. * base node.
  790. * @param {Element} baseNode The node to get the cursor position relative to.
  791. * @return {Range|TextRange|null} The browser range for this position.
  792. * @private
  793. */
  794. goog.editor.plugins.UndoRedo.CursorPosition_.prototype.getRange_ = function(
  795. baseNode) {
  796. if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
  797. var startNode = this.startOffset_.findTargetNode(baseNode);
  798. var endNode = this.endOffset_.findTargetNode(baseNode);
  799. if (!startNode || !endNode) {
  800. return null;
  801. }
  802. // Create range.
  803. return /** @type {Range} */ (
  804. goog.dom.Range
  805. .createFromNodes(
  806. startNode, this.startChildOffset_, endNode,
  807. this.endChildOffset_)
  808. .getBrowserRangeObject());
  809. }
  810. // Create a collapsed selection at the start of the contentEditable region,
  811. // which the offsets were calculated relative to before. Note that we force
  812. // a text range here so we can use moveToElementText.
  813. var sel = baseNode.ownerDocument.body.createTextRange();
  814. sel.moveToElementText(baseNode);
  815. sel.collapse(true);
  816. sel.moveEnd('character', this.endOffset_);
  817. sel.moveStart('character', this.startOffset_);
  818. return sel;
  819. };
  820. /**
  821. * Compute the number of characters to the end of the range in IE.
  822. * @param {TextRange} range The range to compute an offset for.
  823. * @return {number} The number of characters to the end of the range.
  824. * @private
  825. */
  826. goog.editor.plugins.UndoRedo.CursorPosition_.computeEndOffsetIE_ = function(
  827. range) {
  828. var testRange = range.duplicate();
  829. // The number of offset characters is a little off depending on
  830. // what type of block elements happen to be between the start of the
  831. // textedit and the cursor position. We fudge the offset until the
  832. // two ranges match.
  833. var text = range.text;
  834. var guess = text.length;
  835. testRange.collapse(true);
  836. testRange.moveEnd('character', guess);
  837. // Adjust the range until the end points match. This doesn't quite
  838. // work if we're at the end of the field so we give up after a few
  839. // iterations.
  840. var diff;
  841. var numTries = 10;
  842. while (diff = testRange.compareEndPoints('EndToEnd', range)) {
  843. guess -= diff;
  844. testRange.moveEnd('character', -diff);
  845. --numTries;
  846. if (0 == numTries) {
  847. break;
  848. }
  849. }
  850. // When we set innerHTML, blank lines become a single space, causing
  851. // the cursor position to be off by one. So we accommodate for blank
  852. // lines.
  853. var offset = 0;
  854. var pos = text.indexOf('\n\r');
  855. while (pos != -1) {
  856. ++offset;
  857. pos = text.indexOf('\n\r', pos + 1);
  858. }
  859. return guess + offset;
  860. };