MediaWiki:Gadget-FDVEDiagramLink.js: Difference between revisions

From RECESSIM, A Reverse Engineering Community
Jump to navigation Jump to search
Remove the dead "Change which diagram is shown" link: the transclusion dialog is empty for a parser function, so it was a false affordance
Recognise {{Diagram|page=}} as well as the bare parser function, so converted embeds keep double-click-to-edit
Line 65: Line 65:
* upgrade reverts that patch.
* upgrade reverts that patch.
*
*
target.wt       "#display_diagram:Mermaid:Some_Page"
* Two shapes are recognised:
target.function "display_diagram"
{{Diagram|page=Mermaid:Foo}}    -> params.page.wt       (VE-editable)
*  {{#display_diagram:Mermaid:Foo}} -> target.wt after ':'  (legacy, still valid)
*
*
* @param {ve.dm.MWTransclusionNode} model
* @param {ve.dm.MWTransclusionNode} model
Line 72: Line 73:
*/
*/
function diagramPageFromModel( model ) {
function diagramPageFromModel( model ) {
var mwData, parts, i, target, wt, colon;
var mwData, parts, i, part, target, params, wt, colon;


try {
try {
Line 85: Line 86:


for ( i = 0; i < parts.length; i++ ) {
for ( i = 0; i < parts.length; i++ ) {
target = parts[ i ] && parts[ i ].template && parts[ i ].template.target;
part = parts[ i ] && parts[ i ].template;
if ( !target || target.function !== 'display_diagram' ) {
target = part && part.target;
if ( !target ) {
continue;
continue;
}
}
wt = String( target.wt || '' );
 
colon = wt.indexOf( ':' );
// Shape 1 - {{Diagram|page=Mermaid:Foo}}. Parsoid gives a template part
if ( colon === -1 ) {
// a target.href and NO target.function, putting the page name in
// params.page. This is the shape VE can actually edit - parameters are
// editable, a parser-function target is not - which is the whole reason
// Template:Diagram exists.
if ( normaliseTemplateName( target ) === WRAPPER_TEMPLATE ) {
params = part.params;
wt = params && params.page && params.page.wt;
wt = ( wt === undefined || wt === null ) ? '' : String( wt ).trim();
if ( wt ) {
return wt;
}
continue;
continue;
}
}
// Everything after "#display_diagram:" is the diagram page, which
 
// itself contains a namespace colon.
// Shape 2 - a bare {{#display_diagram:Mermaid:Foo}}. Still supported:
wt = wt.slice( colon + 1 ).trim();
// embeds predating the template use it, and it remains legal wikitext.
if ( wt ) {
if ( target.function === 'display_diagram' ) {
return wt;
wt = String( target.wt || '' );
colon = wt.indexOf( ':' );
if ( colon === -1 ) {
continue;
}
// Everything after "#display_diagram:" is the diagram page, which
// itself contains a namespace colon.
wt = wt.slice( colon + 1 ).trim();
if ( wt ) {
return wt;
}
}
}
}
}
return null;
return null;
}
// Name of the wrapper template, normalised (see normaliseTemplateName).
var WRAPPER_TEMPLATE = 'diagram';
/**
* Reduce a transclusion target to a bare, comparable template name.
* Parsoid hrefs look like "./Template:Diagram"; hand-written wikitext may say
* "Template:Diagram", "diagram" or use underscores.
*
* @param {Object} target
* @return {string} lower-cased name with no namespace prefix
*/
function normaliseTemplateName( target ) {
return String( ( target && ( target.href || target.wt ) ) || '' )
.replace( /^\.\//, '' )
.replace( /^Template:/i, '' )
.replace( /_/g, ' ' )
.trim()
.toLowerCase();
}
}



Revision as of 05:11, 11 September 2026

/*
 * Gadget-FDVEDiagramLink.js
 *
 * Makes an embedded FlexDiagrams diagram actionable inside VisualEditor:
 * double-click it (or use the context popup) to open that diagram's own editor
 * in a new tab.
 *
 * Why a plugin rather than a link: VE deliberately neutralises links inside
 * focusable nodes. ve.ce.FocusableNode.js binds a click handler that
 * preventDefault()s left-clicks on anything not content-editable, with the
 * comment "preventing default on mousedown doesn't suppress click events, so
 * link navigation would still occur". The context popup, however, lives OUTSIDE
 * the contenteditable surface, which is why plain anchors work there - see
 * ve.ui.LinkContextItem.
 *
 * Loaded into VisualEditor through $wgVisualEditorPluginModules, so this file
 * executes before the editor toolbar is built. The compact placeholder that
 * replaces the raw diagram source is CSS, in [[MediaWiki:Common.css]].
 *
 * Maintainer note: VE here is the REL1_43 bundle and reports no version number,
 * so there is nothing to gate on - the guard below feature-detects instead.
 * APIs relied on: ve.ui.MWTransclusionContextItem, ve.ui.contextItemFactory,
 * ve.dm.MWTransclusionNode, ve.ui.ModeledFactory's most-specific-wins rule, and
 * ve.ce.MWTransclusionNode.executeCommand delegating to the context item.
 * Re-check those after a MediaWiki upgrade.
 */
( function () {
	'use strict';

	if ( typeof ve === 'undefined' || !ve.ui || !ve.dm ) {
		// Not inside VisualEditor - nothing to do, and nothing worth saying.
		return;
	}

	// VE loads plugin modules as SIBLINGS of its own modules, not after them:
	// ve.init.mw.ArticleTargetLoader builds
	//     [ 'ext.visualEditor.articleTarget', ...conf.pluginModules ]
	// and loads that as one batch, so there is no ordering guarantee. The classes
	// below therefore have to be pulled in through this gadget's own
	// dependencies= in MediaWiki:Gadgets-definition
	// (ext.visualEditor.core, ext.visualEditor.mwtransclusion).
	//
	// If that wiring is ever lost, this file would silently do nothing and the
	// stock template context item would take over again - which is exactly how
	// this failed the first time - so say so out loud.
	if (
		!ve.ui.MWTransclusionContextItem ||
		!ve.ui.contextItemFactory ||
		!ve.dm.MWTransclusionNode
	) {
		// eslint-disable-next-line no-console
		console.warn( 'FDVEDiagramLink: VisualEditor classes not available at ' +
			'plugin execution time; check dependencies= in MediaWiki:Gadgets-definition. ' +
			'Diagram nodes will fall back to the stock template context item.' );
		return;
	}

	/**
	 * Recover the diagram's page name from a transclusion model.
	 *
	 * Read from data-mw rather than the rendered DOM: it is the canonical model
	 * data, it covers all five diagram formats uniformly, and unlike the
	 * data-wiki-page attribute it does not depend on the local patch to
	 * FD_DisplayDiagram.php - so this keeps working even if an extension
	 * upgrade reverts that patch.
	 *
	 * Two shapes are recognised:
	 *   {{Diagram|page=Mermaid:Foo}}    -> params.page.wt        (VE-editable)
	 *   {{#display_diagram:Mermaid:Foo}} -> target.wt after ':'  (legacy, still valid)
	 *
	 * @param {ve.dm.MWTransclusionNode} model
	 * @return {string|null} e.g. "Mermaid:Some_Page"
	 */
	function diagramPageFromModel( model ) {
		var mwData, parts, i, part, target, params, wt, colon;

		try {
			mwData = model.getAttribute( 'mw' );
		} catch ( e ) {
			return null;
		}
		parts = mwData && mwData.parts;
		if ( !Array.isArray( parts ) ) {
			return null;
		}

		for ( i = 0; i < parts.length; i++ ) {
			part = parts[ i ] && parts[ i ].template;
			target = part && part.target;
			if ( !target ) {
				continue;
			}

			// Shape 1 - {{Diagram|page=Mermaid:Foo}}. Parsoid gives a template part
			// a target.href and NO target.function, putting the page name in
			// params.page. This is the shape VE can actually edit - parameters are
			// editable, a parser-function target is not - which is the whole reason
			// Template:Diagram exists.
			if ( normaliseTemplateName( target ) === WRAPPER_TEMPLATE ) {
				params = part.params;
				wt = params && params.page && params.page.wt;
				wt = ( wt === undefined || wt === null ) ? '' : String( wt ).trim();
				if ( wt ) {
					return wt;
				}
				continue;
			}

			// Shape 2 - a bare {{#display_diagram:Mermaid:Foo}}. Still supported:
			// embeds predating the template use it, and it remains legal wikitext.
			if ( target.function === 'display_diagram' ) {
				wt = String( target.wt || '' );
				colon = wt.indexOf( ':' );
				if ( colon === -1 ) {
					continue;
				}
				// Everything after "#display_diagram:" is the diagram page, which
				// itself contains a namespace colon.
				wt = wt.slice( colon + 1 ).trim();
				if ( wt ) {
					return wt;
				}
			}
		}
		return null;
	}

	// Name of the wrapper template, normalised (see normaliseTemplateName).
	var WRAPPER_TEMPLATE = 'diagram';

	/**
	 * Reduce a transclusion target to a bare, comparable template name.
	 * Parsoid hrefs look like "./Template:Diagram"; hand-written wikitext may say
	 * "Template:Diagram", "diagram" or use underscores.
	 *
	 * @param {Object} target
	 * @return {string} lower-cased name with no namespace prefix
	 */
	function normaliseTemplateName( target ) {
		return String( ( target && ( target.href || target.wt ) ) || '' )
			.replace( /^\.\//, '' )
			.replace( /^Template:/i, '' )
			.replace( /_/g, ' ' )
			.trim()
			.toLowerCase();
	}

	/**
	 * Context item shown when an embedded diagram is selected in VE.
	 *
	 * Subclasses the stock transclusion item on purpose: ve.ui.ModeledFactory
	 * keeps only the most specific class in an inheritance chain, so this
	 * replaces the template context item for diagram nodes and leaves every
	 * other template alone.
	 *
	 * @class
	 * @extends ve.ui.MWTransclusionContextItem
	 * @constructor
	 */
	function FDDiagramContextItem() {
		FDDiagramContextItem.super.apply( this, arguments );
		this.$element.addClass( 'fd-ve-diagramContextItem' );
	}

	OO.inheritClass( FDDiagramContextItem, ve.ui.MWTransclusionContextItem );

	FDDiagramContextItem.static.name = 'fdDiagram';
	FDDiagramContextItem.static.icon = 'articles';
	FDDiagramContextItem.static.label = 'Diagram';
	FDDiagramContextItem.static.modelClasses = [ ve.dm.MWTransclusionNode ];

	FDDiagramContextItem.static.isCompatibleWith = function ( model ) {
		return model instanceof ve.dm.MWTransclusionNode &&
			!!diagramPageFromModel( model );
	};

	/**
	 * @return {string|null} URL of this diagram's editor
	 */
	FDDiagramContextItem.prototype.getDiagramEditUrl = function () {
		var page = diagramPageFromModel( this.model );
		return page ? mw.util.getUrl( page, { action: 'editdiagram' } ) : null;
	};

	FDDiagramContextItem.prototype.openDiagramEditor = function () {
		var url = this.getDiagramEditUrl();
		if ( url ) {
			window.open( url, '_blank', 'noopener' );
		}
	};

	/**
	 * Hijack the primary action. ve.ce.MWTransclusionNode.executeCommand looks
	 * for a context item that is an instanceof ve.ui.MWTransclusionContextItem
	 * and calls onEditButtonClick() on it, so overriding this also gives us
	 * double-click and Enter on the node for free.
	 */
	FDDiagramContextItem.prototype.onEditButtonClick = function () {
		this.openDiagramEditor();
	};

	FDDiagramContextItem.prototype.renderBody = function () {
		var page = diagramPageFromModel( this.model ),
			url = this.getDiagramEditUrl(),
			$edit;

		if ( !page || !url ) {
			// Should be unreachable - isCompatibleWith already required a page.
			FDDiagramContextItem.super.prototype.renderBody.call( this );
			return;
		}

		// A plain anchor is fine here: the context popup is outside the
		// contenteditable surface, so VE does not suppress the click.
		$edit = $( '<a>' )
			.addClass( 'fd-ve-diagramContextItem-edit' )
			.attr( { target: '_blank', rel: 'noopener' } )
			.text( 'Edit “' + page.replace( /_/g, ' ' ) + '” ↗' );
		ve.setAttributeSafe( $edit[ 0 ], 'href', url, '#' );

		// Deliberately the only control here. There was also a "Change which
		// diagram is shown" link that fell through to the stock transclusion
		// dialog, but that dialog is empty for this node: #display_diagram is a
		// parser function, so the diagram page is part of the target string
		// rather than a parameter, and VE sources its parameter list from
		// TemplateData, which a parser function has none of. It looked
		// actionable and was not. To point an article at a different diagram,
		// edit the page source - it is one obvious line of wikitext.
		this.$body.empty().append( $( '<div>' ).append( $edit ) );
	};

	/* Registration */
	ve.ui.contextItemFactory.register( FDDiagramContextItem );
}() );