///
import MutationObserver = require('mutation-observer');
import currentRange = require('current-range');
import currentSelection = require('current-selection');
import Editor = require('../editor/index');
import Operation = require('./operation');
import OperationStack = require('./operation-stack');
import UnknownOperation = require('./unknown-operation');
import TransactionManagerCommand = require('./command');
import FrozenRange = require('frozen-range');
import events = require('events');
function freezeRange(doc: Node): FrozenRange {
var range = currentRange(document);
if (!range) {
range = document.createRange();
range.selectNodeContents(doc.firstChild || doc);
range.collapse(true);
};
return new FrozenRange(range, doc);
}
class TransactionManager extends events.EventEmitter {
private inTransaction: boolean;
private doc: HTMLElement;
private opstack: OperationStack;
private lkg: HTMLElement; // last known good copy of the document
private lkgRange: FrozenRange;
private observer: MutationObserver;
public undoCommand: TransactionManagerCommand;
public redoCommand: TransactionManagerCommand;
/**
* Creates a TransactionManager instance, that automatically
* detects changes to the document, and adds operations
* to its stack.
*/
constructor(doc: HTMLElement) {
if (!(this instanceof TransactionManager)) return new TransactionManager(doc);
super();
this.doc = doc;
this.opstack = new OperationStack(doc);
this.lkg = doc.cloneNode(true);
this.lkgRange = freezeRange(doc);
this.inTransaction = false;
this.undoCommand = new TransactionManagerCommand(this, doc, -1);
this.redoCommand = new TransactionManagerCommand(this, doc, +1);
this.observer = new MutationObserver(this.callback.bind(this));
document.addEventListener('selectionchange', this.selectionCallback.bind(this), false);
this.start();
}
/**
* Starts the mutation observer
*/
private start(): void {
this.observer.observe(this.doc, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
}
/**
* Stops the mutation observer
*/
private stop(): void {
var records = this.observer.takeRecords();
if (records.length > 0) {
this.callback(records);
}
this.observer.disconnect();
}
/**
* Fired whenever mutations are observed in the document
*/
private callback(mutations: MutationRecord[]) {
if (!this.changed()) {
return; // nothing changed, no need to do anything
}
var current = this.doc.cloneNode(true);
var currRange = freezeRange(this.doc);
var op = new UnknownOperation(this.lkg, this.lkgRange, current, currRange);
this.lkg = current;
this.lkgRange = currRange;
this.opstack.push(op);
this.emit('contentchange');
}
/**
* Fired whenever the selection changes
*/
private selectionCallback() {
if (this.changed()) {
// Document has changed. Bail without storing the LKG range, since we
// need it to match the LKG state of the document, not the current state.
// The callback() function will take care of updating LKG range later.
return;
}
this.lkgRange = freezeRange(this.doc);
}
/**
* Undoes the last operation. Returns true on success.
*/
public undo(updateRange: boolean = true): FrozenRange {
this.stop();
try {
var result = this.opstack.undo();
if (updateRange) {
var sel = currentSelection(this.doc);
sel.removeAllRanges();
sel.addRange(result.thaw(this.doc));
}
this.lkg = this.doc.cloneNode(true);
this.lkgRange = freezeRange(this.doc);
this.emit('undo');
this.emit('contentchange');
return result;
} catch (e) {
this.rollback();
throw e;
} finally {
this.start();
}
}
/**
* Redoes the last operation. Returns true on success.
*/
public redo(updateRange: boolean = true): FrozenRange {
this.stop();
try {
var result = this.opstack.redo();
if (updateRange) {
var sel = currentSelection(this.doc);
sel.removeAllRanges();
sel.addRange(result.thaw(this.doc));
}
this.lkg = this.doc.cloneNode(true);
this.lkgRange = freezeRange(this.doc);
this.emit('undo');
this.emit('contentchange');
return result;
} catch (e) {
this.rollback();
throw e;
} finally {
this.start();
}
}
/**
* Checks whether an undo operation can be performed
*/
public canUndo(): boolean {
return this.opstack.canUndo();
}
/**
* Checks whether a redo operation can be performed
*/
public canRedo(): boolean {
return this.opstack.canRedo();
}
/**
* Checks whether the content has changed since Last Known Good
*/
private changed(): boolean {
var docNodes = this.doc.childNodes;
var lkgNodes = this.lkg.childNodes;
var length = docNodes.length;
if (lkgNodes.length != length) {
return true;
}
for (var i = 0; i < length; i++) {
if (!docNodes[i].isEqualNode(lkgNodes[i])) return true;
}
return false;
}
/**
* Runs the given function as a transaction.
*
* Each transaction will result in an individual entry on the
* operation stack for undo/redo purposes.
*
* If the given function throws an exception, a rollback
* is performed, and the previous state of the editor
* is restored. The exception is rethrown for further handling.
*
* If the function returns an Operation instance,
* that instance is added to the operation stack. Otherwise,
* an operation is automatically created. Care must be taken
* when returning an Operation to make sure it matches exactly
* the transaction performed.
*/
public run(fn: () => void);
public run(fn: () => Operation) {
return this._run(fn, false);
}
/**
* Similar to run(), but also squashes history. Useful
* for normalization operations.
*/
public runAndSquash(fn: () => void);
public runAndSquash(fn: () => Operation) {
return this._run(fn, true);
}
/**
* Common implementation shared by run() and runAndSquash()
*/
private _run(fn: () => Operation, squash: boolean): void {
if (this.inTransaction) {
throw new Error('A transaction is already taking place.')
}
this.stop();
this.inTransaction = true;
try {
var op = fn();
} catch (e) {
this.rollback();
throw e;
} finally {
this.inTransaction = false;
this.start();
}
var current;
var currRange;
if (!op) {
if (!this.changed()) {
return; // nothing changed, no need to do anything
}
current = this.doc.cloneNode(true);
currRange = freezeRange(this.doc);
op = new UnknownOperation(this.lkg, this.lkgRange, current, currRange);
} else {
current = this.doc.cloneNode(true);
currRange = freezeRange(this.doc);
}
this.lkg = current;
this.lkgRange = currRange;
if (squash) {
this.opstack.squash(op);
} else {
this.opstack.push(op);
}
this.emit('contentchange');
}
/**
* Rollback editor to last known good state
*/
private rollback(): void {
var lkg = this.lkg.cloneNode(true);
this.doc.innerHTML = '';
while (lkg.firstChild) {
this.doc.appendChild(lkg.firstChild);
}
var sel = currentSelection(document);
sel.removeAllRanges();
sel.addRange(this.lkgRange.thaw(this.doc));
}
}
export = TransactionManager;