Implement extension registration from an extension.json file
[mediawiki.git] / resources / src / mediawiki / mediawiki.notification.js
blob132c334f8314c292af606554caab4b1b33a027f2
1 ( function ( mw, $ ) {
2         'use strict';
4         var notification,
5                 // The #mw-notification-area div that all notifications are contained inside.
6                 $area,
7                 // Number of open notification boxes at any time
8                 openNotificationCount = 0,
9                 isPageReady = false,
10                 preReadyNotifQueue = [];
12         /**
13          * A Notification object for 1 message.
14          *
15          * The underscore in the name is to avoid a bug <https://github.com/senchalabs/jsduck/issues/304>.
16          * It is not part of the actual class name.
17          *
18          * @class mw.Notification_
19          * @alternateClassName mw.Notification
20          *
21          * @constructor The constructor is not publicly accessible; use mw.notification#notify instead.
22          *  This does not insert anything into the document (see #start).
23          * @private
24          */
25         function Notification( message, options ) {
26                 var $notification, $notificationTitle, $notificationContent;
28                 $notification = $( '<div class="mw-notification"></div>' )
29                         .data( 'mw.notification', this )
30                         .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
32                 if ( options.tag ) {
33                         // Sanitize options.tag before it is used by any code. (Including Notification class methods)
34                         options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
35                         if ( options.tag ) {
36                                 $notification.addClass( 'mw-notification-tag-' + options.tag );
37                         } else {
38                                 delete options.tag;
39                         }
40                 }
42                 if ( options.title ) {
43                         $notificationTitle = $( '<div class="mw-notification-title"></div>' )
44                                 .text( options.title )
45                                 .appendTo( $notification );
46                 }
48                 $notificationContent = $( '<div class="mw-notification-content"></div>' );
50                 if ( typeof message === 'object' ) {
51                         // Handle mw.Message objects separately from DOM nodes and jQuery objects
52                         if ( message instanceof mw.Message ) {
53                                 $notificationContent.html( message.parse() );
54                         } else {
55                                 $notificationContent.append( message );
56                         }
57                 } else {
58                         $notificationContent.text( message );
59                 }
61                 $notificationContent.appendTo( $notification );
63                 // Private state parameters, meant for internal use only
64                 // isOpen: Set to true after .start() is called to avoid double calls.
65                 //         Set back to false after .close() to avoid duplicating the close animation.
66                 // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
67                 //           Set to true initially so .start() can call .resume().
68                 // message: The message passed to the notification. Unused now but may be used in the future
69                 //          to stop replacement of a tagged notification with another notification using the same message.
70                 // options: The options passed to the notification with a little sanitization. Used by various methods.
71                 // $notification: jQuery object containing the notification DOM node.
72                 this.isOpen = false;
73                 this.isPaused = true;
74                 this.message = message;
75                 this.options = options;
76                 this.$notification = $notification;
77         }
79         /**
80          * Start the notification. Called automatically by mw.notification#notify
81          * (possibly asynchronously on document-ready).
82          *
83          * This inserts the notification into the page, closes any matching tagged notifications,
84          * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
85          *
86          * @private
87          */
88         Notification.prototype.start = function () {
89                 var
90                         // Local references
91                         $notification, options,
92                         // Original opacity so that we can animate back to it later
93                         opacity,
94                         // Other notification elements matching the same tag
95                         $tagMatches,
96                         outerHeight,
97                         placeholderHeight,
98                         autohideCount,
99                         notif;
101                 $area.show();
103                 if ( this.isOpen ) {
104                         return;
105                 }
107                 this.isOpen = true;
108                 openNotificationCount++;
110                 options = this.options;
111                 $notification = this.$notification;
113                 opacity = this.$notification.css( 'opacity' );
115                 // Set the opacity to 0 so we can fade in later.
116                 $notification.css( 'opacity', 0 );
118                 if ( options.tag ) {
119                         // Check to see if there are any tagged notifications with the same tag as the new one
120                         $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
121                 }
123                 // If we found a tagged notification use the replacement pattern instead of the new
124                 // notification fade-in pattern.
125                 if ( options.tag && $tagMatches.length ) {
127                         // Iterate over the tag matches to find the outerHeight we should use
128                         // for the placeholder.
129                         outerHeight = 0;
130                         $tagMatches.each( function () {
131                                 var notif = $( this ).data( 'mw.notification' );
132                                 if ( notif ) {
133                                         // Use the notification's height + padding + border + margins
134                                         // as the placeholder height.
135                                         outerHeight = notif.$notification.outerHeight( true );
136                                         if ( notif.$replacementPlaceholder ) {
137                                                 // Grab the height of a placeholder that has not finished animating.
138                                                 placeholderHeight = notif.$replacementPlaceholder.height();
139                                                 // Remove any placeholders added by a previous tagged
140                                                 // notification that was in the middle of replacing another.
141                                                 // This also makes sure that we only grab the placeholderHeight
142                                                 // for the most recent notification.
143                                                 notif.$replacementPlaceholder.remove();
144                                                 delete notif.$replacementPlaceholder;
145                                         }
146                                         // Close the previous tagged notification
147                                         // Since we're replacing it do this with a fast speed and don't output a placeholder
148                                         // since we're taking care of that transition ourselves.
149                                         notif.close( { speed: 'fast', placeholder: false } );
150                                 }
151                         } );
152                         if ( placeholderHeight !== undefined ) {
153                                 // If the other tagged notification was in the middle of replacing another
154                                 // tagged notification, continue from the placeholder's height instead of
155                                 // using the outerHeight of the notification.
156                                 outerHeight = placeholderHeight;
157                         }
159                         $notification
160                                 // Insert the new notification before the tagged notification(s)
161                                 .insertBefore( $tagMatches.first() )
162                                 .css( {
163                                         // Use an absolute position so that we can use a placeholder to gracefully push other notifications
164                                         // into the right spot.
165                                         position: 'absolute',
166                                         width: $notification.width()
167                                 } )
168                                 // Fade-in the notification
169                                 .animate( { opacity: opacity },
170                                         {
171                                                 duration: 'slow',
172                                                 complete: function () {
173                                                         // After we've faded in clear the opacity and let css take over
174                                                         $( this ).css( { opacity: '' } );
175                                                 }
176                                         } );
178                         notif = this;
180                         // Create a clear placeholder we can use to make the notifications around the notification that is being
181                         // replaced expand or contract gracefully to fit the height of the new notification.
182                         notif.$replacementPlaceholder = $( '<div>' )
183                                 // Set the height to the space the previous notification or placeholder took
184                                 .css( 'height', outerHeight )
185                                 // Make sure that this placeholder is at the very end of this tagged notification group
186                                 .insertAfter( $tagMatches.eq( -1 ) )
187                                 // Animate the placeholder height to the space that this new notification will take up
188                                 .animate( { height: $notification.outerHeight( true ) },
189                                         {
190                                                 // Do space animations fast
191                                                 speed: 'fast',
192                                                 complete: function () {
193                                                         // Reset the notification position after we've finished the space animation
194                                                         // However do not do it if the placeholder was removed because another tagged
195                                                         // notification went and closed this one.
196                                                         if ( notif.$replacementPlaceholder ) {
197                                                                 $notification.css( 'position', '' );
198                                                         }
199                                                         // Finally, remove the placeholder from the DOM
200                                                         $( this ).remove();
201                                                 }
202                                         } );
203                 } else {
204                         // Append to the notification area and fade in to the original opacity.
205                         $notification
206                                 .appendTo( $area )
207                                 .animate( { opacity: opacity },
208                                         {
209                                                 duration: 'fast',
210                                                 complete: function () {
211                                                         // After we've faded in clear the opacity and let css take over
212                                                         $( this ).css( 'opacity', '' );
213                                                 }
214                                         }
215                                 );
216                 }
218                 // By default a notification is paused.
219                 // If this notification is within the first {autoHideLimit} notifications then
220                 // start the auto-hide timer as soon as it's created.
221                 autohideCount = $area.find( '.mw-notification-autohide' ).length;
222                 if ( autohideCount <= notification.autoHideLimit ) {
223                         this.resume();
224                 }
225         };
227         /**
228          * Pause any running auto-hide timer for this notification
229          */
230         Notification.prototype.pause = function () {
231                 if ( this.isPaused ) {
232                         return;
233                 }
234                 this.isPaused = true;
236                 if ( this.timeout ) {
237                         clearTimeout( this.timeout );
238                         delete this.timeout;
239                 }
240         };
242         /**
243          * Start autoHide timer if not already started.
244          * Does nothing if autoHide is disabled.
245          * Either to resume from pause or to make the first start.
246          */
247         Notification.prototype.resume = function () {
248                 var notif = this;
249                 if ( !notif.isPaused ) {
250                         return;
251                 }
252                 // Start any autoHide timeouts
253                 if ( notif.options.autoHide ) {
254                         notif.isPaused = false;
255                         notif.timeout = setTimeout( function () {
256                                 // Already finished, so don't try to re-clear it
257                                 delete notif.timeout;
258                                 notif.close();
259                         }, notification.autoHideSeconds * 1000 );
260                 }
261         };
263         /**
264          * Close/hide the notification.
265          *
266          * @param {Object} options An object containing options for the closing of the notification.
267          *
268          *  - speed: Use a close speed different than the default 'slow'.
269          *  - placeholder: Set to false to disable the placeholder transition.
270          */
271         Notification.prototype.close = function ( options ) {
272                 if ( !this.isOpen ) {
273                         return;
274                 }
275                 this.isOpen = false;
276                 openNotificationCount--;
277                 // Clear any remaining timeout on close
278                 this.pause();
280                 options = $.extend( {
281                         speed: 'slow',
282                         placeholder: true
283                 }, options );
285                 // Remove the mw-notification-autohide class from the notification to avoid
286                 // having a half-closed notification counted as a notification to resume
287                 // when handling {autoHideLimit}.
288                 this.$notification.removeClass( 'mw-notification-autohide' );
290                 // Now that a notification is being closed. Start auto-hide timers for any
291                 // notification that has now become one of the first {autoHideLimit} notifications.
292                 notification.resume();
294                 this.$notification
295                         .css( {
296                                 // Don't trigger any mouse events while fading out, just in case the cursor
297                                 // happens to be right above us when we transition upwards.
298                                 pointerEvents: 'none',
299                                 // Set an absolute position so we can move upwards in the animation.
300                                 // Notification replacement doesn't look right unless we use an animation like this.
301                                 position: 'absolute',
302                                 // We must fix the width to avoid it shrinking horizontally.
303                                 width: this.$notification.width()
304                         } )
305                         // Fix the top/left position to the current computed position from which we
306                         // can animate upwards.
307                         .css( this.$notification.position() );
309                 // This needs to be done *after* notification's position has been made absolute.
310                 if ( options.placeholder ) {
311                         // Insert a placeholder with a height equal to the height of the
312                         // notification plus it's vertical margins in place of the notification
313                         var $placeholder = $( '<div>' )
314                                 .css( 'height', this.$notification.outerHeight( true ) )
315                                 .insertBefore( this.$notification );
316                 }
318                 // Animate opacity and top to create fade upwards animation for notification closing
319                 this.$notification
320                         .animate( {
321                                 opacity: 0,
322                                 top: '-=35'
323                         }, {
324                                 duration: options.speed,
325                                 complete: function () {
326                                         // Remove the notification
327                                         $( this ).remove();
328                                         // Hide the area manually after closing the last notification, since it has padding,
329                                         // causing it to obscure whatever is behind it in spite of being invisible (bug 52659).
330                                         // It's okay to do this before getting rid of the placeholder, as it's invisible as well.
331                                         if ( openNotificationCount === 0 ) {
332                                                 $area.hide();
333                                         }
334                                         if ( options.placeholder ) {
335                                                 // Use a fast slide up animation after closing to make it look like the notifications
336                                                 // below slide up into place when the notification disappears
337                                                 $placeholder.slideUp( 'fast', function () {
338                                                         // Remove the placeholder
339                                                         $( this ).remove();
340                                                 } );
341                                         }
342                                 }
343                         } );
344         };
346         /**
347          * Helper function, take a list of notification divs and call
348          * a function on the Notification instance attached to them.
349          *
350          * @private
351          * @static
352          * @param {jQuery} $notifications A jQuery object containing notification divs
353          * @param {string} fn The name of the function to call on the Notification instance
354          */
355         function callEachNotification( $notifications, fn ) {
356                 $notifications.each( function () {
357                         var notif = $( this ).data( 'mw.notification' );
358                         if ( notif ) {
359                                 notif[fn]();
360                         }
361                 } );
362         }
364         /**
365          * Initialisation.
366          * Must only be called once, and not before the document is ready.
367          * @ignore
368          */
369         function init() {
370                 var offset, $window = $( window );
372                 $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
373                         // Pause auto-hide timers when the mouse is in the notification area.
374                         .on( {
375                                 mouseenter: notification.pause,
376                                 mouseleave: notification.resume
377                         } )
378                         // When clicking on a notification close it.
379                         .on( 'click', '.mw-notification', function () {
380                                 var notif = $( this ).data( 'mw.notification' );
381                                 if ( notif ) {
382                                         notif.close();
383                                 }
384                         } )
385                         // Stop click events from <a> tags from propogating to prevent clicking.
386                         // on links from hiding a notification.
387                         .on( 'click', 'a', function ( e ) {
388                                 e.stopPropagation();
389                         } )
390                         .hide();
392                 // Prepend the notification area to the content area and save it's object.
393                 mw.util.$content.prepend( $area );
394                 offset = $area.offset();
396                 function updateAreaMode() {
397                         var isFloating = $window.scrollTop() > offset.top;
398                         $area
399                                 .toggleClass( 'mw-notification-area-floating', isFloating )
400                                 .toggleClass( 'mw-notification-area-layout', !isFloating );
401                 }
403                 $window.on( 'scroll', updateAreaMode );
405                 // Initial mode
406                 updateAreaMode();
407         }
409         /**
410          * @class mw.notification
411          * @singleton
412          */
413         notification = {
414                 /**
415                  * Pause auto-hide timers for all notifications.
416                  * Notifications will not auto-hide until resume is called.
417                  * @see mw.Notification#pause
418                  */
419                 pause: function () {
420                         callEachNotification(
421                                 $area.children( '.mw-notification' ),
422                                 'pause'
423                         );
424                 },
426                 /**
427                  * Resume any paused auto-hide timers from the beginning.
428                  * Only the first #autoHideLimit timers will be resumed.
429                  */
430                 resume: function () {
431                         callEachNotification(
432                                 // Only call resume on the first #autoHideLimit notifications.
433                                 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
434                                 // `{ autoHide: false }` notifications are at the start preventing any
435                                 // auto-hide notifications from being autohidden.
436                                 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
437                                 'resume'
438                         );
439                 },
441                 /**
442                  * Display a notification message to the user.
443                  *
444                  * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
445                  * @param {Object} options The options to use for the notification.
446                  *  See #defaults for details.
447                  * @return {mw.Notification} Notification object
448                  */
449                 notify: function ( message, options ) {
450                         var notif;
451                         options = $.extend( {}, notification.defaults, options );
453                         notif = new Notification( message, options );
455                         if ( isPageReady ) {
456                                 notif.start();
457                         } else {
458                                 preReadyNotifQueue.push( notif );
459                         }
461                         return notif;
462                 },
464                 /**
465                  * @property {Object}
466                  * The defaults for #notify options parameter.
467                  *
468                  * - autoHide:
469                  *   A boolean indicating whether the notifification should automatically
470                  *   be hidden after shown. Or if it should persist.
471                  *
472                  * - tag:
473                  *   An optional string. When a notification is tagged only one message
474                  *   with that tag will be displayed. Trying to display a new notification
475                  *   with the same tag as one already being displayed will cause the other
476                  *   notification to be closed and this new notification to open up inside
477                  *   the same place as the previous notification.
478                  *
479                  * - title:
480                  *   An optional title for the notification. Will be displayed above the
481                  *   content. Usually in bold.
482                  */
483                 defaults: {
484                         autoHide: true,
485                         tag: false,
486                         title: undefined
487                 },
489                 /**
490                  * @property {number}
491                  * Number of seconds to wait before auto-hiding notifications.
492                  */
493                 autoHideSeconds: 5,
495                 /**
496                  * @property {number}
497                  * Maximum number of notifications to count down auto-hide timers for.
498                  * Only the first #autoHideLimit notifications being displayed will
499                  * auto-hide. Any notifications further down in the list will only start
500                  * counting down to auto-hide after the first few messages have closed.
501                  *
502                  * This basically represents the number of notifications the user should
503                  * be able to process in #autoHideSeconds time.
504                  */
505                 autoHideLimit: 3
506         };
508         $( function () {
509                 var notif;
511                 init();
513                 // Handle pre-ready queue.
514                 isPageReady = true;
515                 while ( preReadyNotifQueue.length ) {
516                         notif = preReadyNotifQueue.shift();
517                         notif.start();
518                 }
519         } );
521         mw.notification = notification;
523 }( mediaWiki, jQuery ) );