mediawiki-extensions-Visual.../modules/ve-mw/dm/nodes/ve.dm.MWTransclusionNode.js
Bartosz Dziewoński 4c93b27c7d Update VE core submodule to master (65e536c1c)
New changes:
3135ad109 Update OOUI to v0.50.0
0eb93c340 Localisation updates from https://translatewiki.net.
2e23d8cac ve.ui.Surface: Improve documentation of surface padding
d42086d50 Allow TableCellableNode to span multiple DOM elements
65e536c1c Localisation updates from https://translatewiki.net.

Local changes:
* Allow MWTransclusionTableCellNode to span multiple DOM elements

Bug: T366984
Bug: T367061
Change-Id: I67239ac72f29b9729b73c7604ee3680ceb1b8475
2024-06-17 20:06:18 +02:00

500 lines
15 KiB
JavaScript

/*!
* VisualEditor DataModel MWTransclusionNode class.
*
* @copyright See AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* DataModel MediaWiki transclusion node.
*
* @class
* @abstract
* @extends ve.dm.LeafNode
* @mixes ve.dm.GeneratedContentNode
* @mixes ve.dm.FocusableNode
*
* @constructor
* @param {Object} [element] Reference to element in linear model
*/
ve.dm.MWTransclusionNode = function VeDmMWTransclusionNode() {
// Parent constructor
ve.dm.MWTransclusionNode.super.apply( this, arguments );
// Mixin constructors
ve.dm.GeneratedContentNode.call( this );
ve.dm.FocusableNode.call( this );
// Properties
this.partsList = null;
// Events
this.connect( this, { attributeChange: 'onAttributeChange' } );
};
/* Inheritance */
OO.inheritClass( ve.dm.MWTransclusionNode, ve.dm.LeafNode );
OO.mixinClass( ve.dm.MWTransclusionNode, ve.dm.GeneratedContentNode );
OO.mixinClass( ve.dm.MWTransclusionNode, ve.dm.FocusableNode );
/* Static members */
ve.dm.MWTransclusionNode.static.name = 'mwTransclusion';
ve.dm.MWTransclusionNode.static.matchTagNames = null;
ve.dm.MWTransclusionNode.static.matchRdfaTypes = [ 'mw:Transclusion' ];
// Transclusion nodes can contain other types, e.g. mw:PageProp/Category.
// Allow all other types (null) so they match to this node.
ve.dm.MWTransclusionNode.static.allowedRdfaTypes = null;
// HACK: This prevents any rules with higher specificity from matching,
// e.g. LanguageAnnotation which uses a match function
ve.dm.MWTransclusionNode.static.matchFunction = function () {
return true;
};
ve.dm.MWTransclusionNode.static.enableAboutGrouping = true;
// We handle rendering ourselves, no need to render attributes from originalDomElements (T207325),
// except for data-parsoid/RESTBase ID (T207325)
ve.dm.MWTransclusionNode.static.preserveHtmlAttributes = function ( attribute ) {
return [ 'data-parsoid', 'id' ].indexOf( attribute ) !== -1;
};
ve.dm.MWTransclusionNode.static.getHashObject = function ( dataElement ) {
return {
type: dataElement.type,
mw: dataElement.attributes.mw
};
};
ve.dm.MWTransclusionNode.static.isDiffComparable = function ( element, other ) {
function getTemplateNames( parts ) {
return parts.map( ( part ) => part.template ? part.template.target.wt : '' ).join( '|' );
}
return ve.dm.MWTransclusionNode.super.static.isDiffComparable.call( this, element, other ) &&
getTemplateNames( element.attributes.mw.parts ) === getTemplateNames( other.attributes.mw.parts );
};
/**
* Node type to use when the transclusion is inline
*
* @static
* @property {string}
* @inheritable
*/
ve.dm.MWTransclusionNode.static.inlineType = 'mwTransclusionInline';
/**
* Node type to use when the transclusion is a block
*
* @static
* @property {string}
* @inheritable
*/
ve.dm.MWTransclusionNode.static.blockType = 'mwTransclusionBlock';
/**
* Node type to use when the transclusion is cellable
*
* @static
* @property {string}
* @inheritable
*/
ve.dm.MWTransclusionNode.static.cellType = 'mwTransclusionTableCell';
ve.dm.MWTransclusionNode.static.toDataElement = function ( domElements, converter ) {
const mwDataJSON = domElements[ 0 ].getAttribute( 'data-mw' ),
mwData = mwDataJSON ? JSON.parse( mwDataJSON ) : {},
isInline = this.isHybridInline( domElements, converter ),
type = isInline ? this.inlineType : this.blockType;
const dataElement = {
type: type,
attributes: {
mw: mwData,
originalMw: mwDataJSON
}
};
if ( ve.dm.TableCellableNode.static.areNodesCellable( domElements ) ) {
dataElement.type = this.cellType;
ve.dm.TableCellableNode.static.setAttributes( dataElement.attributes, domElements, true );
}
if ( !domElements[ 0 ].getAttribute( 'data-ve-no-generated-contents' ) ) {
this.storeGeneratedContents( dataElement, domElements, converter.getStore() );
}
return dataElement;
};
ve.dm.MWTransclusionNode.static.toDomElements = function ( dataElement, doc, converter ) {
const store = converter.getStore(),
originalMw = dataElement.attributes.originalMw,
originalDomElements = store.value( dataElement.originalDomElementsHash );
function wrapTextNode( node ) {
if ( node.nodeType === Node.TEXT_NODE ) {
const wrapper = doc.createElement( 'span' );
wrapper.appendChild( node );
return wrapper;
}
return node;
}
let els;
// If the transclusion is unchanged just send back the
// original DOM elements so selser can skip over it
if (
originalDomElements &&
originalMw && ve.compare( dataElement.attributes.mw, JSON.parse( originalMw ) )
) {
// originalDomElements is also used for CE rendering so return a copy
els = ve.copyDomElements( originalDomElements, doc );
} else {
let value;
if (
converter.doesModeNeedRendering() &&
// Use getHashObjectForRendering to get the rendering from the store
( value = store.value( store.hashOfValue( null, OO.getHash( [ this.getHashObjectForRendering( dataElement ), undefined ] ) ) ) )
) {
// For the clipboard use the current DOM contents so the user has something
// meaningful to paste into external applications
els = ve.copyDomElements( value, doc );
els[ 0 ] = wrapTextNode( els[ 0 ] );
} else if ( originalDomElements ) {
els = [ doc.createElement( originalDomElements[ 0 ].nodeName ) ];
} else if ( dataElement.type === this.cellType ) {
els = [ doc.createElement( dataElement.attributes.style === 'header' ? 'th' : 'td' ) ];
} else {
els = [ doc.createElement( 'span' ) ];
}
// All we need to send back to Parsoid is the original transclusion marker, with a
// reconstructed data-mw property.
els[ 0 ].setAttribute( 'typeof', 'mw:Transclusion' );
els[ 0 ].setAttribute( 'data-mw', JSON.stringify( dataElement.attributes.mw ) );
}
if ( converter.isForClipboard() ) {
// If the first element is a <link>, <meta> or <style> tag, e.g. a category or TemplateStyles,
// ensure it is not destroyed by copy-paste by replacing it with a span
if ( els[ 0 ].tagName === 'LINK' || els[ 0 ].tagName === 'META' || els[ 0 ].tagName === 'STYLE' ) {
const span = doc.createElement( 'span' );
span.setAttribute( 'typeof', 'mw:Transclusion' );
span.setAttribute( 'data-mw', els[ 0 ].getAttribute( 'data-mw' ) );
els[ 0 ] = span;
}
// Empty spans can get thrown around by Chrome when pasting, so give them a space
if ( els[ 0 ].innerHTML === '' ) {
els[ 0 ].appendChild( doc.createTextNode( '\u00a0' ) );
}
// Mark the data-mw element as not having valid generated contents with it in case it is
// inserted into another editor (e.g. via paste).
els[ 0 ].setAttribute( 'data-ve-no-generated-contents', true );
// ... and mark all but the first child as ignorable
for ( let i = 1; i < els.length; i++ ) {
// Wrap plain text nodes so we can give them an attribute
els[ i ] = wrapTextNode( els[ i ] );
els[ i ].setAttribute( 'data-ve-ignore', 'true' );
}
} else if ( converter.isForPreview() ) {
const modelNode = ve.dm.nodeFactory.createFromElement( dataElement );
modelNode.setDocument( converter.internalList.getDocument() );
const viewNode = ve.ce.nodeFactory.createFromModel( modelNode );
// HACK: Node must be attached to check for rendering
viewNode.$element.appendTo( 'body' );
if ( !viewNode.hasRendering() ) {
viewNode.$element.detach();
viewNode.onSetup();
// HACK: Force the icon to render immediately
viewNode.updateInvisibleIconSync( true );
els = viewNode.$element.toArray();
}
viewNode.destroy();
viewNode.$element.detach();
}
return els;
};
ve.dm.MWTransclusionNode.static.describeChanges = function ( attributeChanges ) {
const descriptions = [ ve.msg( 'visualeditor-changedesc-mwtransclusion' ) ];
// This method assumes that the behavior of isDiffComparable above remains
// the same, so it doesn't have to consider whether the actual template
// involved has changed.
function getLabel( par ) {
// If a parameter is an object with a wt key, we just want the value of that.
if ( par && par.wt !== undefined ) {
// Can be `''`, and we're okay with that
return par.wt;
}
return par;
}
if ( attributeChanges.mw.from.parts.length === 1 && attributeChanges.mw.to.parts.length === 1 ) {
// Single-template transclusion, before and after. Relatively easy to summarize.
// TODO: expand this to well-represent transclusions that contain multiple templates.
// The bits of a template we care about are deeply-nested inside an
// attribute. We'll restructure this so that we can pretend template
// params are the direct attributes of the template.
const params = {};
let param;
for ( param in attributeChanges.mw.from.parts[ 0 ].template.params ) {
params[ param ] = { from: getLabel( attributeChanges.mw.from.parts[ 0 ].template.params[ param ] ) };
}
for ( param in attributeChanges.mw.to.parts[ 0 ].template.params ) {
params[ param ] = ve.extendObject(
{ to: getLabel( attributeChanges.mw.to.parts[ 0 ].template.params[ param ] ) },
params[ param ]
);
}
let paramChanges;
for ( param in params ) {
// All we know is that *something* changed, without the normal
// helpful just-being-given-the-changed-bits, so we have to filter
// this ourselves.
// Trim string values, and convert empty strings to undefined
const from = ( params[ param ].from || '' ).trim() || undefined,
to = ( params[ param ].to || '' ).trim() || undefined;
if ( from !== to ) {
const change = this.describeChange( param, { from: from, to: to } );
if ( change ) {
if ( !paramChanges ) {
paramChanges = document.createElement( 'ul' );
descriptions.push( paramChanges );
}
const listItem = document.createElement( 'li' );
if ( typeof change === 'string' ) {
listItem.appendChild( document.createTextNode( change ) );
} else {
change.forEach( ( node ) => {
listItem.appendChild( node );
} );
}
paramChanges.appendChild( listItem );
}
}
}
}
return descriptions;
};
/**
* @inheritdoc ve.dm.Node
*/
ve.dm.MWTransclusionNode.static.cloneElement = function () {
// Parent method
const clone = ve.dm.MWTransclusionNode.super.static.cloneElement.apply( this, arguments );
delete clone.attributes.originalMw;
return clone;
};
/**
* Escape a template parameter. Helper function for #getWikitext.
*
* @static
* @param {string} param Parameter value
* @return {string} Escaped parameter value
*/
ve.dm.MWTransclusionNode.static.escapeParameter = function ( param ) {
let input = param,
output = '',
inNowiki = false,
bracketStack = 0,
linkStack = 0;
while ( input.length > 0 ) {
const match = input.match( /\[\[|]]|{{|}}|\|+|<\/?nowiki>|<nowiki\s*\/>/ );
if ( !match ) {
output += input;
break;
}
output += input.slice( 0, match.index );
input = input.slice( match.index + match[ 0 ].length );
if ( inNowiki ) {
if ( match[ 0 ] === '</nowiki>' ) {
inNowiki = false;
}
output += match[ 0 ];
continue;
}
let needsNowiki = true;
switch ( match[ 0 ].charAt( 0 ) ) {
case '<':
if ( match[ 0 ] === '<nowiki>' ) {
inNowiki = true;
}
needsNowiki = false;
break;
case '[':
linkStack++;
needsNowiki = false;
break;
case ']':
if ( linkStack > 0 ) {
linkStack--;
needsNowiki = false;
}
break;
case '{':
bracketStack++;
needsNowiki = false;
break;
case '}':
if ( bracketStack > 0 ) {
bracketStack--;
needsNowiki = false;
}
break;
case '|':
if ( bracketStack > 0 || linkStack > 0 ) {
needsNowiki = false;
}
break;
}
output += needsNowiki ?
'<nowiki>' + match[ 0 ] + '</nowiki>' :
match[ 0 ];
}
return output;
};
/**
* Recreate the wikitext for this transclusion, possibly containing multiple template invocations,
* mixed with raw wikitext snippets.
*
* @static
* @param {Object} content MW data content
* @return {string} Wikitext
*/
ve.dm.MWTransclusionNode.static.getWikitext = function ( content ) {
let wikitext = '';
// Normalize to multi template format
if ( content.params ) {
content = { parts: [ { template: content } ] };
}
// Build wikitext from content
for ( let i = 0, len = content.parts.length; i < len; i++ ) {
const part = content.parts[ i ];
if ( part.template ) {
// Template
const template = part.template;
wikitext += '{{' + template.target.wt;
for ( const param in template.params ) {
wikitext += '|' + param + '=' +
this.escapeParameter( template.params[ param ].wt );
}
wikitext += '}}';
} else {
// Plain wikitext
wikitext += part;
}
}
return wikitext;
};
/* Methods */
/**
* Handle attribute change events.
*
* @param {string} key Attribute key
* @param {string} from Old value
* @param {string} to New value
*/
ve.dm.MWTransclusionNode.prototype.onAttributeChange = function ( key ) {
if ( key === 'mw' ) {
this.partsList = null;
}
};
/**
* Check if transclusion contains only a single template.
*
* @param {string|string[]} [allowedTemplates] Names of templates to allow, omit to allow any template name
* @return {boolean} Transclusion only contains a single template, which is one of the ones in templates
*/
ve.dm.MWTransclusionNode.prototype.isSingleTemplate = function ( allowedTemplates ) {
const templateNS = mw.config.get( 'wgNamespaceIds' ).template,
parts = this.getPartsList();
function normalizeTemplateTitle( name ) {
const title = mw.Title.newFromText( name, templateNS );
return title ? title.getPrefixedText() : name;
}
// Bail out as early as possible when no filter is given, or it's not a single part anyway
const isSingle = parts.length === 1;
if ( !isSingle || !allowedTemplates ) {
return isSingle;
}
const singlePart = parts[ 0 ];
// It's not a template but e.g. a parser function or raw wikitext content
if ( !singlePart.templatePage ) {
return false;
}
if ( typeof allowedTemplates === 'string' ) {
allowedTemplates = [ allowedTemplates ];
}
return allowedTemplates.some( ( template ) => singlePart.templatePage === normalizeTemplateTitle( template ) );
};
/**
* Get a simplified description of the transclusion's parts.
*
* @return {Object[]} List of objects with either template or content properties
*/
ve.dm.MWTransclusionNode.prototype.getPartsList = function () {
if ( !this.partsList ) {
this.partsList = [];
const content = this.getAttribute( 'mw' );
for ( let i = 0; i < content.parts.length; i++ ) {
const part = content.parts[ i ];
// A template as serialized by {@see ve.dm.MWTemplateModel.serialize}
if ( part.template ) {
const href = part.template.target.href,
page = href ? mw.libs.ve.normalizeParsoidResourceName( href ) : null;
this.partsList.push( {
template: part.template.target.wt,
templatePage: page
} );
} else {
// Raw wikitext as serialized by {@see ve.dm.MWTransclusionContentModel.serialize}
this.partsList.push( { content: part } );
}
}
}
return this.partsList;
};
/**
* Wrapper for static method, {@see ve.dm.MWTransclusionNode.static.getWikitext} above.
*
* @return {string} Wikitext
*/
ve.dm.MWTransclusionNode.prototype.getWikitext = function () {
return this.constructor.static.getWikitext( this.getAttribute( 'mw' ) );
};
/* Registration */
ve.dm.modelRegistry.register( ve.dm.MWTransclusionNode );