mediawiki-extensions-Visual.../modules/ve/dm/lineardata/ve.dm.ElementLinearData.js
Catrope eac44c39f4 Make the AnnotationSet constructor take an array of indexes
Before, it took an array of objects and translated those to indexes
using the store. Literally every caller outside of the test suite got
an array of indexes from the linear model, translated those to objects,
then passed them into the AnnotationSet constructor which translated
them right back to indexes.

The previous behavior was kind of ridiculous on its face, but the
reason we found it is because Inez was investigating the performance
degradation when bolding a line and found that half of it was due
to the hundreds of ve.getHash() calls caused by this behavior.

Change-Id: I38df8ae9f6392849dacf477ea2f804283c964417
2013-04-18 10:56:03 -07:00

703 lines
23 KiB
JavaScript

/*!
* VisualEditor ElementLinearData class.
*
* Class containing element linear data and an index-value store.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Element linear data storage
*
* @class
* @extends ve.dm.LinearData
* @constructor
* @param {ve.dm.IndexValueStore} store Index-value store
* @param {Array} [data] Linear data
*/
ve.dm.ElementLinearData = function VeDmElementLinearData( store, data ) {
ve.dm.LinearData.call( this, store, data );
};
/* Inheritance */
ve.inheritClass( ve.dm.ElementLinearData, ve.dm.LinearData );
/* Methods */
/**
* Get the type of the element at a specified offset
*
* This will return the same string for close and open elements.
*
* @method
* @param {number} offset Document offset
* @returns {string} Type of the element
*/
ve.dm.ElementLinearData.prototype.getType = function ( offset ) {
var item = this.getData( offset );
return this.isCloseElementData( offset ) ? item.type.substr( 1 ) : item.type;
};
/**
* Check if 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.
*
* Element data:
* <heading> a </heading> <paragraph> b c <img></img> </paragraph>
* ^ . ^ ^ . . ^ ^ ^ .
*
* @method
* @param {number} offset Document offset
* @returns {boolean} Data at offset is an element
*/
ve.dm.ElementLinearData.prototype.isElementData = function ( offset ) {
var item = this.getData( offset );
// Data exists at offset and appears to be an element
return item !== undefined && typeof item.type === 'string';
};
/**
* Checks if data at a given offset is a close elements.
* @method
* @param {number} offset Document offset
* @returns {boolean} Data at offset is a close element
*/
ve.dm.ElementLinearData.prototype.isCloseElementData = function ( offset ) {
return this.isElementData( offset ) && this.getData( offset ).type.charAt( 0 ) === '/';
};
/**
* Check 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.
*
* Content offsets:
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
* . ^ ^ . ^ ^ ^ . ^ .
*
* Content offsets:
* <list> <listItem> </listItem> <list>
* . . . . .
*
* @method
* @param {number} offset Document offset
* @returns {boolean} Content can be inserted at offset
*/
ve.dm.ElementLinearData.prototype.isContentOffset = function ( offset ) {
// Edges are never content
if ( offset === 0 || offset === this.getLength() ) {
return false;
}
var left = this.getData( offset - 1 ),
right = this.getData( 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 )
)
)
)
);
};
/**
* Check 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.
*
* Structural offsets (unrestricted = false):
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
* ^ . . ^ . . . . . ^
*
* Structural offsets (unrestricted = true):
* <heading> a </heading> <paragraph> b c <img> </img> </paragraph>
* ^ . . ^ . . . . . ^
*
* Structural offsets (unrestricted = false):
* <list> <listItem> </listItem> <list>
* ^ ^ ^ ^ ^
*
* Content branch offsets (unrestricted = true):
* <list> <listItem> </listItem> <list>
* ^ . ^ . ^
*
* @method
* @param {number} 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.ElementLinearData.prototype.isStructuralOffset = function ( offset, unrestricted ) {
// Edges are always structural
if ( offset === 0 || offset === this.getLength() ) {
return true;
}
// Offsets must be within range and both sides must be elements
var left = this.getData( offset - 1 ),
right = this.getData( 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.canNodeHaveChildrenNotContent( 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
)
)
)
);
};
/**
* Check 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).
*
* @method
* @returns {boolean} At least one elements exists in data
*/
ve.dm.ElementLinearData.prototype.containsElementData = function () {
var i = this.getLength();
while ( i-- ) {
if ( this.getData( i ).type !== undefined ) {
return true;
}
}
return false;
};
/**
* Check 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.
*
* @method
* @returns {boolean} True if all elements in data are content elements
*/
ve.dm.ElementLinearData.prototype.isContentData = function () {
var item, i = this.getLength();
while ( i-- ) {
item = this.getData( i );
if ( item.type !== undefined &&
item.type.charAt( 0 ) !== '/' &&
!ve.dm.nodeFactory.isNodeContent( item.type )
) {
return false;
}
}
return true;
};
/**
* Get annotations covered by an offset.
*
* The returned AnnotationSet is a clone of the one in the document data.
*
* @method
* @param {number} offset Offset to get annotations for
* @returns {ve.dm.AnnotationSet} A set of all annotation objects offset is covered by
* @throws {Error} offset out of bounds
*/
ve.dm.ElementLinearData.prototype.getAnnotationsFromOffset = function ( offset ) {
if ( offset < 0 || offset > this.getLength() ) {
throw new Error( 'offset ' + offset + ' out of bounds' );
}
// Since annotations are not stored on a closing leaf node,
// rewind offset by 1 to return annotations for that structure
var annotations;
if (
ve.isPlainObject( this.getData( offset ) ) && // structural offset
this.getData( offset ).hasOwnProperty( 'type' ) && // just in case
this.getData( offset ).type.charAt( 0 ) === '/' && // closing offset
ve.dm.nodeFactory.canNodeHaveChildren(
this.getData( offset ).type.substr( 1 )
) === false // leaf node
) {
offset = this.getRelativeContentOffset( offset, -1 );
}
annotations = this.getData( offset ).annotations || this.getData( offset )[1];
return annotations ?
new ve.dm.AnnotationSet( this.getStore(), annotations ) :
new ve.dm.AnnotationSet( this.getStore() );
};
/**
* Set annotations of data at a specified offset.
*
* Cleans up data structure if annotation set is empty.
*
* @method
* @param {number} offset Offset to set annotations at
* @param {ve.dm.AnnotationSet} annotations Annotations to set
*/
ve.dm.ElementLinearData.prototype.setAnnotationsAtOffset = function ( offset, annotations ) {
var character, item = this.getData( offset ), isElement = this.isElementData( offset );
if ( !annotations.isEmpty() ) {
if ( isElement ) {
// New element annotation
item.annotations = this.getStore().indexes( annotations.get() );
} else {
// New character annotation
character = this.getCharacterData( offset );
this.setData( offset, [character, this.getStore().indexes( annotations.get() )] );
}
} else {
if ( isElement ) {
// Cleanup empty element annotation
delete item.annotations;
} else {
// Cleanup empty character annotation
character = this.getCharacterData( offset );
this.setData( offset, character );
}
}
};
ve.dm.ElementLinearData.prototype.getCharacterData = function ( offset ) {
var item = this.getData( offset );
return ve.isArray( item ) ? item[0] : item;
};
/**
* Gets the range of content surrounding a given offset that's covered by a given annotation.
*
* @method
* @param {number} 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.ElementLinearData.prototype.getAnnotatedRangeFromOffset = function ( offset, annotation ) {
var start = offset,
end = offset;
if ( this.getAnnotationsFromOffset( offset ).contains( annotation ) === false ) {
return null;
}
while ( start > 0 ) {
start--;
if ( this.getAnnotationsFromOffset( start ).contains( annotation ) === false ) {
start++;
break;
}
}
while ( end < this.getLength() ) {
if ( this.getAnnotationsFromOffset( end ).contains( annotation ) === false ) {
break;
}
end++;
}
return new ve.Range( start, end );
};
/**
* Get the range of an annotation found within a range.
*
* @method
* @param {number} 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 a copy of the range
*/
ve.dm.ElementLinearData.prototype.getAnnotatedRangeFromSelection = function ( range, annotation ) {
var start = range.start,
end = range.end;
while ( start > 0 ) {
start--;
if ( this.getAnnotationsFromOffset( start ).contains( annotation ) === false ) {
start++;
break;
}
}
while ( end < this.getLength() ) {
if ( this.getAnnotationsFromOffset( end ).contains( annotation ) === false ) {
break;
}
end++;
}
return new ve.Range( start, end );
};
/**
* Get annotations common to all content in a range.
*
* @method
* @param {ve.Range} range Range to get annotations for
* @param {boolean} [all] Get all annotations found within the range, not just those that cover it
* @returns {ve.dm.AnnotationSet} All annotation objects range is covered by
*/
ve.dm.ElementLinearData.prototype.getAnnotationsFromRange = function ( range, all ) {
var i,
left,
right;
// Look at left side of range for annotations
left = this.getAnnotationsFromOffset( range.start );
// Shortcut for single character and zero-length ranges
if ( range.getLength() === 0 || range.getLength() === 1 ) {
return left;
}
// Iterator over the range, looking for annotations, starting at the 2nd character
for ( i = range.start + 1; i < range.end; i++ ) {
// Skip non character data
if ( this.isElementData( i ) ) {
continue;
}
// Current character annotations
right = this.getAnnotationsFromOffset( i );
if ( all && !right.isEmpty() ) {
left.addSet( right );
} else if ( !all ) {
// A non annotated character indicates there's no full coverage
if ( right.isEmpty() ) {
return new ve.dm.AnnotationSet( this.getStore() );
}
// Exclude annotations that are in left but not right
left.removeNotInSet( right );
// If we've reduced left down to nothing, just stop looking
if ( left.isEmpty() ) {
break;
}
}
}
return left;
};
/**
* Get a range without any whitespace content at the beginning and end.
*
* @method
* @param {ve.Range} [range] Range of data to get, all data will be given by default
* @returns {Object} A new range if modified, otherwise returns passed range
*/
ve.dm.ElementLinearData.prototype.trimOuterSpaceFromRange = function ( range ) {
var start = range.start,
end = range.end;
while ( this.getCharacterData( end - 1 ) === ' ' ) {
end--;
}
while ( start < end && this.getCharacterData( start ) === ' ' ) {
start++;
}
return range.to < range.end ? new ve.Range( end, start ) : new ve.Range( start, end );
};
/**
* Get an offset at a distance to an offset that passes a validity test.
*
* - If {offset} is not already valid, one step will be used to move it to a valid one.
* - If {offset} is already valid and cannot be moved in the direction of {distance} and still be
* valid, it will be left where it is
* - 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 {number} offset Offset to start from
* @param {number} distance Number of valid offsets to move
* @param {Function} callback Function to call to check if an offset is valid which will be
* given initial argument of offset
* @param {Mixed...} [args] Additional arguments to pass to the callback
* @returns {number} Relative valid offset or -1 if there are no valid offsets in document
*/
ve.dm.ElementLinearData.prototype.getRelativeOffset = function ( offset, distance, callback ) {
var i, direction,
args = Array.prototype.slice.call( arguments, 3 ),
start = offset,
steps = 0,
turnedAround = false;
// 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( this, [offset].concat( args ) ) ) {
return offset;
} else {
distance = 1;
}
}
// Initial values
direction = (
offset <= 0 ? 1 : (
offset >= this.getLength() ? -1 : (
distance > 0 ? 1 : -1
)
)
);
distance = Math.abs( distance );
i = start + direction;
offset = -1;
// Iteration
while ( i >= 0 && i <= this.getLength() ) {
if ( callback.apply( this, [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.getLength() ) )
) {
// Before we turn around, let's see if we are at a valid position
if ( callback.apply( this, [start].concat( args ) ) ) {
// Stay where we are
return start;
}
// Start over going in the opposite direction
direction *= -1;
i = start;
distance = 1;
turnedAround = true;
}
i += direction;
}
return offset;
};
/**
* Get a content offset at a distance from an offset.
*
* This method is a wrapper around {getRelativeOffset}, using {ve.dm.Document.isContentOffset} as
* the offset validation callback.
*
* @method
* @param {number} offset Offset to start from
* @param {number} distance Number of content offsets to move
* @returns {number} Relative content offset or -1 if there are no valid offsets in document
*/
ve.dm.ElementLinearData.prototype.getRelativeContentOffset = function ( offset, distance ) {
return this.getRelativeOffset( offset, distance, this.constructor.prototype.isContentOffset );
};
/**
* Get the nearest content offset to an offset.
*
* If the offset is already a valid offset, it will be returned unchanged. This method differs from
* calling {getRelativeContentOffset} with a zero length difference because the direction can be
* controlled without necessarily 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 {this.isContentOffset} as
* the offset validation callback.
*
* @method
* @param {number} offset Offset to start from
* @param {number} [direction] Direction to prefer matching offset in, -1 for left and 1 for right
* @returns {number} Nearest content offset or -1 if there are no valid offsets in document
*/
ve.dm.ElementLinearData.prototype.getNearestContentOffset = function ( offset, direction ) {
if ( this.isContentOffset( offset ) ) {
return offset;
}
if ( direction === undefined ) {
var left = this.getRelativeOffset( offset, -1, this.constructor.prototype.isContentOffset ),
right = this.getRelativeOffset( offset, 1, this.constructor.prototype.isContentOffset );
return offset - left < right - offset ? left : right;
} else {
return this.getRelativeOffset(
offset, direction > 0 ? 1 : -1, this.constructor.prototype.isContentOffset
);
}
};
/**
* Get a structural offset at a distance from an offset.
*
* This method is a wrapper around {getRelativeOffset}, using {this.isStructuralOffset} as
* the offset validation callback.
*
* @method
* @param {number} offset Offset to start from
* @param {number} distance Number of structural offsets to move
* @param {boolean} [unrestricted] Only consider offsets where any kind of element can be inserted
* @returns {number} Relative structural offset
*/
ve.dm.ElementLinearData.prototype.getRelativeStructuralOffset = function ( offset, distance, unrestricted ) {
// Optimization: start and end are always unrestricted structural offsets
if ( distance === 0 && ( offset === 0 || offset === this.getLength() ) ) {
return offset;
}
return this.getRelativeOffset(
offset, distance, this.constructor.prototype.isStructuralOffset, unrestricted
);
};
/**
* Get the nearest structural offset to an offset.
*
* If the offset is already a valid offset, it will be returned unchanged. This method differs from
* calling {getRelativeStructuralOffset} with a zero length difference because the direction can be
* controlled without necessarily 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 {this.isStructuralOffset} as
* the offset validation callback.
*
* @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 {boolean} [unrestricted] Only consider offsets where any kind of element can be inserted
* @returns {number} Nearest structural offset
*/
ve.dm.ElementLinearData.prototype.getNearestStructuralOffset = function ( offset, direction, unrestricted ) {
if ( this.isStructuralOffset( offset, unrestricted ) ) {
return offset;
}
if ( !direction ) {
var left = this.getRelativeOffset(
offset, -1, this.constructor.prototype.isStructuralOffset, unrestricted
),
right = this.getRelativeOffset(
offset, 1, this.constructor.prototype.isStructuralOffset, unrestricted
);
return offset - left < right - offset ? left : right;
} else {
return this.getRelativeOffset(
offset, direction > 0 ? 1 : -1, this.constructor.prototype.isStructuralOffset, unrestricted
);
}
};
/**
* Get the nearest word boundaries as a range.
*
* The offset will first be moved to the nearest content offset if it's not at one already.
* Elements are always word boundaries.
*
* @method
* @param {number} offset Offset to start from
* @returns {ve.Range} Range around nearest word boundaries
*/
ve.dm.ElementLinearData.prototype.getNearestWordRange = function ( offset ) {
var offsetLeft, offsetRight,
dataString = new ve.dm.DataString( this.getData() );
offset = this.getNearestContentOffset( offset );
// If the cursor offset is a break (i.e. the start/end of word) we should
// check one position either side to see if there is a non-break
// and if so, move the offset accordingly
if ( unicodeJS.wordbreak.isBreak( dataString, offset ) ) {
if ( !unicodeJS.wordbreak.isBreak( dataString, offset + 1 ) ) {
offset++;
} else if ( !unicodeJS.wordbreak.isBreak( dataString, offset - 1 ) ) {
offset--;
} else {
// just return one character to the right, unless we are at the end
// of the text, in which case the character to the left
if ( dataString.read( offset ) !== null ) {
return new ve.Range( offset, offset + 1 );
} else {
return new ve.Range( offset - 1, offset );
}
}
}
offsetRight = unicodeJS.wordbreak.nextBreakOffset( dataString, offset );
offsetLeft = unicodeJS.wordbreak.prevBreakOffset( dataString, offset );
return new ve.Range( offsetLeft, offsetRight );
};