%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/dm/
Upload File :
Create Path :
Current File : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/dm/ve.dm.Transaction.js

/*!
 * VisualEditor DataModel Transaction class.
 *
 * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * DataModel transaction.
 *
 * @class
 * @constructor
 * @param {Object[]} [operations] The operations comprising this transaction; default []
 */
ve.dm.Transaction = function VeDmTransaction( operations ) {
	this.operations = operations || [];
	this.applied = false;
};

/* Static Methods */

/**
 * Generate a transaction that replaces data in a range.
 *
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {ve.Range} range Range of data to remove
 * @param {Array} data Data to insert
 * @param {boolean} [removeMetadata=false] Remove metadata instead of collapsing it
 * @return {ve.dm.Transaction} Transaction that replaces data
 * @throws {Error} Invalid range
 */
ve.dm.Transaction.newFromReplacement = function ( doc, range, data, removeMetadata ) {
	var endOffset,
		tx = new ve.dm.Transaction();
	endOffset = tx.pushRemoval( doc, 0, range, removeMetadata );
	endOffset = tx.pushInsertion( doc, endOffset, endOffset, data );
	tx.pushFinalRetain( doc, endOffset );
	return tx;
};

/**
 * Generate a transaction that inserts data at an offset.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {number} offset Offset to insert at
 * @param {Array} data Data to insert
 * @return {ve.dm.Transaction} Transaction that inserts data
 */
ve.dm.Transaction.newFromInsertion = function ( doc, offset, data ) {
	var tx = new ve.dm.Transaction(),
		endOffset = tx.pushInsertion( doc, 0, offset, data );
	// Retain to end of document, if needed (for completeness)
	tx.pushFinalRetain( doc, endOffset );
	return tx;
};

/**
 * Generate a transaction that removes data from a range.
 *
 * There are three possible results from a removal:
 *
 * - Remove content only
 *    - Occurs when the range starts and ends on elements of different type, depth or ancestry
 * - Remove entire elements and their content
 *    - Occurs when the range spans across an entire element
 * - Merge two elements by removing the end of one and the beginning of another
 *    - Occurs when the range starts and ends on elements of similar type, depth and ancestry
 *
 * This function uses the following logic to decide what to actually remove:
 *
 * 1. Elements are only removed if range being removed covers the entire element
 * 2. Elements can only be merged if {@link ve.dm.Node#canBeMergedWith} returns true
 * 3. Merges take place at the highest common ancestor
 *
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {ve.Range} range Range of data to remove
 * @param {boolean} [removeMetadata=false] Remove metadata instead of collapsing it
 * @return {ve.dm.Transaction} Transaction that removes data
 * @throws {Error} Invalid range
 */
ve.dm.Transaction.newFromRemoval = function ( doc, range, removeMetadata ) {
	var tx = new ve.dm.Transaction(),
		endOffset = tx.pushRemoval( doc, 0, range, removeMetadata );

	// Ensure no transaction leaves the document in a completely empty state
	if ( range.start === 0 && range.end >= doc.getInternalList().getListNode().getOuterRange().start ) {
		endOffset = tx.pushInsertion( doc, endOffset, endOffset, [
			{ type: 'paragraph' },
			{ type: '/paragraph' }
		] );
	}

	// Retain to end of document, if needed (for completeness)
	tx.pushFinalRetain( doc, endOffset );
	return tx;
};

/**
 * Build a transaction that inserts the contents of a document at a given offset.
 *
 * This is typically used to merge changes to a document slice back into the main document. If newDoc
 * is a document slice of doc, it's assumed that there were no changes to doc's internal list since
 * the slice, so any differences between internal items that doc and newDoc have in common will
 * be resolved in newDoc's favor.
 *
 * @param {ve.dm.Document} doc Main document in the state to which the transaction start applies
 * @param {number} offset Offset to insert at
 * @param {ve.dm.Document} newDoc Document to insert
 * @param {ve.Range} [newDocRange] Range from the new document to insert (defaults to entire document)
 * @return {ve.dm.Transaction} Transaction that inserts the nodes and updates the internal list
 */
ve.dm.Transaction.newFromDocumentInsertion = function ( doc, offset, newDoc, newDocRange ) {
	var i, len, listMerge, data, metadata, listData, listMetadata, linearData,
		oldEndOffset, newEndOffset, tx, insertion, spliceItemRange, spliceListNodeRange,
		listNode = doc.internalList.getListNode(),
		listNodeRange = listNode.getRange(),
		newListNode = newDoc.internalList.getListNode(),
		newListNodeRange = newListNode.getRange(),
		newListNodeOuterRange = newListNode.getOuterRange();

	if ( newDocRange ) {
		data = new ve.dm.ElementLinearData( doc.getStore(), newDoc.getData( newDocRange, true ) );
		metadata = new ve.dm.MetaLinearData( doc.getStore(), newDoc.getMetadata( newDocRange, true ) );
	} else {
		// Get the data and the metadata, but skip over the internal list
		data = new ve.dm.ElementLinearData( doc.getStore(),
			newDoc.getData( new ve.Range( 0, newListNodeOuterRange.start ), true ).concat(
				newDoc.getData( new ve.Range( newListNodeOuterRange.end, newDoc.data.getLength() ), true )
			)
		);
		metadata = new ve.dm.MetaLinearData( doc.getStore(),
			newDoc.getMetadata( new ve.Range( 0, newListNodeOuterRange.start ), true ).concat(
				newListNodeOuterRange.end < newDoc.data.getLength() ? newDoc.getMetadata(
					new ve.Range( newListNodeOuterRange.end + 1, newDoc.data.getLength() ), true
				) : []
			)
		);
		// TODO deal with metadata right before and right after the internal list
	}

	// Merge the stores
	doc.getStore().merge( newDoc.getStore() );

	listMerge = doc.internalList.merge( newDoc.internalList, newDoc.origInternalListLength || 0 );
	// Remap the indexes in the data
	data.remapInternalListIndexes( listMerge.mapping, doc.internalList );
	// Get data for the new internal list
	if ( newDoc.origDoc === doc ) {
		// newDoc is a document slice based on doc, so all the internal list items present in doc
		// when it was cloned are also in newDoc. We need to get the newDoc version of these items
		// so that changes made in newDoc are reflected.
		if ( newDoc.origInternalListLength > 0 ) {
			oldEndOffset = doc.internalList.getItemNode( newDoc.origInternalListLength - 1 ).getOuterRange().end;
			newEndOffset = newDoc.internalList.getItemNode( newDoc.origInternalListLength - 1 ).getOuterRange().end;
		} else {
			oldEndOffset = listNodeRange.start;
			newEndOffset = newListNodeRange.start;
		}
		linearData = new ve.dm.ElementLinearData(
			doc.getStore(),
			newDoc.getData( new ve.Range( newListNodeRange.start, newEndOffset ), true )
		);
		listData = linearData.data
			.concat( doc.getData( new ve.Range( oldEndOffset, listNodeRange.end ), true ) );
		listMetadata = newDoc.getMetadata( new ve.Range( newListNodeRange.start, newEndOffset ), true )
			.concat( doc.getMetadata( new ve.Range( oldEndOffset, listNodeRange.end ), true ) );
	} else {
		// newDoc is brand new, so use doc's internal list as a base
		listData = doc.getData( listNodeRange, true );
		listMetadata = doc.getMetadata( listNodeRange, true );
	}
	for ( i = 0, len = listMerge.newItemRanges.length; i < len; i++ ) {
		linearData = new ve.dm.ElementLinearData(
			doc.getStore(),
			newDoc.getData( listMerge.newItemRanges[ i ], true )
		);
		listData = listData.concat( linearData.data );
		// We don't have to worry about merging metadata at the edges, because there can't be
		// metadata between internal list items
		listMetadata = listMetadata.concat( newDoc.getMetadata( listMerge.newItemRanges[ i ], true ) );
	}

	tx = new ve.dm.Transaction();

	if ( offset <= listNodeRange.start ) {
		// offset is before listNodeRange
		// First replace the node, then the internal list

		// Fix up the node insertion
		insertion = doc.fixupInsertion( data.data, offset );
		tx.pushRetain( insertion.offset );
		tx.pushReplace( doc, insertion.offset, insertion.remove, insertion.data, metadata.data );
		tx.pushRetain( listNodeRange.start - ( insertion.offset + insertion.remove ) );
		tx.pushReplace( doc, listNodeRange.start, listNodeRange.end - listNodeRange.start,
			listData, listMetadata
		);
		tx.pushFinalRetain( doc, listNodeRange.end );
	} else if ( offset >= listNodeRange.end ) {
		// offset is after listNodeRange
		// First replace the internal list, then the node

		// Fix up the node insertion
		insertion = doc.fixupInsertion( data.data, offset );
		tx.pushRetain( listNodeRange.start );
		tx.pushReplace( doc, listNodeRange.start, listNodeRange.end - listNodeRange.start,
			listData, listMetadata
		);
		tx.pushRetain( insertion.offset - listNodeRange.end );
		tx.pushReplace( doc, insertion.offset, insertion.remove, insertion.data, metadata.data );
		tx.pushFinalRetain( doc, insertion.offset + insertion.remove );
	} else if ( offset >= listNodeRange.start && offset <= listNodeRange.end ) {
		// offset is within listNodeRange
		// Merge data into listData, then only replace the internal list
		// Find the internalItem we are inserting into
		i = 0;
		// Find item node in doc
		while (
			( spliceItemRange = doc.internalList.getItemNode( i ).getRange() ) &&
			offset > spliceItemRange.end
		) {
			i++;
		}

		if ( newDoc.origDoc === doc ) {
			// Get spliceItemRange from newDoc
			spliceItemRange = newDoc.internalList.getItemNode( i ).getRange();
			spliceListNodeRange = newListNodeRange;
		} else {
			// Get spliceItemRange from doc; the while loop has already set it
			spliceListNodeRange = listNodeRange;
		}
		ve.batchSplice( listData, spliceItemRange.start - spliceListNodeRange.start,
			spliceItemRange.end - spliceItemRange.start, data.data );
		ve.batchSplice( listMetadata, spliceItemRange.start - spliceListNodeRange.start,
			spliceItemRange.end - spliceItemRange.start, metadata.data );

		tx.pushRetain( listNodeRange.start );
		tx.pushReplace( doc, listNodeRange.start, listNodeRange.end - listNodeRange.start,
			listData, listMetadata
		);
		tx.pushFinalRetain( doc, listNodeRange.end );
	}
	return tx;
};

/**
 * Generate a transaction that changes one or more attributes.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {number} offset Offset of element
 * @param {Object.<string,Mixed>} attr List of attribute key and value pairs, use undefined value
 *  to remove an attribute
 * @return {ve.dm.Transaction} Transaction that changes an element
 * @throws {Error} Cannot set attributes to non-element data
 * @throws {Error} Cannot set attributes on closing element
 */
ve.dm.Transaction.newFromAttributeChanges = function ( doc, offset, attr ) {
	var tx = new ve.dm.Transaction(),
		data = doc.getData();
	// Verify element exists at offset
	if ( data[ offset ].type === undefined ) {
		throw new Error( 'Cannot set attributes to non-element data' );
	}
	// Verify element is not a closing
	if ( data[ offset ].type.charAt( 0 ) === '/' ) {
		throw new Error( 'Cannot set attributes on closing element' );
	}
	// Retain up to element
	tx.pushRetain( offset );
	// Change attributes
	tx.pushAttributeChanges( attr, data[ offset ].attributes || {} );
	// Retain to end of document
	tx.pushFinalRetain( doc, offset );
	return tx;
};

/**
 * Generate a transaction that annotates content.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {ve.Range} range Range to annotate
 * @param {string} method Annotation mode
 *  - `set`: Adds annotation to all content in range
 *  - `clear`: Removes instances of annotation from content in range
 * @param {ve.dm.Annotation} annotation Annotation to set or clear
 * @return {ve.dm.Transaction} Transaction that annotates content
 */
ve.dm.Transaction.newFromAnnotation = function ( doc, range, method, annotation ) {
	var covered, type, annotatable,
		tx = new ve.dm.Transaction(),
		data = doc.data,
		index = doc.getStore().index( annotation ),
		i = range.start,
		span = i,
		on = false,
		insideContentNode = false,
		ignoreChildrenDepth = 0;

	// Iterate over all data in range, annotating where appropriate
	while ( i < range.end ) {
		if ( data.isElementData( i ) ) {
			type = data.getType( i );
			if ( ve.dm.nodeFactory.shouldIgnoreChildren( type ) ) {
				ignoreChildrenDepth += data.isOpenElementData( i ) ? 1 : -1;
			}
			if ( ve.dm.nodeFactory.isNodeContent( type ) ) {
				if ( method === 'set' && !ve.dm.nodeFactory.canNodeTakeAnnotationType( type, annotation ) ) {
					// Blacklisted annotations can't be set
					annotatable = false;
				} else {
					annotatable = true;
				}
			} else {
				// Structural nodes are never annotatable
				annotatable = false;
			}
		} else {
			// Text is always annotatable
			annotatable = true;
		}
		// No annotations if we're inside an ignoreChildren node
		annotatable = annotatable && !ignoreChildrenDepth;
		if (
			!annotatable ||
			( insideContentNode && !data.isCloseElementData( i ) )
		) {
			// Structural element opening or closing, or entering a content node
			if ( on ) {
				tx.pushRetain( span );
				tx.pushStopAnnotating( method, index );
				span = 0;
				on = false;
			}
		} else if (
			( !data.isElementData( i ) || !data.isCloseElementData( i ) ) &&
			!insideContentNode
		) {
			// Character or content element opening
			if ( data.isElementData( i ) ) {
				insideContentNode = true;
			}
			if ( method === 'set' ) {
				// Don't re-apply matching annotation
				covered = data.getAnnotationsFromOffset( i ).containsComparable( annotation );
			} else {
				// Expect comparable annotations to be removed individually otherwise
				// we might try to remove more than one annotation per character, which
				// a single transaction can't do.
				covered = data.getAnnotationsFromOffset( i ).contains( annotation );
			}
			if ( ( covered && method === 'set' ) || ( !covered && method === 'clear' ) ) {
				// Skip annotated content
				if ( on ) {
					tx.pushRetain( span );
					tx.pushStopAnnotating( method, index );
					span = 0;
					on = false;
				}
			} else {
				// Cover non-annotated content
				if ( !on ) {
					tx.pushRetain( span );
					tx.pushStartAnnotating( method, index );
					span = 0;
					on = true;
				}
			}
		} else if ( data.isCloseElementData( i ) ) {
			// Content closing, skip
			insideContentNode = false;
		}
		span++;
		i++;
	}
	tx.pushRetain( span );
	if ( on ) {
		tx.pushStopAnnotating( method, index );
	}
	tx.pushFinalRetain( doc, range.end );
	return tx;
};

/**
 * Generate a transaction that inserts metadata elements.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {number} offset Offset of element
 * @param {number} index Index of metadata cursor within element
 * @param {Array} newElements New elements to insert
 * @return {ve.dm.Transaction} Transaction that inserts the metadata elements
 */
ve.dm.Transaction.newFromMetadataInsertion = function ( doc, offset, index, newElements ) {
	var tx = new ve.dm.Transaction(),
		data = doc.metadata,
		elements = data.getData( offset ) || [];

	if ( newElements.length === 0 ) {
		return tx; // no-op
	}

	// Retain up to element
	tx.pushRetain( offset );
	// Retain up to metadata element (second dimension)
	tx.pushRetainMetadata( index );
	// Insert metadata elements
	tx.pushReplaceMetadata(
		[], newElements
	);
	// Retain up to end of metadata elements (second dimension)
	tx.pushRetainMetadata( elements.length - index );
	// Retain to end of document
	tx.pushFinalRetain( doc, offset, elements.length );
	return tx;
};

/**
 * Generate a transaction that removes metadata elements.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {number} offset Offset of element
 * @param {ve.Range} range Range of metadata to remove
 * @return {ve.dm.Transaction} Transaction that removes metadata elements
 * @throws {Error} Cannot remove metadata from empty list
 * @throws {Error} Range out of bounds
 */
ve.dm.Transaction.newFromMetadataRemoval = function ( doc, offset, range ) {
	var selection,
		tx = new ve.dm.Transaction(),
		data = doc.metadata,
		elements = data.getData( offset ) || [];

	if ( !elements.length ) {
		throw new Error( 'Cannot remove metadata from empty list' );
	}

	if ( range.start < 0 || range.end > elements.length ) {
		throw new Error( 'Range out of bounds' );
	}

	selection = elements.slice( range.start, range.end );

	if ( selection.length === 0 ) {
		return tx; // no-op.
	}

	// Retain up to element
	tx.pushRetain( offset );
	// Retain up to metadata element (second dimension)
	tx.pushRetainMetadata( range.start );
	// Remove metadata elements
	tx.pushReplaceMetadata(
		selection, []
	);
	// Retain up to end of metadata elements (second dimension)
	tx.pushRetainMetadata( elements.length - range.end );
	// Retain to end of document (unless we're already off the end )
	tx.pushFinalRetain( doc, offset, elements.length );
	return tx;
};

/**
 * Generate a transaction that replaces a single metadata element.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {number} offset Offset of element
 * @param {number} index Index of metadata cursor within element
 * @param {Object} newElement New element to insert
 * @return {ve.dm.Transaction} Transaction that replaces a metadata element
 * @throws {Error} Metadata index out of bounds
 */
ve.dm.Transaction.newFromMetadataElementReplacement = function ( doc, offset, index, newElement ) {
	var oldElement,
		tx = new ve.dm.Transaction(),
		data = doc.getMetadata(),
		elements = data[ offset ] || [];

	if ( index >= elements.length ) {
		throw new Error( 'Metadata index out of bounds' );
	}

	oldElement = elements[ index ];

	// Retain up to element
	tx.pushRetain( offset );
	// Retain up to metadata element (second dimension)
	tx.pushRetainMetadata( index );
	// Remove metadata elements
	tx.pushReplaceMetadata(
		[ oldElement ], [ newElement ]
	);
	// Retain up to end of metadata elements (second dimension)
	tx.pushRetainMetadata( elements.length - index - 1 );
	// Retain to end of document (unless we're already off the end )
	tx.pushFinalRetain( doc, offset, elements.length );
	return tx;
};

/**
 * Generate a transaction that converts elements that can contain content.
 *
 * @static
 * @method
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {ve.Range} range Range to convert
 * @param {string} type Symbolic name of element type to convert to
 * @param {Object} attr Attributes to initialize element with
 * @return {ve.dm.Transaction} Transaction that converts content branches
 */
ve.dm.Transaction.newFromContentBranchConversion = function ( doc, range, type, attr ) {
	var i, selected, branch, branchOuterRange,
		tx = new ve.dm.Transaction(),
		selection = doc.selectNodes( range, 'leaves' ),
		opening = { type: type },
		closing = { type: '/' + type },
		previousBranch,
		previousBranchOuterRange;
	// Add attributes to opening if needed
	if ( ve.isPlainObject( attr ) ) {
		opening.attributes = attr;
	} else {
		attr = {};
	}
	// Replace the wrappings of each content branch in the range
	for ( i = 0; i < selection.length; i++ ) {
		selected = selection[ i ];
		branch = selected.node.isContent() ? selected.node.getParent() : selected.node;
		if ( branch.canContainContent() ) {
			// Skip branches that are already of the target type and have all attributes in attr
			// set already.
			if ( branch.getType() === type && ve.compare( attr, branch.getAttributes(), true ) ) {
				continue;
			}
			branchOuterRange = branch.getOuterRange();
			// Don't convert the same branch twice
			if ( branch === previousBranch ) {
				continue;
			}

			// Retain up to this branch, considering where the previous one left off
			tx.pushRetain(
				branchOuterRange.start - ( previousBranch ? previousBranchOuterRange.end : 0 )
			);
			if ( branch.getType() === type ) {
				// Same type, different attributes, so we only need an attribute change
				tx.pushAttributeChanges( attr, branch.getAttributes() );
				// Retain the branch, including its opening and closing
				tx.pushRetain( branch.getOuterLength() );
			} else {
				// Types differ, so we need to replace the opening and closing
				// Replace the opening
				tx.pushReplace( doc, branchOuterRange.start, 1, [ ve.copy( opening ) ] );
				// Retain the contents
				tx.pushRetain( branch.getLength() );
				// Replace the closing
				tx.pushReplace( doc, branchOuterRange.end - 1, 1, [ ve.copy( closing ) ] );
			}
			// Remember this branch and its range for next time
			previousBranch = branch;
			previousBranchOuterRange = branchOuterRange;
		}
	}
	// Retain until the end
	tx.pushFinalRetain( doc, previousBranch ? previousBranchOuterRange.end : 0 );
	return tx;
};

/**
 * Generate a transaction that wraps, unwraps or replaces structure.
 *
 * The unwrap parameters are checked against the actual model data, and
 * an exception is thrown if the type fields don't match. This means you
 * can omit attributes from the unwrap parameters, those are automatically
 * picked up from the model data instead.
 *
 * NOTE: This function currently does not fix invalid parent/child relationships, so it will
 * happily convert paragraphs to listItems without wrapping them in a list if that's what you
 * ask it to do. We'll probably fix this later but for now the caller is responsible for giving
 * valid instructions.
 *
 * Changing a paragraph to a header:
 *     Before: [ {type: 'paragraph'}, 'a', 'b', 'c', {type: '/paragraph'} ]
 *     newFromWrap( new ve.Range( 1, 4 ), [ {type: 'paragraph'} ], [ {type: 'heading', level: 1 } ] );
 *     After: [ {type: 'heading', level: 1 }, 'a', 'b', 'c', {type: '/heading'} ]
 *
 * Changing a set of paragraphs to a list:
 *     Before: [ {type: 'paragraph'}, 'a', {type: '/paragraph'}, {'type':'paragraph'}, 'b', {'type':'/paragraph'} ]
 *     newFromWrap( new ve.Range( 0, 6 ), [], [ {type: 'list' } ], [], [ {type: 'listItem', attributes: {styles: ['bullet']}} ] );
 *     After: [ {type: 'list'}, {type: 'listItem', attributes: {styles: ['bullet']}}, {'type':'paragraph'} 'a',
 *              {type: '/paragraph'}, {type: '/listItem'}, {type: 'listItem', attributes: {styles: ['bullet']}},
 *              {type: 'paragraph'}, 'b', {type: '/paragraph'}, {type: '/listItem'}, {type: '/list'} ]
 *
 * @param {ve.dm.Document} doc Document in pre-transaction state
 * @param {ve.Range} range Range to wrap/unwrap/replace around
 * @param {Array} unwrapOuter Opening elements to unwrap. These must be immediately *outside* the range
 * @param {Array} wrapOuter Opening elements to wrap around the range
 * @param {Array} unwrapEach Opening elements to unwrap from each top-level element in the range
 * @param {Array} wrapEach Opening elements to wrap around each top-level element in the range
 * @return {ve.dm.Transaction}
 */
ve.dm.Transaction.newFromWrap = function ( doc, range, unwrapOuter, wrapOuter, unwrapEach, wrapEach ) {
	var i, j, unwrapOuterData, startOffset, unwrapEachData, closingUnwrapEach, closingWrapEach,
		tx = new ve.dm.Transaction(),
		depth = 0;

	// Function to generate arrays of closing elements in reverse order
	function closingArray( openings ) {
		var i,
			closings = [],
			len = openings.length;
		for ( i = 0; i < len; i++ ) {
			closings[ closings.length ] = { type: '/' + openings[ len - i - 1 ].type };
		}
		return closings;
	}
	closingUnwrapEach = closingArray( unwrapEach );
	closingWrapEach = closingArray( wrapEach );

	// TODO: check for and fix nesting validity like fixupInsertion does
	if ( range.start > unwrapOuter.length ) {
		// Retain up to the first thing we're unwrapping
		// The outer unwrapping takes place *outside*
		// the range, so compensate for that
		tx.pushRetain( range.start - unwrapOuter.length );
	} else if ( range.start < unwrapOuter.length ) {
		throw new Error( 'unwrapOuter is longer than the data preceding the range' );
	}

	// Replace the opening elements for the outer unwrap&wrap
	if ( wrapOuter.length > 0 || unwrapOuter.length > 0 ) {
		// Verify that wrapOuter matches the data at this position
		unwrapOuterData = doc.data.slice( range.start - unwrapOuter.length, range.start );
		for ( i = 0; i < unwrapOuterData.length; i++ ) {
			if ( unwrapOuterData[ i ].type !== unwrapOuter[ i ].type ) {
				throw new Error( 'Element in unwrapOuter does not match: expected ' +
					unwrapOuter[ i ].type + ' but found ' + unwrapOuterData[ i ].type );
			}
		}
		// Instead of putting in unwrapOuter as given, put it in the
		// way it appears in the model so we pick up any attributes
		tx.pushReplace( doc, range.start - unwrapOuter.length, unwrapOuter.length, ve.copy( wrapOuter ) );
	}

	if ( wrapEach.length > 0 || unwrapEach.length > 0 ) {
		// Visit each top-level child and wrap/unwrap it
		// TODO figure out if we should use the tree/node functions here
		// rather than iterating over offsets, it may or may not be faster
		for ( i = range.start; i < range.end; i++ ) {
			if ( doc.data.isElementData( i ) ) {
				// This is a structural offset
				if ( !doc.data.isCloseElementData( i ) ) {
					// This is an opening element
					if ( depth === 0 ) {
						// We are at the start of a top-level element
						// Replace the opening elements

						// Verify that unwrapEach matches the data at this position
						unwrapEachData = doc.data.slice( i, i + unwrapEach.length );
						for ( j = 0; j < unwrapEachData.length; j++ ) {
							if ( unwrapEachData[ j ].type !== unwrapEach[ j ].type ) {
								throw new Error( 'Element in unwrapEach does not match: expected ' +
									unwrapEach[ j ].type + ' but found ' +
									unwrapEachData[ j ].type );
							}
						}
						// Instead of putting in unwrapEach as given, put it in the
						// way it appears in the model, so we pick up any attributes
						tx.pushReplace( doc, i, unwrapEach.length, ve.copy( wrapEach ) );

						// Store this offset for later
						startOffset = i + unwrapEach.length;
					}
					depth++;
				} else {
					// This is a closing element
					depth--;
					if ( depth === 0 ) {
						// We are at the end of a top-level element
						// Advance past the element, then back up past the unwrapEach
						j = ( i + 1 ) - unwrapEach.length;
						// Retain the contents of what we're wrapping
						tx.pushRetain( j - startOffset );
						// Replace the closing elements
						tx.pushReplace( doc, j, unwrapEach.length, ve.copy( closingWrapEach ) );
					}
				}
			}
		}
	} else {
		// There is no wrapEach/unwrapEach to be done, just retain
		// up to the end of the range
		tx.pushRetain( range.end - range.start );
	}

	// this is a no-op if unwrapOuter.length===0 and wrapOuter.length===0
	tx.pushReplace( doc, range.end, unwrapOuter.length, closingArray( wrapOuter ) );

	// Retain up to the end of the document
	tx.pushFinalRetain( doc, range.end + unwrapOuter.length );

	return tx;
};

/**
 * Specification for how each type of operation should be reversed.
 *
 * This object maps operation types to objects, which map property names to reversal instructions.
 * A reversal instruction is either a string (which means the value of that property should be used)
 * or an object (which maps old values to new values). For instance, { from: 'to' }
 * means that the .from property of the reversed operation should be set to the .to property of the
 * original operation, and { method: { set: 'clear' } } means that if the .method property of
 * the original operation was 'set', the reversed operation's .method property should be 'clear'.
 *
 * If a property's treatment isn't specified, its value is simply copied without modification.
 * If an operation type's treatment isn't specified, all properties are copied without modification.
 *
 * @type {Object.<string,Object.<string,string|Object.<string, string>>>}
 */
ve.dm.Transaction.reversers = {
	annotate: { method: { set: 'clear', clear: 'set' } }, // swap 'set' with 'clear'
	attribute: { from: 'to', to: 'from' }, // swap .from with .to
	replace: { // swap .insert with .remove and .insertMetadata with .removeMetadata
		insert: 'remove',
		remove: 'insert',
		insertMetadata: 'removeMetadata',
		removeMetadata: 'insertMetadata'
	},
	replaceMetadata: { insert: 'remove', remove: 'insert' } // swap .insert with .remove
};

/* Methods */

/**
 * Get a serializable object describing the transaction
 *
 * @return {Object} Serializable object
 */
ve.dm.Transaction.prototype.toJSON = function () {
	return this.operations;
};

/**
 * Create a clone of this transaction.
 *
 * The returned transaction will be exactly the same as this one, except that its 'applied' flag
 * will be cleared. This means that if a transaction has already been committed, it will still
 * be possible to commit the clone. This is used for redoing transactions that were undone.
 *
 * @return {ve.dm.Transaction} Clone of this transaction
 */
ve.dm.Transaction.prototype.clone = function () {
	var tx = new this.constructor();
	tx.operations = ve.copy( this.operations );
	return tx;
};

/**
 * Create a reversed version of this transaction.
 *
 * The returned transaction will be the same as this one but with all operations reversed. This
 * means that applying the original transaction and then applying the reversed transaction will
 * result in no net changes. This is used to undo transactions.
 *
 * @return {ve.dm.Transaction} Reverse of this transaction
 */
ve.dm.Transaction.prototype.reversed = function () {
	var i, len, op, newOp, reverse, prop, tx = new this.constructor();
	for ( i = 0, len = this.operations.length; i < len; i++ ) {
		op = this.operations[ i ];
		newOp = ve.copy( op );
		reverse = this.constructor.reversers[ op.type ] || {};
		for ( prop in reverse ) {
			if ( typeof reverse[ prop ] === 'string' ) {
				newOp[ prop ] = op[ reverse[ prop ] ];
			} else {
				newOp[ prop ] = reverse[ prop ][ op[ prop ] ];
			}
		}
		tx.operations.push( newOp );
	}
	return tx;
};

/**
 * Check if the transaction would make any actual changes if processed.
 *
 * There may be more sophisticated checks that can be done, like looking for things being replaced
 * with identical content, but such transactions probably should not be created in the first place.
 *
 * @method
 * @return {boolean} Transaction is no-op
 */
ve.dm.Transaction.prototype.isNoOp = function () {
	if ( this.operations.length === 0 ) {
		return true;
	} else if ( this.operations.length === 1 ) {
		return this.operations[ 0 ].type === 'retain';
	} else if ( this.operations.length === 2 ) {
		return this.operations[ 0 ].type === 'retain' &&
			this.operations[ 1 ].type === 'retainMetadata';
	} else {
		return false;
	}
};

/**
 * Get all operations.
 *
 * @method
 * @return {Object[]} List of operations
 */
ve.dm.Transaction.prototype.getOperations = function () {
	return this.operations;
};

/**
 * Check if the transaction has any operations with a certain type.
 *
 * @method
 * @return {boolean} Has operations of a given type
 */
ve.dm.Transaction.prototype.hasOperationWithType = function ( type ) {
	var i, len;
	for ( i = 0, len = this.operations.length; i < len; i++ ) {
		if ( this.operations[ i ].type === type ) {
			return true;
		}
	}
	return false;
};

/**
 * Check if the transaction has any content data operations, such as insertion or deletion.
 *
 * @method
 * @return {boolean} Has content data operations
 */
ve.dm.Transaction.prototype.hasContentDataOperations = function () {
	return this.hasOperationWithType( 'replace' );
};

/**
 * Check if the transaction has any element attribute operations.
 *
 * @method
 * @return {boolean} Has element attribute operations
 */
ve.dm.Transaction.prototype.hasElementAttributeOperations = function () {
	return this.hasOperationWithType( 'attribute' );
};

/**
 * Check if the transaction has any annotation operations.
 *
 * @method
 * @return {boolean} Has annotation operations
 */
ve.dm.Transaction.prototype.hasAnnotationOperations = function () {
	return this.hasOperationWithType( 'annotate' );
};

/**
 * Check whether the transaction has already been applied.
 *
 * @method
 * @return {boolean}
 */
ve.dm.Transaction.prototype.hasBeenApplied = function () {
	return this.applied;
};

/**
 * Mark the transaction as having been applied.
 *
 * Should only be called after committing the transaction.
 *
 * @see ve.dm.Transaction#hasBeenApplied
 */
ve.dm.Transaction.prototype.markAsApplied = function () {
	this.applied = true;
};

/**
 * Translate an offset based on a transaction.
 *
 * This is useful when you want to anticipate what an offset will be after a transaction is
 * processed.
 *
 * @method
 * @param {number} offset Offset in the linear model before the transaction has been processed
 * @param {boolean} [excludeInsertion] Map the offset immediately before an insertion to
 *  right before the insertion rather than right after
 * @return {number} Translated offset, as it will be after processing transaction
 */
ve.dm.Transaction.prototype.translateOffset = function ( offset, excludeInsertion ) {
	var i, op, insertLength, removeLength, prevAdjustment,
		cursor = 0,
		adjustment = 0;

	for ( i = 0; i < this.operations.length; i++ ) {
		op = this.operations[ i ];
		if ( op.type === 'replace' ) {
			insertLength = op.insert.length;
			removeLength = op.remove.length;
			prevAdjustment = adjustment;
			adjustment += insertLength - removeLength;
			if ( offset === cursor + removeLength ) {
				// Offset points to right after the removal or right before the insertion
				if ( excludeInsertion && insertLength > removeLength ) {
					// Translate it to before the insertion
					return offset + adjustment - insertLength + removeLength;
				} else {
					// Translate it to after the removal/insertion
					return offset + adjustment;
				}
			} else if ( offset === cursor ) {
				// The offset points to right before the removal or replacement
				if ( insertLength === 0 ) {
					// Translate it to after the removal
					return cursor + removeLength + adjustment;
				} else {
					// Translate it to before the replacement
					// To translate this correctly, we have to use adjustment as it was before
					// we adjusted it for this replacement
					return cursor + prevAdjustment;
				}
			} else if ( offset > cursor && offset < cursor + removeLength ) {
				// The offset points inside of the removal
				// Translate it to after the removal
				return cursor + removeLength + adjustment;
			}
			cursor += removeLength;
		} else if ( op.type === 'retain' ) {
			if ( offset >= cursor && offset < cursor + op.length ) {
				return offset + adjustment;
			}
			cursor += op.length;
		}
	}
	return offset + adjustment;
};

/**
 * Translate a range based on a transaction.
 *
 * This is useful when you want to anticipate what a selection will be after a transaction is
 * processed.
 *
 * @method
 * @see #translateOffset
 * @param {ve.Range} range Range in the linear model before the transaction has been processed
 * @param {boolean} [excludeInsertion] Do not grow the range to cover insertions
 *  on the boundaries of the range.
 * @return {ve.Range} Translated range, as it will be after processing transaction
 */
ve.dm.Transaction.prototype.translateRange = function ( range, excludeInsertion ) {
	var start = this.translateOffset( range.start, !excludeInsertion ),
		end = this.translateOffset( range.end, excludeInsertion );
	return range.isBackwards() ? new ve.Range( end, start ) : new ve.Range( start, end );
};

/**
 * Get the range that covers modifications made by this transaction.
 *
 * In the case of insertions, the range covers content the user intended to insert.
 * It ignores wrappers added by ve.dm.Document#fixUpInsertion.
 *
 * The returned range is relative to the new state, after the transaction is applied. So for a
 * simple insertion transaction, the range will cover the newly inserted data, and for a simple
 * removal transaction it will be a zero-length range.
 *
 * Changes within the internal list at the end of a document are ignored.
 *
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @return {ve.Range|null} Range covering modifications, or null for a no-op transaction
 */
ve.dm.Transaction.prototype.getModifiedRange = function ( doc ) {
	var i, len, op, start, end,
		oldOffset = 0,
		offset = 0,
		internalListNode = doc.getInternalList().getListNode(),
		docEndOffset = internalListNode ? internalListNode.getOuterRange().start : this.getDocument().data.getLength();

	opLoop:
	for ( i = 0, len = this.operations.length; i < len; i++ ) {
		op = this.operations[ i ];
		switch ( op.type ) {
			case 'retainMetadata':
				continue;

			case 'retain':
				if ( oldOffset + op.length > docEndOffset ) {
					break opLoop;
				}
				offset += op.length;
				oldOffset += op.length;
				break;

			default:
				if ( start === undefined ) {
					// This is the first non-retain operation, set start to right before it
					start = offset + ( op.insertedDataOffset || 0 );
				}
				if ( op.type === 'replace' ) {
					offset += op.insert.length;
					oldOffset += op.remove.length;
				}
				// Set end, so it'll end up being right after the last non-retain operation
				if ( op.insertedDataLength ) {
					end = start + op.insertedDataLength;
				} else {
					end = offset;
				}
				break;
		}
	}
	if ( start === undefined || end === undefined ) {
		// No-op transaction
		return null;
	}
	return new ve.Range( start, end );
};

/**
 * Add a final retain operation to finish off a transaction (internal helper).
 *
 * @private
 * @method
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @param {number} offset Final offset edited by the transaction up to this point.
 * @param {number} [metaOffset=0] Final metadata offset edited, if non-zero.
 */
ve.dm.Transaction.prototype.pushFinalRetain = function ( doc, offset, metaOffset ) {
	var data = doc.data,
		metadata = doc.metadata,
		finalMetadata = metadata.getData( data.getLength() );
	if ( offset < doc.data.getLength() ) {
		this.pushRetain( doc.data.getLength() - offset );
		metaOffset = 0;
	}
	// if there is trailing metadata, push a final retainMetadata
	if ( finalMetadata !== undefined && finalMetadata.length > 0 ) {
		this.pushRetainMetadata( finalMetadata.length - ( metaOffset || 0 ) );
	}
};

/**
 * Add a retain operation.
 *
 * @method
 * @param {number} length Length of content data to retain
 * @throws {Error} Cannot retain backwards
 */
ve.dm.Transaction.prototype.pushRetain = function ( length ) {
	var end;
	if ( length < 0 ) {
		throw new Error( 'Invalid retain length, cannot retain backwards:' + length );
	}
	if ( length ) {
		end = this.operations.length - 1;
		if ( this.operations.length && this.operations[ end ].type === 'retain' ) {
			this.operations[ end ].length += length;
		} else {
			this.operations.push( {
				type: 'retain',
				length: length
			} );
		}
	}
};

/**
 * Add a retain metadata operation.
 * // TODO: this is a copy/paste of pushRetain (at the moment). Consider a refactor.
 *
 * @method
 * @param {number} length Length of content data to retain
 * @throws {Error} Cannot retain backwards
 */
ve.dm.Transaction.prototype.pushRetainMetadata = function ( length ) {
	var end;
	if ( length < 0 ) {
		throw new Error( 'Invalid retain length, cannot retain backwards:' + length );
	}
	if ( length ) {
		end = this.operations.length - 1;
		if ( this.operations.length && this.operations[ end ].type === 'retainMetadata' ) {
			this.operations[ end ].length += length;
		} else {
			this.operations.push( {
				type: 'retainMetadata',
				length: length
			} );
		}
	}
};

/**
 * Adds a replace op to remove the desired range and, where required, splices in retain ops
 * to prevent the deletion of undeletable nodes.
 *
 * An extra `replaceMetadata` operation might be pushed at the end if the
 * affected region contains metadata; see
 * {@link ve.dm.Transaction#pushReplace} for details.
 *
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @param {number} removeStart Offset to start removing from
 * @param {number} removeEnd Offset to remove to
 * @param {boolean} [removeMetadata=false] Remove metadata instead of collapsing it
 * @return {number} End offset of the removal
 */
ve.dm.Transaction.prototype.addSafeRemoveOps = function ( doc, removeStart, removeEnd, removeMetadata ) {
	var i, queuedRetain,
		retainStart = removeStart,
		undeletableStackDepth = 0;
	// Iterate over removal range and use a stack counter to determine if
	// we are inside an undeletable node
	for ( i = removeStart; i < removeEnd; i++ ) {
		if ( doc.data.isElementData( i ) && !ve.dm.nodeFactory.isNodeDeletable( doc.data.getType( i ) ) ) {
			if ( !doc.data.isCloseElementData( i ) ) {
				if ( undeletableStackDepth === 0 ) {
					if ( queuedRetain ) {
						this.pushRetain( queuedRetain );
					}
					this.pushReplace( doc, removeStart, i - removeStart, [], removeMetadata ? [] : undefined );
					retainStart = i;
				}
				undeletableStackDepth++;
			} else {
				undeletableStackDepth--;
				if ( undeletableStackDepth === 0 ) {
					queuedRetain = i + 1 - retainStart;
					removeStart = i + 1;
				}
			}
		}
	}
	if ( removeEnd - removeStart ) {
		if ( queuedRetain ) {
			this.pushRetain( queuedRetain );
		}
		this.pushReplace( doc, removeStart, removeEnd - removeStart, [], removeMetadata ? [] : undefined );
		retainStart = removeEnd;
	}
	return retainStart;
};

/**
 * Add a replace operation (internal helper).
 *
 * @private
 * @method
 * @param {Array} remove Data removed.
 * @param {Array} insert Data to insert.
 * @param {Array|undefined} removeMetadata Metadata removed.
 * @param {Array} insertMetadata Metadata to insert.
 */
ve.dm.Transaction.prototype.pushReplaceInternal = function ( remove, insert, removeMetadata, insertMetadata, insertedDataOffset, insertedDataLength ) {
	var op = {
		type: 'replace',
		remove: remove,
		insert: insert
	};
	if ( remove.length === 0 && insert.length === 0 ) {
		return; // no-op
	}
	if ( removeMetadata !== undefined && insertMetadata !== undefined ) {
		op.removeMetadata = removeMetadata;
		op.insertMetadata = insertMetadata;
	}
	if ( insertedDataOffset !== undefined && insertedDataLength !== undefined ) {
		op.insertedDataOffset = insertedDataOffset;
		op.insertedDataLength = insertedDataLength;
	}
	this.operations.push( op );
};

/**
 * Add a replace operation, keeping metadata in sync if required.
 *
 * Note that metadata attached to removed content is moved so that it
 * attaches just before the inserted content.  If there is
 * metadata attached to the removed content but there is no inserted
 * content, then an extra `replaceMetadata` operation is pushed in order
 * to properly insert the merged metadata before the character immediately
 * after the removed content. (Note that there is an extra metadata element
 * after the final data element; if the removed region is at the very end of
 * the document, the inserted `replaceMetadata` operation targets this
 * final metadata element.)
 *
 * @method
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @param {number} offset Offset to start at
 * @param {number} removeLength Number of data items to remove
 * @param {Array} insert Data to insert
 * @param {Array} [insertMetadata] Overwrite the metadata with this data, rather than collapsing it
 * @param {number} [insertedDataOffset] Offset of the originally inserted data in the resulting operation data
 * @param {number} [insertedDataLength] Length of the originally inserted data in the resulting operation data
 */
ve.dm.Transaction.prototype.pushReplace = function ( doc, offset, removeLength, insert, insertMetadata, insertedDataOffset, insertedDataLength ) {
	var extraMetadata, end, lastOp, penultOp, range, remove, removeMetadata,
		isRemoveEmpty, isInsertEmpty, mergedMetadata;

	if ( removeLength === 0 && insert.length === 0 ) {
		// Don't push no-ops
		return;
	}

	end = this.operations.length - 1;
	lastOp = end >= 0 ? this.operations[ end ] : null;
	penultOp = end >= 1 ? this.operations[ end - 1 ] : null;
	range = new ve.Range( offset, offset + removeLength );
	remove = doc.getData( range );
	removeMetadata = doc.getMetadata( range );
	// ve.compare compares arrays as objects, so no need to check against
	// an array of the same length for emptiness.
	isRemoveEmpty = ve.compare( removeMetadata, [] );
	isInsertEmpty = insertMetadata && ve.compare( insertMetadata, [] );
	mergedMetadata = [];

	if ( !insertMetadata && !isRemoveEmpty ) {
		// if we are removing a range which includes metadata, we need to
		// collapse it.  If there's nothing to insert, we also need to add
		// an extra `replaceMetadata` operation later in order to insert the
		// collapsed metadata.
		insertMetadata = ve.dm.MetaLinearData.static.merge( removeMetadata );
		if ( insert.length === 0 ) {
			extraMetadata = insertMetadata[ 0 ];
			insertMetadata = [];
		} else {
			// pad out at end so insert metadata is the same length as insert data
			ve.batchSplice( insertMetadata, 1, 0, new Array( insert.length - 1 ) );
		}
		isInsertEmpty = ve.compare( insertMetadata, new Array( insertMetadata.length ) );
	} else if ( isInsertEmpty && isRemoveEmpty ) {
		// No metadata changes, don't pollute the transaction with [undefined, undefined, ...]
		insertMetadata = undefined;
	}

	// simple replaces can be combined
	// (but don't do this if there is metadata to be removed and the previous
	// replace had a non-zero insertion, because that would shift the metadata
	// location.  also skip this if the last replace deliberately removed
	// metadata instead of merging it.)
	if (
		lastOp && lastOp.type === 'replaceMetadata' &&
		lastOp.insert.length > 0 && lastOp.remove.length === 0 &&
		penultOp && penultOp.type === 'replace' &&
		penultOp.insert.length === 0 /* this is always true */
	) {
		mergedMetadata = [ lastOp.insert ];
		this.operations.pop();
		lastOp = penultOp;
		/* fall through */
	}
	// merge, where extraMetadata will not be required
	if (
		lastOp && lastOp.type === 'replace' &&
		!( lastOp.insert.length > 0 && insertMetadata !== undefined ) &&
		lastOp.insertedDataOffset === undefined && !extraMetadata &&
		// don't merge if we mergedMetadata and had to insert non-empty
		// metadata as a result
		!( mergedMetadata.length > 0 && insertMetadata !== undefined && !isInsertEmpty )
	) {
		lastOp = this.operations.pop();
		this.pushReplace(
			doc,
			offset - lastOp.remove.length,
			lastOp.remove.length + removeLength,
			lastOp.insert.concat( insert ),
			(
				lastOp.insertMetadata || new Array( lastOp.insert.length )
			).concat(
				mergedMetadata
			).concat(
				( insertMetadata === undefined || isInsertEmpty ) ?
				new Array( insert.length - mergedMetadata.length ) :
				insertMetadata
			),
			insertedDataOffset,
			insertedDataLength
		);
		return;
	}
	// merge a "remove after remove" (where extraMetadata will be required)
	if (
		lastOp && lastOp.type === 'replace' &&
		lastOp.insert.length === 0 && insert.length === 0 &&
		( lastOp.removeMetadata === undefined || mergedMetadata.length > 0 ) &&
		( insertMetadata === undefined || extraMetadata )
	) {
		lastOp = this.operations.pop();
		this.pushReplace(
			doc,
			offset - lastOp.remove.length,
			lastOp.remove.length + removeLength,
			[]
		);
		return;
	}

	if ( lastOp && lastOp.type === 'replaceMetadata' ) {
		// `replace` operates on the metadata at the given offset; the transaction
		// touches the same region twice if `replace` follows a `replaceMetadata`
		// without a `retain` in between.
		throw new Error( 'replace after replaceMetadata not allowed' );
	}

	this.pushReplaceInternal( remove, insert, removeMetadata, insertMetadata, insertedDataOffset, insertedDataLength );

	if ( extraMetadata !== undefined ) {
		this.pushReplaceMetadata( [], extraMetadata );
	}
};

/**
 * Add a replace metadata operation
 *
 * @method
 * @param {Array} remove Metadata to remove
 * @param {Array} insert Metadata to replace 'remove' with
 */
ve.dm.Transaction.prototype.pushReplaceMetadata = function ( remove, insert ) {
	if ( remove.length === 0 && insert.length === 0 ) {
		// Don't push no-ops
		return;
	}
	this.operations.push( {
		type: 'replaceMetadata',
		remove: remove,
		insert: insert
	} );
};

/**
 * Add an element attribute change operation.
 *
 * @method
 * @param {string} key Name of attribute to change
 * @param {Mixed} from Value change attribute from, or undefined if not previously set
 * @param {Mixed} to Value to change attribute to, or undefined to remove
 */
ve.dm.Transaction.prototype.pushReplaceElementAttribute = function ( key, from, to ) {
	this.operations.push( {
		type: 'attribute',
		key: key,
		from: from,
		to: to
	} );
};

/**
 * Add a series of element attribute change operations.
 *
 * @param {Object} changes Object mapping attribute names to new values
 * @param {Object} oldAttrs Object mapping attribute names to old values
 */
ve.dm.Transaction.prototype.pushAttributeChanges = function ( changes, oldAttrs ) {
	var key;
	for ( key in changes ) {
		if ( oldAttrs[ key ] !== changes[ key ] ) {
			this.pushReplaceElementAttribute( key, oldAttrs[ key ], changes[ key ] );
		}
	}
};

/**
 * Add a start annotating operation.
 *
 * @method
 * @param {string} method Method to use, either "set" or "clear"
 * @param {Object} index Store index of annotation object to start setting or clearing from content data
 */
ve.dm.Transaction.prototype.pushStartAnnotating = function ( method, index ) {
	this.operations.push( {
		type: 'annotate',
		method: method,
		bias: 'start',
		index: index
	} );
};

/**
 * Add a stop annotating operation.
 *
 * @method
 * @param {string} method Method to use, either "set" or "clear"
 * @param {Object} index Store index of annotation object to stop setting or clearing from content data
 */
ve.dm.Transaction.prototype.pushStopAnnotating = function ( method, index ) {
	this.operations.push( {
		type: 'annotate',
		method: method,
		bias: 'stop',
		index: index
	} );
};

/**
 * Internal helper method for newFromInsertion and newFromReplacement.
 * Adds an insertion to an existing transaction object.
 *
 * @private
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @param {number} currentOffset Offset up to which the transaction has gone already
 * @param {number} insertOffset Offset to insert at
 * @param {Array} data Linear model data to insert
 * @return {number} End offset of the insertion
 */
ve.dm.Transaction.prototype.pushInsertion = function ( doc, currentOffset, insertOffset, data ) {
	// Fix up the insertion
	var insertion = doc.fixupInsertion( data, insertOffset );
	// Retain up to insertion point, if needed
	this.pushRetain( insertion.offset - currentOffset );
	// Insert data
	this.pushReplace(
		doc, insertion.offset, insertion.remove, insertion.data, undefined,
		insertion.insertedDataOffset, insertion.insertedDataLength
	);
	return insertion.offset + insertion.remove;
};

/**
 * Internal helper method for newFromRemoval and newFromReplacement.
 * Adds a removal to an existing transaction object.
 *
 * @private
 * @param {ve.dm.Document} doc The document in the state to which the transaction applies
 * @param {number} currentOffset Offset up to which the transaction has gone already
 * @param {ve.Range} range Range to remove
 * @param {boolean} [removeMetadata=false] Remove metadata instead of collapsing it
 * @return {number} End offset of the removal
 */
ve.dm.Transaction.prototype.pushRemoval = function ( doc, currentOffset, range, removeMetadata ) {
	var i, selection, first, last, nodeStart, nodeEnd,
		offset = currentOffset,
		removeStart = null,
		removeEnd = null;
	// Validate range
	if ( range.isCollapsed() ) {
		// Empty range, nothing to remove
		this.pushRetain( range.start - currentOffset );
		return range.start;
	}
	// Select nodes and validate selection
	selection = doc.selectNodes( range, 'covered' );
	if ( selection.length === 0 ) {
		// Empty selection? Something is wrong!
		throw new Error( 'Invalid range, cannot remove from ' + range.start + ' to ' + range.end );
	}
	first = selection[ 0 ];
	last = selection[ selection.length - 1 ];
	// If the first and last node are mergeable, merge them
	if ( first.node.canBeMergedWith( last.node ) ) {
		if ( !first.range && !last.range ) {
			// First and last node are both completely covered, remove them
			removeStart = first.nodeOuterRange.start;
			removeEnd = last.nodeOuterRange.end;
		} else {
			// Either the first node or the last node is partially covered, so remove
			// the selected content. The other node might be fully covered, in which case
			// we remove its contents (nodeRange). For fully covered content nodes, we must
			// remove the entire node (nodeOuterRange).
			removeStart = (
				first.range ||
				( first.node.isContent() ? first.nodeOuterRange : first.nodeRange )
			).start;
			removeEnd = (
				last.range ||
				( last.node.isContent() ? last.nodeOuterRange : last.nodeRange )
			).end;
		}
		this.pushRetain( removeStart - currentOffset );
		removeEnd = this.addSafeRemoveOps( doc, removeStart, removeEnd, removeMetadata );
		// All done
		return removeEnd;
	}

	// The selection wasn't mergeable, so remove nodes that are completely covered, and strip
	// nodes that aren't
	for ( i = 0; i < selection.length; i++ ) {
		if ( !selection[ i ].range ) {
			// Entire node is covered, remove it
			nodeStart = selection[ i ].nodeOuterRange.start;
			nodeEnd = selection[ i ].nodeOuterRange.end;
		} else {
			// Part of the node is covered, remove that range
			nodeStart = selection[ i ].range.start;
			nodeEnd = selection[ i ].range.end;
		}

		// Merge contiguous removals. Only apply a removal when a gap appears, or at the
		// end of the loop
		if ( removeEnd === null ) {
			// First removal
			removeStart = nodeStart;
			removeEnd = nodeEnd;
		} else if ( removeEnd === nodeStart ) {
			// Merge this removal into the previous one
			removeEnd = nodeEnd;
		} else {
			// There is a gap between the previous removal and this one

			// Push the previous removal first
			this.pushRetain( removeStart - offset );
			offset = this.addSafeRemoveOps( doc, removeStart, removeEnd, removeMetadata );

			// Now start this removal
			removeStart = nodeStart;
			removeEnd = nodeEnd;
		}
	}
	// Apply the last removal, if any
	if ( removeEnd !== null ) {
		this.pushRetain( removeStart - offset );
		offset = this.addSafeRemoveOps( doc, removeStart, removeEnd, removeMetadata );
	}
	return offset;
};

Zerion Mini Shell 1.0