%PDF- %PDF-
Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/dm/ |
Current File : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/src/dm/ve.dm.SurfaceFragment.js |
/*! * VisualEditor DataModel Fragment class. * * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org */ /** * 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] 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; // 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 ) { var i, len, nodes, selectedNode, annotations; // Handle null selection if ( this.isNull() ) { return []; } annotations = this.getAnnotations( all ); // Filter out nodes with collapsed ranges if ( all ) { nodes = this.getCoveredNodes(); for ( 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 = []; 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. * * @method * @param {ve.dm.Selection} [selection] Optional selection to set * @chainable */ ve.dm.SurfaceFragment.prototype.update = function ( selection ) { var txs; // 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 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. * * @method * @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. * * @method * @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. * * @method */ ve.dm.SurfaceFragment.prototype.getSelection = function () { this.update(); return this.selection; }; /** * Check if the fragment is null. * * @method * @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. * * @method * @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. * * @method * @param {boolean} [autoSelect=true] Automatically update surface selection * @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 * @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 * * @method * @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 ) { var newRange, oldRange; if ( !( this.selection instanceof ve.dm.LinearSelection ) ) { return this.clone(); } oldRange = this.getSelection().getRange(); newRange = oldRange && new ve.Range( oldRange.start + ( start || 0 ), oldRange.end + ( end || 0 ) ); return this.clone( new ve.dm.LinearSelection( this.getDocument(), newRange ) ); }; /** * Get a new fragment with a truncated length. * * @method * @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 ) { var range; if ( !( this.selection instanceof ve.dm.LinearSelection ) ) { return this.clone(); } range = this.getSelection().getRange(); return this.clone( new ve.dm.LinearSelection( this.getDocument(), range.truncate( limit ) ) ); }; /** * Get a new fragment with a zero-length selection at the start offset. * * @method * @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. * * @method * @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. * * @method * @return {ve.dm.SurfaceFragment} Trimmed fragment */ ve.dm.SurfaceFragment.prototype.trimLinearSelection = function () { var oldRange, newRange; if ( !( this.selection instanceof ve.dm.LinearSelection ) ) { return this.clone(); } oldRange = this.getSelection().getRange(); newRange = oldRange; 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( this.getDocument(), newRange ) ); }; /** * Get a new fragment that covers an expanded range of the document. * * @method * @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 (argument) 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 {Mixed} [type] Parameter to use with scope method if needed * @return {ve.dm.SurfaceFragment} Expanded fragment */ ve.dm.SurfaceFragment.prototype.expandLinearSelection = function ( scope, type ) { var node, nodes, parent, newRange, oldRange; if ( !( this.selection instanceof ve.dm.LinearSelection ) ) { return this.clone(); } oldRange = this.getSelection().getRange(); 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 = new ve.Range( 0, this.getDocument().getInternalList().getListNode().getOuterRange().start ); 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( this.getDocument(), newRange ) : new ve.dm.NullSelection( this.getDocument() ) ); }; /** * Get data for the fragment. * * @method * @param {boolean} [deep] Get a deep copy of the data * @return {Array} Fragment data */ ve.dm.SurfaceFragment.prototype.getData = function ( deep ) { var range = this.getSelection().getCoveringRange(); if ( !range ) { return []; } return this.document.getData( range, deep ); }; /** * Get plain text for the fragment. * * @method * @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 ) { var range = this.getSelection().getCoveringRange(); if ( !range ) { return ''; } return this.document.data.getText( maintainIndices, range ); }; /** * 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. * * @method * @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 ) { var i, l, ranges, rangeAnnotations, matchingAnnotations, selection = this.getSelection(), annotations = new ve.dm.AnnotationSet( this.getDocument().getStore() ); if ( selection.isCollapsed() ) { return this.surface.getInsertionAnnotations(); } else { ranges = selection.getRanges(); for ( i = 0, l = ranges.length; i < l; i++ ) { rangeAnnotations = this.getDocument().data.getAnnotationsFromRange( ranges[ i ], all ); if ( !i ) { // First range, annotations must be empty annotations = rangeAnnotations; } else if ( all ) { annotations.addSet( rangeAnnotations ); } else { 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; } }; /** * Check if the fragment has any annotations * * Quicker than doing !fragment.getAnnotations( true ).isEmpty() as * it stops at the first sight of an annotation. * * @method * @return {boolean} The fragment contains at least one annotation */ ve.dm.SurfaceFragment.prototype.hasAnnotations = function () { var i, l, ranges = this.getSelection().getRanges(); for ( 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 ve.Document#selectNodes Used to get the return value * * @method * @return {Array} List of nodes and related information */ ve.dm.SurfaceFragment.prototype.getLeafNodes = function () { var 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. * * @method * @return {Array} List of nodes and related information */ ve.dm.SurfaceFragment.prototype.getSelectedLeafNodes = function () { var i, len, selectedLeafNodes = [], leafNodes = this.getLeafNodes(); for ( i = 0, len = leafNodes.length; i < len; i++ ) { if ( len === 1 || !leafNodes[ i ].range || leafNodes[ i ].range.getLength() ) { 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 () { var 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 ve.Document#selectNodes for more information about the return value * * @method * @return {Array} List of nodes and related information */ ve.dm.SurfaceFragment.prototype.getCoveredNodes = function () { var 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 ve.Document#selectNodes for more information about the return value. * * @method * @return {Array} List of nodes and related information */ ve.dm.SurfaceFragment.prototype.getSiblingNodes = function () { var range = this.getSelection().getCoveringRange(); if ( !range ) { return []; } return this.document.selectNodes( range, 'siblings' ); }; /** * Apply the fragment's range to the surface as a selection. * * @method * @chainable */ ve.dm.SurfaceFragment.prototype.select = function () { this.surface.setSelection( this.getSelection() ); return this; }; /** * Change one or more attributes on covered nodes. * * @method * @param {Object} attr List of attributes to change, use undefined to remove an attribute * @param {string} [type] Node type to restrict changes to * @chainable */ ve.dm.SurfaceFragment.prototype.changeAttributes = function ( attr, type ) { var i, len, result, txs = [], covered = this.getCoveredNodes(); for ( i = 0, len = covered.length; i < len; i++ ) { 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.Transaction.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 bug 33108, 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. * * @method * @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) * @chainable */ ve.dm.SurfaceFragment.prototype.annotateContent = function ( method, nameOrAnnotations, data ) { var annotation, i, ilen, j, jlen, tx, range, annotations = new ve.dm.AnnotationSet( this.getDocument().getStore() ), ranges = this.getSelection().getRanges(), txs = []; if ( nameOrAnnotations instanceof ve.dm.AnnotationSet ) { annotations = nameOrAnnotations; } else if ( nameOrAnnotations instanceof ve.dm.Annotation ) { annotations.push( nameOrAnnotations ); } else { annotation = ve.dm.annotationFactory.create( nameOrAnnotations, data ); if ( method === 'set' ) { annotations.push( annotation ); } else if ( method === 'clear' ) { for ( i = 0, ilen = ranges.length; i < ilen; i++ ) { annotations.addSet( this.document.data.getAnnotationsFromRange( ranges[ i ], true ).getAnnotationsByName( annotation.name ) ); } } } for ( i = 0, ilen = ranges.length; i < ilen; i++ ) { range = ranges[ i ]; if ( !range.isCollapsed() ) { // Apply to selection for ( j = 0, jlen = annotations.getLength(); j < jlen; j++ ) { tx = ve.dm.Transaction.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. * * @method * @param {string|Array} content Content to insert, can be either a string or array of data * @param {boolean} [annotate] Content should be automatically annotated to match surrounding content * @chainable */ ve.dm.SurfaceFragment.prototype.insertContent = function ( content, annotate ) { var i, l, lines, annotations, tx, offset, newRange, range = this.getSelection().getCoveringRange(), doc = this.getDocument(); if ( !range ) { return this; } 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(); } offset = range.start; // Auto-convert content to array of plain text characters if ( typeof content === 'string' ) { lines = content.split( /[\r\n]+/ ); if ( lines.length > 1 ) { content = []; for ( i = 0, l = lines.length; i < l; i++ ) { if ( lines[ i ].length ) { content.push( { type: 'paragraph' } ); content = content.concat( 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 ) { ve.dm.Document.static.addAnnotationsToData( content, annotations ); } tx = ve.dm.Transaction.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 newRange = tx.getModifiedRange( doc ); this.change( tx, newRange ? new ve.dm.LinearSelection( doc, newRange ) : new ve.dm.NullSelection( doc ) ); } 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. * * @method * @param {string} html HTML to insert * @param {Object} [importRules] The import rules for the target surface, if importing * @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. * * @method * @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} [annotate] Content should be automatically annotated to match surrounding content * @chainable */ ve.dm.SurfaceFragment.prototype.insertDocument = function ( newDoc, newDocRange, annotate ) { var tx, newRange, annotations, offset, range = this.getSelection().getCoveringRange(), doc = this.getDocument(); if ( !range ) { return this; } 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(); } offset = range.start; 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 ); } tx = ve.dm.Transaction.newFromDocumentInsertion( doc, offset, newDoc, 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 newRange = tx.getModifiedRange( doc ); this.change( tx, newRange ? new ve.dm.LinearSelection( doc, newRange ) : new ve.dm.NullSelection( doc ) ); if ( annotations && annotations.getLength() > 0 ) { this.annotateContent( 'set', annotations ); } } return this; }; /** * Remove content in the fragment. * * @method * @chainable */ ve.dm.SurfaceFragment.prototype.removeContent = function () { var range = this.getSelection().getCoveringRange(); if ( !range ) { return this; } if ( !range.isCollapsed() ) { this.change( ve.dm.Transaction.newFromRemoval( this.document, range ) ); } return this; }; /** * Delete content and correct selection * * @method * @param {number} [directionAfterDelete=-1] Direction to move after delete: 1 or -1 or 0 * @chainable */ ve.dm.SurfaceFragment.prototype.delete = function ( directionAfterDelete ) { var rangeAfterRemove, tx, startNode, endNode, endNodeData, nodeToDelete, nearestOffset, 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). tx = ve.dm.Transaction.newFromRemoval( this.document, rangeToRemove ); this.change( tx ); rangeAfterRemove = tx.translateRange( rangeToRemove ); 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. 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.Transaction.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). endNodeData = this.document.getData( endNode.getRange() ); nodeToDelete = endNode; nodeToDelete.traverseUpstream( function ( node ) { var parent = node.getParent(); if ( parent.children.length === 1 ) { nodeToDelete = parent; return true; } else { return false; } } ); tx = ve.dm.Transaction.newFromRemoval( this.document, nodeToDelete.getOuterRange() ); if ( !tx.isNoOp() ) { // Move contents of endNode into startNode, and delete nodeToDelete this.change( [ tx, ve.dm.Transaction.newFromInsertion( this.document, rangeAfterRemove.start, endNodeData ) ] ); } } } // Use a collapsed range at a content offset beside rangeAfterRemove.start 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( this.getDocument(), rangeAfterRemove ) ); return this; }; /** * Convert each content branch in the fragment from one type to another. * * @method * @param {string} type Element type to convert to * @param {Object} [attr] Initial attributes for new element * @chainable */ ve.dm.SurfaceFragment.prototype.convertNodes = function ( type, attr ) { var range = this.getSelection().getCoveringRange(); if ( !range ) { return this; } this.change( ve.dm.Transaction.newFromContentBranchConversion( this.document, range, type, attr ) ); 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> * * @method * @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 * @chainable */ ve.dm.SurfaceFragment.prototype.wrapNodes = function ( wrapper ) { var range = this.getSelection().getCoveringRange(); if ( !range ) { return this; } if ( !Array.isArray( wrapper ) ) { wrapper = [ wrapper ]; } this.change( ve.dm.Transaction.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>」 * * @method * @param {number} outerDepth Number of nodes outside the selection to unwrap * @param {number} innerDepth Number of nodes inside the selection to unwrap * @chainable */ ve.dm.SurfaceFragment.prototype.unwrapNodes = function ( outerDepth, innerDepth ) { var i, range = this.getSelection().getCoveringRange(), innerUnwrapper = [], outerUnwrapper = []; if ( !range ) { return this; } if ( range.getLength() < innerDepth * 2 ) { throw new Error( 'cannot unwrap by greater depth than maximum theoretical depth of selection' ); } for ( i = 0; i < innerDepth; i++ ) { innerUnwrapper.push( this.surface.getDocument().data.getData( range.start + i ) ); } for ( i = outerDepth; i > 0; i-- ) { outerUnwrapper.push( this.surface.getDocument().data.getData( range.start - i ) ); } this.change( ve.dm.Transaction.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> * * @method * @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 * @chainable */ ve.dm.SurfaceFragment.prototype.rewrapNodes = function ( depth, wrapper ) { var i, range = this.getSelection().getCoveringRange(), unwrapper = []; 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' ); } for ( i = 0; i < depth; i++ ) { unwrapper.push( this.surface.getDocument().data.getData( range.start + i ) ); } this.change( ve.dm.Transaction.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> * * @method * @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 * @chainable */ ve.dm.SurfaceFragment.prototype.wrapAllNodes = function ( wrapOuter, wrapEach ) { var 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.Transaction.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> * * @method * @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 * @chainable */ ve.dm.SurfaceFragment.prototype.rewrapAllNodes = function ( depth, wrapper ) { var i, innerRange, range = this.getSelection().getCoveringRange(), unwrapper = []; if ( !range ) { return this; } // TODO: preserve direction 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 ( i = 0; i < depth; i++ ) { unwrapper.push( this.surface.getDocument().data.getData( range.start + i ) ); } this.change( ve.dm.Transaction.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. * * @method * @param {string} isolateForType Node type to isolate for * @chainable */ ve.dm.SurfaceFragment.prototype.isolateAndUnwrap = function ( isolateForType ) { var nodes, startSplitNode, endSplitNode, startOffset, endOffset, oldExclude, allowedParents, insertions = [], outerDepth = 0, factory = ve.dm.nodeFactory, startSplitRequired = false, endSplitRequired = false, startSplitNodes = [], endSplitNodes = [], fragment = this; function createSplits( splitNodes, insertBefore ) { var i, length, adjustment = 0, data = []; for ( 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; } allowedParents = factory.getSuggestedParentNodeTypes( isolateForType ); nodes = this.getSiblingNodes(); // Find start split point, if required startSplitNode = nodes[ 0 ].node; startOffset = startSplitNode.getOuterRange().start; while ( allowedParents !== null && 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 endSplitNode = nodes[ nodes.length - 1 ].node; endOffset = endSplitNode.getOuterRange().end; while ( allowedParents !== null && 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) oldExclude = this.willExcludeInsertions(); this.setExcludeInsertions( true ); if ( startSplitRequired ) { createSplits( startSplitNodes, true ); } if ( endSplitRequired ) { createSplits( endSplitNodes, false ); } insertions.forEach( function ( insertion ) { fragment.change( ve.dm.Transaction.newFromInsertion( fragment.getDocument(), insertion.offset, insertion.data ) ); } ); this.setExcludeInsertions( oldExclude ); this.unwrapNodes( outerDepth, 0 ); return this; };