Merge "EditPage::newSectionSummary should return a value in all code paths"
[lhc/web/wiklou.git] / resources / src / mediawiki / mediawiki.notification.js
1 ( function ( mw, $ ) {
2 'use strict';
3
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 = [];
11
12 /**
13 * A Notification object for 1 message.
14 *
15 * The "_" in the name is to avoid a bug (http://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;
27
28 $notification = $( '<div class="mw-notification"></div>' )
29 .data( 'mw.notification', this )
30 .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
31
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 }
41
42 if ( options.title ) {
43 $notificationTitle = $( '<div class="mw-notification-title"></div>' )
44 .text( options.title )
45 .appendTo( $notification );
46 }
47
48 $notificationContent = $( '<div class="mw-notification-content"></div>' );
49
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 }
60
61 $notificationContent.appendTo( $notification );
62
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 }
78
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;
100
101 $area.show();
102
103 if ( this.isOpen ) {
104 return;
105 }
106
107 this.isOpen = true;
108 openNotificationCount++;
109
110 options = this.options;
111 $notification = this.$notification;
112
113 opacity = this.$notification.css( 'opacity' );
114
115 // Set the opacity to 0 so we can fade in later.
116 $notification.css( 'opacity', 0 );
117
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 }
122
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 ) {
126
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 }
158
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 } );
177
178 notif = this;
179
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 }
217
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 };
226
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;
235
236 if ( this.timeout ) {
237 clearTimeout( this.timeout );
238 delete this.timeout;
239 }
240 };
241
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 };
262
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();
279
280 options = $.extend( {
281 speed: 'slow',
282 placeholder: true
283 }, options );
284
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' );
289
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();
293
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() );
308
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 }
317
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 };
345
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 }
363
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 );
371
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
391 // Prepend the notification area to the content area and save it's object.
392 mw.util.$content.prepend( $area );
393 offset = $area.offset();
394
395 function updateAreaMode() {
396 var isFloating = $window.scrollTop() > offset.top;
397 $area
398 .toggleClass( 'mw-notification-area-floating', isFloating )
399 .toggleClass( 'mw-notification-area-layout', !isFloating );
400 }
401
402 $window.on( 'scroll', updateAreaMode );
403
404 // Initial mode
405 updateAreaMode();
406 }
407
408 /**
409 * @class mw.notification
410 * @singleton
411 */
412 notification = {
413 /**
414 * Pause auto-hide timers for all notifications.
415 * Notifications will not auto-hide until resume is called.
416 * @see mw.Notification#pause
417 */
418 pause: function () {
419 callEachNotification(
420 $area.children( '.mw-notification' ),
421 'pause'
422 );
423 },
424
425 /**
426 * Resume any paused auto-hide timers from the beginning.
427 * Only the first #autoHideLimit timers will be resumed.
428 */
429 resume: function () {
430 callEachNotification(
431 // Only call resume on the first #autoHideLimit notifications.
432 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
433 // `{ autoHide: false }` notifications are at the start preventing any
434 // auto-hide notifications from being autohidden.
435 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
436 'resume'
437 );
438 },
439
440 /**
441 * Display a notification message to the user.
442 *
443 * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
444 * @param {Object} options The options to use for the notification.
445 * See #defaults for details.
446 * @return {mw.Notification} Notification object
447 */
448 notify: function ( message, options ) {
449 var notif;
450 options = $.extend( {}, notification.defaults, options );
451
452 notif = new Notification( message, options );
453
454 if ( isPageReady ) {
455 notif.start();
456 } else {
457 preReadyNotifQueue.push( notif );
458 }
459
460 return notif;
461 },
462
463 /**
464 * @property {Object}
465 * The defaults for #notify options parameter.
466 *
467 * - autoHide:
468 * A boolean indicating whether the notifification should automatically
469 * be hidden after shown. Or if it should persist.
470 *
471 * - tag:
472 * An optional string. When a notification is tagged only one message
473 * with that tag will be displayed. Trying to display a new notification
474 * with the same tag as one already being displayed will cause the other
475 * notification to be closed and this new notification to open up inside
476 * the same place as the previous notification.
477 *
478 * - title:
479 * An optional title for the notification. Will be displayed above the
480 * content. Usually in bold.
481 */
482 defaults: {
483 autoHide: true,
484 tag: false,
485 title: undefined
486 },
487
488 /**
489 * @property {number}
490 * Number of seconds to wait before auto-hiding notifications.
491 */
492 autoHideSeconds: 5,
493
494 /**
495 * @property {number}
496 * Maximum number of notifications to count down auto-hide timers for.
497 * Only the first #autoHideLimit notifications being displayed will
498 * auto-hide. Any notifications further down in the list will only start
499 * counting down to auto-hide after the first few messages have closed.
500 *
501 * This basically represents the number of notifications the user should
502 * be able to process in #autoHideSeconds time.
503 */
504 autoHideLimit: 3
505 };
506
507 $( function () {
508 var notif;
509
510 init();
511
512 // Handle pre-ready queue.
513 isPageReady = true;
514 while ( preReadyNotifQueue.length ) {
515 notif = preReadyNotifQueue.shift();
516 notif.start();
517 }
518 } );
519
520 mw.notification = notification;
521
522 }( mediaWiki, jQuery ) );