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