%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.Document.js |
/*! * VisualEditor DataModel Document class. * * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org */ /** * DataModel document. * * WARNING: The data parameter is passed by reference. Do not modify a data array after passing * it to this constructor, and do not construct multiple Documents with the same data array. If you * need to do these things, make a deep copy (ve#copy) of the data array and operate on the * copy. * * @class * @extends ve.Document * @constructor * @param {Array|ve.dm.ElementLinearData|ve.dm.FlatLinearData} data Raw linear model data, * ElementLinearData or FlatLinearData to be split * @param {HTMLDocument} [htmlDocument] HTML document the data was converted from, if any. * If omitted, a new document will be created. If data is an HTMLDocument, this parameter is * ignored. * @param {ve.dm.Document} [parentDocument] Document to use as root for created nodes * @param {ve.dm.InternalList} [internalList] Internal list to clone; passed when creating a document slice * @param {Array} [innerWhitespace] Inner whitespace to clone; passed when creating a document slice * @param {string} [lang] Language code * @param {string} [dir='ltr'] Directionality (ltr/rtl) */ ve.dm.Document = function VeDmDocument( data, htmlDocument, parentDocument, internalList, innerWhitespace, lang, dir ) { var fullData, result, split, doc, root; // Parent constructor ve.dm.Document.super.call( this, new ve.dm.DocumentNode() ); // Initialization split = true; doc = parentDocument || this; root = this.documentNode; this.lang = lang || 'en'; this.dir = dir || 'ltr'; this.documentNode.setRoot( root ); // ve.Document already called setDocument(), but it could be that doc !== this // so call it again this.documentNode.setDocument( doc ); this.internalList = internalList ? internalList.clone( this ) : new ve.dm.InternalList( this ); this.innerWhitespace = innerWhitespace ? ve.copy( innerWhitespace ) : new Array( 2 ); // Properties this.parentDocument = parentDocument; this.completeHistory = []; this.nodesByType = {}; if ( data instanceof ve.dm.ElementLinearData ) { // Pre-split ElementLinearData split = false; fullData = data; } else if ( data instanceof ve.dm.FlatLinearData ) { // Element + Meta linear data fullData = data; } else { // Raw linear model data fullData = new ve.dm.FlatLinearData( new ve.dm.IndexValueStore(), Array.isArray( data ) ? data : [] ); } this.store = fullData.getStore(); this.htmlDocument = htmlDocument || ve.createDocumentFromHtml( '' ); if ( split ) { result = this.constructor.static.splitData( fullData ); this.data = result.elementData; this.metadata = result.metaData; } else { this.data = fullData; this.metadata = new ve.dm.MetaLinearData( this.data.getStore(), new Array( 1 + this.data.getLength() ) ); } }; /* Inheritance */ OO.inheritClass( ve.dm.Document, ve.Document ); /* Events */ /** * @event precommit * Emitted when a transaction is about to be committed. */ /** * @event presynchronize * Emitted when a transaction has been applied to the linear model * but the model tree has not yet been synchronized. * @param {ve.dm.Transaction} tx Transaction that is about to be synchronized */ /** * @event transact * Emitted when a transaction has been committed. * @param {ve.dm.Transaction} tx Transaction that was just processed */ /* Static methods */ /** * Split data into element data and meta data. * * @static * @param {ve.dm.FlatLinearData} fullData Full data from converter * @return {Object} Object containing element linear data and meta linear data (if processed) */ ve.dm.Document.static.splitData = function ( fullData ) { var i, len, offset, meta, elementData, metaData; elementData = new ve.dm.ElementLinearData( fullData.getStore() ); // Sparse array containing the metadata for each offset // Each element is either undefined, or an array of metadata elements // Because the indexes in the metadata array represent offsets in the data array, the // metadata array has one element more than the data array. metaData = new ve.dm.MetaLinearData( fullData.getStore() ); // Separate element data and metadata and build node tree for ( i = 0, len = fullData.getLength(); i < len; i++ ) { if ( !fullData.isElementData( i ) ) { // Add to element linear data elementData.push( fullData.getData( i ) ); } else { // Element data if ( fullData.isOpenElementData( i ) && ve.dm.metaItemFactory.lookup( fullData.getType( i ) ) ) { // Metadata meta = fullData.getData( i ); offset = elementData.getLength(); // Put the meta data in the meta-linmod if ( !metaData.getData( offset ) ) { metaData.setData( offset, [] ); } metaData.getData( offset ).push( meta ); // Skip close element i++; continue; } // Add to element linear data elementData.push( fullData.getData( i ) ); } } // Pad out the metadata length to element data length + 1 if ( metaData.getLength() < elementData.getLength() + 1 ) { metaData.data = metaData.data.concat( new Array( 1 + elementData.getLength() - metaData.getLength() ) ); } return { elementData: elementData, metaData: metaData }; }; /** * Apply annotations to content data. * * This method modifies data in place. * * @static * @param {Array} data Data to apply annotations to * @param {ve.dm.AnnotationSet} annotationSet Annotations to apply */ ve.dm.Document.static.addAnnotationsToData = function ( data, annotationSet ) { var i, length, newAnnotationSet, store = annotationSet.getStore(); if ( annotationSet.isEmpty() ) { // Nothing to do return; } // Apply annotations to data for ( i = 0, length = data.length; i < length; i++ ) { if ( data[ i ].type ) { // Element continue; } else if ( !Array.isArray( data[ i ] ) ) { // Wrap in array data[ i ] = [ data[ i ] ]; newAnnotationSet = annotationSet.clone(); } else { // Add to existing array newAnnotationSet = new ve.dm.AnnotationSet( store, data[ i ][ 1 ] ); newAnnotationSet.addSet( annotationSet.clone() ); } data[ i ][ 1 ] = newAnnotationSet.getIndexes(); } }; /* Methods */ /** * @inheritdoc */ ve.dm.Document.prototype.getDocumentNode = function () { if ( !this.documentNode.length && !this.documentNode.getDocument().buildingNodeTree ) { this.buildNodeTree(); } return this.documentNode; }; /** * Build the node tree. */ ve.dm.Document.prototype.buildNodeTree = function () { var i, len, node, children, currentStack, parentStack, nodeStack, currentNode, doc, textLength = 0, inTextNode = false; // Build a tree of nodes and nodes that will be added to them after a full scan is complete, // then from the bottom up add nodes to their potential parents. This avoids massive length // updates being broadcast upstream constantly while building is underway. currentStack = []; parentStack = [ this.documentNode ]; // Stack of stacks nodeStack = [ parentStack, currentStack ]; currentNode = this.documentNode; doc = this.documentNode.getDocument(); // Separate element data and metadata and build node tree for ( i = 0, len = this.data.getLength(); i < len; i++ ) { if ( !this.data.isElementData( i ) ) { // Text node opening if ( !inTextNode ) { // Create a lengthless text node node = new ve.dm.TextNode(); node.setDocument( doc ); // Put the node on the current inner stack currentStack.push( node ); currentNode = node; // Set a flag saying we're inside a text node inTextNode = true; } // Track the length textLength++; } else { // Text node closing if ( inTextNode ) { // Finish the text node by setting the length currentNode.setLength( textLength ); // Put the state variables back as they were currentNode = parentStack[ parentStack.length - 1 ]; inTextNode = false; textLength = 0; } // Element open/close if ( this.data.isOpenElementData( i ) ) { // Branch or leaf node opening // Create a childless node node = ve.dm.nodeFactory.createFromElement( this.data.getData( i ) ); node.setDocument( doc ); // Put the childless node on the current inner stack currentStack.push( node ); if ( ve.dm.nodeFactory.canNodeHaveChildren( node.getType() ) ) { // Create a new inner stack for this node parentStack = currentStack; currentStack = []; nodeStack.push( currentStack ); currentNode = node; } else { // Assert that the next element is a closing element for this node, // and skip over it. if ( !this.data.isCloseElementData( i + 1 ) || this.data.getType( i + 1 ) !== this.data.getType( i ) ) { throw new Error( 'Opening element for node that cannot have children must be followed by closing element' ); } i++; } } else { // Branch or leaf node closing // Pop this node's inner stack from the outer stack. It'll have all of the // node's child nodes fully constructed children = nodeStack.pop(); currentStack = parentStack; parentStack = nodeStack[ nodeStack.length - 2 ]; if ( !parentStack ) { // This can only happen if we got unbalanced data throw new Error( 'Unbalanced input passed to document' ); } // Attach the children to the node ve.batchSplice( currentNode, 0, 0, children ); currentNode = parentStack[ parentStack.length - 1 ]; } } } if ( inTextNode ) { // Text node ended by end-of-input rather than by an element currentNode.setLength( textLength ); // Don't bother updating currentNode et al, we don't use them below } // State variable that allows nodes to know that they are being // appended in order. Used by ve.dm.InternalList. doc.buildingNodeTree = true; // The end state is stack = [ [this.documentNode] [ array, of, its, children ] ] // so attach all nodes in stack[1] to the root node ve.batchSplice( this.documentNode, 0, 0, currentStack ); this.updateNodesByType( [ this.documentNode ], [] ); doc.buildingNodeTree = false; }; /** * Get the length of the document. This is also the highest valid offset in the document. * * @return {number} Length of the document */ ve.dm.Document.prototype.getLength = function () { return this.data.getLength(); }; /** * Apply a transaction's effects on the content data. * * @method * @param {ve.dm.Transaction} transaction Transaction to apply * @param {boolean} isStaging Transaction is being applied in staging mode * @fires transact * @throws {Error} Cannot commit a transaction that has already been committed */ ve.dm.Document.prototype.commit = function ( transaction, isStaging ) { var doc = this; if ( transaction.hasBeenApplied() ) { throw new Error( 'Cannot commit a transaction that has already been committed' ); } this.emit( 'precommit' ); new ve.dm.TransactionProcessor( this, transaction, isStaging ).process( function () { doc.emit( 'presynchronize', transaction ); } ); this.completeHistory.push( transaction ); this.emit( 'transact', transaction ); }; /** * Get a slice or copy of the document data. * * @method * @param {ve.Range} [range] Range of data to get, all data will be given by default * @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow) * @return {Array} Slice or copy of document data */ ve.dm.Document.prototype.getData = function ( range, deep ) { return this.data.getDataSlice( range, deep ); }; /** * Get a slice or copy of the document metadata. * * @method * @param {ve.Range} [range] Range of metadata to get, all metadata will be given by default * @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow) * @return {Array} Slice or copy of document metadata */ ve.dm.Document.prototype.getMetadata = function ( range, deep ) { return this.metadata.getDataSlice( range, deep ); }; /** * Get the HTMLDocument associated with this document. * * @method * @return {HTMLDocument} Associated document */ ve.dm.Document.prototype.getHtmlDocument = function () { return this.htmlDocument; }; /** * Get the document's index-value store * * @method * @return {ve.dm.IndexValueStore} The document's index-value store */ ve.dm.Document.prototype.getStore = function () { return this.store; }; /** * Get the document's internal list * * @return {ve.dm.InternalList} The document's internal list */ ve.dm.Document.prototype.getInternalList = function () { return this.internalList; }; /** * Get the document's inner whitespace * * @return {Array} The document's inner whitespace */ ve.dm.Document.prototype.getInnerWhitespace = function () { return this.innerWhitespace; }; /** * Clone a sub-document from a shallow copy of this document. * * The new document's elements, internal list and store will only contain references to data within the slice. * * @param {ve.dm.Selection} selection Selection to create sub-document from * @return {ve.dm.DocumentSlice} New document */ ve.dm.Document.prototype.shallowCloneFromSelection = function ( selection ) { var i, l, linearData, ranges, tableRange, data = []; if ( selection instanceof ve.dm.LinearSelection ) { return this.shallowCloneFromRange( selection.getRange() ); } else if ( selection instanceof ve.dm.TableSelection ) { ranges = selection.getTableSliceRanges(); for ( i = 0, l = ranges.length; i < l; i++ ) { data = data.concat( this.data.slice( ranges[ i ].start, ranges[ i ].end ) ); } linearData = new ve.dm.ElementLinearData( this.getStore(), data ); tableRange = new ve.Range( 0, data.length ); // Copy over the internal list ve.batchSplice( linearData.data, linearData.getLength(), 0, this.getData( this.getInternalList().getListNode().getOuterRange(), true ) ); // The internalList is rebuilt by the document constructor return new ve.dm.TableSlice( linearData, undefined, undefined, this.getInternalList(), tableRange ); } else { return this.shallowCloneFromRange( new ve.Range( 0 ) ); } }; /** * Clone a sub-document from a shallow copy of this document. * * The new document's elements, internal list and store will only contain references to data within the slice. * * @param {ve.Range} range Range of data to slice * @return {ve.dm.DocumentSlice} New document */ ve.dm.Document.prototype.shallowCloneFromRange = function ( range ) { var i, first, last, firstNode, lastNode, linearData, slice, originalRange, balancedRange, balancedNodes, needsContext, contextElement, isContent, startNode = this.getBranchNodeFromOffset( range.start ), endNode = this.getBranchNodeFromOffset( range.end ), selection = this.selectNodes( range, 'siblings' ), balanceOpenings = [], balanceClosings = [], contextOpenings = [], contextClosings = []; // Fix up selection to remove empty items in unwrapped nodes // TODO: fix this is selectNodes while ( selection[ 0 ] && selection[ 0 ].range && selection[ 0 ].range.isCollapsed() && !selection[ 0 ].node.isWrapped() ) { selection.shift(); } i = selection.length - 1; while ( selection[ i ] && selection[ i ].range && selection[ i ].range.isCollapsed() && !selection[ i ].node.isWrapped() ) { selection.pop(); i--; } if ( selection.length === 0 ) { // Nothing selected linearData = new ve.dm.ElementLinearData( this.getStore(), [ { type: 'paragraph', internal: { generated: 'empty' } }, { type: 'paragraph' } ] ); originalRange = balancedRange = new ve.Range( 1 ); } else if ( startNode === endNode ) { // Nothing to balance balancedNodes = selection; } else { // Selection is not balanced first = selection[ 0 ]; last = selection[ selection.length - 1 ]; firstNode = first.node; lastNode = last.node; while ( !firstNode.isWrapped() ) { firstNode = firstNode.getParent(); } while ( !lastNode.isWrapped() ) { lastNode = lastNode.getParent(); } if ( first.range ) { while ( true ) { while ( !startNode.isWrapped() ) { startNode = startNode.getParent(); } balanceOpenings.push( startNode.getClonedElement() ); if ( startNode === firstNode ) { break; } startNode = startNode.getParent(); } } if ( last !== first && last.range ) { while ( true ) { while ( !endNode.isWrapped() ) { endNode = endNode.getParent(); } balanceClosings.push( { type: '/' + endNode.getType() } ); if ( endNode === lastNode ) { break; } endNode = endNode.getParent(); } } balancedNodes = this.selectNodes( new ve.Range( firstNode.getOuterRange().start, lastNode.getOuterRange().end ), 'covered' ); } function nodeNeedsContext( node ) { return node.getParentNodeTypes() !== null || node.isContent(); } if ( !balancedRange ) { // Check if any of the balanced siblings need more context for insertion anywhere needsContext = false; for ( i = balancedNodes.length - 1; i >= 0; i-- ) { if ( nodeNeedsContext( balancedNodes[ i ].node ) ) { needsContext = true; break; } } if ( needsContext ) { startNode = balancedNodes[ 0 ].node; // Keep wrapping until the outer node can be inserted anywhere while ( startNode.getParent() && nodeNeedsContext( startNode ) ) { isContent = startNode.isContent(); startNode = startNode.getParent(); contextElement = startNode.getClonedElement(); if ( isContent ) { ve.setProp( contextElement, 'internal', 'generated', 'wrapper' ); } contextOpenings.push( contextElement ); contextClosings.push( { type: '/' + contextElement.type } ); } } // Final data: // contextOpenings + balanceOpenings + data slice + balanceClosings + contextClosings linearData = new ve.dm.ElementLinearData( this.getStore(), contextOpenings.reverse() .concat( balanceOpenings.reverse() ) .concat( this.data.slice( range.start, range.end ) ) .concat( balanceClosings ) .concat( contextClosings ) ); originalRange = new ve.Range( contextOpenings.length + balanceOpenings.length, contextOpenings.length + balanceOpenings.length + range.getLength() ); balancedRange = new ve.Range( contextOpenings.length, contextOpenings.length + balanceOpenings.length + range.getLength() + balanceClosings.length ); } // Copy over the internal list ve.batchSplice( linearData.data, linearData.getLength(), 0, this.getData( this.getInternalList().getListNode().getOuterRange(), true ) ); // The internalList is rebuilt by the document constructor slice = new ve.dm.DocumentSlice( linearData, undefined, undefined, this.getInternalList(), originalRange, balancedRange ); return slice; }; /** * Clone a sub-document from a range in this document. The new document's elements, store and internal list * will be clones of the ones in this document. * * @param {ve.Range} range Range of data to clone * @return {ve.dm.Document} New document */ ve.dm.Document.prototype.cloneFromRange = function ( range ) { var listRange = this.getInternalList().getListNode().getOuterRange(), data = ve.copy( this.getFullData( range, true ) ); if ( range.start > listRange.start || range.end < listRange.end ) { // The range does not include the entire internal list, so add it data = data.concat( this.getFullData( listRange ) ); } return this.cloneWithData( data, true ); }; /** * Create a sub-document associated with this document like #cloneFromRange, but without cloning * any data from a range in this document: instead, use the specified data. * * @param {Array|ve.dm.ElementLinearData|ve.dm.FlatLinearData} data Raw linear model data, * ElementLinearData or FlatLinearData * @param {boolean} [copyInternalList] Copy the internal list * @return {ve.dm.Document} New document */ ve.dm.Document.prototype.cloneWithData = function ( data, copyInternalList ) { var newDoc; if ( Array.isArray( data ) ) { data = new ve.dm.FlatLinearData( this.getStore().clone(), data ); } newDoc = new this.constructor( data, // htmlDocument this.getHtmlDocument(), // parentDocument undefined, // internalList copyInternalList ? this.getInternalList() : undefined, // innerWhitespace undefined, // lang+dir this.getLang(), this.getDir() ); if ( copyInternalList ) { // Record the length of the internal list at the time the slice was created so we can // reconcile additions properly newDoc.origDoc = this; newDoc.origInternalListLength = this.internalList.getItemNodeCount(); } return newDoc; }; /** * Get the full document data including metadata. * * Metadata will be into the document data to produce the "full data" result. If a range is passed, * metadata at the edges of the range won't be included unless edgeMetadata is set to true. If * no range is passed, the entire document's data is returned and metadata at the edges is * included. * * @param {ve.Range} [range] Range to get full data for. If omitted, all data will be returned * @param {boolean} [edgeMetadata=false] Include metadata at the edges of the range * @return {Array} Data with metadata interleaved */ ve.dm.Document.prototype.getFullData = function ( range, edgeMetadata ) { var j, jLen, i = range ? range.start : 0, iLen = range ? range.end : this.data.getLength(), result = []; if ( edgeMetadata === undefined ) { edgeMetadata = !range; } while ( i <= iLen ) { if ( this.metadata.getData( i ) && ( edgeMetadata || ( i !== range.start && i !== range.end ) ) ) { for ( j = 0, jLen = this.metadata.getData( i ).length; j < jLen; j++ ) { result.push( this.metadata.getData( i )[ j ] ); result.push( { type: '/' + this.metadata.getData( i )[ j ].type } ); } } if ( i < iLen ) { result.push( this.data.getData( i ) ); } i++; } return result; }; /** * Get the nearest word boundary. * * @method * @param {number} offset Offset to start from * @param {number} [direction] Direction to prefer matching offset in, -1 for left and 1 for right * @return {number} Nearest word boundary */ ve.dm.Document.prototype.getSiblingWordBoundary = function ( offset, direction ) { var dataString = new ve.dm.DataString( this.getData() ); return unicodeJS.wordbreak.moveBreakOffset( direction, dataString, offset, true ); }; /** * Get the relative word or character boundary. * * @method * @param {number} offset Offset to start from * @param {number} direction Direction to prefer matching offset in, -1 for left and 1 for right * @param {string} [unit] Unit [word|character] * @return {number} Relative offset */ ve.dm.Document.prototype.getRelativeOffset = function ( offset, direction, unit ) { var relativeContentOffset, relativeStructuralOffset, newOffset, adjacentDataOffset, isFocusable, data = this.data; if ( unit === 'word' ) { // word // Method getSiblingWordBoundary does not "move/jump" over element data. If passed offset is // an element data offset then the same offset is returned - and in such case this method // fallback to the other path (character) which does "move/jump" over element data. newOffset = this.getSiblingWordBoundary( offset, direction ); if ( offset === newOffset ) { newOffset = this.getRelativeOffset( offset, direction, 'character' ); } return newOffset; } else { // character // Check if we are adjacent to a focusable node adjacentDataOffset = offset + ( direction > 0 ? 0 : -1 ); if ( data.isElementData( adjacentDataOffset ) && ve.dm.nodeFactory.isNodeFocusable( data.getType( adjacentDataOffset ) ) ) { // We are adjacent to a focusableNode, move inside it return offset + direction; } relativeContentOffset = data.getRelativeContentOffset( offset, direction ); relativeStructuralOffset = data.getRelativeStructuralOffset( offset, direction, true ); // Check the structural offset is not in the wrong direction if ( ( relativeStructuralOffset - offset < 0 ? -1 : 1 ) !== direction ) { relativeStructuralOffset = offset; } else { isFocusable = ( relativeStructuralOffset - offset < 0 ? -1 : 1 ) === direction && data.isElementData( relativeStructuralOffset + direction ) && ve.dm.nodeFactory.isNodeFocusable( data.getType( relativeStructuralOffset + direction ) ); } // Check if we've moved into a slug or a focusableNode if ( isFocusable || this.hasSlugAtOffset( relativeStructuralOffset ) ) { if ( isFocusable ) { relativeStructuralOffset += direction; } // Check if the relative content offset is in the opposite direction we are trying to go if ( relativeContentOffset === offset || ( relativeContentOffset - offset < 0 ? -1 : 1 ) !== direction ) { return relativeStructuralOffset; } // There's a slug nearby, go into it if it's closer return direction > 0 ? Math.min( relativeContentOffset, relativeStructuralOffset ) : Math.max( relativeContentOffset, relativeStructuralOffset ); } else { // Don't allow the offset to move in the wrong direction return direction > 0 ? Math.max( relativeContentOffset, offset ) : Math.min( relativeContentOffset, offset ); } } }; /** * Get the relative range. * * @method * @param {ve.Range} range Input range * @param {number} direction Direction to look in, +1 or -1 * @param {string} unit Unit [word|character] * @param {boolean} expand Expanding range * @param {ve.Range} [limit] Optional limiting range. If the relative range is not in this range * the input range is returned instead. * @return {ve.Range} Relative range */ ve.dm.Document.prototype.getRelativeRange = function ( range, direction, unit, expand, limit ) { var contentOrSlugOffset, focusableNode, newOffset, newRange, to = range.to; // If you have a non-collapsed range and you move, collapse to the end // in the direction you moved, provided you end up at a content or slug offset if ( !range.isCollapsed() && !expand ) { newOffset = direction > 0 ? range.end : range.start; if ( this.data.isContentOffset( newOffset ) || this.hasSlugAtOffset( newOffset ) ) { return new ve.Range( newOffset ); } else { to = newOffset; } } contentOrSlugOffset = this.getRelativeOffset( to, direction, unit ); focusableNode = this.getNearestFocusableNode( to, direction, contentOrSlugOffset ); if ( focusableNode ) { newRange = focusableNode.getOuterRange( direction === -1 ); } else { newRange = new ve.Range( contentOrSlugOffset ); } if ( limit && !limit.containsRange( newRange ) ) { return range; } if ( expand ) { return new ve.Range( range.from, newRange.to ); } else { return newRange; } }; /** * Get the nearest focusable node. * * @method * @param {number} offset Offset to start looking at * @param {number} direction Direction to look in, +1 or -1 * @param {number} limit Stop looking after reaching certain offset */ ve.dm.Document.prototype.getNearestFocusableNode = function ( offset, direction, limit ) { // It is never an offset of the node, but just an offset for which getNodeFromOffset should // return that node. Usually it would be node offset + 1 or offset of node closing tag. var coveredOffset; this.data.getRelativeOffset( offset, direction === 1 ? 0 : -1, function ( index, limit ) { // Our result must be between offset and limit if ( index >= Math.max( offset, limit ) || index < Math.min( offset, limit ) ) { return true; } if ( this.isOpenElementData( index ) && ve.dm.nodeFactory.isNodeFocusable( this.getType( index ) ) ) { coveredOffset = index + 1; return true; } if ( this.isCloseElementData( index ) && ve.dm.nodeFactory.isNodeFocusable( this.getType( index ) ) ) { coveredOffset = index; return true; } }, limit ); if ( coveredOffset ) { return this.getDocumentNode().getNodeFromOffset( coveredOffset ); } else { return null; } }; /** * Get the nearest offset that a cursor can be placed at. * * @method * @param {number} offset Offset to start looking at * @param {number} [direction=-1] Direction to look in, +1 or -1 * @return {number} Nearest offset a cursor can be placed at */ ve.dm.Document.prototype.getNearestCursorOffset = function ( offset, direction ) { var contentOffset, structuralOffset; direction = direction > 0 ? 1 : -1; if ( this.data.isContentOffset( offset ) || this.hasSlugAtOffset( offset ) ) { return offset; } contentOffset = this.data.getNearestContentOffset( offset, direction ); structuralOffset = this.data.getNearestStructuralOffset( offset, direction, true ); if ( !this.hasSlugAtOffset( structuralOffset ) && contentOffset !== -1 ) { return contentOffset; } if ( direction === 1 ) { if ( contentOffset < offset ) { return structuralOffset; } else { return Math.min( contentOffset, structuralOffset ); } } else { if ( contentOffset > offset ) { return structuralOffset; } else { return Math.max( contentOffset, structuralOffset ); } } }; /** * @inheritdoc */ ve.dm.Document.prototype.getBranchNodeFromOffset = function ( offset ) { if ( offset < 0 || offset > this.data.getLength() ) { throw new Error( 've.dm.Document.getBranchNodeFromOffset(): offset ' + offset + ' is out of bounds' ); } return ve.Document.prototype.getBranchNodeFromOffset.call( this, offset ); }; /** * Check if there is a slug at an offset. * * @method * @param {number} offset Offset to check for a slug at * @return {boolean} There is a slug at the offset */ ve.dm.Document.prototype.hasSlugAtOffset = function ( offset ) { var node = this.getBranchNodeFromOffset( offset ); return node ? node.hasSlugAtOffset( offset ) : false; }; /** * Get the content data of a node. * * @method * @param {ve.dm.Node} node Node to get content data for * @return {Array|null} List of content and elements inside node or null if node is not found */ ve.dm.Document.prototype.getDataFromNode = function ( node ) { var length = node.getLength(), offset = node.getOffset(); if ( offset >= 0 ) { // FIXME T126023: If the node is wrapped in an element than we should increment // the offset by one so we only return the content inside the element. if ( node.isWrapped() ) { offset++; } return this.data.slice( offset, offset + length ); } return null; }; /** * Rebuild one or more nodes following a change in document data. * * The data provided to this method may contain either one node or multiple sibling nodes, but it * must be balanced and valid. Data provided to this method also may not contain any content at the * top level. The tree is updated during this operation. * * Process: * * 1. Nodes between {index} and {index} + {numNodes} in {parent} will be removed * 2. Data will be retrieved from this.data using {offset} and {newLength} * 3. A document fragment will be generated from the retrieved data * 4. The document fragment's nodes will be inserted into {parent} at {index} * * Use cases: * * 1. Rebuild old nodes and offset data after a change to the linear model. * 2. Insert new nodes and offset data after a insertion in the linear model. * * @param {ve.dm.Node} parent Parent of the node(s) being rebuilt * @param {number} index Index within parent to rebuild or insert nodes * * - If {numNodes} == 0: Index to insert nodes at * - If {numNodes} >= 1: Index of first node to rebuild * @param {number} numNodes Total number of nodes to rebuild * * - If {numNodes} == 0: Nothing will be rebuilt, but the node(s) built from data will be * inserted before {index}. To insert nodes at the end, use number of children in 'parent' * - If {numNodes} == 1: Only the node at {index} will be rebuilt * - If {numNodes} > 1: The node at {index} and the next {numNodes-1} nodes will be rebuilt * @param {number} offset Linear model offset to rebuild from * @param {number} newLength Length of data in linear model to rebuild or insert nodes for * @return {ve.dm.Node[]} Array containing the rebuilt/inserted nodes */ ve.dm.Document.prototype.rebuildNodes = function ( parent, index, numNodes, offset, newLength ) { // Get a slice of the document where it's been changed var data = this.data.sliceObject( offset, offset + newLength ), // Build document fragment from data fragment = new this.constructor( data, this.htmlDocument, this ), // Get generated child nodes from the document fragment addedNodes = fragment.getDocumentNode().getChildren(), // Replace nodes in the model tree removedNodes = ve.batchSplice( parent, index, numNodes, addedNodes ); this.updateNodesByType( addedNodes, removedNodes ); // Return inserted nodes return addedNodes; }; /** * Rebuild the entire node tree from linear model data. */ ve.dm.Document.prototype.rebuildTree = function () { var documentNode = this.getDocumentNode(); this.rebuildNodes( documentNode, 0, documentNode.getChildren().length, 0, this.data.getLength() ); }; /** * Update the nodes-by-type index * * @param {ve.dm.Node[]} addedNodes Added nodes * @param {ve.dm.Node[]} removedNodes Removed nodes */ ve.dm.Document.prototype.updateNodesByType = function ( addedNodes, removedNodes ) { var doc = this; function remove( node ) { var type = node.getType(), nodes = doc.nodesByType[ type ] || [], index = nodes.indexOf( node ); if ( index !== -1 ) { nodes.splice( index, 1 ); if ( !nodes.length ) { delete doc.nodesByType[ type ]; } } } function add( node ) { var type = node.getType(), nodes = doc.nodesByType[ type ] = doc.nodesByType[ type ] || []; nodes.push( node ); } function traverse( nodes, action ) { nodes.forEach( function ( node ) { if ( node.hasChildren() ) { node.traverse( action ); } action( node ); } ); } traverse( removedNodes, remove ); traverse( addedNodes, add ); }; /** * Get all nodes in the tree for a specific type * * If a string type is passed only nodes of that exact type will be returned, * if a node class is passed, all sub types will be matched. * * String type matching will be faster than class matching. * * @param {string|Function} type Node type name or node constructor * @param {boolean} sort Sort nodes by document order * @return {ve.dm.Node[]} Nodes of a specific type */ ve.dm.Document.prototype.getNodesByType = function ( type, sort ) { var t, nodeType, nodes = []; if ( type instanceof Function ) { for ( t in this.nodesByType ) { nodeType = ve.dm.nodeFactory.lookup( t ); if ( nodeType === type || nodeType.prototype instanceof type ) { nodes = nodes.concat( this.getNodesByType( t ) ); } } } else { nodes = this.nodesByType[ type ] || []; } if ( sort ) { nodes.sort( function ( a, b ) { return a.getOffset() - b.getOffset(); } ); } return nodes; }; /** * Fix up data so it can safely be inserted into the document data at an offset. * * TODO: this function needs more work but it seems to work, mostly * * @method * @param {Array} data Snippet of linear model data to insert * @param {number} offset Offset in the linear model where the caller wants to insert data * @return {Object} A (possibly modified) copy of data, a (possibly modified) offset, * and a number of elements to remove and the position of the original data in the new data */ ve.dm.Document.prototype.fixupInsertion = function ( data, offset ) { var // Array where we build the return value newData = [], // Temporary variables for handling combining marks insert, annotations, // An unattached combining mark may require the insertion to remove a character, // so we send this counter back in the result remove = 0, // *** Stacks *** // Array of element openings (object). Openings in data are pushed onto this stack // when they are encountered and popped off when they are closed openingStack = [], // Array of node objects. Closings in data that close nodes that were // not opened in data (i.e. were already in the document) are pushed onto this stack // and popped off when balanced out by an opening in data closingStack = [], // Track the position of the original data in the fixed up data for range adjustments insertedDataOffset = 0, insertedDataLength = data.length, // Pointer to this document for private methods doc = this, // *** State persisting across iterations of the outer loop *** // The node (from the document) we're currently in. When in a node that was opened // in data, this is set to its first ancestor that is already in the document parentNode, // The type of the node we're currently in, even if that node was opened within data parentType, // Whether we are currently in a text node inTextNode, // Whether this is the first child of its parent // The test for last child isn't a loop so we don't need to cache it isFirstChild, // *** Temporary variables that do not persist across iterations *** // The type of the node we're currently inserting. When the to-be-inserted node // is wrapped, this is set to the type of the outer wrapper. childType, // Stores the return value of getParentNodeTypes( childType ) allowedParents, // Stores the return value of getChildNodeTypes( parentType ) allowedChildren, // Whether parentType matches allowedParents parentsOK, // Whether childType matches allowedChildren childrenOK, // Array of opening elements to insert (for wrapping the to-be-inserted element) openings, // Array of closing elements to insert (for splitting nodes) closings, // Array of opening elements matching the elements in closings (in the same order) reopenElements, // *** Other variables *** // Used to store values popped from various stacks popped, // Loop variables i, j; /** * Append a linear model element to newData and update the state. * * This function updates parentNode, parentType, openingStack and closingStack. * * @private * @method * @param {Object|Array|string} element Linear model element * @param {number} index Index in data that the element came from (for error reporting only) */ function writeElement( element, index ) { var expectedType; if ( element.type !== undefined ) { // Content, do nothing if ( element.type.charAt( 0 ) !== '/' ) { // Opening // Check if this opening balances an earlier closing of a node that was already in // the document. This is only the case if openingStack is empty (otherwise we still // have unclosed nodes from within data) and if this opening matches the top of // closingStack if ( openingStack.length === 0 && closingStack.length > 0 && closingStack[ closingStack.length - 1 ].getType() === element.type ) { // The top of closingStack is now balanced out, so remove it // Also restore parentNode from closingStack. While this is technically not // entirely accurate (the current node is a new node that's a sibling of this // node), it's good enough for the purposes of this algorithm parentNode = closingStack.pop(); } else { // This opens something new, put it on openingStack openingStack.push( element ); } parentType = element.type; } else { // Closing // Make sure that this closing matches the currently opened node if ( openingStack.length > 0 ) { // The opening was on openingStack, so we're closing a node that was opened // within data. Don't track that on closingStack expectedType = openingStack.pop().type; } else { // openingStack is empty, so we're closing a node that was already in the // document. This means we have to reopen it later, so track this on // closingStack expectedType = parentNode.getType(); closingStack.push( parentNode ); parentNode = parentNode.getParent(); if ( !parentNode ) { throw new Error( 'Inserted data is trying to close the root node ' + '(at index ' + index + ')' ); } parentType = expectedType; // Validate // FIXME this breaks certain input, should fix it up, not scream and die // For now we fall back to inserting balanced data, but then we miss out on // a lot of the nice content adoption abilities of just fixing up the data in // the context of the insertion point - an example of how this will fail is if // you try to insert "b</p></li></ul><p>c" into "<p>a[cursor]d</p>" if ( element.type !== '/' + expectedType && ( // Only throw an error if the content can't be adopted from one content // branch to another !ve.dm.nodeFactory.canNodeContainContent( element.type.slice( 1 ) ) || !ve.dm.nodeFactory.canNodeContainContent( expectedType ) ) ) { throw new Error( 'Cannot adopt content from ' + element.type + ' nodes into ' + expectedType + ' nodes (at index ' + index + ')' ); } } } } newData.push( element ); } parentNode = this.getBranchNodeFromOffset( offset ); parentType = parentNode.getType(); inTextNode = false; isFirstChild = doc.data.isOpenElementData( offset - 1 ); for ( i = 0; i < data.length; i++ ) { if ( inTextNode && data[ i ].type !== undefined ) { parentType = openingStack.length > 0 ? openingStack[ openingStack.length - 1 ].type : parentNode.getType(); } if ( data[ i ].type === undefined || data[ i ].type.charAt( 0 ) !== '/' ) { childType = data[ i ].type || 'text'; openings = []; closings = []; reopenElements = []; // Opening or content // Make sure that opening this element here does not violate the parent/children/content // rules. If it does, insert stuff to fix it // If this node is content, check that the containing node can contain content. If not, // wrap in a paragraph if ( ve.dm.nodeFactory.isNodeContent( childType ) && !ve.dm.nodeFactory.canNodeContainContent( parentType ) ) { childType = 'paragraph'; openings.unshift( ve.dm.nodeFactory.getDataElement( childType ) ); } // Check that this node is allowed to have the containing node as its parent. If not, // wrap it until it's fixed do { allowedParents = ve.dm.nodeFactory.getParentNodeTypes( childType ); parentsOK = allowedParents === null || allowedParents.indexOf( parentType ) !== -1; if ( !parentsOK ) { // We can't have this as the parent if ( allowedParents.length === 0 ) { throw new Error( 'Cannot insert ' + childType + ' because it ' + ' cannot have a parent (at index ' + i + ')' ); } // Open an allowed node around this node childType = allowedParents[ 0 ]; openings.unshift( ve.dm.nodeFactory.getDataElement( childType ) ); } } while ( !parentsOK ); // Check that the containing node can have this node as its child. If not, close nodes // until it's fixed do { allowedChildren = ve.dm.nodeFactory.getChildNodeTypes( parentType ); childrenOK = allowedChildren === null || allowedChildren.indexOf( childType ) !== -1; // Also check if we're trying to insert structure into a node that has to contain // content childrenOK = childrenOK && !( !ve.dm.nodeFactory.isNodeContent( childType ) && ve.dm.nodeFactory.canNodeContainContent( parentType ) ); if ( !childrenOK ) { // We can't insert this into this parent if ( isFirstChild ) { // This element is the first child of its parent, so // abandon this fix up and try again one offset to the left return this.fixupInsertion( data, offset - 1 ); } // Close the parent and try one level up closings.push( { type: '/' + parentType } ); if ( openingStack.length > 0 ) { popped = openingStack.pop(); parentType = popped.type; reopenElements.push( ve.copy( popped ) ); // The opening was on openingStack, so we're closing a node that was opened // within data. Don't track that on closingStack } else { if ( !parentNode.getParent() ) { throw new Error( 'Cannot insert ' + childType + ' even after closing ' + 'all containing nodes (at index ' + i + ')' ); } // openingStack is empty, so we're closing a node that was already in the // document. This means we have to reopen it later, so track this on // closingStack closingStack.push( parentNode ); reopenElements.push( parentNode.getClonedElement() ); parentNode = parentNode.getParent(); parentType = parentNode.getType(); } } } while ( !childrenOK ); if ( i === 0 && childType === 'text' && ve.isUnattachedCombiningMark( data[ i ] ) ) { // Note we only need to check data[0] as combining marks further // along should already have been merged if ( doc.data.isElementData( offset - 1 ) ) { // Inserting a unattached combining mark is generally pretty badly // supported (browser rendering bugs), so we'll just prevent it. continue; } else { offset--; remove++; insert = doc.data.getCharacterData( offset ) + data[ i ]; annotations = doc.data.getAnnotationIndexesFromOffset( offset ); if ( annotations.length ) { insert = [ insert, annotations ]; } data[ i ] = insert; } } for ( j = 0; j < closings.length; j++ ) { // writeElement() would update openingStack/closingStack, but we've already done // that for closings if ( i === 0 ) { insertedDataOffset++; } else { insertedDataLength++; } newData.push( closings[ j ] ); } for ( j = 0; j < openings.length; j++ ) { if ( i === 0 ) { insertedDataOffset++; } else { insertedDataLength++; } writeElement( openings[ j ], i ); } writeElement( data[ i ], i ); if ( data[ i ].type === undefined ) { // Special treatment for text nodes inTextNode = true; if ( openings.length > 0 ) { // We wrapped the text node, update parentType parentType = childType; } // If we didn't wrap the text node, then the node we're inserting into can have // content, so we couldn't have closed anything } else { parentType = data[ i ].type; } } else { // Closing writeElement( data[ i ], i ); parentType = openingStack.length > 0 ? openingStack[ openingStack.length - 1 ].type : parentNode.getType(); } } if ( closingStack.length > 0 && doc.data.isCloseElementData( offset ) ) { // This element is the last child of its parent, so // abandon this fix up and try again one offset to the right return this.fixupInsertion( data, offset + 1 ); } if ( inTextNode ) { parentType = openingStack.length > 0 ? openingStack[ openingStack.length - 1 ].type : parentNode.getType(); } // Close unclosed openings while ( openingStack.length > 0 ) { popped = openingStack[ openingStack.length - 1 ]; // writeElement() will perform the actual pop() that removes // popped from openingStack writeElement( { type: '/' + popped.type }, i ); } // Re-open closed nodes while ( closingStack.length > 0 ) { popped = closingStack[ closingStack.length - 1 ]; // writeElement() will perform the actual pop() that removes // popped from closingStack writeElement( popped.getClonedElement(), i ); } return { offset: offset, data: newData, remove: remove, insertedDataOffset: insertedDataOffset, insertedDataLength: insertedDataLength }; }; /** * Create a document given an HTML string or document. * * @method * @param {string|HTMLDocument} html HTML string or document to insert * @param {Object} [importRules] The import rules with which to sanitize the HTML, if importing * @return {ve.dm.Document} New document */ ve.dm.Document.prototype.newFromHtml = function ( html, importRules ) { var htmlDoc = typeof html === 'string' ? ve.createDocumentFromHtml( html ) : html, doc = ve.dm.converter.getModelFromDom( htmlDoc, { targetDoc: this.getHtmlDocument(), fromClipboard: !!importRules } ), data = doc.data; // FIXME T126020: This is a paste-specific thing and possibly should not be in the generic // newFromHtml() function. Maybe make this be triggered by a pasteRules property? // Clear metadata doc.metadata = new ve.dm.MetaLinearData( doc.getStore(), new Array( 1 + data.getLength() ) ); if ( importRules ) { data.sanitize( importRules.external || {} ); data.sanitize( importRules.all || {} ); } data.remapInternalListKeys( this.getInternalList() ); // Initialize node tree // BUG T75569: This shouldn't be needed doc.buildNodeTree(); return doc; }; /** * Find a text string within the document * * @param {string|RegExp} query Text to find, string or regex with no flags * @param {Object} [options] Search options * @param {boolean} [options.caseSensitiveString] Case sensitive search for a string query. Ignored by regexes (use 'i' flag). * @param {boolean} [options.noOverlaps] Avoid overlapping matches * @param {boolean} [options.wholeWord] Only match whole-word occurrences * @return {ve.Range[]} List of ranges where the string was found */ ve.dm.Document.prototype.findText = function ( query, options ) { var i, l, len, match, offset, lines, dataString, ranges = [], text = this.data.getText( true, new ve.Range( 0, this.getInternalList().getListNode().getOuterRange().start ) ); options = options || {}; if ( query instanceof RegExp ) { offset = 0; // Avoid multi-line matching by only matching within newlines lines = text.split( '\n' ); for ( i = 0, l = lines.length; i < l; i++ ) { while ( lines[ i ] && ( match = query.exec( lines[ i ] ) ) !== null ) { // Skip empty string matches (e.g. with .*) if ( match[ 0 ].length === 0 ) { // Set lastIndex to the next character to avoid an infinite // loop. Browsers differ in whether they do this for you // for empty matches; see // http://blog.stevenlevithan.com/archives/exec-bugs query.lastIndex = match.index + 1; continue; } ranges.push( new ve.Range( offset + match.index, offset + match.index + match[ 0 ].length ) ); if ( !options.noOverlaps ) { query.lastIndex = match.index + 1; } } offset += lines[ i ].length + 1; query.lastIndex = 0; } } else { if ( !options.caseSensitiveString ) { text = text.toLowerCase(); query = query.toLowerCase(); } len = query.length; offset = -1; while ( ( offset = text.indexOf( query, offset ) ) !== -1 ) { ranges.push( new ve.Range( offset, offset + len ) ); offset += options.noOverlaps ? len : 1; } } if ( options.wholeWord ) { dataString = new ve.dm.DataString( this.getData() ); ranges = ranges.filter( function ( range ) { return unicodeJS.wordbreak.isBreak( dataString, range.start ) && unicodeJS.wordbreak.isBreak( dataString, range.end ); } ); } return ranges; }; /** * Get the length of the complete history stack. This is also the current pointer. * * @return {number} Length of the complete history stack */ ve.dm.Document.prototype.getCompleteHistoryLength = function () { return this.completeHistory.length; }; /** * Get all the items in the complete history stack since a specified pointer. * * @param {number} pointer Pointer from where to start the slice * @return {Array} Array of transaction objects with undo flag */ ve.dm.Document.prototype.getCompleteHistorySince = function ( pointer ) { return this.completeHistory.slice( pointer ); }; /** * Get the content language * * @return {string} Language code */ ve.dm.Document.prototype.getLang = function () { return this.lang; }; /** * Get the content directionality * * @return {string} Directionality (ltr/rtl) */ ve.dm.Document.prototype.getDir = function () { return this.dir; };