mediawiki-extensions-Visual.../modules/ve/ce/nodes/ve.ce.GeneratedContentNode.js
Roan Kattouw 74b8807df5 Resolve rendered URLs according to the provided <base>
This is done by using the computed property value rather than the
literal attribute value when rendering href and src attributes.
Helpfully, this provides perfect URL resolution natively in the browser,
which means the document's <base> is respected and all that good stuff.

For GeneratedContentNodes, we also need to find all DOM elements inside
the rendered DOM that have href or src attributes and resolve those.
This is done in the new getRenderedDomElements() function, which the
existing cleanup steps (remove <link>/<meta>/<style>, clone for
correct document) were moved into.

In order to make sure that the computed values are always computed
correctly, we need to make sure that in cases where HTML strings
in data-mw are parsed, they're parsed in the context of the correct
document so the correct <base> is applied.

We still need to solve this problem for models that actually store and
edit an href or src as an attribute. I'll post more about that on
bug 48915.

Bug: 48915
Change-Id: Iaccb9e3fc05cd151a0f5e632c8d3bd3568735309
2013-10-28 15:16:05 +00:00

253 lines
8 KiB
JavaScript

/*!
* VisualEditor ContentEditable GeneratedContentNode class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* ContentEditable generated content node.
*
* @class
* @abstract
*
* @constructor
*/
ve.ce.GeneratedContentNode = function VeCeGeneratedContentNode() {
// Properties
this.generatingPromise = null;
// DOM changes
this.$.addClass( 've-ce-generatedContentNode' );
// Events
this.model.connect( this, { 'update': 'onGeneratedContentNodeUpdate' } );
// Initialization
this.update();
};
/* Events */
/**
* @event setup
*/
/**
* @event teardown
*/
/**
* @event rerender
*/
/* Static members */
ve.ce.GeneratedContentNode.static = {};
// this.$ is just a wrapper for the real content, so don't duplicate attributes on it
ve.ce.GeneratedContentNode.static.renderHtmlAttributes = false;
/* Abstract methods */
/**
* Start a deferred process to generate the contents of the node.
*
* If successful, the returned promise must be resolved with the generated DOM elements passed
* in as the first parameter, i.e. promise.resolve( domElements ); . Any other parameters to
* .resolve() are ignored.
*
* If the returned promise object is abortable (has an .abort() method), .abort() will be called if
* a newer update is started before the current update has finished. When a promise is aborted, it
* should cease its work and shouldn't be resolved or rejected. If an outdated update's promise
* is resolved or rejected anyway (which may happen if an aborted promise misbehaves, or if the
* promise wasn't abortable), this is ignored and doneGenerating()/failGenerating() is not called.
*
* Additional data may be passed in the config object to instruct this function to render something
* different than what's in the model. This data is implementation-specific and is passed through
* by forceUpdate().
*
* @abstract
* @param {Object} [config] Optional additional data
* @returns {jQuery.Promise} Promise object, may be abortable
*/
ve.ce.GeneratedContentNode.prototype.generateContents = function () {
throw new Error( 've.ce.GeneratedContentNode subclass must implement generateContents' );
};
/* Methods */
/**
* Handler for the update event
*/
ve.ce.GeneratedContentNode.prototype.onGeneratedContentNodeUpdate = function () {
this.update();
};
/**
* Make an array of DOM elements suitable for rendering.
*
* Subclasses can override this to provide their own cleanup steps. This function takes an
* array of DOM elements cloned within the source document and returns an array of DOM elements
* cloned into the target document. If it's important that the DOM elements still be associated
* with the original document, you should modify domElements before calling the parent
* implementation, otherwise you should call the parent implementation first and modify its
* return value.
*
* @param {HTMLElement[]} domElements Clones of the DOM elements from the store
* @returns {HTMLElement[]} Clones of the DOM elements in the right document, with modifications
*/
ve.ce.GeneratedContentNode.prototype.getRenderedDomElements = function ( domElements ) {
var i, len, attr, $rendering,
doc = this.getElementDocument();
/**
* Callback for jQuery.fn.each that resolves the value of attr to the computed
* property value. Called in the context of an HTMLElement.
* @private
*/
function resolveAttribute() {
this.setAttribute( attr, this[attr] );
}
// Copy domElements so we can modify the elements
// Filter out link, meta and style tags for bug 50043
$rendering = $( domElements ).not( 'link, meta, style' );
// Also remove link, meta and style tags nested inside other tags
$rendering.find( 'link, meta, style' ).remove();
// Render the computed values of some attributes
for ( i = 0, len = ve.dm.Converter.computedAttributes.length; i < len; i++ ) {
attr = ve.dm.Converter.computedAttributes[i];
$rendering.find( '[' + attr + ']' )
.add( $rendering.filter( '[' + attr + ']' ) )
.each( resolveAttribute );
}
// Clone the elements into the target document
return ve.copyDomElements( $rendering.toArray(), doc );
};
/**
* Rerender the contents of this node.
*
* @param {Object|string|Array} generatedContents Generated contents, in the default case an HTMLElement array
* @fires setup
* @fires teardown
*/
ve.ce.GeneratedContentNode.prototype.render = function ( generatedContents ) {
if ( this.live ) {
this.emit( 'teardown' );
}
this.$.empty().append( this.getRenderedDomElements( ve.copyDomElements( generatedContents ) ) );
if ( this.live ) {
this.emit( 'setup' );
this.afterRender( generatedContents );
}
};
/**
* Trigger rerender events after rendering the contents of the node.
*
* Nodes may override this method if the rerender event needs to be deferred (e.g. until images have loaded)
*
* @param {Object|string|Array} generatedContents Generated contents
* @fires rerender
*/
ve.ce.GeneratedContentNode.prototype.afterRender = function () {
this.emit( 'rerender' );
};
/**
* Update the contents of this node based on the model and config data. If this combination of
* model and config data has been rendered before, the cached rendering in the store will be used.
*
* @param {Object} [config] Optional additional data to pass to generateContents()
*/
ve.ce.GeneratedContentNode.prototype.update = function ( config ) {
var store = this.model.doc.getStore(),
index = store.indexOfHash( OO.getHash( [ this.model, config ] ) );
if ( index !== null ) {
this.render( store.value( index ) );
} else {
this.forceUpdate( config );
}
};
/**
* Force the contents to be updated. Like update(), but bypasses the store.
*
* @param {Object} [config] Optional additional data to pass to generateContents()
*/
ve.ce.GeneratedContentNode.prototype.forceUpdate = function ( config ) {
var promise, node = this;
if ( this.generatingPromise ) {
// Abort the currently pending generation process if possible
// Unset this.generatingPromise first so that if the promise is resolved or rejected
// when we abort, this is ignored as it should be
promise = this.generatingPromise;
this.generatingPromise = null;
if ( $.isFunction( promise.abort ) ) {
promise.abort();
}
} else {
// Only call startGenerating() if we weren't generating before
this.startGenerating();
}
// Create a new promise
promise = this.generatingPromise = this.generateContents( config );
promise
// If this promise is no longer the currently pending one, ignore it completely
.done( function ( generatedContents ) {
if ( node.generatingPromise === promise ) {
node.doneGenerating( generatedContents, config );
}
} )
.fail( function () {
if ( node.generatingPromise === promise ) {
node.failGenerating();
}
} );
};
/**
* Called when the node starts generating new content.
*
* This function is only called when the node wasn't already generating content. If a second update
* comes in, this function will only be called if the first update has already finished (i.e.
* doneGenerating or failGenerating has already been called).
*
* @method
*/
ve.ce.GeneratedContentNode.prototype.startGenerating = function () {
this.$.addClass( 've-ce-generatedContentNode-generating' );
};
/**
* Called when the node successfully finishes generating new content.
*
* @method
* @param {Object|string|Array} generatedContents Generated contents
* @param {Object} [config] Config object passed to forceUpdate()
*/
ve.ce.GeneratedContentNode.prototype.doneGenerating = function ( generatedContents, config ) {
var store = this.model.doc.getStore(),
hash = OO.getHash( [ this.model, config ] );
store.index( generatedContents, hash );
this.$.removeClass( 've-ce-generatedContentNode-generating' );
this.generatingPromise = null;
this.render( generatedContents );
};
/**
* Called when the has failed to generate new content.
*
* @method
*/
ve.ce.GeneratedContentNode.prototype.failGenerating = function () {
this.$.removeClass( 've-ce-generatedContentNode-generating' );
this.generatingPromise = null;
};