/*!
 * VisualEditor DataModel Fragment class.
 *
 * @copyright See AUTHORS.txt
 */

/**
 * DataModel surface fragment.
 *
 * @class
 *
 * @constructor
 * @param {ve.dm.Surface} surface Target surface
 * @param {ve.dm.Selection} [selection] Selection within target document, current selection used by default
 * @param {boolean} [noAutoSelect] Don't update the surface's selection when making changes
 * @param {boolean} [excludeInsertions] Exclude inserted content at the boundaries when updating range
 */
ve.dm.SurfaceFragment = function VeDmSurfaceFragment( surface, selection, noAutoSelect, excludeInsertions ) {
	// Short-circuit for missing-surface null fragment
	if ( !surface ) {
		return this;
	}

	// Properties
	this.document = surface.getDocument();
	this.noAutoSelect = !!noAutoSelect;
	this.excludeInsertions = !!excludeInsertions;
	this.surface = surface;
	this.selection = selection || surface.getSelection();
	this.leafNodes = null;
	this.pending = [];

	// Initialization
	this.historyPointer = this.document.getCompleteHistoryLength();
};

/* Inheritance */

OO.initClass( ve.dm.SurfaceFragment );

/* Methods */

/**
 * Get list of selected nodes and annotations.
 *
 * @param {boolean} [all] Include nodes and annotations which only cover some of the fragment
 * @return {ve.dm.Model[]} Selected models
 */
ve.dm.SurfaceFragment.prototype.getSelectedModels = function ( all ) {
	// Handle null selection
	if ( this.isNull() ) {
		return [];
	}

	const annotations = this.getAnnotations( all );

	// Filter out nodes with collapsed ranges
	let nodes;
	if ( all ) {
		nodes = this.getCoveredNodes();
		for ( let i = 0, len = nodes.length; i < len; i++ ) {
			if ( nodes[ i ].range && nodes[ i ].range.isCollapsed() ) {
				nodes.splice( i, 1 );
				len--;
				i--;
			} else {
				nodes[ i ] = nodes[ i ].node;
			}
		}
	} else {
		nodes = [];
		const selectedNode = this.getSelectedNode();
		if ( selectedNode ) {
			nodes.push( selectedNode );
		}
	}

	return nodes.concat( !annotations.isEmpty() ? annotations.get() : [] );
};

/**
 * Update selection based on un-applied transactions in the surface, or specified selection.
 *
 * @param {ve.dm.Selection} [selection] Optional selection to set
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.update = function ( selection ) {
	// Handle null selection
	if ( this.isNull() ) {
		return this;
	}

	if ( selection && !selection.equals( this.selection ) ) {
		this.selection = selection;
		this.leafNodes = null;
		this.historyPointer = this.document.getCompleteHistoryLength();
	} else if ( this.historyPointer < this.document.getCompleteHistoryLength() ) {
		// Small optimisation: check history pointer is in the past
		const txs = this.document.getCompleteHistorySince( this.historyPointer );
		this.selection = this.selection.translateByTransactions( txs, this.excludeInsertions );
		this.leafNodes = null;
		this.historyPointer += txs.length;
	}
	return this;
};

/**
 * Process a set of transactions on the surface, and update the selection if the fragment
 * is auto-selecting.
 *
 * @param {ve.dm.Transaction|ve.dm.Transaction[]} txs Transaction(s) to process
 * @param {ve.dm.Selection} [selection] Selection to set, if different from translated selection, required if the
 *   fragment is null
 * @throws {Error} If fragment is null and selection is omitted
 */
ve.dm.SurfaceFragment.prototype.change = function ( txs, selection ) {
	if ( !selection && this.isNull() ) {
		throw new Error( 'Cannot change null fragment without selection' );
	}

	if ( !Array.isArray( txs ) ) {
		txs = [ txs ];
	}
	this.surface.change(
		txs,
		!this.noAutoSelect && ( selection || this.getSelection().translateByTransactions( txs, this.excludeInsertions ) )
	);
	if ( selection ) {
		// Overwrite the selection
		this.update( selection );
	}
};

/**
 * Get the surface the fragment is a part of.
 *
 * @return {ve.dm.Surface|null} Surface of fragment
 */
ve.dm.SurfaceFragment.prototype.getSurface = function () {
	return this.surface;
};

/**
 * Get the document of the surface the fragment is a part of.
 *
 * @return {ve.dm.Document|null} Document of surface of fragment
 */
ve.dm.SurfaceFragment.prototype.getDocument = function () {
	return this.document;
};

/**
 * Get the selection of the fragment within the surface.
 *
 * This method also calls update to make sure the selection returned is current.
 *
 * @return {ve.dm.Selection} The selection
 */
ve.dm.SurfaceFragment.prototype.getSelection = function () {
	this.update();
	return this.selection;
};

/**
 * Check if the fragment is null.
 *
 * @return {boolean} Fragment is a null fragment
 */
ve.dm.SurfaceFragment.prototype.isNull = function () {
	return this.selection.isNull();
};

/**
 * Check if the surface's selection will be updated automatically when changes are made.
 *
 * @return {boolean} Will automatically update surface selection
 */
ve.dm.SurfaceFragment.prototype.willAutoSelect = function () {
	return !this.noAutoSelect;
};

/**
 * Change whether to automatically update the surface selection when making changes.
 *
 * @param {boolean} [autoSelect=true] Automatically update surface selection
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.setAutoSelect = function ( autoSelect ) {
	this.noAutoSelect = !autoSelect;
	return this;
};

/**
 * Get a clone of this SurfaceFragment, optionally with a different selection.
 *
 * @param {ve.dm.Selection} [selection] If set, use this selection rather than the old fragment's selection
 * @return {ve.dm.SurfaceFragment} Clone of this fragment
 */
ve.dm.SurfaceFragment.prototype.clone = function ( selection ) {
	return new this.constructor(
		this.surface,
		selection || this.getSelection(),
		this.noAutoSelect,
		this.excludeInsertions
	);
};

/**
 * Check whether updates to this fragment's selection will exclude content inserted at the boundaries.
 *
 * @return {boolean} Selection updates will exclude insertions
 */
ve.dm.SurfaceFragment.prototype.willExcludeInsertions = function () {
	return this.excludeInsertions;
};

/**
 * Tell this fragment whether it should exclude insertions. If this option is enabled, updates to
 * this fragment's selection in response to transactions will not include content inserted at the
 * boundaries of the selection; if it is disabled, insertions will be included.
 *
 * @param {boolean} excludeInsertions Whether to exclude insertions
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.setExcludeInsertions = function ( excludeInsertions ) {
	excludeInsertions = !!excludeInsertions;
	if ( this.excludeInsertions !== excludeInsertions ) {
		// Process any deferred updates with the old value
		this.update();
		// Set the new value
		this.excludeInsertions = excludeInsertions;
	}
	return this;
};

/**
 * Get a new fragment with an adjusted position
 *
 * @param {number} [start] Adjustment for start position
 * @param {number} [end] Adjustment for end position
 * @return {ve.dm.SurfaceFragment} Adjusted fragment
 */
ve.dm.SurfaceFragment.prototype.adjustLinearSelection = function ( start, end ) {
	if ( !( this.selection instanceof ve.dm.LinearSelection ) ) {
		return this.clone();
	}
	const oldRange = this.getSelection().getRange();
	const newRange = oldRange && new ve.Range( oldRange.start + ( start || 0 ), oldRange.end + ( end || 0 ) );
	return this.clone( new ve.dm.LinearSelection( newRange ) );
};

/**
 * Get a new fragment with a truncated length.
 *
 * @param {number} limit Maximum length of new range (negative for left-side truncation)
 * @return {ve.dm.SurfaceFragment} Truncated fragment
 */
ve.dm.SurfaceFragment.prototype.truncateLinearSelection = function ( limit ) {
	if ( !( this.selection instanceof ve.dm.LinearSelection ) ) {
		return this.clone();
	}
	const range = this.getSelection().getRange();
	return this.clone( new ve.dm.LinearSelection( range.truncate( limit ) ) );
};

/**
 * Get a new fragment with a zero-length selection at the start offset.
 *
 * @return {ve.dm.SurfaceFragment} Collapsed fragment
 */
ve.dm.SurfaceFragment.prototype.collapseToStart = function () {
	return this.clone( this.getSelection().collapseToStart() );
};

/**
 * Get a new fragment with a zero-length selection at the end offset.
 *
 * @return {ve.dm.SurfaceFragment} Collapsed fragment
 */
ve.dm.SurfaceFragment.prototype.collapseToEnd = function () {
	return this.clone( this.getSelection().collapseToEnd() );
};

/**
 * Get a new fragment with a range that no longer includes leading and trailing whitespace.
 *
 * @return {ve.dm.SurfaceFragment} Trimmed fragment
 */
ve.dm.SurfaceFragment.prototype.trimLinearSelection = function () {
	if ( !( this.selection instanceof ve.dm.LinearSelection ) ) {
		return this.clone();
	}
	const oldRange = this.getSelection().getRange();
	let newRange;

	if ( this.getText().trim().length === 0 ) {
		// oldRange is only whitespace
		newRange = new ve.Range( oldRange.start );
	} else {
		newRange = this.document.data.trimOuterSpaceFromRange( oldRange );
	}

	return this.clone( new ve.dm.LinearSelection( newRange ) );
};

/**
 * Get a new fragment that covers an expanded range of the document.
 *
 * @param {string} [scope='parent'] Method of expansion:
 *  - `word`: Expands to cover the nearest word by looking for word breaks (see UnicodeJS.wordbreak)
 *  - `annotation`: Expands to cover a given annotation type (ve.dm.Annotation) within the current range
 *  - `root`: Expands to cover the entire document
 *  - `siblings`: Expands to cover all sibling nodes
 *  - `closest`: Expands to cover the closest common ancestor node of a give type (ve.dm.Node)
 *  - `parent`: Expands to cover the closest common parent node
 * @param {ve.dm.Annotation|ve.dm.Node} [type] Parameter to use with scope method if needed
 * @return {ve.dm.SurfaceFragment} Expanded fragment
 */
ve.dm.SurfaceFragment.prototype.expandLinearSelection = function ( scope, type ) {
	if ( !( this.selection instanceof ve.dm.LinearSelection ) ) {
		return this.clone();
	}

	const oldRange = this.getSelection().getRange();
	let newRange;
	let nodes, node, parent;

	switch ( scope || 'parent' ) {
		case 'word':
			if ( !oldRange.isCollapsed() ) {
				newRange = ve.Range.static.newCoveringRange( [
					this.document.data.getWordRange( oldRange.start ),
					this.document.data.getWordRange( oldRange.end )
				], oldRange.isBackwards() );
			} else {
				// Optimisation for zero-length ranges
				newRange = this.document.data.getWordRange( oldRange.start );
			}
			break;
		case 'annotation':
			newRange = this.document.data.getAnnotatedRangeFromSelection( oldRange, type );
			// Adjust selection if it does not contain the annotated range
			if ( oldRange.start > newRange.start || oldRange.end < newRange.end ) {
				// Maintain range direction
				if ( oldRange.from > oldRange.to ) {
					newRange = newRange.flip();
				}
			} else {
				// Otherwise just keep the range as is
				newRange = oldRange;
			}
			break;
		case 'root':
			newRange = this.getDocument().getDocumentRange();
			break;
		case 'siblings':
			// Grow range to cover all siblings
			nodes = this.document.selectNodes( oldRange, 'siblings' );
			if ( nodes.length === 1 ) {
				newRange = nodes[ 0 ].node.getOuterRange();
			} else {
				newRange = new ve.Range(
					nodes[ 0 ].node.getOuterRange().start,
					nodes[ nodes.length - 1 ].node.getOuterRange().end
				);
			}
			break;
		case 'closest':
			// Grow range to cover closest common ancestor node of given type
			nodes = this.document.selectNodes( oldRange, 'siblings' );
			// If the range covered the entire node check that node
			if ( nodes[ 0 ].nodeRange.equalsSelection( oldRange ) && nodes[ 0 ].node instanceof type ) {
				newRange = nodes[ 0 ].nodeOuterRange;
				break;
			}
			parent = nodes[ 0 ].node.getParent();
			while ( parent && !( parent instanceof type ) ) {
				node = parent;
				parent = parent.getParent();
			}
			if ( parent ) {
				newRange = parent.getOuterRange();
			}
			break;
		case 'parent':
			// Grow range to cover the closest common parent node
			node = this.document.selectNodes( oldRange, 'siblings' )[ 0 ].node;
			parent = node.getParent();
			if ( parent ) {
				newRange = parent.getOuterRange();
			}
			break;
		default:
			throw new Error( 'Invalid scope argument: ' + scope );
	}
	return this.clone(
		newRange ?
			new ve.dm.LinearSelection( newRange ) :
			new ve.dm.NullSelection()
	);
};

/**
 * Get data for the fragment.
 *
 * @param {boolean} [deep] Get a deep copy of the data
 * @return {Array} Fragment data
 */
ve.dm.SurfaceFragment.prototype.getData = function ( deep ) {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return [];
	}
	return this.document.getData( range, deep );
};

/**
 * Get plain text for the fragment.
 *
 * @param {boolean} [maintainIndices] Maintain data offset to string index alignment by replacing elements with line breaks
 * @return {string} Fragment text
 */
ve.dm.SurfaceFragment.prototype.getText = function ( maintainIndices ) {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return '';
	}
	return this.document.data.getText( maintainIndices, range );
};

/**
 * Whether the fragment contains only text, allowing annotations
 *
 * @method
 * @return {boolean} Whether there's only text
 */
ve.dm.SurfaceFragment.prototype.containsOnlyText = function () {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return true;
	}
	return this.document.data.isPlainText( range, false, false, false, true );
};

/**
 * Get annotations in fragment.
 *
 * By default, this will only get annotations that completely cover the fragment. Use the {all}
 * argument to get all annotations that occur within the fragment.
 *
 * @param {boolean} [all] Get annotations which only cover some of the fragment
 * @return {ve.dm.AnnotationSet} All annotation objects range is covered by
 */
ve.dm.SurfaceFragment.prototype.getAnnotations = function ( all ) {
	const selection = this.getSelection();

	if ( selection.isCollapsed() ) {
		return this.surface.getInsertionAnnotations();
	} else {
		let annotations = null;
		const ranges = selection.getRanges( this.getDocument() );
		for ( let i = 0, l = ranges.length; i < l; i++ ) {
			const rangeAnnotations = this.getDocument().data.getAnnotationsFromRange( ranges[ i ], all, true );
			if ( !rangeAnnotations ) {
				continue;
			}
			if ( !annotations ) {
				annotations = rangeAnnotations;
			} else if ( all ) {
				annotations.addSet( rangeAnnotations );
			} else {
				const matchingAnnotations = rangeAnnotations.getComparableAnnotationsFromSet( annotations );
				if ( matchingAnnotations.isEmpty() ) {
					// Nothing matched so our intersection is empty
					annotations = matchingAnnotations;
					break;
				} else {
					// Match in the other direction, to keep all distinct compatible annotations (e.g. both b and strong)
					annotations = annotations.getComparableAnnotationsFromSet( rangeAnnotations );
					annotations.addSet( matchingAnnotations );
				}
			}
		}
		return annotations || new ve.dm.AnnotationSet( this.getDocument().getStore() );
	}
};

/**
 * Check if the fragment has any annotations
 *
 * Quicker than doing !fragment.getAnnotations( true ).isEmpty() as
 * it stops at the first sight of an annotation.
 *
 * @return {boolean} The fragment contains at least one annotation
 */
ve.dm.SurfaceFragment.prototype.hasAnnotations = function () {
	const ranges = this.getSelection().getRanges( this.getDocument() );

	for ( let i = 0, l = ranges.length; i < l; i++ ) {
		if ( this.getDocument().data.hasAnnotationsInRange( ranges[ i ] ) ) {
			return true;
		}
	}
	return false;
};

/**
 * Get all leaf nodes covered by the fragment.
 *
 * @see {@link ve.Document#selectNodes} for more information about the return value.
 *
 * @return {Array} List of nodes and related information
 */
ve.dm.SurfaceFragment.prototype.getLeafNodes = function () {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return [];
	}

	// Update in case the cache needs invalidating
	this.update();
	// Cache leafNodes because it's expensive to compute
	if ( !this.leafNodes ) {
		this.leafNodes = this.document.selectNodes( range, 'leaves' );
	}
	return this.leafNodes;
};

/**
 * Get all leaf nodes excluding nodes where the selection is empty.
 *
 * @return {ve.dm.Node[]} List of nodes and related information
 */
ve.dm.SurfaceFragment.prototype.getSelectedLeafNodes = function () {
	const selectedLeafNodes = [],
		leafNodes = this.getLeafNodes();
	for ( let i = 0, len = leafNodes.length; i < len; i++ ) {
		if ( len === 1 || !leafNodes[ i ].range || !leafNodes[ i ].range.isCollapsed() ) {
			selectedLeafNodes.push( leafNodes[ i ].node );
		}
	}
	return selectedLeafNodes;
};

/**
 * Get the node selected by a range, i.e. the range matches the node's range exactly.
 *
 * Note that this method operates on the fragment's range, not the document's current selection.
 * This fragment does not need to be selected for this method to work.
 *
 * @return {ve.dm.Node|null} The node selected by the range, or null if a node is not selected
 */
ve.dm.SurfaceFragment.prototype.getSelectedNode = function () {
	const surface = this.getSurface();

	// Ensure the fragment is up to date
	this.update();
	return this.selection.equals( surface.getSelection() ) ?
		// If the selection is equal to the surface's use the cached node
		surface.getSelectedNode() :
		surface.getSelectedNodeFromSelection( this.selection );
};

/**
 * Get nodes covered by the fragment.
 *
 * Does not descend into nodes that are entirely covered by the range. The result is
 * similar to that of {ve.dm.SurfaceFragment.prototype.getLeafNodes} except that if a node is
 * entirely covered, its children aren't returned separately.
 *
 * @see {@link ve.Document#selectNodes} for more information about the return value.
 *
 * @return {Array} List of nodes and related information
 */
ve.dm.SurfaceFragment.prototype.getCoveredNodes = function () {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return [];
	}
	return this.document.selectNodes( range, 'covered' );
};

/**
 * Get nodes covered by the fragment.
 *
 * Includes adjacent siblings covered by the range, descending if the range is in a single node.
 *
 * @see {@link ve.Document#selectNodes} for more information about the return value.
 *
 * @return {Array} List of nodes and related information
 */
ve.dm.SurfaceFragment.prototype.getSiblingNodes = function () {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return [];
	}
	return this.document.selectNodes( range, 'siblings' );
};

/**
 * Check if the nodes at the current fragment have an ancestor with matching type and attribute values.
 *
 * @param {string} type Node type to match
 * @param {Object} [attributes] Node attributes to match
 * @param {boolean} [matchFirstAncestorOfType] Require the match to be the first of its type, e.g. if type is 'list',
 *  only match the first 'list' ancestor, then check if the attributes match.
 * @return {boolean} Nodes have a matching ancestor
 */
ve.dm.SurfaceFragment.prototype.hasMatchingAncestor = function ( type, attributes, matchFirstAncestorOfType ) {
	const selection = this.getSelection();

	let all;
	if ( selection instanceof ve.dm.LinearSelection ) {
		const nodes = this.getSelectedLeafNodes();
		all = !!nodes.length;
		for ( let i = 0, len = nodes.length; i < len; i++ ) {
			if ( matchFirstAncestorOfType ) {
				const node = nodes[ i ].findMatchingAncestor( type );
				if ( !( node && node.compareAttributes( attributes ) ) ) {
					all = false;
					break;
				}
			} else {
				if ( !nodes[ i ].hasMatchingAncestor( type, attributes ) ) {
					all = false;
					break;
				}
			}
		}
	} else if ( selection instanceof ve.dm.TableSelection ) {
		const cells = selection.getMatrixCells( this.getDocument() );
		all = true;
		for ( let j = cells.length - 1; j >= 0; j-- ) {
			if ( !cells[ j ].node.matches( type, attributes ) ) {
				all = false;
				break;
			}
		}
	}

	return all;
};

/**
 * Clear a fragment's pending list
 */
ve.dm.SurfaceFragment.prototype.clearPending = function () {
	this.pending = [];
};

/**
 * Push a promise to the fragment's pending list
 *
 * @param {jQuery.Promise} promise
 */
ve.dm.SurfaceFragment.prototype.pushPending = function ( promise ) {
	this.pending.push( promise );
};

/**
 * Get a promise that resolves when the pending list is complete
 *
 * @return {jQuery.Promise}
 */
ve.dm.SurfaceFragment.prototype.getPending = function () {
	return ve.promiseAll( this.pending );
};

/**
 * Apply the fragment's range to the surface as a selection.
 *
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.select = function () {
	this.surface.setSelection( this.getSelection() );
	return this;
};

/**
 * Change one or more attributes on covered nodes.
 *
 * @param {Object} attr List of attributes to change, use undefined to remove an attribute
 * @param {string} [type] Node type to restrict changes to
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.changeAttributes = function ( attr, type ) {
	const txs = [],
		covered = this.getCoveredNodes();

	for ( let i = 0, len = covered.length; i < len; i++ ) {
		const result = covered[ i ];
		if (
			// Non-wrapped nodes have no attributes
			!result.node.isWrapped() ||
			// Filtering by node type
			( type && result.node.getType() !== type ) ||
			// Ignore zero-length results
			( result.range && result.range.isCollapsed() )
		) {
			continue;
		}
		txs.push(
			ve.dm.TransactionBuilder.static.newFromAttributeChanges(
				this.document, result.nodeOuterRange.start, attr
			)
		);
	}
	if ( txs.length ) {
		this.change( txs );
	}
	return this;
};

/**
 * Apply an annotation to content in the fragment.
 *
 * To avoid problems identified in T35108, use the {ve.dm.SurfaceFragment.trimLinearSelection} method.
 *
 * TODO: Optionally take an annotation set instead of name and data arguments and set/clear multiple
 * annotations in a single transaction.
 *
 * @param {string} method Mode of annotation, either 'set' or 'clear'
 * @param {string|ve.dm.Annotation|ve.dm.AnnotationSet} nameOrAnnotations Annotation name, for example: 'textStyle/bold',
 *  Annotation object or AnnotationSet
 * @param {Object} [data] Additional annotation data (not used if annotation object is given)
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.annotateContent = function ( method, nameOrAnnotations, data ) {
	const ranges = this.getSelection().getRanges( this.getDocument() ),
		txs = [];

	let annotations = new ve.dm.AnnotationSet( this.getDocument().getStore() );
	if ( nameOrAnnotations instanceof ve.dm.AnnotationSet ) {
		annotations = nameOrAnnotations;
	} else if ( nameOrAnnotations instanceof ve.dm.Annotation ) {
		annotations.push( nameOrAnnotations );
	} else {
		const annotation = ve.dm.annotationFactory.create( nameOrAnnotations, data );
		if ( method === 'set' ) {
			annotations.push( annotation );
		} else if ( method === 'clear' ) {
			for ( let i = 0, ilen = ranges.length; i < ilen; i++ ) {
				annotations.addSet(
					this.document.data.getAnnotationsFromRange( ranges[ i ], true ).getAnnotationsByName( annotation.name )
				);
			}
		}
	}
	for ( let i = 0, ilen = ranges.length; i < ilen; i++ ) {
		const range = ranges[ i ];
		if ( !range.isCollapsed() ) {
			// Apply to selection
			for ( let j = 0, jlen = annotations.getLength(); j < jlen; j++ ) {
				const tx = ve.dm.TransactionBuilder.static.newFromAnnotation( this.document, range, method, annotations.get( j ) );
				txs.push( tx );
			}
		} else {
			// Apply annotation to stack
			if ( method === 'set' ) {
				this.surface.addInsertionAnnotations( annotations );
			} else if ( method === 'clear' ) {
				this.surface.removeInsertionAnnotations( annotations );
			}
		}
	}
	this.change( txs );

	return this;
};

/**
 * Remove content in the fragment and insert content before it.
 *
 * This will move the fragment's range to cover the inserted content. Note that this may be
 * different from what a normal range translation would do: the insertion might occur
 * at a different offset if that is needed to make the document balanced.
 *
 * If the content is a plain text string containing linebreaks, each line will be wrapped
 * in a paragraph.
 *
 * @param {string|Array} content Content to insert, can be either a string or array of data
 * @param {boolean|ve.dm.AnnotationSet} [annotateOrSet] Content should be automatically annotated to match surrounding content,
 *  or an AnnotationSet from the current offset (calculated from the view)
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.insertContent = function ( content, annotateOrSet ) {
	const range = this.getSelection().getCoveringRange(),
		doc = this.getDocument();

	if ( !range ) {
		return this;
	}

	let annotations, annotate;
	if ( annotateOrSet instanceof ve.dm.AnnotationSet ) {
		annotations = annotateOrSet;
		annotate = true;
	} else {
		annotate = annotateOrSet;
	}

	if ( !range.isCollapsed() ) {
		if ( annotate ) {
			// If we're replacing content, use the annotations selected
			// instead of continuing from the left
			annotations = this.getAnnotations();
		}
		this.removeContent();
	}

	const offset = range.start;
	// Auto-convert content to array of plain text characters
	if ( typeof content === 'string' ) {
		const lines = content.split( /[\r\n]+/ );

		if ( lines.length > 1 ) {
			content = [];
			for ( let i = 0, l = lines.length; i < l; i++ ) {
				if ( lines[ i ].length ) {
					content.push( { type: 'paragraph' } );
					ve.batchPush( content, lines[ i ].split( '' ) );
					content.push( { type: '/paragraph' } );
				}
			}
		} else {
			content = content.split( '' );
		}
	}
	if ( content.length ) {
		if ( annotate && !annotations ) {
			// TODO T126021: Don't reach into properties of document
			// FIXME T126022: the logic we actually need for annotating inserted content
			// correctly is much more complicated
			annotations = doc.data
				.getAnnotationsFromOffset( offset === 0 ? 0 : offset - 1 );
		}
		if ( annotations && annotations.getLength() > 0 ) {
			// Add the annotations to the content, passing the
			// replaceComparable argument which will remove any comparable
			// annotations from content so that they can be replaced with
			// those in annotations. This has the effect of not double-
			// wrapping any annotations which are identical apart from their
			// original DOM element so we don't generate markup like
			// <b>f<b>o</b>o</b> when pasting.
			ve.dm.Document.static.addAnnotationsToData( content, annotations, true, doc.store );
		}
		const tx = ve.dm.TransactionBuilder.static.newFromInsertion( doc, offset, content );
		// Set the range to cover the inserted content; the offset translation will be wrong
		// if newFromInsertion() decided to move the insertion point
		const newRange = tx.getModifiedRange( doc );
		this.change( tx, newRange ? new ve.dm.LinearSelection( newRange ) : new ve.dm.NullSelection() );
	}

	return this;
};

/**
 * Insert HTML in the fragment.
 *
 * This will move the fragment's range to cover the inserted content. Note that this may be
 * different from what a normal range translation would do: the insertion might occur
 * at a different offset if that is needed to make the document balanced.
 *
 * @param {string} html HTML to insert
 * @param {Object} [importRules] The import rules for the target surface, if importing
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.insertHtml = function ( html, importRules ) {
	this.insertDocument( this.getDocument().newFromHtml( html, importRules ) );
	return this;
};

/**
 * Insert a ve.dm.Document in the fragment.
 *
 * This will move the fragment's range to cover the inserted content. Note that this may be
 * different from what a normal range translation would do: the insertion might occur
 * at a different offset if that is needed to make the document balanced.
 *
 * @param {ve.dm.Document} newDoc Document to insert
 * @param {ve.Range} [newDocRange] Range from the new document to insert (defaults to entire document)
 * @param {boolean|ve.dm.AnnotationSet} [annotateOrSet] Content should be automatically annotated to match surrounding content,
 *  or an AnnotationSet from the current offset (calculated from the view)
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.insertDocument = function ( newDoc, newDocRange, annotateOrSet ) {
	const range = this.getSelection().getCoveringRange(),
		doc = this.getDocument();

	if ( !range ) {
		return this;
	}

	let annotations, annotate;
	if ( annotateOrSet instanceof ve.dm.AnnotationSet ) {
		annotations = annotateOrSet;
		annotate = true;
	} else {
		annotate = annotateOrSet;
	}

	if ( !range.isCollapsed() ) {
		if ( annotate ) {
			// If we're replacing content, use the annotations selected
			// instead of continuing from the left
			annotations = this.getAnnotations();
		}
		this.removeContent();
	}

	const offset = range.start;
	if ( annotate && !annotations ) {
		// TODO T126021: Don't reach into properties of document
		annotations = doc.data
			.getAnnotationsFromOffset( offset === 0 ? 0 : offset - 1 );
	}

	let annotatedDoc;
	if ( !annotations || annotations.getLength() === 0 ) {
		annotatedDoc = newDoc;
	} else {
		// Build shallow-cloned annotatedData array, copying on write as we go
		// FIXME T126022: the logic we actually need for annotating inserted content
		// correctly is much more complicated
		const annotatedData = newDoc.data.slice();
		ve.dm.Document.static.addAnnotationsToData( annotatedData, annotations, true, newDoc.store, true );
		annotatedDoc = newDoc.cloneWithData( annotatedData );
	}
	const tx = ve.dm.TransactionBuilder.static.newFromDocumentInsertion( doc, offset, annotatedDoc, newDocRange );
	if ( !tx.isNoOp() ) {
		// Set the range to cover the inserted content; the offset translation will be wrong
		// if newFromInsertion() decided to move the insertion point
		const newRange = tx.getModifiedRange( doc );
		this.change( tx, newRange ? new ve.dm.LinearSelection( newRange ) : new ve.dm.NullSelection() );
	}
	return this;
};

/**
 * Remove content in the fragment.
 *
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.removeContent = function () {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return this;
	}

	if ( !range.isCollapsed() ) {
		this.change( ve.dm.TransactionBuilder.static.newFromRemoval( this.document, range ) );
	}

	return this;
};

/**
 * Delete content and correct selection
 *
 * @param {number} [directionAfterDelete=-1] Direction to move after delete: 1 or -1 or 0
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.delete = function ( directionAfterDelete ) {
	const rangeToRemove = this.getSelection().getCoveringRange();

	if ( !rangeToRemove || rangeToRemove.isCollapsed() ) {
		return this;
	}

	// Try to build a removal transaction. At the moment the transaction processor is only
	// capable of merging nodes of the same type and at the same depth level, so some or all
	// of rangeToRemove may be left untouched (and in some cases tx may not remove anything
	// at all).
	let tx = ve.dm.TransactionBuilder.static.newFromRemoval( this.document, rangeToRemove );
	this.change( tx );
	let rangeAfterRemove = tx.translateRange( rangeToRemove );

	let endNode;
	if (
		!rangeAfterRemove.isCollapsed() &&
		( endNode = this.document.getBranchNodeFromOffset( rangeAfterRemove.end, false ) ) &&
		// If endNode is within our rangeAfterRemove, then we shouldn't delete it
		endNode.getRange().start >= rangeAfterRemove.end
	) {
		// If after processing removal transaction range is not collapsed it means that
		// not everything got merged nicely, so we process further to deal with
		// remaining content.

		let startNode = this.document.getBranchNodeFromOffset( rangeAfterRemove.start, false );
		if ( startNode.getRange().isCollapsed() ) {
			// If startNode has no content then just delete that node instead of
			// moving content from endNode to startNode. This prevents content being
			// inserted into empty structure, e.g. and empty heading will be deleted
			// rather than "converting" the paragraph beneath to a heading.
			while ( true ) {
				tx = ve.dm.TransactionBuilder.static.newFromRemoval( this.document, startNode.getOuterRange() );
				startNode = startNode.getParent();
				this.change( tx );

				// If the removal resulted in the parent node being empty (e.g.
				// when startNode was a paragraph inside a list item), loop to
				// delete the parent node. Else break.
				if ( !( startNode && startNode.children.length === 0 && (
					startNode.hasSlugAtOffset( startNode.getRange().start ) ||
					// These would be uneditable when empty, so remove
					startNode instanceof ve.dm.DefinitionListNode ||
					startNode instanceof ve.dm.ListNode
				) && startNode.canHaveChildrenNotContent() ) ) {
					break;
				}
				// Only fix up the range if we're going to loop (if we're not, the
				// range collapse using getNearestContentOffset below will already
				// do the fix up).
				rangeAfterRemove = tx.translateRange( rangeAfterRemove );
			}
		} else {
			// If startNode has content then take remaining content from endNode and
			// append it into startNode. Then remove endNode (and recursively any
			// ancestor that the removal causes to be empty).
			const endNodeData = this.document.getData( endNode.getRange() );
			let nodeToDelete = endNode;
			nodeToDelete.traverseUpstream( ( node ) => {
				const parent = node.getParent();
				if ( parent.children.length === 1 ) {
					nodeToDelete = parent;
					return true;
				} else {
					return false;
				}
			} );
			tx = ve.dm.TransactionBuilder.static.newFromRemoval(
				this.document,
				nodeToDelete.getOuterRange()
			);
			if ( !tx.isNoOp() ) {
				// Move contents of endNode into startNode, and delete nodeToDelete
				this.change( tx );
				this.change( ve.dm.TransactionBuilder.static.newFromInsertion(
					this.document,
					rangeAfterRemove.start,
					endNodeData
				) );
			}
		}
	}

	// Use a collapsed range at a content offset beside rangeAfterRemove.start
	const nearestOffset = this.document.data.getNearestContentOffset(
		rangeAfterRemove.start,
		// If undefined (e.g. cut), default to backwards movement
		directionAfterDelete || -1
	);
	if ( nearestOffset > -1 ) {
		rangeAfterRemove = new ve.Range( nearestOffset );
	} else {
		// There isn't a valid content offset. This probably means that we're
		// in a strange document which consists entirely of aliens, with no
		// text entered. This is unusual, but not impossible. As such, just
		// collapse the selection and accept that it won't really be
		// meaningful in most cases.
		rangeAfterRemove = new ve.Range( rangeAfterRemove.start );
	}

	this.change( [], new ve.dm.LinearSelection( rangeAfterRemove ) );

	return this;
};

/**
 * Convert each content branch in the fragment from one type to another.
 *
 * @param {string} type Element type to convert to
 * @param {Object} [attr] Initial attributes for new element
 * @param {Object} [internal] Internal attributes for new element
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.convertNodes = function ( type, attr, internal ) {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return this;
	}

	this.change( ve.dm.TransactionBuilder.static.newFromContentBranchConversion(
		this.document, range, type, attr, internal
	) );

	return this;
};

/**
 * Wrap each node in the fragment with one or more elements.
 *
 * A wrapper object is a linear model element; a plain object containing a type property and an
 * optional attributes property.
 *
 * Example:
 *     // fragment is a selection of: <p>a</p><p>b</p>
 *     fragment.wrapNodes(
 *         [{ type: 'list', attributes: { style: 'bullet' } }, { type: 'listItem' }]
 *     )
 *     // fragment is now a selection of: <ul><li><p>a</p></li></ul><ul><li><p>b</p></li></ul>
 *
 * @param {Object|Object[]} wrapper Wrapper object, or array of wrapper objects (see above)
 * @param {string} wrapper.type Node type of wrapper
 * @param {Object} [wrapper.attributes] Attributes of wrapper
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.wrapNodes = function ( wrapper ) {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return this;
	}

	if ( !Array.isArray( wrapper ) ) {
		wrapper = [ wrapper ];
	}
	this.change(
		ve.dm.TransactionBuilder.static.newFromWrap( this.document, range, [], [], [], wrapper )
	);

	return this;
};

/**
 * Unwrap nodes in the fragment out of one or more elements.
 *
 * Example:
 *     // fragment is a selection of: <ul>「<li><p>a</p></li><li><p>b</p></li>」</ul>
 *     fragment.unwrapNodes( 1, 1 )
 *     // fragment is now a selection of: 「<p>a</p><p>b</p>」
 *
 * @param {number} outerDepth Number of nodes outside the selection to unwrap
 * @param {number} innerDepth Number of nodes inside the selection to unwrap
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.unwrapNodes = function ( outerDepth, innerDepth ) {
	const range = this.getSelection().getCoveringRange();

	if ( !range ) {
		return this;
	}

	if ( range.getLength() < innerDepth * 2 ) {
		throw new Error( 'cannot unwrap by greater depth than maximum theoretical depth of selection' );
	}

	let i;
	const innerUnwrapper = [];
	const outerUnwrapper = [];
	for ( i = 0; i < innerDepth; i++ ) {
		innerUnwrapper.push( this.document.data.getData( range.start + i ) );
	}
	for ( i = outerDepth; i > 0; i-- ) {
		outerUnwrapper.push( this.document.data.getData( range.start - i ) );
	}

	this.change( ve.dm.TransactionBuilder.static.newFromWrap(
		this.document, range, outerUnwrapper, [], innerUnwrapper, []
	) );

	return this;
};

/**
 * Change the wrapping of each node in the fragment from one type to another.
 *
 * A wrapper object is a linear model element; a plain object containing a type property and an
 * optional attributes property.
 *
 * Example:
 *     // fragment is a selection of: <dl><dt><p>a</p></dt></dl><dl><dt><p>b</p></dt></dl>
 *     fragment.rewrapNodes(
 *         2,
 *         [{ type: 'list', attributes: { style: 'bullet' } }, { type: 'listItem' }]
 *     )
 *     // fragment is now a selection of: <ul><li><p>a</p></li></ul><ul><li><p>b</p></li></ul>
 *
 * @param {number} depth Number of nodes to unwrap
 * @param {Object|Object[]} wrapper Wrapper object, or array of wrapper objects (see above)
 * @param {string} wrapper.type Node type of wrapper
 * @param {Object} [wrapper.attributes] Attributes of wrapper
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.rewrapNodes = function ( depth, wrapper ) {
	const range = this.getSelection().getCoveringRange();

	if ( !range ) {
		return this;
	}

	if ( !Array.isArray( wrapper ) ) {
		wrapper = [ wrapper ];
	}

	if ( range.getLength() < depth * 2 ) {
		throw new Error( 'cannot unwrap by greater depth than maximum theoretical depth of selection' );
	}

	const unwrapper = [];
	for ( let i = 0; i < depth; i++ ) {
		unwrapper.push( this.document.data.getData( range.start + i ) );
	}

	this.change(
		ve.dm.TransactionBuilder.static.newFromWrap( this.document, range, [], [], unwrapper, wrapper )
	);

	return this;
};

/**
 * Wrap nodes in the fragment with one or more elements.
 *
 * A wrapper object is a linear model element; a plain object containing a type property and an
 * optional attributes property.
 *
 * Example:
 *     // fragment is a selection of: <p>a</p><p>b</p>
 *     fragment.wrapAllNodes(
 *         { type: 'list', attributes: { style: 'bullet' } },
 *         { type: 'listItem' }
 *     )
 *     // fragment is now a selection of: <ul><li><p>a</p></li><li><p>b</p></li></ul>
 *
 * Example:
 *     // fragment is a selection of: <p>a</p><p>b</p>
 *     fragment.wrapAllNodes(
 *         [{ type: 'list', attributes: { style: 'bullet' } }, { type: 'listItem' }]
 *     )
 *     // fragment is now a selection of: <ul><li><p>a</p><p>b</p></li></ul>
 *
 * @param {Object|Object[]} wrapOuter Opening element(s) to wrap around the range
 * @param {Object|Object[]} wrapEach Opening element(s) to wrap around each top-level element in the range
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.wrapAllNodes = function ( wrapOuter, wrapEach ) {
	const range = this.getSelection().getCoveringRange();
	if ( !range ) {
		return this;
	}

	if ( !Array.isArray( wrapOuter ) ) {
		wrapOuter = [ wrapOuter ];
	}

	wrapEach = wrapEach || [];

	if ( !Array.isArray( wrapEach ) ) {
		wrapEach = [ wrapEach ];
	}

	this.change(
		ve.dm.TransactionBuilder.static.newFromWrap( this.document, range, [], wrapOuter, [], wrapEach )
	);

	return this;
};

/**
 * Change the wrapping of nodes in the fragment from one type to another.
 *
 * A wrapper object is a linear model element; a plain object containing a type property and an
 * optional attributes property.
 *
 * Example:
 *     // fragment is a selection of: <h1><p>a</p><p>b</p></h1>
 *     fragment.rewrapAllNodes( 1, { type: 'heading', attributes: { level: 2 } } );
 *     // fragment is now a selection of: <h2><p>a</p><p>b</p></h2>
 *
 * @param {number} depth Number of nodes to unwrap
 * @param {Object|Object[]} wrapper Wrapper object, or array of wrapper objects (see above)
 * @param {string} wrapper.type Node type of wrapper
 * @param {Object} [wrapper.attributes] Attributes of wrapper
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.rewrapAllNodes = function ( depth, wrapper ) {
	const range = this.getSelection().getCoveringRange(),
		unwrapper = [];

	if ( !range ) {
		return this;
	}

	// TODO: preserve direction
	const innerRange = new ve.Range(
		range.start + depth,
		range.end - depth
	);

	if ( !Array.isArray( wrapper ) ) {
		wrapper = [ wrapper ];
	}

	if ( range.getLength() < depth * 2 ) {
		throw new Error( 'cannot unwrap by greater depth than maximum theoretical depth of selection' );
	}

	for ( let i = 0; i < depth; i++ ) {
		unwrapper.push( this.document.data.getData( range.start + i ) );
	}

	this.change(
		ve.dm.TransactionBuilder.static.newFromWrap( this.document, innerRange, unwrapper, wrapper, [], [] )
	);

	return this;
};

/**
 * Isolates the nodes in a fragment then unwraps them.
 *
 * The node selection is expanded to siblings. These are isolated such that they are the
 * sole children of the nearest parent element which can 'type' can exist in.
 *
 * The new isolated selection is then safely unwrapped.
 *
 * @param {string} isolateForType Node type to isolate for
 * @return {ve.dm.SurfaceFragment}
 * @chainable
 */
ve.dm.SurfaceFragment.prototype.isolateAndUnwrap = function ( isolateForType ) {
	const insertions = [],
		factory = ve.dm.nodeFactory,
		startSplitNodes = [],
		endSplitNodes = [];
	let startOffset, endOffset,
		outerDepth = 0,
		startSplitRequired = false,
		endSplitRequired = false;

	function createSplits( splitNodes, insertBefore ) {
		const data = [];

		let adjustment = 0;
		for ( let i = 0, length = splitNodes.length; i < length; i++ ) {
			data.unshift( { type: '/' + splitNodes[ i ].type } );
			data.push( splitNodes[ i ].getClonedElement() );

			if ( insertBefore ) {
				adjustment += 2;
			}
		}

		// Queue up transaction data
		insertions.push( {
			offset: insertBefore ? startOffset : endOffset,
			data: data
		} );

		startOffset += adjustment;
		endOffset += adjustment;
	}

	if ( !( this.selection instanceof ve.dm.LinearSelection ) ) {
		return this;
	}

	const allowedParents = factory.getSuggestedParentNodeTypes( isolateForType );
	const nodes = this.getSiblingNodes();

	// Find start split point, if required
	let startSplitNode = nodes[ 0 ].node;
	startOffset = startSplitNode.getOuterRange().start;
	if ( allowedParents !== null ) {
		while ( allowedParents.indexOf( startSplitNode.getParent().type ) === -1 ) {
			if ( startSplitNode.getParent().indexOf( startSplitNode ) > 0 ) {
				startSplitRequired = true;
			}
			startSplitNode = startSplitNode.getParent();
			if ( startSplitRequired ) {
				startSplitNodes.unshift( startSplitNode );
			} else {
				startOffset = startSplitNode.getOuterRange().start;
			}
			outerDepth++;
		}
	}

	// Find end split point, if required
	let endSplitNode = nodes[ nodes.length - 1 ].node;
	endOffset = endSplitNode.getOuterRange().end;
	if ( allowedParents !== null ) {
		while ( allowedParents.indexOf( endSplitNode.getParent().type ) === -1 ) {
			if ( endSplitNode.getParent().indexOf( endSplitNode ) < endSplitNode.getParent().getChildren().length - 1 ) {
				endSplitRequired = true;
			}
			endSplitNode = endSplitNode.getParent();
			if ( endSplitRequired ) {
				endSplitNodes.unshift( endSplitNode );
			} else {
				endOffset = endSplitNode.getOuterRange().end;
			}
		}
	}

	// We have to exclude insertions while doing splits, because we want the range to be
	// exactly what we're isolating, we don't want it to grow to include the separators
	// we're inserting (which would happen if one of them is immediately adjacent to the range)
	const oldExclude = this.willExcludeInsertions();
	this.setExcludeInsertions( true );

	if ( startSplitRequired ) {
		createSplits( startSplitNodes, true );
	}

	if ( endSplitRequired ) {
		createSplits( endSplitNodes, false );
	}

	insertions.forEach( ( insertion ) => {
		this.change(
			ve.dm.TransactionBuilder.static.newFromInsertion( this.getDocument(), insertion.offset, insertion.data )
		);
	} );

	this.setExcludeInsertions( oldExclude );

	this.unwrapNodes( outerDepth, 0 );

	return this;
};

/**
 * Insert new metadata into the document. This builds and processes a transaction that inserts
 * metadata into the document.
 *
 * Pass a plain object rather than a MetaItem into this function unless you know what you're doing.
 *
 * @param {Object|ve.dm.MetaItem} meta Metadata element (or MetaItem) to insert
 * @param {number} offset Document offset to insert at; must be a valid offset for metadata;
 * defaults to document end
 */
ve.dm.SurfaceFragment.prototype.insertMeta = function ( meta, offset ) {
	if ( arguments[ 2 ] !== undefined ) {
		throw new Error( 'Old "index" argument is no longer supported' );
	}
	if ( meta instanceof ve.dm.MetaItem ) {
		meta = meta.getElement();
	}
	const closeMeta = { type: '/' + meta.type };
	const doc = this.getDocument();
	if ( offset === undefined ) {
		offset = doc.getDocumentRange().end;
	}
	const tx = ve.dm.TransactionBuilder.static.newFromInsertion( doc, offset, [ meta, closeMeta ] );
	this.surface.change( tx );
};

/**
 * Remove a meta item from the document. This builds and processes a transaction that removes the
 * associated metadata from the document.
 *
 * @param {ve.dm.MetaItem} item Item to remove
 */
ve.dm.SurfaceFragment.prototype.removeMeta = function ( item ) {
	const tx = ve.dm.TransactionBuilder.static.newFromRemoval(
		this.getDocument(),
		item.getOuterRange(),
		true
	);
	this.surface.change( tx );
};

/**
 * Replace a MetaItem with another in-place.
 *
 * Pass a plain object rather than a MetaItem into this function unless you know what you're doing.
 *
 * @param {ve.dm.MetaItem} oldItem Old item to replace
 * @param {Object|ve.dm.MetaItem} meta Metadata element (or MetaItem) to insert
 */
ve.dm.SurfaceFragment.prototype.replaceMeta = function ( oldItem, meta ) {
	if ( meta instanceof ve.dm.MetaItem ) {
		meta = meta.getElement();
	}
	const closeMeta = { type: '/' + meta.type };
	const tx = ve.dm.TransactionBuilder.static.newFromReplacement(
		this.getDocument(),
		oldItem.getOuterRange(),
		[ meta, closeMeta ],
		true
	);
	this.surface.change( tx );
};