VisualEditor/Gadgets/Creating a custom command

This pages shows you by a commented example how to adapt a gadget that currently works with the old wikitext editor to use it with VisualEditor, especially with the 2017 wikitext editor. For comparison, this page also shows you how to solve the same task in visual mode, but note that while the wikitext part is generic and can be used with any existing code, the visual part is specific to the action the code actually should do. But it is possible to write a source code only tool, so you don't have to rewrite your code for visual mode if you don't want to.

Example code edit

To test the following code, you can execute it in your browser's console before VE is loaded and then start editing in VE, i.e., click "Edit". It will show up in the "Page options" menu and unlink all years (i.e. remove all links to pages with a numeric title, keeping the label), either in the selected part of the page, or (if nothing is selected) in the whole page.

function makeUnlinkYearsTool() {
	// Function to modify wikitext
	function unlinkYears( wikitext ) {
		return wikitext.replace( /\[\[(\d+)\]\]/g, '$1' )
			.replace( /\[\[\d+\|(.*?)\]\]/g, '$1' );
	}

	// Create and register command
	function UnlinkYearsCommand() {
		UnlinkYearsCommand.parent.call( this, 'unlinkYears' );
	}
	OO.inheritClass( UnlinkYearsCommand, ve.ui.Command );

	UnlinkYearsCommand.prototype.execute = function ( surface ) {
		var surfaceModel, fragment, wikitext, data = [], onCompleteText;
		// Get fragment to work on
		surfaceModel = surface.getModel();
		fragment = surfaceModel.getFragment();
		if ( fragment.getSelection().isCollapsed() ) {
			surfaceModel.setLinearSelection(
				new ve.Range( 0, surfaceModel.getDocument().data.getLength() )
			);
			fragment = surfaceModel.getFragment();
			onCompleteText = true;
		}
		// Visual mode
		if ( ve.init.target.getSurface().getMode() !== 'source' ) {
			fragment.annotateContent(
				'clear',
				fragment.getAnnotations( true ).filter( function ( annotation ) {
					return annotation.getType() === 'link/mwInternal' &&
						/^\d+$/.test( annotation.getAttribute( 'normalizedTitle' ) );
				} )
			);
			return true;
		}
		// Source mode
		wikitext = fragment.getText( true ).replace( /^\n/, '' ).replace( /\n\n/g, '\n' );
		wikitext = unlinkYears( wikitext );
		wikitext.split( '' ).forEach( function ( c ) {
			if ( c === '\n' ) {
				data.push( { type: '/paragraph' } );
				data.push( { type: 'paragraph' } );
			} else {
				data.push( c );
			}
		} );
		if ( onCompleteText ) {
			fragment.insertContent( wikitext );
		} else {
			fragment.insertContent( data );
		}
		if ( onCompleteText ) {
			fragment.collapseToStart().select();
		}
		return true;
	};

	ve.ui.commandRegistry.register( new UnlinkYearsCommand() );

	// Create, register and insert tool
	function UnlinkYearsTool() {
		UnlinkYearsTool.parent.apply( this, arguments );
	}
	OO.inheritClass( UnlinkYearsTool, ve.ui.Tool );

	UnlinkYearsTool.static.name = 'unlinkYears';
	UnlinkYearsTool.static.group = 'utility';
	UnlinkYearsTool.static.title = 'Unlink years';
	UnlinkYearsTool.static.icon = 'noWikiText';
	UnlinkYearsTool.static.commandName = 'unlinkYears';
	UnlinkYearsTool.static.autoAddToCatchall = false;
	UnlinkYearsTool.static.deactivateOnSelect = false;

	UnlinkYearsTool.prototype.onUpdateState = function () {
		UnlinkYearsTool.parent.prototype.onUpdateState.apply( this, arguments );
		this.setActive( false );
	};

	ve.ui.toolFactory.register( UnlinkYearsTool );

	var groups = ve.init.mw.DesktopArticleTarget.static.toolbarGroups.concat( ve.init.mw.DesktopArticleTarget.static.actionGroups );
	groups.some( function ( group ) {
		if ( group.name === 'unlinkYears' ) {
			group.include.push( tool.name );
			return true;
		}
		return false;
	} );
}

// Initialize
mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init' ).then( function () {
	mw.libs.ve.addPlugin( function () {
		return mw.loader.using( [ 'ext.visualEditor.core' ] )
			.then( function () {
				makeUnlinkYearsTool();
			} );
	} );
} );

Explanation edit

Function to modify wikitext edit

First of all, we define our function to modify wikitext. This is not specific to VisualEditor, if you're reading this because you want to adapt your scripts to VisualEditor, you should already have this function.

Just as a minor note: In the early days of Wikipedia linking every year in a text was very popular. After some time editors realized that these links didn't really help anyone in most cases, so they tried to eliminate all those links. During that time scripts to do so automatically were very popular. Nowadays the remaining links to years are sensible in most cases, so scripts to remove them should no longer be needed, but it's still a nice example.

Create and register command edit

Next, we create a command to unlink all years. Most commands work by invoking a method from an ve.ui.Action (documentation, see this example about how to create a command this way), but here it's easier to provide our own execution method instead.

So we inherit from ve.ui.Command (documentation), and override the execute method.

Get fragment to work on edit

First, we get a SurfaceFragment (documentation) from the current selection to work on. If it is collapsed (i.e. nothing is selected) we select the whole document.

Visual mode edit

When we are in visual mode, we use the following way to remove links to years:

  • Links are Annotations (documentation), so we use fragment.getAnnotations( true ) to get them all.
  • From this AnnotationSet (documentation) we filter the links the are interested in: Internal links have the type 'link/mwInternal', the linked page is in the normalizedTitle attribute.
  • We use fragment.annotateContent( 'clear', … ) to remove the links.

At the end we return true to indicate we executed the command.

Source mode edit

In source mode we first get the wikitext from the fragment. The result from the getText method is almost what we want, but we have to fix the newline characters: In source mode, every line is wrapped in <p> tags, and the method returns '\n' for both the opening and closing tags. So we have to strip the first one and replace two consecutive linebreaks with just one.

Now we can apply our function to change the wikitext. When we work on the whole text, we can actually insert it just as it is. But otherwise we have to transform it back to the format VisualEditor uses, so we replace every linebreak with a closing and an opening paragraph tag. We could do so for the whole text as well, but just using insertContent with a string is easier. Just note that inserting a string with linebreaks in it directly will force a newline before and after the inserted text. When you're replacing whole lines only (as you do when working on the complete text) this doesn't matter, but when you're inside a line you won't get the desired result when inserting the string directly.

If we worked on the whole text, we collapse the selection, i.e. we set the cursor to the start of the text. Note that if we started with a selection it is automatically adapted to the changed text (in our case: it is made shorter for the removed links).

Create, register and insert tool edit

Now that we have our command, we need a way to execute it. One way is to create a tool that will be shown in the toolbar. This is very similar to the example adding a tool to insert a template, which also has some more ways to execute a command. Here, we inherit directly from ve.ui.Tool (documentation), and override some of its properties:

  • name is the name of the tool (which coincides with the name of the command, but this isn't required).
  • group is the name of the group the tool belongs to and actually doesn't really matter here.
  • title is the text shown for that tool.
  • icon is a name from this list of available icons.
  • commandName is the name of the command and links the tool to our command.
  • autoAddToCatchall prevents the tool from being added automatically to the toolbar, we want to choose the place ourselves.
  • deactivateOnSelect allows to execute the tool multiple times.
  • onUpdateState makes sure the tool isn't shown as active after execution.

We register our tool and then add it (using its name) to the "Page options" menu. This part of the toolbar is defined in ve.init.mw.DesktopArticleTarget.js, and we just push our tool to the right list.

Initialize edit

The code to initialize is the same as in VisualEditor/Gadgets/Add a tool#Initialize.

More ideas edit

Here are some more ideas to try out yourself.

Source mode only edit

When you are porting an existing script for the old wikitext editor to the new one, you might at first want to only run the tool in source mode, not in visual mode. To do so, just add the following method:

UnlinkYearsCommand.prototype.isExecutable = function() {
    var surface = ve.init.target.getSurface();
    return surface && surface.getMode() === 'source';
};

This will disable your tool in visual mode. The execute method shouldn't be called in this case, but just to make sure, you should return false when it is called in visual mode to indicate that nothing has been executed.

Better icon edit

The icon we chose doesn't really express what the tool does, and for other commands you might not find a suitable icon on that list. That's no problem, just use your own icon. All you need to do is to set the icon property to something else (e.g. 'myIcon') and add a little bit of CSS:

.oo-ui-icon-myIcon {
    background-image: url(https://upload.wikimedia.org/wikipedia/commons/b/b9/Laptop_font_awesome.svg);
}

You can use any SVG image you want to. To add the CSS you can use mw.util.addCSS().

Keep cursor position edit

When we work on the whole text, the cursor is set to the start of the text when we're done. It would be nice if it stayed in the original place, only shifted by the applied changes. Three steps are necessary:

  1. Get the original position: fragment.getSelection().getCoveringRange().start tells you the starting position of a ve.Range (documentation), which is the cursor position for a collapsed selection. Note that linebreaks are counted the same way as above, so you have to do some additional calculations to get the actual position in the wikitext if you need it.
  2. Calculate the new position. This may be difficult, but this has nothing to do with VisualEditor.
  3. Set the new position: You can use the setLinearSelection method the same way it is used above to select the whole content, just pass a collapsed range here.