/*!
* VisualEditor ContentEditable GeneratedContentNode class.
*
* @copyright See AUTHORS.txt
*/
/**
* ContentEditable generated content node.
*
* @class
* @abstract
*
* @constructor
*/
ve.ce.GeneratedContentNode = function VeCeGeneratedContentNode() {
// Properties
this.generatingPromise = null;
this.generatedContentsInvalid = null;
// Events
this.model.connect( this, { update: 'onGeneratedContentNodeUpdate' } );
this.connect( this, { teardown: 'abortGenerating' } );
// Initialization
this.update();
};
/* Inheritance */
OO.initClass( ve.ce.GeneratedContentNode );
/* Events */
/**
* @event ve.ce.GeneratedContentNode#rerender
*/
/* Static members */
// We handle rendering ourselves, no need to render attributes from originalDomElements
ve.ce.GeneratedContentNode.static.renderHtmlAttributes = false;
/* Static methods */
/**
* Wait for all content-generation within a given node to finish
*
* If no GeneratedContentNodes are within the node, a resolved promise will be
* returned.
*
* @param {ve.ce.View} view Any view node
* @return {jQuery.Promise} Promise, resolved when content is generated
*/
ve.ce.GeneratedContentNode.static.awaitGeneratedContent = function ( view ) {
const promises = [];
function queueNode( node ) {
if ( typeof node.generateContents === 'function' ) {
if ( node.isGenerating() ) {
const promise = ve.createDeferred();
node.once( 'rerender', promise.resolve );
promises.push( promise );
}
}
}
// Traverse children to see when they are all rerendered
if ( view instanceof ve.ce.BranchNode ) {
view.traverse( queueNode );
} else {
queueNode( view );
}
return ve.promiseAll( promises );
};
/* 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
* @method
* @param {Object} [config] Optional additional data
* @return {jQuery.Promise} Promise object, may be abortable
*/
ve.ce.GeneratedContentNode.prototype.generateContents = null;
/* Methods */
/**
* Handler for the update event
*
* @param {boolean} staged Update happened in staging mode
*/
ve.ce.GeneratedContentNode.prototype.onGeneratedContentNodeUpdate = function ( staged ) {
this.update( undefined, staged );
};
/**
* 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 {Node[]} domElements Clones of the DOM elements from the store
* @return {HTMLElement[]} Clones of the DOM elements in the right document, with modifications
*/
ve.ce.GeneratedContentNode.prototype.getRenderedDomElements = function ( domElements ) {
const doc = this.getElementDocument();
let rendering = this.filterRenderedDomElements(
// Clone the elements into the target document
ve.copyDomElements( domElements, doc )
);
if ( rendering.length ) {
// Span wrap root text nodes so they can be measured
rendering = rendering.map( ( node ) => {
if ( node.nodeType === Node.TEXT_NODE ) {
const span = document.createElement( 'span' );
span.appendChild( node );
return span;
}
return node;
} );
// Render the computed values of some attributes
ve.resolveAttributes(
rendering,
domElements[ 0 ].ownerDocument,
ve.dm.Converter.static.computedAttributes
);
} else {
rendering = [ document.createElement( 'span' ) ];
}
if ( rendering.every( ve.isVoidElement ) ) {
// Should contain at least one non-void element, e.g. for attaching
// a visibility button in ve.ce.FocusableNode#updateInvisibleIconSync
rendering.push( document.createElement( 'span' ) );
}
return rendering;
};
/**
* Filter out elements from the rendered content which we don't want to display in the CE.
*
* @param {Node[]} domElements Clones of the DOM elements from the store, already copied into the document
* @return {Node[]} DOM elements to keep
*/
ve.ce.GeneratedContentNode.prototype.filterRenderedDomElements = function ( domElements ) {
return ve.filterMetaElements( domElements );
};
/**
* Rerender the contents of this node.
*
* @param {Object|string|Array} generatedContents Generated contents, in the default case an HTMLElement array
* @param {boolean} [staged] Update happened in staging mode
* @fires ve.ce.View#setup
* @fires ve.ce.View#teardown
* @fires ve.dm.GeneratedContentNode#generatedContentsError
*/
ve.ce.GeneratedContentNode.prototype.render = function ( generatedContents, staged ) {
if ( this.live ) {
this.emit( 'teardown' );
}
const $newElements = $( this.getRenderedDomElements( ve.copyDomElements( generatedContents ) ) );
this.generatedContentsInvalid = !this.validateGeneratedContents( $( generatedContents ) );
if ( !staged || !this.generatedContentsInvalid ) {
if ( !this.$element[ 0 ].parentNode ) {
// this.$element hasn't been attached yet, so just overwrite it
this.$element = $newElements;
} else {
// Switch out this.$element (which can contain multiple siblings) in place
const lengthChange = this.$element.length !== $newElements.length;
this.$element.first().replaceWith( $newElements );
this.$element.remove();
this.$element = $newElements;
if ( lengthChange ) {
// Changing the DOM node count can move the cursor, so re-apply
// the cursor position from the model (T231094).
setTimeout( () => {
if ( this.getRoot() && this.getRoot().getSurface() ) {
this.getRoot().getSurface().showModelSelection();
}
} );
}
}
} else {
this.generatedContentsValid = false;
this.model.emit( 'generatedContentsError', $newElements );
}
// Prevent tabbing to focusable elements inside the editable surface
this.preventTabbingInside();
// Update focusable and resizable elements if necessary
// TODO: Move these method definitions to their respective mixins.
if ( this.$focusable ) {
this.$focusable = this.getFocusableElement();
this.$bounding = this.getBoundingElement();
}
if ( this.$resizable ) {
this.$resizable = this.getResizableElement();
}
this.initialize();
if ( this.live ) {
this.emit( 'setup' );
}
this.afterRender();
};
/**
* Prevent tabbing to focusable elements inside the editable surface, because it conflicts with
* allowing tabbing out of the surface. (The surface takes the focus back when it moves to an
* element inside it.)
*
* In the future, this might be implemented using the `inert` property, currently not supported by
* any browser: https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees
* https://caniuse.com/mdn-api_htmlelement_inert
*
* @private
*/
ve.ce.GeneratedContentNode.prototype.preventTabbingInside = function () {
// Like OO.ui.findFocusable(), but find *all* such nodes rather than the first one.
const selector = 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]',
$focusableCandidates = this.$element.find( selector ).addBack( selector );
$focusableCandidates.each( ( i, element ) => {
const $element = $( element );
if ( OO.ui.isFocusableElement( $element ) ) {
$element.attr( 'tabindex', -1 );
}
} );
};
/**
* 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)
*
* @fires ve.ce.GeneratedContentNode#rerender
*/
ve.ce.GeneratedContentNode.prototype.afterRender = function () {
this.emit( 'rerender' );
};
/**
* Check whether the response HTML contains an error.
*
* The default implementation always returns true.
*
* @param {jQuery} $element The generated element
* @return {boolean} There is no error
*/
ve.ce.GeneratedContentNode.prototype.validateGeneratedContents = function () {
return true;
};
/**
* 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()
* @param {boolean} [staged] Update happened in staging mode
*/
ve.ce.GeneratedContentNode.prototype.update = function ( config, staged ) {
const store = this.model.doc.getStore(),
contents = store.value( store.hashOfValue( null, OO.getHash( [ this.model.getHashObjectForRendering(), config ] ) ) );
if ( contents ) {
this.render( contents, staged );
} else {
this.forceUpdate( config, staged );
}
};
/**
* Force the contents to be updated. Like update(), but bypasses the store.
*
* @param {Object} [config] Optional additional data to pass to generateContents()
* @param {boolean} [staged] Update happened in staging mode
*/
ve.ce.GeneratedContentNode.prototype.forceUpdate = function ( config, staged ) {
if ( this.generatingPromise ) {
// Abort the currently pending generation process if possible
this.abortGenerating();
} else {
// Only call startGenerating if we weren't generating before
this.startGenerating();
}
// Create a new promise
const promise = this.generatingPromise = this.generateContents( config );
promise
// If this promise is no longer the currently pending one, ignore it completely
.done( ( generatedContents ) => {
if ( this.generatingPromise === promise ) {
this.doneGenerating( generatedContents, config, staged );
}
} )
.fail( () => {
if ( this.generatingPromise === promise ) {
this.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).
*/
ve.ce.GeneratedContentNode.prototype.startGenerating = function () {
this.$element.addClass( 've-ce-generatedContentNode-generating' );
};
/**
* Abort the currently pending generation, if any, and remove the generating CSS class.
*
* This invokes .abort() on the pending promise if the promise has that method. It also ensures
* that if the promise does get resolved or rejected later, this is ignored.
*/
ve.ce.GeneratedContentNode.prototype.abortGenerating = function () {
const promise = this.generatingPromise;
if ( promise ) {
// Unset this.generatingPromise first so that if the promise is resolved or rejected
// from within .abort(), this is ignored as it should be
this.generatingPromise = null;
if ( typeof promise.abort === 'function' ) {
promise.abort();
}
}
this.$element.removeClass( 've-ce-generatedContentNode-generating' );
};
/**
* Called when the node successfully finishes generating new content.
*
* @param {Object|string|Array} generatedContents Generated contents
* @param {Object} [config] Config object passed to forceUpdate()
* @param {boolean} [staged] Update happened in staging mode
*/
ve.ce.GeneratedContentNode.prototype.doneGenerating = function ( generatedContents, config, staged ) {
this.$element.removeClass( 've-ce-generatedContentNode-generating' );
this.generatingPromise = null;
// Because doneGenerating is invoked asynchronously, the model node may have become detached
// in the meantime. Handle this gracefully.
if ( this.model && this.model.doc ) {
const store = this.model.doc.getStore();
const hash = OO.getHash( [ this.model.getHashObjectForRendering(), config ] );
store.hash( generatedContents, hash );
this.render( generatedContents, staged );
}
};
/**
* Called when the GeneratedContentNode has failed to generate new content.
*/
ve.ce.GeneratedContentNode.prototype.failGenerating = function () {
this.$element.removeClass( 've-ce-generatedContentNode-generating' );
this.generatingPromise = null;
};
/**
* Check whether this GeneratedContentNode is currently generating new content.
*
* @return {boolean} Whether we're generating
*/
ve.ce.GeneratedContentNode.prototype.isGenerating = function () {
return !!this.generatingPromise;
};
/**
* Get the focusable element
*
* @return {jQuery} Focusable element
*/
ve.ce.GeneratedContentNode.prototype.getFocusableElement = function () {
return this.$element;
};
/**
* Get the bounding element
*
* @return {jQuery} Bounding element
*/
ve.ce.GeneratedContentNode.prototype.getBoundingElement = function () {
return this.$element;
};
/**
* Get the resizable element
*
* @return {jQuery} Resizable element
*/
ve.ce.GeneratedContentNode.prototype.getResizableElement = function () {
return this.$element;
};