mediawiki-extensions-Visual.../modules/ve/dm/ve.dm.Node.js
Catrope 2eb0d2a6b2 Great Annotation Refactor of 2013
This changes the annotation API to be the same as the node API, sans
a few boolean flags that don't apply. The APIs were different, but
there was really no good reason why, so this makes things simpler for
API users. It also means we'll be able to factor a bunch of things out
because they're now duplicated between nodes, meta items and annotations.

Linear model annotations are now objects with 'type' and 'attributes'
properties (rather than 'name' and 'data'), for consistency with elements.
They now also contain html/0/* attributes for HTML attribute preservation,
which obsoletes the htmlTagName and htmlAttributes properties.
dm.Annotation subclasses take a reference to such an object and implement
conversion using .static.toDataElement and .static.toDomElements just
like nodes do. The custom .getHash() functions are no longer necessary
because of the way HTML attribute preservation was reimplemented.

CE rendering has been moved out of dm.Annotation (it never made sense to
have CE rendering functions in DM classes, this was bothering me) and into
separate ce.Annotation subclasses. These are very similar to CE nodes in
that they have a this.$ generated based on something in the DM; the main
difference is that nodes listen to events and update themselves, whereas
annotations are static and are simply destroyed and rebuilt when they
change. This change also adds whitelisted HTML attribute rendering for
annotations, as well as class="ve-ce-FooAnnotation" attributes.

Now that annotation classes produce real DOM nodes rather than weird
objects describing HTML tags, we can't generate HTML as a string in
ce.ContentBranchNode anymore. getRenderedContents() has been rewritten
to be much more similar to the way the converter renders annotations;
in fact, significant parts of it were copied from the converter, so that
should be factored out in the future. This change actually fixes an
annotation rendering discrepancy between ce.ContentBranchNode and
dm.Converter; see the diff of ve.ce.ContentBranchNode.test.js.

ve.ce.MWEntityNode.js:
* Remove stray property

ve.dm.MWExternalLinkAnnotation.js:
* Store 'rel' attribute

ve.dm.TextStyleAnnotation.js:
* Put all the conversion logic in the abstract base class

ve.dm.Converter.js:
* Also feed annotations through getDomElementsFromDataElement() and
  createDataElement()

ve.dm.Node.js:
* Fix undocumented property

ve.ce.ContentBranchNode.test.js:
* Add descriptive messages for each test case
* Compare DOM trees, not HTML strings
* Compare without all the class="ve-ce-WhateverAnnotation" clutter

ve.ui.LinkInspector.js:
* Replace direct .getHash() calls (evil!) with ve.getHash()

Bug: 46464
Bug: 44808
Change-Id: I31991488579b8cce6d98ed8b29b486ba5ec38cdc
2013-04-08 18:10:16 -07:00

609 lines
18 KiB
JavaScript

/*!
* VisualEditor DataModel Node class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Generic DataModel node.
*
* @abstract
* @extends ve.Node
* @constructor
* @param {number} [length] Length of content data in document
* @param {Object} [element] Reference to element in linear model
*/
ve.dm.Node = function VeDmNode( length, element ) {
// Parent constructor
ve.Node.call( this );
// Properties
this.length = length || 0;
this.element = element;
this.doc = undefined;
};
/**
* @event lengthChange
* @param diff
*/
/**
* @event update
*/
/* Inheritance */
ve.inheritClass( ve.dm.Node, ve.Node );
/**
* Array of HTML tag names that this node should be a match candidate for.
* Empty array means none, null means any.
* For more information about element matching, see ve.dm.ModelRegistry.
* @static
* @property {string[]} static.matchTagNames
* @inheritable
*/
ve.dm.Node.static.matchTagNames = null;
/**
* Array of RDFa types that this node should be a match candidate for.
* Empty array means none, null means any.
* For more information about element matching, see ve.dm.ModelRegistry.
* @static
* @property {Array} static.matchRdfaType Array of strings or regular expressions
* @inheritable
*/
ve.dm.Node.static.matchRdfaTypes = null;
/**
* Optional function to determine whether this node should match a given element.
* Takes an HTMLElement and returns true or false.
* This function is only called if this node has a chance of "winning"; see
* ve.dm.ModelRegistry for more information about element matching.
* If set to null, this property is ignored. Setting this to null is not the same as unconditionally
* returning true, because the presence or absence of a matchFunction affects the node's
* specificity.
*
* NOTE: This function is NOT a method, within this function "this" will not refer to an instance
* of this class (or to anything reasonable, for that matter).
* @static
* @property {Function} static.matchFunction
* @inheritable
*/
ve.dm.Node.static.matchFunction = null;
/**
* Static function to convert a DOM element or set of sibling DOM elements to a linear model data
* element for this node type.
*
* This function is only called if this node "won" the matching for the first DOM element, so
* domElements[0] will match this node's matching rule. There is usually only one node in
* domElements[]. Multiple nodes will only be passed if this node supports about groups.
* If there are multiple nodes, the nodes are all adjacent siblings in the same about group
* (i.e. they are grouped together because they have the same value for the about attribute).
*
* This function is allowed to return a content element when context indicates that a non-content
* element is expected or vice versa. If that happens, the converter deals with it in the following way:
*
* - if a non-content element is expected but a content element is returned:
* - open a wrapper paragraph
* - put the returned element in the wrapper
* - if a content element is expected but a non-content element is returned:
* - if we are in a wrapper paragraph:
* - if we can close the wrapper:
* - close the wrapper
* - insert the returned element right after the end of the wrapper
* - if we can't close the wrapper:
* - alienate the element
* - if we aren't in a wrapper paragraph:
* - alienate the element
*
* The returned linear model element must have a type property set to a registered node name
* (usually the node's .static.name, but that's not required). It may optionally have an attributes
* property set to an object with key-value pairs. Any other properties are not allowed.
*
* @static
* @method
* @param {HTMLElement[]} domElements DOM elements to convert. Usually only one element
* @param {Object} context Object describing the current state of the converter
* @param {boolean} context.expectingContent Whether this function is expected to return a content element
* @param {boolean} context.inWrapper Whether this element is in a wrapper paragraph generated by the converter;
* can only be true if context.expectingContent is also true
* @param {boolean} context.canCloseWrapper Whether the current wrapper paragraph can be closed;
* can only be true if context.inWrapper is also true
* @returns {Object|null} Linear model element, or null to alienate
*/
ve.dm.Node.static.toDataElement = function ( /*domElements, context*/ ) {
throw new Error( 've.dm.Node subclass must implement toDataElement' );
};
/**
* Static function to convert a linear model data element for this node type back to one or more
* DOM elements.
*
* NOTE: If this function returns multiple DOM elements, the DOM elements produced by the children
* of this node (if any) will be attached to the first DOM element in the array.
*
* @static
* @method
* @param {Object} Linear model element with a type property and optionally an attributes property
* @returns {HTMLElement[]} DOM elements
*/
ve.dm.Node.static.toDomElements = function ( /*dataElement*/ ) {
throw new Error( 've.dm.Node subclass must implement toDomElements' );
};
/**
* Whether this node supports about grouping. When a DOM element matches a node type that has
* about grouping enabled, the converter will look for adjacent siblings with the same value for
* the about attribute, and ask toDataElement() to produce a single data element for all of those
* DOM nodes combined.
*
* The converter doesn't descend into about groups, i.e. it doesn't convert the children of the
* DOM elements that make up the about group. This means the resulting linear model element will
* be childless.
*
* @static
* @property {boolean} static.enableAboutGrouping
* @inheritable
*/
ve.dm.Node.static.enableAboutGrouping = false;
/**
* Whether HTML attributes should be preserved for this node type. If true, the HTML attributes
* of the DOM elements will be stored as linear model attributes. The attribute names will be
* html/i/attrName, where i is the index of the DOM element in the domElements array, and attrName
* is the name of the attribute.
*
* This should generally be enabled, except for node types that store their entire HTML in an
* attribute.
*
* @static
* @property {boolean} static.storeHtmlAttributes
* @inheritable
*/
ve.dm.Node.static.storeHtmlAttributes = true;
/**
* Whether this node type has a wrapping element in the linear model. Most node types are wrapped,
* only special node types are not wrapped.
*
* @static
* @property {boolean} static.isWrapped
* @inheritable
*/
ve.dm.Node.static.isWrapped = true;
/**
* Whether this node type is a content node type. This means the node represents content, cannot
* have children, and can only appear as children of a content container node. Content nodes are
* also known as inline nodes.
*
* @static
* @property {boolean} static.isContent
* @inheritable
*/
ve.dm.Node.static.isContent = false;
/**
* Whether this node type can contain content. The children of content container nodes must be
* content nodes.
*
* @static
* @property {boolean} static.canContainContent
* @inheritable
*/
ve.dm.Node.static.canContainContent = false;
/**
* Whether this node type has significant whitespace. Only applies to content container nodes
* (i.e. can only be true if canContainContent is also true).
*
* If a content node has significant whitespace, the text inside it is not subject to whitespace
* stripping and preservation.
*
* @static
* @property {boolean} static.hasSignificantWhitespace
* @inheritable
*/
ve.dm.Node.static.hasSignificantWhitespace = false;
/**
* Array of allowed child node types for this node type.
*
* An empty array means no children are allowed. null means any node type is allowed as a child.
*
* @static
* @property {string[]|null} static.childNodeTypes
* @inheritable
*/
ve.dm.Node.static.childNodeTypes = null;
/**
* Array of allowed parent node types for this node type.
*
* An empty array means this node type cannot be the child of any node. null means this node type
* can be the child of any node type.
*
* @static
* @property {string[]|null} static.parentNodeTypes
* @inheritable
*/
ve.dm.Node.static.parentNodeTypes = null;
/**
* Array of suggested parent node types for this node type.
*
* These parent node types are allowed but the editor will avoid creating them.
*
* An empty array means this node type should not be the child of any node. null means this node type
* can be the child of any node type.
*
* @static
* @property {string[]|null} static.suggestedParentNodeTypes
* @inheritable
*/
ve.dm.Node.static.suggestedParentNodeTypes = null;
/**
* Default attributes to set for newly created linear model elements. These defaults will be used
* when creating a new element in ve.dm.NodeFactory#getDataElement when there is no DOM node or
* existing linear model element to base the attributes on.
*
* This property is an object with attribute names as keys and attribute values as values.
* Attributes may be omitted, in which case they'll simply be undefined.
*
* @static
* @property {Object} static.defaultAttributes
* @inheritable
*/
ve.dm.Node.static.defaultAttributes = {};
/* Methods */
/**
* Get allowed child node types.
*
* @method
* @returns {string[]|null} List of node types allowed as children or null if any type is allowed
*/
ve.dm.Node.prototype.getChildNodeTypes = function () {
return this.constructor.static.childNodeTypes;
};
/**
* Get allowed parent node types.
*
* @method
* @returns {string[]|null} List of node types allowed as parents or null if any type is allowed
*/
ve.dm.Node.prototype.getParentNodeTypes = function () {
return this.constructor.static.parentNodeTypes;
};
/**
* Get suggested parent node types.
*
* @method
* @returns {string[]|null} List of node types suggested as parents or null if any type is suggested
*/
ve.dm.Node.prototype.getSuggestedParentNodeTypes = function () {
return this.constructor.static.suggestedParentNodeTypes;
};
/**
* Check if the node can have children.
*
* @method
* @returns {boolean} Node can have children
*/
ve.dm.Node.prototype.canHaveChildren = function () {
return ve.dm.nodeFactory.canNodeHaveChildren( this.type );
};
/**
* Check if the node can have children but not content nor be content.
*
* @method
* @returns {boolean} Node can have children but not content nor be content
*/
ve.dm.Node.prototype.canHaveChildrenNotContent = function () {
return ve.dm.nodeFactory.canNodeHaveChildrenNotContent( this.type );
};
/**
* Check if the node has a wrapped element in the document data.
*
* @method
* @returns {boolean} Node represents a wrapped element
*/
ve.dm.Node.prototype.isWrapped = function () {
return this.constructor.static.isWrapped;
};
/**
* Check if the node can contain content.
*
* @method
* @returns {boolean} Node can contain content
*/
ve.dm.Node.prototype.canContainContent = function () {
return this.constructor.static.canContainContent;
};
/**
* Check if the node is content.
*
* @method
* @returns {boolean} Node is content
*/
ve.dm.Node.prototype.isContent = function () {
return this.constructor.static.isContent;
};
/**
* Check if the node has significant whitespace.
*
* Can only be true if canContainContent is also true.
*
* @method
* @returns {boolean} Node has significant whitespace
*/
ve.dm.Node.prototype.hasSignificantWhitespace = function () {
return this.constructor.static.hasSignificantWhitespace;
};
/**
* Check if the node has an ancestor with matching type and attribute values.
*
* @method
* @returns {boolean} Node is content
*/
ve.dm.Node.prototype.hasMatchingAncestor = function ( type, attributes ) {
var key,
node = this;
// Traverse up to matching node
while ( node && node.getType() !== type ) {
node = node.getParent();
// Stop at root
if ( node === null ) {
return false;
}
}
// Check attributes
if ( attributes ) {
for ( key in attributes ) {
if ( node.getAttribute( key ) !== attributes[key] ) {
return false;
}
}
}
return true;
};
/**
* Get the length of the node.
*
* @method
* @returns {number} Length of the node's contents
*/
ve.dm.Node.prototype.getLength = function () {
return this.length;
};
/**
* Get the outer length of the node, which includes wrappers if present.
*
* @method
* @returns {number} Length of the entire node
*/
ve.dm.Node.prototype.getOuterLength = function () {
return this.length + ( this.isWrapped() ? 2 : 0 );
};
/**
* Get the range inside the node.
*
* @method
* @returns {ve.Range} Inner node range
*/
ve.dm.Node.prototype.getRange = function () {
var offset = this.getOffset();
if ( this.isWrapped() ) {
offset++;
}
return new ve.Range( offset, offset + this.length );
};
/**
* Get the range outside the node.
*
* @method
* @returns {ve.Range} Outer node range
*/
ve.dm.Node.prototype.getOuterRange = function () {
var offset = this.getOffset();
return new ve.Range( offset, offset + this.getOuterLength() );
};
/**
* Set the inner length of the node.
*
* This should only be called after a relevant change to the document data. Calling this method will
* not change the document data.
*
* @method
* @param {number} length Length of content
* @emits lengthChange
* @emits update
* @throws {Error} Invalid content length error if length is less than 0
*/
ve.dm.Node.prototype.setLength = function ( length ) {
if ( length < 0 ) {
throw new Error( 'Length cannot be negative' );
}
// Compute length adjustment from old length
var diff = length - this.length;
// Set new length
this.length = length;
// Adjust the parent's length
if ( this.parent ) {
this.parent.adjustLength( diff );
}
// Emit events
this.emit( 'lengthChange', diff );
this.emit( 'update' );
};
/**
* Adjust the length.
*
* This should only be called after a relevant change to the document data. Calling this method will
* not change the document data.
*
* @method
* @param {number} adjustment Amount to adjust length by
* @emits lengthChange
* @emits update
* @throws {Error} Invalid adjustment error if resulting length is less than 0
*/
ve.dm.Node.prototype.adjustLength = function ( adjustment ) {
this.setLength( this.length + adjustment );
};
/**
* Get the offset of the node within the document.
*
* If the node has no parent than the result will always be 0.
*
* @method
* @returns {number} Offset of node
*/
ve.dm.Node.prototype.getOffset = function () {
return this.root === this ? 0 : this.root.getOffsetFromNode( this );
};
/**
* Get the value of an attribute.
*
* Return value is by reference if array or object.
*
* @method
* @param {string} key Name of attribute to get
* @returns {Mixed} Value of attribute, or undefined if no such attribute exists
*/
ve.dm.Node.prototype.getAttribute = function ( key ) {
return this.element && this.element.attributes ? this.element.attributes[key] : undefined;
};
/**
* Get a copy of all attributes.
*
* Values are by reference if array or object, similar to using the getAttribute method.
*
* @method
* @param {string} prefix Only return attributes with this prefix, and remove the prefix from them
* @returns {Object} Attributes
*/
ve.dm.Node.prototype.getAttributes = function ( prefix ) {
var key, filtered,
attributes = this.element && this.element.attributes ? this.element.attributes : {};
if ( prefix ) {
filtered = {};
for ( key in attributes ) {
if ( key.indexOf( prefix ) === 0 ) {
filtered[key.substr( prefix.length )] = attributes[key];
}
}
return filtered;
}
return ve.extendObject( {}, attributes );
};
/**
* Check if the node has certain attributes.
*
* If an array of keys is provided only the presence of the attributes will be checked. If an object
* with keys and values is provided both the presence of the attributes and their values will be
* checked. Comparison of values is done by casting to strings unless the strict argument is used.
*
* @method
* @param {string[]|Object} attributes Array of keys or object of keys and values
* @param {boolean} strict Use strict comparison when checking if values match
* @returns {boolean} Node has attributes
*/
ve.dm.Node.prototype.hasAttributes = function ( attributes, strict ) {
var key, i, len,
ourAttributes = this.getAttributes() || {};
if ( ve.isPlainObject( attributes ) ) {
// Node must have all the required attributes
for ( key in attributes ) {
if (
!( key in ourAttributes ) ||
( strict ?
attributes[key] !== ourAttributes[key] :
String( attributes[key] ) !== String( ourAttributes[key] )
)
) {
return false;
}
}
} else if ( ve.isArray( attributes ) ) {
for ( i = 0, len = attributes.length; i < len; i++ ) {
if ( !( attributes[i] in ourAttributes ) ) {
return false;
}
}
}
return true;
};
/**
* Get a clone of the node's document data element.
*
* The attributes object will be deep-copied.
*
* @returns {Object} Cloned element object
*/
ve.dm.Node.prototype.getClonedElement = function () {
var clone = ve.copyObject( this.element );
if ( clone.internal ) {
delete clone.internal.generated;
}
return clone;
};
/**
* Check if the node can be merged with another.
*
* For two nodes to be mergeable, the two nodes must either be the same node or:
* - Have the same type
* - Have the same depth
* - Have similar ancestory (each node upstream must have the same type)
*
* @method
* @param {ve.dm.Node} node Node to consider merging with
* @returns {boolean} Nodes can be merged
*/
ve.dm.Node.prototype.canBeMergedWith = function ( node ) {
var n1 = this,
n2 = node;
// Move up from n1 and n2 simultaneously until we find a common ancestor
while ( n1 !== n2 ) {
if (
// Check if we have reached a root (means there's no common ancestor or unequal depth)
( n1 === null || n2 === null ) ||
// Ensure that types match
n1.getType() !== n2.getType()
) {
return false;
}
// Move up
n1 = n1.getParent();
n2 = n2.getParent();
}
return true;
};