diff options
author | Pierre Schmitz <pierre@archlinux.de> | 2014-12-27 15:41:37 +0100 |
---|---|---|
committer | Pierre Schmitz <pierre@archlinux.de> | 2014-12-31 11:43:28 +0100 |
commit | c1f9b1f7b1b77776192048005dcc66dcf3df2bfb (patch) | |
tree | 2b38796e738dd74cb42ecd9bfd151803108386bc /resources/src/jquery | |
parent | b88ab0086858470dd1f644e64cb4e4f62bb2be9b (diff) |
Update to MediaWiki 1.24.1
Diffstat (limited to 'resources/src/jquery')
53 files changed, 6570 insertions, 0 deletions
diff --git a/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png Binary files differnew file mode 100644 index 00000000..84ed2a2d --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png diff --git a/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png Binary files differnew file mode 100644 index 00000000..7cfbfeba --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png diff --git a/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png Binary files differnew file mode 100644 index 00000000..eb070280 --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png diff --git a/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png Binary files differnew file mode 100644 index 00000000..7ea2fdb5 --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png diff --git a/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png Binary files differnew file mode 100644 index 00000000..3ad990b6 --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png diff --git a/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png Binary files differnew file mode 100644 index 00000000..1d3048ef --- /dev/null +++ b/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png diff --git a/resources/src/jquery/images/marker.png b/resources/src/jquery/images/marker.png Binary files differnew file mode 100644 index 00000000..19efb6ce --- /dev/null +++ b/resources/src/jquery/images/marker.png diff --git a/resources/src/jquery/images/mask.png b/resources/src/jquery/images/mask.png Binary files differnew file mode 100644 index 00000000..fe08de0e --- /dev/null +++ b/resources/src/jquery/images/mask.png diff --git a/resources/src/jquery/images/sort_both.gif b/resources/src/jquery/images/sort_both.gif Binary files differnew file mode 100644 index 00000000..50ad15a0 --- /dev/null +++ b/resources/src/jquery/images/sort_both.gif diff --git a/resources/src/jquery/images/sort_down.gif b/resources/src/jquery/images/sort_down.gif Binary files differnew file mode 100644 index 00000000..ec4f41b0 --- /dev/null +++ b/resources/src/jquery/images/sort_down.gif diff --git a/resources/src/jquery/images/sort_none.gif b/resources/src/jquery/images/sort_none.gif Binary files differnew file mode 100644 index 00000000..edd07e58 --- /dev/null +++ b/resources/src/jquery/images/sort_none.gif diff --git a/resources/src/jquery/images/sort_up.gif b/resources/src/jquery/images/sort_up.gif Binary files differnew file mode 100644 index 00000000..80189185 --- /dev/null +++ b/resources/src/jquery/images/sort_up.gif diff --git a/resources/src/jquery/images/spinner-large.gif b/resources/src/jquery/images/spinner-large.gif Binary files differnew file mode 100644 index 00000000..72203fdd --- /dev/null +++ b/resources/src/jquery/images/spinner-large.gif diff --git a/resources/src/jquery/images/spinner.gif b/resources/src/jquery/images/spinner.gif Binary files differnew file mode 100644 index 00000000..6146be4e --- /dev/null +++ b/resources/src/jquery/images/spinner.gif diff --git a/resources/src/jquery/images/wheel.png b/resources/src/jquery/images/wheel.png Binary files differnew file mode 100644 index 00000000..7e53103e --- /dev/null +++ b/resources/src/jquery/images/wheel.png diff --git a/resources/src/jquery/jquery.accessKeyLabel.js b/resources/src/jquery/jquery.accessKeyLabel.js new file mode 100644 index 00000000..7b49cb2d --- /dev/null +++ b/resources/src/jquery/jquery.accessKeyLabel.js @@ -0,0 +1,200 @@ +/** + * jQuery plugin to update the tooltip to show the correct access key + * + * @class jQuery.plugin.accessKeyLabel + */ +( function ( $, mw ) { + +// Cached access key prefix for used browser +var cachedAccessKeyPrefix, + + // Wether to use 'test-' instead of correct prefix (used for testing) + useTestPrefix = false, + + // tag names which can have a label tag + // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content + labelable = 'button, input, textarea, keygen, meter, output, progress, select'; + +/** + * Get the prefix for the access key for browsers that don't support accessKeyLabel. + * + * For browsers that support accessKeyLabel, #getAccessKeyLabel never calls here. + * + * @private + * @param {Object} [ua] An object with a 'userAgent' and 'platform' property. + * @return {string} Access key prefix + */ +function getAccessKeyPrefix( ua ) { + // use cached prefix if possible + if ( !ua && cachedAccessKeyPrefix ) { + return cachedAccessKeyPrefix; + } + + var profile = $.client.profile( ua ), + accessKeyPrefix = 'alt-'; + + // Opera on any platform + if ( profile.name === 'opera' ) { + accessKeyPrefix = 'shift-esc-'; + + // Chrome on any platform + } else if ( profile.name === 'chrome' ) { + accessKeyPrefix = ( + profile.platform === 'mac' + // Chrome on Mac + ? 'ctrl-option-' + // Chrome on Windows or Linux + // (both alt- and alt-shift work, but alt with E, D, F etc does not + // work since they are browser shortcuts) + : 'alt-shift-' + ); + + // Non-Windows Safari with webkit_version > 526 + } else if ( profile.platform !== 'win' + && profile.name === 'safari' + && profile.layoutVersion > 526 + ) { + accessKeyPrefix = 'ctrl-alt-'; + + // Safari/Konqueror on any platform, or any browser on Mac + // (but not Safari on Windows) + } else if ( !( profile.platform === 'win' && profile.name === 'safari' ) + && ( profile.name === 'safari' + || profile.platform === 'mac' + || profile.name === 'konqueror' ) + ) { + accessKeyPrefix = 'ctrl-'; + + // Firefox/Iceweasel 2.x and later + } else if ( ( profile.name === 'firefox' || profile.name === 'iceweasel' ) + && profile.versionBase > '1' + ) { + accessKeyPrefix = 'alt-shift-'; + } + + // cache prefix + if ( !ua ) { + cachedAccessKeyPrefix = accessKeyPrefix; + } + return accessKeyPrefix; +} + +/** + * Get the access key label for an element. + * + * Will use native accessKeyLabel if available (currently only in Firefox 8+), + * falls back to #getAccessKeyPrefix. + * + * @private + * @param {HTMLElement} element Element to get the label for + * @return {string} Access key label + */ +function getAccessKeyLabel( element ) { + // abort early if no access key + if ( !element.accessKey ) { + return ''; + } + // use accessKeyLabel if possible + // http://www.whatwg.org/specs/web-apps/current-work/multipage/editing.html#dom-accesskeylabel + if ( !useTestPrefix && element.accessKeyLabel ) { + return element.accessKeyLabel; + } + return ( useTestPrefix ? 'test-' : getAccessKeyPrefix() ) + element.accessKey; +} + +/** + * Update the title for an element (on the element with the access key or it's label) to show + * the correct access key label. + * + * @private + * @param {HTMLElement} element Element with the accesskey + * @param {HTMLElement} titleElement Element with the title to update (may be the same as `element`) + */ +function updateTooltipOnElement( element, titleElement ) { + var array = ( mw.msg( 'word-separator' ) + mw.msg( 'brackets' ) ).split( '$1' ), + regexp = new RegExp( $.map( array, $.escapeRE ).join( '.*?' ) + '$' ), + oldTitle = titleElement.title, + rawTitle = oldTitle.replace( regexp, '' ), + newTitle = rawTitle, + accessKeyLabel = getAccessKeyLabel( element ); + + // don't add a title if the element didn't have one before + if ( !oldTitle ) { + return; + } + + if ( accessKeyLabel ) { + // Should be build the same as in Linker::titleAttrib + newTitle += mw.msg( 'word-separator' ) + mw.msg( 'brackets', accessKeyLabel ); + } + if ( oldTitle !== newTitle ) { + titleElement.title = newTitle; + } +} + +/** + * Update the title for an element to show the correct access key label. + * + * @private + * @param {HTMLElement} element Element with the accesskey + */ +function updateTooltip( element ) { + var id, $element, $label, $labelParent; + updateTooltipOnElement( element, element ); + + // update associated label if there is one + $element = $( element ); + if ( $element.is( labelable ) ) { + // Search it using 'for' attribute + id = element.id.replace( /"/g, '\\"' ); + if ( id ) { + $label = $( 'label[for="' + id + '"]' ); + if ( $label.length === 1 ) { + updateTooltipOnElement( element, $label[0] ); + } + } + + // Search it as parent, because the form control can also be inside the label element itself + $labelParent = $element.parents( 'label' ); + if ( $labelParent.length === 1 ) { + updateTooltipOnElement( element, $labelParent[0] ); + } + } +} + +/** + * Update the titles for all elements in a jQuery selection. + * + * @return {jQuery} + * @chainable + */ +$.fn.updateTooltipAccessKeys = function () { + return this.each( function () { + updateTooltip( this ); + } ); +}; + +/** + * Exposed for testing. + * + * @method updateTooltipAccessKeys_getAccessKeyPrefix + * @inheritdoc #getAccessKeyPrefix + */ +$.fn.updateTooltipAccessKeys.getAccessKeyPrefix = getAccessKeyPrefix; + +/** + * Switch test mode on and off. + * + * @method updateTooltipAccessKeys_setTestMode + * @param {boolean} mode New mode + */ +$.fn.updateTooltipAccessKeys.setTestMode = function ( mode ) { + useTestPrefix = mode; +}; + +/** + * @class jQuery + * @mixins jQuery.plugin.accessKeyLabel + */ + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.arrowSteps.css b/resources/src/jquery/jquery.arrowSteps.css new file mode 100644 index 00000000..f8f6e951 --- /dev/null +++ b/resources/src/jquery/jquery.arrowSteps.css @@ -0,0 +1,45 @@ +.arrowSteps { + list-style-type: none; + list-style-image: none; + border: 1px solid #666666; + position: relative; +} + +.arrowSteps li { + float: left; + padding: 0px; + margin: 0px; + border: 0 none; +} + +.arrowSteps li div { + padding: 0.5em; + text-align: center; + white-space: nowrap; + overflow: hidden; +} + +.arrowSteps li.arrow div { + /* @embed */ + background: url(images/jquery.arrowSteps.divider-ltr.png) no-repeat right center; +} + +/* applied to the element preceding the highlighted step */ +.arrowSteps li.arrow.tail div { + /* @embed */ + background: url(images/jquery.arrowSteps.tail-ltr.png) no-repeat right center; +} + +/* this applies to all highlighted, including the last */ +.arrowSteps li.head div { + /* @embed */ + background: url(images/jquery.arrowSteps.head-ltr.png) no-repeat left center; + font-weight: bold; +} + +/* this applies to all highlighted arrows except the last */ +.arrowSteps li.arrow.head div { + /* TODO: eliminate duplication of jquery.arrowSteps.head.png embedding */ + /* @embed */ + background: url(images/jquery.arrowSteps.head-ltr.png) no-repeat right center; +} diff --git a/resources/src/jquery/jquery.arrowSteps.js b/resources/src/jquery/jquery.arrowSteps.js new file mode 100644 index 00000000..f8641e10 --- /dev/null +++ b/resources/src/jquery/jquery.arrowSteps.js @@ -0,0 +1,98 @@ +/*! + * jQuery arrowSteps plugin + * Copyright Neil Kandalgaonkar, 2010 + * + * This work is licensed under the terms of the GNU General Public License, + * version 2 or later. + * (see http://www.fsf.org/licensing/licenses/gpl.html). + * Derivative works and later versions of the code must be free software + * licensed under the same or a compatible license. + */ + +/** + * @class jQuery.plugin.arrowSteps + */ +( function ( $ ) { + /** + * Show users their progress through a series of steps, via a row of items that fit + * together like arrows. One item can be highlighted at a time. + * + * <ul id="robin-hood-daffy"> + * <li id="guard"><div>Guard!</div></li> + * <li id="turn"><div>Turn!</div></li> + * <li id="parry"><div>Parry!</div></li> + * <li id="dodge"><div>Dodge!</div></li> + * <li id="spin"><div>Spin!</div></li> + * <li id="ha"><div>Ha!</div></li> + * <li id="thrust"><div>Thrust!</div></li> + * </ul> + * + * <script> + * $( '#robin-hood-daffy' ).arrowSteps(); + * </script> + * + * @return {jQuery} + * @chainable + */ + $.fn.arrowSteps = function () { + var $steps, width, arrowWidth, $stepDiv, + $el = this, + paddingSide = $( 'body' ).hasClass( 'rtl' ) ? 'padding-left' : 'padding-right'; + + $el.addClass( 'arrowSteps' ); + $steps = $el.find( 'li' ); + + width = parseInt( 100 / $steps.length, 10 ); + $steps.css( 'width', width + '%' ); + + // Every step except the last one has an arrow pointing forward: + // at the right hand side in LTR languages, and at the left hand side in RTL. + // Also add in the padding for the calculated arrow width. + $stepDiv = $steps.filter( ':not(:last-child)' ).addClass( 'arrow' ).find( 'div' ); + + // Execute when complete page is fully loaded, including all frames, objects and images + $( window ).load( function () { + arrowWidth = parseInt( $el.outerHeight(), 10 ); + $stepDiv.css( paddingSide, arrowWidth.toString() + 'px' ); + } ); + + $el.data( 'arrowSteps', $steps ); + + return this; + }; + + /** + * Highlights the element selected by the selector. + * + * $( '#robin-hood-daffy' ).arrowStepsHighlight( '#guard' ); + * // 'Guard!' is highlighted. + * + * // ... user completes the 'guard' step ... + * + * $( '#robin-hood-daffy' ).arrowStepsHighlight( '#turn' ); + * // 'Turn!' is highlighted. + * + * @param {string} selector + */ + $.fn.arrowStepsHighlight = function ( selector ) { + var $previous, + $steps = this.data( 'arrowSteps' ); + $.each( $steps, function ( i, step ) { + var $step = $( step ); + if ( $step.is( selector ) ) { + if ($previous) { + $previous.addClass( 'tail' ); + } + $step.addClass( 'head' ); + } else { + $step.removeClass( 'head tail lasthead' ); + } + $previous = $step; + } ); + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.arrowSteps + */ +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.autoEllipsis.js b/resources/src/jquery/jquery.autoEllipsis.js new file mode 100644 index 00000000..9a196b5d --- /dev/null +++ b/resources/src/jquery/jquery.autoEllipsis.js @@ -0,0 +1,168 @@ +/** + * @class jQuery.plugin.autoEllipsis + */ +( function ( $ ) { + +var + // Cache ellipsed substrings for every string-width-position combination + cache = {}, + + // Use a separate cache when match highlighting is enabled + matchTextCache = {}; + +/** + * Automatically truncate the plain text contents of an element and add an ellipsis + * + * @param {Object} options + * @param {'center'|'left'|'right'} [options.position='center'] Where to remove text. + * @param {boolean} [options.tooltip=false] Whether to show a tooltip with the remainder + * of the text. + * @param {boolean} [options.restoreText=false] Whether to save the text for restoring + * later. + * @param {boolean} [options.hasSpan=false] Whether the element is already a container, + * or if the library should create a new container for it. + * @param {string|null} [options.matchText=null] Text to highlight, e.g. search terms. + * @return {jQuery} + * @chainable + */ +$.fn.autoEllipsis = function ( options ) { + options = $.extend( { + position: 'center', + tooltip: false, + restoreText: false, + hasSpan: false, + matchText: null + }, options ); + + return this.each( function () { + var $trimmableText, + text, trimmableText, w, pw, + l, r, i, side, m, + // container element - used for measuring against + $container = $( this ); + + if ( options.restoreText ) { + if ( !$container.data( 'autoEllipsis.originalText' ) ) { + $container.data( 'autoEllipsis.originalText', $container.text() ); + } else { + $container.text( $container.data( 'autoEllipsis.originalText' ) ); + } + } + + // trimmable text element - only the text within this element will be trimmed + if ( options.hasSpan ) { + $trimmableText = $container.children( options.selector ); + } else { + $trimmableText = $( '<span>' ) + .css( 'whiteSpace', 'nowrap' ) + .text( $container.text() ); + $container + .empty() + .append( $trimmableText ); + } + + text = $container.text(); + trimmableText = $trimmableText.text(); + w = $container.width(); + pw = 0; + + // Try cache + if ( options.matchText ) { + if ( !( text in matchTextCache ) ) { + matchTextCache[text] = {}; + } + if ( !( options.matchText in matchTextCache[text] ) ) { + matchTextCache[text][options.matchText] = {}; + } + if ( !( w in matchTextCache[text][options.matchText] ) ) { + matchTextCache[text][options.matchText][w] = {}; + } + if ( options.position in matchTextCache[text][options.matchText][w] ) { + $container.html( matchTextCache[text][options.matchText][w][options.position] ); + if ( options.tooltip ) { + $container.attr( 'title', text ); + } + return; + } + } else { + if ( !( text in cache ) ) { + cache[text] = {}; + } + if ( !( w in cache[text] ) ) { + cache[text][w] = {}; + } + if ( options.position in cache[text][w] ) { + $container.html( cache[text][w][options.position] ); + if ( options.tooltip ) { + $container.attr( 'title', text ); + } + return; + } + } + + if ( $trimmableText.width() + pw > w ) { + switch ( options.position ) { + case 'right': + // Use binary search-like technique for efficiency + l = 0; + r = trimmableText.length; + do { + m = Math.ceil( ( l + r ) / 2 ); + $trimmableText.text( trimmableText.slice( 0, m ) + '...' ); + if ( $trimmableText.width() + pw > w ) { + // Text is too long + r = m - 1; + } else { + l = m; + } + } while ( l < r ); + $trimmableText.text( trimmableText.slice( 0, l ) + '...' ); + break; + case 'center': + // TODO: Use binary search like for 'right' + i = [Math.round( trimmableText.length / 2 ), Math.round( trimmableText.length / 2 )]; + // Begin with making the end shorter + side = 1; + while ( $trimmableText.outerWidth() + pw > w && i[0] > 0 ) { + $trimmableText.text( trimmableText.slice( 0, i[0] ) + '...' + trimmableText.slice( i[1] ) ); + // Alternate between trimming the end and begining + if ( side === 0 ) { + // Make the begining shorter + i[0]--; + side = 1; + } else { + // Make the end shorter + i[1]++; + side = 0; + } + } + break; + case 'left': + // TODO: Use binary search like for 'right' + r = 0; + while ( $trimmableText.outerWidth() + pw > w && r < trimmableText.length ) { + $trimmableText.text( '...' + trimmableText.slice( r ) ); + r++; + } + break; + } + } + if ( options.tooltip ) { + $container.attr( 'title', text ); + } + if ( options.matchText ) { + $container.highlightText( options.matchText ); + matchTextCache[text][options.matchText][w][options.position] = $container.html(); + } else { + cache[text][w][options.position] = $container.html(); + } + + } ); +}; + +/** + * @class jQuery + * @mixins jQuery.plugin.autoEllipsis + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.badge.css b/resources/src/jquery/jquery.badge.css new file mode 100644 index 00000000..fa7ea702 --- /dev/null +++ b/resources/src/jquery/jquery.badge.css @@ -0,0 +1,36 @@ +.mw-badge { + min-width: 7px; + border-radius: 2px; + padding: 1px 4px; + text-align: center; + font-size: 12px; + line-height: 12px; + background-color: #d2d2d2; + cursor: pointer; +} + +.mw-badge-content { + font-weight: bold; + color: white; + vertical-align: baseline; + text-shadow: 0 1px rgba(0, 0, 0, 0.4); +} + +.mw-badge-inline { + margin-left: 3px; + display: inline-block; + /* Hack for IE6 and IE7 (bug 47926) */ + zoom: 1; + *display: inline; + +} +.mw-badge-overlay { + position: absolute; + bottom: -1px; + right: -3px; + z-index: 50; +} + +.mw-badge-important { + background-color: #cc0000; +} diff --git a/resources/src/jquery/jquery.badge.js b/resources/src/jquery/jquery.badge.js new file mode 100644 index 00000000..023b6e28 --- /dev/null +++ b/resources/src/jquery/jquery.badge.js @@ -0,0 +1,88 @@ +/*! + * jQuery Badge plugin + * + * @license MIT + * + * @author Ryan Kaldari <rkaldari@wikimedia.org>, 2012 + * @author Andrew Garrett <agarrett@wikimedia.org>, 2012 + * @author Marius Hoch <hoo@online.de>, 2012 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * This program is distributed WITHOUT ANY WARRANTY. + */ + +/** + * @class jQuery.plugin.badge + */ +( function ( $, mw ) { + /** + * Put a badge on an item on the page. The badge container will be appended to the selected element(s). + * + * $element.badge( text ); + * $element.badge( 5 ); + * $element.badge( '100+' ); + * $element.badge( text, inline ); + * $element.badge( 'New', true ); + * + * @param {number|string} text The value to display in the badge. If the value is falsey (0, + * null, false, '', etc.), any existing badge will be removed. + * @param {boolean} [inline=true] True if the badge should be displayed inline, false + * if the badge should overlay the parent element. + * @param {boolean} [displayZero=false] True if the number zero should be displayed, + * false if the number zero should result in the badge being hidden + * @return {jQuery} + * @chainable + */ + $.fn.badge = function ( text, inline, displayZero ) { + var $badge = this.find( '.mw-badge' ), + badgeStyleClass = 'mw-badge-' + ( inline ? 'inline' : 'overlay' ), + isImportant = true, displayBadge = true; + + // If we're displaying zero, ensure style to be non-important + if ( mw.language.convertNumber( text, true ) === 0 ) { + isImportant = false; + if ( !displayZero ) { + displayBadge = false; + } + // If text is falsey (besides 0), hide the badge + } else if ( !text ) { + displayBadge = false; + } + + if ( displayBadge ) { + // If a badge already exists, reuse it + if ( $badge.length ) { + $badge + .toggleClass( 'mw-badge-important', isImportant ) + .find( '.mw-badge-content' ) + .text( text ); + } else { + // Otherwise, create a new badge with the specified text and style + $badge = $( '<div class="mw-badge"></div>' ) + .addClass( badgeStyleClass ) + .toggleClass( 'mw-badge-important', isImportant ) + .append( + $( '<span class="mw-badge-content"></span>' ).text( text ) + ) + .appendTo( this ); + } + } else { + $badge.remove(); + } + return this; + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.badge + */ +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.byteLength.js b/resources/src/jquery/jquery.byteLength.js new file mode 100644 index 00000000..7fe25ee3 --- /dev/null +++ b/resources/src/jquery/jquery.byteLength.js @@ -0,0 +1,40 @@ +/** + * @class jQuery.plugin.byteLength + * @author Jan Paul Posma, 2011 + * @author Timo Tijhof, 2012 + * @author David Chan, 2013 + */ + +/** + * Calculate the byte length of a string (accounting for UTF-8). + * + * @static + * @inheritable + * @param {string} str + * @return {string} + */ +jQuery.byteLength = function ( str ) { + // This basically figures out how many bytes a UTF-16 string (which is what js sees) + // will take in UTF-8 by replacing a 2 byte character with 2 *'s, etc, and counting that. + // Note, surrogate (\uD800-\uDFFF) characters are counted as 2 bytes, since there's two of them + // and the actual character takes 4 bytes in UTF-8 (2*2=4). Might not work perfectly in + // edge cases such as illegal sequences, but that should never happen. + + // https://en.wikipedia.org/wiki/UTF-8#Description + // The mapping from UTF-16 code units to UTF-8 bytes is as follows: + // > Range 0000-007F: codepoints that become 1 byte of UTF-8 + // > Range 0080-07FF: codepoints that become 2 bytes of UTF-8 + // > Range 0800-D7FF: codepoints that become 3 bytes of UTF-8 + // > Range D800-DFFF: Surrogates (each pair becomes 4 bytes of UTF-8) + // > Range E000-FFFF: codepoints that become 3 bytes of UTF-8 (continued) + + return str + .replace( /[\u0080-\u07FF\uD800-\uDFFF]/g, '**' ) + .replace( /[\u0800-\uD7FF\uE000-\uFFFF]/g, '***' ) + .length; +}; + +/** + * @class jQuery + * @mixins jQuery.plugin.byteLength + */ diff --git a/resources/src/jquery/jquery.byteLimit.js b/resources/src/jquery/jquery.byteLimit.js new file mode 100644 index 00000000..5551232a --- /dev/null +++ b/resources/src/jquery/jquery.byteLimit.js @@ -0,0 +1,235 @@ +/** + * @class jQuery.plugin.byteLimit + */ +( function ( $ ) { + + /** + * Utility function to trim down a string, based on byteLimit + * and given a safe start position. It supports insertion anywhere + * in the string, so "foo" to "fobaro" if limit is 4 will result in + * "fobo", not "foba". Basically emulating the native maxlength by + * reconstructing where the insertion occurred. + * + * @private + * @param {string} safeVal Known value that was previously returned by this + * function, if none, pass empty string. + * @param {string} newVal New value that may have to be trimmed down. + * @param {number} byteLimit Number of bytes the value may be in size. + * @param {Function} [fn] See jQuery.byteLimit. + * @return {Object} + * @return {string} return.newVal + * @return {boolean} return.trimmed + */ + function trimValForByteLength( safeVal, newVal, byteLimit, fn ) { + var startMatches, endMatches, matchesLen, inpParts, + oldVal = safeVal; + + // Run the hook if one was provided, but only on the length + // assessment. The value itself is not to be affected by the hook. + if ( $.byteLength( fn ? fn( newVal ) : newVal ) <= byteLimit ) { + // Limit was not reached, just remember the new value + // and let the user continue. + return { + newVal: newVal, + trimmed: false + }; + } + + // Current input is longer than the active limit. + // Figure out what was added and limit the addition. + startMatches = 0; + endMatches = 0; + + // It is important that we keep the search within the range of + // the shortest string's length. + // Imagine a user adds text that matches the end of the old value + // (e.g. "foo" -> "foofoo"). startMatches would be 3, but without + // limiting both searches to the shortest length, endMatches would + // also be 3. + matchesLen = Math.min( newVal.length, oldVal.length ); + + // Count same characters from the left, first. + // (if "foo" -> "foofoo", assume addition was at the end). + while ( + startMatches < matchesLen && + oldVal.charAt( startMatches ) === newVal.charAt( startMatches ) + ) { + startMatches += 1; + } + + while ( + endMatches < ( matchesLen - startMatches ) && + oldVal.charAt( oldVal.length - 1 - endMatches ) === newVal.charAt( newVal.length - 1 - endMatches ) + ) { + endMatches += 1; + } + + inpParts = [ + // Same start + newVal.slice( 0, startMatches ), + // Inserted content + newVal.slice( startMatches, newVal.length - endMatches ), + // Same end + newVal.slice( newVal.length - endMatches ) + ]; + + // Chop off characters from the end of the "inserted content" string + // until the limit is statisfied. + if ( fn ) { + // stop, when there is nothing to slice - bug 41450 + while ( $.byteLength( fn( inpParts.join( '' ) ) ) > byteLimit && inpParts[1].length > 0 ) { + inpParts[1] = inpParts[1].slice( 0, -1 ); + } + } else { + while ( $.byteLength( inpParts.join( '' ) ) > byteLimit ) { + inpParts[1] = inpParts[1].slice( 0, -1 ); + } + } + + newVal = inpParts.join( '' ); + + return { + newVal: newVal, + trimmed: true + }; + } + + var eventKeys = [ + 'keyup.byteLimit', + 'keydown.byteLimit', + 'change.byteLimit', + 'mouseup.byteLimit', + 'cut.byteLimit', + 'paste.byteLimit', + 'focus.byteLimit', + 'blur.byteLimit' + ].join( ' ' ); + + /** + * Enforces a byte limit on an input field, so that UTF-8 entries are counted as well, + * when, for example, a database field has a byte limit rather than a character limit. + * Plugin rationale: Browser has native maxlength for number of characters, this plugin + * exists to limit number of bytes instead. + * + * Can be called with a custom limit (to use that limit instead of the maxlength attribute + * value), a filter function (in case the limit should apply to something other than the + * exact input value), or both. Order of parameters is important! + * + * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute, + * called with fetched value as argument. + * @param {Function} [fn] Function to call on the string before assessing the length. + * @return {jQuery} + * @chainable + */ + $.fn.byteLimit = function ( limit, fn ) { + // If the first argument is the function, + // set fn to the first argument's value and ignore the second argument. + if ( $.isFunction( limit ) ) { + fn = limit; + limit = undefined; + // Either way, verify it is a function so we don't have to call + // isFunction again after this. + } else if ( !fn || !$.isFunction( fn ) ) { + fn = undefined; + } + + // The following is specific to each element in the collection. + return this.each( function ( i, el ) { + var $el, elLimit, prevSafeVal; + + $el = $( el ); + + // If no limit was passed to byteLimit(), use the maxlength value. + // Can't re-use 'limit' variable because it's in the higher scope + // that would affect the next each() iteration as well. + // Note that we use attribute to read the value instead of property, + // because in Chrome the maxLength property by default returns the + // highest supported value (no indication that it is being enforced + // by choice). We don't want to bind all of this for some ridiculously + // high default number, unless it was explicitly set in the HTML. + // Also cast to a (primitive) number (most commonly because the maxlength + // attribute contains a string, but theoretically the limit parameter + // could be something else as well). + elLimit = Number( limit === undefined ? $el.attr( 'maxlength' ) : limit ); + + // If there is no (valid) limit passed or found in the property, + // skip this. The < 0 check is required for Firefox, which returns + // -1 (instead of undefined) for maxLength if it is not set. + if ( !elLimit || elLimit < 0 ) { + return; + } + + if ( fn ) { + // Save function for reference + $el.data( 'byteLimit.callback', fn ); + } + + // Remove old event handlers (if there are any) + $el.off( '.byteLimit' ); + + if ( fn ) { + // Disable the native maxLength (if there is any), because it interferes + // with the (differently calculated) byte limit. + // Aside from being differently calculated (average chars with byteLimit + // is lower), we also support a callback which can make it to allow longer + // values (e.g. count "Foo" from "User:Foo"). + // maxLength is a strange property. Removing or setting the property to + // undefined directly doesn't work. Instead, it can only be unset internally + // by the browser when removing the associated attribute (Firefox/Chrome). + // http://code.google.com/p/chromium/issues/detail?id=136004 + $el.removeAttr( 'maxlength' ); + + } else { + // If we don't have a callback the bytelimit can only be lower than the charlimit + // (that is, there are no characters less than 1 byte in size). So lets (re-)enforce + // the native limit for efficiency when possible (it will make the while-loop below + // faster by there being less left to interate over). + $el.attr( 'maxlength', elLimit ); + } + + // Safe base value, used to determine the path between the previous state + // and the state that triggered the event handler below - and enforce the + // limit approppiately (e.g. don't chop from the end if text was inserted + // at the beginning of the string). + prevSafeVal = ''; + + // We need to listen to after the change has already happened because we've + // learned that trying to guess the new value and canceling the event + // accordingly doesn't work because the new value is not always as simple as: + // oldValue + String.fromCharCode( e.which ); because of cut, paste, select-drag + // replacements, and custom input methods and what not. + // Even though we only trim input after it was changed (never prevent it), we do + // listen on events that input text, because there are cases where the text has + // changed while text is being entered and keyup/change will not be fired yet + // (such as holding down a single key, fires keydown, and after each keydown, + // we can trim the previous one). + // See http://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for + // the order and characteristics of the key events. + $el.on( eventKeys, function () { + var res = trimValForByteLength( + prevSafeVal, + this.value, + elLimit, + fn + ); + + // Only set value property if it was trimmed, because whenever the + // value property is set, the browser needs to re-initiate the text context, + // which moves the cursor at the end the input, moving it away from wherever it was. + // This is a side-effect of limiting after the fact. + if ( res.trimmed === true ) { + this.value = res.newVal; + } + // Always adjust prevSafeVal to reflect the input value. Not doing this could cause + // trimValForByteLength to compare the new value to an empty string instead of the + // old value, resulting in trimming always from the end (bug 40850). + prevSafeVal = res.newVal; + } ); + } ); + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.byteLimit + */ +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.checkboxShiftClick.js b/resources/src/jquery/jquery.checkboxShiftClick.js new file mode 100644 index 00000000..d99e9f0a --- /dev/null +++ b/resources/src/jquery/jquery.checkboxShiftClick.js @@ -0,0 +1,43 @@ +/** + * @class jQuery.plugin.checkboxShiftClick + */ +( function ( $ ) { + + /** + * Enable checkboxes to be checked or unchecked in a row by clicking one, + * holding shift and clicking another one. + * + * @return {jQuery} + * @chainable + */ + $.fn.checkboxShiftClick = function () { + var prevCheckbox = null, + $box = this; + // When our boxes are clicked.. + $box.click( function ( e ) { + // And one has been clicked before... + if ( prevCheckbox !== null && e.shiftKey ) { + // Check or uncheck this one and all in-between checkboxes, + // except for disabled ones + $box + .slice( + Math.min( $box.index( prevCheckbox ), $box.index( e.target ) ), + Math.max( $box.index( prevCheckbox ), $box.index( e.target ) ) + 1 + ) + .filter( function () { + return !this.disabled; + } ) + .prop( 'checked', !!e.target.checked ); + } + // Either way, update the prevCheckbox variable to the one clicked now + prevCheckbox = e.target; + } ); + return $box; + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.checkboxShiftClick + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.client.js b/resources/src/jquery/jquery.client.js new file mode 100644 index 00000000..662a6887 --- /dev/null +++ b/resources/src/jquery/jquery.client.js @@ -0,0 +1,301 @@ +/** + * User-agent detection + * + * @class jQuery.client + * @singleton + */ +( function ( $ ) { + + /** + * @private + * @property {Object} profileCache Keyed by userAgent string, + * value is the parsed $.client.profile object for that user agent. + */ + var profileCache = {}; + + $.client = { + + /** + * Get an object containing information about the client. + * + * @param {Object} [nav] An object with a 'userAgent' and 'platform' property. + * Defaults to the global `navigator` object. + * @return {Object} The resulting client object will be in the following format: + * + * { + * 'name': 'firefox', + * 'layout': 'gecko', + * 'layoutVersion': 20101026, + * 'platform': 'linux' + * 'version': '3.5.1', + * 'versionBase': '3', + * 'versionNumber': 3.5, + * } + */ + profile: function ( nav ) { + /*jshint boss: true */ + + if ( nav === undefined ) { + nav = window.navigator; + } + + // Use the cached version if possible + if ( profileCache[ nav.userAgent + '|' + nav.platform ] !== undefined ) { + return profileCache[ nav.userAgent + '|' + nav.platform ]; + } + + var + versionNumber, + key = nav.userAgent + '|' + nav.platform, + + // Configuration + + // Name of browsers or layout engines we don't recognize + uk = 'unknown', + // Generic version digit + x = 'x', + // Strings found in user agent strings that need to be conformed + wildUserAgents = ['Opera', 'Navigator', 'Minefield', 'KHTML', 'Chrome', 'PLAYSTATION 3', 'Iceweasel'], + // Translations for conforming user agent strings + userAgentTranslations = [ + // Tons of browsers lie about being something they are not + [/(Firefox|MSIE|KHTML,?\slike\sGecko|Konqueror)/, ''], + // Chrome lives in the shadow of Safari still + ['Chrome Safari', 'Chrome'], + // KHTML is the layout engine not the browser - LIES! + ['KHTML', 'Konqueror'], + // Firefox nightly builds + ['Minefield', 'Firefox'], + // This helps keep different versions consistent + ['Navigator', 'Netscape'], + // This prevents version extraction issues, otherwise translation would happen later + ['PLAYSTATION 3', 'PS3'] + ], + // Strings which precede a version number in a user agent string - combined and used as + // match 1 in version detection + versionPrefixes = [ + 'camino', 'chrome', 'firefox', 'iceweasel', 'netscape', 'netscape6', 'opera', 'version', 'konqueror', + 'lynx', 'msie', 'safari', 'ps3', 'android' + ], + // Used as matches 2, 3 and 4 in version extraction - 3 is used as actual version number + versionSuffix = '(\\/|\\;?\\s|)([a-z0-9\\.\\+]*?)(\\;|dev|rel|\\)|\\s|$)', + // Names of known browsers + names = [ + 'camino', 'chrome', 'firefox', 'iceweasel', 'netscape', 'konqueror', 'lynx', 'msie', 'opera', + 'safari', 'ipod', 'iphone', 'blackberry', 'ps3', 'rekonq', 'android' + ], + // Tanslations for conforming browser names + nameTranslations = [], + // Names of known layout engines + layouts = ['gecko', 'konqueror', 'msie', 'trident', 'opera', 'webkit'], + // Translations for conforming layout names + layoutTranslations = [ ['konqueror', 'khtml'], ['msie', 'trident'], ['opera', 'presto'] ], + // Names of supported layout engines for version number + layoutVersions = ['applewebkit', 'gecko', 'trident'], + // Names of known operating systems + platforms = ['win', 'wow64', 'mac', 'linux', 'sunos', 'solaris', 'iphone'], + // Translations for conforming operating system names + platformTranslations = [ ['sunos', 'solaris'], ['wow64', 'win'] ], + + /** + * Performs multiple replacements on a string + * @ignore + */ + translate = function ( source, translations ) { + var i; + for ( i = 0; i < translations.length; i++ ) { + source = source.replace( translations[i][0], translations[i][1] ); + } + return source; + }, + + // Pre-processing + + ua = nav.userAgent, + match, + name = uk, + layout = uk, + layoutversion = uk, + platform = uk, + version = x; + + if ( match = new RegExp( '(' + wildUserAgents.join( '|' ) + ')' ).exec( ua ) ) { + // Takes a userAgent string and translates given text into something we can more easily work with + ua = translate( ua, userAgentTranslations ); + } + // Everything will be in lowercase from now on + ua = ua.toLowerCase(); + + // Extraction + + if ( match = new RegExp( '(' + names.join( '|' ) + ')' ).exec( ua ) ) { + name = translate( match[1], nameTranslations ); + } + if ( match = new RegExp( '(' + layouts.join( '|' ) + ')' ).exec( ua ) ) { + layout = translate( match[1], layoutTranslations ); + } + if ( match = new RegExp( '(' + layoutVersions.join( '|' ) + ')\\\/(\\d+)').exec( ua ) ) { + layoutversion = parseInt( match[2], 10 ); + } + if ( match = new RegExp( '(' + platforms.join( '|' ) + ')' ).exec( nav.platform.toLowerCase() ) ) { + platform = translate( match[1], platformTranslations ); + } + if ( match = new RegExp( '(' + versionPrefixes.join( '|' ) + ')' + versionSuffix ).exec( ua ) ) { + version = match[3]; + } + + // Edge Cases -- did I mention about how user agent string lie? + + // Decode Safari's crazy 400+ version numbers + if ( name === 'safari' && version > 400 ) { + version = '2.0'; + } + // Expose Opera 10's lies about being Opera 9.8 + if ( name === 'opera' && version >= 9.8 ) { + match = ua.match( /\bversion\/([0-9\.]*)/ ); + if ( match && match[1] ) { + version = match[1]; + } else { + version = '10'; + } + } + // And Opera 15's lies about being Chrome + if ( name === 'chrome' && ( match = ua.match( /\bopr\/([0-9\.]*)/ ) ) ) { + if ( match[1] ) { + name = 'opera'; + version = match[1]; + } + } + // And IE 11's lies about being not being IE + if ( layout === 'trident' && layoutversion >= 7 && ( match = ua.match( /\brv[ :\/]([0-9\.]*)/ ) ) ) { + if ( match[1] ) { + name = 'msie'; + version = match[1]; + } + } + // And Amazon Silk's lies about being Android on mobile or Safari on desktop + if ( match = ua.match( /\bsilk\/([0-9.\-_]*)/ ) ) { + if ( match[1] ) { + name = 'silk'; + version = match[1]; + } + } + + versionNumber = parseFloat( version, 10 ) || 0.0; + + // Caching + + return profileCache[ key ] = { + name: name, + layout: layout, + layoutVersion: layoutversion, + platform: platform, + version: version, + versionBase: ( version !== x ? Math.floor( versionNumber ).toString() : x ), + versionNumber: versionNumber + }; + }, + + /** + * Checks the current browser against a support map object. + * + * Version numbers passed as numeric values will be compared like numbers (1.2 > 1.11). + * Version numbers passed as string values will be compared using a simple component-wise + * algorithm, similar to PHP's version_compare ('1.2' < '1.11'). + * + * A browser map is in the following format: + * + * { + * // Multiple rules with configurable operators + * 'msie': [['>=', 7], ['!=', 9]], + * // Match no versions + * 'iphone': false, + * // Match any version + * 'android': null + * } + * + * It can optionally be split into ltr/rtl sections: + * + * { + * 'ltr': { + * 'android': null, + * 'iphone': false + * }, + * 'rtl': { + * 'android': false, + * // rules are not inherited from ltr + * 'iphone': false + * } + * } + * + * @param {Object} map Browser support map + * @param {Object} [profile] A client-profile object + * @param {boolean} [exactMatchOnly=false] Only return true if the browser is matched, otherwise + * returns true if the browser is not found. + * + * @return {boolean} The current browser is in the support map + */ + test: function ( map, profile, exactMatchOnly ) { + /*jshint evil: true */ + + var conditions, dir, i, op, val, j, pieceVersion, pieceVal, compare; + profile = $.isPlainObject( profile ) ? profile : $.client.profile(); + if ( map.ltr && map.rtl ) { + dir = $( 'body' ).is( '.rtl' ) ? 'rtl' : 'ltr'; + map = map[dir]; + } + // Check over each browser condition to determine if we are running in a compatible client + if ( typeof map !== 'object' || map[profile.name] === undefined ) { + // Not found, return true if exactMatchOnly not set, false otherwise + return !exactMatchOnly; + } + conditions = map[profile.name]; + if ( conditions === false ) { + // Match no versions + return false; + } + if ( conditions === null ) { + // Match all versions + return true; + } + for ( i = 0; i < conditions.length; i++ ) { + op = conditions[i][0]; + val = conditions[i][1]; + if ( typeof val === 'string' ) { + // Perform a component-wise comparison of versions, similar to PHP's version_compare + // but simpler. '1.11' is larger than '1.2'. + pieceVersion = profile.version.toString().split( '.' ); + pieceVal = val.split( '.' ); + // Extend with zeroes to equal length + while ( pieceVersion.length < pieceVal.length ) { + pieceVersion.push( '0' ); + } + while ( pieceVal.length < pieceVersion.length ) { + pieceVal.push( '0' ); + } + // Compare components + compare = 0; + for ( j = 0; j < pieceVersion.length; j++ ) { + if ( Number( pieceVersion[j] ) < Number( pieceVal[j] ) ) { + compare = -1; + break; + } else if ( Number( pieceVersion[j] ) > Number( pieceVal[j] ) ) { + compare = 1; + break; + } + } + // compare will be -1, 0 or 1, depending on comparison result + if ( !( eval( '' + compare + op + '0' ) ) ) { + return false; + } + } else if ( typeof val === 'number' ) { + if ( !( eval( 'profile.versionNumber' + op + val ) ) ) { + return false; + } + } + } + + return true; + } + }; +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.color.js b/resources/src/jquery/jquery.color.js new file mode 100644 index 00000000..04f8047b --- /dev/null +++ b/resources/src/jquery/jquery.color.js @@ -0,0 +1,55 @@ +/** + * jQuery Color Animations + * + * @author John Resig, 2007 + * @author Krinkle, 2011 + * Released under the MIT and GPL licenses. + * + * - 2011-01-05: Forked for MediaWiki. See also jQuery.colorUtil plugin + */ +( function ( $ ) { + + function getColor( elem, attr ) { + /*jshint boss:true */ + var color; + + do { + color = $.css( elem, attr ); + + // Keep going until we find an element that has color, or we hit the body + if ( color !== '' && color !== 'transparent' || $.nodeName( elem, 'body' ) ) { + break; + } + + attr = 'backgroundColor'; + } while ( elem = elem.parentNode ); + + return $.colorUtil.getRGB( color ); + } + + // We override the animation for all of these color styles + $.each([ + 'backgroundColor', + 'borderBottomColor', + 'borderLeftColor', + 'borderRightColor', + 'borderTopColor', + 'color', + 'outlineColor' + ], function ( i, attr ) { + $.fx.step[attr] = function ( fx ) { + if ( !fx.colorInit ) { + fx.start = getColor( fx.elem, attr ); + fx.end = $.colorUtil.getRGB( fx.end ); + fx.colorInit = true; + } + + fx.elem.style[attr] = 'rgb(' + [ + Math.max( Math.min( parseInt( (fx.pos * (fx.end[0] - fx.start[0])) + fx.start[0], 10 ), 255 ), 0 ), + Math.max( Math.min( parseInt( (fx.pos * (fx.end[1] - fx.start[1])) + fx.start[1], 10 ), 255 ), 0 ), + Math.max( Math.min( parseInt( (fx.pos * (fx.end[2] - fx.start[2])) + fx.start[2], 10 ), 255 ), 0 ) + ].join( ',' ) + ')'; + }; + } ); + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.colorUtil.js b/resources/src/jquery/jquery.colorUtil.js new file mode 100644 index 00000000..a6ff8bc8 --- /dev/null +++ b/resources/src/jquery/jquery.colorUtil.js @@ -0,0 +1,262 @@ +/*! + * jQuery Color Utilities + * + * Released under the MIT and GPL licenses. + * + * Mostly based on other plugins and functions (linted and optimized a little). + * Sources cited inline. + */ +( function ( $ ) { + /** + * @class jQuery.colorUtil + * @singleton + */ + $.colorUtil = { + + /** + * Parse CSS color strings looking for color tuples + * + * Based on highlightFade by Blair Mitchelmore + * <http://jquery.offput.ca/highlightFade/> + * + * @param {Array|string} color + * @return {Array} + */ + getRGB: function ( color ) { + /*jshint boss:true */ + var result; + + // Check if we're already dealing with an array of colors + if ( color && $.isArray( color ) && color.length === 3 ) { + return color; + } + + // Look for rgb(num,num,num) + if (result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(color)) { + return [ + parseInt( result[1], 10 ), + parseInt( result[2], 10 ), + parseInt( result[3], 10 ) + ]; + } + + // Look for rgb(num%,num%,num%) + if (result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(color)) { + return [ + parseFloat( result[1] ) * 2.55, + parseFloat( result[2] ) * 2.55, + parseFloat( result[3] ) * 2.55 + ]; + } + + // Look for #a0b1c2 + if (result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(color)) { + return [ + parseInt( result[1], 16 ), + parseInt( result[2], 16 ), + parseInt( result[3], 16 ) + ]; + } + + // Look for #fff + if (result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(color)) { + return [ + parseInt( result[1] + result[1], 16 ), + parseInt( result[2] + result[2], 16 ), + parseInt( result[3] + result[3], 16) + ]; + } + + // Look for rgba(0, 0, 0, 0) == transparent in Safari 3 + if (result = /rgba\(0, 0, 0, 0\)/.exec(color)) { + return $.colorUtil.colors.transparent; + } + + // Otherwise, we're most likely dealing with a named color + return $.colorUtil.colors[$.trim(color).toLowerCase()]; + }, + + /** + * Named color map + * + * Based on Interface by Stefan Petre + * <http://interface.eyecon.ro/> + * + * @property {Object} + */ + colors: { + aqua: [0, 255, 255], + azure: [240, 255, 255], + beige: [245, 245, 220], + black: [0, 0, 0], + blue: [0, 0, 255], + brown: [165, 42, 42], + cyan: [0, 255, 255], + darkblue: [0, 0, 139], + darkcyan: [0, 139, 139], + darkgrey: [169, 169, 169], + darkgreen: [0, 100, 0], + darkkhaki: [189, 183, 107], + darkmagenta: [139, 0, 139], + darkolivegreen: [85, 107, 47], + darkorange: [255, 140, 0], + darkorchid: [153, 50, 204], + darkred: [139, 0, 0], + darksalmon: [233, 150, 122], + darkviolet: [148, 0, 211], + fuchsia: [255, 0, 255], + gold: [255, 215, 0], + green: [0, 128, 0], + indigo: [75, 0, 130], + khaki: [240, 230, 140], + lightblue: [173, 216, 230], + lightcyan: [224, 255, 255], + lightgreen: [144, 238, 144], + lightgrey: [211, 211, 211], + lightpink: [255, 182, 193], + lightyellow: [255, 255, 224], + lime: [0, 255, 0], + magenta: [255, 0, 255], + maroon: [128, 0, 0], + navy: [0, 0, 128], + olive: [128, 128, 0], + orange: [255, 165, 0], + pink: [255, 192, 203], + purple: [128, 0, 128], + violet: [128, 0, 128], + red: [255, 0, 0], + silver: [192, 192, 192], + white: [255, 255, 255], + yellow: [255, 255, 0], + transparent: [255, 255, 255] + }, + + /** + * Convert an RGB color value to HSL. + * + * Conversion formula based on + * <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript> + * + * Adapted from <https://en.wikipedia.org/wiki/HSL_color_space>. + * + * Assumes `r`, `g`, and `b` are contained in the set `[0, 255]` and + * returns `h`, `s`, and `l` in the set `[0, 1]`. + * + * @param {number} r The red color value + * @param {number} g The green color value + * @param {number} b The blue color value + * @return {number[]} The HSL representation + */ + rgbToHsl: function ( r, g, b ) { + r = r / 255; + g = g / 255; + b = b / 255; + + var d, + max = Math.max( r, g, b ), + min = Math.min( r, g, b ), + h, + s, + l = (max + min) / 2; + + if ( max === min ) { + // achromatic + h = s = 0; + } else { + d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch ( max ) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + return [h, s, l]; + }, + + /** + * Convert an HSL color value to RGB. + * + * Conversion formula based on + * <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript> + * + * Adapted from <https://en.wikipedia.org/wiki/HSL_color_space>. + * + * Assumes `h`, `s`, and `l` are contained in the set `[0, 1]` and + * returns `r`, `g`, and `b` in the set `[0, 255]`. + * + * @param {number} h The hue + * @param {number} s The saturation + * @param {number} l The lightness + * @return {number[]} The RGB representation + */ + hslToRgb: function ( h, s, l ) { + var r, g, b, hue2rgb, q, p; + + if ( s === 0 ) { + r = g = b = l; // achromatic + } else { + hue2rgb = function ( p, q, t ) { + if ( t < 0 ) { + t += 1; + } + if ( t > 1 ) { + t -= 1; + } + if ( t < 1 / 6 ) { + return p + (q - p) * 6 * t; + } + if ( t < 1 / 2 ) { + return q; + } + if ( t < 2 / 3 ) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; + }; + + q = l < 0.5 ? l * (1 + s) : l + s - l * s; + p = 2 * l - q; + r = hue2rgb( p, q, h + 1 / 3 ); + g = hue2rgb( p, q, h ); + b = hue2rgb( p, q, h - 1 / 3 ); + } + + return [r * 255, g * 255, b * 255]; + }, + + /** + * Get a brighter or darker rgb() value string. + * + * Usage: + * + * $.colorUtil.getColorBrightness( 'red', +0.1 ); + * // > "rgb(255,50,50)" + * $.colorUtil.getColorBrightness( 'rgb(200,50,50)', -0.2 ); + * // > "rgb(118,29,29)" + * + * @param {Mixed} currentColor Current value in css + * @param {number} mod Wanted brightness modification between -1 and 1 + * @return {string} Like `'rgb(r,g,b)'` + */ + getColorBrightness: function ( currentColor, mod ) { + var rgbArr = $.colorUtil.getRGB( currentColor ), + hslArr = $.colorUtil.rgbToHsl(rgbArr[0], rgbArr[1], rgbArr[2] ); + rgbArr = $.colorUtil.hslToRgb(hslArr[0], hslArr[1], hslArr[2] + mod); + + return 'rgb(' + + [parseInt( rgbArr[0], 10), parseInt( rgbArr[1], 10 ), parseInt( rgbArr[2], 10 )].join( ',' ) + + ')'; + } + + }; + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.confirmable.css b/resources/src/jquery/jquery.confirmable.css new file mode 100644 index 00000000..de690726 --- /dev/null +++ b/resources/src/jquery/jquery.confirmable.css @@ -0,0 +1,28 @@ +.jquery-confirmable-button { + /* Automatically flipped */ + margin-left: 1ex; +} + +.jquery-confirmable-wrapper { + /* Line breaks within the interface text are unpleasant */ + white-space: nowrap; + /* Hiding the original text when it slides to the left */ + overflow: hidden; +} + +.jquery-confirmable-wrapper, +.jquery-confirmable-element, +.jquery-confirmable-interface { + /* We need inline-block to be able to size the elements and calculate their dimensions */ + display: inline-block; + /* inline-block elements in this context align to baseline by default */ + vertical-align: bottom; +} + +.jquery-confirmable-element { + transition: margin 250ms cubic-bezier(0.2, 0.8, 0.2, 0.8); +} + +.jquery-confirmable-interface { + transition: width 250ms cubic-bezier(0.2, 0.8, 0.2, 0.8); +} diff --git a/resources/src/jquery/jquery.confirmable.js b/resources/src/jquery/jquery.confirmable.js new file mode 100644 index 00000000..339e65a4 --- /dev/null +++ b/resources/src/jquery/jquery.confirmable.js @@ -0,0 +1,170 @@ +/** + * jQuery confirmable plugin + * + * Released under the MIT License. + * + * @author Bartosz Dziewoński + * + * @class jQuery.plugin.confirmable + */ +( function ( $ ) { + var identity = function ( data ) { + return data; + }; + + /** + * Enable inline confirmation for given clickable element (like `<a />` or `<button />`). + * + * An additional inline confirmation step being shown before the default action is carried out on + * click. + * + * Calling `.confirmable( { handler: function () { … } } )` will fire the handler only after the + * confirmation step. + * + * The element will have the `jquery-confirmable-element` class added to it when it's clicked for + * the first time, which has `white-space: nowrap;` and `display: inline-block;` defined in CSS. + * If the computed values for the element are different when you make it confirmable, you might + * encounter unexpected behavior. + * + * @param {Object} [options] + * @param {string} [options.events='click'] Events to hook to. + * @param {Function} [options.wrapperCallback] Callback to fire when preparing confirmable + * interface. Receives the interface jQuery object as the only parameter. + * @param {Function} [options.buttonCallback] Callback to fire when preparing confirmable buttons. + * It is fired separately for the 'Yes' and 'No' button. Receives the button jQuery object as + * the first parameter and 'yes' or 'no' as the second. + * @param {Function} [options.handler] Callback to fire when the action is confirmed (user clicks + * the 'Yes' button). + * @param {string} [options.i18n] Text to use for interface elements. + * @param {string} [options.i18n.space] Word separator to place between the three text messages. + * @param {string} [options.i18n.confirm] Text to use for the confirmation question. + * @param {string} [options.i18n.yes] Text to use for the 'Yes' button. + * @param {string} [options.i18n.no] Text to use for the 'No' button. + * + * @chainable + */ + $.fn.confirmable = function ( options ) { + options = $.extend( true, {}, $.fn.confirmable.defaultOptions, options || {} ); + + return this.on( options.events, function ( e ) { + var $element, $text, $buttonYes, $buttonNo, $wrapper, $interface, $elementClone, + interfaceWidth, elementWidth, rtl, positionOffscreen, positionRestore, sideMargin; + + $element = $( this ); + + if ( $element.data( 'jquery-confirmable-button' ) ) { + // We're running on a clone of this element that represents the 'Yes' or 'No' button. + // (This should never happen for the 'No' case unless calling code does bad things.) + return; + } + + // Only prevent native event handling. Stopping other JavaScript event handlers + // is impossible because they might have already run (we have no control over the order). + e.preventDefault(); + + rtl = $element.css( 'direction' ) === 'rtl'; + if ( rtl ) { + positionOffscreen = { position: 'absolute', right: '-9999px' }; + positionRestore = { position: '', right: '' }; + sideMargin = 'marginRight'; + } else { + positionOffscreen = { position: 'absolute', left: '-9999px' }; + positionRestore = { position: '', left: '' }; + sideMargin = 'marginLeft'; + } + + if ( $element.hasClass( 'jquery-confirmable-element' ) ) { + $wrapper = $element.closest( '.jquery-confirmable-wrapper' ); + $interface = $wrapper.find( '.jquery-confirmable-interface' ); + $text = $interface.find( '.jquery-confirmable-text' ); + $buttonYes = $interface.find( '.jquery-confirmable-button-yes' ); + $buttonNo = $interface.find( '.jquery-confirmable-button-no' ); + + interfaceWidth = $interface.data( 'jquery-confirmable-width' ); + elementWidth = $element.data( 'jquery-confirmable-width' ); + } else { + $elementClone = $element.clone( true ); + $element.addClass( 'jquery-confirmable-element' ); + + elementWidth = $element.width(); + $element.data( 'jquery-confirmable-width', elementWidth ); + + $wrapper = $( '<span>' ) + .addClass( 'jquery-confirmable-wrapper' ); + $element.wrap( $wrapper ); + + // Build the mini-dialog + $text = $( '<span>' ) + .addClass( 'jquery-confirmable-text' ) + .text( options.i18n.confirm ); + + // Clone original element along with event handlers to easily replicate its behavior. + // We could fiddle with .trigger() etc., but that is troublesome especially since + // Safari doesn't implement .click() on <a> links and jQuery follows suit. + $buttonYes = $elementClone.clone( true ) + .addClass( 'jquery-confirmable-button jquery-confirmable-button-yes' ) + .data( 'jquery-confirmable-button', true ) + .text( options.i18n.yes ); + if ( options.handler ) { + $buttonYes.on( options.events, options.handler ); + } + $buttonYes = options.buttonCallback( $buttonYes, 'yes' ); + + // Clone it without any events and prevent default action to represent the 'No' button. + $buttonNo = $elementClone.clone( false ) + .addClass( 'jquery-confirmable-button jquery-confirmable-button-no' ) + .data( 'jquery-confirmable-button', true ) + .text( options.i18n.no ) + .on( options.events, function ( e ) { + $element.css( sideMargin, 0 ); + $interface.css( 'width', 0 ); + e.preventDefault(); + } ); + $buttonNo = options.buttonCallback( $buttonNo, 'no' ); + + // Prevent memory leaks + $elementClone.remove(); + + $interface = $( '<span>' ) + .addClass( 'jquery-confirmable-interface' ) + .append( $text, options.i18n.space, $buttonYes, options.i18n.space, $buttonNo ); + $interface = options.wrapperCallback( $interface ); + + // Render offscreen to measure real width + $interface.css( positionOffscreen ); + // Insert it in the correct place while we're at it + $element.after( $interface ); + interfaceWidth = $interface.width(); + $interface.data( 'jquery-confirmable-width', interfaceWidth ); + $interface.css( positionRestore ); + + // Hide to animate the transition later + $interface.css( 'width', 0 ); + } + + // Hide element, show interface. This triggers both transitions. + // In a timeout to trigger the 'width' transition. + setTimeout( function () { + $element.css( sideMargin, -elementWidth ); + $interface.css( 'width', interfaceWidth ); + }, 1 ); + } ); + }; + + /** + * Default options. Overridable primarily for internationalisation handling. + * @property {Object} defaultOptions + */ + $.fn.confirmable.defaultOptions = { + events: 'click', + wrapperCallback: identity, + buttonCallback: identity, + handler: null, + i18n: { + space: ' ', + confirm: 'Are you sure?', + yes: 'Yes', + no: 'No' + } + }; +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.confirmable.mediawiki.js b/resources/src/jquery/jquery.confirmable.mediawiki.js new file mode 100644 index 00000000..d4a106e3 --- /dev/null +++ b/resources/src/jquery/jquery.confirmable.mediawiki.js @@ -0,0 +1,14 @@ +/*! + * jQuery confirmable plugin customization for MediaWiki + * + * This file serves to inject our localised messages into it. + */ + +( function ( mw, $ ) { + $.fn.confirmable.defaultOptions.i18n = { + space: mw.message( 'word-separator' ).text(), + confirm: mw.message( 'confirmable-confirm', mw.user ).text(), + yes: mw.message( 'confirmable-yes' ).text(), + no: mw.message( 'confirmable-no' ).text() + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/jquery/jquery.expandableField.js b/resources/src/jquery/jquery.expandableField.js new file mode 100644 index 00000000..732cc6ec --- /dev/null +++ b/resources/src/jquery/jquery.expandableField.js @@ -0,0 +1,140 @@ +/** + * This plugin provides functionality to expand a text box on focus to double it's current width + * + * Usage: + * + * Set options: + * $('#textbox').expandableField( { option1: value1, option2: value2 } ); + * $('#textbox').expandableField( option, value ); + * Get option: + * value = $('#textbox').expandableField( option ); + * Initialize: + * $('#textbox').expandableField(); + * + * Options: + * + */ +( function ( $ ) { + + $.expandableField = { + /** + * Expand the field, make the callback + */ + expandField: function ( e, context ) { + context.config.beforeExpand.call( context.data.$field, context ); + context.data.$field + .animate( { 'width': context.data.expandedWidth }, 'fast', function () { + context.config.afterExpand.call( this, context ); + } ); + }, + /** + * Condense the field, make the callback + */ + condenseField: function ( e, context ) { + context.config.beforeCondense.call( context.data.$field, context ); + context.data.$field + .animate( { 'width': context.data.condensedWidth }, 'fast', function () { + context.config.afterCondense.call( this, context ); + } ); + }, + /** + * Sets the value of a property, and updates the widget accordingly + * @param property String Name of property + * @param value Mixed Value to set property with + */ + configure: function ( context, property, value ) { + // TODO: Validate creation using fallback values + context.config[property] = value; + } + + }; + + $.fn.expandableField = function () { + + // Multi-context fields + var returnValue, + args = arguments; + + $( this ).each( function () { + var key, context, timeout; + + /* Construction / Loading */ + + context = $( this ).data( 'expandableField-context' ); + + // TODO: Do we need to check both null and undefined? + if ( context === undefined || context === null ) { + context = { + config: { + // callback function for before collapse + beforeCondense: function () {}, + + // callback function for before expand + beforeExpand: function () {}, + + // callback function for after collapse + afterCondense: function () {}, + + // callback function for after expand + afterExpand: function () {}, + + // Whether the field should expand to the left or the right -- defaults to left + expandToLeft: true + } + }; + } + + /* API */ + // Handle various calling styles + if ( args.length > 0 ) { + if ( typeof args[0] === 'object' ) { + // Apply set of properties + for ( key in args[0] ) { + $.expandableField.configure( context, key, args[0][key] ); + } + } else if ( typeof args[0] === 'string' ) { + if ( args.length > 1 ) { + // Set property values + $.expandableField.configure( context, args[0], args[1] ); + + // TODO: Do we need to check both null and undefined? + } else if ( returnValue === null || returnValue === undefined ) { + // Get property values, but don't give access to internal data - returns only the first + returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] ); + } + } + } + + /* Initialization */ + + if ( context.data === undefined ) { + context.data = { + // The width of the field in it's condensed state + condensedWidth: $( this ).width(), + + // The width of the field in it's expanded state + expandedWidth: $( this ).width() * 2, + + // Reference to the field + $field: $( this ) + }; + + $( this ) + .addClass( 'expandableField' ) + .focus( function ( e ) { + clearTimeout( timeout ); + $.expandableField.expandField( e, context ); + } ) + .blur( function ( e ) { + timeout = setTimeout( function () { + $.expandableField.condenseField( e, context ); + }, 250 ); + } ); + } + // Store the context for next time + $( this ).data( 'expandableField-context', context ); + } ); + return returnValue !== undefined ? returnValue : $(this); + }; + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.farbtastic.css b/resources/src/jquery/jquery.farbtastic.css new file mode 100644 index 00000000..1c6428f8 --- /dev/null +++ b/resources/src/jquery/jquery.farbtastic.css @@ -0,0 +1,54 @@ +/** + * Farbtastic Color Picker 1.2 + * © 2008 Steven Wittens + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +.farbtastic { + position: relative; +} +.farbtastic * { + position: absolute; + cursor: crosshair; +} +.farbtastic, .farbtastic .wheel { + width: 195px; + height: 195px; +} +.farbtastic .color, .farbtastic .overlay { + top: 47px; + left: 47px; + width: 101px; + height: 101px; +} +.farbtastic .wheel { + /* @embed */ + background: url(images/wheel.png) no-repeat; + width: 195px; + height: 195px; +} +.farbtastic .overlay { + /* @embed */ + background: url(images/mask.png) no-repeat; +} +.farbtastic .marker { + width: 17px; + height: 17px; + margin: -8px 0 0 -8px; + overflow: hidden; + /* @embed */ + background: url(images/marker.png) no-repeat; +} + diff --git a/resources/src/jquery/jquery.farbtastic.js b/resources/src/jquery/jquery.farbtastic.js new file mode 100644 index 00000000..d7024cc8 --- /dev/null +++ b/resources/src/jquery/jquery.farbtastic.js @@ -0,0 +1,286 @@ +/** + * Farbtastic Color Picker 1.2 + * © 2008 Steven Wittens + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +//Adapted to uniform style with jQuery UI widgets and slightly change behavior +//TODO: +// - remove duplicated code by replacing it with jquery.colorUtils and modern jQuery +// - uniform code style + +jQuery.fn.farbtastic = function (callback) { + $.farbtastic(this, callback); + return this; +}; + +jQuery.farbtastic = function (container, callback) { + var container = $(container).get(0); + return container.farbtastic || (container.farbtastic = new jQuery._farbtastic(container, callback)); +} + +jQuery._farbtastic = function (container, callback) { + // Store farbtastic object + var fb = this; + + // Insert markup + $(container).html('<div class="farbtastic ui-widget-content"><div class="color"></div><div class="wheel"></div><div class="overlay"></div><div class="h-marker marker"></div><div class="sl-marker marker"></div></div>'); + $(container).addClass('ui-widget'); + var e = $('.farbtastic', container); + fb.wheel = $('.wheel', container).get(0); + // Dimensions + fb.radius = 84; + fb.square = 100; + fb.width = 194; + + // Fix background PNGs in IE6 + if (navigator.appVersion.match(/MSIE [0-6]\./)) { + $('*', e).each(function () { + if (this.currentStyle.backgroundImage != 'none') { + var image = this.currentStyle.backgroundImage; + image = this.currentStyle.backgroundImage.slice(5, image.length - 2); + $(this).css({ + 'backgroundImage': 'none', + 'filter': "progid:DXImageTransform.Microsoft.AlphaImageLoader(enabled=true, sizingMethod=crop, src='" + image + "')" + }); + } + }); + } + + /** + * Link to the given element(s) or callback. + */ + fb.linkTo = function (callback) { + // Unbind previous nodes + if (typeof fb.callback == 'object') { + $(fb.callback).unbind('keyup', fb.updateValue); + } + + // Reset color + fb.color = null; + + // Bind callback or elements + if (typeof callback == 'function') { + fb.callback = callback; + } + else if (typeof callback == 'object' || typeof callback == 'string') { + fb.callback = $(callback); + fb.callback.bind('keyup', fb.updateValue); + if (fb.callback.get(0).value) { + fb.setColor(fb.callback.get(0).value); + } + } + return this; + } + fb.updateValue = function (event) { + if (this.value != fb.color) { + fb.setColor(this.value); + } + } + + /** + * Change color with HTML syntax #123456 + */ + fb.setColor = function (color) { + var rgb = $.colorUtil.getRGB( color ); + if (fb.color != color && rgb) { + rgb = rgb.slice( 0 ); //make a clone + //TODO: rewrite code so that this is not needed + rgb[0] /= 255; + rgb[1] /= 255; + rgb[2] /= 255; + fb.color = color; + fb.rgb = rgb; + fb.hsl = fb.RGBToHSL(fb.rgb); + fb.updateDisplay(); + } + return this; + } + + /** + * Change color with HSL triplet [0..1, 0..1, 0..1] + */ + fb.setHSL = function (hsl) { + fb.hsl = hsl; + fb.rgb = fb.HSLToRGB(hsl); + fb.color = fb.pack(fb.rgb); + fb.updateDisplay(); + return this; + } + + ///////////////////////////////////////////////////// + + /** + * Retrieve the coordinates of the given event relative to the center + * of the widget. + */ + fb.widgetCoords = function (event) { + var ref = $( fb.wheel ).offset(); + return { + x: event.pageX - ref.left - fb.width / 2, + y: event.pageY - ref.top - fb.width / 2 + }; + } + + /** + * Mousedown handler + */ + fb.mousedown = function (event) { + // Capture mouse + if (!document.dragging) { + $(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup); + document.dragging = true; + } + + // Check which area is being dragged + var pos = fb.widgetCoords(event); + fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) * 2 > fb.square; + + // Process + fb.mousemove(event); + return false; + } + + /** + * Mousemove handler + */ + fb.mousemove = function (event) { + // Get coordinates relative to color picker center + var pos = fb.widgetCoords(event); + + // Set new HSL parameters + if (fb.circleDrag) { + var hue = Math.atan2(pos.x, -pos.y) / 6.28; + if (hue < 0) hue += 1; + fb.setHSL([hue, fb.hsl[1], fb.hsl[2]]); + } + else { + var sat = Math.max(0, Math.min(1, -(pos.x / fb.square) + .5)); + var lum = Math.max(0, Math.min(1, -(pos.y / fb.square) + .5)); + fb.setHSL([fb.hsl[0], sat, lum]); + } + return false; + } + + /** + * Mouseup handler + */ + fb.mouseup = function () { + // Uncapture mouse + $(document).unbind('mousemove', fb.mousemove); + $(document).unbind('mouseup', fb.mouseup); + document.dragging = false; + } + + /** + * Update the markers and styles + */ + fb.updateDisplay = function () { + // Markers + var angle = fb.hsl[0] * 6.28; + $('.h-marker', e).css({ + left: Math.round(Math.sin(angle) * fb.radius + fb.width / 2) + 'px', + top: Math.round(-Math.cos(angle) * fb.radius + fb.width / 2) + 'px' + }); + + $('.sl-marker', e).css({ + left: Math.round(fb.square * (.5 - fb.hsl[1]) + fb.width / 2) + 'px', + top: Math.round(fb.square * (.5 - fb.hsl[2]) + fb.width / 2) + 'px' + }); + + // Saturation/Luminance gradient + $('.color', e).css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5]))); + + // Linked elements or callback + if (typeof fb.callback == 'object') { + // Set background/foreground color + $(fb.callback).css({ + backgroundColor: fb.color, + color: fb.hsl[2] > 0.5 ? '#000' : '#fff' + }); + + // Change linked value + $(fb.callback).each(function() { + if ( $( this ).val() != fb.color) { + $( this ).val( fb.color ).change(); + } + }); + } + else if (typeof fb.callback == 'function') { + fb.callback.call(fb, fb.color); + } + } + + /* Various color utility functions */ + fb.pack = function (rgb) { + var r = Math.round(rgb[0] * 255); + var g = Math.round(rgb[1] * 255); + var b = Math.round(rgb[2] * 255); + return '#' + (r < 16 ? '0' : '') + r.toString(16) + + (g < 16 ? '0' : '') + g.toString(16) + + (b < 16 ? '0' : '') + b.toString(16); + } + + fb.HSLToRGB = function (hsl) { + var m1, m2, r, g, b; + var h = hsl[0], s = hsl[1], l = hsl[2]; + m2 = (l <= 0.5) ? l * (s + 1) : l + s - l*s; + m1 = l * 2 - m2; + return [this.hueToRGB(m1, m2, h+0.33333), + this.hueToRGB(m1, m2, h), + this.hueToRGB(m1, m2, h-0.33333)]; + } + + fb.hueToRGB = function (m1, m2, h) { + h = (h < 0) ? h + 1 : ((h > 1) ? h - 1 : h); + if (h * 6 < 1) return m1 + (m2 - m1) * h * 6; + if (h * 2 < 1) return m2; + if (h * 3 < 2) return m1 + (m2 - m1) * (0.66666 - h) * 6; + return m1; + } + + fb.RGBToHSL = function (rgb) { + var min, max, delta, h, s, l; + var r = rgb[0], g = rgb[1], b = rgb[2]; + min = Math.min(r, Math.min(g, b)); + max = Math.max(r, Math.max(g, b)); + delta = max - min; + l = (min + max) / 2; + s = 0; + if (l > 0 && l < 1) { + s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l)); + } + h = 0; + if (delta > 0) { + if (max == r && max != g) h += (g - b) / delta; + if (max == g && max != b) h += (2 + (b - r) / delta); + if (max == b && max != r) h += (4 + (r - g) / delta); + h /= 6; + } + return [h, s, l]; + } + + // Install mousedown handler (the others are set on the document on-demand) + $('*', e).mousedown(fb.mousedown); + + // Init color + fb.setColor('#000000'); + + // Set linked elements/callback + if (callback) { + fb.linkTo(callback); + } +} diff --git a/resources/src/jquery/jquery.footHovzer.css b/resources/src/jquery/jquery.footHovzer.css new file mode 100644 index 00000000..77d9514c --- /dev/null +++ b/resources/src/jquery/jquery.footHovzer.css @@ -0,0 +1,6 @@ +#jquery-foot-hovzer { + position: fixed; + bottom: 0; + width: 100%; + z-index: 1000; +} diff --git a/resources/src/jquery/jquery.footHovzer.js b/resources/src/jquery/jquery.footHovzer.js new file mode 100644 index 00000000..de745c33 --- /dev/null +++ b/resources/src/jquery/jquery.footHovzer.js @@ -0,0 +1,66 @@ +/** + * @class jQuery.plugin.footHovzer + */ +( function ( $ ) { + var $hovzer, footHovzer, prevHeight, newHeight; + + function getHovzer() { + if ( $hovzer === undefined ) { + $hovzer = $( '<div id="jquery-foot-hovzer"></div>' ).appendTo( 'body' ); + } + return $hovzer; + } + + /** + * Utility to stack stuff in an overlay fixed on the bottom of the page. + * + * Usage: + * + * var hovzer = $.getFootHovzer(); + * hovzer.$.append( $myCollection ); + * hovzer.update(); + * + * @static + * @inheritable + * @return {jQuery.footHovzer} + */ + $.getFootHovzer = function () { + footHovzer.$ = getHovzer(); + return footHovzer; + }; + + /** + * @private + * @class jQuery.footHovzer + */ + footHovzer = { + + /** + * @property {jQuery} $ The stack container + */ + + /** + * Update dimensions of stack to account for changes in the subtree. + */ + update: function () { + var $body; + + $body = $( 'body' ); + if ( prevHeight === undefined ) { + prevHeight = getHovzer().outerHeight( /* includeMargin = */ true ); + $body.css( 'paddingBottom', '+=' + prevHeight + 'px' ); + } else { + newHeight = getHovzer().outerHeight( true ); + $body.css( 'paddingBottom', ( parseFloat( $body.css( 'paddingBottom' ) ) - prevHeight ) + newHeight ); + + prevHeight = newHeight; + } + } + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.footHovzer + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.getAttrs.js b/resources/src/jquery/jquery.getAttrs.js new file mode 100644 index 00000000..c44831c4 --- /dev/null +++ b/resources/src/jquery/jquery.getAttrs.js @@ -0,0 +1,42 @@ +/** + * @class jQuery.plugin.getAttrs + */ + +/** + * Get the attributes of an element directy as a plain object. + * + * If there are more elements in the collection, like most jQuery get/read methods, + * this method will use the first element in the collection. + * + * In IE6, the `attributes` map of a node includes *all* allowed attributes + * for an element (including those not set). Those will have values like + * `undefined`, `null`, `0`, `false`, `""` or `"inherit"`. + * + * However there may be attributes genuinely set to one of those values, and there + * is no way to distinguish between attributes set to that and those not set and + * it being the default. If you need them, set `all` to `true`. They are filtered out + * by default. + * + * @param {boolean} [all=false] + * @return {Object} + */ +jQuery.fn.getAttrs = function ( all ) { + var map = this[0].attributes, + attrs = {}, + len = map.length, + i, v; + + for ( i = 0; i < len; i++ ) { + v = map[i].nodeValue; + if ( all || ( v && v !== 'inherit' ) ) { + attrs[ map[i].nodeName ] = v; + } + } + + return attrs; +}; + +/** + * @class jQuery + * @mixins jQuery.plugin.getAttrs + */ diff --git a/resources/src/jquery/jquery.hidpi.js b/resources/src/jquery/jquery.hidpi.js new file mode 100644 index 00000000..4ecfeb88 --- /dev/null +++ b/resources/src/jquery/jquery.hidpi.js @@ -0,0 +1,129 @@ +/** + * Responsive images based on `srcset` and `window.devicePixelRatio` emulation where needed. + * + * Call `.hidpi()` on a document or part of a document to proces image srcsets within that section. + * + * `$.devicePixelRatio()` can be used as a substitute for `window.devicePixelRatio`. + * It provides a familiar interface to retrieve the pixel ratio for browsers that don't + * implement `window.devicePixelRatio` but do have a different way of getting it. + * + * @class jQuery.plugin.hidpi + */ +( function ( $ ) { + +/** + * Get reported or approximate device pixel ratio. + * + * - 1.0 means 1 CSS pixel is 1 hardware pixel + * - 2.0 means 1 CSS pixel is 2 hardware pixels + * - etc. + * + * Uses `window.devicePixelRatio` if available, or CSS media queries on IE. + * + * @static + * @inheritable + * @return {number} Device pixel ratio + */ +$.devicePixelRatio = function () { + if ( window.devicePixelRatio !== undefined ) { + // Most web browsers: + // * WebKit (Safari, Chrome, Android browser, etc) + // * Opera + // * Firefox 18+ + return window.devicePixelRatio; + } else if ( window.msMatchMedia !== undefined ) { + // Windows 8 desktops / tablets, probably Windows Phone 8 + // + // IE 10 doesn't report pixel ratio directly, but we can get the + // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for + // simplicity, but you may get different values depending on zoom + // factor, size of screen and orientation in Metro IE. + if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) { + return 2; + } else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) { + return 1.5; + } else { + return 1; + } + } else { + // Legacy browsers... + // Assume 1 if unknown. + return 1; + } +}; + +/** + * Implement responsive images based on srcset attributes, if browser has no + * native srcset support. + * + * @return {jQuery} This selection + * @chainable + */ +$.fn.hidpi = function () { + var $target = this, + // @todo add support for dpi media query checks on Firefox, IE + devicePixelRatio = $.devicePixelRatio(), + testImage = new Image(); + + if ( devicePixelRatio > 1 && testImage.srcset === undefined ) { + // No native srcset support. + $target.find( 'img' ).each( function () { + var $img = $( this ), + srcset = $img.attr( 'srcset' ), + match; + if ( typeof srcset === 'string' && srcset !== '' ) { + match = $.matchSrcSet( devicePixelRatio, srcset ); + if (match !== null ) { + $img.attr( 'src', match ); + } + } + }); + } + + return $target; +}; + +/** + * Match a srcset entry for the given device pixel ratio + * + * Exposed for testing. + * + * @private + * @static + * @param {number} devicePixelRatio + * @param {string} srcset + * @return {Mixed} null or the matching src string + */ +$.matchSrcSet = function ( devicePixelRatio, srcset ) { + var candidates, + candidate, + bits, + src, + i, + ratioStr, + ratio, + selectedRatio = 1, + selectedSrc = null; + candidates = srcset.split( / *, */ ); + for ( i = 0; i < candidates.length; i++ ) { + candidate = candidates[i]; + bits = candidate.split( / +/ ); + src = bits[0]; + if ( bits.length > 1 && bits[1].charAt( bits[1].length - 1 ) === 'x' ) { + ratioStr = bits[1].slice( 0, -1 ); + ratio = parseFloat( ratioStr ); + if ( ratio <= devicePixelRatio && ratio > selectedRatio ) { + selectedRatio = ratio; + selectedSrc = src; + } + } + } + return selectedSrc; +}; + +/** + * @class jQuery + * @mixins jQuery.plugin.hidpi + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.highlightText.js b/resources/src/jquery/jquery.highlightText.js new file mode 100644 index 00000000..13382182 --- /dev/null +++ b/resources/src/jquery/jquery.highlightText.js @@ -0,0 +1,73 @@ +/** + * Plugin that highlights matched word partials in a given element. + * TODO: Add a function for restoring the previous text. + * TODO: Accept mappings for converting shortcuts like WP: to Wikipedia:. + */ +( function ( $ ) { + + $.highlightText = { + + // Split our pattern string at spaces and run our highlight function on the results + splitAndHighlight: function ( node, pat ) { + var i, + patArray = pat.split( ' ' ); + for ( i = 0; i < patArray.length; i++ ) { + if ( patArray[i].length === 0 ) { + continue; + } + $.highlightText.innerHighlight( node, patArray[i] ); + } + return node; + }, + + // scans a node looking for the pattern and wraps a span around each match + innerHighlight: function ( node, pat ) { + var i, match, pos, spannode, middlebit, middleclone; + // if this is a text node + if ( node.nodeType === 3 ) { + // TODO - need to be smarter about the character matching here. + // non latin characters can make regex think a new word has begun: do not use \b + // http://stackoverflow.com/questions/3787072/regex-wordwrap-with-utf8-characters-in-js + // look for an occurrence of our pattern and store the starting position + match = node.data.match( new RegExp( '(^|\\s)' + $.escapeRE( pat ), 'i' ) ); + if ( match ) { + pos = match.index + match[1].length; // include length of any matched spaces + // create the span wrapper for the matched text + spannode = document.createElement( 'span' ); + spannode.className = 'highlight'; + // shave off the characters preceding the matched text + middlebit = node.splitText( pos ); + // shave off any unmatched text off the end + middlebit.splitText( pat.length ); + // clone for appending to our span + middleclone = middlebit.cloneNode( true ); + // append the matched text node to the span + spannode.appendChild( middleclone ); + // replace the matched node, with our span-wrapped clone of the matched node + middlebit.parentNode.replaceChild( spannode, middlebit ); + } + // if this is an element with childnodes, and not a script, style or an element we created + } else if ( node.nodeType === 1 + && node.childNodes + && !/(script|style)/i.test( node.tagName ) + && !( node.tagName.toLowerCase() === 'span' + && node.className.match( /\bhighlight/ ) + ) + ) { + for ( i = 0; i < node.childNodes.length; ++i ) { + // call the highlight function for each child node + $.highlightText.innerHighlight( node.childNodes[i], pat ); + } + } + } + }; + + $.fn.highlightText = function ( matchString ) { + return this.each( function () { + var $el = $( this ); + $el.data( 'highlightText', { originalText: $el.text() } ); + $.highlightText.splitAndHighlight( this, matchString ); + } ); + }; + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.localize.js b/resources/src/jquery/jquery.localize.js new file mode 100644 index 00000000..0b423545 --- /dev/null +++ b/resources/src/jquery/jquery.localize.js @@ -0,0 +1,170 @@ +/** + * @class jQuery.plugin.localize + */ +( function ( $, mw ) { + +/** + * Gets a localized message, using parameters from options if present. + * @ignore + * + * @param {Object} options + * @param {string} key + * @return {string} Localized message + */ +function msg( options, key ) { + var args = options.params[key] || []; + // Format: mw.msg( key [, p1, p2, ...] ) + args.unshift( options.prefix + ( options.keys[key] || key ) ); + return mw.msg.apply( mw, args ); +} + +/** + * Localizes a DOM selection by replacing <html:msg /> elements with localized text and adding + * localized title and alt attributes to elements with title-msg and alt-msg attributes + * respectively. + * + * Call on a selection of HTML which contains `<html:msg key="message-key" />` elements or elements + * with title-msg="message-key", alt-msg="message-key" or placeholder-msg="message-key" attributes. + * `<html:msg />` elements will be replaced with localized text, *-msg attributes will be replaced + * with attributes that do not have the "-msg" suffix and contain a localized message. + * + * Example: + * // Messages: { 'title': 'Awesome', 'desc': 'Cat doing backflip' 'search' contains 'Search' } + * var html = '\ + * <p>\ + * <html:msg key="title" />\ + * <img src="something.jpg" title-msg="title" alt-msg="desc" />\ + * <input type="text" placeholder-msg="search" />\ + * </p>'; + * $( 'body' ).append( $( html ).localize() ); + * + * Appends something like this to the body... + * <p> + * Awesome + * <img src="something.jpg" title="Awesome" alt="Cat doing backflip" /> + * <input type="text" placeholder="Search" /> + * </p> + * + * Arguments can be passed into uses of a message using the params property of the options object + * given to .localize(). Multiple messages can be given parameters, because the params property is + * an object keyed by the message key to apply the parameters to, each containing an array of + * parameters to use. The limitation is that you can not use different parameters to individual uses + * of a message in the same selection being localized - they will all recieve the same parameters. + * + * Example: + * // Messages: { 'easy-as': 'Easy as $1 $2 $3.' } + * var html = '<p><html:msg key="easy-as" /></p>'; + * $( 'body' ).append( $( html ).localize( { 'params': { 'easy-as': ['a', 'b', 'c'] } } ) ); + * + * Appends something like this to the body... + * <p>Easy as a, b, c</p> + * + * Raw HTML content can be used, instead of it being escaped as text. To do this, just use the raw + * attribute on a msg element. + * + * Example: + * // Messages: { 'hello': '<b><i>Hello</i> $1!</b>' } + * var html = '\ + * <p>\ + * <!-- escaped: --><html:msg key="hello" />\ + * <!-- raw: --><html:msg key="hello" raw />\ + * </p>'; + * $( 'body' ).append( $( html ).localize( { 'params': { 'hello': ['world'] } } ) ); + * + * Appends something like this to the body... + * <p> + * <!-- escaped: --><b><i>Hello</i> world!</b> + * <!-- raw: --><b><i>Hello</i> world!</b> + * </p> + * + * Message keys can also be remapped, allowing the same generic template to be used with a variety + * of messages. This is important for improving re-usability of templates. + * + * Example: + * // Messages: { 'good-afternoon': 'Good afternoon' } + * var html = '<p><html:msg key="greeting" /></p>'; + * $( 'body' ).append( $( html ).localize( { 'keys': { 'greeting': 'good-afternoon' } } ) ); + * + * Appends something like this to the body... + * <p>Good afternoon</p> + * + * Message keys can also be prefixed globally, which is handy when writing extensions, where by + * convention all messages are prefixed with the extension's name. + * + * Example: + * // Messages: { 'teleportation-warning': 'You may not get there all in one piece.' } + * var html = '<p><html:msg key="warning" /></p>'; + * $( 'body' ).append( $( html ).localize( { 'prefix': 'teleportation-' } ) ); + * + * Appends something like this to the body... + * <p>You may not get there all in one piece.</p> + * + * @param {Object} options Map of options to be used while localizing + * @param {string} options.prefix String to prepend to all message keys + * @param {Object} options.keys Message key aliases, used for remapping keys to a template + * @param {Object} options.params Lists of parameters to use with certain message keys + * @return {jQuery} + * @chainable + */ +$.fn.localize = function ( options ) { + var $target = this, + attributes = ['title', 'alt', 'placeholder']; + + // Extend options + options = $.extend( { + prefix: '', + keys: {}, + params: {} + }, options ); + + // Elements + // Ok, so here's the story on this selector. In IE 6/7, searching for 'msg' turns up the + // 'html:msg', but searching for 'html:msg' doesn't. In later IE and other browsers, searching + // for 'html:msg' turns up the 'html:msg', but searching for 'msg' doesn't. So searching for + // both 'msg' and 'html:msg' seems to get the job done. This feels pretty icky, though. + $target.find( 'msg,html\\:msg' ).each( function () { + var $el = $( this ); + // Escape by default + if ( $el.attr( 'raw' ) ) { + $el.html( msg( options, $el.attr( 'key' ) ) ); + } else { + $el.text( msg( options, $el.attr( 'key' ) ) ); + } + // Remove wrapper + $el.replaceWith( $el.html() ); + } ); + + // Attributes + // Note: there's no way to prevent escaping of values being injected into attributes, this is + // on purpose, not a design flaw. + $.each( attributes, function ( i, attr ) { + var msgAttr = attr + '-msg'; + $target.find( '[' + msgAttr + ']' ).each( function () { + var $el = $( this ); + $el.attr( attr, msg( options, $el.attr( msgAttr ) ) ).removeAttr( msgAttr ); + } ); + } ); + + // HTML, Text for elements which cannot have children e.g. OPTION + $target.find( '[data-msg-text]' ).each( function () { + var $el = $( this ); + $el.text( msg( options, $el.attr( 'data-msg-text' ) ) ); + } ); + + $target.find( '[data-msg-html]' ).each( function () { + var $el = $( this ); + $el.html( msg( options, $el.attr( 'data-msg-html' ) ) ); + } ); + + return $target; +}; + +// Let IE know about the msg tag before it's used... +document.createElement( 'msg' ); + +/** + * @class jQuery + * @mixins jQuery.plugin.localize + */ + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.makeCollapsible.css b/resources/src/jquery/jquery.makeCollapsible.css new file mode 100644 index 00000000..0f471509 --- /dev/null +++ b/resources/src/jquery/jquery.makeCollapsible.css @@ -0,0 +1,27 @@ +/* See also jquery.makeCollapsible.js */ +.mw-collapsible-toggle { + float: right; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mw-customtoggle, +.mw-collapsible-toggle { + cursor: pointer; +} + +/* collapse links in captions should be inline */ +caption .mw-collapsible-toggle { + float: none; +} + +/* list-items go as wide as their parent element, don't float them inside list items */ +li .mw-collapsible-toggle { + float: none; +} + +/* the added list item should have no list-style */ +.mw-collapsible-toggle-li { + list-style: none; +} diff --git a/resources/src/jquery/jquery.makeCollapsible.js b/resources/src/jquery/jquery.makeCollapsible.js new file mode 100644 index 00000000..c4e25203 --- /dev/null +++ b/resources/src/jquery/jquery.makeCollapsible.js @@ -0,0 +1,404 @@ +/** + * jQuery makeCollapsible + * + * Dual licensed: + * - CC BY 3.0 <http://creativecommons.org/licenses/by/3.0> + * - GPL2 <http://www.gnu.org/licenses/old-licenses/gpl-2.0.html> + * + * @class jQuery.plugin.makeCollapsible + */ +( function ( $, mw ) { + + /** + * Handler for a click on a collapsible toggler. + * + * @private + * @param {jQuery} $collapsible + * @param {string} action The action this function will take ('expand' or 'collapse'). + * @param {jQuery|null} [$defaultToggle] + * @param {Object|undefined} [options] + */ + function toggleElement( $collapsible, action, $defaultToggle, options ) { + var $collapsibleContent, $containers, hookCallback; + options = options || {}; + + // Validate parameters + + // $collapsible must be an instance of jQuery + if ( !$collapsible.jquery ) { + return; + } + if ( action !== 'expand' && action !== 'collapse' ) { + // action must be string with 'expand' or 'collapse' + return; + } + if ( $defaultToggle === undefined ) { + $defaultToggle = null; + } + if ( $defaultToggle !== null && !$defaultToggle.jquery ) { + // is optional (may be undefined), but if defined it must be an instance of jQuery. + // If it's not, abort right away. + // After this $defaultToggle is either null or a valid jQuery instance. + return; + } + + // Trigger a custom event to allow callers to hook to the collapsing/expanding, + // allowing the module to be testable, and making it possible to + // e.g. implement persistence via cookies + $collapsible.trigger( action === 'expand' ? 'beforeExpand.mw-collapsible' : 'beforeCollapse.mw-collapsible' ); + hookCallback = function () { + $collapsible.trigger( action === 'expand' ? 'afterExpand.mw-collapsible' : 'afterCollapse.mw-collapsible' ); + }; + + // Handle different kinds of elements + + if ( !options.plainMode && $collapsible.is( 'table' ) ) { + // Tables + // If there is a caption, hide all rows; otherwise, only hide body rows + if ( $collapsible.find( '> caption' ).length ) { + $containers = $collapsible.find( '> * > tr' ); + } else { + $containers = $collapsible.find( '> tbody > tr' ); + } + if ( $defaultToggle ) { + // Exclude table row containing togglelink + $containers = $containers.not( $defaultToggle.closest( 'tr' ) ); + } + + if ( action === 'collapse' ) { + // Hide all table rows of this table + // Slide doesn't work with tables, but fade does as of jQuery 1.1.3 + // http://stackoverflow.com/questions/467336#920480 + if ( options.instantHide ) { + $containers.hide(); + hookCallback(); + } else { + $containers.stop( true, true ).fadeOut().promise().done( hookCallback ); + } + } else { + $containers.stop( true, true ).fadeIn().promise().done( hookCallback ); + } + + } else if ( !options.plainMode && ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) ) { + // Lists + $containers = $collapsible.find( '> li' ); + if ( $defaultToggle ) { + // Exclude list-item containing togglelink + $containers = $containers.not( $defaultToggle.parent() ); + } + + if ( action === 'collapse' ) { + if ( options.instantHide ) { + $containers.hide(); + hookCallback(); + } else { + $containers.stop( true, true ).slideUp().promise().done( hookCallback ); + } + } else { + $containers.stop( true, true ).slideDown().promise().done( hookCallback ); + } + + } else { + // Everything else: <div>, <p> etc. + $collapsibleContent = $collapsible.find( '> .mw-collapsible-content' ); + + // If a collapsible-content is defined, act on it + if ( !options.plainMode && $collapsibleContent.length ) { + if ( action === 'collapse' ) { + if ( options.instantHide ) { + $collapsibleContent.hide(); + hookCallback(); + } else { + $collapsibleContent.slideUp().promise().done( hookCallback ); + } + } else { + $collapsibleContent.slideDown().promise().done( hookCallback ); + } + + // Otherwise assume this is a customcollapse with a remote toggle + // .. and there is no collapsible-content because the entire element should be toggled + } else { + if ( action === 'collapse' ) { + if ( options.instantHide ) { + $collapsible.hide(); + hookCallback(); + } else { + if ( $collapsible.is( 'tr' ) || $collapsible.is( 'td' ) || $collapsible.is( 'th' ) ) { + $collapsible.fadeOut().promise().done( hookCallback ); + } else { + $collapsible.slideUp().promise().done( hookCallback ); + } + } + } else { + if ( $collapsible.is( 'tr' ) || $collapsible.is( 'td' ) || $collapsible.is( 'th' ) ) { + $collapsible.fadeIn().promise().done( hookCallback ); + } else { + $collapsible.slideDown().promise().done( hookCallback ); + } + } + } + } + } + + /** + * Handle clicking/keypressing on the collapsible element toggle and other + * situations where a collapsible element is toggled (e.g. the initial + * toggle for collapsed ones). + * + * @private + * @param {jQuery} $toggle the clickable toggle itself + * @param {jQuery} $collapsible the collapsible element + * @param {jQuery.Event|null} e either the event or null if unavailable + * @param {Object|undefined} options + */ + function togglingHandler( $toggle, $collapsible, e, options ) { + var wasCollapsed, $textContainer, collapseText, expandText; + + if ( options === undefined ) { + options = {}; + } + + if ( e ) { + if ( e.type === 'click' && options.linksPassthru && $.nodeName( e.target, 'a' ) ) { + // Don't fire if a link was clicked, if requested (for premade togglers by default) + return; + } else if ( e.type === 'keypress' && e.which !== 13 && e.which !== 32 ) { + // Only handle keypresses on the "Enter" or "Space" keys + return; + } else { + e.preventDefault(); + e.stopPropagation(); + } + } + + // This allows the element to be hidden on initial toggle without fiddling with the class + if ( options.wasCollapsed !== undefined ) { + wasCollapsed = options.wasCollapsed; + } else { + wasCollapsed = $collapsible.hasClass( 'mw-collapsed' ); + } + + // Toggle the state of the collapsible element (that is, expand or collapse) + $collapsible.toggleClass( 'mw-collapsed', !wasCollapsed ); + + // Toggle the mw-collapsible-toggle classes, if requested (for default and premade togglers by default) + if ( options.toggleClasses ) { + $toggle + .toggleClass( 'mw-collapsible-toggle-collapsed', !wasCollapsed ) + .toggleClass( 'mw-collapsible-toggle-expanded', wasCollapsed ); + } + + // Toggle the text ("Show"/"Hide"), if requested (for default togglers by default) + if ( options.toggleText ) { + collapseText = options.toggleText.collapseText; + expandText = options.toggleText.expandText; + + $textContainer = $toggle.find( '> a' ); + if ( !$textContainer.length ) { + $textContainer = $toggle; + } + $textContainer.text( wasCollapsed ? collapseText : expandText ); + } + + // And finally toggle the element state itself + toggleElement( $collapsible, wasCollapsed ? 'expand' : 'collapse', $toggle, options ); + } + + /** + * Enable collapsible-functionality on all elements in the collection. + * + * - Will prevent binding twice to the same element. + * - Initial state is expanded by default, this can be overriden by adding class + * "mw-collapsed" to the "mw-collapsible" element. + * - Elements made collapsible have jQuery data "mw-made-collapsible" set to true. + * - The inner content is wrapped in a "div.mw-collapsible-content" (except for tables and lists). + * + * @param {Object} [options] + * @param {string} [options.collapseText] Text used for the toggler, when clicking it would + * collapse the element. Default: the 'data-collapsetext' attribute of the + * collapsible element or the content of 'collapsible-collapse' message. + * @param {string} [options.expandText] Text used for the toggler, when clicking it would + * expand the element. Default: the 'data-expandtext' attribute of the + * collapsible element or the content of 'collapsible-expand' message. + * @param {boolean} [options.collapsed] Whether to collapse immediately. By default + * collapse only if the elements has the 'mw-collapsible' class. + * @param {jQuery} [options.$customTogglers] Elements to be used as togglers + * for this collapsible element. By default, if the collapsible element + * has an id attribute like 'mw-customcollapsible-XXX', elements with a + * *class* of 'mw-customtoggle-XXX' are made togglers for it. + * @param {boolean} [options.plainMode=false] Whether to use a "plain mode" when making the + * element collapsible - that is, hide entire tables and lists (instead + * of hiding only all rows but first of tables, and hiding each list + * item separately for lists) and don't wrap other elements in + * div.mw-collapsible-content. May only be used with custom togglers. + * @return {jQuery} + * @chainable + */ + $.fn.makeCollapsible = function ( options ) { + if ( options === undefined ) { + options = {}; + } + + return this.each( function () { + var $collapsible, collapseText, expandText, $caption, $toggle, actionHandler, buildDefaultToggleLink, + premadeToggleHandler, $toggleLink, $firstItem, collapsibleId, $customTogglers, firstval; + + // Ensure class "mw-collapsible" is present in case .makeCollapsible() + // is called on element(s) that don't have it yet. + $collapsible = $( this ).addClass( 'mw-collapsible' ); + + // Return if it has been enabled already. + if ( $collapsible.data( 'mw-made-collapsible' ) ) { + return; + } else { + $collapsible.data( 'mw-made-collapsible', true ); + } + + // Use custom text or default? + collapseText = options.collapseText || $collapsible.attr( 'data-collapsetext' ) || mw.msg( 'collapsible-collapse' ); + expandText = options.expandText || $collapsible.attr( 'data-expandtext' ) || mw.msg( 'collapsible-expand' ); + + // Default click/keypress handler and toggle link to use when none is present + actionHandler = function ( e, opts ) { + var defaultOpts = { + toggleClasses: true, + toggleText: { collapseText: collapseText, expandText: expandText } + }; + opts = $.extend( defaultOpts, options, opts ); + togglingHandler( $( this ), $collapsible, e, opts ); + }; + // Default toggle link. Only build it when needed to avoid jQuery memory leaks (event data). + buildDefaultToggleLink = function () { + return $( '<a href="#"></a>' ) + .text( collapseText ) + .wrap( '<span class="mw-collapsible-toggle"></span>' ) + .parent() + .prepend( '<span class="mw-collapsible-bracket">[</span>' ) + .append( '<span class="mw-collapsible-bracket">]</span>' ) + .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ); + }; + + // Default handler for clicking on premade toggles + premadeToggleHandler = function ( e, opts ) { + var defaultOpts = { toggleClasses: true, linksPassthru: true }; + opts = $.extend( defaultOpts, options, opts ); + togglingHandler( $( this ), $collapsible, e, opts ); + }; + + // Check if this element has a custom position for the toggle link + // (ie. outside the container or deeper inside the tree) + if ( options.$customTogglers ) { + $customTogglers = $( options.$customTogglers ); + } else { + collapsibleId = $collapsible.attr( 'id' ) || ''; + if ( collapsibleId.indexOf( 'mw-customcollapsible-' ) === 0 ) { + $customTogglers = $( '.' + collapsibleId.replace( 'mw-customcollapsible', 'mw-customtoggle' ) ) + .addClass( 'mw-customtoggle' ); + } + } + + // Add event handlers to custom togglers or create our own ones + if ( $customTogglers && $customTogglers.length ) { + actionHandler = function ( e, opts ) { + var defaultOpts = {}; + opts = $.extend( defaultOpts, options, opts ); + togglingHandler( $( this ), $collapsible, e, opts ); + }; + + $toggleLink = $customTogglers + .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ) + .prop( 'tabIndex', 0 ); + + } else { + // If this is not a custom case, do the default: wrap the + // contents and add the toggle link. Different elements are + // treated differently. + if ( $collapsible.is( 'table' ) ) { + + // If the table has a caption, collapse to the caption + // as opposed to the first row + $caption = $collapsible.find( '> caption' ); + if ( $caption.length ) { + $toggle = $caption.find( '> .mw-collapsible-toggle' ); + + // If there is no toggle link, add it to the end of the caption + if ( !$toggle.length ) { + $toggleLink = buildDefaultToggleLink().appendTo( $caption ); + } else { + actionHandler = premadeToggleHandler; + $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ) + .prop( 'tabIndex', 0 ); + } + } else { + // The toggle-link will be in one the the cells (td or th) of the first row + $firstItem = $collapsible.find( 'tr:first th, tr:first td' ); + $toggle = $firstItem.find( '> .mw-collapsible-toggle' ); + + // If theres no toggle link, add it to the last cell + if ( !$toggle.length ) { + $toggleLink = buildDefaultToggleLink().prependTo( $firstItem.eq( -1 ) ); + } else { + actionHandler = premadeToggleHandler; + $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ) + .prop( 'tabIndex', 0 ); + } + } + + } else if ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) { + // The toggle-link will be in the first list-item + $firstItem = $collapsible.find( 'li:first' ); + $toggle = $firstItem.find( '> .mw-collapsible-toggle' ); + + // If theres no toggle link, add it + if ( !$toggle.length ) { + // Make sure the numeral order doesn't get messed up, force the first (soon to be second) item + // to be "1". Except if the value-attribute is already used. + // If no value was set WebKit returns "", Mozilla returns '-1', others return 0, null or undefined. + firstval = $firstItem.prop( 'value' ); + if ( firstval === undefined || !firstval || firstval === '-1' || firstval === -1 ) { + $firstItem.prop( 'value', '1' ); + } + $toggleLink = buildDefaultToggleLink(); + $toggleLink.wrap( '<li class="mw-collapsible-toggle-li"></li>' ).parent().prependTo( $collapsible ); + } else { + actionHandler = premadeToggleHandler; + $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ) + .prop( 'tabIndex', 0 ); + } + + } else { // <div>, <p> etc. + + // The toggle-link will be the first child of the element + $toggle = $collapsible.find( '> .mw-collapsible-toggle' ); + + // If a direct child .content-wrapper does not exists, create it + if ( !$collapsible.find( '> .mw-collapsible-content' ).length ) { + $collapsible.wrapInner( '<div class="mw-collapsible-content"></div>' ); + } + + // If theres no toggle link, add it + if ( !$toggle.length ) { + $toggleLink = buildDefaultToggleLink().prependTo( $collapsible ); + } else { + actionHandler = premadeToggleHandler; + $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ) + .prop( 'tabIndex', 0 ); + } + } + } + + // Initial state + if ( options.collapsed || $collapsible.hasClass( 'mw-collapsed' ) ) { + // One toggler can hook to multiple elements, and one element can have + // multiple togglers. This is the sanest way to handle that. + actionHandler.call( $toggleLink.get( 0 ), null, { instantHide: true, wasCollapsed: false } ); + } + } ); + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.makeCollapsible + */ + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.mw-jump.js b/resources/src/jquery/jquery.mw-jump.js new file mode 100644 index 00000000..5eae0bec --- /dev/null +++ b/resources/src/jquery/jquery.mw-jump.js @@ -0,0 +1,15 @@ +/** + * JavaScript to show jump links to motor-impaired users when they are focused. + */ +jQuery( function ( $ ) { + + $( '.mw-jump' ).on( 'focus blur', 'a', function ( e ) { + // Confusingly jQuery leaves e.type as focusout for delegated blur events + if ( e.type === 'blur' || e.type === 'focusout' ) { + $( this ).closest( '.mw-jump' ).css( { height: 0 } ); + } else { + $( this ).closest( '.mw-jump' ).css( { height: 'auto' } ); + } + } ); + +} ); diff --git a/resources/src/jquery/jquery.mwExtension.js b/resources/src/jquery/jquery.mwExtension.js new file mode 100644 index 00000000..dc7aaa45 --- /dev/null +++ b/resources/src/jquery/jquery.mwExtension.js @@ -0,0 +1,122 @@ +/* + * JavaScript backwards-compatibility alternatives and other convenience functions + */ +( function ( $ ) { + + $.extend( { + trimLeft: function ( str ) { + return str === null ? '' : str.toString().replace( /^\s+/, '' ); + }, + trimRight: function ( str ) { + return str === null ? + '' : str.toString().replace( /\s+$/, '' ); + }, + ucFirst: function ( str ) { + return str.charAt( 0 ).toUpperCase() + str.slice( 1 ); + }, + escapeRE: function ( str ) { + return str.replace ( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ); + }, + isDomElement: function ( el ) { + return !!el && !!el.nodeType; + }, + isEmpty: function ( v ) { + var key; + if ( v === '' || v === 0 || v === '0' || v === null + || v === false || v === undefined ) + { + return true; + } + // the for-loop could potentially contain prototypes + // to avoid that we check it's length first + if ( v.length === 0 ) { + return true; + } + if ( typeof v === 'object' ) { + for ( key in v ) { + return false; + } + return true; + } + return false; + }, + compareArray: function ( arrThis, arrAgainst ) { + if ( arrThis.length !== arrAgainst.length ) { + return false; + } + for ( var i = 0; i < arrThis.length; i++ ) { + if ( $.isArray( arrThis[i] ) ) { + if ( !$.compareArray( arrThis[i], arrAgainst[i] ) ) { + return false; + } + } else if ( arrThis[i] !== arrAgainst[i] ) { + return false; + } + } + return true; + }, + compareObject: function ( objectA, objectB ) { + var prop, type; + + // Do a simple check if the types match + if ( typeof objectA === typeof objectB ) { + + // Only loop over the contents if it really is an object + if ( typeof objectA === 'object' ) { + // If they are aliases of the same object (ie. mw and mediaWiki) return now + if ( objectA === objectB ) { + return true; + } else { + // Iterate over each property + for ( prop in objectA ) { + // Check if this property is also present in the other object + if ( prop in objectB ) { + // Compare the types of the properties + type = typeof objectA[prop]; + if ( type === typeof objectB[prop] ) { + // Recursively check objects inside this one + switch ( type ) { + case 'object' : + if ( !$.compareObject( objectA[prop], objectB[prop] ) ) { + return false; + } + break; + case 'function' : + // Functions need to be strings to compare them properly + if ( objectA[prop].toString() !== objectB[prop].toString() ) { + return false; + } + break; + default: + // Strings, numbers + if ( objectA[prop] !== objectB[prop] ) { + return false; + } + break; + } + } else { + return false; + } + } else { + return false; + } + } + // Check for properties in B but not in A + // This is about 15% faster (tested in Safari 5 and Firefox 3.6) + // ...than incrementing a count variable in the above and below loops + // See also: https://www.mediawiki.org/wiki/ResourceLoader/Default_modules/compareObject_test#Results + for ( prop in objectB ) { + if ( !( prop in objectA ) ) { + return false; + } + } + } + } + } else { + return false; + } + return true; + } + } ); + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.placeholder.js b/resources/src/jquery/jquery.placeholder.js new file mode 100644 index 00000000..d4580190 --- /dev/null +++ b/resources/src/jquery/jquery.placeholder.js @@ -0,0 +1,229 @@ +/** + * HTML5 placeholder emulation for jQuery plugin + * + * This will automatically use the HTML5 placeholder attribute if supported, or emulate this behavior if not. + * + * This is a fork from Mathias Bynens' jquery.placeholder as of this commit + * https://github.com/mathiasbynens/jquery-placeholder/blob/47f05d400e2dd16b59d144141a2cf54a9a77c502/jquery.placeholder.js + * + * @author Mathias Bynens <http://mathiasbynens.be/> + * @author Trevor Parscal <tparscal@wikimedia.org>, 2012 + * @author Krinkle <krinklemail@gmail.com>, 2012 + * @author Alex Ivanov <alexivanov97@gmail.com>, 2013 + * @version 2.1.0 + * @license MIT + */ +(function ($) { + + var isInputSupported = 'placeholder' in document.createElement('input'), + isTextareaSupported = 'placeholder' in document.createElement('textarea'), + prototype = $.fn, + valHooks = $.valHooks, + propHooks = $.propHooks, + hooks, + placeholder; + + if (isInputSupported && isTextareaSupported) { + + placeholder = prototype.placeholder = function (text) { + var hasArgs = arguments.length; + + if (hasArgs) { + changePlaceholder.call(this, text); + } + + return this; + }; + + placeholder.input = placeholder.textarea = true; + + } else { + + placeholder = prototype.placeholder = function (text) { + var $this = this, + hasArgs = arguments.length; + + if (hasArgs) { + changePlaceholder.call(this, text); + } + + $this + .filter((isInputSupported ? 'textarea' : ':input') + '[placeholder]') + .filter(function () { + return !$(this).data('placeholder-enabled'); + }) + .bind({ + 'focus.placeholder drop.placeholder': clearPlaceholder, + 'blur.placeholder': setPlaceholder + }) + .data('placeholder-enabled', true) + .trigger('blur.placeholder'); + return $this; + }; + + placeholder.input = isInputSupported; + placeholder.textarea = isTextareaSupported; + + hooks = { + 'get': function (element) { + var $element = $(element), + $passwordInput = $element.data('placeholder-password'); + if ($passwordInput) { + return $passwordInput[0].value; + } + + return $element.data('placeholder-enabled') && $element.hasClass('placeholder') ? '' : element.value; + }, + 'set': function (element, value) { + var $element = $(element), + $passwordInput = $element.data('placeholder-password'); + if ($passwordInput) { + $passwordInput[0].value = value; + return value; + } + + if (!$element.data('placeholder-enabled')) { + element.value = value; + return value; + } + if (!value) { + element.value = value; + // Issue #56: Setting the placeholder causes problems if the element continues to have focus. + if (element !== safeActiveElement()) { + // We can't use `triggerHandler` here because of dummy text/password inputs :( + setPlaceholder.call(element); + } + } else if ($element.hasClass('placeholder')) { + if (!clearPlaceholder.call(element, true, value)) { + element.value = value; + } + } else { + element.value = value; + } + // `set` can not return `undefined`; see http://jsapi.info/jquery/1.7.1/val#L2363 + return $element; + } + }; + + if (!isInputSupported) { + valHooks.input = hooks; + propHooks.value = hooks; + } + if (!isTextareaSupported) { + valHooks.textarea = hooks; + propHooks.value = hooks; + } + + $(function () { + // Look for forms + $(document).delegate('form', 'submit.placeholder', function () { + // Clear the placeholder values so they don't get submitted + var $inputs = $('.placeholder', this).each(clearPlaceholder); + setTimeout(function () { + $inputs.each(setPlaceholder); + }, 10); + }); + }); + + // Clear placeholder values upon page reload + $(window).bind('beforeunload.placeholder', function () { + $('.placeholder').each(function () { + this.value = ''; + }); + }); + + } + + function args(elem) { + // Return an object of element attributes + var newAttrs = {}, + rinlinejQuery = /^jQuery\d+$/; + $.each(elem.attributes, function (i, attr) { + if (attr.specified && !rinlinejQuery.test(attr.name)) { + newAttrs[attr.name] = attr.value; + } + }); + return newAttrs; + } + + function clearPlaceholder(event, value) { + var input = this, + $input = $(input); + if (input.value === $input.attr('placeholder') && $input.hasClass('placeholder')) { + if ($input.data('placeholder-password')) { + $input = $input.hide().next().show().attr('id', $input.removeAttr('id').data('placeholder-id')); + // If `clearPlaceholder` was called from `$.valHooks.input.set` + if (event === true) { + $input[0].value = value; + return value; + } + $input.focus(); + } else { + input.value = ''; + $input.removeClass('placeholder'); + if (input === safeActiveElement()) { + input.select(); + } + } + } + } + + function setPlaceholder() { + var $replacement, + input = this, + $input = $(input), + id = this.id; + if (!input.value) { + if (input.type === 'password') { + if (!$input.data('placeholder-textinput')) { + try { + $replacement = $input.clone().attr({ 'type': 'text' }); + } catch (e) { + $replacement = $('<input>').attr($.extend(args(this), { 'type': 'text' })); + } + $replacement + .removeAttr('name') + .data({ + 'placeholder-password': $input, + 'placeholder-id': id + }) + .bind('focus.placeholder drop.placeholder', clearPlaceholder); + $input + .data({ + 'placeholder-textinput': $replacement, + 'placeholder-id': id + }) + .before($replacement); + } + $input = $input.removeAttr('id').hide().prev().attr('id', id).show(); + // Note: `$input[0] != input` now! + } + $input.addClass('placeholder'); + $input[0].value = $input.attr('placeholder'); + } else { + $input.removeClass('placeholder'); + } + } + + function safeActiveElement() { + // Avoid IE9 `document.activeElement` of death + // https://github.com/mathiasbynens/jquery-placeholder/pull/99 + try { + return document.activeElement; + } catch (err) {} + } + + function changePlaceholder(text) { + var hasArgs = arguments.length, + $input = this; + if (hasArgs) { + if ($input.attr('placeholder') !== text) { + $input.prop('placeholder', text); + if ($input.hasClass('placeholder')) { + $input[0].value = text; + } + } + } + } + +}(jQuery)); diff --git a/resources/src/jquery/jquery.qunit.completenessTest.js b/resources/src/jquery/jquery.qunit.completenessTest.js new file mode 100644 index 00000000..8d38401e --- /dev/null +++ b/resources/src/jquery/jquery.qunit.completenessTest.js @@ -0,0 +1,305 @@ +/** + * jQuery QUnit CompletenessTest 0.4 + * + * Tests the completeness of test suites for object oriented javascript + * libraries. Written to be used in environments with jQuery and QUnit. + * Requires jQuery 1.7.2 or higher. + * + * Built for and tested with: + * - Chrome 19 + * - Firefox 4 + * - Safari 5 + * + * @author Timo Tijhof, 2011-2012 + */ +( function ( mw, $ ) { + 'use strict'; + + var util, + hasOwn = Object.prototype.hasOwnProperty, + log = (window.console && window.console.log) + ? function () { return window.console.log.apply(window.console, arguments); } + : function () {}; + + // Simplified version of a few jQuery methods, except that they don't + // call other jQuery methods. Required to be able to run the CompletenessTest + // on jQuery itself as well. + util = { + keys: Object.keys || function ( object ) { + var key, keys = []; + for ( key in object ) { + if ( hasOwn.call( object, key ) ) { + keys.push( key ); + } + } + return keys; + }, + each: function ( object, callback ) { + var name; + for ( name in object ) { + if ( callback.call( object[ name ], name, object[ name ] ) === false ) { + break; + } + } + }, + // $.type and $.isEmptyObject are safe as is, they don't call + // other $.* methods. Still need to be derefenced into `util` + // since the CompletenessTest will overload them with spies. + type: $.type, + isEmptyObject: $.isEmptyObject + }; + + /** + * CompletenessTest + * @constructor + * + * @example + * var myTester = new CompletenessTest( myLib ); + * @param masterVariable {Object} The root variable that contains all object + * members. CompletenessTest will recursively traverse objects and keep track + * of all methods. + * @param ignoreFn {Function} Optionally pass a function to filter out certain + * methods. Example: You may want to filter out instances of jQuery or some + * other constructor. Otherwise "missingTests" will include all methods that + * were not called from that instance. + */ + function CompletenessTest( masterVariable, ignoreFn ) { + var warn, + that = this; + + // Keep track in these objects. Keyed by strings with the + // method names (ie. 'my.foo', 'my.bar', etc.) values are boolean true. + this.injectionTracker = {}; + this.methodCallTracker = {}; + this.missingTests = {}; + + this.ignoreFn = ignoreFn === undefined ? function () { return false; } : ignoreFn; + + // Lazy limit in case something weird happends (like recurse (part of) ourself). + this.lazyLimit = 2000; + this.lazyCounter = 0; + + // Bind begin and end to QUnit. + QUnit.begin( function () { + // Suppress warnings (e.g. deprecation notices for accessing the properties) + warn = mw.log.warn; + mw.log.warn = $.noop; + + that.walkTheObject( masterVariable, null, masterVariable, [] ); + log( 'CompletenessTest/walkTheObject', that ); + + // Restore warnings + mw.log.warn = warn; + warn = undefined; + }); + + QUnit.done( function () { + that.populateMissingTests(); + log( 'CompletenessTest/populateMissingTests', that ); + + var toolbar, testResults, cntTotal, cntCalled, cntMissing; + + cntTotal = util.keys( that.injectionTracker ).length; + cntCalled = util.keys( that.methodCallTracker ).length; + cntMissing = util.keys( that.missingTests ).length; + + function makeTestResults( blob, title, style ) { + var elOutputWrapper, elTitle, elContainer, elList, elFoot; + + elTitle = document.createElement( 'strong' ); + elTitle.textContent = title || 'Values'; + + elList = document.createElement( 'ul' ); + util.each( blob, function ( key ) { + var elItem = document.createElement( 'li' ); + elItem.textContent = key; + elList.appendChild( elItem ); + }); + + elFoot = document.createElement( 'p' ); + elFoot.innerHTML = '<em>— CompletenessTest</em>'; + + elContainer = document.createElement( 'div' ); + elContainer.appendChild( elTitle ); + elContainer.appendChild( elList ); + elContainer.appendChild( elFoot ); + + elOutputWrapper = document.getElementById( 'qunit-completenesstest' ); + if ( !elOutputWrapper ) { + elOutputWrapper = document.createElement( 'div' ); + elOutputWrapper.id = 'qunit-completenesstest'; + } + elOutputWrapper.appendChild( elContainer ); + + util.each( style, function ( key, value ) { + elOutputWrapper.style[key] = value; + }); + return elOutputWrapper; + } + + if ( cntMissing === 0 ) { + // Good + testResults = makeTestResults( + {}, + 'Detected calls to ' + cntCalled + '/' + cntTotal + ' methods. No missing tests!', + { + backgroundColor: '#D2E0E6', + color: '#366097', + paddingTop: '1em', + paddingRight: '1em', + paddingBottom: '1em', + paddingLeft: '1em' + } + ); + } else { + // Bad + testResults = makeTestResults( + that.missingTests, + 'Detected calls to ' + cntCalled + '/' + cntTotal + ' methods. ' + cntMissing + ' methods not covered:', + { + backgroundColor: '#EE5757', + color: 'black', + paddingTop: '1em', + paddingRight: '1em', + paddingBottom: '1em', + paddingLeft: '1em' + } + ); + } + + toolbar = document.getElementById( 'qunit-testrunner-toolbar' ); + if ( toolbar ) { + toolbar.insertBefore( testResults, toolbar.firstChild ); + } + }); + + return this; + } + + /* Public methods */ + CompletenessTest.fn = CompletenessTest.prototype = { + + /** + * CompletenessTest.fn.walkTheObject + * + * This function recursively walks through the given object, calling itself as it goes. + * Depending on the action it either injects our listener into the methods, or + * reads from our tracker and records which methods have not been called by the test suite. + * + * @param currName {String|Null} Name of the given object member (Initially this is null). + * @param currVar {mixed} The variable to check (initially an object, + * further down it could be anything). + * @param masterVariable {Object} Throughout our interation, always keep track of the master/root. + * Initially this is the same as currVar. + * @param parentPathArray {Array} Array of names that indicate our breadcrumb path starting at + * masterVariable. Not including currName. + */ + walkTheObject: function ( currObj, currName, masterVariable, parentPathArray ) { + var key, currVal, type, + ct = this, + currPathArray = parentPathArray; + + if ( currName ) { + currPathArray.push( currName ); + currVal = currObj[currName]; + } else { + currName = '(root)'; + currVal = currObj; + } + + type = util.type( currVal ); + + // Hard ignores + if ( this.ignoreFn( currVal, this, currPathArray ) ) { + return null; + } + + // Handle the lazy limit + this.lazyCounter++; + if ( this.lazyCounter > this.lazyLimit ) { + log( 'CompletenessTest.fn.walkTheObject> Limit reached: ' + this.lazyCounter, currPathArray ); + return null; + } + + // Functions + if ( type === 'function' ) { + // Don't put a spy in constructor functions as it messes with + // instanceof etc. + if ( !currVal.prototype || util.isEmptyObject( currVal.prototype ) ) { + this.injectionTracker[ currPathArray.join( '.' ) ] = true; + this.injectCheck( currObj, currName, function () { + ct.methodCallTracker[ currPathArray.join( '.' ) ] = true; + } ); + } + } + + // Recursively. After all, this is the *completeness* test + // This also traverses static properties and the prototype of a constructor + if ( type === 'object' || type === 'function' ) { + for ( key in currVal ) { + if ( hasOwn.call( currVal, key ) ) { + this.walkTheObject( currVal, key, masterVariable, currPathArray.slice() ); + } + } + } + }, + + populateMissingTests: function () { + var ct = this; + util.each( ct.injectionTracker, function ( key ) { + ct.hasTest( key ); + }); + }, + + /** + * CompletenessTest.fn.hasTest + * + * Checks if the given method name (ie. 'my.foo.bar') + * was called during the test suite (as far as the tracker knows). + * If not it adds it to missingTests. + * + * @param fnName {String} + * @return {Boolean} + */ + hasTest: function ( fnName ) { + if ( !( fnName in this.methodCallTracker ) ) { + this.missingTests[fnName] = true; + return false; + } + return true; + }, + + /** + * CompletenessTest.fn.injectCheck + * + * Injects a function (such as a spy that updates methodCallTracker when + * it's called) inside another function. + * + * @param masterVariable {Object} + * @param objectPathArray {Array} + * @param injectFn {Function} + */ + injectCheck: function ( obj, key, injectFn ) { + var spy, + val = obj[ key ]; + + spy = function () { + injectFn(); + return val.apply( this, arguments ); + }; + + // Make the spy inherit from the original so that its static methods are also + // visible in the spy (e.g. when we inject a check into mw.log, mw.log.warn + // must remain accessible). + /*jshint proto:true */ + spy.__proto__ = val; + + // Objects are by reference, members (unless objects) are not. + obj[ key ] = spy; + } + }; + + /* Expose */ + window.CompletenessTest = CompletenessTest; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/jquery/jquery.spinner.css b/resources/src/jquery/jquery.spinner.css new file mode 100644 index 00000000..a9e06dbe --- /dev/null +++ b/resources/src/jquery/jquery.spinner.css @@ -0,0 +1,40 @@ +.mw-spinner { + background-color: transparent; + background-position: center center; + background-repeat: no-repeat; +} + +.mw-spinner-small { + /* @embed */ + background-image: url(images/spinner.gif); + height: 20px; + width: 20px; + /* Avoid issues with .mw-spinner-block when floated without width. */ + min-width: 20px; +} + +.mw-spinner-large { + /* @embed */ + background-image: url(images/spinner-large.gif); + height: 32px; + width: 32px; + /* Avoid issues with .mw-spinner-block when floated without width. */ + min-width: 32px; +} + +.mw-spinner-block { + display: block; + /* This overrides width from .mw-spinner-large / .mw-spinner-small, + * This is where the min-width kicks in. + */ + width: 100%; +} + +.mw-spinner-inline { + display: inline-block; + vertical-align: middle; + + /* IE < 8 */ + zoom: 1; + *display: inline; +} diff --git a/resources/src/jquery/jquery.spinner.js b/resources/src/jquery/jquery.spinner.js new file mode 100644 index 00000000..361d3e08 --- /dev/null +++ b/resources/src/jquery/jquery.spinner.js @@ -0,0 +1,112 @@ +/** + * jQuery Spinner + * + * Simple jQuery plugin to create, inject and remove spinners. + * + * @class jQuery.plugin.spinner + */ +( function ( $ ) { + + // Default options for new spinners, + // stored outside the function to share between calls. + var defaults = { + id: undefined, + size: 'small', + type: 'inline' + }; + + $.extend( { + /** + * Create a spinner element + * + * The argument is an object with options used to construct the spinner (see below). + * + * It is a good practice to keep a reference to the created spinner to be able to remove it + * later. Alternatively, one can use the 'id' option and #removeSpinner (but make sure to choose + * an id that's unlikely to cause conflicts, e.g. with extensions, gadgets or user scripts). + * + * CSS classes used: + * + * - .mw-spinner for every spinner + * - .mw-spinner-small / .mw-spinner-large for size + * - .mw-spinner-block / .mw-spinner-inline for display types + * + * Example: + * + * // Create a large spinner reserving all available horizontal space. + * var $spinner = $.createSpinner( { size: 'large', type: 'block' } ); + * // Insert above page content. + * $( '#mw-content-text' ).prepend( $spinner ); + * + * // Place a small inline spinner next to the "Save" button + * var $spinner = $.createSpinner( { size: 'small', type: 'inline' } ); + * // Alternatively, just `$.createSpinner();` as these are the default options. + * $( '#wpSave' ).after( $spinner ); + * + * // The following two are equivalent: + * $.createSpinner( 'magic' ); + * $.createSpinner( { id: 'magic' } ); + * + * @static + * @inheritable + * @param {Object|string} [opts] Options. If a string is given, it will be treated as the value + * of the `id` option. If an object is given, the possible option keys are: + * @param {string} [opts.id] If given, spinner will be given an id of "mw-spinner-{id}". + * @param {string} [opts.size='small'] 'small' or 'large' for a 20-pixel or 32-pixel spinner. + * @param {string} [opts.type='inline'] 'inline' or 'block'. Inline creates an inline-block with + * width and height equal to spinner size. Block is a block-level element with width 100%, + * height equal to spinner size. + * @return {jQuery} + */ + createSpinner: function ( opts ) { + if ( opts !== undefined && $.type( opts ) !== 'object' ) { + opts = { + id: opts + }; + } + + opts = $.extend( {}, defaults, opts ); + + var $spinner = $( '<div>', { 'class': 'mw-spinner', 'title': '...' } ); + if ( opts.id !== undefined ) { + $spinner.attr( 'id', 'mw-spinner-' + opts.id ); + } + + $spinner.addClass( opts.size === 'large' ? 'mw-spinner-large' : 'mw-spinner-small' ); + $spinner.addClass( opts.type === 'block' ? 'mw-spinner-block' : 'mw-spinner-inline' ); + + return $spinner; + }, + + /** + * Remove a spinner element + * + * @static + * @inheritable + * @param {string} id Id of the spinner, as passed to #createSpinner + * @return {jQuery} The (now detached) spinner element + */ + removeSpinner: function ( id ) { + return $( '#mw-spinner-' + id ).remove(); + } + } ); + + /** + * Inject a spinner after each element in the collection + * + * Inserts spinner as siblings (not children) of the target elements. + * Collection contents remain unchanged. + * + * @param {Object|string} [opts] See #createSpinner + * @return {jQuery} + */ + $.fn.injectSpinner = function ( opts ) { + return this.after( $.createSpinner( opts ) ); + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.spinner + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.suggestions.css b/resources/src/jquery/jquery.suggestions.css new file mode 100644 index 00000000..15cd9264 --- /dev/null +++ b/resources/src/jquery/jquery.suggestions.css @@ -0,0 +1,76 @@ +/* suggestions plugin */ + +.suggestions { + overflow: hidden; + position: absolute; + top: 0; + left: 0; + width: 0; + border: none; + z-index: 1099; + padding: 0; + margin: -1px 0 0 0; +} + +.suggestions-special { + position: relative; + background-color: white; + cursor: pointer; + border: solid 1px #aaaaaa; + padding: 0; + margin: 0; + margin-top: -2px; + display: none; + padding: 0.25em 0.25em; + line-height: 1.25em; +} + +.suggestions-results { + background-color: white; + cursor: pointer; + border: solid 1px #aaaaaa; + padding: 0; + margin: 0; +} + +.suggestions-result { + color: black; + margin: 0; + line-height: 1.5em; + padding: 0.01em 0.25em; + text-align: left; + /* Apply ellipsis to suggestions */ + overflow: hidden; + -o-text-overflow: ellipsis; /* Opera 9 to 10 */ + text-overflow: ellipsis; + white-space: nowrap; +} + +.suggestions-result-current { + background-color: #4C59A6; + color: white; +} + +.suggestions-special .special-label { + color: gray; + text-align: left; +} + +.suggestions-special .special-query { + color: black; + font-style: italic; + text-align: left; +} + +.suggestions-special .special-hover { + background-color: silver; +} + +.suggestions-result-current .special-label, +.suggestions-result-current .special-query { + color: white; +} + +.highlight { + font-weight: bold; +} diff --git a/resources/src/jquery/jquery.suggestions.js b/resources/src/jquery/jquery.suggestions.js new file mode 100644 index 00000000..3369cde2 --- /dev/null +++ b/resources/src/jquery/jquery.suggestions.js @@ -0,0 +1,684 @@ +/** + * This plugin provides a generic way to add suggestions to a text box. + * + * Usage: + * + * Set options: + * $( '#textbox' ).suggestions( { option1: value1, option2: value2 } ); + * $( '#textbox' ).suggestions( option, value ); + * Get option: + * value = $( '#textbox' ).suggestions( option ); + * Initialize: + * $( '#textbox' ).suggestions(); + * + * Options: + * + * fetch(query): Callback that should fetch suggestions and set the suggestions property. + * Executed in the context of the textbox + * Type: Function + * cancel: Callback function to call when any pending asynchronous suggestions fetches + * should be canceled. Executed in the context of the textbox + * Type: Function + * special: Set of callbacks for rendering and selecting + * Type: Object of Functions 'render' and 'select' + * result: Set of callbacks for rendering and selecting + * Type: Object of Functions 'render' and 'select' + * $region: jQuery selection of element to place the suggestions below and match width of + * Type: jQuery Object, Default: $( this ) + * suggestions: Suggestions to display + * Type: Array of strings + * maxRows: Maximum number of suggestions to display at one time + * Type: Number, Range: 1 - 100, Default: 7 + * delay: Number of ms to wait for the user to stop typing + * Type: Number, Range: 0 - 1200, Default: 120 + * cache: Whether to cache results from a fetch + * Type: Boolean, Default: false + * cacheMaxAge: Number of ms to cache results from a fetch + * Type: Number, Range: 1 - Infinity, Default: 60000 (1 minute) + * submitOnClick: Whether to submit the form containing the textbox when a suggestion is clicked + * Type: Boolean, Default: false + * maxExpandFactor: Maximum suggestions box width relative to the textbox width. If set + * to e.g. 2, the suggestions box will never be grown beyond 2 times the width of the textbox. + * Type: Number, Range: 1 - infinity, Default: 3 + * expandFrom: Which direction to offset the suggestion box from. + * Values 'start' and 'end' translate to left and right respectively depending on the + * directionality of the current document, according to $( 'html' ).css( 'direction' ). + * Type: String, default: 'auto', options: 'left', 'right', 'start', 'end', 'auto'. + * positionFromLeft: Sets expandFrom=left, for backwards compatibility + * Type: Boolean, Default: true + * highlightInput: Whether to hightlight matched portions of the input or not + * Type: Boolean, Default: false + */ +( function ( $ ) { + +var hasOwn = Object.hasOwnProperty; + +$.suggestions = { + /** + * Cancel any delayed maybeFetch() call and callback the context so + * they can cancel any async fetching if they use AJAX or something. + */ + cancel: function ( context ) { + if ( context.data.timerID !== null ) { + clearTimeout( context.data.timerID ); + } + if ( $.isFunction( context.config.cancel ) ) { + context.config.cancel.call( context.data.$textbox ); + } + }, + + /** + * Hide the element with suggestions and clean up some state. + */ + hide: function ( context ) { + // Remove any highlights, including on "special" items + context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' ); + // Hide the container + context.data.$container.hide(); + }, + + /** + * Restore the text the user originally typed in the textbox, before it + * was overwritten by highlight(). This restores the value the currently + * displayed suggestions are based on, rather than the value just before + * highlight() overwrote it; the former is arguably slightly more sensible. + */ + restore: function ( context ) { + context.data.$textbox.val( context.data.prevText ); + }, + + /** + * Ask the user-specified callback for new suggestions. Any previous delayed + * call to this function still pending will be canceled. If the value in the + * textbox is empty or hasn't changed since the last time suggestions were fetched, + * this function does nothing. + * @param {Boolean} delayed Whether or not to delay this by the currently configured amount of time + */ + update: function ( context, delayed ) { + function maybeFetch() { + var val = context.data.$textbox.val(), + cache = context.data.cache, + cacheHit; + + // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden + // if the textbox is empty then clear the result div, but leave other settings intouched + if ( val.length === 0 ) { + $.suggestions.hide( context ); + context.data.prevText = ''; + } else if ( + val !== context.data.prevText || + !context.data.$container.is( ':visible' ) + ) { + context.data.prevText = val; + // Try cache first + if ( context.config.cache && hasOwn.call( cache, val ) ) { + if ( +new Date() - cache[ val ].timestamp < context.config.cacheMaxAge ) { + context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions ); + cacheHit = true; + } else { + // Cache expired + delete cache[ val ]; + } + } + if ( !cacheHit && typeof context.config.fetch === 'function' ) { + context.config.fetch.call( + context.data.$textbox, + val, + function ( suggestions ) { + context.data.$textbox.suggestions( 'suggestions', suggestions ); + if ( context.config.cache ) { + cache[ val ] = { + suggestions: suggestions, + timestamp: +new Date() + }; + } + } + ); + } + } + + // Always update special rendering + $.suggestions.special( context ); + } + + // Cancels any delayed maybeFetch call, and invokes context.config.cancel. + $.suggestions.cancel( context ); + + if ( delayed ) { + // To avoid many started/aborted requests while typing, we're gonna take a short + // break before trying to fetch data. + context.data.timerID = setTimeout( maybeFetch, context.config.delay ); + } else { + maybeFetch(); + } + }, + + special: function ( context ) { + // Allow custom rendering - but otherwise don't do any rendering + if ( typeof context.config.special.render === 'function' ) { + // Wait for the browser to update the value + setTimeout( function () { + // Render special + var $special = context.data.$container.find( '.suggestions-special' ); + context.config.special.render.call( $special, context.data.$textbox.val(), context ); + }, 1 ); + } + }, + + /** + * Sets the value of a property, and updates the widget accordingly + * @param property String Name of property + * @param value Mixed Value to set property with + */ + configure: function ( context, property, value ) { + var newCSS, + $result, $results, $spanForWidth, childrenWidth, + i, expWidth, maxWidth, text; + + // Validate creation using fallback values + switch ( property ) { + case 'fetch': + case 'cancel': + case 'special': + case 'result': + case '$region': + case 'expandFrom': + context.config[property] = value; + break; + case 'suggestions': + context.config[property] = value; + // Update suggestions + if ( context.data !== undefined ) { + if ( context.data.$textbox.val().length === 0 ) { + // Hide the div when no suggestion exist + $.suggestions.hide( context ); + } else { + // Rebuild the suggestions list + context.data.$container.show(); + // Update the size and position of the list + newCSS = { + top: context.config.$region.offset().top + context.config.$region.outerHeight(), + bottom: 'auto', + width: context.config.$region.outerWidth(), + height: 'auto' + }; + + // Process expandFrom, after this it is set to left or right. + context.config.expandFrom = ( function ( expandFrom ) { + var regionWidth, docWidth, regionCenter, docCenter, + docDir = $( document.documentElement ).css( 'direction' ), + $region = context.config.$region; + + // Backwards compatible + if ( context.config.positionFromLeft ) { + expandFrom = 'left'; + + // Catch invalid values, default to 'auto' + } else if ( $.inArray( expandFrom, ['left', 'right', 'start', 'end', 'auto'] ) === -1 ) { + expandFrom = 'auto'; + } + + if ( expandFrom === 'auto' ) { + if ( $region.data( 'searchsuggest-expand-dir' ) ) { + // If the markup explicitly contains a direction, use it. + expandFrom = $region.data( 'searchsuggest-expand-dir' ); + } else { + regionWidth = $region.outerWidth(); + docWidth = $( document ).width(); + if ( regionWidth > ( 0.85 * docWidth ) ) { + // If the input size takes up more than 85% of the document horizontally + // expand the suggestions to the writing direction's native end. + expandFrom = 'start'; + } else { + // Calculate the center points of the input and document + regionCenter = $region.offset().left + regionWidth / 2; + docCenter = docWidth / 2; + if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) { + // If the input's center is within 10% of the document center + // use the writing direction's native end. + expandFrom = 'start'; + } else { + // Otherwise expand the input from the closest side of the page, + // towards the side of the page with the most free open space + expandFrom = regionCenter > docCenter ? 'right' : 'left'; + } + } + } + } + + if ( expandFrom === 'start' ) { + expandFrom = docDir === 'rtl' ? 'right' : 'left'; + + } else if ( expandFrom === 'end' ) { + expandFrom = docDir === 'rtl' ? 'left' : 'right'; + } + + return expandFrom; + + }( context.config.expandFrom ) ); + + if ( context.config.expandFrom === 'left' ) { + // Expand from left + newCSS.left = context.config.$region.offset().left; + newCSS.right = 'auto'; + } else { + // Expand from right + newCSS.left = 'auto'; + newCSS.right = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() ); + } + + context.data.$container.css( newCSS ); + $results = context.data.$container.children( '.suggestions-results' ); + $results.empty(); + expWidth = -1; + for ( i = 0; i < context.config.suggestions.length; i++ ) { + /*jshint loopfunc:true */ + text = context.config.suggestions[i]; + $result = $( '<div>' ) + .addClass( 'suggestions-result' ) + .attr( 'rel', i ) + .data( 'text', context.config.suggestions[i] ) + .mousemove( function () { + context.data.selectedWithMouse = true; + $.suggestions.highlight( + context, + $( this ).closest( '.suggestions-results .suggestions-result' ), + false + ); + } ) + .appendTo( $results ); + // Allow custom rendering + if ( typeof context.config.result.render === 'function' ) { + context.config.result.render.call( $result, context.config.suggestions[i], context ); + } else { + $result.text( text ); + } + + if ( context.config.highlightInput ) { + $result.highlightText( context.data.prevText ); + } + + // Widen results box if needed (new width is only calculated here, applied later). + + // The monstrosity below accomplishes two things: + // * Wraps the text contents in a DOM element, so that we can know its width. There is + // no way to directly access the width of a text node, and we can't use the parent + // node width as it has text-overflow: ellipsis; and overflow: hidden; applied to + // it, which trims it to a smaller width. + // * Temporarily applies position: absolute; to the wrapper to pull it out of normal + // document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden; + // rules would cause some browsers (at least all versions of IE from 6 to 11) to + // still report the "trimmed" width. This should not be done in regular CSS + // stylesheets as we don't want this rule to apply to other <span> elements, like + // the ones generated by jquery.highlightText. + $spanForWidth = $result.wrapInner( '<span>' ).children(); + childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth(); + $spanForWidth.contents().unwrap(); + + if ( childrenWidth > $result.width() && childrenWidth > expWidth ) { + // factor in any padding, margin, or border space on the parent + expWidth = childrenWidth + ( context.data.$container.width() - $result.width() ); + } + } + + // Apply new width for results box, if any + if ( expWidth > context.data.$container.width() ) { + maxWidth = context.config.maxExpandFactor * context.data.$textbox.width(); + context.data.$container.width( Math.min( expWidth, maxWidth ) ); + } + } + } + break; + case 'maxRows': + context.config[property] = Math.max( 1, Math.min( 100, value ) ); + break; + case 'delay': + context.config[property] = Math.max( 0, Math.min( 1200, value ) ); + break; + case 'cacheMaxAge': + context.config[property] = Math.max( 1, value ); + break; + case 'maxExpandFactor': + context.config[property] = Math.max( 1, value ); + break; + case 'cache': + case 'submitOnClick': + case 'positionFromLeft': + case 'highlightInput': + context.config[property] = !!value; + break; + } + }, + + /** + * Highlight a result in the results table + * @param result <tr> to highlight: jQuery object, or 'prev' or 'next' + * @param updateTextbox If true, put the suggestion in the textbox + */ + highlight: function ( context, result, updateTextbox ) { + var selected = context.data.$container.find( '.suggestions-result-current' ); + if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) { + if ( result === 'prev' ) { + if ( selected.hasClass( 'suggestions-special' ) ) { + result = context.data.$container.find( '.suggestions-result:last' ); + } else { + result = selected.prev(); + if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { + // there is something in the DOM between selected element and the wrapper, bypass it + result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 ); + } + + if ( selected.length === 0 ) { + // we are at the beginning, so lets jump to the last item + if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) { + result = context.data.$container.find( '.suggestions-special' ); + } else { + result = context.data.$container.find( '.suggestions-results .suggestions-result:last' ); + } + } + } + } else if ( result === 'next' ) { + if ( selected.length === 0 ) { + // No item selected, go to the first one + result = context.data.$container.find( '.suggestions-results .suggestions-result:first' ); + if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) { + // No suggestion exists, go to the special one directly + result = context.data.$container.find( '.suggestions-special' ); + } + } else { + result = selected.next(); + if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { + // there is something in the DOM between selected element and the wrapper, bypass it + result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 ); + } + + if ( selected.hasClass( 'suggestions-special' ) ) { + result = $( [] ); + } else if ( + result.length === 0 && + context.data.$container.find( '.suggestions-special' ).html() !== '' + ) { + // We were at the last item, jump to the specials! + result = context.data.$container.find( '.suggestions-special' ); + } + } + } + selected.removeClass( 'suggestions-result-current' ); + result.addClass( 'suggestions-result-current' ); + } + if ( updateTextbox ) { + if ( result.length === 0 || result.is( '.suggestions-special' ) ) { + $.suggestions.restore( context ); + } else { + context.data.$textbox.val( result.data( 'text' ) ); + // .val() doesn't call any event handlers, so + // let the world know what happened + context.data.$textbox.change(); + } + context.data.$textbox.trigger( 'change' ); + } + }, + + /** + * Respond to keypress event + * @param key Integer Code of key pressed + */ + keypress: function ( e, context, key ) { + var selected, + wasVisible = context.data.$container.is( ':visible' ), + preventDefault = false; + + switch ( key ) { + // Arrow down + case 40: + if ( wasVisible ) { + $.suggestions.highlight( context, 'next', true ); + context.data.selectedWithMouse = false; + } else { + $.suggestions.update( context, false ); + } + preventDefault = true; + break; + // Arrow up + case 38: + if ( wasVisible ) { + $.suggestions.highlight( context, 'prev', true ); + context.data.selectedWithMouse = false; + } + preventDefault = wasVisible; + break; + // Escape + case 27: + $.suggestions.hide( context ); + $.suggestions.restore( context ); + $.suggestions.cancel( context ); + context.data.$textbox.trigger( 'change' ); + preventDefault = wasVisible; + break; + // Enter + case 13: + preventDefault = wasVisible; + selected = context.data.$container.find( '.suggestions-result-current' ); + $.suggestions.hide( context ); + if ( selected.length === 0 || context.data.selectedWithMouse ) { + // If nothing is selected or if something was selected with the mouse + // cancel any current requests and allow the form to be submitted + // (simply don't prevent default behavior). + $.suggestions.cancel( context ); + preventDefault = false; + } else if ( selected.is( '.suggestions-special' ) ) { + if ( typeof context.config.special.select === 'function' ) { + // Allow the callback to decide whether to prevent default or not + if ( context.config.special.select.call( selected, context.data.$textbox ) === true ) { + preventDefault = false; + } + } + } else { + $.suggestions.highlight( context, selected, true ); + + if ( typeof context.config.result.select === 'function' ) { + // Allow the callback to decide whether to prevent default or not + if ( context.config.result.select.call( selected, context.data.$textbox ) === true ) { + preventDefault = false; + } + } + } + break; + default: + $.suggestions.update( context, true ); + break; + } + if ( preventDefault ) { + e.preventDefault(); + e.stopPropagation(); + } + } +}; +$.fn.suggestions = function () { + + // Multi-context fields + var returnValue, + args = arguments; + + $( this ).each( function () { + var context, key; + + /* Construction / Loading */ + + context = $( this ).data( 'suggestions-context' ); + if ( context === undefined || context === null ) { + context = { + config: { + fetch: function () {}, + cancel: function () {}, + special: {}, + result: {}, + $region: $( this ), + suggestions: [], + maxRows: 7, + delay: 120, + cache: false, + cacheMaxAge: 60000, + submitOnClick: false, + maxExpandFactor: 3, + expandFrom: 'auto', + highlightInput: false + } + }; + } + + /* API */ + + // Handle various calling styles + if ( args.length > 0 ) { + if ( typeof args[0] === 'object' ) { + // Apply set of properties + for ( key in args[0] ) { + $.suggestions.configure( context, key, args[0][key] ); + } + } else if ( typeof args[0] === 'string' ) { + if ( args.length > 1 ) { + // Set property values + $.suggestions.configure( context, args[0], args[1] ); + } else if ( returnValue === null || returnValue === undefined ) { + // Get property values, but don't give access to internal data - returns only the first + returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] ); + } + } + } + + /* Initialization */ + + if ( context.data === undefined ) { + context.data = { + // ID of running timer + timerID: null, + + // Text in textbox when suggestions were last fetched + prevText: null, + + // Cache of fetched suggestions + cache: {}, + + // Number of results visible without scrolling + visibleResults: 0, + + // Suggestion the last mousedown event occurred on + mouseDownOn: $( [] ), + $textbox: $( this ), + selectedWithMouse: false + }; + + context.data.$container = $( '<div>' ) + .css( 'display', 'none' ) + .addClass( 'suggestions' ) + .append( + $( '<div>' ).addClass( 'suggestions-results' ) + // Can't use click() because the container div is hidden when the + // textbox loses focus. Instead, listen for a mousedown followed + // by a mouseup on the same div. + .mousedown( function ( e ) { + context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results .suggestions-result' ); + } ) + .mouseup( function ( e ) { + var $result = $( e.target ).closest( '.suggestions-results .suggestions-result' ), + $other = context.data.mouseDownOn; + + context.data.mouseDownOn = $( [] ); + if ( $result.get( 0 ) !== $other.get( 0 ) ) { + return; + } + // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click). + if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { + $.suggestions.highlight( context, $result, true ); + if ( typeof context.config.result.select === 'function' ) { + context.config.result.select.call( $result, context.data.$textbox ); + } + // This will hide the link we're just clicking on, which causes problems + // when done synchronously in at least Firefox 3.6 (bug 62858). + setTimeout( function () { + $.suggestions.hide( context ); + }, 0 ); + } + // Always bring focus to the textbox, as that's probably where the user expects it + // if they were just typing. + context.data.$textbox.focus(); + } ) + ) + .append( + $( '<div>' ).addClass( 'suggestions-special' ) + // Can't use click() because the container div is hidden when the + // textbox loses focus. Instead, listen for a mousedown followed + // by a mouseup on the same div. + .mousedown( function ( e ) { + context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' ); + } ) + .mouseup( function ( e ) { + var $special = $( e.target ).closest( '.suggestions-special' ), + $other = context.data.mouseDownOn; + + context.data.mouseDownOn = $( [] ); + if ( $special.get( 0 ) !== $other.get( 0 ) ) { + return; + } + // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click). + if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { + if ( typeof context.config.special.select === 'function' ) { + context.config.special.select.call( $special, context.data.$textbox ); + } + // This will hide the link we're just clicking on, which causes problems + // when done synchronously in at least Firefox 3.6 (bug 62858). + setTimeout( function () { + $.suggestions.hide( context ); + }, 0 ); + } + // Always bring focus to the textbox, as that's probably where the user expects it + // if they were just typing. + context.data.$textbox.focus(); + } ) + .mousemove( function ( e ) { + context.data.selectedWithMouse = true; + $.suggestions.highlight( + context, $( e.target ).closest( '.suggestions-special' ), false + ); + } ) + ) + .appendTo( $( 'body' ) ); + + $( this ) + // Stop browser autocomplete from interfering + .attr( 'autocomplete', 'off' ) + .keydown( function ( e ) { + // Store key pressed to handle later + context.data.keypressed = e.which; + context.data.keypressedCount = 0; + } ) + .keypress( function ( e ) { + context.data.keypressedCount++; + $.suggestions.keypress( e, context, context.data.keypressed ); + } ) + .keyup( function ( e ) { + // Some browsers won't throw keypress() for arrow keys. If we got a keydown and a keyup without a + // keypress in between, solve it + if ( context.data.keypressedCount === 0 ) { + $.suggestions.keypress( e, context, context.data.keypressed ); + } + } ) + .blur( function () { + // When losing focus because of a mousedown + // on a suggestion, don't hide the suggestions + if ( context.data.mouseDownOn.length > 0 ) { + return; + } + $.suggestions.hide( context ); + $.suggestions.cancel( context ); + } ); + } + + // Store the context for next time + $( this ).data( 'suggestions-context', context ); + } ); + return returnValue !== undefined ? returnValue : $( this ); +}; + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.tabIndex.js b/resources/src/jquery/jquery.tabIndex.js new file mode 100644 index 00000000..46cc8f2c --- /dev/null +++ b/resources/src/jquery/jquery.tabIndex.js @@ -0,0 +1,57 @@ +/** + * @class jQuery.plugin.tabIndex + */ +( function ( $ ) { + + /** + * Find the lowest tabindex in use within a selection. + * + * @return {number} Lowest tabindex on the page + */ + $.fn.firstTabIndex = function () { + var minTabIndex = null; + $(this).find( '[tabindex]' ).each( function () { + var tabIndex = parseInt( $(this).prop( 'tabindex' ), 10 ); + // In IE6/IE7 the above jQuery selector returns all elements, + // becuase it has a default value for tabIndex in IE6/IE7 of 0 + // (rather than null/undefined). Therefore check "> 0" as well. + // Under IE7 under Windows NT 5.2 is also capable of returning NaN. + if ( tabIndex > 0 && !isNaN( tabIndex ) ) { + // Initial value + if ( minTabIndex === null ) { + minTabIndex = tabIndex; + } else if ( tabIndex < minTabIndex ) { + minTabIndex = tabIndex; + } + } + } ); + return minTabIndex; + }; + + /** + * Find the highest tabindex in use within a selection. + * + * @return {number} Highest tabindex on the page + */ + $.fn.lastTabIndex = function () { + var maxTabIndex = null; + $(this).find( '[tabindex]' ).each( function () { + var tabIndex = parseInt( $(this).prop( 'tabindex' ), 10 ); + if ( tabIndex > 0 && !isNaN( tabIndex ) ) { + // Initial value + if ( maxTabIndex === null ) { + maxTabIndex = tabIndex; + } else if ( tabIndex > maxTabIndex ) { + maxTabIndex = tabIndex; + } + } + } ); + return maxTabIndex; + }; + + /** + * @class jQuery + * @mixins jQuery.plugin.tabIndex + */ + +}( jQuery ) ); diff --git a/resources/src/jquery/jquery.tablesorter.css b/resources/src/jquery/jquery.tablesorter.css new file mode 100644 index 00000000..a88acc09 --- /dev/null +++ b/resources/src/jquery/jquery.tablesorter.css @@ -0,0 +1,17 @@ +/* Table Sorting */ +table.jquery-tablesorter th.headerSort { + /* @embed */ + background-image: url(images/sort_both.gif); + cursor: pointer; + background-repeat: no-repeat; + background-position: center right; + padding-right: 21px; +} +table.jquery-tablesorter th.headerSortUp { + /* @embed */ + background-image: url(images/sort_up.gif); +} +table.jquery-tablesorter th.headerSortDown { + /* @embed */ + background-image: url(images/sort_down.gif); +} diff --git a/resources/src/jquery/jquery.tablesorter.js b/resources/src/jquery/jquery.tablesorter.js new file mode 100644 index 00000000..ea2c5f92 --- /dev/null +++ b/resources/src/jquery/jquery.tablesorter.js @@ -0,0 +1,1161 @@ +/** + * TableSorter for MediaWiki + * + * Written 2011 Leo Koppelkamm + * Based on tablesorter.com plugin, written (c) 2007 Christian Bach. + * + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + * + * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgContentLanguage) + * and mw.language.months. + * + * Uses 'tableSorterCollation' in mw.config (if available) + */ +/** + * + * @description Create a sortable table with multi-column sorting capabilitys + * + * @example $( 'table' ).tablesorter(); + * @desc Create a simple tablesorter interface. + * + * @example $( 'table' ).tablesorter( { sortList: [ { 0: 'desc' }, { 1: 'asc' } ] } ); + * @desc Create a tablesorter interface initially sorting on the first and second column. + * + * @option String cssHeader ( optional ) A string of the class name to be appended + * to sortable tr elements in the thead of the table. Default value: + * "header" + * + * @option String cssAsc ( optional ) A string of the class name to be appended to + * sortable tr elements in the thead on a ascending sort. Default value: + * "headerSortUp" + * + * @option String cssDesc ( optional ) A string of the class name to be appended + * to sortable tr elements in the thead on a descending sort. Default + * value: "headerSortDown" + * + * @option String sortInitialOrder ( optional ) A string of the inital sorting + * order can be asc or desc. Default value: "asc" + * + * @option String sortMultisortKey ( optional ) A string of the multi-column sort + * key. Default value: "shiftKey" + * + * @option Boolean sortLocaleCompare ( optional ) Boolean flag indicating whatever + * to use String.localeCampare method or not. Set to false. + * + * @option Boolean cancelSelection ( optional ) Boolean flag indicating if + * tablesorter should cancel selection of the table headers text. + * Default value: true + * + * @option Array sortList ( optional ) An array containing objects specifying sorting. + * By passing more than one object, multi-sorting will be applied. Object structure: + * { <Integer column index>: <String 'asc' or 'desc'> } + * Default value: [] + * + * @option Boolean debug ( optional ) Boolean flag indicating if tablesorter + * should display debuging information usefull for development. + * + * @event sortEnd.tablesorter: Triggered as soon as any sorting has been applied. + * + * @type jQuery + * + * @name tablesorter + * + * @cat Plugins/Tablesorter + * + * @author Christian Bach/christian.bach@polyester.se + */ + +( function ( $, mw ) { + /* Local scope */ + + var ts, + parsers = []; + + /* Parser utility functions */ + + function getParserById( name ) { + var i, + len = parsers.length; + for ( i = 0; i < len; i++ ) { + if ( parsers[i].id.toLowerCase() === name.toLowerCase() ) { + return parsers[i]; + } + } + return false; + } + + function getElementSortKey( node ) { + var $node = $( node ), + // Use data-sort-value attribute. + // Use data() instead of attr() so that live value changes + // are processed as well (bug 38152). + data = $node.data( 'sortValue' ); + + if ( data !== null && data !== undefined ) { + // Cast any numbers or other stuff to a string, methods + // like charAt, toLowerCase and split are expected. + return String( data ); + } else { + if ( !node ) { + return $node.text(); + } else if ( node.tagName.toLowerCase() === 'img' ) { + return $node.attr( 'alt' ) || ''; // handle undefined alt + } else { + return $.map( $.makeArray( node.childNodes ), function ( elem ) { + // 1 is for document.ELEMENT_NODE (the constant is undefined on old browsers) + if ( elem.nodeType === 1 ) { + return getElementSortKey( elem ); + } else { + return $.text( elem ); + } + } ).join( '' ); + } + } + } + + function detectParserForColumn( table, rows, cellIndex ) { + var l = parsers.length, + nodeValue, + // Start with 1 because 0 is the fallback parser + i = 1, + rowIndex = 0, + concurrent = 0, + needed = ( rows.length > 4 ) ? 5 : rows.length; + + while ( i < l ) { + if ( rows[rowIndex] && rows[rowIndex].cells[cellIndex] ) { + nodeValue = $.trim( getElementSortKey( rows[rowIndex].cells[cellIndex] ) ); + } else { + nodeValue = ''; + } + + if ( nodeValue !== '' ) { + if ( parsers[i].is( nodeValue, table ) ) { + concurrent++; + rowIndex++; + if ( concurrent >= needed ) { + // Confirmed the parser for multiple cells, let's return it + return parsers[i]; + } + } else { + // Check next parser, reset rows + i++; + rowIndex = 0; + concurrent = 0; + } + } else { + // Empty cell + rowIndex++; + if ( rowIndex > rows.length ) { + rowIndex = 0; + i++; + } + } + } + + // 0 is always the generic parser (text) + return parsers[0]; + } + + function buildParserCache( table, $headers ) { + var sortType, cells, len, i, parser, + rows = table.tBodies[0].rows, + parsers = []; + + if ( rows[0] ) { + + cells = rows[0].cells; + len = cells.length; + + for ( i = 0; i < len; i++ ) { + parser = false; + sortType = $headers.eq( i ).data( 'sortType' ); + if ( sortType !== undefined ) { + parser = getParserById( sortType ); + } + + if ( parser === false ) { + parser = detectParserForColumn( table, rows, i ); + } + + parsers.push( parser ); + } + } + return parsers; + } + + /* Other utility functions */ + + function buildCache( table ) { + var i, j, $row, cols, + totalRows = ( table.tBodies[0] && table.tBodies[0].rows.length ) || 0, + totalCells = ( table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length ) || 0, + parsers = table.config.parsers, + cache = { + row: [], + normalized: [] + }; + + for ( i = 0; i < totalRows; ++i ) { + + // Add the table data to main data array + $row = $( table.tBodies[0].rows[i] ); + cols = []; + + // if this is a child row, add it to the last row's children and + // continue to the next row + if ( $row.hasClass( table.config.cssChildRow ) ) { + cache.row[cache.row.length - 1] = cache.row[cache.row.length - 1].add( $row ); + // go to the next for loop + continue; + } + + cache.row.push( $row ); + + for ( j = 0; j < totalCells; ++j ) { + cols.push( parsers[j].format( getElementSortKey( $row[0].cells[j] ), table, $row[0].cells[j] ) ); + } + + cols.push( cache.normalized.length ); // add position for rowCache + cache.normalized.push( cols ); + cols = null; + } + + return cache; + } + + function appendToTable( table, cache ) { + var i, pos, l, j, + row = cache.row, + normalized = cache.normalized, + totalRows = normalized.length, + checkCell = ( normalized[0].length - 1 ), + fragment = document.createDocumentFragment(); + + for ( i = 0; i < totalRows; i++ ) { + pos = normalized[i][checkCell]; + + l = row[pos].length; + + for ( j = 0; j < l; j++ ) { + fragment.appendChild( row[pos][j] ); + } + + } + table.tBodies[0].appendChild( fragment ); + + $( table ).trigger( 'sortEnd.tablesorter' ); + } + + /** + * Find all header rows in a thead-less table and put them in a <thead> tag. + * This only treats a row as a header row if it contains only <th>s (no <td>s) + * and if it is preceded entirely by header rows. The algorithm stops when + * it encounters the first non-header row. + * + * After this, it will look at all rows at the bottom for footer rows + * And place these in a tfoot using similar rules. + * @param $table jQuery object for a <table> + */ + function emulateTHeadAndFoot( $table ) { + var $thead, $tfoot, i, len, + $rows = $table.find( '> tbody > tr' ); + if ( !$table.get( 0 ).tHead ) { + $thead = $( '<thead>' ); + $rows.each( function () { + if ( $( this ).children( 'td' ).length ) { + // This row contains a <td>, so it's not a header row + // Stop here + return false; + } + $thead.append( this ); + } ); + $table.find( ' > tbody:first' ).before( $thead ); + } + if ( !$table.get( 0 ).tFoot ) { + $tfoot = $( '<tfoot>' ); + len = $rows.length; + for ( i = len - 1; i >= 0; i-- ) { + if ( $( $rows[i] ).children( 'td' ).length ) { + break; + } + $tfoot.prepend( $( $rows[i] ) ); + } + $table.append( $tfoot ); + } + } + + function buildHeaders( table, msg ) { + var maxSeen = 0, + colspanOffset = 0, + columns, + i, + rowspan, + colspan, + headerCount, + longestTR, + exploded, + $tableHeaders = $( [] ), + $tableRows = $( 'thead:eq(0) > tr', table ); + if ( $tableRows.length <= 1 ) { + $tableHeaders = $tableRows.children( 'th' ); + } else { + exploded = []; + + // Loop through all the dom cells of the thead + $tableRows.each( function ( rowIndex, row ) { + $.each( row.cells, function ( columnIndex, cell ) { + var matrixRowIndex, + matrixColumnIndex; + + rowspan = Number( cell.rowSpan ); + colspan = Number( cell.colSpan ); + + // Skip the spots in the exploded matrix that are already filled + while ( exploded[rowIndex] && exploded[rowIndex][columnIndex] !== undefined ) { + ++columnIndex; + } + + // Find the actual dimensions of the thead, by placing each cell + // in the exploded matrix rowspan times colspan times, with the proper offsets + for ( matrixColumnIndex = columnIndex; matrixColumnIndex < columnIndex + colspan; ++matrixColumnIndex ) { + for ( matrixRowIndex = rowIndex; matrixRowIndex < rowIndex + rowspan; ++matrixRowIndex ) { + if ( !exploded[matrixRowIndex] ) { + exploded[matrixRowIndex] = []; + } + exploded[matrixRowIndex][matrixColumnIndex] = cell; + } + } + } ); + } ); + // We want to find the row that has the most columns (ignoring colspan) + $.each( exploded, function ( index, cellArray ) { + headerCount = $( uniqueElements( cellArray ) ).filter( 'th' ).length; + if ( headerCount >= maxSeen ) { + maxSeen = headerCount; + longestTR = index; + } + } ); + // We cannot use $.unique() here because it sorts into dom order, which is undesirable + $tableHeaders = $( uniqueElements( exploded[longestTR] ) ).filter( 'th' ); + } + + // as each header can span over multiple columns (using colspan=N), + // we have to bidirectionally map headers to their columns and columns to their headers + table.headerToColumns = []; + table.columnToHeader = []; + + $tableHeaders.each( function ( headerIndex ) { + columns = []; + for ( i = 0; i < this.colSpan; i++ ) { + table.columnToHeader[ colspanOffset + i ] = headerIndex; + columns.push( colspanOffset + i ); + } + + table.headerToColumns[ headerIndex ] = columns; + colspanOffset += this.colSpan; + + this.headerIndex = headerIndex; + this.order = 0; + this.count = 0; + + if ( $( this ).hasClass( table.config.unsortableClass ) ) { + this.sortDisabled = true; + } + + if ( !this.sortDisabled ) { + $( this ) + .addClass( table.config.cssHeader ) + .prop( 'tabIndex', 0 ) + .attr( { + role: 'columnheader button', + title: msg[1] + } ); + } + + // add cell to headerList + table.config.headerList[headerIndex] = this; + } ); + + return $tableHeaders; + + } + + /** + * Sets the sort count of the columns that are not affected by the sorting to have them sorted + * in default (ascending) order when their header cell is clicked the next time. + * + * @param {jQuery} $headers + * @param {Number[][]} sortList + * @param {Number[][]} headerToColumns + */ + function setHeadersOrder( $headers, sortList, headerToColumns ) { + // Loop through all headers to retrieve the indices of the columns the header spans across: + $.each( headerToColumns, function ( headerIndex, columns ) { + + $.each( columns, function ( i, columnIndex ) { + var header = $headers[headerIndex]; + + if ( !isValueInArray( columnIndex, sortList ) ) { + // Column shall not be sorted: Reset header count and order. + header.order = 0; + header.count = 0; + } else { + // Column shall be sorted: Apply designated count and order. + $.each( sortList, function ( j, sortColumn ) { + if ( sortColumn[0] === i ) { + header.order = sortColumn[1]; + header.count = sortColumn[1] + 1; + return false; + } + } ); + } + } ); + + } ); + } + + function isValueInArray( v, a ) { + var i, + len = a.length; + for ( i = 0; i < len; i++ ) { + if ( a[i][0] === v ) { + return true; + } + } + return false; + } + + function uniqueElements( array ) { + var uniques = []; + $.each( array, function ( index, elem ) { + if ( elem !== undefined && $.inArray( elem, uniques ) === -1 ) { + uniques.push( elem ); + } + } ); + return uniques; + } + + function setHeadersCss( table, $headers, list, css, msg, columnToHeader ) { + // Remove all header information and reset titles to default message + $headers.removeClass( css[0] ).removeClass( css[1] ).attr( 'title', msg[1] ); + + for ( var i = 0; i < list.length; i++ ) { + $headers.eq( columnToHeader[ list[i][0] ] ) + .addClass( css[ list[i][1] ] ) + .attr( 'title', msg[ list[i][1] ] ); + } + } + + function sortText( a, b ) { + return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) ); + } + + function sortTextDesc( a, b ) { + return ( ( b < a ) ? -1 : ( ( b > a ) ? 1 : 0 ) ); + } + + function multisort( table, sortList, cache ) { + var i, + sortFn = [], + len = sortList.length; + for ( i = 0; i < len; i++ ) { + sortFn[i] = ( sortList[i][1] ) ? sortTextDesc : sortText; + } + cache.normalized.sort( function ( array1, array2 ) { + var i, col, ret; + for ( i = 0; i < len; i++ ) { + col = sortList[i][0]; + ret = sortFn[i].call( this, array1[col], array2[col] ); + if ( ret !== 0 ) { + return ret; + } + } + // Fall back to index number column to ensure stable sort + return sortText.call( this, array1[array1.length - 1], array2[array2.length - 1] ); + } ); + return cache; + } + + function buildTransformTable() { + var ascii, localised, i, digitClass, + digits = '0123456789,.'.split( '' ), + separatorTransformTable = mw.config.get( 'wgSeparatorTransformTable' ), + digitTransformTable = mw.config.get( 'wgDigitTransformTable' ); + + if ( separatorTransformTable === null || ( separatorTransformTable[0] === '' && digitTransformTable[2] === '' ) ) { + ts.transformTable = false; + } else { + ts.transformTable = {}; + + // Unpack the transform table + ascii = separatorTransformTable[0].split( '\t' ).concat( digitTransformTable[0].split( '\t' ) ); + localised = separatorTransformTable[1].split( '\t' ).concat( digitTransformTable[1].split( '\t' ) ); + + // Construct regex for number identification + for ( i = 0; i < ascii.length; i++ ) { + ts.transformTable[localised[i]] = ascii[i]; + digits.push( $.escapeRE( localised[i] ) ); + } + } + digitClass = '[' + digits.join( '', digits ) + ']'; + + // We allow a trailing percent sign, which we just strip. This works fine + // if percents and regular numbers aren't being mixed. + ts.numberRegex = new RegExp( '^(' + '[-+\u2212]?[0-9][0-9,]*(\\.[0-9,]*)?(E[-+\u2212]?[0-9][0-9,]*)?' + // Fortran-style scientific + '|' + '[-+\u2212]?' + digitClass + '+[\\s\\xa0]*%?' + // Generic localised + ')$', 'i' ); + } + + function buildDateTable() { + var i, name, + regex = []; + + ts.monthNames = {}; + + for ( i = 0; i < 12; i++ ) { + name = mw.language.months.names[i].toLowerCase(); + ts.monthNames[name] = i + 1; + regex.push( $.escapeRE( name ) ); + name = mw.language.months.genitive[i].toLowerCase(); + ts.monthNames[name] = i + 1; + regex.push( $.escapeRE( name ) ); + name = mw.language.months.abbrev[i].toLowerCase().replace( '.', '' ); + ts.monthNames[name] = i + 1; + regex.push( $.escapeRE( name ) ); + } + + // Build piped string + regex = regex.join( '|' ); + + // Build RegEx + // Any date formated with . , ' - or / + ts.dateRegex[0] = new RegExp( /^\s*(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{2,4})\s*?/i ); + + // Written Month name, dmy + ts.dateRegex[1] = new RegExp( '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); + + // Written Month name, mdy + ts.dateRegex[2] = new RegExp( '^\\s*(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' ); + + } + + /** + * Replace all rowspanned cells in the body with clones in each row, so sorting + * need not worry about them. + * + * @param $table jQuery object for a <table> + */ + function explodeRowspans( $table ) { + var spanningRealCellIndex, rowSpan, colSpan, + cell, i, $tds, $clone, $nextRows, + rowspanCells = $table.find( '> tbody > tr > [rowspan]' ).get(); + + // Short circuit + if ( !rowspanCells.length ) { + return; + } + + // First, we need to make a property like cellIndex but taking into + // account colspans. We also cache the rowIndex to avoid having to take + // cell.parentNode.rowIndex in the sorting function below. + $table.find( '> tbody > tr' ).each( function () { + var i, + col = 0, + l = this.cells.length; + for ( i = 0; i < l; i++ ) { + this.cells[i].realCellIndex = col; + this.cells[i].realRowIndex = this.rowIndex; + col += this.cells[i].colSpan; + } + } ); + + // Split multi row cells into multiple cells with the same content. + // Sort by column then row index to avoid problems with odd table structures. + // Re-sort whenever a rowspanned cell's realCellIndex is changed, because it + // might change the sort order. + function resortCells() { + rowspanCells = rowspanCells.sort( function ( a, b ) { + var ret = a.realCellIndex - b.realCellIndex; + if ( !ret ) { + ret = a.realRowIndex - b.realRowIndex; + } + return ret; + } ); + $.each( rowspanCells, function () { + this.needResort = false; + } ); + } + resortCells(); + + function filterfunc() { + return this.realCellIndex >= spanningRealCellIndex; + } + + function fixTdCellIndex() { + this.realCellIndex += colSpan; + if ( this.rowSpan > 1 ) { + this.needResort = true; + } + } + + while ( rowspanCells.length ) { + if ( rowspanCells[0].needResort ) { + resortCells(); + } + + cell = rowspanCells.shift(); + rowSpan = cell.rowSpan; + colSpan = cell.colSpan; + spanningRealCellIndex = cell.realCellIndex; + cell.rowSpan = 1; + $nextRows = $( cell ).parent().nextAll(); + for ( i = 0; i < rowSpan - 1; i++ ) { + $tds = $( $nextRows[i].cells ).filter( filterfunc ); + $clone = $( cell ).clone(); + $clone[0].realCellIndex = spanningRealCellIndex; + if ( $tds.length ) { + $tds.each( fixTdCellIndex ); + $tds.first().before( $clone ); + } else { + $nextRows.eq( i ).append( $clone ); + } + } + } + } + + function buildCollationTable() { + ts.collationTable = mw.config.get( 'tableSorterCollation' ); + ts.collationRegex = null; + if ( ts.collationTable ) { + var key, + keys = []; + + // Build array of key names + for ( key in ts.collationTable ) { + // Check hasOwn to be safe + if ( ts.collationTable.hasOwnProperty( key ) ) { + keys.push( key ); + } + } + if ( keys.length ) { + ts.collationRegex = new RegExp( '[' + keys.join( '' ) + ']', 'ig' ); + } + } + } + + function cacheRegexs() { + if ( ts.rgx ) { + return; + } + ts.rgx = { + IPAddress: [ + new RegExp( /^\d{1,3}[\.]\d{1,3}[\.]\d{1,3}[\.]\d{1,3}$/ ) + ], + currency: [ + new RegExp( /(^[£$€¥]|[£$€¥]$)/ ), + new RegExp( /[£$€¥]/g ) + ], + url: [ + new RegExp( /^(https?|ftp|file):\/\/$/ ), + new RegExp( /(https?|ftp|file):\/\// ) + ], + isoDate: [ + new RegExp( /^\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}$/ ) + ], + usLongDate: [ + new RegExp( /^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/ ) + ], + time: [ + new RegExp( /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/ ) + ] + }; + } + + /** + * Converts sort objects [ { Integer: String }, ... ] to the internally used nested array + * structure [ [ Integer , Integer ], ... ] + * + * @param sortObjects {Array} List of sort objects. + * @return {Array} List of internal sort definitions. + */ + + function convertSortList( sortObjects ) { + var sortList = []; + $.each( sortObjects, function ( i, sortObject ) { + $.each( sortObject, function ( columnIndex, order ) { + var orderIndex = ( order === 'desc' ) ? 1 : 0; + sortList.push( [parseInt( columnIndex, 10 ), orderIndex] ); + } ); + } ); + return sortList; + } + + /* Public scope */ + + $.tablesorter = { + + defaultOptions: { + cssHeader: 'headerSort', + cssAsc: 'headerSortUp', + cssDesc: 'headerSortDown', + cssChildRow: 'expand-child', + sortInitialOrder: 'asc', + sortMultiSortKey: 'shiftKey', + sortLocaleCompare: false, + unsortableClass: 'unsortable', + parsers: {}, + widgets: [], + headers: {}, + cancelSelection: true, + sortList: [], + headerList: [], + selectorHeaders: 'thead tr:eq(0) th', + debug: false + }, + + dateRegex: [], + monthNames: {}, + + /** + * @param $tables {jQuery} + * @param settings {Object} (optional) + */ + construct: function ( $tables, settings ) { + return $tables.each( function ( i, table ) { + // Declare and cache. + var $headers, cache, config, sortCSS, sortMsg, + $table = $( table ), + firstTime = true; + + // Quit if no tbody + if ( !table.tBodies ) { + return; + } + if ( !table.tHead ) { + // No thead found. Look for rows with <th>s and + // move them into a <thead> tag or a <tfoot> tag + emulateTHeadAndFoot( $table ); + + // Still no thead? Then quit + if ( !table.tHead ) { + return; + } + } + $table.addClass( 'jquery-tablesorter' ); + + // FIXME config should probably not be stored in the plain table node + // New config object. + table.config = {}; + + // Merge and extend. + config = $.extend( table.config, $.tablesorter.defaultOptions, settings ); + + // Save the settings where they read + $.data( table, 'tablesorter', { config: config } ); + + // Get the CSS class names, could be done else where. + sortCSS = [ config.cssDesc, config.cssAsc ]; + sortMsg = [ mw.msg( 'sort-descending' ), mw.msg( 'sort-ascending' ) ]; + + // Build headers + $headers = buildHeaders( table, sortMsg ); + + // Grab and process locale settings. + buildTransformTable(); + buildDateTable(); + + // Precaching regexps can bring 10 fold + // performance improvements in some browsers. + cacheRegexs(); + + function setupForFirstSort() { + firstTime = false; + + // Defer buildCollationTable to first sort. As user and site scripts + // may customize tableSorterCollation but load after $.ready(), other + // scripts may call .tablesorter() before they have done the + // tableSorterCollation customizations. + buildCollationTable(); + + // Legacy fix of .sortbottoms + // Wrap them inside inside a tfoot (because that's what they actually want to be) & + // and put the <tfoot> at the end of the <table> + var $tfoot, + $sortbottoms = $table.find( '> tbody > tr.sortbottom' ); + if ( $sortbottoms.length ) { + $tfoot = $table.children( 'tfoot' ); + if ( $tfoot.length ) { + $tfoot.eq( 0 ).prepend( $sortbottoms ); + } else { + $table.append( $( '<tfoot>' ).append( $sortbottoms ) ); + } + } + + explodeRowspans( $table ); + + // try to auto detect column type, and store in tables config + table.config.parsers = buildParserCache( table, $headers ); + } + + // Apply event handling to headers + // this is too big, perhaps break it out? + $headers.not( '.' + table.config.unsortableClass ).on( 'keypress click', function ( e ) { + var cell, columns, newSortList, i, + totalRows, + j, s, o; + + if ( e.type === 'click' && e.target.nodeName.toLowerCase() === 'a' ) { + // The user clicked on a link inside a table header. + // Do nothing and let the default link click action continue. + return true; + } + + if ( e.type === 'keypress' && e.which !== 13 ) { + // Only handle keypresses on the "Enter" key. + return true; + } + + if ( firstTime ) { + setupForFirstSort(); + } + + // Build the cache for the tbody cells + // to share between calculations for this sort action. + // Re-calculated each time a sort action is performed due to possiblity + // that sort values change. Shouldn't be too expensive, but if it becomes + // too slow an event based system should be implemented somehow where + // cells get event .change() and bubbles up to the <table> here + cache = buildCache( table ); + + totalRows = ( $table[0].tBodies[0] && $table[0].tBodies[0].rows.length ) || 0; + if ( !table.sortDisabled && totalRows > 0 ) { + // Get current column sort order + this.order = this.count % 2; + this.count++; + + cell = this; + // Get current column index + columns = table.headerToColumns[ this.headerIndex ]; + newSortList = $.map( columns, function ( c ) { + // jQuery "helpfully" flattens the arrays... + return [[c, cell.order]]; + } ); + // Index of first column belonging to this header + i = columns[0]; + + if ( !e[config.sortMultiSortKey] ) { + // User only wants to sort on one column set + // Flush the sort list and add new columns + config.sortList = newSortList; + } else { + // Multi column sorting + // It is not possible for one column to belong to multiple headers, + // so this is okay - we don't need to check for every value in the columns array + if ( isValueInArray( i, config.sortList ) ) { + // The user has clicked on an already sorted column. + // Reverse the sorting direction for all tables. + for ( j = 0; j < config.sortList.length; j++ ) { + s = config.sortList[j]; + o = config.headerList[s[0]]; + if ( isValueInArray( s[0], newSortList ) ) { + o.count = s[1]; + o.count++; + s[1] = o.count % 2; + } + } + } else { + // Add columns to sort list array + config.sortList = config.sortList.concat( newSortList ); + } + } + + // Reset order/counts of cells not affected by sorting + setHeadersOrder( $headers, config.sortList, table.headerToColumns ); + + // Set CSS for headers + setHeadersCss( $table[0], $headers, config.sortList, sortCSS, sortMsg, table.columnToHeader ); + appendToTable( + $table[0], multisort( $table[0], config.sortList, cache ) + ); + + // Stop normal event by returning false + return false; + } + + // Cancel selection + } ).mousedown( function () { + if ( config.cancelSelection ) { + this.onselectstart = function () { + return false; + }; + return false; + } + } ); + + /** + * Sorts the table. If no sorting is specified by passing a list of sort + * objects, the table is sorted according to the initial sorting order. + * Passing an empty array will reset sorting (basically just reset the headers + * making the table appear unsorted). + * + * @param sortList {Array} (optional) List of sort objects. + */ + $table.data( 'tablesorter' ).sort = function ( sortList ) { + + if ( firstTime ) { + setupForFirstSort(); + } + + if ( sortList === undefined ) { + sortList = config.sortList; + } else if ( sortList.length > 0 ) { + sortList = convertSortList( sortList ); + } + + // Set each column's sort count to be able to determine the correct sort + // order when clicking on a header cell the next time + setHeadersOrder( $headers, sortList, table.headerToColumns ); + + // re-build the cache for the tbody cells + cache = buildCache( table ); + + // set css for headers + setHeadersCss( table, $headers, sortList, sortCSS, sortMsg, table.columnToHeader ); + + // sort the table and append it to the dom + appendToTable( table, multisort( table, sortList, cache ) ); + }; + + // sort initially + if ( config.sortList.length > 0 ) { + setupForFirstSort(); + config.sortList = convertSortList( config.sortList ); + $table.data( 'tablesorter' ).sort(); + } + + } ); + }, + + addParser: function ( parser ) { + var i, + len = parsers.length, + a = true; + for ( i = 0; i < len; i++ ) { + if ( parsers[i].id.toLowerCase() === parser.id.toLowerCase() ) { + a = false; + } + } + if ( a ) { + parsers.push( parser ); + } + }, + + formatDigit: function ( s ) { + var out, c, p, i; + if ( ts.transformTable !== false ) { + out = ''; + for ( p = 0; p < s.length; p++ ) { + c = s.charAt( p ); + if ( c in ts.transformTable ) { + out += ts.transformTable[c]; + } else { + out += c; + } + } + s = out; + } + i = parseFloat( s.replace( /[, ]/g, '' ).replace( '\u2212', '-' ) ); + return isNaN( i ) ? 0 : i; + }, + + formatFloat: function ( s ) { + var i = parseFloat( s ); + return isNaN( i ) ? 0 : i; + }, + + formatInt: function ( s ) { + var i = parseInt( s, 10 ); + return isNaN( i ) ? 0 : i; + }, + + clearTableBody: function ( table ) { + $( table.tBodies[0] ).empty(); + } + }; + + // Shortcut + ts = $.tablesorter; + + // Register as jQuery prototype method + $.fn.tablesorter = function ( settings ) { + return ts.construct( this, settings ); + }; + + // Add default parsers + ts.addParser( { + id: 'text', + is: function () { + return true; + }, + format: function ( s ) { + s = $.trim( s.toLowerCase() ); + if ( ts.collationRegex ) { + var tsc = ts.collationTable; + s = s.replace( ts.collationRegex, function ( match ) { + var r = tsc[match] ? tsc[match] : tsc[match.toUpperCase()]; + return r.toLowerCase(); + } ); + } + return s; + }, + type: 'text' + } ); + + ts.addParser( { + id: 'IPAddress', + is: function ( s ) { + return ts.rgx.IPAddress[0].test( s ); + }, + format: function ( s ) { + var i, item, + a = s.split( '.' ), + r = '', + len = a.length; + for ( i = 0; i < len; i++ ) { + item = a[i]; + if ( item.length === 1 ) { + r += '00' + item; + } else if ( item.length === 2 ) { + r += '0' + item; + } else { + r += item; + } + } + return $.tablesorter.formatFloat( r ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'currency', + is: function ( s ) { + return ts.rgx.currency[0].test( s ); + }, + format: function ( s ) { + return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[1], '' ) ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'url', + is: function ( s ) { + return ts.rgx.url[0].test( s ); + }, + format: function ( s ) { + return $.trim( s.replace( ts.rgx.url[1], '' ) ); + }, + type: 'text' + } ); + + ts.addParser( { + id: 'isoDate', + is: function ( s ) { + return ts.rgx.isoDate[0].test( s ); + }, + format: function ( s ) { + return $.tablesorter.formatFloat( ( s !== '' ) ? new Date( s.replace( + new RegExp( /-/g ), '/' ) ).getTime() : '0' ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'usLongDate', + is: function ( s ) { + return ts.rgx.usLongDate[0].test( s ); + }, + format: function ( s ) { + return $.tablesorter.formatFloat( new Date( s ).getTime() ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'date', + is: function ( s ) { + return ( ts.dateRegex[0].test( s ) || ts.dateRegex[1].test( s ) || ts.dateRegex[2].test( s ) ); + }, + format: function ( s ) { + var match, y; + s = $.trim( s.toLowerCase() ); + + if ( ( match = s.match( ts.dateRegex[0] ) ) !== null ) { + if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgContentLanguage' ) === 'en' ) { + s = [ match[3], match[1], match[2] ]; + } else if ( mw.config.get( 'wgDefaultDateFormat' ) === 'dmy' ) { + s = [ match[3], match[2], match[1] ]; + } else { + // If we get here, we don't know which order the dd-dd-dddd + // date is in. So return something not entirely invalid. + return '99999999'; + } + } else if ( ( match = s.match( ts.dateRegex[1] ) ) !== null ) { + s = [ match[3], '' + ts.monthNames[match[2]], match[1] ]; + } else if ( ( match = s.match( ts.dateRegex[2] ) ) !== null ) { + s = [ match[3], '' + ts.monthNames[match[1]], match[2] ]; + } else { + // Should never get here + return '99999999'; + } + + // Pad Month and Day + if ( s[1].length === 1 ) { + s[1] = '0' + s[1]; + } + if ( s[2].length === 1 ) { + s[2] = '0' + s[2]; + } + + if ( ( y = parseInt( s[0], 10 ) ) < 100 ) { + // Guestimate years without centuries + if ( y < 30 ) { + s[0] = 2000 + y; + } else { + s[0] = 1900 + y; + } + } + while ( s[0].length < 4 ) { + s[0] = '0' + s[0]; + } + return parseInt( s.join( '' ), 10 ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'time', + is: function ( s ) { + return ts.rgx.time[0].test( s ); + }, + format: function ( s ) { + return $.tablesorter.formatFloat( new Date( '2000/01/01 ' + s ).getTime() ); + }, + type: 'numeric' + } ); + + ts.addParser( { + id: 'number', + is: function ( s ) { + return $.tablesorter.numberRegex.test( $.trim( s ) ); + }, + format: function ( s ) { + return $.tablesorter.formatDigit( s ); + }, + type: 'numeric' + } ); + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/jquery/jquery.textSelection.js b/resources/src/jquery/jquery.textSelection.js new file mode 100644 index 00000000..8d440fdc --- /dev/null +++ b/resources/src/jquery/jquery.textSelection.js @@ -0,0 +1,572 @@ +/** + * These plugins provide extra functionality for interaction with textareas. + */ +( function ( $ ) { + if ( document.selection && document.selection.createRange ) { + // On IE, patch the focus() method to restore the windows' scroll position + // (bug 32241) + $.fn.extend( { + focus: ( function ( jqFocus ) { + return function () { + var $w, state, result; + if ( arguments.length === 0 ) { + $w = $( window ); + state = { top: $w.scrollTop(), left: $w.scrollLeft() }; + result = jqFocus.apply( this, arguments ); + window.scrollTo( state.top, state.left ); + return result; + } + return jqFocus.apply( this, arguments ); + }; + }( $.fn.focus ) ) + } ); + } + + $.fn.textSelection = function ( command, options ) { + var fn, + context, + hasWikiEditorSurface, // The alt edit surface needs to implement the WikiEditor API + needSave, + retval; + + /** + * Helper function to get an IE TextRange object for an element + */ + function rangeForElementIE( e ) { + if ( e.nodeName.toLowerCase() === 'input' ) { + return e.createTextRange(); + } else { + var sel = document.body.createTextRange(); + sel.moveToElementText( e ); + return sel; + } + } + + /** + * Helper function for IE for activating the textarea. Called only in the + * IE-specific code paths below; makes use of IE-specific non-standard + * function setActive() if possible to avoid screen flicker. + */ + function activateElementOnIE( element ) { + if ( element.setActive ) { + element.setActive(); // bug 32241: doesn't scroll + } else { + $( element ).focus(); // may scroll (but we patched it above) + } + } + + fn = { + /** + * Get the contents of the textarea + */ + getContents: function () { + return this.val(); + }, + /** + * Set the contents of the textarea, replacing anything that was there before + */ + setContents: function ( content ) { + this.val( content ); + }, + /** + * Get the currently selected text in this textarea. Will focus the textarea + * in some browsers (IE/Opera) + */ + getSelection: function () { + var retval, range, + el = this.get( 0 ); + + if ( !el || $( el ).is( ':hidden' ) ) { + retval = ''; + } else if ( document.selection && document.selection.createRange ) { + activateElementOnIE( el ); + range = document.selection.createRange(); + retval = range.text; + } else if ( el.selectionStart || el.selectionStart === 0 ) { + retval = el.value.substring( el.selectionStart, el.selectionEnd ); + } + + return retval; + }, + /** + * Ported from skins/common/edit.js by Trevor Parscal + * (c) 2009 Wikimedia Foundation (GPLv2) - http://www.wikimedia.org + * + * Inserts text at the beginning and end of a text selection, optionally + * inserting text at the caret when selection is empty. + * + * @fixme document the options parameters + */ + encapsulateSelection: function ( options ) { + return this.each( function () { + var selText, scrollTop, insertText, + isSample, range, range2, range3, startPos, endPos, + pre = options.pre, + post = options.post; + + /** + * Check if the selected text is the same as the insert text + */ + function checkSelectedText() { + if ( !selText ) { + selText = options.peri; + isSample = true; + } else if ( options.replace ) { + selText = options.peri; + } else { + while ( selText.charAt( selText.length - 1 ) === ' ' ) { + // Exclude ending space char + selText = selText.slice( 0, -1 ); + post += ' '; + } + while ( selText.charAt( 0 ) === ' ' ) { + // Exclude prepending space char + selText = selText.slice( 1 ); + pre = ' ' + pre; + } + } + } + + /** + * Do the splitlines stuff. + * + * Wrap each line of the selected text with pre and post + */ + function doSplitLines( selText, pre, post ) { + var i, + insertText = '', + selTextArr = selText.split( '\n' ); + for ( i = 0; i < selTextArr.length; i++ ) { + insertText += pre + selTextArr[i] + post; + if ( i !== selTextArr.length - 1 ) { + insertText += '\n'; + } + } + return insertText; + } + + isSample = false; + // Do nothing if display none + if ( this.style.display !== 'none' ) { + if ( document.selection && document.selection.createRange ) { + // IE + + // Note that IE9 will trigger the next section unless we check this first. + // See bug 35201. + + activateElementOnIE( this ); + if ( context ) { + context.fn.restoreCursorAndScrollTop(); + } + if ( options.selectionStart !== undefined ) { + $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } ); + } + + selText = $( this ).textSelection( 'getSelection' ); + scrollTop = this.scrollTop; + range = document.selection.createRange(); + + checkSelectedText(); + insertText = pre + selText + post; + if ( options.splitlines ) { + insertText = doSplitLines( selText, pre, post ); + } + if ( options.ownline && range.moveStart ) { + range2 = document.selection.createRange(); + range2.collapse(); + range2.moveStart( 'character', -1 ); + // FIXME: Which check is correct? + if ( range2.text !== '\r' && range2.text !== '\n' && range2.text !== '' ) { + insertText = '\n' + insertText; + pre += '\n'; + } + range3 = document.selection.createRange(); + range3.collapse( false ); + range3.moveEnd( 'character', 1 ); + if ( range3.text !== '\r' && range3.text !== '\n' && range3.text !== '' ) { + insertText += '\n'; + post += '\n'; + } + } + + range.text = insertText; + if ( isSample && options.selectPeri && range.moveStart ) { + range.moveStart( 'character', -post.length - selText.length ); + range.moveEnd( 'character', -post.length ); + } + range.select(); + // Restore the scroll position + this.scrollTop = scrollTop; + } else if ( this.selectionStart || this.selectionStart === 0 ) { + // Mozilla/Opera + + $( this ).focus(); + if ( options.selectionStart !== undefined ) { + $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } ); + } + + selText = $( this ).textSelection( 'getSelection' ); + startPos = this.selectionStart; + endPos = this.selectionEnd; + scrollTop = this.scrollTop; + checkSelectedText(); + if ( options.selectionStart !== undefined + && endPos - startPos !== options.selectionEnd - options.selectionStart ) + { + // This means there is a difference in the selection range returned by browser and what we passed. + // This happens for Chrome in the case of composite characters. Ref bug #30130 + // Set the startPos to the correct position. + startPos = options.selectionStart; + } + + insertText = pre + selText + post; + if ( options.splitlines ) { + insertText = doSplitLines( selText, pre, post ); + } + if ( options.ownline ) { + if ( startPos !== 0 && this.value.charAt( startPos - 1 ) !== '\n' && this.value.charAt( startPos - 1 ) !== '\r' ) { + insertText = '\n' + insertText; + pre += '\n'; + } + if ( this.value.charAt( endPos ) !== '\n' && this.value.charAt( endPos ) !== '\r' ) { + insertText += '\n'; + post += '\n'; + } + } + this.value = this.value.slice( 0, startPos ) + insertText + + this.value.slice( endPos ); + // Setting this.value scrolls the textarea to the top, restore the scroll position + this.scrollTop = scrollTop; + if ( window.opera ) { + pre = pre.replace( /\r?\n/g, '\r\n' ); + selText = selText.replace( /\r?\n/g, '\r\n' ); + post = post.replace( /\r?\n/g, '\r\n' ); + } + if ( isSample && options.selectPeri && !options.splitlines ) { + this.selectionStart = startPos + pre.length; + this.selectionEnd = startPos + pre.length + selText.length; + } else { + this.selectionStart = startPos + insertText.length; + this.selectionEnd = this.selectionStart; + } + } + } + $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline, + options.replace, options.spitlines ] ); + } ); + }, + /** + * Ported from Wikia's LinkSuggest extension + * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest + * Some code copied from + * http://www.dedestruct.com/2008/03/22/howto-cross-browser-cursor-position-in-textareas/ + * + * Get the position (in resolution of bytes not necessarily characters) + * in a textarea + * + * Will focus the textarea in some browsers (IE/Opera) + * + * @fixme document the options parameters + */ + getCaretPosition: function ( options ) { + function getCaret( e ) { + var caretPos = 0, + endPos = 0, + preText, rawPreText, periText, + rawPeriText, postText, rawPostText, + // IE Support + preFinished, + periFinished, + postFinished, + // Range containing text in the selection + periRange, + // Range containing text before the selection + preRange, + // Range containing text after the selection + postRange; + + if ( e && document.selection && document.selection.createRange ) { + // IE doesn't properly report non-selected caret position through + // the selection ranges when textarea isn't focused. This can + // lead to saving a bogus empty selection, which then screws up + // whatever we do later (bug 31847). + activateElementOnIE( e ); + + preFinished = false; + periFinished = false; + postFinished = false; + periRange = document.selection.createRange().duplicate(); + + preRange = rangeForElementIE( e ); + // Move the end where we need it + preRange.setEndPoint( 'EndToStart', periRange ); + + postRange = rangeForElementIE( e ); + // Move the start where we need it + postRange.setEndPoint( 'StartToEnd', periRange ); + + // Load the text values we need to compare + preText = rawPreText = preRange.text; + periText = rawPeriText = periRange.text; + postText = rawPostText = postRange.text; + + /* + * Check each range for trimmed newlines by shrinking the range by 1 + * character and seeing if the text property has changed. If it has + * not changed then we know that IE has trimmed a \r\n from the end. + */ + do { + if ( !preFinished ) { + if ( preRange.compareEndPoints( 'StartToEnd', preRange ) === 0 ) { + preFinished = true; + } else { + preRange.moveEnd( 'character', -1 ); + if ( preRange.text === preText ) { + rawPreText += '\r\n'; + } else { + preFinished = true; + } + } + } + if ( !periFinished ) { + if ( periRange.compareEndPoints( 'StartToEnd', periRange ) === 0 ) { + periFinished = true; + } else { + periRange.moveEnd( 'character', -1 ); + if ( periRange.text === periText ) { + rawPeriText += '\r\n'; + } else { + periFinished = true; + } + } + } + if ( !postFinished ) { + if ( postRange.compareEndPoints( 'StartToEnd', postRange ) === 0 ) { + postFinished = true; + } else { + postRange.moveEnd( 'character', -1 ); + if ( postRange.text === postText ) { + rawPostText += '\r\n'; + } else { + postFinished = true; + } + } + } + } while ( ( !preFinished || !periFinished || !postFinished ) ); + caretPos = rawPreText.replace( /\r\n/g, '\n' ).length; + endPos = caretPos + rawPeriText.replace( /\r\n/g, '\n' ).length; + } else if ( e && ( e.selectionStart || e.selectionStart === 0 ) ) { + // Firefox support + caretPos = e.selectionStart; + endPos = e.selectionEnd; + } + return options.startAndEnd ? [ caretPos, endPos ] : caretPos; + } + return getCaret( this.get( 0 ) ); + }, + /** + * @fixme document the options parameters + */ + setSelection: function ( options ) { + return this.each( function () { + var selection, length, newLines; + // Do nothing if hidden + if ( !$( this ).is( ':hidden' ) ) { + if ( this.selectionStart || this.selectionStart === 0 ) { + // Opera 9.0 doesn't allow setting selectionStart past + // selectionEnd; any attempts to do that will be ignored + // Make sure to set them in the right order + if ( options.start > this.selectionEnd ) { + this.selectionEnd = options.end; + this.selectionStart = options.start; + } else { + this.selectionStart = options.start; + this.selectionEnd = options.end; + } + } else if ( document.body.createTextRange ) { + selection = rangeForElementIE( this ); + length = this.value.length; + // IE doesn't count \n when computing the offset, so we won't either + newLines = this.value.match( /\n/g ); + if ( newLines ) { + length = length - newLines.length; + } + selection.moveStart( 'character', options.start ); + selection.moveEnd( 'character', -length + options.end ); + + // This line can cause an error under certain circumstances (textarea empty, no selection) + // Silence that error + try { + selection.select(); + } catch ( e ) { } + } + } + } ); + }, + /** + * Ported from Wikia's LinkSuggest extension + * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest + * + * Scroll a textarea to the current cursor position. You can set the cursor + * position with setSelection() + * @param options boolean Whether to force a scroll even if the caret position + * is already visible. Defaults to false + * + * @fixme document the options parameters (function body suggests options.force is a boolean, not options itself) + */ + scrollToCaretPosition: function ( options ) { + function getLineLength( e ) { + return Math.floor( e.scrollWidth / ( $.client.profile().platform === 'linux' ? 7 : 8 ) ); + } + function getCaretScrollPosition( e ) { + // FIXME: This functions sucks and is off by a few lines most + // of the time. It should be replaced by something decent. + var i, j, + nextSpace, + text = e.value.replace( /\r/g, '' ), + caret = $( e ).textSelection( 'getCaretPosition' ), + lineLength = getLineLength( e ), + row = 0, + charInLine = 0, + lastSpaceInLine = 0; + + for ( i = 0; i < caret; i++ ) { + charInLine++; + if ( text.charAt( i ) === ' ' ) { + lastSpaceInLine = charInLine; + } else if ( text.charAt( i ) === '\n' ) { + lastSpaceInLine = 0; + charInLine = 0; + row++; + } + if ( charInLine > lineLength ) { + if ( lastSpaceInLine > 0 ) { + charInLine = charInLine - lastSpaceInLine; + lastSpaceInLine = 0; + row++; + } + } + } + nextSpace = 0; + for ( j = caret; j < caret + lineLength; j++ ) { + if ( + text.charAt( j ) === ' ' || + text.charAt( j ) === '\n' || + caret === text.length + ) { + nextSpace = j; + break; + } + } + if ( nextSpace > lineLength && caret <= lineLength ) { + charInLine = caret - lastSpaceInLine; + row++; + } + return ( $.client.profile().platform === 'mac' ? 13 : ( $.client.profile().platform === 'linux' ? 15 : 16 ) ) * row; + } + return this.each( function () { + var scroll, range, savedRange, pos, oldScrollTop; + // Do nothing if hidden + if ( !$( this ).is( ':hidden' ) ) { + if ( this.selectionStart || this.selectionStart === 0 ) { + // Mozilla + scroll = getCaretScrollPosition( this ); + if ( options.force || scroll < $( this ).scrollTop() || + scroll > $( this ).scrollTop() + $( this ).height() ) { + $( this ).scrollTop( scroll ); + } + } else if ( document.selection && document.selection.createRange ) { + // IE / Opera + /* + * IE automatically scrolls the selected text to the + * bottom of the textarea at range.select() time, except + * if it was already in view and the cursor position + * wasn't changed, in which case it does nothing. To + * cover that case, we'll force it to act by moving one + * character back and forth. + */ + range = document.body.createTextRange(); + savedRange = document.selection.createRange(); + pos = $( this ).textSelection( 'getCaretPosition' ); + oldScrollTop = this.scrollTop; + range.moveToElementText( this ); + range.collapse(); + range.move( 'character', pos + 1 ); + range.select(); + if ( this.scrollTop !== oldScrollTop ) { + this.scrollTop += range.offsetTop; + } else if ( options.force ) { + range.move( 'character', -1 ); + range.select(); + } + savedRange.select(); + } + } + $( this ).trigger( 'scrollToPosition' ); + } ); + } + }; + + // Apply defaults + switch ( command ) { + //case 'getContents': // no params + //case 'setContents': // no params with defaults + //case 'getSelection': // no params + case 'encapsulateSelection': + options = $.extend( { + pre: '', // Text to insert before the cursor/selection + peri: '', // Text to insert between pre and post and select afterwards + post: '', // Text to insert after the cursor/selection + ownline: false, // Put the inserted text on a line of its own + replace: false, // If there is a selection, replace it with peri instead of leaving it alone + selectPeri: true, // Select the peri text if it was inserted (but not if there was a selection and replace==false, or if splitlines==true) + splitlines: false, // If multiple lines are selected, encapsulate each line individually + selectionStart: undefined, // Position to start selection at + selectionEnd: undefined // Position to end selection at. Defaults to start + }, options ); + break; + case 'getCaretPosition': + options = $.extend( { + // Return [start, end] instead of just start + startAndEnd: false + }, options ); + // FIXME: We may not need character position-based functions if we insert markers in the right places + break; + case 'setSelection': + options = $.extend( { + // Position to start selection at + start: undefined, + // Position to end selection at. Defaults to start + end: undefined + }, options ); + + if ( options.end === undefined ) { + options.end = options.start; + } + // FIXME: We may not need character position-based functions if we insert markers in the right places + break; + case 'scrollToCaretPosition': + options = $.extend( { + force: false // Force a scroll even if the caret position is already visible + }, options ); + break; + } + + context = $( this ).data( 'wikiEditor-context' ); + hasWikiEditorSurface = ( context !== undefined && context.$iframe !== undefined ); + + // IE selection restore voodoo + needSave = false; + if ( hasWikiEditorSurface && context.savedSelection !== null ) { + context.fn.restoreSelection(); + needSave = true; + } + retval = ( hasWikiEditorSurface && context.fn[command] !== undefined ? context.fn : fn )[command].call( this, options ); + if ( hasWikiEditorSurface && needSave ) { + context.fn.saveSelection(); + } + + return retval; + }; + +}( jQuery ) ); |