mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-29 16:44:51 +00:00
f6864b0c04
Change-Id: I96db0f28bb220f1c3b23990824e9523278cb8f9b
1001 lines
35 KiB
JavaScript
1001 lines
35 KiB
JavaScript
/**
|
|
* DataModel document.
|
|
*
|
|
* @class
|
|
* @extends {ve.Document}
|
|
* @constructor
|
|
* @param {Array} data Linear model data to start with
|
|
* @param {ve.dm.Document} [parentDocument] Document to use as root for created nodes
|
|
*/
|
|
ve.dm.Document = function( data, parentDocument ) {
|
|
// Inheritance
|
|
ve.Document.call( this, new ve.dm.DocumentNode() );
|
|
|
|
// Properties
|
|
this.parentDocument = parentDocument;
|
|
this.data = data || [];
|
|
this.offsetMap = new Array( this.data.length );
|
|
|
|
// Initialization
|
|
var doc = parentDocument || this;
|
|
this.documentNode.setDocument( doc );
|
|
var root = doc.getDocumentNode();
|
|
this.documentNode.setRoot( root );
|
|
|
|
/*
|
|
* The offsetMap is always one element longer than data because it includes a reference to the
|
|
* root node at the offset just past the end. To make population work correctly, we have to
|
|
* start out with that one extra reference.
|
|
*/
|
|
this.offsetMap.push( this.documentNode );
|
|
|
|
/*
|
|
* 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. Also populate the
|
|
* offset map as we go.
|
|
*/
|
|
var node,
|
|
textLength = 0,
|
|
inTextNode = false,
|
|
// Stack of stacks, each containing a
|
|
stack = [[this.documentNode], []],
|
|
children,
|
|
openingIndex,
|
|
currentStack = stack[1],
|
|
parentStack = stack[0],
|
|
currentNode = this.documentNode;
|
|
for ( var i = 0, length = this.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 its 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.
|
|
*
|
|
* We want to populate the offsetMap with branches only, but we've just written the actual
|
|
* node that lives at this offset. So if it's a leaf node, change it to its parent.
|
|
*/
|
|
this.offsetMap[i] = ve.dm.nodeFactory.canNodeHaveChildren( currentNode.getType() ) ?
|
|
currentNode : parentStack[parentStack.length - 1];
|
|
// Infer that if an item in the linear model has a type attribute than it must be an element
|
|
if ( this.data[i].type === undefined ) {
|
|
// Text node opening
|
|
if ( !inTextNode ) {
|
|
// Create a lengthless text node
|
|
node = new ve.dm.TextNode();
|
|
// Set the root pointer now, to prevent cascading updates
|
|
node.setRoot( root );
|
|
// 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[i].type.charAt( 0 ) != '/' ) {
|
|
// Branch or leaf node opening
|
|
// Create a childless node
|
|
node = ve.dm.nodeFactory.create( this.data[i].type, [], this.data[i].attributes );
|
|
// Set the root pointer now, to prevent cascading updates
|
|
node.setRoot( root );
|
|
// 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 = [];
|
|
stack.push( currentStack );
|
|
}
|
|
currentNode = node;
|
|
} else {
|
|
// Branch or leaf node closing
|
|
if ( ve.dm.nodeFactory.canNodeHaveChildren( currentNode.getType() ) ) {
|
|
// Pop this node's inner stack from the outer stack. It'll have all of the
|
|
// node's child nodes fully constructed
|
|
children = stack.pop();
|
|
currentStack = parentStack;
|
|
parentStack = stack[stack.length - 2];
|
|
if ( !parentStack ) {
|
|
// This can only happen if we got unbalanced data
|
|
throw '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
|
|
}
|
|
// 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, stack[1] );
|
|
};
|
|
|
|
/* Static methods */
|
|
|
|
/**
|
|
* Checks if content can be inserted at an offset in document data.
|
|
*
|
|
* This method assumes that any value that has a type property that's a string is an element object.
|
|
*
|
|
* @example Content offsets:
|
|
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
|
|
* . ^ ^ . ^ ^ ^ . ^ .
|
|
*
|
|
* @example Content offsets:
|
|
* <list> <listItem> </listItem> <list>
|
|
* . . . . .
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Array} data Document data
|
|
* @param {Integer} offset Document offset
|
|
* @returns {Boolean} Content can be inserted at offset
|
|
*/
|
|
ve.dm.Document.isContentOffset = function( data, offset ) {
|
|
// Edges are never content
|
|
if ( offset === 0 || offset === data.length ) {
|
|
return false;
|
|
}
|
|
var left = data[offset - 1],
|
|
right = data[offset],
|
|
factory = ve.dm.nodeFactory;
|
|
return (
|
|
// Data exists at offsets
|
|
( left !== undefined && right !== undefined ) &&
|
|
(
|
|
// If there's content on the left or the right of the offset than we are good
|
|
// <paragraph>|a|</paragraph>
|
|
( typeof left === 'string' || typeof right === 'string' ) ||
|
|
// Same checks but for annotated characters - isArray is slower, try it next
|
|
( ve.isArray( left ) || ve.isArray( right ) ) ||
|
|
// The most expensive test are last, these deal with elements
|
|
(
|
|
// Right of a leaf
|
|
// <paragraph><image></image>|</paragraph>
|
|
(
|
|
// Is an element
|
|
typeof left.type === 'string' &&
|
|
// Is a closing
|
|
left.type.charAt( 0 ) === '/' &&
|
|
// Is a leaf
|
|
factory.isNodeContent( left.type.substr( 1 ) )
|
|
) ||
|
|
// Left of a leaf
|
|
// <paragraph>|<image></image></paragraph>
|
|
(
|
|
// Is an element
|
|
typeof right.type === 'string' &&
|
|
// Is not a closing
|
|
right.type.charAt( 0 ) !== '/' &&
|
|
// Is a leaf
|
|
factory.isNodeContent( right.type )
|
|
) ||
|
|
// Inside empty content branch
|
|
// <paragraph>|</paragraph>
|
|
(
|
|
// Inside empty element
|
|
'/' + left.type === right.type &&
|
|
// Both are content branches (right is the same type)
|
|
factory.canNodeContainContent( left.type )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Checks if structure can be inserted at an offset in document data.
|
|
*
|
|
* If the {unrestricted} param is true than only offsets where any kind of element can be inserted
|
|
* will return true. This can be used to detect the difference between a location that a paragraph
|
|
* can be inserted, such as between two tables but not direclty inside a table.
|
|
*
|
|
* This method assumes that any value that has a type property that's a string is an element object.
|
|
*
|
|
* @example Structural offsets (unrestricted = false):
|
|
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
|
|
* ^ . . ^ . . . . . ^
|
|
*
|
|
* @example Structural offsets (unrestricted = true):
|
|
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
|
|
* ^ . . ^ . . . . . ^
|
|
*
|
|
* @example Structural offsets (unrestricted = false):
|
|
* <list> <listItem> </listItem> <list>
|
|
* ^ ^ ^ ^ ^
|
|
*
|
|
* @example Content branch offsets (unrestricted = true):
|
|
* <list> <listItem> </listItem> <list>
|
|
* ^ . ^ . ^
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Array} data Document data
|
|
* @param {Integer} offset Document offset
|
|
* @param {Boolean} [unrestricted] Only return true if any kind of element can be inserted at offset
|
|
* @returns {Boolean} Structure can be inserted at offset
|
|
*/
|
|
ve.dm.Document.isStructuralOffset = function( data, offset, unrestricted ) {
|
|
// Edges are always structural
|
|
if ( offset === 0 || offset === data.length ) {
|
|
return true;
|
|
}
|
|
// Offsets must be within range and both sides must be elements
|
|
var left = data[offset - 1],
|
|
right = data[offset],
|
|
factory = ve.dm.nodeFactory;
|
|
return (
|
|
(
|
|
left !== undefined &&
|
|
right !== undefined &&
|
|
typeof left.type === 'string' &&
|
|
typeof right.type === 'string'
|
|
) &&
|
|
(
|
|
// Right of a branch
|
|
// <list><listItem><paragraph>a</paragraph>|</listItem>|</list>|
|
|
(
|
|
// Is a closing
|
|
left.type.charAt( 0 ) === '/' &&
|
|
// Is a branch or non-content leaf
|
|
(
|
|
factory.canNodeHaveChildren( left.type.substr( 1 ) ) ||
|
|
!factory.isNodeContent( left.type.substr( 1 ) )
|
|
) &&
|
|
(
|
|
// Only apply this rule in unrestricted mode
|
|
!unrestricted ||
|
|
// Right of an unrestricted branch
|
|
// <list><listItem><paragraph>a</paragraph>|</listItem></list>|
|
|
// Both are non-content branches that can have any kind of child
|
|
factory.getParentNodeTypes( left.type.substr( 1 ) ) === null
|
|
)
|
|
) ||
|
|
// Left of a branch
|
|
// |<list>|<listItem>|<paragraph>a</paragraph></listItem></list>
|
|
(
|
|
// Is not a closing
|
|
right.type.charAt( 0 ) !== '/' &&
|
|
// Is a branch or non-content leaf
|
|
(
|
|
factory.canNodeHaveChildren( right.type ) ||
|
|
!factory.isNodeContent( right.type )
|
|
) &&
|
|
(
|
|
// Only apply this rule in unrestricted mode
|
|
!unrestricted ||
|
|
// Left of an unrestricted branch
|
|
// |<list><listItem>|<paragraph>a</paragraph></listItem></list>
|
|
// Both are non-content branches that can have any kind of child
|
|
factory.getParentNodeTypes( right.type ) === null
|
|
)
|
|
) ||
|
|
// Inside empty non-content branch
|
|
// <list>|</list> or <list><listItem>|</listItem></list>
|
|
(
|
|
// Inside empty element
|
|
'/' + left.type === right.type &&
|
|
// Both are non-content branches (right is the same type)
|
|
factory.canNodeHaveGrandchildren( left.type ) &&
|
|
(
|
|
// Only apply this rule in unrestricted mode
|
|
!unrestricted ||
|
|
// Both are non-content branches that can have any kind of child
|
|
factory.getChildNodeTypes( left.type ) === null
|
|
)
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Checks if a data at a given offset is an element.
|
|
*
|
|
* This method assumes that any value that has a type property that's a string is an element object.
|
|
*
|
|
* @example Element data:
|
|
* <heading> a </heading> <paragraph> b c <img></img> </paragraph>
|
|
* ^ . ^ ^ . . ^ ^ ^ .
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Array} data Document data
|
|
* @param {Integer} offset Document offset
|
|
* @returns {Boolean} Data at offset is an element
|
|
*/
|
|
ve.dm.Document.isElementData = function( data, offset ) {
|
|
// Data exists at offset and appears to be an element
|
|
return data[offset] !== undefined && typeof data[offset].type === 'string';
|
|
};
|
|
|
|
/**
|
|
* Checks for elements in document data.
|
|
*
|
|
* This method assumes that any value that has a type property that's a string is an element object.
|
|
* Elements are discovered by iterating through the entire data array (backwards).
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Array} data Document data
|
|
* @returns {Boolean} At least one elements exists in data
|
|
*/
|
|
ve.dm.Document.containsElementData = function( data ) {
|
|
var i = data.length;
|
|
while ( i-- ) {
|
|
if ( data[i].type !== undefined ) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Checks for non-content elements in document data.
|
|
*
|
|
* This method assumes that any value that has a type property that's a string is an element object.
|
|
* Elements are discovered by iterating through the entire data array.
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Array} data Document data
|
|
* @returns {Boolean} True if all elements in data are content elements
|
|
*/
|
|
ve.dm.Document.isContentData = function( data ) {
|
|
for ( var i = 0, len = data.length; i < len; i++ ) {
|
|
if ( data[i].type !== undefined &&
|
|
data[i].type.charAt( 0 ) !== '/' &&
|
|
!ve.dm.nodeFactory.isNodeContent( data[i].type )
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* Gets 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)
|
|
* @returns {Array} Slice or copy of document data
|
|
*/
|
|
ve.dm.Document.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 ) );
|
|
}
|
|
// IE work-around: 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 either the slice or a deep copy of the slice
|
|
return deep ? ve.copyArray( data ) : data;
|
|
};
|
|
|
|
ve.dm.Document.prototype.getOffsetMap = function() {
|
|
return this.offsetMap;
|
|
};
|
|
|
|
ve.dm.Document.prototype.getNodeFromOffset = function( offset ) {
|
|
return this.offsetMap[offset];
|
|
};
|
|
|
|
/**
|
|
* 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.Document.prototype.getDataFromNode = function( node ) {
|
|
var length = node.getLength(),
|
|
offset = this.documentNode.getOffsetFromNode( node );
|
|
if ( offset >= 0 ) {
|
|
// XXX: 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;
|
|
};
|
|
|
|
/**
|
|
* 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.Document.prototype.getAnnotationsFromOffset = function( offset ) {
|
|
var annotations;
|
|
// Since annotations are not stored on a closing leaf node,
|
|
// rewind offset by 1 to return annotations for that structure
|
|
if (
|
|
ve.isPlainObject( this.data[offset] ) && // structural offset
|
|
this.data[offset].hasOwnProperty('type') && // just in case
|
|
this.data[offset].type.charAt( 0 ) === '/' && // closing offset
|
|
ve.dm.nodeFactory.canNodeHaveChildren(
|
|
this.data[offset].type.substr( 1 )
|
|
) === false // leaf node
|
|
){
|
|
offset = this.getRelativeContentOffset( offset, -1 );
|
|
}
|
|
|
|
annotations = ve.isArray( this.data[offset] ) ?
|
|
this.data[offset][1] : this.data[offset].annotations;
|
|
|
|
if ( ve.isPlainObject( annotations ) ) {
|
|
return ve.getObjectValues( annotations );
|
|
}
|
|
return [];
|
|
};
|
|
|
|
/**
|
|
* Does this offset contain the specified annotation
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to look at
|
|
* @param {Object} annotation Object to look for
|
|
* @returns {Boolean} Whether an offset contains the specified annotation
|
|
*/
|
|
ve.dm.Document.prototype.offsetContainsAnnotation = function ( offset, annotation ) {
|
|
var annotations = this.getAnnotationsFromOffset( offset );
|
|
for ( var a=0; a<annotations.length; a++ ) {
|
|
if ( ve.compareObjects( annotations[a], annotation ) ){
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Gets the range of content surrounding a given offset that's covered by a given annotation.
|
|
*
|
|
* @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.Document.prototype.getAnnotatedRangeFromOffset = function ( offset, annotation ) {
|
|
var start = offset,
|
|
end = offset;
|
|
if ( this.offsetContainsAnnotation( offset, annotation ) === false ) {
|
|
return null;
|
|
}
|
|
while ( start > 0 ) {
|
|
start--;
|
|
if ( this.offsetContainsAnnotation( start, annotation ) === false ) {
|
|
start++;
|
|
break;
|
|
}
|
|
}
|
|
while ( end < this.data.length ) {
|
|
if ( this.offsetContainsAnnotation(end, annotation ) === false ) {
|
|
break;
|
|
}
|
|
end++;
|
|
}
|
|
return new ve.Range( start, end );
|
|
};
|
|
|
|
/**
|
|
* Checks if a character has matching annotations.
|
|
*
|
|
* @static
|
|
* @methodng
|
|
* @param {Integer} offset Offset of annotated character
|
|
* @param {RegExp} pattern Regular expression pattern to match with
|
|
* @returns {Boolean} Character has matching annotations
|
|
*/
|
|
ve.dm.Document.prototype.offsetContainsMatchingAnnotations = function( offset, pattern ) {
|
|
if ( !( pattern instanceof RegExp ) ) {
|
|
throw 'Invalid Pattern. Pattern not instance of RegExp';
|
|
}
|
|
var annotations = ve.isArray( this.data[offset] ) ?
|
|
this.data[offset][1] : this.data[offset].annotations;
|
|
if ( ve.isPlainObject( annotations ) ) {
|
|
for ( var hash in annotations ) {
|
|
if ( pattern.test( annotations[hash].type ) ) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Gets a list of annotations that match a regular expression.
|
|
*
|
|
* @static
|
|
* @methodng
|
|
* @param {Integer} offset Offset of annotated character
|
|
* @param {RegExp} pattern Regular expression pattern to match with
|
|
* @returns {Object} Annotations that match the pattern
|
|
*/
|
|
ve.dm.Document.prototype.getMatchingAnnotations = function( offset, pattern ) {
|
|
if ( !( pattern instanceof RegExp ) ) {
|
|
throw 'Invalid Pattern. Pattern not instance of RegExp';
|
|
}
|
|
var matches = {},
|
|
annotations = ve.isArray( this.data[offset] ) ?
|
|
this.data[offset][1] : this.data[offset].annotations;
|
|
if ( ve.isPlainObject( annotations ) ) {
|
|
for ( var hash in annotations ) {
|
|
if ( pattern.test( annotations[hash].type ) ){
|
|
matches[hash] = annotations[hash];
|
|
}
|
|
}
|
|
}
|
|
return matches;
|
|
};
|
|
|
|
/**
|
|
* Gets an array of common annnotations across a range.
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to get annotations for
|
|
* @returns {Object[]} A copy of all annotation objects offset is covered by
|
|
*/
|
|
ve.dm.Document.prototype.getAnnotationsFromRange = function( range ) {
|
|
var currentChar = {},
|
|
annotations = [],
|
|
charCount = 0,
|
|
map = {};
|
|
|
|
range.normalize();
|
|
for ( var i = range.start; i < range.end; i++ ) {
|
|
// skip non characters
|
|
if ( ve.dm.Document.isElementData( this.data, i ) ) {
|
|
continue;
|
|
}
|
|
//current character annotations
|
|
currentChar = this.data[i][1];
|
|
// if a non annotated character, no commonality.
|
|
if ( currentChar === undefined ) {
|
|
return [];
|
|
}
|
|
charCount++;
|
|
// if current char annotations are not the same as previous char.
|
|
if ( ve.compareObjects( map, currentChar ) === false) {
|
|
//retain common annotations
|
|
if ( charCount > 1 ) {
|
|
// look for annotation in map
|
|
for ( var a in currentChar ) {
|
|
if( map[a] === undefined ) {
|
|
delete currentChar[a];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//save map
|
|
map = currentChar;
|
|
}
|
|
// build array of annotations
|
|
for ( var key in map ) {
|
|
annotations.push( map[key] );
|
|
}
|
|
return annotations;
|
|
};
|
|
|
|
/**
|
|
* Rebuild one or more nodes following a change in linear model 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 and offset map are 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 offset map will be inserted into this document at {offset}
|
|
* 5. 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 {Integer} 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 {Integer} 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 {Integer} offset Linear model offset to rebuild or insert offset map data
|
|
* - If {numNodes} == 0: Offset to insert offset map data at
|
|
* - If {numNodes} >= 1: Offset to remove old and insert new offset map data at
|
|
* @param {Integer} newLength Length of data in linear model to rebuild or insert nodes for
|
|
* @returns {ve.dm.Node[]} Array containing the rebuilt/inserted nodes
|
|
*/
|
|
ve.dm.Document.prototype.rebuildNodes = function( parent, index, numNodes, offset, newLength ) {
|
|
// Compute the length of the old nodes (so we can splice their offsets out of the offset map)
|
|
var oldLength = 0;
|
|
for ( var i = index; i < index + numNodes; i++ ) {
|
|
oldLength += parent.children[i].getOuterLength();
|
|
}
|
|
// Get a slice of the document where it's been changed
|
|
var data = this.data.slice( offset, offset + newLength );
|
|
// Build document fragment from data
|
|
var fragment = new ve.dm.Document( data, this );
|
|
// Get generated child nodes from the document fragment
|
|
var nodes = fragment.getDocumentNode().getChildren();
|
|
// Replace nodes in the model tree
|
|
ve.batchSplice( parent, index, numNodes, nodes );
|
|
// Update offset map
|
|
ve.batchSplice( this.offsetMap, offset, oldLength, fragment.getOffsetMap() );
|
|
// Return inserted nodes
|
|
return nodes;
|
|
};
|
|
|
|
/**
|
|
* Gets an offset a given distance from another using a callback to check if offsets are valid.
|
|
*
|
|
* - If {offset} is not already valid, one step will be used to move it to an valid one.
|
|
* - If {distance} is zero the result will either be {offset} if it's already valid or the
|
|
* nearest valid offset to the right if possible and to the left otherwise.
|
|
* - If {offset} is after the last valid offset and {distance} is >= 1, or if {offset} if
|
|
* before the first valid offset and {distance} <= 1 than the result will be the nearest
|
|
* valid offset in the opposite direction.
|
|
* - If the document does not contain a single valid offset the result will be -1
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to start from
|
|
* @param {Integer} distance Number of valid offsets to move
|
|
* @param {Function} callback Function to call to check if an offset is valid which will be
|
|
* given two intital arguments of data and offset
|
|
* @param {Mixed} [...] Additional arguments to pass to the callback
|
|
* @returns {Integer} Relative valid offset or -1 if there are no valid offsets in document
|
|
*/
|
|
ve.dm.Document.prototype.getRelativeOffset = function( offset, distance, callback ) {
|
|
var args = Array.prototype.slice.call( arguments, 3 );
|
|
// If offset is already a structural offset and distance is zero than no further work is needed,
|
|
// otherwise distance should be 1 so that we can get out of the invalid starting offset
|
|
if ( distance === 0 ) {
|
|
if ( callback.apply( window, [this.data, offset].concat( args ) ) ) {
|
|
return offset;
|
|
} else {
|
|
distance = 1;
|
|
}
|
|
}
|
|
var direction = (
|
|
offset <= 0 ? 1 : (
|
|
offset >= this.data.length ? -1 : (
|
|
distance > 0 ? 1 : -1
|
|
)
|
|
)
|
|
),
|
|
start = offset,
|
|
i = start + direction,
|
|
steps = 0,
|
|
turnedAround = false;
|
|
distance = Math.abs( distance );
|
|
offset = -1;
|
|
while ( i >= 0 && i <= this.data.length ) {
|
|
if ( callback.apply( window, [this.data, i].concat( args ) ) ) {
|
|
steps++;
|
|
offset = i;
|
|
if ( distance === steps ) {
|
|
return offset;
|
|
}
|
|
} else if (
|
|
// Don't keep turning around over and over
|
|
!turnedAround &&
|
|
// Only turn around if not a single step could be taken
|
|
steps === 0 &&
|
|
// Only turn around if we're about to reach the edge
|
|
( ( direction < 0 && i === 0 ) || ( direction > 0 && i === this.data.length ) )
|
|
) {
|
|
// Start over going in the opposite direction
|
|
direction *= -1;
|
|
i = start;
|
|
distance = 1;
|
|
turnedAround = true;
|
|
}
|
|
i += direction;
|
|
}
|
|
return offset;
|
|
};
|
|
|
|
/**
|
|
* Gets a content offset a given distance forwards or backwards from another.
|
|
*
|
|
* This method is a wrapper around {getRelativeOffset}, using {ve.dm.Document.isContentOffset} as
|
|
* the offset validation callback.
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to start from
|
|
* @param {Integer} distance Number of content offsets to move
|
|
* @returns {Integer} Relative content offset or -1 if there are no valid offsets in document
|
|
*/
|
|
ve.dm.Document.prototype.getRelativeContentOffset = function( offset, distance ) {
|
|
return this.getRelativeOffset( offset, distance, ve.dm.Document.isContentOffset );
|
|
};
|
|
|
|
/**
|
|
* Gets the nearest content offset to a given offset.
|
|
*
|
|
* If the offset is already a valid offset, it will be returned unchanged. This method differs from
|
|
* calling {getRelativeContentOffset} with a zero length differece because the direction can be
|
|
* controlled without nessecarily moving the offset if it's already valid. Also, if the direction
|
|
* is 0 or undefined than nearest offsets will be found to the left and right and the one with the
|
|
* shortest distance will be used.
|
|
*
|
|
* This method is a wrapper around {getRelativeOffset}, using {ve.dm.Document.isContentOffset} as
|
|
* the offset validation callback.
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to start from
|
|
* @param {Integer} [direction] Direction to prefer matching offset in, -1 for left and 1 for right
|
|
* @returns {Integer} Nearest content offset or -1 if there are no valid offsets in document
|
|
*/
|
|
ve.dm.Document.prototype.getNearestContentOffset = function( offset, direction ) {
|
|
if ( ve.dm.Document.isContentOffset( this.data, offset ) ) {
|
|
return offset;
|
|
}
|
|
if ( direction === undefined ) {
|
|
var left = this.getRelativeOffset( offset, -1, ve.dm.Document.isContentOffset ),
|
|
right = this.getRelativeOffset( offset, 1, ve.dm.Document.isContentOffset );
|
|
return offset - left < right - offset ? left : right;
|
|
} else {
|
|
return this.getRelativeOffset(
|
|
offset, direction > 0 ? 1 : -1, ve.dm.Document.isContentOffset
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Gets a structural offset a given distance forwards or backwards from another.
|
|
*
|
|
* This method is a wrapper around {getRelativeOffset}, using {ve.dm.Document.isStructuralOffset} as
|
|
* the offset validation callback.
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to start from
|
|
* @param {Integer} distance Number of structural offsets to move
|
|
* @param {Boolean} [unrestricted] Only return true if any kind of element can be inserted at offset
|
|
* @returns {Integer} Relative structural offset
|
|
*/
|
|
ve.dm.Document.prototype.getRelativeStructuralOffset = function( offset, distance, unrestricted ) {
|
|
// Optimization: start and end are always unrestricted structural offsets
|
|
if ( distance === 0 && ( offset === 0 || offset === this.data.length ) ) {
|
|
return offset;
|
|
}
|
|
return this.getRelativeOffset(
|
|
offset, distance, ve.dm.Document.isStructuralOffset, unrestricted
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Gets the nearest structural offset to a given offset.
|
|
*
|
|
* If the offset is already a valid offset, it will be returned unchanged. This method differs from
|
|
* calling {getRelativeStructuralOffset} with a zero length differece because the direction can be
|
|
* controlled without nessecarily moving the offset if it's already valid. Also, if the direction
|
|
* is 0 or undefined than nearest offsets will be found to the left and right and the one with the
|
|
* shortest distance will be used.
|
|
*
|
|
* This method is a wrapper around {getRelativeOffset}, using {ve.dm.Document.isStructuralOffset} as
|
|
* the offset validation callback.
|
|
*
|
|
* @method
|
|
* @param {Integer} offset Offset to start from
|
|
* @param {Integer} [direction] Direction to prefer matching offset in, -1 for left and 1 for right
|
|
* @param {Boolean} [unrestricted] Only return true if any kind of element can be inserted at offset
|
|
* @returns {Integer} Nearest structural offset
|
|
*/
|
|
ve.dm.Document.prototype.getNearestStructuralOffset = function( offset, direction, unrestricted ) {
|
|
if ( ve.dm.Document.isStructuralOffset( this.data, offset, unrestricted ) ) {
|
|
return offset;
|
|
}
|
|
if ( !direction ) {
|
|
var left = this.getRelativeOffset(
|
|
offset, -1, ve.dm.Document.isStructuralOffset, unrestricted
|
|
),
|
|
right = this.getRelativeOffset(
|
|
offset, 1, ve.dm.Document.isStructuralOffset, unrestricted
|
|
);
|
|
return offset - left < right - offset ? left : right;
|
|
} else {
|
|
return this.getRelativeOffset(
|
|
offset, direction > 0 ? 1 : -1, ve.dm.Document.isStructuralOffset, unrestricted
|
|
);
|
|
}
|
|
};
|
|
|
|
// TODO this function needs more work but it seems to work, mostly
|
|
/**
|
|
* Fix up data so it can safely be inserted into the linear model at offset.
|
|
* @param data {Array} Snippet of linear model data to insert
|
|
* @param offset {Integer} Offset in the linear model where the caller wants to insert data
|
|
* @returns {Array} A (possibly modified) copy of data
|
|
*/
|
|
ve.dm.Document.prototype.fixupInsertion = function( data, offset ) {
|
|
var newData = [], i, j, openingStack = [], closingStack = [], fixupStack = [], node, index,
|
|
parentNode, parentType, childType, allowedParents, allowedChildren,
|
|
parentsOK, childrenOK, openings, closings, expectedType, popped;
|
|
node = this.getNodeFromOffset( offset );
|
|
// TODO update for iscontent and cancontaincontent stuff
|
|
|
|
// TODO document
|
|
function writeElement( element, index ) {
|
|
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] === element.type
|
|
) {
|
|
// The top of closingStack is now balanced out, so remove it
|
|
closingStack.pop();
|
|
} else {
|
|
// This opens something new, put it on openingStack
|
|
openingStack.push( 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();
|
|
} 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 = node.getType();
|
|
closingStack.push( expectedType );
|
|
node = node.getParent();
|
|
if ( !node ) {
|
|
throw 'Inserted data is trying to close the root node ' +
|
|
'(at index ' + index + ')';
|
|
}
|
|
}
|
|
if ( element.type !== '/' + expectedType ) {
|
|
throw 'Type mismatch, expected /' + expectedType +
|
|
' but got ' + element.type + ' (at index ' + index + ')';
|
|
}
|
|
}
|
|
newData.push( element );
|
|
}
|
|
|
|
parentNode = node;
|
|
parentType = parentNode.getType();
|
|
for ( i = 0; i < data.length; i++ ) {
|
|
if ( data[i].type === undefined ) {
|
|
// Content, write through
|
|
// TODO check that content is allowed at the current offset
|
|
// TODO make this aware of text nodes
|
|
newData.push( data[i] );
|
|
} else if ( data[i].type.charAt( 0 ) !== '/' ) {
|
|
// Opening
|
|
// Make sure that opening this element here does not violate the
|
|
// parent/children rules. If it does, insert stuff to fix it
|
|
childType = data[i].type;
|
|
openings = [];
|
|
do {
|
|
allowedParents = ve.dm.nodeFactory.getParentNodeTypes( childType );
|
|
parentsOK = allowedParents === null ||
|
|
$.inArray( parentType, allowedParents ) !== -1;
|
|
if ( !parentsOK ) {
|
|
// We can't have this as the parent
|
|
if ( allowedParents.length === 0 ) {
|
|
throw 'Cannot insert ' + childType + ' because it ' +
|
|
' cannot have a parent (at index ' + i + ')';
|
|
}
|
|
// Open an allowed node around this node
|
|
childType = allowedParents[0];
|
|
openings.unshift( { 'type': childType } );
|
|
}
|
|
} while ( !parentsOK );
|
|
closings = [];
|
|
do {
|
|
allowedChildren = ve.dm.nodeFactory.getChildNodeTypes( parentType );
|
|
childrenOK = allowedChildren === null ||
|
|
$.inArray( childType, allowedChildren ) !== -1;
|
|
if ( !childrenOK ) {
|
|
// We can't insert this into this parent
|
|
// Close the parent and try one level up
|
|
closings.push( { 'type': '/' + parentType } );
|
|
if ( openingStack.length > 0 ) {
|
|
parentType = openingStack.pop();
|
|
// The opening was on openingStack, so we're closing
|
|
// a node that was opened within data. Don't track
|
|
// that on closingStack
|
|
} else {
|
|
parentNode = parentNode.getParent();
|
|
if ( !parentNode ) {
|
|
throw 'Cannot insert ' + childType + ' even ' +
|
|
' after closing all containing nodes ' +
|
|
'(at index ' + i + ')';
|
|
}
|
|
parentType = parentNode.getType();
|
|
}
|
|
}
|
|
} while( !childrenOK );
|
|
|
|
for ( j = 0; j < closings.length; j++ ) {
|
|
writeElement( closings[j], i );
|
|
}
|
|
for ( j = 0; j < openings.length; j++ ) {
|
|
writeElement( openings[j], i );
|
|
}
|
|
writeElement( data[i], i );
|
|
fixupStack.push( { 'expectedType': '/' + data[i].type, 'openings': openings, 'closings': closings } );
|
|
parentType = data[i].type;
|
|
} else {
|
|
// Closing
|
|
writeElement( data[i], i );
|
|
if ( fixupStack.length > 0 && fixupStack[fixupStack.length - 1].expectedType == data[i].type ) {
|
|
popped = fixupStack.pop();
|
|
// Go through these in reverse!
|
|
for ( j = popped.openings.length - 1; j >= 0; j-- ) {
|
|
writeElement( { 'type': '/' + popped.openings[j].type }, i );
|
|
}
|
|
for ( j = popped.closings.length - 1; j >= 0; j-- ) {
|
|
// TODO keep actual elements around so attributes are preserved
|
|
writeElement( { 'type': popped.closings[j].type.substr( 1 ) }, i );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }, 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( { 'type': popped.substr( 1 ) }, i );
|
|
}
|
|
|
|
return newData;
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
ve.extendClass( ve.dm.Document, ve.Document );
|