mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-29 16:44:51 +00:00
ae3d31d447
This fixes an annotation contamination issue that was causing Rob's tests to throw exceptions Change-Id: Ic5171d600b0b2ff97cc569e1e6f9488fab177c7c
350 lines
12 KiB
JavaScript
350 lines
12 KiB
JavaScript
/**
|
|
* Converter between HTML DOM and VisualEditor linear data.
|
|
*
|
|
* @author Gabriel Wicke
|
|
* @author Roan Kattouw
|
|
* @author Christian Williams
|
|
* @author Inez Korczynski
|
|
* @author Trevor Parscal
|
|
*
|
|
* @class
|
|
* @constructor
|
|
* @param {Object} options Conversion options
|
|
*/
|
|
ve.dm.HTMLConverter = function( options ) {
|
|
this.options = options || {};
|
|
};
|
|
|
|
/* Static Members */
|
|
|
|
/**
|
|
* Object mapping HTML DOM node names to linear model element names.
|
|
*
|
|
* 'leafNode': Type of model tree node used to represent this element
|
|
* - If true, it's a leaf node (can not contain children)
|
|
* - If false, it's a branch node (can contain children)
|
|
* 'type': Symbolic name of element in VisualEditor linear data.
|
|
* 'attributes': Additional attributes to set for this element (optional)
|
|
*/
|
|
ve.dm.HTMLConverter.elementTypes = {
|
|
'p': { 'leafNode': false, 'canContainContent': true, 'type': 'paragraph' },
|
|
'h1': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 1 } },
|
|
'h2': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 2 } },
|
|
'h3': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 3 } },
|
|
'h4': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 4 } },
|
|
'h5': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 5 } },
|
|
'h6': { 'leafNode': false, 'canContainContent': true, 'type': 'heading', 'attributes': { 'level': 6 } },
|
|
'img': { 'leafnode': true, 'canContainContent': false, 'type': 'image' },
|
|
'li': { 'leafNode': false, 'canContainContent': false, 'type': 'listItem' },
|
|
'dt': { 'leafNode': false, 'canContainContent': false, 'type': 'listItem', 'attributes': { 'style': 'term' } },
|
|
'dd': { 'leafNode': false, 'canContainContent': false, 'type': 'listItem', 'attributes': { 'style': 'definition' } },
|
|
'pre': { 'leafNode': false, 'canContainContent': true, 'type': 'preformatted' },
|
|
'table': { 'leafNode': false, 'canContainContent': false, 'type': 'table' },
|
|
'tbody': { }, // Ignore tbody for now, we might want it later for header/footer support though
|
|
'tr': { 'leafNode': false, 'canContainContent': false, 'type': 'tableRow' },
|
|
'th': { 'leafNode': false, 'canContainContent': false, 'type': 'tableHeading' },
|
|
'td': { 'leafNode': false, 'canContainContent': false, 'type': 'tableCell' },
|
|
'ul': { 'leafNode': false, 'canContainContent': false, 'type': 'list', 'attributes': { 'style': 'bullet' } },
|
|
'ol': { 'leafNode': false, 'canContainContent': false, 'type': 'list', 'attributes': { 'style': 'number' } },
|
|
'dl': { 'leafNode': false, 'canContainContent': false, 'type': 'definitionList' }
|
|
|
|
// Missing types that will end up being alien nodes (not a complete list):
|
|
// div, center, blockquote, caption, tbody, thead, tfoot, horizontalRule, br, img, video, audio
|
|
};
|
|
|
|
/**
|
|
* Object mapping HTML DOM node names to linear model annotation types.
|
|
*
|
|
* The value can either be a string, or a function that takes the relevant HTML DOM node and returns
|
|
* a string.
|
|
*/
|
|
ve.dm.HTMLConverter.annotationTypes = {
|
|
'i': 'textStyle/italic',
|
|
'b': 'textStyle/bold',
|
|
'u': 'textStyle/underline',
|
|
'small': 'textStyle/small',
|
|
'span': 'textStyle/span',
|
|
'a': function( node ) {
|
|
// FIXME: the parser currently doesn't output this data this way
|
|
// Internal links get 'linkType': 'internal' in the data-mw-rt attrib, while external links
|
|
// currently get nothing
|
|
var atype = node.getAttribute( 'data-type' );
|
|
if ( atype ) {
|
|
return 'link/' + atype;
|
|
} else {
|
|
return 'link/unknown';
|
|
}
|
|
},
|
|
'template': 'object/template',
|
|
'ref': 'object/hook',
|
|
'includeonly': 'object/includeonly'
|
|
};
|
|
|
|
/**
|
|
* List of HTML DOM attributes that should be passed through verbatim by convertAttributes() rather
|
|
* than being prefixed with 'html/'
|
|
*
|
|
* TODO: Add href to this list?
|
|
*/
|
|
ve.dm.HTMLConverter.attributeWhitelist = [ 'title' ];
|
|
|
|
/* Static Methods */
|
|
|
|
/**
|
|
* Convert the attributes of an HTML DOM node to linear model attributes.
|
|
*
|
|
* - Attributes prefixed with data-json- will have the prefix removed and their
|
|
* value JSON-decoded.
|
|
* - Attributes prefixed with data- will have the prefix removed.
|
|
* - Attributes in attributeWhitelist are passed through unchanged
|
|
* - All other attribuetes are prefixed with html/
|
|
*
|
|
* @param {Object} node HTML DOM node
|
|
* @returns {Object} Converted attribute map
|
|
*/
|
|
ve.dm.HTMLConverter.convertAttributes = function( node ) {
|
|
var converted = {},
|
|
attrib,
|
|
name,
|
|
value,
|
|
i;
|
|
if ( !node.length ) {
|
|
return {};
|
|
}
|
|
for ( i = 0; i < node.length; i++ ) {
|
|
attrib = node[i];
|
|
name = attrib.name;
|
|
value = attrib.value;
|
|
if ( name.substr( 0, 10 ) == 'data-json-' ) {
|
|
// Strip data-json- prefix and decode
|
|
converted[name.substr( 10 )] = JSON.parse( value );
|
|
} else if ( node[i].name.substr( 0, 5 ) == 'data-' ) {
|
|
// Strip data- prefix
|
|
converted[name.substr( 5 )] = value;
|
|
} else if ( ve.dm.HTMLConverter.attributeWhitelist.indexOf( name ) != -1 ) {
|
|
// Pass through a few whitelisted keys
|
|
converted[name] = value;
|
|
} else {
|
|
// Prefix key with 'html/'
|
|
converted['html/' + name] = value;
|
|
}
|
|
}
|
|
return converted;
|
|
};
|
|
|
|
/**
|
|
* Check if an HTML DOM node represents an annotation, and if so, build an
|
|
* annotation object for it.
|
|
*
|
|
* The annotation object looks like {'type': 'type', data: {'attrKey': 'attrValue', ...}}
|
|
*
|
|
* @param {node} HTML DOM node
|
|
* @returns {Object|false} Annotation object, or false if this node is not an annotation
|
|
*/
|
|
ve.dm.HTMLConverter.getAnnotation = function( node ) {
|
|
var name = node.nodeName.toLowerCase(),
|
|
type = ve.dm.HTMLConverter.annotationTypes[name];
|
|
if ( !type ) {
|
|
// Not an annotation
|
|
return false;
|
|
}
|
|
if ( typeof type == 'function' ) {
|
|
type = type( node );
|
|
}
|
|
return {
|
|
'type': type,
|
|
'data': ve.dm.HTMLConverter.convertAttributes( node )
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Convert a string to (possibly annotated) linear model data
|
|
*
|
|
* @param {String} content String to convert
|
|
* @param {Array} annotations Array of annotation objects to apply
|
|
* @returns {Array} Linear model data, one element per character
|
|
*/
|
|
ve.dm.HTMLConverter.generateAnnotatedContent = function( content, annotations ) {
|
|
var characters = content.split( '' ),
|
|
annotationMap = {},
|
|
i;
|
|
if ( !annotations || annotations.length === 0 ) {
|
|
return characters;
|
|
}
|
|
for ( i = 0; i < annotations.length; i++ ) {
|
|
if ( annotations[i].data !== undefined && Object.keys(annotations[i].data).length === 0 ) {
|
|
delete annotations[i].data;
|
|
}
|
|
annotationMap[ve.getHash( annotations[i] )] = annotations[i];
|
|
}
|
|
for ( i = 0; i < characters.length; i++ ) {
|
|
// Make a shallow copy of the annotationMap object, otherwise adding an annotation
|
|
// to one character automatically adds it to all of others as well
|
|
characters[i] = [characters[i], ve.extendObject( {}, annotationMap )];
|
|
}
|
|
return characters;
|
|
};
|
|
|
|
/**
|
|
* Get linear model from HTML DOM
|
|
*
|
|
* @static
|
|
* @method
|
|
* @param {Object} node HTML node to recursively convert
|
|
* @param {Object} options Options to use
|
|
* @returns {Array} Linear model data
|
|
*/
|
|
ve.dm.HTMLConverter.getLinearModel = function( node, options ) {
|
|
return ( new ve.dm.HTMLConverter( options ) ).convert( node );
|
|
};
|
|
|
|
|
|
/**
|
|
* Recursively convert an HTML DOM node to a linear model array.
|
|
*
|
|
* This is the main function.
|
|
*
|
|
* @param {Object} node HTML DOM node
|
|
* @param {Array} [annotations] Annotations to apply. Only used for recursion.
|
|
* @param {Object} [typeData] Information about the linear model element type corresponding to this
|
|
* node. Only used for recursion. This data usually comes from elementTypes. All keys are optional.
|
|
* Keys are:
|
|
* 'type': If set, add an opening and a closing element with this type and with the DOM
|
|
* node's attributes.
|
|
* 'attributes': If set and type is set, these attributes will be added to the element's
|
|
* attributes.
|
|
* 'leafNode': If set and set to true, this element is supposed to be a leaf node in the
|
|
* linear model. This means that any content in this node will be put in the output
|
|
* directly rather than being wrapped in paragraphs, and that any child nodes that are
|
|
* elements will not be descended into.
|
|
* 'canContainContent': If set and set to true, this element is allowed to contain annotated
|
|
* text and inline elements.
|
|
* @returns {Array} Linear model data
|
|
*/
|
|
ve.dm.HTMLConverter.prototype.convert = function( node, annotations, typeData ) {
|
|
var data = [],
|
|
i,
|
|
child,
|
|
annotation,
|
|
paragraphOpened = false,
|
|
element,
|
|
attributes,
|
|
childTypeData;
|
|
|
|
annotations = annotations || [];
|
|
typeData = typeData || {};
|
|
|
|
if ( typeData.type ) {
|
|
element = { 'type': typeData.type };
|
|
attributes = ve.dm.HTMLConverter.convertAttributes( node.attributes );
|
|
if ( typeData.attributes ) {
|
|
attributes = $.extend( attributes, typeData.attributes );
|
|
}
|
|
if ( !$.isEmptyObject( attributes ) ) {
|
|
element.attributes = attributes;
|
|
}
|
|
data.push( element );
|
|
}
|
|
|
|
for ( i = 0; i < node.childNodes.length; i++ ) {
|
|
child = node.childNodes[i];
|
|
switch ( child.nodeType ) {
|
|
case Node.ELEMENT_NODE:
|
|
// Check if this is an annotation
|
|
annotation = ve.dm.HTMLConverter.getAnnotation( child );
|
|
if ( annotation ) {
|
|
// If we have annotated text within a branch node, open a paragraph
|
|
// Leaf nodes don't need this because they're allowed to contain content
|
|
if ( !paragraphOpened && typeData && !typeData.canContainContent ) {
|
|
data.push( { 'type': 'paragraph' } );
|
|
paragraphOpened = true;
|
|
}
|
|
// Recurse into this node
|
|
data = data.concat( ve.dm.HTMLConverter.prototype.convert( child,
|
|
annotations.concat( [ annotation ] ),
|
|
{ 'leafNode': true, 'canContainContent': true }
|
|
) );
|
|
} else {
|
|
// Skip over unknown nodes, keeping their innerHTML as an attribute
|
|
if ( !( child.nodeName.toLowerCase() in ve.dm.HTMLConverter.elementTypes ) ) {
|
|
// Use inline or block depending on parent's ability to contain content
|
|
var type = typeData.canContainContent ? 'alienInline' : 'alienBlock';
|
|
data.push( { 'type': type, 'attributes': { 'html': child.innerHTML } } );
|
|
data.push( { 'type': '/' + type } );
|
|
continue;
|
|
}
|
|
/*
|
|
if ( typeData.canContainContent ) {
|
|
// We've found an element node *inside* a leaf node.
|
|
// This is illegal, so warn and skip it
|
|
console.warn( 'HTML DOM to linear model conversion error: ' +
|
|
'found element that can contain content (' + child.nodeName +
|
|
') inside another element that can contain content node (' +
|
|
node.nodeName + ')' );
|
|
break;
|
|
}
|
|
*/
|
|
// Close the last paragraph, if still open
|
|
if ( paragraphOpened ) {
|
|
data.push( { 'type': '/paragraph' } );
|
|
paragraphOpened = false;
|
|
}
|
|
|
|
// Get the typeData for this node
|
|
childTypeData = ve.dm.HTMLConverter.elementTypes[child.nodeName.toLowerCase()];
|
|
|
|
if ( childTypeData ) {
|
|
// Recurse into this node
|
|
// Don't pass annotations through; we should never have those here
|
|
// anyway because only leaves can have them.
|
|
data = data.concat(
|
|
ve.dm.HTMLConverter.prototype.convert( child, [], childTypeData )
|
|
);
|
|
} else {
|
|
console.warn(
|
|
'HTML DOM to linear model conversion error: unknown node with name ' +
|
|
child.nodeName + ' and content ' + child.innerHTML
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
case Node.TEXT_NODE:
|
|
// If we have annotated text within a branch node, open a paragraph
|
|
if ( !paragraphOpened && typeData && !typeData.canContainContent ) {
|
|
data.push( { 'type': 'paragraph' } );
|
|
paragraphOpened = true;
|
|
}
|
|
// Annotate the text and output it
|
|
data = data.concat(
|
|
ve.dm.HTMLConverter.generateAnnotatedContent( child.data, annotations )
|
|
);
|
|
break;
|
|
case Node.COMMENT_NODE:
|
|
// Comment, do nothing
|
|
break;
|
|
default:
|
|
console.warn(
|
|
'HTML DOM to linear model conversion error: unknown node of type ' +
|
|
child.nodeType + ' with content ' + child.innerHTML
|
|
);
|
|
}
|
|
}
|
|
// Close the last paragraph, if still open
|
|
if ( paragraphOpened ) {
|
|
data.push( { 'type': '/paragraph' } );
|
|
}
|
|
|
|
if ( typeData && typeData.type ) {
|
|
data.push( { 'type': '/' + typeData.type } );
|
|
}
|
|
return data;
|
|
};
|
|
|
|
/**
|
|
* Recursively convert a linear model array to HTML DOM.
|
|
* This function is not yet written.
|
|
*/
|
|
ve.dm.HTMLConverter.prototype.unconvert = function() {
|
|
// TODO: Implement
|
|
};
|