/** * Creates an ve.dm.DocumentNode object. * * ve.dm.DocumentNode objects extend the native Array object, so it's contents are directly * accessible through the typical methods. * * @class * @constructor * @extends {ve.dm.BranchNode} * @param {Array} data Model data to initialize with, such as data from ve.dm.DocumentNode.getData() * @param {Object} attributes Document attributes */ ve.dm.DocumentNode = function( data, attributes ) { // Inheritance ve.dm.BranchNode.call( this, 'document', null ); // Properties this.data = ve.isArray( data ) ? data : []; this.attributes = ve.isPlainObject( attributes ) ? attributes : {}; // Auto-generate model tree var stuff = ve.dm.DocumentNode.buildNodesAndOffsetsFromData( this.data, this ); ve.batchedSplice( this, 0, 0, stuff.nodes ); // this.offsetMap contains a reference to a node object for each // offset in the linear model, such that the node at offset n is // this.offsetMap[n] this.offsetMap = stuff.offsets; this.offsetMap.push( this ); // Add one for the offset past the end }; /* Static Members */ /** * Mapping of symbolic names and node model constructors. */ ve.dm.DocumentNode.nodeModels = {}; /** * Mapping of symbolic names and nesting rules. * * Each rule is an object with the follwing properties: * parents and children properties may contain one of two possible values: * {Array} List symbolic names of allowed element types (if empty, none will be allowed) * {Null} Any element type is allowed (as long as the other element also allows it) * * @example Paragraph rules * { * 'parents': null, * 'children': [] * } * @example List rules * { * 'parents': null, * 'children': ['listItem'] * } * @example ListItem rules * { * 'parents': ['list'], * 'children': null * } * @example TableCell rules * { * 'parents': ['tableRow'], * 'children': null * } */ ve.dm.DocumentNode.nodeRules = { 'document': { 'parents': null, 'children': null } }; /* Static Methods */ /* * Helper function for rebuildNodes(), do not use this unless you know what you're doing. * * Build nodes from linear model data. This builds two arrays, a nodes array * that can be spliced into a node using .splice(), and an array mapping * offsets to nodes that can be spliced into the DocumentNode's offset map. * The nodes will have their parent set to an empty BranchNode. * * @param {Array} data Fragment of linear model data * @param {ve.BranchNode} [parent] Node that this subtree will be attached to, if known. * Only used for the offset map. * @returns {Object} {'nodes': ve.dm.Node[], 'offsets': Array} */ ve.dm.DocumentNode.buildNodesAndOffsetsFromData = function( data, parent ) { // We temporarily attach the constructed nodes to fakeNode // before returning them. The caller will then attach them // somewhere else. // TODO if this function is rewritten to use recursion or // a stack, we won't need this any more var fakeNode = new ve.dm.BranchNode(), currentNode = fakeNode, offsets = new Array( data.length ), contentLength = 0, i; if ( parent ) { // Set the parent's root on the fake node now // Otherwise, the nodes we build all have their root pointer // pointing to fakeNode, and those will all need to be updated // when we attach the subtree to the main tree fakeNode.setRoot( parent.getRoot() ); } for ( i = 0, length = data.length; i < length; i++ ) { // Set the node reference for this offset in the offset cache // This looks simple, but there are three cases that result in the same thing: // 1. data[i] is an opening, so offset i is before the opening, so we // need to point to the parent of the opened element. currentNode // will be set to the opened element later, but right now it's // still set to the parent of the opened element. // 2. data[i] is a closing, so offset i is before the closing, so we // need to point to the closed element. currentNode will be set to // the parent of the closed element later, but right now it's still // set to the closed element // 3. data[i] is content, so offset i is in the middle of an element, // so obviously we need currentNode, which won't be changed by this // iteration offsets[i] = currentNode === fakeNode ? parent : currentNode; if ( data[i].type !== undefined ) { // It's an element, figure out it's type var element = data[i], type = element.type, open = type.charAt( 0 ) !== '/'; // Trim the "/" off the beginning of closing tag types if ( !open ) { type = type.substr( 1 ); } if ( open ) { // Validate the element type if ( !( type in ve.dm.DocumentNode.nodeModels ) ) { throw 'Unsupported element error. No class registered ' + 'for element type: ' + type; } // Create a model node for the element var newNode = new ve.dm.DocumentNode.nodeModels[element.type]( element, 0 ); // Add the new model node as a child // TODO this is inefficient because all these child and content // additions bubble up and change the lengths of the parents. It // might be better to rewrite this function to be recursive or use a // stack that's only combined at the last minute currentNode.push( newNode ); // Descend into the new model node currentNode = newNode; } else { // Set the length on this node, if it's a leaf node if ( contentLength > 0 ) { currentNode.setContentLength( contentLength ); contentLength = 0; } // Return to the parent node currentNode = currentNode.getParent(); if ( currentNode === null ) { throw 'buildNodesAndOffsetsFromData() received unbalanced data: ' + 'found closing without matching opening at index ' + i; } } } else { // It's content. Add this to the length // We don't need a stack here because content is only // allowed in leaf nodes, so mixing content and structure // inside the same node is illegal // TODO actually enforce this contentLength++; } } return { 'nodes': fakeNode.getChildren(), 'offsets': offsets }; }; /** * Exists for backwards compatibility only, do not use * TODO migrate callers */ ve.dm.DocumentNode.createNodesFromData = function( data ) { return ve.dm.DocumentNode.buildNodesAndOffsetsFromData( data, null ).nodes; }; /** * Creates a document model from a plain object. * * @static * @method * @param {Object} obj Object to create new document model from * @returns {ve.dm.DocumentNode} Document model created from obj */ ve.dm.DocumentNode.newFromPlainObject = function( obj ) { if ( obj.type === 'document' ) { var data = [], attributes = ve.isPlainObject( obj.attributes ) ? ve.copyObject( obj.attributes ) : {}; for ( var i = 0; i < obj.children.length; i++ ) { data = data.concat( ve.dm.DocumentNode.flattenPlainObjectElementNode( obj.children[i] ) ); } return new ve.dm.DocumentNode( data, attributes ); } throw 'Invalid object error. Object is not a valid document object.'; }; /** * Generates a hash of an annotation object based on it's name and data. * * @static * @method * @param {Object} annotation Annotation object to generate hash for * @returns {String} Hash of annotation */ ve.dm.DocumentNode.getHash = ( window.JSON && typeof JSON.stringify === 'function' ) ? JSON.stringify : ve.dm.JsonSerializer.stringify; /** * Gets the index of the first instance of a given annotation. * * This method differs from ve.inArray because it compares hashes instead of references. * * @static * @method * @param {Array} annotations Annotations to search through * @param {Object} annotation Annotation to search for * @param {Boolean} typeOnly Whether to only consider the type * @returns {Integer} Index of annotation in annotations, or -1 if annotation was not found */ ve.dm.DocumentNode.getIndexOfAnnotation = function( annotations, annotation, typeOnly ) { if ( annotation === undefined || annotation.type === undefined ) { throw 'Invalid annotation error. Can not find non-annotation data in character.'; } if ( ve.isArray( annotations ) ) { // Find the index of a comparable annotation (checking for same value, not reference) for ( var i = 0; i < annotations.length; i++ ) { // Skip over character data - used when this is called on a content data item if ( typeof annotations[i] === 'string' ) { continue; } if ( ( typeOnly && annotations[i].type === annotation.type ) || ( !typeOnly && annotations[i].hash === ( annotation.hash || ve.dm.DocumentNode.getHash( annotation ) ) ) ) { return i; } } } return -1; }; /** * Gets a list of indexes of annotations that match a regular expression. * * @static * @method * @param {Array} annotations Annotations to search through * @param {RegExp} pattern Regular expression pattern to match with * @returns {Integer[]} List of indexes in annotations that match */ ve.dm.DocumentNode.getMatchingAnnotations = function( annotations, pattern ) { if ( !( pattern instanceof RegExp ) ) { throw 'Invalid annotation error. Can not find non-annotation data in character.'; } var matches = []; if ( ve.isArray( annotations ) ) { // Find the index of a comparable annotation (checking for same value, not reference) for ( var i = 0; i < annotations.length; i++ ) { // Skip over character data - used when this is called on a content data item if ( typeof annotations[i] === 'string' ) { continue; } if ( pattern.test( annotations[i].type ) ) { matches.push( annotations[i] ); } } } return matches; }; /** * Sorts annotations of a character. * * This method modifies data in place. The string portion of the annotation character will always * remain at the beginning. * * @static * @method * @param {Array} character Annotated character to be sorted */ ve.dm.DocumentNode.sortCharacterAnnotations = function( character ) { if ( !ve.isArray( character ) ) { return; } character.sort( function( a, b ) { var aHash = a.hash || ve.dm.DocumentNode.getHash( a ), bHash = b.hash || ve.dm.DocumentNode.getHash( b ); return typeof a === 'string' ? -1 : ( typeof b === 'string' ? 1 : ( aHash == bHash ? 0 : ( aHash < bHash ? -1 : 1 ) ) ); } ); }; /** * Adds annotation hashes to content data. * * This method modifies data in place. * * @method * @param {Array} data Data to add annotation hashes to */ ve.dm.DocumentNode.addAnnotationHashesToData = function( data ) { for ( var i = 0; i < data.length; i++ ) { if ( ve.isArray( data[i] ) ) { for ( var j = 1; j < data.length; j++ ) { if ( data[i][j].hash === undefined ) { data[i][j].hash = ve.dm.DocumentNode.getHash( data[i][j] ); } } } } }; /** * Applies annotations to content data. * * This method modifies data in place. * * @method * @param {Array} data Data to remove annotations from * @param {Array} annotations Annotations to apply */ ve.dm.DocumentNode.addAnnotationsToData = function( data, annotations ) { if ( annotations && annotations.length ) { for ( var i = 0; i < data.length; i++ ) { if ( ve.isArray( data[i] ) ) { data[i] = [data[i]]; } data[i] = [data[i]].concat( annotations ); } } }; /** * Removes annotations from content data. * * This method modifies data in place. * * @method * @param {Array} data Data to remove annotations from * @param {Array} [annotations] Annotations to remove (all will be removed if undefined) */ ve.dm.DocumentNode.removeAnnotationsFromData = function( data, annotations ) { for ( var i = 0; i < data.length; i++ ) { if ( ve.isArray( data[i] ) ) { data[i] = data[i][0]; } } }; /** * Creates an ve.ContentModel object from a plain content object. * * A plain content object contains plain text and a series of annotations to be applied to ranges of * the text. * * @example * { * 'text': '1234', * 'annotations': [ * // Makes "23" bold * { * 'type': 'bold', * 'range': { * 'start': 1, * 'end': 3 * } * } * ] * } * * @static * @method * @param {Object} obj Plain content object, containing a "text" property and optionally * an "annotations" property, the latter of which being an array of annotation objects including * range information * @returns {Array} */ ve.dm.DocumentNode.flattenPlainObjectContentNode = function( obj ) { if ( !ve.isPlainObject( obj ) ) { // Use empty content return []; } else { // Convert string to array of characters var data = obj.text.split(''); // Render annotations if ( ve.isArray( obj.annotations ) ) { for ( var i = 0, length = obj.annotations.length; i < length; i++ ) { var src = obj.annotations[i]; // Build simplified annotation object var dst = { 'type': src.type }; if ( 'data' in src ) { dst.data = ve.copyObject( src.data ); } // Add a hash to the annotation for faster comparison dst.hash = ve.dm.DocumentNode.getHash( dst ); // Apply annotation to range if ( src.range.start < 0 ) { // TODO: The start can not be lower than 0! Throw error? // Clamp start value src.range.start = 0; } if ( src.range.end > data.length ) { // TODO: The end can not be higher than the length! Throw error? // Clamp end value src.range.end = data.length; } for ( var j = src.range.start; j < src.range.end; j++ ) { // Auto-convert to array if ( typeof data[j] === 'string' ) { data[j] = [data[j]]; } // Append data[j].push( dst ); } } } return data; } }; /** * Flatten a plain node object into a data array, recursively. * * TODO: where do we document this whole structure - aka "WikiDom"? * * @static * @method * @param {Object} obj Plain node object to flatten * @returns {Array} Flattened version of obj */ ve.dm.DocumentNode.flattenPlainObjectElementNode = function( obj ) { var i, data = [], element = { 'type': obj.type }; if ( ve.isPlainObject( obj.attributes ) ) { element.attributes = ve.copyObject( obj.attributes ); } // Open element data.push( element ); if ( ve.isPlainObject( obj.content ) ) { // Add content data = data.concat( ve.dm.DocumentNode.flattenPlainObjectContentNode( obj.content ) ); } else if ( ve.isArray( obj.children ) ) { // Add children - only do this if there is no content property for ( i = 0; i < obj.children.length; i++ ) { // TODO: Figure out if all this concatenating is inefficient. I think it is data = data.concat( ve.dm.DocumentNode.flattenPlainObjectElementNode( obj.children[i] ) ); } } // Close element - TODO: Do we need attributes here or not? data.push( { 'type': '/' + obj.type } ); return data; }; /** * Get a plain object representation of content data. * * @method * @returns {Object} Plain object representation */ ve.dm.DocumentNode.getExpandedContentData = function( data ) { var stack = []; // Text and annotations function start( offset, annotation ) { // Make a new verion of the annotation object and push it to the stack var obj = { 'type': annotation.type, 'range': { 'start': offset } }; if ( annotation.data ) { obj.data = ve.copyObject( annotation.data ); } stack.push( obj ); } function end( offset, annotation ) { for ( var i = stack.length - 1; i >= 0; i-- ) { if ( !stack[i].range.end ) { if ( annotation ) { // We would just compare hashes, but the stack doesn't contain any if ( stack[i].type === annotation.type && ve.compareObjects( stack[i].data, annotation.data ) ) { stack[i].range.end = offset; break; } } else { stack[i].range.end = offset; } } } } var left = '', right, leftPlain, rightPlain, obj = { 'text': '' }, offset = 0, i, j; for ( i = 0; i < data.length; i++ ) { right = data[i]; leftPlain = typeof left === 'string'; rightPlain = typeof right === 'string'; // Open or close annotations if ( !leftPlain && rightPlain ) { // [formatted][plain] pair, close any annotations for left end( i - offset ); } else if ( leftPlain && !rightPlain ) { // [plain][formatted] pair, open any annotations for right for ( j = 1; j < right.length; j++ ) { start( i - offset, right[j] ); } } else if ( !leftPlain && !rightPlain ) { // [formatted][formatted] pair, open/close any differences for ( j = 1; j < left.length; j++ ) { if ( ve.dm.DocumentNode.getIndexOfAnnotation( data[i] , left[j], true ) === -1 ) { end( i - offset, left[j] ); } } for ( j = 1; j < right.length; j++ ) { if ( ve.dm.DocumentNode.getIndexOfAnnotation( data[i - 1], right[j], true ) === -1 ) { start( i - offset, right[j] ); } } } obj.text += rightPlain ? right : right[0]; left = right; } if ( data.length ) { end( i - offset ); } if ( stack.length ) { obj.annotations = stack; } // Copy attributes if there are any set if ( !ve.isEmptyObject( this.attributes ) ) { obj.attributes = ve.extendObject( true, {}, this.attributes ); } return obj; }; /** * Checks if a data at a given offset is content. * * @example Content data: * a b c d e f * ^ ^ ^ ^ ^ ^ * * @static * @method * @param {Array} data Data to evaluate offset within * @param {Integer} offset Offset in data to check * @returns {Boolean} If data at offset is content */ ve.dm.DocumentNode.isContentData = function( data, offset ) { // Shortcut: if there's already content there, we will trust it's supposed to be there return typeof data[offset] === 'string' || ve.isArray( data[offset] ); }; /** * Checks if a data at a given offset is an element. * * @example Element data: * a b c d e f * ^ ^ ^ ^ ^ ^ * * @static * @method * @param {Array} data Data to evaluate offset within * @param {Integer} offset Offset in data to check * @returns {Boolean} If data at offset is an element */ ve.dm.DocumentNode.isElementData = function( data, offset ) { // TODO: Is there a safer way to check if it's a plain object without sacrificing speed? return offset >= 0 && offset < data.length && data[offset].type !== undefined; }; /** * Checks if an offset within given data is structural. * * Structural offsets are those at the beginning, end or surrounded by elements. This differs * from a location at which an element is present in that elements can be safely inserted at a * structural location, but not nessecarily where an element is present. * * @example Structural offsets: * a b c d e f * ^ ^ ^ ^ ^ * * @static * @method * @param {Array} data Data to evaluate offset within * @param {Integer} offset Offset to check * @returns {Boolean} Whether offset is structural or not */ ve.dm.DocumentNode.isStructuralOffset = function( data, offset ) { // Edges are always structural if ( offset === 0 || offset === data.length ) { return true; } // Structual offsets will have elements on each side if ( data[offset - 1].type !== undefined && data[offset].type !== undefined ) { if ( '/' + data[offset - 1].type === data[offset].type ) { return false; } return true; } return false; }; /** * Checks if elements are present within data. * * @static * @method * @param {Array} data Data to look for elements within * @returns {Boolean} If elements exist in data */ ve.dm.DocumentNode.containsElementData = function( data ) { for ( var i = 0, length = data.length; i < length; i++ ) { if ( data[i].type !== undefined ) { return true; } } return false; }; /* Methods */ /** * Creates a document view for this model. * * @method * @returns {ve.ce.DocumentNode} */ ve.dm.DocumentNode.prototype.createView = function() { return new ve.ce.DocumentNode( this ); }; /** * Gets 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) * @returns {Array} Copy of document data */ ve.dm.DocumentNode.prototype.getData = function( range, deep ) { var start = 0, end; if ( range !== undefined ) { range.normalize(); start = Math.max( 0, Math.min( this.data.length, range.start ) ); end = Math.max( 0, Math.min( this.data.length, range.end ) ); } // Work around IE bug: arr.slice( 0, undefined ) returns [] while arr.slice( 0 ) behaves // correctly var data = end === undefined ? this.data.slice( start ) : this.data.slice( start, end ); return deep ? ve.copyArray( data ) : data; }; /** * Gets the element object of a node. * * @method * @param {ve.dm.Node} node Node to get element object for * @returns {Object|null} Element object */ ve.dm.DocumentNode.prototype.getElementFromNode = function( node ) { var offset = this.getOffsetFromNode( node ); if ( offset !== false ) { return this.data[offset]; } return null; }; /** * Gets the element data of a node. * * @method * @param {ve.dm.Node} node Node to get element data for */ ve.dm.DocumentNode.prototype.getElementDataFromNode = function( node ) { var length = node.getElementLength(); var offset = this.getOffsetFromNode( node ); if ( offset !== -1 ) { return this.data.slice( offset, offset + length ); } return null; }; /** * Gets the content data of a node. * * @method * @param {ve.dm.Node} node Node to get content data for * @returns {Array|null} List of content and elements inside node or null if node is not found */ ve.dm.DocumentNode.prototype.getContentDataFromNode = function( node, range ) { var length = node.getContentLength(); if ( range ) { range.normalize(); if ( range.start < 0 ) { throw 'Invalid range error. Range can not start before node start: ' + range.start; } if ( range.end > length ) { throw 'Invalid range error. Range can not end after node end: ' + range.end; } } else { range = { 'start': 0, 'end': length }; } var offset = this.getOffsetFromNode( node ); if ( offset !== -1 ) { if ( node.type !== 'document' ) { offset++; } return this.data.slice( offset + range.start, offset + range.end ); } return null; }; /** * Gets the range of content surrounding a given offset that's covered by a given annotation. * * @method * @param {Integer} offset Offset to begin looking forward and backward from * @param {Object} annotation Annotation to test for coverage with * @returns {ve.Range|null} Range of content covered by annotation, or null if offset is not covered */ ve.dm.DocumentNode.prototype.getAnnotationBoundaries = function( offset, annotation, typeOnly ) { // Alias to reduce hash table lookups var DocumentNode = ve.dm.DocumentNode; if ( annotation.hash === undefined ) { annotation.hash = DocumentNode.getHash( annotation ); } if ( DocumentNode.getIndexOfAnnotation( this.data[offset], annotation, typeOnly ) === -1 ) { return null; } var start = offset, end = offset, item; while ( start > 0 ) { start--; if ( DocumentNode.getIndexOfAnnotation( this.data[start], annotation, typeOnly ) === -1 ) { start++; break; } } while ( end < this.data.length ) { if ( DocumentNode.getIndexOfAnnotation( this.data[end], annotation, typeOnly ) === -1 ) { break; } end++; } return new ve.Range( start, end ); }; /** * Gets a list of annotations that a given offset is covered by. * * @method * @param {Integer} offset Offset to get annotations for * @returns {Object[]} A copy of all annotation objects offset is covered by */ ve.dm.DocumentNode.prototype.getAnnotationsFromOffset = function( offset, byref ) { if ( ve.isArray( this.data[offset] ) ) { if ( byref === true ) { return this.data[offset].slice( 1 ); } else { return ve.copyArray( this.data[offset].slice( 1 ) ); } } return []; }; /** * Gets a list of annotations that a given range is covered by. * * @method * @param {ve.Range} range Range to get annotations for * @returns {Object[]} A copy of all annotation objects offset is covered by */ ve.dm.DocumentNode.prototype.getAnnotationsFromRange = function( range ) { range.normalize(); var annotations = { 'full': [], 'partial': [], 'all': [] }, map = {}, elementsCount = 0; for ( var i = range.start; i < range.end; i++ ) { if ( ve.dm.DocumentNode.isElementData( this.data, i ) ) { elementsCount++; continue; } for ( var j = 1; j < this.data[i].length; j++ ) { hash = this.data[i][j].hash; if ( hash in map ) { map[hash][1]++; } else { map[hash] = [this.data[i][j], 1]; } } } var length = range.getLength(); for ( var hash in map ) { if ( map[hash][1] === length - elementsCount ) { annotations.full.push( map[hash][0] ); } else { annotations.partial.push( map[hash][0] ); } annotations.all.push( map[hash][0] ); } return annotations; }; /** * Gets the range of content surrounding a given offset that makes up a whole word. * * @method * @param {Integer} offset Offset to begin looking forward and backward from * @returns {ve.Range|null} Range of content making up a whole word or null if offset is not content */ ve.dm.DocumentNode.prototype.getWordBoundaries = function( offset ) { if ( ve.dm.DocumentNode.isStructuralOffset( this.data, offset ) || ve.dm.DocumentNode.isElementData( this.data, offset ) ) { return null; } var offsetItem = typeof this.data[offset] === 'string' ? this.data[offset] : this.data[offset][0], regex = offsetItem.match( /\B/ ) ? /\b/ : /\B/, start = offset, end = offset, item; while ( start > 0 ) { start--; if ( typeof this.data[start] !== 'string' && !ve.isArray( this.data[start] ) ) { start++; break; } item = typeof this.data[start] === 'string' ? this.data[start] : this.data[start][0]; if ( item.match( regex ) ) { start++; break; } } while ( end < this.data.length ) { if ( typeof this.data[end] !== 'string' && !ve.isArray( this.data[end] ) ) { break; } item = typeof this.data[end] === 'string' ? this.data[end] : this.data[end][0]; if ( item.match( regex ) ) { break; } end++; } return new ve.Range( start, end ); }; /** * Gets a content offset a given distance forwards or backwards from another. * * @method * @param {Integer} offset Offset to start from * @param {Integer} distance Number of content offsets to move * @param {Integer} Offset a given distance from the given offset */ ve.dm.DocumentNode.prototype.getRelativeContentOffset = function( offset, distance ) { if ( distance === 0 ) { return offset; } var direction = distance > 0 ? 1 : -1, i = offset + direction, steps = 0; distance = Math.abs( distance ); while ( i > 0 && i < this.data.length ) { if ( !ve.dm.DocumentNode.isStructuralOffset( this.data, i ) ) { steps++; offset = i; if ( distance === steps ) { return offset; } } i += direction; } return offset; }; /** * Rebuild one or more nodes from a linear model fragment, updating the tree and the offset map. * This will remove the nodes from the tree and replace them with whatever the linear model data * expands to. The data fragment may represent one node or multiple sibling nodes, but it must be * balanced and valid and it may not contain any content at the top level. This function can also be * used to insert nodes from linear model data by rebuilding zero nodes. * * @param {ve.dm.Node} parent Parent of the node(s) to rebuild * @param {Integer} index Index in parent's children array of the first node to rebuild (numNodes>1) * or index to insert nodes at (if numNodes=0) * @param {Integer} numNodes Total number of nodes to rebuild. * If set to 1, only parent[index] will be rebuilt * If >1, parent[index] through parent[index+numNodes-1] will be rebuilt * If set to 0, nothing is removed or rebuilt, but the node(s) built from * data are inserted before parent[index]. To insert nodes at the end, * set index to one past the end (i.e. to parent.getChildren().length) * @param {Integer} offset Linear model offset of the first node to rebuild (numNodes>1) * or offset of the insertion point (if numNodes=0) * @param {Array} data Linear model data fragment to rebuild the nodes from, or to construct the * inserted nodes from * @returns {ve.dm.Node[]} Array containing the rebuilt/inserted nodes */ ve.dm.DocumentNode.prototype.rebuildNodes = function( parent, index, numNodes, offset, data ) { // TODO add smartness similar to that in TransactionProcessor.rebuildNodes() // to avoid rebuilding if we're replacing one node with one other node of the // same type var rebuilt, totalLength, i; // Compute the total length of the nodes totalLength = 0; for ( i = index; i < index + numNodes; i++ ) { totalLength += parent.children[i].getElementLength(); } // Build nodes from data rebuilt = ve.dm.DocumentNode.buildNodesAndOffsetsFromData( data, parent ); // Replace the nodes in the model tree ve.batchedSplice( parent, index, numNodes, rebuilt.nodes ); // Update the offsets array ve.batchedSplice( this.offsetMap, offset, totalLength, rebuilt.offsets ); return rebuilt.nodes; }; /** * Generates a transaction which inserts data at a given offset. * * @method * @param {Integer} offset * @param {Array} data * @returns {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.prepareInsertion = function( offset, data ) { /** * Balances mismatched openings/closings in data * @return data itself if nothing was changed, or a clone of data with balancing changes made. * data itself is never touched */ function balance( data ) { var i, stack = [], element, workingData = null; for ( i = 0; i < data.length; i++ ) { if ( data[i].type === undefined ) { // Not an opening or a closing, skip } else if ( data[i].type.charAt( 0 ) != '/' ) { // Opening stack.push( data[i].type ); } else { // Closing if ( stack.length === 0 ) { // The stack is empty, so this is an unopened closing // Remove it if ( workingData === null ) { workingData = data.slice( 0 ); } workingData.splice( i, 1 ); } else { element = stack.pop(); if ( element != data[i].type.substr( 1 ) ) { // Closing doesn't match what's expected // This means the input is malformed and cannot possibly // have been a fragment taken from well-formed data throw 'Input is malformed: expected /' + element + ' but got ' + data[i].type + ' at index ' + i; } } } } // Check whether there are any unclosed tags and close them if ( stack.length > 0 && workingData === null ) { workingData = data.slice( 0 ); } while ( stack.length > 0 ) { element = stack.pop(); workingData.push( { 'type': '/' + element } ); } // TODO // Check whether there is any raw unenclosed content and deal with that somehow return workingData || data; } var tx = new ve.dm.Transaction(), insertedData = data, // may be cloned and modified isStructuralLoc, wrappingElementType; if ( offset < 0 || offset > this.data.length ) { throw 'Offset ' + offset + ' out of bounds [0..' + this.data.length + ']'; } // Has to be after the bounds check, because isStructuralOffset doesn't like out-of-bounds // offsets isStructuralLoc = ve.dm.DocumentNode.isStructuralOffset( this.data, offset ); if ( offset > 0 ) { tx.pushRetain( offset ); } if ( ve.dm.DocumentNode.containsElementData( insertedData ) ) { if ( insertedData[0].type !== undefined && insertedData[0].type.charAt( 0 ) != '/' ) { // insertedData starts with an opening, so this is really intended to insert structure // Balance it to make it sane, if it's not already // TODO we need an actual validator and check that the insertion is really valid insertedData = balance( insertedData ); if ( !isStructuralLoc ) { // We're inserting structure at a content location, // so we need to split up the wrapping element wrappingElementType = this.getNodeFromOffset( offset ).getElementType(); var arr = [{ 'type': '/' + wrappingElementType }, { 'type': wrappingElementType }]; ve.insertIntoArray( arr, 1, insertedData ); insertedData = arr; } // else we're inserting structure at a structural location, which is fine } else { // insertedData starts with content but contains structure // TODO balance and validate, will be different for this case } } else { if ( isStructuralLoc ) { // We're inserting content into a structural location, // so we need to wrap the inserted content in a paragraph. insertedData = [ { 'type': 'paragraph' }, { 'type': '/paragraph' } ]; ve.insertIntoArray( insertedData, 1, data ); } else { // Content being inserted in content is fine, do nothing } } tx.pushInsert( insertedData ); if ( offset < this.data.length ) { tx.pushRetain( this.data.length - offset ); } return tx; /* * // Structural changes * There are 2 basic types of locations the insertion point can be: * Structural locations * |

a

b

- Beginning of the document *

a

|

b

- Between elements (like in a document or list) *

a

b

| - End of the document * Content locations *

|a

b

- Inside an element (like in a paragraph or listItem) *

a|

b

- May also be inside an element but right before/after an * open/close * * if ( Incoming data contains structural elements ) { // We're assuming the incoming data is balanced, is that OK? * if ( Insertion point is a structural location ) { * if ( Incoming data is not a complete structural element ) { * Incoming data must be balanced * } * } else { * Closing and opening elements for insertion point must be added to incoming data * } * } else { * if ( Insertion point is a structural location ) { * Incoming data must be balanced //how? Should this even be allowed? * } else { * Content being inserted into content is OK, do nothing * } * } */ }; /** * Generates a transaction which removes data from a given range. * * When removing data inside an element, the data is simply discarded and the node's length is * adjusted accordingly. When removing data across elements, there are two situations that can cause * added complexity: * 1. A range spans between nodes of different levels or types * 2. A range only partially covers one or two nodes * * To resolve these issues in a predictable way the following rules must be obeyed: * 1. Structural elements are retained unless the range being removed covers the entire element * 2. Elements can only be merged if they are * 2a. Same type * 2b. Same depth * 2c. Types match at each level up to a common ancestor * 3. Merge takes place at the highest common ancestor * * @method * @param {ve.Range} range * @returns {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.prepareRemoval = function( range ) { // If a selection is painted across two paragraphs, and then the text is deleted, the two // paragraphs can become one paragraph. However, if the selection crosses into a table, those // cannot be merged. To make this simple, we follow rule #2 in the comment above for deciding // whether two elements can be merged. // So you can merge adjacent paragraphs, or list items. And you can't merge a paragraph into // a table row. There may be other rules we will want in here later, for instance, special // casing merging a listitem into a paragraph. function canMerge( node1, node2 ) { var result = ve.Node.getCommonAncestorPaths( node1, node2 ); if ( !result ) { return false; } // Check that corresponding nodes in the paths have the same type for ( var i = 0; i < result.node1Path.length; i++ ) { if ( result.node1Path[i].getElementType() !== result.node2Path[i].getElementType() ) { return false; } } return true; } var tx = new ve.dm.Transaction(), selectedNodes, selectedNode, startNode, endNode, i; range.normalize(); if ( range.start === range.end ) { // Empty range, nothing to do // Retain up to the end of the document. Why do we do this? Because Trevor said so! tx.pushRetain( this.data.length ); return tx; } selectedNodes = this.selectNodes( range ); startNode = selectedNodes[0].node; endNode = selectedNodes[selectedNodes.length - 1].node; if ( startNode && endNode && canMerge( startNode, endNode ) ) { // This is the simple case. node1 and node2 are either the same node, or can be merged // So we can just remove all the data in the range and call it a day, no fancy // processing necessary // Retain to the start of the range if ( range.start > 0 ) { tx.pushRetain( range.start ); } // Remove all data in a given range. tx.pushRemove( this.data.slice( range.start, range.end ) ); // Retain up to the end of the document. Why do we do this? Because Trevor said so! if ( range.end < this.data.length ) { tx.pushRetain( this.data.length - range.end ); } } else { var index = 0; for ( i = 0; i < selectedNodes.length; i++ ) { selectedNode = selectedNodes[i]; // Retain up to where the next removal starts if ( selectedNode.globalRange.start > index ) { tx.pushRetain( selectedNode.globalRange.start - index ); } // Remove stuff if ( selectedNode.globalRange.getLength() ) { tx.pushRemove( this.data.slice( selectedNode.globalRange.start, selectedNode.globalRange.end ) ); } index = selectedNode.globalRange.end; } // Retain up to the end of the document. Why do we do this? Because Trevor said so! if ( index < this.data.length ) { tx.pushRetain( this.data.length - index ); } } return tx; }; /** * Generates a transaction which removes content from a given range and * replaces it with other content. * * @param {ve.Range} range * @param {Array} content data * @returns {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.prepareContentReplacement = function( range, data ) { // TODO this is way too simple and doesn't verify that there's no structural changes being snuck in var tx = new ve.dm.Transaction(); range.normalize(); if ( range.start > 0 ) { tx.pushRetain( range.start ); } tx.pushReplace( this.data.slice( range.start, range.end ), data ); if ( range.end < this.data.length ) { tx.pushRetain( this.data.length - range.end ); } return tx; }; /** * Generates a transaction which wraps, unwraps or replaces structure. * * The unwrap parameters are checked against the actual model data, and * an exception is thrown if the type fields don't match. This means you * can omit attributes from the unwrap parameters, those are automatically * picked up from the model data instead. * * @param {ve.Range} range Range to wrap/unwrap/replace around * @param {Array} unwrapOuter Array of opening elements to unwrap. These must be immediately *outside* the range. * @param {Array} wrapOuter Array of opening elements to wrap around the range. * @param {Array} unwrapEach Array of opening elements to unwrap from each top-level element in the range. * @param {Array} wrapEach Array of opening elements to wrap around each top-level element in the range. * @returns {ve.dm.Transaction} * * @example Changing a paragraph to a header: * Before: [ {'type': 'paragraph'}, 'a', 'b', 'c', {'type': '/paragraph'} ] * prepareWrap( new ve.Range( 1, 4 ), [ {'type': 'paragraph'} ], [ {'type': 'heading', 'level': 1 } ] ); * After: [ {'type': 'heading', 'level': 1 }, 'a', 'b', 'c', {'type': '/heading'} ] * * @example Changing a set of paragraphs to a list: * Before: [ {'type': 'paragraph'}, 'a', {'type': '/paragraph'}, {'type':'paragraph'}, 'b', {'type':'/paragraph'} ] * prepareWrap( new ve.Range( 0, 6 ), [], [ {'type': 'list' } ], [], [ {'type': 'listItem', 'attributes': {'styles': ['bullet']}} ] ); * After: [ {'type': 'list'}, {'type': 'listItem', 'attributes': {'styles': ['bullet']}}, {'type':'paragraph'} 'a', * {'type': '/paragraph'}, {'type': '/listItem'}, {'type': 'listItem', 'attributes': {'styles': ['bullet']}}, * {'type': 'paragraph'}, 'b', {'type': '/paragraph'}, {'type': '/listItem'}, {'type': '/list'} ] * */ ve.dm.DocumentNode.prototype.prepareWrap = function( range, unwrapOuter, wrapOuter, unwrapEach, wrapEach ) { // Function to generate arrays of closing elements in reverse order function closingArray( openings ) { var closings = [], i, len = openings.length; for ( i = 0; i < len; i++ ) { closings[closings.length] = { 'type': '/' + openings[len - i - 1].type }; } return closings; } // TODO: we're not checking for nesting validity. prepareInsertion also needs this var tx = new ve.dm.Transaction(), i, j, unwrapOuterData; range.normalize(); if ( range.start > unwrapOuter.length ) { // Retain up to the first thing we're unwrapping // The outer unwrapping takes place *outside* // the range, so compensate for that tx.pushRetain( range.start - unwrapOuter.length ); } else if ( range.start < unwrapOuter.length ) { throw 'unwrapOuter is longer than the data preceding the range'; } // Replace the opening elements for the outer unwrap&wrap if ( wrapOuter.length > 0 || unwrapOuter.length > 0 ) { // Verify that wrapOuter matches the data at this position unwrapOuterData = this.data.slice( range.start - unwrapOuter.length, range.start ); for ( i = 0; i < unwrapOuterData.length; i++ ) { if ( unwrapOuterData[i].type !== unwrapOuter[i].type ) { throw 'Element in unwrapOuter does not match: expected ' + unwrapOuter[i].type + ' but found ' + unwrapOuterData[i].type; } } // Instead of putting in unwrapOuter as given, put it in the // way it appears in the mode,l so we pick up any attributes tx.pushReplace( unwrapOuterData, wrapOuter ); } if ( wrapEach.length > 0 || unwrapEach.length > 0 ) { var closingUnwrapEach = closingArray( unwrapEach ), closingWrapEach = closingArray( wrapEach ), depth = 0, startOffset, unwrapEachData; // Visit each top-level child and wrap/unwrap it // TODO figure out if we should use the tree/node functions here // rather than iterating over offsets, it may or may not be faster for ( i = range.start; i < range.end; i++ ) { if ( this.data[i].type === undefined ) { // This is a content offset, skip } else { // This is a structural offset if ( this.data[i].type.charAt( 0 ) != '/' ) { // This is an opening element if ( depth === 0 ) { // We are at the start of a top-level element // Replace the opening elements // Verify that unwrapEach matches the data at this position unwrapEachData = this.data.slice( i, i + unwrapEach.length ); for ( j = 0; j < unwrapEachData.length; j++ ) { if ( unwrapEachData[j].type !== unwrapEach[j].type ) { throw 'Element in unwrapEach does not match: expected ' + unwrapEach[j].type + ' but found ' + unwrapEachData[j].type; } } // Instead of putting in unwrapEach as given, put it in the // way it appears in the model, so we pick up any attributes tx.pushReplace( unwrapEachData, wrapEach ); // Store this offset for later startOffset = i; } depth++; } else { // This is a closing element depth--; if ( depth === 0 ) { // We are at the end of a top-level element // Retain the contents of what we're wrapping tx.pushRetain( i - startOffset + 1 - unwrapEach.length*2 ); // Replace the closing elements tx.pushReplace( closingUnwrapEach, closingWrapEach ); } } } } } else { // There is no wrapEach/unwrapEach to be done, just retain // up to the end of the range tx.pushRetain( range.end - range.start ); } if ( wrapOuter.length > 0 || unwrapOuter.length > 0 ) { tx.pushReplace( closingArray( unwrapOuter ), closingArray( wrapOuter ) ); } // Retain up to the end of the document if ( range.end < this.data.length ) { tx.pushRetain( this.data.length - range.end - unwrapOuter.length ); } return tx; }; /** * Generates a transaction which annotates content within a given range. * * @method * @returns {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.prepareContentAnnotation = function( range, method, annotation ) { if ( annotation instanceof RegExp && method !== 'clear' ) { throw 'Invalid annotation error. RegExp patterns can only be used with the clear method.'; } var tx = new ve.dm.Transaction(); range.normalize(); if ( annotation.hash === undefined ) { annotation.hash = ve.dm.DocumentNode.getHash( annotation ); } var i = range.start, span = i, on = false; while ( i < range.end ) { if ( this.data[i].type !== undefined ) { // Don't annotate structural elements if ( on ) { if ( span ) { tx.pushRetain( span ); } tx.pushStopAnnotating( method, annotation ); span = 0; on = false; } } else { var covered; if ( annotation instanceof RegExp ) { covered = !!ve.dm.DocumentNode.getMatchingAnnotations( this.data[i], annotation ).length; } else { covered = ve.dm.DocumentNode.getIndexOfAnnotation( this.data[i], annotation ) !== -1; } if ( ( covered && method === 'set' ) || ( !covered && method === 'clear' ) ) { // Don't set/clear annotations on content that's already set/cleared if ( on ) { if ( span ) { tx.pushRetain( span ); } tx.pushStopAnnotating( method, annotation ); span = 0; on = false; } } else { // Content if ( !on ) { if ( span ) { tx.pushRetain( span ); } tx.pushStartAnnotating( method, annotation ); span = 0; on = true; } } } span++; i++; } if ( span ) { tx.pushRetain( span ); } if ( on ) { tx.pushStopAnnotating( method, annotation ); } if ( range.end < this.data.length ) { tx.pushRetain( this.data.length - range.end ); } return tx; }; /** * Generates a transaction which changes attributes on an element at a given offset. * * @method * @returns {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.prepareElementAttributeChange = function( offset, key, to ) { var tx = new ve.dm.Transaction(); if ( offset ) { tx.pushRetain( offset ); } if ( this.data[offset].type === undefined ) { throw 'Invalid element offset error. Can not set attributes to non-element data.'; } if ( this.data[offset].type[0] === '/' ) { throw 'Invalid element offset error. Can not set attributes on closing element.'; } var from = 'attributes' in this.data[offset] ? this.data[offset].attributes[key] : undefined; tx.pushReplaceElementAttribute( key, from, to ); if ( offset < this.data.length ) { tx.pushRetain( this.data.length - offset ); } return tx; }; /** * Applies a transaction to the content data. * * @method * @param {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.commit = function( transaction ) { ve.dm.TransactionProcessor.commit( this, transaction ); }; /** * Reverses a transaction's effects on the content data. * * @method * @param {ve.dm.Transaction} */ ve.dm.DocumentNode.prototype.rollback = function( transaction ) { ve.dm.TransactionProcessor.rollback( this, transaction ); }; ve.dm.DocumentNode.prototype.prepareLeafConversion = function( range, type, attributes ) { range.normalize(); var startNode = this.getNodeFromOffset( range.start ), endNode = this.getNodeFromOffset( range.end ), nodes = [], nodeOffset, txs = []; this.traverseLeafNodes( function( node ) { nodes.push( node ); if( node === endNode ) { return false; } }, startNode ); // TODO: skip nodes which have the same type and attributes as the target for ( var i = 0; i < nodes.length; i++ ) { nodeOffset = this.getOffsetFromNode( nodes[i], false ); txs.push( this.prepareRemoval( new ve.Range( nodeOffset, nodeOffset + nodes[i].getElementLength() ) ) ); txs.push( this.prepareInsertion( nodeOffset, [ { 'type': type, 'attributes': attributes } ] .concat( nodes[i].getContentData() ) .concat( [ { 'type': '/' + type } ] ) ) ); } return txs; }; /* Inheritance */ ve.extendClass( ve.dm.DocumentNode, ve.dm.BranchNode );