mediawiki-extensions-Visual.../modules/ve/dm/ve.dm.Converter.js
Catrope 5e16141750 Change context.wrapping to context.inWrapper
It's what the docs say it should be, so let's call it that :)

Change-Id: Ia10861ce77872243beb3d3a1886877824103f6c9
2013-02-22 15:21:40 -08:00

1086 lines
37 KiB
JavaScript

/*!
* VisualEditor DataModel Converter class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* DataModel converter.
*
* Converts between HTML DOM and VisualEditor linear data.
*
* @class
* @constructor
* @param {ve.dm.NodeFactory} nodeFactory
* @param {ve.dm.AnnotationFactory} annotationFactory
*/
ve.dm.Converter = function VeDmConverter( nodeFactory, annotationFactory ) {
// Properties
this.nodeFactory = nodeFactory;
this.annotationFactory = annotationFactory;
this.elements = {
'toDomElement': {},
'toDataElement': {},
'dataElementTypes': {}
};
// Events
this.nodeFactory.addListenerMethod( this, 'register', 'onNodeRegister' );
};
/* Static Methods */
/**
* Get linear model data from a string optionally applying annotations
*
* @static
* @param {string} text Plain text to convert
* @param {ve.AnnotationSet} [annotations] Annotations to apply
* @returns {Array} Linear model data, one element per character
*/
ve.dm.Converter.getDataContentFromText = function ( text, annotations ) {
var characters = text.split( '' ),
i;
if ( !annotations || annotations.isEmpty() ) {
return characters;
}
// Apply annotations to characters
for ( i = 0; i < characters.length; i++ ) {
// Make a shallow copy of the annotationSet object, otherwise adding an annotation to one
// character automatically adds it to all of others as well, annotations should be treated
// as immutable, so it's OK to share references, but annotation sets are not immutable, so
// it's not safe to share references - each annotated character needs its own set
characters[i] = [characters[i], annotations.clone()];
}
return characters;
};
/* Methods */
/**
* Handle register events from the node factory.
*
* FIXME
* If a node is special; such as document, alienInline, alienBlock and text; its {converters}
* property should be set to null, as to distinguish it from a new node type that someone has simply
* forgotten to implement converters for.
*
* @method
* @param {string} type Node type
* @param {Function} constructor Node constructor
* @throws {Error} Missing conversion data in node implementation
*/
ve.dm.Converter.prototype.onNodeRegister = function ( dataElementType, constructor ) {
if ( !constructor.static.toDomElement || !constructor.static.toDataElement ) {
throw new Error( 'Missing static properties in node implementation of ' + dataElementType );
} else {
var i,
domElementTypes = constructor.static.matchTagNames || [],
toDomElement = constructor.static.toDomElement,
toDataElement = constructor.static.toDataElement;
// Registration
this.elements.toDomElement[dataElementType] = toDomElement;
for ( i = 0; i < domElementTypes.length; i++ ) {
this.elements.toDataElement[domElementTypes[i]] = toDataElement;
this.elements.dataElementTypes[domElementTypes[i]] = dataElementType;
}
}
};
/**
* Get the DOM element for a given linear model element.
*
* This invokes the toDomElement function registered for the element type.
*
* @method
* @param {Object} dataElement Linear model element
* @param {HTMLDocument} doc Document to create DOM elements in
* @returns {HTMLElement|boolean} DOM element, or false if the element cannot be converted
*/
ve.dm.Converter.prototype.getDomElementFromDataElement = function ( dataElement, doc ) {
var key, domElement, dataElementAttributes, wrapper,
dataElementType = dataElement.type;
if ( dataElementType === 'alienInline' || dataElementType === 'alienBlock' ) {
// Alien
// Create nodes from source
wrapper = doc.createElement( 'div' );
wrapper.innerHTML = dataElement.attributes.html;
if ( wrapper.childNodes.length > 1 ) {
// Wrap the HTML in a single element, this makes
// it much easier to deal with. It'll be unwrapped
// at the end of getDomFromData().
domElement = doc.createElement( 'div' );
domElement.setAttribute( 'data-ve-multi-child-alien-wrapper', 'true' );
while ( wrapper.firstChild ) {
domElement.appendChild( wrapper.firstChild );
}
} else {
domElement = wrapper.firstChild;
}
return domElement;
}
if ( !( dataElementType in this.elements.toDomElement ) ) {
// Unsupported element
return false;
}
domElement = this.elements.toDomElement[dataElementType]( dataElement );
dataElementAttributes = dataElement.attributes;
if ( dataElementAttributes ) {
for ( key in dataElementAttributes ) {
// Only include 'html/0/*' attributes and strip the 'html/0/' from the beginning of the name
if ( key.indexOf( 'html/0/' ) === 0 ) {
domElement.setAttribute( key.substr( 7 ), dataElementAttributes[key] );
}
}
}
// Change markers
if (
dataElement.internal && dataElement.internal.changed &&
!ve.isEmptyObject( dataElement.internal.changed ) &&
ve.init.platform.useChangeMarkers()
) {
domElement.setAttribute( 'data-ve-changed',
JSON.stringify( dataElement.internal.changed )
);
}
return domElement;
};
/**
* Get the linear model data element for a given DOM element.
*
* This invokes the toDataElement function registered for the element type
*
* @method
* @param {HTMLElement} domElement DOM element
* @param {ve.AnnotationSet} annotations Annotations to apply if the node is a content node
* @returns {Object|boolean} Linear model element, or false if the node cannot be converted
*/
ve.dm.Converter.prototype.getDataElementFromDomElement = function ( domElement, annotations ) {
var dataElement, domElementAttributes, dataElementAttributes, domElementAttribute, i,
domElementType = domElement.nodeName.toLowerCase();
annotations = annotations || new ve.AnnotationSet();
if (
// Unsupported elements
!( domElementType in this.elements.toDataElement )
// TODO check for generated elements
) {
return false;
}
dataElement = this.elements.toDataElement[domElementType]( domElement );
domElementAttributes = domElement.attributes;
if (
dataElement && ve.dm.nodeFactory.doesNodeStoreHtmlAttributes( dataElement.type ) &&
domElementAttributes.length
) {
dataElementAttributes = dataElement.attributes = dataElement.attributes || {};
// Include all attributes and prepend 'html/0/' to each attribute name
for ( i = 0; i < domElementAttributes.length; i++ ) {
domElementAttribute = domElementAttributes[i];
dataElementAttributes['html/0/' + domElementAttribute.name] = domElementAttribute.value;
}
}
if ( this.nodeFactory.isNodeContent( dataElement.type ) && !annotations.isEmpty() ) {
dataElement.annotations = annotations.clone();
}
return dataElement;
};
/**
* Check if an HTML DOM node represents an annotation, and if so, build an annotation object for it.
*
* Annotation Object:
* { 'type': 'type', data: { 'key': 'value', ... } }
*
* @param {HTMLElement} domElement HTML DOM node
* @returns {Object|boolean} Annotation object, or false if the node is not an annotation
*/
ve.dm.Converter.prototype.getDataAnnotationFromDomElement = function ( domElement ) {
return this.annotationFactory.createFromElement( domElement ) || false;
};
/**
* Build an HTML DOM node for a linear model annotation.
*
* @method
* @param {Object} dataAnnotation Annotation object
* @returns {HTMLElement} HTML DOM node
*/
ve.dm.Converter.prototype.getDomElementFromDataAnnotation = function ( dataAnnotation, doc ) {
var htmlData = dataAnnotation.toHTML(),
domElement = doc.createElement( htmlData.tag );
ve.setDomAttributes( domElement, htmlData.attributes );
return domElement;
};
/**
* Convert an HTML document to a linear model.
* @param {HTMLDocument} doc HTML document to convert
* @returns {Array} Linear model data
*/
ve.dm.Converter.prototype.getDataFromDom = function ( doc ) {
// Possibly do things with doc and the head in the future
return this.getDataFromDomRecursion( doc.body );
};
/**
* Recursive implementation of getDataFromDom(). For internal use.
*
* @method
* @param {HTMLElement} domElement HTML element to convert
* @param {ve.AnnotationSet} [annotations] Annotations to apply to the generated data
* @param {Object} [dataElement] Data element to wrap the returned data in
* @param {Array} [path] Array of linear model element types
* @param {boolean} [alreadyWrapped] Whether the caller has already started wrapping bare content in a paragraph
* @returns {Array} Linear model data
*/
ve.dm.Converter.prototype.getDataFromDomRecursion = function ( domElement, annotations,
dataElement, path, alreadyWrapped ) {
function createAlien( domElement, context, isWrapper ) {
// We generate alienBlock elements for block tags and alienInline elements for
// inline tags; unless we're in a content location, in which case we have no choice
// but to generate an alienInline element.
var isInline =
// Force inline in content locations (but not wrappers)
( !context.inWrapper && context.expectingContent ) ||
// Also force inline in wrappers that we can't close
( context.inWrapper && !context.canCloseWrapper ) ||
// Look at the tag name otherwise
!ve.isBlockElement( domElement ),
type = isInline ? 'alienInline' : 'alienBlock',
html, alien;
if ( isWrapper ) {
html = $( domElement ).html();
} else {
html = $( '<div>', doc ).append( $( domElement ).clone() ).html();
}
alien = [
{
'type': type,
'attributes': {
'html': html
}
},
{ 'type': '/' + type }
];
if ( !annotations.isEmpty() ) {
alien[0].annotations = annotations.clone();
}
return alien;
}
function addWhitespace( element, index, whitespace ) {
if ( !element.internal ) {
element.internal = {};
}
// whitespace = [ outerPre, innerPre, innerPost, outerPost ]
// <tag> text </tag> <nextTag>
// ^^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^
// outerPre innerPre innerPost outerPost
if ( !element.internal.whitespace ) {
element.internal.whitespace = [];
}
if ( !element.internal.whitespace[index] ) {
element.internal.whitespace[index] = '';
}
element.internal.whitespace[index] = whitespace;
}
function processNextWhitespace( element ) {
// This function uses and changes nextWhitespace in the outer function's scope,
// which means it's not really a function but more of a shortcut.
if ( nextWhitespace !== '' ) {
addWhitespace( element, 0, nextWhitespace );
nextWhitespace = '';
}
}
function startWrapping() {
// Mark this paragraph as having been generated by
// us, so we can strip it on the way out
wrappingParagraph = {
'type': 'paragraph',
'internal': { 'generated': 'wrapper' }
};
data.push( wrappingParagraph );
context.inWrapper = true;
context.canCloseWrapper = true;
context.expectingContent = true;
processNextWhitespace( wrappingParagraph );
}
function stopWrapping() {
if ( wrappedWhitespace !== '' ) {
// Remove wrappedWhitespace from data
data.splice( wrappedWhitespaceIndex, wrappedWhitespace.length );
addWhitespace( wrappingParagraph, 3, wrappedWhitespace );
nextWhitespace = wrappedWhitespace;
}
data.push( { 'type': '/paragraph' } );
wrappingParagraph = undefined;
context.inWrapper = false;
context.canCloseWrapper = false;
context.expectingContent = originallyExpectingContent;
}
/**
* Helper function to group adjacent child elements with the same about attribute together.
* If there are multiple adjacent child nodes with the same about attribute, they are
* wrapped in a `<div>` with the data-ve-aboutgroup attribute set.
*
* This function does not wrap single-element about groups, and does not descend into the
* child elements.
*
* @private
* @param element {HTMLElement} Element to process
*/
function doAboutGrouping( element ) {
var child = element.firstChild, textNodes = [],
prevChild, aboutGroup, aboutWrapper, childAbout, nextChild, i;
while ( child ) {
nextChild = child.nextSibling;
if ( !child.getAttribute ) {
// Text nodes don't have a getAttribute() method. Thanks HTML DOM,
// that's really helpful ^^
textNodes.push( child );
child = nextChild;
continue;
}
childAbout = child.getAttribute( 'about' );
if ( childAbout && !aboutGroup ) {
// Start of a new about group
aboutGroup = childAbout;
} else if ( childAbout && childAbout === aboutGroup ) {
// Continuation of the current about group
if ( !aboutWrapper ) {
// This is the second child in this group, so the
// previous child is the first child in this group.
// Wrap the previous child
aboutWrapper = doc.createElement( 'div' );
aboutWrapper.setAttribute( 'data-ve-aboutgroup', aboutGroup );
element.insertBefore( aboutWrapper, prevChild );
aboutWrapper.appendChild( prevChild );
}
// Append any outstanding text nodes to the wrapper
for ( i = 0; i < textNodes.length; i++ ) {
aboutWrapper.appendChild( textNodes[i] );
}
// Append this child to the wrapper
aboutWrapper.appendChild( child );
} else if ( aboutGroup ) {
// This child isn't in the current about group
aboutGroup = undefined;
aboutWrapper = undefined;
if ( childAbout ) {
// Start of a new about group
aboutGroup = childAbout;
}
}
prevChild = child;
child = nextChild;
textNodes = [];
}
}
// Fallback to defaults
annotations = annotations || new ve.AnnotationSet();
path = path || ['document'];
var i, j, childDomElement, annotation, childDataElement, text, childTypes, matches,
wrappingParagraph, prevElement, alien, rdfaType, isLink, childAnnotations,
doc = domElement.ownerDocument,
data = [],
branchType = path[path.length - 1],
branchHasContent = this.nodeFactory.canNodeContainContent( branchType ),
originallyExpectingContent = branchHasContent || !annotations.isEmpty(),
childIsContent,
nextWhitespace = '',
wrappedWhitespace = '',
wrappedWhitespaceIndex,
context = {
'expectingContent': originallyExpectingContent,
'inWrapper': alreadyWrapped,
'canCloseWrapper': false
};
// Open element
if ( dataElement ) {
data.push( dataElement );
}
// Do about grouping
// FIXME this assumes every about group is an alien
doAboutGrouping( domElement );
// Add contents
for ( i = 0; i < domElement.childNodes.length; i++ ) {
childDomElement = domElement.childNodes[i];
switch ( childDomElement.nodeType ) {
case Node.ELEMENT_NODE:
// Alienate about groups
if ( childDomElement.hasAttribute( 'data-ve-aboutgroup' ) ) {
alien = createAlien( childDomElement, context, true );
if ( context.inWrapper && alien[0].type === 'alienBlock' ) {
stopWrapping();
} else if (
!context.inWrapper && !context.expectingContent &&
alien[0].type === 'alienInline'
) {
startWrapping();
}
data = data.concat( alien );
processNextWhitespace( alien[0] );
prevElement = alien[0];
break;
}
// HACK handle <meta>/<link> separately because of the
// metaInline/metaBlock distinction
if (
childDomElement.nodeName.toLowerCase() === 'meta' ||
childDomElement.nodeName.toLowerCase() === 'link'
) {
isLink = childDomElement.nodeName.toLowerCase() === 'link';
childDataElement = {
'type': context.expectingContent ? 'metaInline' : 'metaBlock',
'attributes': {
'style': isLink ? 'link' : 'meta',
'key': childDomElement.getAttribute( isLink ? 'rel' : 'property' )
}
};
if ( childDomElement.hasAttribute( isLink ? 'href' : 'content' ) ) {
childDataElement.attributes.value = childDomElement.getAttribute( isLink ? 'href' : 'content' );
}
// Preserve HTML attributes
// FIXME the following is duplicated from getDataElementFromDomElement()
// Include all attributes and prepend 'html/0/' to each attribute name
for ( j = 0; j < childDomElement.attributes.length; j++ ) {
// ..but exclude attributes we've already processed,
// because they'll be overwritten otherwise *sigh*
// FIXME this sucks, we need a new node type API so bad
if (
childDomElement.attributes[j].name !== ( isLink ? 'rel' : 'property' ) &&
childDomElement.attributes[j].name !== ( isLink ? 'href' : 'content' )
) {
childDataElement.attributes['html/0/' + childDomElement.attributes[j].name] = childDomElement.attributes[j].value;
}
}
data.push( childDataElement );
data.push( { 'type': context.expectingContent ? '/metaInline' : '/metaBlock' } );
processNextWhitespace( childDataElement );
prevElement = childDataElement;
break;
}
// Alienate anything with a mw: type that isn't registered
// HACK because we don't actually have an RDFa type registry yet,
// this hardcodes the set of recognized types
rdfaType = childDomElement.getAttribute( 'rel' ) ||
childDomElement.getAttribute( 'typeof' ) ||
childDomElement.getAttribute( 'property' );
if (
rdfaType &&
rdfaType.match( /^mw:/ ) &&
!rdfaType.match( /^mw:WikiLink/ ) &&
!rdfaType.match( /^mw:ExtLink/ ) &&
!rdfaType.match( /^mw:Entity/ )
) {
alien = createAlien( childDomElement, context );
if ( context.inWrapper && alien[0].type === 'alienBlock' ) {
stopWrapping();
} else if (
!context.inWrapper && !context.expectingContent &&
alien[0].type === 'alienInline'
) {
startWrapping();
}
data = data.concat( alien );
processNextWhitespace( alien[0] );
prevElement = alien[0];
break;
}
// Detect and handle annotated content
// HACK except for mw:Entity. We need a node API rewrite, badly
annotation = this.getDataAnnotationFromDomElement( childDomElement );
if ( annotation && rdfaType !== 'mw:Entity' ) {
// Start auto-wrapping of bare content
if ( !context.inWrapper && !context.expectingContent ) {
startWrapping();
prevElement = wrappingParagraph;
}
// Append child element data
childAnnotations = annotations.clone();
childAnnotations.push( annotation );
data = data.concat(
this.getDataFromDomRecursion(
childDomElement, childAnnotations,
undefined, path, context.inWrapper
)
);
break;
}
// Look up child element type
childDataElement = this.getDataElementFromDomElement( childDomElement, annotations );
if ( childDataElement ) {
childIsContent = this.nodeFactory.isNodeContent( childDataElement.type );
// Check that something isn't terribly wrong
if ( !(
// Non-content child in a content container
( originallyExpectingContent && !childIsContent ) ||
// Non-content child trying to break wrapping at
// the wrong level
( context.inWrapper && !context.canCloseWrapper && !childIsContent )
) ) {
// End auto-wrapping of bare content from a previously processed node
// but only if childDataElement is a non-content element
if ( context.inWrapper && context.canCloseWrapper && !childIsContent ) {
stopWrapping();
} else if ( !context.inWrapper && !context.expectingContent && childIsContent ) {
startWrapping();
prevElement = wrappingParagraph;
}
if ( this.nodeFactory.canNodeHaveChildren( childDataElement.type ) ) {
// Append child element data
data = data.concat(
this.getDataFromDomRecursion(
childDomElement,
new ve.AnnotationSet(),
childDataElement,
path.concat( childDataElement.type ),
context.inWrapper
)
);
} else {
// Append empty node
data.push( childDataElement );
data.push( { 'type': '/' + childDataElement.type } );
}
processNextWhitespace( childDataElement );
prevElement = childDataElement;
break;
}
// If something is wrong, fall through, and the bad child
// will be alienated below.
}
// We don't know what this is, fall back to alien.
alien = createAlien( childDomElement, context );
if ( context.inWrapper && alien[0].type === 'alienBlock' ) {
stopWrapping();
} else if (
!context.inWrapper && !context.expectingContent &&
alien[0].type === 'alienInline'
) {
startWrapping();
}
data = data.concat( alien );
processNextWhitespace( alien[0] );
prevElement = alien[0];
break;
case Node.TEXT_NODE:
text = childDomElement.data;
if ( text === '' ) {
// Empty text node?!?
break;
}
if ( !originallyExpectingContent ) {
// Strip and store outer whitespace
if ( text.match( /^\s+$/ ) ) {
// This text node is whitespace only
if ( context.inWrapper ) {
// We're already wrapping, so output this whitespace
// and store it in wrappedWhitespace (see
// comment about wrappedWhitespace below)
wrappedWhitespace = text;
wrappedWhitespaceIndex = data.length;
data = data.concat(
ve.dm.Converter.getDataContentFromText( wrappedWhitespace, annotations )
);
} else {
// We're not in wrapping mode, store this whitespace
if ( !prevElement ) {
if ( dataElement ) {
// First child, store as inner
// whitespace in the parent
addWhitespace( dataElement, 1, text );
}
// Else, WTF?!? This is not supposed to
// happen, but it's not worth
// throwing an exception over.
} else {
addWhitespace( prevElement, 3, text );
}
nextWhitespace = text;
wrappedWhitespace = '';
}
// We're done, no actual text left to process
break;
} else {
// This text node contains actual text
// Separate the real text from the whitespace
// HACK: . doesn't match newlines in JS, so use
// [\s\S] to match any character
matches = text.match( /^(\s*)([\s\S]*?)(\s*)$/ );
if ( !context.inWrapper ) {
// Wrap the text in a paragraph and output it
startWrapping();
// Only store leading whitespace if we just
// started wrapping
if ( matches[1] !== '' ) {
if ( !prevElement ) {
if ( dataElement ) {
// First child, store as inner
// whitespace in the parent
addWhitespace( dataElement, 1, matches[1] );
}
// Else, WTF?!? This is not supposed to
// happen, but it's not worth
// throwing an exception over.
} else {
addWhitespace( prevElement, 3, matches[1] );
}
addWhitespace( wrappingParagraph, 0, matches[1] );
}
} else {
// We were already wrapping in a paragraph,
// so the leading whitespace must be output
data = data.concat(
ve.dm.Converter.getDataContentFromText( matches[1], annotations )
);
}
// Output the text sans whitespace
data = data.concat(
ve.dm.Converter.getDataContentFromText( matches[2], annotations )
);
// Don't store this in wrappingParagraph.internal.whitespace[3]
// and nextWhitespace just yet. Instead, store it
// in wrappedWhitespace. There might be more text
// nodes after this one, so we output wrappedWhitespace
// for now and undo that if it turns out this was
// the last text node. We can't output it later
// because we have to apply the correct annotations.
wrappedWhitespace = matches[3];
wrappedWhitespaceIndex = data.length;
data = data.concat(
ve.dm.Converter.getDataContentFromText( wrappedWhitespace, annotations )
);
prevElement = wrappingParagraph;
break;
}
}
// Strip leading and trailing inner whitespace
// (but only in non-annotation nodes)
// and store it so it can be restored later.
if (
annotations.isEmpty() && i === 0 && dataElement &&
!this.nodeFactory.doesNodeHaveSignificantWhitespace( dataElement.type )
) {
// Strip leading whitespace from the first child
matches = text.match( /^\s+/ );
if ( matches && matches[0] !== '' ) {
addWhitespace( dataElement, 1, matches[0] );
text = text.substring( matches[0].length );
}
}
if (
annotations.isEmpty() &&
i === domElement.childNodes.length - 1 &&
dataElement &&
!this.nodeFactory.doesNodeHaveSignificantWhitespace( dataElement.type )
) {
// Strip trailing whitespace from the last child
matches = text.match( /\s+$/ );
if ( matches && matches[0] !== '' ) {
addWhitespace( dataElement, 2, matches[0] );
text = text.substring( 0,
text.length - matches[0].length );
}
}
// Annotate the text and output it
data = data.concat(
ve.dm.Converter.getDataContentFromText( text, annotations )
);
break;
case Node.COMMENT_NODE:
childDataElement = {
'type': context.expectingContent ? 'metaInline' : 'metaBlock',
'attributes': {
'style': 'comment',
'text': childDomElement.data
}
};
data.push( childDataElement );
data.push( { 'type': context.expectingContent ? '/metaInline' : '/metaBlock' } );
processNextWhitespace( childDataElement );
prevElement = childDataElement;
break;
}
}
// End auto-wrapping of bare content
if ( context.inWrapper && context.canCloseWrapper ) {
stopWrapping();
// HACK: don't set context.inWrapper = false here because it's checked below
context.inWrapper = true;
}
// If we're closing a node that doesn't have any children, but could contain a paragraph,
// add a paragraph. This prevents things like empty list items
childTypes = this.nodeFactory.getChildNodeTypes( branchType );
if ( branchType !== 'paragraph' && dataElement && data[data.length - 1] === dataElement &&
!context.inWrapper && !this.nodeFactory.canNodeContainContent( branchType ) &&
!this.nodeFactory.isNodeContent( branchType ) &&
( childTypes === null || ve.indexOf( 'paragraph', childTypes ) !== -1 )
) {
data.push( { 'type': 'paragraph', 'internal': { 'generated': 'empty' } } );
data.push( { 'type': '/paragraph' } );
}
// Close element
if ( dataElement ) {
data.push( { 'type': '/' + dataElement.type } );
// Add the whitespace after the last child to the parent as innerPost
if ( nextWhitespace !== '' ) {
addWhitespace( dataElement, 2, nextWhitespace );
nextWhitespace = '';
}
}
// Don't return an empty document
if ( branchType === 'document' && data.length === 0 ) {
return [
{ 'type': 'paragraph', 'internal': { 'generated': 'empty' } },
{ 'type': '/paragraph' }
];
}
return data;
};
/**
* Convert linear model data to an HTML DOM
*
* @method
* @param {Array} data Linear model data
* @returns {HTMLDocument} Document containing the resulting HTML
*/
ve.dm.Converter.prototype.getDomFromData = function ( data ) {
var text, i, j, k, annotations, annotation, annotationElement, dataElement, arr,
childDomElement, pre, ours, theirs, parentDomElement, startClosingAt,
isContentNode, changed, parentChanged,
doc = ve.createDocumentFromHTML( '' ),
container = doc.body,
domElement = container,
annotationStack = new ve.AnnotationSet();
for ( i = 0; i < data.length; i++ ) {
if ( typeof data[i] === 'string' ) {
// Text
text = '';
// Continue forward as far as the plain text goes
while ( typeof data[i] === 'string' ) {
text += data[i];
i++;
}
// i points to the first non-text thing, go back one so we don't skip this later
i--;
// Add text
domElement.appendChild( doc.createTextNode( text ) );
} else if (
ve.isArray( data[i] ) ||
(
data[i].annotations !== undefined &&
this.nodeFactory.isNodeContent( data[i].type )
)
) {
// Annotated text or annotated nodes
text = '';
while (
ve.isArray( data[i] ) ||
(
data[i].annotations !== undefined &&
this.nodeFactory.isNodeContent( data[i].type )
)
) {
annotations = data[i].annotations || data[i][1];
// Close annotations as needed
// Go through annotationStack from bottom to top (low to high),
// and find the first annotation that's not in annotations.
startClosingAt = undefined;
arr = annotationStack.get();
for ( j = 0; j < arr.length; j++ ) {
annotation = arr[j];
if ( !annotations.contains( annotation ) ) {
startClosingAt = j;
break;
}
}
if ( startClosingAt !== undefined ) {
// Close all annotations from top to bottom (high to low)
// until we reach startClosingAt
for ( j = annotationStack.getLength() - 1; j >= startClosingAt; j-- ) {
// Add text if needed
if ( text.length > 0 ) {
domElement.appendChild( doc.createTextNode( text ) );
text = '';
}
// Traverse up
domElement = domElement.parentNode;
// Remove from annotationStack
annotationStack.removeAt( j );
}
}
// Open annotations as needed
arr = annotations.get();
for ( j = 0; j < arr.length; j++ ) {
annotation = arr[j];
if ( !annotationStack.contains( annotation ) ) {
// Add text if needed
if ( text.length > 0 ) {
domElement.appendChild( doc.createTextNode( text ) );
text = '';
}
// Create new node and descend into it
annotationElement = this.getDomElementFromDataAnnotation( annotation, doc );
domElement.appendChild( annotationElement );
domElement = annotationElement;
// Add to annotationStack
annotationStack.push( annotation );
}
}
if ( data[i].annotations === undefined ) {
// Annotated text
text += data[i][0];
} else {
// Annotated node
// Add text if needed
if ( text.length > 0 ) {
domElement.appendChild( doc.createTextNode( text ) );
text = '';
}
// Insert the element
domElement.appendChild( this.getDomElementFromDataElement( data[i], doc ) );
// Increment i once more so we skip over the closing as well
i++;
}
i++;
}
// We're now at the first non-annotated thing, go back one so we don't skip this later
i--;
// Add any gathered text
if ( text.length > 0 ) {
domElement.appendChild( doc.createTextNode( text ) );
text = '';
}
// Close any remaining annotation nodes
for ( j = annotationStack.getLength() - 1; j >= 0; j-- ) {
// Traverse up
domElement = domElement.parentNode;
}
// Clear annotationStack
annotationStack = new ve.AnnotationSet();
} else if ( data[i].type !== undefined ) {
dataElement = data[i];
// Element
if ( dataElement.type.charAt( 0 ) === '/' ) {
parentDomElement = domElement.parentNode;
isContentNode = this.nodeFactory.isNodeContent( data[i].type.substr( 1 ) );
// Process whitespace
// whitespace = [ outerPre, innerPre, innerPost, outerPost ]
if (
!isContentNode &&
domElement.veInternal &&
domElement.veInternal.whitespace
) {
// Process inner whitespace. innerPre is for sure legitimate
// whitespace that should be inserted; if it was a duplicate
// of our child's outerPre, we would have cleared it.
pre = domElement.veInternal.whitespace[1];
if ( pre ) {
if (
domElement.firstChild &&
domElement.firstChild.nodeType === 3
) {
// First child is a TextNode, prepend to it
domElement.firstChild.insertData( 0, pre );
} else {
// Prepend a TextNode
domElement.insertBefore(
doc.createTextNode( pre ),
domElement.firstChild
);
}
}
ours = domElement.veInternal.whitespace[2];
if ( domElement.lastOuterPost === undefined ) {
// This node didn't have any structural children
// (i.e. it's a content-containing node), so there's
// nothing to check innerPost against
theirs = ours;
} else {
theirs = domElement.lastOuterPost;
}
if ( ours && ours === theirs ) {
if (
domElement.lastChild &&
domElement.lastChild.nodeType === 3
) {
// Last child is a TextNode, append to it
domElement.lastChild.appendData( ours );
} else {
// Append a TextNode
domElement.appendChild(
doc.createTextNode( ours )
);
}
}
// Tell the parent about our outerPost
parentDomElement.lastOuterPost = domElement.veInternal.whitespace[3] || '';
} else if ( !isContentNode ) {
// Use empty string, because undefined means there were no
// structural children
parentDomElement.lastOuterPost = '';
}
// else don't touch lastOuterPost
// If closing a generated wrapper node, unwrap it
// It would be nicer if we could avoid generating in the first
// place, but then remembering where we have to skip ascending
// to the parent would be tricky.
// We unwrap all nodes with generated=wrapper, as well as nodes that
// have generated=empty and are empty.
if (
domElement.veInternal && (
domElement.veInternal.generated === 'wrapper' || (
domElement.veInternal.generated === 'empty' &&
domElement.childNodes.length === 0
)
)
) {
while ( domElement.firstChild ) {
parentDomElement.insertBefore(
domElement.firstChild,
domElement
);
}
// Transfer change markers
changed = domElement.getAttribute( 'data-ve-changed' );
if ( changed ) {
parentChanged = parentDomElement.getAttribute( 'data-ve-changed' );
if ( parentChanged ) {
changed = $.parseJSON( changed );
parentChanged = $.parseJSON( parentChanged );
for ( k in changed ) {
if ( k in parentChanged ) {
parentChanged[k] += changed[k];
} else {
parentChanged[k] = changed[k];
}
}
parentDomElement.setAttribute( 'data-ve-changed',
JSON.stringify( parentChanged ) );
} else {
parentDomElement.setAttribute( 'data-ve-changed',
changed );
}
}
parentDomElement.removeChild( domElement );
}
delete domElement.veInternal;
delete domElement.lastOuterPost;
// Ascend to parent node
domElement = parentDomElement;
} else {
// Create node from data
childDomElement = this.getDomElementFromDataElement( dataElement, doc );
// Add reference to internal data
if ( dataElement.internal ) {
childDomElement.veInternal = dataElement.internal;
}
// Add element
domElement.appendChild( childDomElement );
// Descend into child node
parentDomElement = domElement;
domElement = childDomElement;
// Process outer whitespace
// Every piece of outer whitespace is duplicated somewhere:
// each node's outerPost is duplicated as the next node's
// outerPre, the first node's outerPre is the parent's
// innerPre, and the last node's outerPost is the parent's
// innerPost. For each piece of whitespace, we verify that
// the duplicate matches. If it doesn't, we take that to
// mean the user has messed with it and don't output any
// whitespace.
if ( domElement.veInternal && domElement.veInternal.whitespace ) {
// Process this node's outerPre
ours = domElement.veInternal.whitespace[0];
theirs = undefined;
if ( domElement.previousSibling ) {
// Get previous sibling's outerPost
theirs = parentDomElement.lastOuterPost;
} else if ( parentDomElement === container ) {
// outerPre of the very first node in the document, this one
// has no duplicate
theirs = ours;
} else {
// First child, get parent's innerPre
if (
parentDomElement.veInternal &&
parentDomElement.veInternal.whitespace
) {
theirs = parentDomElement.veInternal.whitespace[1];
// Clear after use so it's not used twice
parentDomElement.veInternal.whitespace[1] = undefined;
}
// else theirs=undefined
}
if ( ours && ours === theirs ) {
// Matches the duplicate, insert a TextNode
parentDomElement.insertBefore(
doc.createTextNode( ours ),
domElement
);
}
}
}
}
}
// Process the outerPost whitespace of the very last node
if ( container.lastOuterPost !== undefined ) {
if ( container.lastChild && container.lastChild.nodeType === 3 ) {
// Last child is a TextNode, append to it
container.lastChild.appendData( container.lastOuterPost );
} else {
// Append a TextNode
container.appendChild( doc.createTextNode( container.lastOuterPost ) );
}
delete container.lastOuterPost;
}
// Unwrap multi-child alien wrappers
$( container ).find( '[data-ve-multi-child-alien-wrapper]' ).each( function() {
$( this ).replaceWith( $( this ).contents() );
} );
// Workaround for bug 42469: if a <pre> starts with a newline, that means .innerHTML will
// screw up and stringify it with one fewer newline. Work around this by adding a newline.
// If we don't see a leading newline, we still don't know if the original HTML was
// <pre>Foo</pre> or <pre>\nFoo</pre> , but that's a syntactic difference, not a semantic
// one, and handling that is Parsoid's job.
$( container ).find( 'pre' ).each( function() {
var matches;
if ( this.firstChild.nodeType === Node.TEXT_NODE ) {
matches = this.firstChild.data.match( /^(\r\n|\r|\n)/ );
if ( matches && matches[1] ) {
// Prepend a newline exactly like the one we saw
this.firstChild.insertData( 0, matches[1] );
}
}
} );
return doc;
};
/* Initialization */
ve.dm.converter = new ve.dm.Converter( ve.dm.nodeFactory, ve.dm.annotationFactory );