From dbe4890ed6d6c88c32d17092b333abd6f873a578 Mon Sep 17 00:00:00 2001 From: Neil Kandalgaonkar Date: Tue, 6 Dec 2011 01:52:38 +0000 Subject: [PATCH] Simplified transaction model, introduced isPartial for some deletes --- modules/es/models/es.SurfaceModel.js | 256 ++++++++++----------------- modules/es/views/es.SurfaceView.js | 32 ++-- 2 files changed, 107 insertions(+), 181 deletions(-) diff --git a/modules/es/models/es.SurfaceModel.js b/modules/es/models/es.SurfaceModel.js index 8e94fbdefd..5fe8fd8492 100644 --- a/modules/es/models/es.SurfaceModel.js +++ b/modules/es/models/es.SurfaceModel.js @@ -12,26 +12,22 @@ es.SurfaceModel = function( doc ) { // Properties this.doc = doc; - this.selection = new es.Range(); - this.states = [[]]; - this.initializeState( this.states.length - 1 ); + this.selection = null; + this.history = []; + this.historyIndex = 0; + this.currentLengthDifference = 0; + // TODO magic number move to configuration // Configuration - this.distanceLimit = 24; this.lengthDifferenceLimit = 24; + + // DEBUG don't commit + var _this = this; + this.addListener( 'transact', function() { console.log( _this.history ); } ); }; /* Methods */ -es.SurfaceModel.prototype.initializeState = function( stateIndex ) { - if ( this.states[stateIndex] === undefined ) { - throw 'Invalid state index error. State index our of range: ' + stateIndex; - } - this.currentStateIndex = stateIndex; - this.currentState = this.states[stateIndex]; - this.currentStateDistance = 0; - this.currentStateLengthDifference = 0; -}; /** * Gets the document model of the surface. @@ -44,10 +40,10 @@ es.SurfaceModel.prototype.getDocument = function() { }; /** - * Gets the selection for the current state. + * Gets the selection * * @method - * @returns {es.Range} Current state's selection + * @returns {es.Range} Current selection */ es.SurfaceModel.prototype.getSelection = function() { return this.selection; @@ -57,34 +53,37 @@ es.SurfaceModel.prototype.getSelection = function() { * Changes the selection. * * If changing the selection at a high frequency (such as while dragging) use the combine argument - * to avoid them being split up into multiple states. + * to avoid them being split up into multiple history items * * @method * @param {es.Range} selection - * @param {Boolean} combine Whether to prevent this transaction from causing a state push + * @param {Boolean} combine Whether to prevent this transaction from causing a history push */ -es.SurfaceModel.prototype.select = function( selection, combine ) { +es.SurfaceModel.prototype.select = function( selection, isManual ) { selection.normalize(); - if ( !combine && this.shouldPushState( selection ) ) { - this.pushState(); + + this.selection = selection; + + if ( isManual ) { + // check if the last thing is a selection, if so, swap it. + this.pushSelection( selection ); } - // Filter out calls to select if they do not change the selection values - var selectionChanged = !this.selection || ( - this.selection.from !== selection.from || - this.selection.to !== selection.to - ); - if ( selectionChanged ) { - var lastAction = this.states[this.states.length - 1]; - if ( lastAction instanceof es.Range ) { - this.currentStateDistance += Math.abs( - selection.from - this.states[this.states.length - 1].from - ); - } - this.currentState.push( selection ); - this.selection = selection; - if ( selectionChanged ) { - this.emit( 'select', this.selection.clone() ); - } + + this.emit( 'select', this.selection.clone() ); +}; + +/** + * Adds a selection (which is really just a marker for when we stop undo/redo) to the history. + * For the history, selections are just markers, so we don't want to record many of them in a row. + * + * @method + * @param {es.Range} selection + */ +es.SurfaceModel.prototype.pushSelection = function( selection ) { + if ( this.history[ this.history.length - 1 ] instanceof es.Range ) { + this.history[ this.history.length - 1 ] = selection; + } else { + this.history.push( selection ); } }; @@ -92,53 +91,81 @@ es.SurfaceModel.prototype.select = function( selection, combine ) { * Applies a series of transactions to the content data. * * If committing multiple transactions which are the result of a single user action and need to be - * part of a single state, use the combine argument for all but the last one to avoid them being - * split up into multple states. + * part of a single history item, use the isPartial argument for all but the last one to avoid them being + * split up into multple history items. * * @method * @param {es.TransactionModel} transactions Tranasction to apply to the document - * @param {Boolean} combine Whether to prevent this transaction from causing a state push + * @param {boolean} isPartial whether this transaction is part of a larger logical grouping of transactions + * (such as when replacing - delete, then insert) */ -es.SurfaceModel.prototype.transact = function( transaction, combine ) { - if ( !combine && this.shouldPushState( transaction ) ) { - this.pushState(); - } - this.currentStateLengthDifference += transaction.getLengthDifference(); +es.SurfaceModel.prototype.transact = function( transaction, isPartial ) { + console.log( 'tx:' + $.map( transaction.getOperations(), function(tx) { return tx.type; } ).join(",") + + ' isPartial:' + isPartial ); this.doc.commit( transaction ); - this.currentState.push( transaction ); + + // if we have changed the kind of operation (delete -> insert or insert -> delete or annotations ) + // then push a new selection onto the history, to mark where the undo/redo should end. + var d = transaction.getLengthDifference(); + if ( + !isPartial && + ( + ( d === 0 ) || + ( this.currentLengthDifference < 0 && d > 0 ) || + ( this.currentLengthDifference > 0 && d < 0 ) || + ( Math.abs( this.currentLengthDifference ) > this.lengthDifferenceLimit ) + ) + ) { + this.currentLengthDifference = d; + this.history.push( this.selection ); + } else { + this.currentLengthDifference += d; + } + + this.history.push( transaction ); + this.emit( 'transact', transaction ); }; + /** - * Reverses one or more states. - * We assume the user wants to undo some visible change to the document, so we only count - * states that contain at least one transaction that changed the document somehow. + * Reverses one or more history items. * * @method - * @param {Integer} Number of document-changing states to reverse + * @param {Integer} Number of history items to roll back */ -es.SurfaceModel.prototype.undo = function( transactionsToUndo ) { +es.SurfaceModel.prototype.undo = function( statesToUndo ) { + + console.log( 'about to undo...' ); + console.log( this.states ); + console.log( 'currentState: ' + this.currentState ); + console.log( 'currentStateIndex: ' + this.currentStateIndex ); - while ( transactionsToUndo ) { - var hadTransaction = false; + lengthDifference = 0; - var state = this.currentState; + while ( statesToUndo ) { + statesToUndo--; - var i = state.length - 1; - while ( i-- ) { - if ( state[i] instanceof es.TransactionModel ) { - hadTransaction = true; - this.doc.rollback( state[i] ); + if ( this.currentState.length ) { + for (var i = this.currentState.length - 1; i >= 0; i-- ) { + lengthDifference += this.currentState[i].getLengthDifference(); + this.doc.rollback( this.currentState[i] ); } + this.emit( 'undo', this.currentState ); } - this.emit( 'undo', state ); - if ( hadTransaction ) { - transactionsToUndo--; - } // do we also want all the effects of initializeState? currentStateDistance to be 0, currentStateLengthDifference? - this.initializeState( this.currentStateIndex - 1 ); + if ( this.currentStateIndex > 0 ) { + this.initializeState( this.currentStateIndex - 1 ); + } } + + console.log( 'after undo...' ); + console.log( this.states ); + console.log( 'currentState: ' + this.currentState ); + console.log( 'currentStateIndex: ' + this.currentStateIndex ); + + // TODO - make the appropriate selection now }; /** @@ -152,107 +179,6 @@ es.SurfaceModel.prototype.redo = function( steps ) { this.emit( 'redo'/*, transaction/selection*/ ); }; -/** - * Checks if it's an appropriate time to push the state. - * - * @method - * @returns {Boolean} Whether the state should be pushed - */ -es.SurfaceModel.prototype.shouldPushState = function( nextAction ) { - // Never push a new state if the current one is empty - if ( !this.currentState.length ) { - return false; - } - var lastAction = this.currentState[this.currentState.length - 1], - nextDirection, - lastDirection; - if ( - // Check that types match - nextAction instanceof es.Range && lastAction instanceof es.Range - ) { - if ( - // 2 or more select actions in a row are required to detect a direction - this.states.length >= 2 && this.states[this.states.length - 2] instanceof es.Range - ) { - // Check we haven't changed directions - lastDirection = this.states[this.states.length - 2].from - lastAction.from; - nextDirection = lastAction.from - nextAction.from; - if ( - // Both movements are in the same direction - ( lastDirection < 0 && nextDirection < 0 ) || - ( lastDirection > 0 && nextDirection > 0 ) - ) { - // Check we are still within the distance threshold - if ( - Math.abs( nextAction.from - lastAction.from ) + this.currentStateDistance < - this.distanceLimit - ) { - return false; - } - } - } - } else if ( - // Check that types match - nextAction instanceof es.TransactionModel && lastAction instanceof es.TransactionModel - ) { - // Check if we've changed directions (insert vs remove) - lastLengthDifference = lastAction.getLengthDifference(); - nextLengthDifference = nextAction.getLengthDifference(); - if ( - // Both movements are in the same direction - ( lastLengthDifference < 0 && nextLengthDifference < 0 ) || - ( lastLengthDifference > 0 && nextLengthDifference > 0 ) - ) { - // Check we are still within the length difference threshold - if ( - nextLengthDifference + this.currentStateLengthDifference < - this.lengthDifferenceLimit - ) { - return false; - } - } - } - return true; -}; - -/** - * Removes any undone states and pushes a new state to the stack. - * - * @method - */ -es.SurfaceModel.prototype.pushState = function() { - // Automatically drop undone states - we are now moving in a new direction - if ( this.states[this.states.length - 1] !== this.currentState ) { - for ( var i = this.states.length - 1; i > this.currentStateIndex; i-- ) { - this.emit( 'popState', this.states.pop() ); - } - } - // Push a new state to the stack - this.optimizeState( this.states.length - 1 ); - this.states.push( [] ); - this.initializeState( this.states.length - 1 ); - this.emit( 'pushState' ); -}; - -/** - * Remove irrelevant selection actions from a given state - * TODO: replace this with code to remove irrelevant selections as they are pushed - * (for instance, between two insertions). - * @param {Integer} - */ -es.SurfaceModel.prototype.optimizeState = function( stateIndex ) { - var skipSelects = false, - newState = []; - for ( var i = this.states[stateIndex].length - 1; i >= 0; i-- ) { - var action = this.states[stateIndex][i]; - if ( !( action instanceof es.Range && skipSelects ) ) { - newState.push( action ); - skipSelects = true; - } - } - this.states[stateIndex] = newState; -}; - /* Inheritance */ es.extendClass( es.SurfaceModel, es.EventEmitter ); diff --git a/modules/es/views/es.SurfaceView.js b/modules/es/views/es.SurfaceView.js index 77317ff91d..9ec8c050ab 100644 --- a/modules/es/views/es.SurfaceView.js +++ b/modules/es/views/es.SurfaceView.js @@ -321,7 +321,7 @@ es.SurfaceView.prototype.onMouseDown = function( e ) { // Reset the initial left position this.cursor.initialLeft = null; // Apply new selection - this.model.select( selection ); + this.model.select( selection, true ); } // If the inut isn't already focused, focus it and select it's contents if ( !this.$input.is( ':focus' ) ) { @@ -381,7 +381,7 @@ es.SurfaceView.prototype.onMouseMove = function( e ) { es.SurfaceView.prototype.onMouseUp = function( e ) { if ( e.which === 1 ) { // left mouse button this.mouse.selectingMode = this.mouse.selectedRange = null; - this.model.select( this.currentSelection ); + this.model.select( this.currentSelection, true ); // We have to manually call this because the selection will not have changed between the // most recent mousemove and this mouseup this.contextView.set(); @@ -544,7 +544,7 @@ es.SurfaceView.prototype.onKeyUp = function( e ) { return true; }; -es.SurfaceView.prototype.handleDelete = function( backspace ) { +es.SurfaceView.prototype.handleDelete = function( backspace, isPartial ) { var selection = this.currentSelection.clone(), sourceOffset, targetOffset, @@ -575,7 +575,7 @@ es.SurfaceView.prototype.handleDelete = function( backspace ) { } selection.from = selection.to = targetOffset; - this.model.select( selection, true ); + this.model.select( selection ); if ( sourceNode === targetNode || ( typeof sourceSplitableNode !== 'undefined' && @@ -583,12 +583,12 @@ es.SurfaceView.prototype.handleDelete = function( backspace ) { tx = this.model.getDocument().prepareRemoval( new es.Range( targetOffset, sourceOffset ) ); - this.model.transact( tx, true ); + this.model.transact( tx, isPartial ); } else { tx = this.model.getDocument().prepareInsertion( targetOffset, sourceNode.model.getContentData() ); - this.model.transact( tx, true ); + this.model.transact( tx, isPartial ); var nodeToDelete = sourceNode; es.DocumentNode.traverseUpstream( nodeToDelete, function( node ) { @@ -603,14 +603,14 @@ es.SurfaceView.prototype.handleDelete = function( backspace ) { range.from = this.documentView.getOffsetFromNode( nodeToDelete, false ); range.to = range.from + nodeToDelete.getElementLength(); tx = this.model.getDocument().prepareRemoval( range ); - this.model.transact( tx, true ); + this.model.transact( tx, isPartial ); } } else { // selection removal tx = this.model.getDocument().prepareRemoval( selection ); - this.model.transact( tx, true ); + this.model.transact( tx, isPartial ); selection.from = selection.to = selection.start; - this.model.select( selection, true ); + this.model.select( selection ); } }; @@ -618,7 +618,7 @@ es.SurfaceView.prototype.handleEnter = function() { var selection = this.currentSelection.clone(), tx; if ( selection.from !== selection.to ) { - this.handleDelete(); + this.handleDelete( false, true ); } var node = this.documentView.getNodeFromOffset( selection.to, false ), nodeOffset = this.documentView.getOffsetFromNode( node, false ); @@ -631,7 +631,7 @@ es.SurfaceView.prototype.handleEnter = function() { nodeOffset + node.getElementLength(), [ { 'type': 'paragraph' }, { 'type': '/paragraph' } ] ); - this.model.transact( tx, true ); + this.model.transact( tx ); selection.from = selection.to = nodeOffset + node.getElementLength() + 1; } else { var stack = [], @@ -658,11 +658,11 @@ es.SurfaceView.prototype.handleEnter = function() { return true; } ); tx = this.documentView.model.prepareInsertion( selection.to, stack ); - this.model.transact( tx, true ); + this.model.transact( tx ); selection.from = selection.to = this.model.getDocument().getRelativeContentOffset( selection.to, 1 ); } - this.model.select( selection, true ); + this.model.select( selection ); }; es.SurfaceView.prototype.insertFromInput = function() { @@ -697,12 +697,12 @@ es.SurfaceView.prototype.insertFromInput = function() { var data = val.split(''); es.DocumentModel.addAnnotationsToData( data, this.getInsertionAnnotations() ); tx = this.model.getDocument().prepareInsertion( selection.from, data ); - this.model.transact( tx, true ); + this.model.transact( tx ); // Move the selection selection.from += val.length; selection.to += val.length; - this.model.select( selection, true ); + this.model.select( selection ); } }; @@ -832,7 +832,7 @@ es.SurfaceView.prototype.moveCursor = function( direction, unit ) { } else { selection.from = selection.to = to; } - this.model.select( selection ); + this.model.select( selection, true ); }; /**