/*!
* VisualEditor ElementLinearData classes.
*
* Class containing element linear data and an hash-value store.
*
* @copyright See AUTHORS.txt
*/
/**
* Element linear data storage
*
* @class
* @extends ve.dm.FlatLinearData
* @constructor
* @param {ve.dm.HashValueStore} store Hash-value store
* @param {Array} [data] Linear data
*/
ve.dm.ElementLinearData = function VeDmElementLinearData() {
// Parent constructor
ve.dm.ElementLinearData.super.apply( this, arguments );
};
/* Inheritance */
OO.inheritClass( ve.dm.ElementLinearData, ve.dm.FlatLinearData );
/* Static Members */
ve.dm.ElementLinearData.static.startWordRegExp = new RegExp(
'^(' + unicodeJS.characterclass.patterns.word + ')'
);
ve.dm.ElementLinearData.static.endWordRegExp = new RegExp(
'(' + unicodeJS.characterclass.patterns.word + ')$'
);
/* Static Methods */
/**
* Compare two elements' basic properties
*
* Elements are comparable if they have the same type and attributes, or
* have the same text data. Anything semantically irrelevant is filtered
* out first.
*
* When changing, ensure that ve.dm.Transaction.static.compareElementsForTranslate
* is also updated.
*
* @param {Object|Array|string} a First element
* @param {Object|Array|string} b Second element
* @return {boolean} Elements are comparable
*/
ve.dm.ElementLinearData.static.compareElementsUnannotated = function ( a, b ) {
let aPlain = a,
bPlain = b;
if ( a === b ) {
return true;
}
if ( Array.isArray( a ) ) {
aPlain = a[ 0 ];
}
if ( Array.isArray( b ) ) {
bPlain = b[ 0 ];
}
if ( typeof aPlain === 'string' && typeof bPlain === 'string' ) {
return aPlain === bPlain;
}
if ( typeof a !== typeof b ) {
// Different types
return false;
}
// By this point, both must be objects, so must have equal types
if ( a.type !== b.type ) {
return false;
}
// Both objects are open elements, so compare hashes.
// (NB we only need to check one as they have equal .type)
if ( ve.dm.LinearData.static.isOpenElementData( a ) ) {
// As we are using hashes, we don't need to worry about annotations
aPlain = ve.dm.modelRegistry.lookup( a.type ).static.getHashObject( a );
delete aPlain.originalDomElementsHash;
bPlain = ve.dm.modelRegistry.lookup( b.type ).static.getHashObject( b );
delete bPlain.originalDomElementsHash;
return ve.compare( aPlain, bPlain );
} else {
// Both objects are close elements, no need to compare attributes
return true;
}
};
/**
* Compare two elements' basic properties and annotations
*
* Elements are comparable if they have the same type, attributes,
* text data and annotations, as determined by
* ve.dm.AnnotationSet#compareTo .
*
* @param {Object|Array|string} a First element
* @param {Object|Array|string} b Second element
* @param {ve.dm.HashValueStore} aStore First element's store
* @param {ve.dm.HashValueStore} [bStore] Second element's store, if different
* @return {boolean} Elements are comparable
*/
ve.dm.ElementLinearData.static.compareElements = function ( a, b, aStore, bStore ) {
if ( a === b ) {
return true;
}
const typeofA = typeof a;
if ( typeofA !== typeof b ) {
// Different types
return false;
}
if ( typeofA === 'string' ) {
// Both strings, and not equal
return false;
}
if ( !this.compareElementsUnannotated( a, b ) ) {
return false;
}
let aAnnotations, bAnnotations;
// Elements are equal without annotations, now compare annotations:
if ( Array.isArray( a ) ) {
aAnnotations = a[ 1 ];
}
if ( Array.isArray( b ) ) {
bAnnotations = b[ 1 ];
}
if ( a && a.type ) {
aAnnotations = a.annotations;
}
if ( b && b.type ) {
bAnnotations = b.annotations;
}
const aSet = new ve.dm.AnnotationSet( aStore, aAnnotations || [] );
const bSet = new ve.dm.AnnotationSet( bStore || aStore, bAnnotations || [] );
return aSet.compareTo( bSet );
};
/**
* Read the array of annotation store hashes from an item of linear data
*
* @param {string|Array|Object} item Item of linear data
* @return {string[]} An array of annotation store hashes
*/
ve.dm.ElementLinearData.static.getAnnotationHashesFromItem = function ( item ) {
if ( typeof item === 'string' ) {
return [];
} else if ( item.annotations ) {
return item.annotations.slice();
} else if ( item[ 1 ] ) {
return item[ 1 ].slice();
} else {
return [];
}
};
/**
* Set annotations' store hashes at a specified offset.
*
* Cleans up data structure if hashes array is empty.
*
* @param {string|Array|Object} item Item of linear data
* @param {string[]} hashes Annotations' store hashes
* @return {string|Array|Object} Deep-copied, modified item
*/
ve.dm.ElementLinearData.static.replaceAnnotationHashesForItem = function ( item, hashes ) {
const isElement = ve.dm.LinearData.static.isElementData( item );
item = ve.copy( item );
hashes = hashes.slice();
if ( hashes.length > 0 ) {
if ( isElement ) {
// New element annotation
item.annotations = hashes;
} else {
// New character annotation
const character = ve.dm.ElementLinearData.static.getCharacterDataFromItem( item );
item = [ character, hashes ];
}
} else {
if ( isElement ) {
// Cleanup empty element annotation
delete item.annotations;
} else {
// Cleanup empty character annotation
item = ve.dm.ElementLinearData.static.getCharacterDataFromItem( item );
}
}
return item;
};
/**
* Get character data from an item
*
* @param {string|Array|Object} item Item to get character data from
* @return {string} Character data, or '' if no character data
*/
ve.dm.ElementLinearData.static.getCharacterDataFromItem = function ( item ) {
const data = Array.isArray( item ) ? item[ 0 ] : item;
return typeof data === 'string' ? data : '';
};
/* Methods */
/**
* 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>
* . . . . .
*
* @param {number} offset Document offset
* @return {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;
}
const left = this.getData( offset - 1 );
const right = this.getData( offset );
const 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
( Array.isArray( left ) || Array.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.slice( 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 directly 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>
* ^ . ^ . ^
*
* @param {number} offset Document offset
* @param {boolean} [unrestricted] Only return true if any kind of element can be inserted at offset
* @return {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
const left = this.getData( offset - 1 );
const right = this.getData( offset );
const 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.slice( 1 ) ) ||
!factory.isNodeContent( left.type.slice( 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.slice( 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 non-content elements in 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.
*
* @return {boolean} True if all elements in data are content elements
*/
ve.dm.ElementLinearData.prototype.isContentData = function () {
let i = this.getLength();
while ( i-- ) {
const item = this.getData( i );
if ( item.type !== undefined &&
item.type.charAt( 0 ) !== '/' &&
!ve.dm.nodeFactory.isNodeContent( item.type )
) {
return false;
}
}
return true;
};
/**
* Check if an annotation can be applied at a specific offset
*
* @param {number} offset
* @param {ve.dm.Annotation} annotation
* @param {boolean} [ignoreClose] Ignore close elements, otherwise check if their open element is annotatable
* @return {boolean} Annotation can be applied at this offset
*/
ve.dm.ElementLinearData.prototype.canTakeAnnotationAtOffset = function ( offset, annotation, ignoreClose ) {
if ( this.isElementData( offset ) ) {
if ( ignoreClose && this.isCloseElementData( offset ) ) {
return false;
}
const type = this.getType( offset );
// Structural nodes are never annotatable
// Disallowed annotations can't be set
return ve.dm.nodeFactory.isNodeContent( type ) && ve.dm.nodeFactory.canNodeTakeAnnotation( type, annotation );
} else {
// Text is always annotatable
return true;
}
};
/**
* Get annotations' store hashes covered by an offset.
*
* @param {number} offset Offset to get annotations for
* @param {boolean} [ignoreClose] Ignore annotations on close elements
* @return {string[]} An array of annotation store hashes the offset is covered by
* @throws {Error} offset out of bounds
*/
ve.dm.ElementLinearData.prototype.getAnnotationHashesFromOffset = function ( offset, ignoreClose ) {
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
if (
!ignoreClose &&
this.isCloseElementData( offset ) &&
!ve.dm.nodeFactory.canNodeHaveChildren( this.getType( offset ) ) // Leaf node
) {
offset = this.getRelativeContentOffset( offset, -1 );
if ( offset === -1 ) {
return [];
}
}
const item = this.getData( offset );
return this.constructor.static.getAnnotationHashesFromItem( item ) || [];
};
/**
* Get annotations covered by an offset.
*
* The returned AnnotationSet is a clone of the one in the data.
*
* @param {number} offset Offset to get annotations for
* @param {boolean} [ignoreClose] Ignore annotations on close elements
* @return {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, ignoreClose ) {
return new ve.dm.AnnotationSet( this.getStore(), this.getAnnotationHashesFromOffset( offset, ignoreClose ) );
};
/**
* Set annotations of data at a specified offset.
*
* Cleans up data structure if annotation set is empty.
*
* @param {number} offset Offset to set annotations at
* @param {ve.dm.AnnotationSet} annotations Annotations to set
*/
ve.dm.ElementLinearData.prototype.setAnnotationsAtOffset = function ( offset, annotations ) {
this.setAnnotationHashesAtOffset( offset, this.getStore().hashAll( annotations.get() ) );
};
/**
* Set annotations' store hashes at a specified offset.
*
* Cleans up data structure if hashes array is empty.
*
* @param {number} offset Offset to set annotation hashes at
* @param {string[]} hashes Annotations' store hashes
*/
ve.dm.ElementLinearData.prototype.setAnnotationHashesAtOffset = function ( offset, hashes ) {
let item = this.getData( offset );
item = this.constructor.static.replaceAnnotationHashesForItem( item, hashes );
this.setData( offset, item );
};
/**
* Set or unset an attribute at a specified offset.
*
* @param {number} offset Offset to set/unset attribute at
* @param {string} key Attribute name
* @param {any} value Value to set, or undefined to unset
*/
ve.dm.ElementLinearData.prototype.setAttributeAtOffset = function ( offset, key, value ) {
if ( !this.isElementData( offset ) ) {
return;
}
this.modifyData( offset, ( item ) => {
if ( value === undefined ) {
// Clear
if ( item.attributes ) {
delete item.attributes[ key ];
}
} else {
// Automatically initialize attributes object
if ( !item.attributes ) {
item.attributes = {};
}
// Set
item.attributes[ key ] = value;
}
} );
};
/**
* Get character data at a specified offset
*
* @param {number} offset Offset to get character data from
* @return {string} Character data
*/
ve.dm.ElementLinearData.prototype.getCharacterData = function ( offset ) {
const item = this.getData( offset );
return ve.dm.ElementLinearData.static.getCharacterDataFromItem( item );
};
/**
* Gets the range of content surrounding a given offset that's covered by a given annotation.
*
* @param {number} offset Offset to begin looking forward and backward from
* @param {Object} annotation Annotation to test for coverage with
* @return {ve.Range|null} Range of content covered by annotation, or null if offset is not covered
*/
ve.dm.ElementLinearData.prototype.getAnnotatedRangeFromOffset = function ( offset, annotation ) {
let 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.
*
* @param {ve.Range} range Range to begin looking forward and backward from
* @param {ve.dm.Annotation} annotation Annotation to test for coverage with
* @return {ve.Range|null} Range of content covered by annotation, or a copy of the range
*/
ve.dm.ElementLinearData.prototype.getAnnotatedRangeFromSelection = function ( range, annotation ) {
let 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.
*
* @param {ve.Range} range Range to get annotations for
* @param {boolean} [all=false] Get all annotations found within the range, not just those that cover it
* @param {boolean} [nullIfContentEmpty=false] Returns null (instead of an empty ve.dm.AnnotationSet) if
* there is no content in the range.
* @return {ve.dm.AnnotationSet|null} All annotation objects range is covered by.
*/
ve.dm.ElementLinearData.prototype.getAnnotationsFromRange = function ( range, all, nullIfContentEmpty ) {
let ignoreChildrenDepth = 0;
let left, right;
// Iterator over the range, looking for annotations, starting at the 2nd character
for ( let i = range.start; i < range.end; i++ ) {
if ( this.isElementData( i ) ) {
if ( ve.dm.nodeFactory.shouldIgnoreChildren( this.getType( i ) ) ) {
ignoreChildrenDepth += this.isOpenElementData( i ) ? 1 : -1;
}
// Skip non-content data
if ( !ve.dm.nodeFactory.isNodeContent( this.getType( i ) ) ) {
continue;
}
}
// Ignore things inside ignoreChildren nodes
if ( ignoreChildrenDepth > 0 ) {
continue;
}
if ( !left ) {
// Look at left side of range for annotations
left = this.getAnnotationsFromOffset( i );
// Shortcut for single character and zero-length ranges
if ( range.getLength() === 0 || range.getLength() === 1 ) {
return left;
}
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 comparable annotations that are in left but not right
left = left.getComparableAnnotationsFromSet( right );
// If we've reduced left down to nothing, just stop looking
if ( left.isEmpty() ) {
break;
}
}
}
return left || ( nullIfContentEmpty ? null : new ve.dm.AnnotationSet( this.getStore() ) );
};
/**
* Get the insertion annotations that should apply to a range.
*
* The semantics are intended to match Chromium's behaviour.
* TODO: This cannot match Firefox behaviour, which depends on the cursor's annotation
* boundary side, and performs a union of the annotations at each end of the selection;
* see https://phabricator.wikimedia.org/T113869 .
*
* @param {ve.Range} range The range into which text would be inserted
* @param {boolean} [startAfterAnnotations] Use annotations after cursor if collapsed
* @return {ve.dm.AnnotationSet} The insertion annotations that should apply
*/
ve.dm.ElementLinearData.prototype.getInsertionAnnotationsFromRange = function ( range, startAfterAnnotations ) {
let start;
// Get position for start annotations
if ( range.isCollapsed() && !startAfterAnnotations ) {
// Use the position just before the cursor
start = Math.max( 0, range.start - 1 );
} else {
// If uncollapsed, use the first character of the selection
// If collapsed, use the first position after the cursor
start = range.start;
}
let startAnnotations;
// Get startAnnotations: the annotations that apply at the selection start
if ( this.isContentOffset( start ) ) {
startAnnotations = this.getAnnotationsFromOffset( start );
} else {
startAnnotations = new ve.dm.AnnotationSet( this.getStore() );
}
let afterAnnotations;
// Get afterAnnotations: the annotations that apply straight after the selection
if ( this.isContentOffset( range.end ) ) {
afterAnnotations = this.getAnnotationsFromOffset( range.end );
} else {
// Use the empty set
afterAnnotations = new ve.dm.AnnotationSet( this.getStore() );
}
// Return those startAnnotations that either continue in afterAnnotations or
// should get added to appended content
return startAnnotations.filter( ( annotation ) => annotation.constructor.static.applyToAppendedContent ||
afterAnnotations.containsComparable( annotation ) );
};
/**
* Check if the range has any annotations
*
* @param {ve.Range} range Range to check for annotations
* @return {boolean} The range contains at least one annotation
*/
ve.dm.ElementLinearData.prototype.hasAnnotationsInRange = function ( range ) {
for ( let i = range.start; i < range.end; i++ ) {
if ( this.getAnnotationHashesFromOffset( i, true ).length ) {
return true;
}
}
return false;
};
/**
* Get a range without any whitespace content at the beginning and end.
*
* @param {ve.Range} range Range to trim
* @return {Object} Trimmed range
*/
ve.dm.ElementLinearData.prototype.trimOuterSpaceFromRange = function ( range ) {
let start = range.start,
end = range.end;
while ( /^\s+$/.test( this.getCharacterData( end - 1 ) ) ) {
end--;
}
while ( start < end && /^\s+$/.test( this.getCharacterData( start ) ) ) {
start++;
}
return range.to < range.end ? new ve.Range( end, start ) : new ve.Range( start, end );
};
/**
* Check if the data is just text
*
* @param {ve.Range} [range] Range to get the data for. The whole data set if not specified.
* @param {boolean} [ignoreNonContentNodes] Ignore all non-content nodes, e.g. paragraphs, headings, lists
* @param {string[]} [ignoredTypes] Only ignore specific non-content types
* @param {boolean} [ignoreCoveringAnnotations] Ignore covering annotations
* @param {boolean} [ignoreAllAnnotations] Ignore all annotations
* @return {boolean} The data is plain text
*/
ve.dm.ElementLinearData.prototype.isPlainText = function ( range, ignoreNonContentNodes, ignoredTypes, ignoreCoveringAnnotations, ignoreAllAnnotations ) {
range = range || new ve.Range( 0, this.getLength() );
let annotations;
if ( ignoreCoveringAnnotations ) {
annotations = this.getAnnotationsFromRange( range );
}
for ( let i = range.start; i < range.end; i++ ) {
if ( typeof this.data[ i ] === 'string' ) {
// Un-annotated text
continue;
} else if ( Array.isArray( this.data[ i ] ) ) {
// Annotated text
if ( ignoreAllAnnotations ) {
continue;
}
if (
ignoreCoveringAnnotations &&
annotations.containsAllOf( this.getAnnotationsFromOffset( i ) )
) {
continue;
}
} else if ( ignoreNonContentNodes || ignoredTypes ) {
// Element data
const type = this.getType( i );
if ( ignoredTypes && ignoredTypes.indexOf( type ) !== -1 ) {
continue;
}
if ( ignoreNonContentNodes && !ve.dm.nodeFactory.isNodeContent( type ) ) {
continue;
}
}
return false;
}
return true;
};
/**
* Execute a callback function for each group of consecutive content data (text or content element).
*
* @param {ve.Range} range Range in which to search
* @param {Function} callback Function called with the following parameters:
* @param {number} callback.offset Offset of the first datum of the run.
* @param {string} callback.text Text of the run (with content element opening/closing data
* replaced with U+FFFC).
*/
ve.dm.ElementLinearData.prototype.forEachRunOfContent = function ( range, callback ) {
let text = '';
for ( let i = range.start; i < range.end; i++ ) {
if ( !this.isElementData( i ) ) {
text += this.getCharacterData( i );
} else if ( ve.dm.nodeFactory.isNodeContent( this.getType( i ) ) ) {
text += '\uFFFC'; // U+FFFC OBJECT REPLACEMENT CHARACTER
} else {
if ( text ) {
callback( i - text.length, text );
}
text = '';
}
}
if ( text ) {
callback( range.end - text.length, text );
}
};
/**
* Get the data as plain text
*
* @param {boolean} [maintainIndices] Maintain data offset to string index alignment by replacing elements with line breaks
* @param {ve.Range} [range] Range to get the data for. The whole data set if not specified.
* @return {string} Data as plain text
*/
ve.dm.ElementLinearData.prototype.getText = function ( maintainIndices, range ) {
range = range || new ve.Range( 0, this.getLength() );
let text = '';
for ( let i = range.start; i < range.end; i++ ) {
if ( !this.isElementData( i ) ) {
text += this.getCharacterData( i );
} else if ( maintainIndices ) {
text += '\n';
}
}
return text;
};
/**
* Get the data as original source text (source mode only)
*
* Split paragraphs are converted to single line breaks. It is assumed the
* document contains nothing but plain text and paragraph elements.
*
* @param {ve.Range} [range] Range to get the data for. The whole data set if not specified.
* @return {string} Data as original source text
*/
ve.dm.ElementLinearData.prototype.getSourceText = function ( range ) {
return ve.dm.sourceConverter.getSourceTextFromDataRange( this.data, range );
};
/**
* 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 data does not contain a single valid offset the result will be -1
*
* Nodes that want their children to be ignored (see ve.dm.Node#static-ignoreChildren) are not
* descended into. Giving a starting offset inside an ignoreChildren node will give unpredictable
* results.
*
* @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 {...any} [args] Additional arguments to pass to the callback
* @return {number} Relative valid offset or -1 if there are no valid offsets in data
*/
ve.dm.ElementLinearData.prototype.getRelativeOffset = function ( offset, distance, callback, ...args ) {
// 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.call( this, offset, ...args ) ) {
return offset;
} else {
distance = 1;
}
}
// Initial values
let direction = (
offset <= 0 ? 1 : (
offset >= this.getLength() ? -1 : (
distance > 0 ? 1 : -1
)
)
);
distance = Math.abs( distance );
const start = offset;
let i = start + direction;
offset = -1;
let steps = 0;
let ignoreChildrenDepth = 0;
let turnedAround = false;
// Iteration
while ( i >= 0 && i <= this.getLength() ) {
// Detect when the search for a valid offset enters a node whose children should be
// ignored, and don't return an offset inside such a node. This clearly won't work
// if you start inside such a node, but you shouldn't be doing that to being with
const dataOffset = i + ( direction > 0 ? -1 : 0 );
if (
this.isElementData( dataOffset ) &&
ve.dm.nodeFactory.shouldIgnoreChildren( this.getType( dataOffset ) )
) {
const isOpen = this.isOpenElementData( dataOffset );
// We have entered a node if we step right over an open, or left over a close.
// Otherwise we have left a node
if ( ( direction > 0 && isOpen ) || ( direction < 0 && !isOpen ) ) {
ignoreChildrenDepth++;
} else {
ignoreChildrenDepth--;
if ( ignoreChildrenDepth < 0 ) {
return -1;
}
}
}
if ( callback.call( this, i, ...args ) ) {
if ( !ignoreChildrenDepth ) {
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() || this.getType( i - 1 ) === 'internalList' ) ) )
) {
// Before we turn around, let's see if we are at a valid position
if ( callback.call( this, start, ...args ) ) {
// Stay where we are
return start;
}
// Start over going in the opposite direction
direction *= -1;
i = start;
distance = 1;
turnedAround = true;
ignoreChildrenDepth = 0;
}
i += direction;
}
return offset;
};
/**
* Get a content offset at a distance from an offset.
*
* This method is a wrapper around {getRelativeOffset}, using {isContentOffset} as
* the offset validation callback.
*
* @param {number} offset Offset to start from
* @param {number} distance Number of content offsets to move
* @return {number} Relative content offset or -1 if there are no valid offsets in data
*/
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.
*
* @param {number} offset Offset to start from
* @param {number} [direction] Direction to prefer matching offset in, -1 for left and 1 for right
* @return {number} Nearest content offset or -1 if there are no valid offsets in data
*/
ve.dm.ElementLinearData.prototype.getNearestContentOffset = function ( offset, direction ) {
if ( this.isContentOffset( offset ) ) {
return offset;
}
if ( direction === undefined ) {
const left = this.getRelativeContentOffset( offset, -1 );
const right = this.getRelativeContentOffset( offset, 1 );
return offset - left < right - offset ? left : right;
} else {
return this.getRelativeContentOffset( offset, direction > 0 ? 1 : -1 );
}
};
/**
* 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.
*
* @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
* @return {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.
*
* @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
* @return {number} Nearest structural offset
*/
ve.dm.ElementLinearData.prototype.getNearestStructuralOffset = function ( offset, direction, unrestricted ) {
if ( this.isStructuralOffset( offset, unrestricted ) ) {
return offset;
}
if ( !direction ) {
const left = this.getRelativeStructuralOffset( offset, -1, unrestricted );
const right = this.getRelativeStructuralOffset( offset, 1, unrestricted );
return offset - left < right - offset ? left : right;
} else {
return this.getRelativeStructuralOffset( offset, direction > 0 ? 1 : -1, unrestricted );
}
};
/**
* Get the range of the word at offset (else a collapsed range)
*
* First, if the offset is not a content offset then it will be moved to the nearest one.
* Then, if the offset is inside a word, it will be expanded to that word;
* else if the offset is at the end of a word, it will be expanded to that word;
* else if the offset is at the start of a word, it will be expanded to that word;
* else the offset is not adjacent to any word and is returned as a collapsed range.
*
* @param {number} offset Offset to start from; must not be inside a surrogate pair
* @return {ve.Range} Boundaries of the adjacent word (else offset as collapsed range)
*/
ve.dm.ElementLinearData.prototype.getWordRange = function ( offset ) {
const dataString = new ve.dm.DataString( this.getData() );
offset = this.getNearestContentOffset( offset );
let range;
if ( unicodeJS.wordbreak.isBreak( dataString, offset ) ) {
// The cursor offset is not inside a word. See if there is an adjacent word
// codepoint (checking two chars to allow surrogate pairs). If so, expand in that
// direction only (preferring backwards if there are word codepoints on both
// sides).
if ( this.constructor.static.endWordRegExp.exec(
( dataString.read( offset - 2 ) || ' ' ) +
( dataString.read( offset - 1 ) || ' ' )
) ) {
// Cursor is immediately after a word codepoint: expand backwards
range = new ve.Range(
unicodeJS.wordbreak.prevBreakOffset( dataString, offset ),
offset
);
} else if ( this.constructor.static.startWordRegExp.exec(
( dataString.read( offset ) || ' ' ) +
( dataString.read( offset + 1 ) || ' ' )
) ) {
// Cursor is immediately before a word codepoint: expand forwards
range = new ve.Range(
offset,
unicodeJS.wordbreak.nextBreakOffset( dataString, offset )
);
} else {
// Cursor is not adjacent to a word codepoint: do not expand
return new ve.Range( offset );
}
} else {
// Cursor is inside a word: expand both backwards and forwards
range = new ve.Range(
unicodeJS.wordbreak.prevBreakOffset( dataString, offset ),
unicodeJS.wordbreak.nextBreakOffset( dataString, offset )
);
}
// Range expanded to all whitespace: collapse
if ( this.getText( false, range ).trim().length === 0 ) {
return new ve.Range( offset );
}
return range;
};
/**
* Finds all instances of items being stored in the hash-value store for this data store
*
* Currently this is just all annotations still in use.
*
* @param {ve.Range} [range] Optional range to get store values for
* @return {Object} Object containing all store values, keyed by store hash
*/
ve.dm.ElementLinearData.prototype.getUsedStoreValues = function ( range ) {
const store = this.getStore(),
valueStore = {};
range = range || new ve.Range( 0, this.data.length );
for ( let i = range.start; i < range.end; i++ ) {
// Annotations
// Use ignoreClose to save time; no need to count every element annotation twice
const hashes = this.getAnnotationHashesFromOffset( i, true );
let j = hashes.length;
while ( j-- ) {
const hash = hashes[ j ];
if ( !Object.prototype.hasOwnProperty.call( valueStore, hash ) ) {
valueStore[ hash ] = store.value( hash );
}
}
if ( this.data[ i ].originalDomElementsHash !== undefined ) {
valueStore[ this.data[ i ].originalDomElementsHash ] = store.value( this.data[ i ].originalDomElementsHash );
}
}
return valueStore;
};
/**
* Remap the internal list indexes used in this linear data.
*
* Calls remapInternalListIndexes() for each node.
*
* @param {Object} mapping Mapping from internal list indexes to internal list indexes
* @param {ve.dm.InternalList} internalList Internal list the indexes are being mapped into.
* Used for refreshing attribute values that were computed with getNextUniqueNumber().
*/
ve.dm.ElementLinearData.prototype.remapInternalListIndexes = function ( mapping, internalList ) {
for ( let i = 0, ilen = this.data.length; i < ilen; i++ ) {
if ( this.isOpenElementData( i ) ) {
const nodeClass = ve.dm.nodeFactory.lookup( this.getType( i ) );
this.modifyData( i, ( item ) => {
nodeClass.static.remapInternalListIndexes( item, mapping, internalList );
} );
}
}
};
/**
* Remap the internal list keys used in this linear data.
*
* Calls remapInternalListKeys() for each node.
*
* @param {ve.dm.InternalList} internalList Internal list the keys are being mapped into.
*/
ve.dm.ElementLinearData.prototype.remapInternalListKeys = function ( internalList ) {
for ( let i = 0, ilen = this.data.length; i < ilen; i++ ) {
if ( this.isOpenElementData( i ) ) {
const nodeClass = ve.dm.nodeFactory.lookup( this.getType( i ) );
this.modifyData( i, ( item ) => {
nodeClass.static.remapInternalListKeys( item, internalList );
} );
}
}
};
/**
* Remap an annotation hash when it changes
*
* @param {string} oldHash Old hash to replace
* @param {string} newHash New hash to replace it with
*/
ve.dm.ElementLinearData.prototype.remapAnnotationHash = function ( oldHash, newHash ) {
function remap( annotations ) {
let spliceAt;
while ( ( spliceAt = annotations.indexOf( oldHash ) ) !== -1 ) {
if ( annotations.indexOf( newHash ) === -1 ) {
annotations.splice( spliceAt, 1, newHash );
} else {
annotations.splice( spliceAt, 1 );
}
}
}
for ( let i = 0, ilen = this.data.length; i < ilen; i++ ) {
if ( this.data[ i ] === undefined || typeof this.data[ i ] === 'string' ) {
// Common case, cheap, avoid the isArray check
continue;
} else {
this.modifyData( i, ( item ) => {
if ( Array.isArray( item ) ) {
remap( item[ 1 ] );
} else if ( item.annotations !== undefined ) {
remap( item.annotations );
}
if ( ve.getProp( item, 'internal', 'metaItems' ) ) {
const data = ve.getProp( item, 'internal', 'metaItems' );
for ( let j = 0, jlen = data.length; j < jlen; j++ ) {
if ( data[ j ].annotations !== undefined ) {
remap( data[ j ].annotations );
}
}
}
} );
}
}
};
/**
* Sanitize data according to a set of rules.
*
* @param {Object} rules Sanitization rules
* @param {string[]} [rules.blacklist] Blacklist of model types which aren't allowed
* @param {Object} [rules.conversions] Model type conversions to apply, e.g. { heading: 'paragraph' }
* @param {boolean} [rules.removeOriginalDomElements] Remove references to DOM elements data was converted from
* @param {boolean} [rules.plainText] Remove all formatting for plain text import
* @param {boolean} [rules.allowBreaks] Allow <br> line breaks, otherwise the node will be split
* @param {boolean} [rules.preserveHtmlWhitespace] Preserve non-semantic HTML whitespace
* @param {boolean} [rules.nodeSanitization] Apply per-type node sanitizations via ve.dm.Node#sanitize
* @param {boolean} [rules.keepEmptyContentBranches] Preserve empty content branch nodes
* @param {boolean} [rules.singleLine] Don't allow more that one ContentBranchNode
* @param {boolean} [rules.allowMetaData] Don't strip metadata
*/
ve.dm.ElementLinearData.prototype.sanitize = function ( rules ) {
const elementStack = [],
store = this.getStore(),
allAnnotations = this.getAnnotationsFromRange( new ve.Range( 0, this.getLength() ), true );
let emptySet, setToRemove;
if ( rules.plainText ) {
emptySet = new ve.dm.AnnotationSet( store );
} else {
if ( rules.removeOriginalDomElements ) {
// Remove originalDomElements from annotations
for ( let i = 0, len = allAnnotations.getLength(); i < len; i++ ) {
const ann = allAnnotations.get( i );
if ( ann.element.originalDomElementsHash !== undefined ) {
// This changes the hash of the value, so we have to
// update that. If we don't do this, other assumptions
// that values fetched from the store are actually in the
// store will fail.
const oldHash = store.hashOfValue( ann );
delete allAnnotations.get( i ).element.originalDomElementsHash;
const newHash = store.replaceHash( oldHash, ann );
this.remapAnnotationHash( oldHash, newHash );
if ( allAnnotations.storeHashes.indexOf( newHash ) !== -1 ) {
// New annotation-value was already in the set, which
// just reduces the effective-length of the set.
allAnnotations.storeHashes.splice( i, 1 );
i--;
len--;
} else {
allAnnotations.storeHashes.splice( i, 1, newHash );
}
}
}
}
// Create annotation set to remove from blacklist
setToRemove = allAnnotations.filter( ( annotation ) => (
rules.blacklist && rules.blacklist[ annotation.name ]
) || (
// If original DOM element references are being removed, remove spans
annotation.name === 'textStyle/span' && rules.removeOriginalDomElements
) );
}
let contentElement;
for ( let i = 0, len = this.getLength(); i < len; i++ ) {
if ( this.isElementData( i ) ) {
let type = this.getType( i );
const canContainContent = ve.dm.nodeFactory.canNodeContainContent( type );
const isOpen = this.isOpenElementData( i );
if ( isOpen ) {
elementStack.push( this.getData( i ) );
} else {
elementStack.pop();
}
// Apply type conversions
if ( rules.conversions && rules.conversions[ type ] ) {
type = rules.conversions[ type ];
this.modifyData( i, ( item ) => {
item.type = ( !isOpen ? '/' : '' ) + type;
} );
}
// Convert content-containing non-paragraph nodes to paragraphs in plainText mode
if ( rules.plainText && type !== 'paragraph' && canContainContent ) {
type = 'paragraph';
this.setData( i, {
type: ( this.isCloseElementData( i ) ? '/' : '' ) + type
} );
}
// Remove blacklisted nodes, and metadata if disallowed
if (
( rules.blacklist && rules.blacklist[ type ] ) ||
( rules.plainText && type !== 'paragraph' && type !== 'internalList' ) ||
( !rules.allowMetadata && ve.dm.nodeFactory.isMetaData( type ) )
) {
this.splice( i, 1 );
len--;
// Make sure you haven't just unwrapped a wrapper paragraph
if ( isOpen ) {
this.modifyData( i, ( item ) => {
ve.deleteProp( item, 'internal', 'generated' );
} );
}
// Move pointer back and continue
i--;
continue;
}
// Split on breaks
if ( !rules.allowBreaks && type === 'break' && contentElement ) {
if ( this.isOpenElementData( i - 1 ) && this.isCloseElementData( i + 2 ) ) {
// If the break is the only element in another element it was likely added
// to force it open, so remove it.
this.splice( i, 2 );
len -= 2;
} else {
this.splice( i, 2, { type: '/' + contentElement.type }, ve.copy( contentElement ) );
}
// Move pointer back and continue
i--;
continue;
}
// If a node is empty but can contain content, then just remove it
if (
!rules.keepEmptyContentBranches &&
isOpen && this.isCloseElementData( i + 1 ) &&
!ve.getProp( this.getData( i ), 'internal', 'generated' ) &&
canContainContent
) {
this.splice( i, 2 );
len -= 2;
// Move pointer back and continue
i--;
continue;
}
if ( !rules.preserveHtmlWhitespace ) {
this.modifyData( i, ( item ) => {
ve.deleteProp( item, 'internal', 'whitespace' );
} );
}
if ( canContainContent && !isOpen && rules.singleLine ) {
i++;
const start = i;
while ( i < len && !( this.isOpenElementData( i ) && this.getType( i ) === 'internalList' ) ) {
i++;
}
this.splice( start, i - start );
break;
}
// Store the current contentElement for splitting
if ( canContainContent ) {
contentElement = isOpen ? this.getData( i ) : null;
}
} else {
// Support: Firefox
// Remove plain newline characters, as they are semantically meaningless
// and will confuse the user. Firefox adds these automatically when copying
// line-wrapped HTML. T104790
// However, don't remove them if we're in a situation where they might
// actually be meaningful -- i.e. if we're inside a <pre>. T132006
if (
this.getCharacterData( i ) === '\n' &&
// Get last open type from the stack
!ve.dm.nodeFactory.doesNodeHaveSignificantWhitespace( elementStack[ elementStack.length - 1 ].type )
) {
if ( /^\s+$/.test( this.getCharacterData( i + 1 ) ) ||
/^\s+$/.test( this.getCharacterData( i - 1 ) )
) {
// If whitespace-adjacent, remove the newline to avoid double spaces
this.splice( i, 1 );
len--;
// Move pointer back and continue
i--;
continue;
} else {
// …otherwise replace it with a space
if ( typeof this.getData( i ) === 'string' ) {
this.setData( i, ' ' );
} else {
this.modifyData( i, ( item ) => {
item[ 0 ] = ' ';
} );
}
}
}
// Support: Chrome, Safari
// Sometimes all spaces are replaced with NBSP by the browser, so replace those
// which aren't adjacent to plain spaces. T183647
if (
this.getCharacterData( i ) === '\u00a0' &&
// Get last open type from the stack
!ve.dm.nodeFactory.doesNodeHaveSignificantWhitespace( elementStack[ elementStack.length - 1 ].type )
) {
if ( !( this.getCharacterData( i + 1 ) === ' ' || this.getCharacterData( i - 1 ) === ' ' ) ) {
// Replace with a space
if ( typeof this.getData( i ) === 'string' ) {
this.setData( i, ' ' );
} else {
this.modifyData( i, ( item ) => {
item[ 0 ] = ' ';
} );
}
}
}
}
const annotations = this.getAnnotationsFromOffset( i, true );
if ( !annotations.isEmpty() ) {
if ( rules.plainText ) {
this.setAnnotationsAtOffset( i, emptySet );
} else if ( setToRemove.getLength() ) {
// Remove blacklisted annotations
annotations.removeSet( setToRemove );
this.setAnnotationsAtOffset( i, annotations );
}
}
if ( this.isOpenElementData( i ) ) {
if ( rules.nodeSanitization ) {
const nodeClass = ve.dm.modelRegistry.lookup( this.getType( i ) );
// Perform per-class sanitizations:
this.modifyData( i, ( item ) => {
nodeClass.static.sanitize( item, rules );
} );
}
if ( rules.removeOriginalDomElements ) {
this.modifyData( i, ( item ) => {
// Remove originalDomElements from nodes
delete item.originalDomElementsHash;
} );
}
// Remove metadata if disallowed (moved metadata)
if ( !rules.allowMetadata ) {
this.modifyData( i, ( item ) => {
ve.deleteProp( item, 'internal', 'metaItems' );
} );
}
}
}
};
/**
* Run all elements through getClonedElement(). This should be done if
* you intend to insert the sliced data back into the document as a copy
* of the original data (e.g. for copy and paste).
*
* @param {boolean} preserveGenerated Preserve internal.generated properties of elements
*/
ve.dm.ElementLinearData.prototype.cloneElements = function ( preserveGenerated ) {
const store = this.getStore();
for ( let i = 0, len = this.getLength(); i < len; i++ ) {
if ( this.isOpenElementData( i ) ) {
const nodeClass = ve.dm.nodeFactory.lookup( this.getType( i ) );
if ( nodeClass ) {
this.setData( i, nodeClass.static.cloneElement( this.getData( i ), store, preserveGenerated ) );
}
}
}
};
/**
* Counts all elements that aren't between internalList and /internalList
*
* @param {number} [limit] Number of elements after which to stop counting
* @return {number} Number of elements that aren't in an internalList
*/
ve.dm.ElementLinearData.prototype.countNonInternalElements = function ( limit ) {
let internalDepth = 0,
count = 0;
for ( let i = 0, l = this.getLength(); i < l; i++ ) {
const type = this.getType( i );
if ( type && ve.dm.nodeFactory.isNodeInternal( type ) ) {
if ( this.isOpenElementData( i ) ) {
internalDepth++;
} else {
internalDepth--;
}
} else if ( !internalDepth ) {
count++;
if ( limit && count >= limit ) {
return count;
}
}
}
return count;
};
/**
* Checks if the document has content that's not part of an internalList.
*
* @return {boolean} The document has content
*/
ve.dm.ElementLinearData.prototype.hasContent = function () {
// Two or less elements (<p>, </p>) is considered an empty document
// For performance, abort the count when we reach 3.
return this.countNonInternalElements( 3 ) > 2 ||
// Also check that the element is not a content branch node, e.g. a blockImage
// and also that is not the internal list
(
this.isElementData( 0 ) &&
!ve.dm.nodeFactory.canNodeContainContent( this.getType( 0 ) ) &&
!ve.dm.nodeFactory.isNodeInternal( this.getType( 0 ) )
);
};
/**
* Get the length of the common start sequence of annotations that applies to a whole range
*
* @param {ve.Range} range The document range
* @return {number} Common start sequence length (0 if the range is empty)
*/
ve.dm.ElementLinearData.prototype.getCommonAnnotationArrayLength = function ( range ) {
const annotationHashesForOffset = [];
for ( let i = range.start; i < range.end; i++ ) {
annotationHashesForOffset.push( this.getAnnotationHashesFromOffset( i ) );
}
return ve.getCommonStartSequenceLength( annotationHashesForOffset );
};