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.
// <syntaxhighlight lang="javascript">
/**
* Adapted from Minieditor Autocomplete (MiniComplete) by Cade Calrayn
*
* Adds autocomplete to certain form elements.
* - Special:Upload description
* - Special:MultipleUpload description
* - Message Wall comments
* - Article comments
* - Blog comments
* - Special:Forum posts
*
* Can also be used in other scripts that require an autocomplete
*
*/
/*jshint
bitwise:true, camelcase:true, curly:true, eqeqeq:true, es3:false,
forin:true, immed:true, indent:4, latedef:true, newcap:true,
noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
undef:true, unused:true, strict:true, trailing:true,
browser:true, devel:false, jquery:true,
onevar:true
*/
// disable indent warning
/*jshint -W015 */
;( function ( window, document, setTimeout, $, mw, dev, undefined ) {
/*jshint +W015 */
'use strict';
// prevent loading twice
if ( dev.minicomplete !== undefined ) {
mw.log( 'Error: dev.minicomplete already loaded. Aborting...' );
return;
}
dev.minicomplete = ( function () {
/**
* mw.config values for use in multiple functions
*/
var config = mw.config.get( [
'wgCanonicalSpecialPageName',
'wgNamespaceIds',
'wgNamespaceNumber',
'wgScriptPath',
'wgServer'
] ),
/**
* These are the functions that make up the dev.minicomplete object
* which need to be accessed for use in other scripts
*/
global = {
/**
* @desc Main loading function
* @param arguments {string|DOM element|jQuery object}
*/
load: function () {
var check,
$elem,
i,
ul;
// check for arguments
if ( arguments.length === 0 ) {
mw.log( 'Error: No parameters passed to dev.minicomplete.load' );
return;
}
// handle one argument
if ( arguments.length === 1 ) {
check = local.elemCheck( arguments[0] );
if ( check === false ) {
mw.log( 'Error: No matches for arguments passed to dev.minicomplete.load' );
return;
}
$elem = check;
}
// handle multiple arguments
if ( arguments.length > 1 ) {
for ( i = 0; i < arguments.length; i += 1 ) {
check = local.elemCheck( arguments[i] );
// handle no results for match
if ( check === false ) {
continue;
// assign first result directly to $elem
} else if ( $elem === undefined ) {
$elem = check;
// merge subsequent results with $elem
} else {
$elem = $elem.add( check );
}
}
// make sure $elem is defined
if ( $elem === undefined ) {
mw.log( 'Error: No matches for arguments passed to dev.minicomplete.load' );
return;
}
}
// only do this once
// problems caused by re-adding event listeners to
// textareas with editing comments/posts
if ( !document.getElementById( 'minicomplete-list' ) ) {
// load css
local.insertCSS();
// create wrapper
ul = document.createElement( 'ul' );
ul.setAttribute( 'id', 'minicomplete-list' );
document.getElementsByTagName( 'body' )[0].appendChild( ul );
// attach required event listeners to document
// don't attach listeners to options until it's populated
local.bindEvents();
// make sure the options are removed when moving between textareas
} else {
$( '#minicomplete-list' ).hide().empty();
}
// remove any existing event listeners
// caused by running this function when new .wikiaEditor textareas are added
$elem.off( 'input' ).on( 'input', function () {
// hide and empty menu
$( '#minicomplete-list' ).hide().empty();
// store node for later use
local.elem = this;
mw.log( this );
// run api query
local.findTerm( this );
} );
}
},
/**
* These are local functions that should not be interacted with directly
* so are kept private to prevent that happening
*/
local = {
/**
* @desc Checks for correct environment and implements custom ResourceLoader module.
*/
init: function () {
var special = ['Upload', 'MultipleUpload'],
namespace = {
// message wall
'1200': '#WallMessageBody',
// Special:Forum (Thread)
'1201': '.replyBody',
// Special:Forum (Board)
'2000': '.body'
},
selector;
// Special:Upload and Special:MultipleUpload
if ( special.indexOf( config.wgCanonicalSpecialPageName ) > -1 ) {
selector = '#wpUploadDescription';
}
// /*
// Message Wall and Special:Forum
// will not work for Special:Forum replies
// or editing existing posts on either
if ( namespace[config.wgNamespaceNumber] ) {
selector = namespace[config.wgNamespaceNumber];
}
// Article and Blog comments
if ( $( '#WikiaArticleComments' ).length ) {
// create custom ResourceLoader module
mw.loader.implement( 'minicomplete.dependencies',
['/load.php?debug=false&lang=en&mode=articles&skin=oasis&missingCallback=importArticleMissing&articles=u%3Acamtest%3AMediaWiki%3ATextareaHelper.js%7Cu%3Adev%3AColors%2Fcode.js&only=scripts'],
// don't load css here as there's no way to differentiate between a module being loaded with css or without
// and it would get confusing when this script is used as a module
{}, {} );
mw.loader.using( 'minicomplete.dependencies', local.commentsLoaded );
}
// fix when editing special:forum posts and message wall comments
// don't run on special:forum (board)
if ( [1200, 1201].indexOf( config.wgNamespaceNumber ) > -1 ) {
$( '.edit-message' ).on( 'click', function () {
mw.log( 'editing forum post' );
local.editorInserted( $( '.body' ).length, '.body' );
} );
}
// */
// selector should be defined by this point if we are to continue
// if it's not, we either used a different method to continue
// or we're in the wrong environment
// there's no need to continue past this point in either case
if ( !selector ) {
return;
}
// create custom ResourceLoader module
mw.loader.implement( 'minicomplete.dependencies',
['/load.php?debug=false&lang=en&mode=articles&skin=oasis&missingCallback=importArticleMissing&articles=u%3Acamtest%3AMediaWiki%3ATextareaHelper.js%7Cu%3Adev%3AColors%2Fcode.js&only=scripts'],
{}, {} );
// we need custom module after this point
// so declare our dependencies and run the rest of the script
// in the callback
mw.loader.using( ['mediawiki.api', 'minicomplete.dependencies'], function () {
global.load( selector );
} );
},
/**
* @desc Tests for selector, DOM node or jQuery object
* @param elem {string|DOM element|jQuery object} Element or selector to check
* @return {jQuery object|boolean} Element to manipulate or false if there's no match
*/
elemCheck: function ( elem ) {
// test for selector
if ( typeof elem === 'string' && $( elem ).length ) {
return $( elem );
}
// test for DOM element
if ( elem.nodeType && $( elem ).length ) {
return $( elem );
}
// test for jquery object
if ( elem.jquery && elem.length ) {
return elem;
}
// returning a jquery object will pass a simple conditional
// @example if ( $elem ) {...}
// so return false for a strict comparison
mw.log( 'Error: Argument passed to dev.minicomplete.load is not a selector, DOM element of jQuery object' );
return false;
},
/**
* @desc Checks if Article comments are loaded and run autocomplete when done
*/
commentsLoaded: function () {
if ( window.ArticleComments.initCompleted ) {
mw.log( 'Article comments loaded' );
global.load( '#article-comm' );
// this is where we detect replies being added
// as the textareas used aren't inserted when the comments are loaded
// but when someone actually wants to reply
// @todo Why am I passing a jQuery object to jQuery?
$( $( '.article-comm-reply' ) ).on( 'click', function () {
mw.log( 'reply detected' );
// don't continue if there is already an editor present
// which is leftover from making a reply previously
if ( $( this ).parent().parent().next().find( '.wikiaEditor' ).length ) {
return;
}
local.editorInserted( $( '.wikiaEditor' ).length, '.wikiaEditor' );
} );
} else {
setTimeout( local.commentsLoaded, 500 );
}
},
/**
* @desc Looks for new textareas to run script on
* @param editors {number} Number of editor at start of check
* @param selector {string} Selector of editor to track
*/
editorInserted: function ( editors, selector ) {
if ( $( selector ).length !== editors ) {
mw.log( 'new editor inserted' );
global.load( selector );
} else {
setTimeout( function () {
local.editorInserted( editors, selector );
}, 500 );
}
},
/**
* @desc Insert stylesheet using colours set by ThemeDesigner
* @todo Allow custom colours for when there's non-themedesigner colours or custom monobook theme
*/
insertCSS: function () {
var page = dev.colors.parse( dev.colors.wikia.page ),
buttons = dev.colors.parse( dev.colors.wikia.menu ),
mix = buttons.mix( page, 20 ),
shadow = page.lighten( -8 ),
css;
if ( !page.isBright() ){
mix = mix.lighten( 8 );
}
css =
// variable css for options
'.minicomplete-option:hover,.minicomplete-option.selected{background-color:#DDDDDD;color:#222222;text-decoration:none;}';
dev.colors.css( css, {
mix: mix,
shadow: shadow
} );
},
/**
* @desc Binds events related to navigating through menu with up/down keys
* and what to do when pressing esc or left/right keys.
*/
bindEvents: function () {
$( document ).on( 'keydown', function ( e ) {
var $option = $( '.minicomplete-option' ),
$select = $( '.minicomplete-option.selected' ),
i;
// stop if the list is empty
if ( !$option.length ) {
return;
}
switch ( e.keyCode ) {
// hide options on esc keydown
case 27:
// hide options on left/right keydown
// as it suggests the user is moving through to edit the text
case 37:
case 39:
$( '#minicomplete-list' ).hide().empty();
break;
// navigate through menu using up keydown
case 38:
if ( !$option.length ) {
return;
}
// stop caret moving
e.preventDefault();
if ( !$select.length ) {
$( $option[$option.length - 1] ).addClass( 'selected' );
} else {
for ( i = 0; i < $option.length; i += 1 ) {
if ( $( $option[i] ).hasClass( 'selected' ) ) {
// remove class
$( $option[i] ).removeClass( 'selected' );
// if at top of list jump to bottom
if ( i === 0 ) {
$( $option[$option.length - 1] ).addClass( 'selected' );
// else move up list
} else {
$( $option[i - 1] ).addClass( 'selected' );
}
return;
}
}
}
break;
// navigate through menu using down keydown
case 40:
if ( !$option.length ) {
return;
}
// stop caret moving
e.preventDefault();
if ( !$select.length ) {
$( $option[0] ).addClass( 'selected' );
} else {
for ( i = 0; i < $option.length; i += 1 ) {
if ( $( $option[i] ).hasClass( 'selected' ) ) {
// remove selected class
$( $option[i] ).removeClass( 'selected' );
// if at bottom of list jump to top
if ( i === ( $option.length - 1 ) ) {
$( $option[0] ).addClass( 'selected' );
// else move down list
} else {
$( $option[i + 1] ).addClass( 'selected' );
}
return;
}
}
}
break;
// insert selected option on enter keydown
case 13:
if ( !$select.length ) {
return;
}
e.preventDefault();
local.insertComplete( $select.text() );
break;
}
} );
},
/**
* @desc Counts back from caret position looking for unclosed {{ or [[
* @param elem {node} Element to look for search term within
* @todo make this DRYer - separate function or something?
*/
findTerm: function ( elem ) {
// compare against undefined
// to stop empty strings triggering this too
// stops errors when input event in bound to the wrong element
if ( elem.value === undefined ) {
mw.log( 'Error: Element does not support value attribute' );
return;
}
// text to search for
var searchText = elem.value.substring( 0, local.getCaretPos() ),
// for separating search term
linkCheck = searchText.lastIndexOf( '[['),
templateCheck = searchText.lastIndexOf( '{{' ),
// disallows certain characters in search terms
// based on $wgLegalTitleChars <http://www.mediawiki.org/wiki/Manual:$wgLegalTitleChars>
// and to prevent searches for terms that don't need it
// such as those with pipes as they signal template params or
// link display changes or if the user is closing the link/template themselves
illegalChars = /[\{\}\[\]\|#<>%\+\?\\]/,
term,
ns;
// searchText will be empty if the browser does not support getCaretPos
// which will probably cause errors/confusion
// so stop here if that's the case
if ( !searchText.length ) {
return;
}
if ( linkCheck > -1 ) {
if ( linkCheck < searchText.lastIndexOf( ']]' ) ) {
return;
}
// lastIndexOf measures from just before it starts
// so add 2 to check the term length
// to make sure we're just selecting the search term
if ( ( searchText.length - ( linkCheck + 2 ) ) >= 0 ) {
term = searchText.substring( linkCheck + 2 );
if ( term.match( illegalChars ) ) {
return;
}
// fix for when the namespace is preceded by a :
if ( term.indexOf( ':' ) === 0 ) {
term = term.substring( 1 );
}
// prevent searches for empty strings
if ( !term.length ) {
return;
}
// set type here as it's easier than
// passing it through all the functions
local.type = '[[';
local.getSuggestions( term, 0 );
}
}
if ( templateCheck > -1 ) {
if ( templateCheck < searchText.lastIndexOf( '}}' ) ) {
return;
}
// lastIndexOf measures from just before it starts
// so add 2 to check the term length
// to make sure we're just selecting the search term
if ( ( searchText.length - ( templateCheck + 2 ) ) > 0 ) {
term = searchText.substring( templateCheck + 2 );
if ( term.match( illegalChars ) ) {
return;
}
// fix for when the namespace is preceded by a :
if ( term.indexOf( ':' ) === 0 ) {
term = term.substring( 1 );
// use mainspace queries if using a :
// as it signifies a page transclusion
// rather than a template
ns = 0;
} else {
ns = 10;
}
// prevent searches for empty strings
if ( !term.length ) {
return;
}
// set type here as it's easier than
// passing it through all the functions
local.type = '{{';
local.getSuggestions( term, ns );
}
}
},
/**
* @desc Gets caret position for detecting search term and inserting autocomplete
* term.
* @source <http://blog.vishalon.net/index.php/javascript-getting-and-setting-caret-position-in-textarea/>
* @return {number} Caret position in string.
* If browser does not support caret position methods returns 0
* to prevent syntax errors
*/
getCaretPos: function () {
var elem = local.elem,
caretPos = 0,
sel;
// support for older versions of IE
// IE7-8? IE9 apparently supports selectionStart
if ( document.selection ) {
elem.focus();
sel = document.selection.createRange();
sel.moveStart( 'character', -elem.value.length );
caretPos = sel.text.length;
// modern browsers
} else if ( elem.selectionStart || elem.selectionStart === '0' ) {
caretPos = elem.selectionStart;
}
return ( caretPos );
},
/**
* @desc Queries mw api for possible suggestions
* @link <https://www.mediawiki.org/wiki/API:Allpages> Allpages API docs
* @param term {string} Page title to search for
* @param ns {integer} Namespace to search in
* @todo Test alternative api queries, see main code docs for details
*/
getSuggestions: function ( term, ns ) {
var query = {
action: 'query',
list: 'allpages',
aplimit: '5',
apfilterredir: 'nonredirects',
apnamespace: ns,
apprefix: term,
format: 'json'
},
termSplit,
namespaceId,
title;
mw.log( term );
// handles namespaces in search query
// if the namespace exists, this alters the query to affect that
// otherwise the original search term will be used
if ( term.indexOf( ':' ) > -1 ) {
termSplit = term.split( ':' );
title = termSplit[1];
// make sure there's only the namespace and the page title
if ( termSplit.length > 2 ) {
return;
}
namespaceId = config.wgNamespaceIds[
// wgNamespaceIds uses underscores and lower case
termSplit[0]
.replace( / /g, '_' )
.toLowerCase()
];
if ( namespaceId ) {
query.apnamespace = namespaceId;
query.apprefix = title;
}
}
$.ajax( {
url: config.wgServer + config.wgScriptPath + '/api.php',
data: query,
dataType: 'json',
success: function ( data ) {
// error handling
if ( data.error ) {
mw.log( 'API error: ', data.error.code, data.error.info );
return;
}
// no suggestions
if ( !data.query.allpages.length ) {
return;
}
local.showSuggestions( data.query.allpages );
},
error: function ( xhr, error, desc ) {
mw.log( 'AJAX error: ', error, ' - ', desc );
}
} );
},
/**
* @desc Inserts list of options to select from
* @param result {array} Result from API
*/
showSuggestions: function ( result ) {
var options = '',
$body = $( 'body' ).width(),
i,
coords,
offset,
$list,
leftpos,
$options;
mw.log( result );
for ( i = 0; i < result.length; i += 1 ) {
options += '<li class="minicomplete-box"><a class="minicomplete-option">' + result[ i ].title + '</a></li>';
}
// append options to container
$( '#minicomplete-list' ).html( options );
// cache list
// do this after it's been populated to stop errors
$list = $( '#minicomplete-list' );
// show option list
$list.show();
// position option list
coords = $( local.elem ).textareaHelper( 'caretPos' );
offset = $( local.elem ).offset();
leftpos = offset.left + coords.left;
// realign against right side of page if overflowing
// monobook issue on Special:Upload
// this won't work if someone's extended the body past the limits of the window
if ( leftpos + $list.width() > $body ) {
leftpos = $body - $list.width();
}
// no fix has been added for if the menu is outside the vertical
// limits of the window as if it moved down it would obscure text
// and if it moved up chances are the user can't see the textarea in the first place
$list.css( {
top: offset.top + coords.top - $list.height(),
left: leftpos
} );
// add event handlers for .minicomplete-option here
// as they won't fire if they aren't created when you try to bind events to them
// cache options
$options = $( '.minicomplete-option' );
// add onclick handler for inserting the option
$options.on( 'click', function () {
local.insertComplete( $( this ).text() );
} );
// clear .selected class on hover
// css :hover pseudo-class does hover colour change instead
$options.on( 'mouseover', function () {
if ( $( '.minicomplete-option.selected' ).length ) {
// don't use this here as it refers to the hovered element
// we want to strip the selected class as soon as we enter the menu
$options.removeClass( 'selected' );
}
} );
},
/**
* @desc Inserts selected suggestion
* @param complete {string} Search suggestion to insert
*/
insertComplete: function ( complete ) {
var caret = local.getCaretPos(),
val = local.elem.value,
text = val.substring( 0, caret ),
open = local.type,
close = open === '[[' ? ']]' : '}}',
before = text.substring( 0, text.lastIndexOf( open ) );
// strip template namespace for template transclusion
if ( open === '{{' && complete.split( ':' )[0] === 'Template' ) {
complete = complete.split( ':' )[1];
}
// check if a colon is after the opening brackets
if ( text[text.lastIndexOf( open ) + 2] === ':' ) {
open += ':';
}
// insert search term
local.elem.value = before + open + complete + close + val.substring( caret );
// hide and empty options
$( '#minicomplete-list' ).hide().empty();
}
};
local.init();
return global;
} () );
}( this, this.document, this.setTimeout, this.jQuery, this.mediaWiki, this.dev = this.dev || {} ) );
// </syntaxhighlight> __NOWYSIWYG__