Jump to content

MediaWiki:Common.js

From openjournal

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/*!
 * openjournal -- Markdown shortcuts for the visual editor.
 *
 * Loaded into MediaWiki:Common.js by the seed step. Edit it on-wiki at
 * /wiki/MediaWiki:Common.js, or here and re-seed.
 *
 * MediaWiki stores wikitext, not Markdown, and that does not change here.
 * What changes is what happens as you type: the visual editor already turns
 * "* " into a bullet and "1. " into a numbered list, so the rest of a writer's
 * Markdown muscle memory is registered the same way. You type Markdown, the
 * editor formats it immediately, and the page is still wikitext underneath --
 * which keeps diffs, templates and the API working exactly as before.
 *
 * These use ve.ui.sequenceRegistry, VisualEditor's own public mechanism for
 * this, and follow the same pattern as its built-in registrations.
 */
( function () {
	'use strict';

	// Names of the commands the inline shortcuts run, and whether registration
	// has already happened. Both hooks below are safe to run more than once.
	var inlineCommandNames = [],
		registered = false;

	/**
	 * A sequence that fires at the start of a paragraph, e.g. "## " or "> ".
	 *
	 * @param {string} name Registry name; reusing an existing name replaces it
	 * @param {string} command VisualEditor command to run
	 * @param {string} prefix Characters typed before the space
	 * @param {boolean} space Whether a trailing space is part of the trigger
	 * @return {ve.ui.Sequence}
	 */
	function paragraphPrefix( name, command, prefix, space ) {
		var data = [ { type: 'paragraph' } ].concat( prefix.split( '' ) );
		if ( space ) {
			data.push( ' ' );
		}
		// Strip everything the user typed; the paragraph element is not typed.
		return new ve.ui.Sequence( name, command, data, data.length - 1 );
	}

	function registerMarkdownSequences() {
		var registry = ve.ui.sequenceRegistry,
			level;

		for ( level = 1; level <= 6; level++ ) {
			registry.register( paragraphPrefix(
				// MediaWiki registers "# " as a wikitext numbered list under the
				// name "numberHash". Reusing that name replaces it, so a lone
				// "# " means a heading here, as a Markdown writer expects.
				// "1. " still makes a numbered list, which is the Markdown way.
				level === 1 ? 'numberHash' : 'openjournalHeading' + level,
				'heading' + level,
				new Array( level + 1 ).join( '#' ),
				true
			) );
		}

		// "- " for bullets. VisualEditor ships "* " already; Markdown uses both.
		registry.register(
			paragraphPrefix( 'openjournalBulletDash', 'bulletWrapOnce', '-', true )
		);

		// "> " for a blockquote. MediaWiki maps ": " to this; Markdown uses ">".
		registry.register(
			paragraphPrefix( 'openjournalBlockquote', 'blockquoteWrap', '>', true )
		);

		// "```" opens a code block.
		registry.register(
			paragraphPrefix( 'openjournalCodeBlock', 'preformatted', '```', false )
		);

		// "---" is a horizontal rule. VisualEditor wants four dashes; Markdown
		// takes three, and three fires first, so this supersedes it.
		registry.register(
			paragraphPrefix( 'openjournalHorizontalRule', 'insertHorizontalRule', '---', false )
		);
	}

	/**
	 * Inline Markdown: **bold**, *italic*, `code`.
	 *
	 * Block shortcuts above are plain sequences, because the characters that
	 * trigger them sit at the start of a paragraph and VisualEditor's `strip`
	 * removes them. Inline Markdown wraps text instead, and `strip` only takes
	 * characters off the right-hand end -- so the opening delimiter would be
	 * left behind. These use a regular expression to match the whole
	 * "**text**", select it, and hand it to the action below, which swaps in
	 * the text without its delimiters and styles it.
	 */
	function registerInlineAction() {
		ve.ui.OpenJournalMarkdownAction = function VeUiOpenJournalMarkdownAction() {
			ve.ui.OpenJournalMarkdownAction.super.apply( this, arguments );
		};
		OO.inheritClass( ve.ui.OpenJournalMarkdownAction, ve.ui.Action );

		ve.ui.OpenJournalMarkdownAction.static.name = 'openjournalMarkdown';
		ve.ui.OpenJournalMarkdownAction.static.methods = [ 'wrap' ];

		/**
		 * @param {string} delimiter The Markdown characters, e.g. "**"
		 * @param {string} annotation VisualEditor annotation, e.g. "textStyle/bold"
		 * @return {boolean} Whether the text was styled
		 */
		ve.ui.OpenJournalMarkdownAction.prototype.wrap = function ( delimiter, annotation ) {
			var surfaceModel = this.surface.getModel(),
				fragment = surfaceModel.getFragment(),
				text = fragment.getText(),
				width = delimiter.length;

			// Returning false makes VisualEditor undo the selection change, so
			// anything that does not really look like "**text**" is left alone.
			if (
				text.length <= width * 2 ||
				text.slice( 0, width ) !== delimiter ||
				text.slice( text.length - width ) !== delimiter
			) {
				return false;
			}

			fragment
				.insertContent( text.slice( width, text.length - width ), false )
				.annotateContent( 'set', annotation )
				.collapseToEnd()
				.select();

			// Without this the annotation stays armed and the next word typed
			// comes out bold too.
			surfaceModel.setInsertionAnnotations( null );

			return true;
		};

		ve.ui.actionFactory.register( ve.ui.OpenJournalMarkdownAction );
	}

	function registerInlineSequences() {
		[
			// label,  delimiter, annotation,         pattern
			[ 'Bold', '**', 'textStyle/bold', '\\*\\*[^*]+\\*\\*$' ],
			// "*text*" must not fire midway through typing "**text**", so the
			// opening star may not itself be preceded by one. Lookbehind is
			// unsupported on some older browsers, hence the per-pattern guard.
			[ 'Italic', '*', 'textStyle/italic', '(?<!\\*)\\*[^*]+\\*$' ],
			[ 'Code', '`', 'textStyle/code', '`[^`]+`$' ],
			// Markdown's other pair of emphasis characters. These refuse an
			// opening underscore that follows a word character, which is what
			// stops snake_case_names turning italic halfway through -- the
			// same rule CommonMark applies, and the reason "_" needs a guard
			// that "*" does not. \w covers "_" itself, so it doubles as the
			// "not midway through __bold__" check.
			[ 'BoldUnderscore', '__', 'textStyle/bold', '(?<!\\w)__[^_]+__$' ],
			[ 'ItalicUnderscore', '_', 'textStyle/italic', '(?<!\\w)_[^_]+_$' ],
			[ 'Strikethrough', '~~', 'textStyle/strikethrough', '~~[^~]+~~$' ]
		].forEach( function ( spec ) {
			var name = 'openjournalMarkdown' + spec[ 0 ],
				pattern;

			inlineCommandNames.push( name );

			try {
				pattern = new RegExp( spec[ 3 ] );
			} catch ( e ) {
				mw.log.warn( 'openjournal: skipping ' + name + ' shortcut', e );
				return;
			}

			ve.ui.commandRegistry.register( new ve.ui.Command(
				name, 'openjournalMarkdown', 'wrap',
				{ args: [ spec[ 1 ], spec[ 2 ] ], supportedSelections: [ 'linear' ] }
			) );
			ve.ui.sequenceRegistry.register( new ve.ui.Sequence(
				name, name, pattern, 0, { setSelection: true }
			) );
		} );
	}

	// Register through VisualEditor's plugin hook, which runs after its modules
	// load but before the editing surface is built. That timing matters: a
	// surface copies the command registry once, at construction, and
	// ve.ui.Sequence refuses to run a command the surface does not list. A
	// custom command registered any later is silently ignored -- the sequence
	// matches, nothing happens. Sequences themselves are read live on every
	// keystroke, which is why the block shortcuts survived being registered
	// late and the inline ones did not.
	function registerEverything() {
		if ( registered ) {
			return;
		}
		registered = true;
		registerInlineAction();
		registerInlineSequences();
		registerMarkdownSequences();
	}

	/**
	 * Top up the live surface's command list.
	 *
	 * The plugin hook below runs early enough that this should be redundant.
	 * It is here because the failure it guards against is silent: if anything
	 * builds a surface without the plugin hook having run, every inline
	 * shortcut matches and then quietly does nothing, which is a miserable
	 * thing to debug from the outside.
	 */
	function backfillSurfaceCommands() {
		var surface = ve.init && ve.init.target &&
			ve.init.target.getSurface && ve.init.target.getSurface();

		if ( !surface ) {
			return;
		}

		var commands = surface.getCommands();
		inlineCommandNames.forEach( function ( name ) {
			if ( commands.indexOf( name ) === -1 ) {
				commands.push( name );
			}
		} );
	}

	function guard( fn ) {
		return function () {
			try {
				fn.apply( null, arguments );
			} catch ( e ) {
				// A broken shortcut must never take the editor down with it.
				mw.log.error( 'openjournal: Markdown shortcuts failed', e );
			}
		};
	}

	mw.hook( 've.loadModules' ).add( guard( function ( addPlugin ) {
		addPlugin( guard( registerEverything ) );
	} ) );

	// Belt and braces, once the editor is actually up.
	mw.hook( 've.activationComplete' ).add( guard( function () {
		registerEverything();
		backfillSurfaceCommands();
	} ) );
}() );