summaryrefslogtreecommitdiff
path: root/www/wiki/resources/src/mediawiki/mediawiki.notification.js
blob: aa86a4b58acbd3678e5876d18b016a264030a2d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
( function ( mw, $ ) {
	'use strict';

	var notification,
		// The #mw-notification-area div that all notifications are contained inside.
		$area,
		// Number of open notification boxes at any time
		openNotificationCount = 0,
		isPageReady = false,
		preReadyNotifQueue = [],
		rAF = window.requestAnimationFrame || setTimeout;

	/**
	 * A Notification object for 1 message.
	 *
	 * The underscore in the name is to avoid a bug <https://github.com/senchalabs/jsduck/issues/304>.
	 * It is not part of the actual class name.
	 *
	 * The constructor is not publicly accessible; use mw.notification#notify instead.
	 * This does not insert anything into the document (see #start).
	 *
	 * @class mw.Notification_
	 * @alternateClassName mw.Notification
	 * @constructor
	 * @private
	 * @param {mw.Message|jQuery|HTMLElement|string} message
	 * @param {Object} options
	 */
	function Notification( message, options ) {
		var $notification, $notificationContent;

		$notification = $( '<div class="mw-notification"></div>' )
			.data( 'mw.notification', this )
			.addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );

		if ( options.tag ) {
			// Sanitize options.tag before it is used by any code. (Including Notification class methods)
			options.tag = options.tag.replace( /[ _-]+/g, '-' ).replace( /[^-a-z0-9]+/ig, '' );
			if ( options.tag ) {
				$notification.addClass( 'mw-notification-tag-' + options.tag );
			} else {
				delete options.tag;
			}
		}

		if ( options.type ) {
			// Sanitize options.type
			options.type = options.type.replace( /[ _-]+/g, '-' ).replace( /[^-a-z0-9]+/ig, '' );
			$notification.addClass( 'mw-notification-type-' + options.type );
		}

		if ( options.title ) {
			$( '<div class="mw-notification-title"></div>' )
				.text( options.title )
				.appendTo( $notification );
		}

		$notificationContent = $( '<div class="mw-notification-content"></div>' );

		if ( typeof message === 'object' ) {
			// Handle mw.Message objects separately from DOM nodes and jQuery objects
			if ( message instanceof mw.Message ) {
				$notificationContent.html( message.parse() );
			} else {
				$notificationContent.append( message );
			}
		} else {
			$notificationContent.text( message );
		}

		$notificationContent.appendTo( $notification );

		// Private state parameters, meant for internal use only
		// autoHideSeconds: String alias for number of seconds for timeout of auto-hiding notifications.
		// isOpen: Set to true after .start() is called to avoid double calls.
		//         Set back to false after .close() to avoid duplicating the close animation.
		// isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
		//           Set to true initially so .start() can call .resume().
		// message: The message passed to the notification. Unused now but may be used in the future
		//          to stop replacement of a tagged notification with another notification using the same message.
		// options: The options passed to the notification with a little sanitization. Used by various methods.
		// $notification: jQuery object containing the notification DOM node.
		// timeout: Holds appropriate methods to set/clear timeouts
		this.autoHideSeconds = options.autoHideSeconds &&
			notification.autoHideSeconds[ options.autoHideSeconds ] ||
			notification.autoHideSeconds.short;
		this.isOpen = false;
		this.isPaused = true;
		this.message = message;
		this.options = options;
		this.$notification = $notification;
		if ( options.visibleTimeout ) {
			this.timeout = require( 'mediawiki.visibleTimeout' );
		} else {
			this.timeout = {
				set: setTimeout,
				clear: clearTimeout
			};
		}
	}

	/**
	 * Start the notification. Called automatically by mw.notification#notify
	 * (possibly asynchronously on document-ready).
	 *
	 * This inserts the notification into the page, closes any matching tagged notifications,
	 * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
	 *
	 * @private
	 */
	Notification.prototype.start = function () {
		var options, $notification, $tagMatches, autohideCount;

		$area.css( 'display', '' );

		if ( this.isOpen ) {
			return;
		}

		this.isOpen = true;
		openNotificationCount++;

		options = this.options;
		$notification = this.$notification;

		if ( options.tag ) {
			// Find notifications with the same tag
			$tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
		}

		// If we found existing notification with the same tag, replace them
		if ( options.tag && $tagMatches.length ) {

			// While there can be only one "open" notif with a given tag, there can be several
			// matches here because they remain in the DOM until the animation is finished.
			$tagMatches.each( function () {
				var notif = $( this ).data( 'mw.notification' );
				if ( notif && notif.isOpen ) {
					// Detach from render flow with position absolute so that the new tag can
					// occupy its space instead.
					notif.$notification
						.css( {
							position: 'absolute',
							width: notif.$notification.width()
						} )
						.css( notif.$notification.position() )
						.addClass( 'mw-notification-replaced' );
					notif.close();
				}
			} );

			$notification
				.insertBefore( $tagMatches.first() )
				.addClass( 'mw-notification-visible' );
		} else {
			$area.append( $notification );
			rAF( function () {
				// This frame renders the element in the area (invisible)
				rAF( function () {
					$notification.addClass( 'mw-notification-visible' );
				} );
			} );
		}

		// By default a notification is paused.
		// If this notification is within the first {autoHideLimit} notifications then
		// start the auto-hide timer as soon as it's created.
		autohideCount = $area.find( '.mw-notification-autohide' ).length;
		if ( autohideCount <= notification.autoHideLimit ) {
			this.resume();
		}
	};

	/**
	 * Pause any running auto-hide timer for this notification
	 */
	Notification.prototype.pause = function () {
		if ( this.isPaused ) {
			return;
		}
		this.isPaused = true;

		if ( this.timeoutId ) {
			this.timeout.clear( this.timeoutId );
			delete this.timeoutId;
		}
	};

	/**
	 * Start autoHide timer if not already started.
	 * Does nothing if autoHide is disabled.
	 * Either to resume from pause or to make the first start.
	 */
	Notification.prototype.resume = function () {
		var notif = this;

		if ( !notif.isPaused ) {
			return;
		}
		// Start any autoHide timeouts
		if ( notif.options.autoHide ) {
			notif.isPaused = false;
			notif.timeoutId = notif.timeout.set( function () {
				// Already finished, so don't try to re-clear it
				delete notif.timeoutId;
				notif.close();
			}, this.autoHideSeconds * 1000 );
		}
	};

	/**
	 * Close the notification.
	 */
	Notification.prototype.close = function () {
		var notif = this;

		if ( !this.isOpen ) {
			return;
		}

		this.isOpen = false;
		openNotificationCount--;

		// Clear any remaining timeout on close
		this.pause();

		// Remove the mw-notification-autohide class from the notification to avoid
		// having a half-closed notification counted as a notification to resume
		// when handling {autoHideLimit}.
		this.$notification.removeClass( 'mw-notification-autohide' );

		// Now that a notification is being closed. Start auto-hide timers for any
		// notification that has now become one of the first {autoHideLimit} notifications.
		notification.resume();

		rAF( function () {
			notif.$notification.removeClass( 'mw-notification-visible' );

			setTimeout( function () {
				if ( openNotificationCount === 0 ) {
					// Hide the area after the last notification closes. Otherwise, the padding on
					// the area can be obscure content, despite the area being empty/invisible (T54659). // FIXME
					$area.css( 'display', 'none' );
					notif.$notification.remove();
				} else {
					notif.$notification.slideUp( 'fast', function () {
						$( this ).remove();
					} );
				}
			}, 500 );
		} );
	};

	/**
	 * Helper function, take a list of notification divs and call
	 * a function on the Notification instance attached to them.
	 *
	 * @private
	 * @static
	 * @param {jQuery} $notifications A jQuery object containing notification divs
	 * @param {string} fn The name of the function to call on the Notification instance
	 */
	function callEachNotification( $notifications, fn ) {
		$notifications.each( function () {
			var notif = $( this ).data( 'mw.notification' );
			if ( notif ) {
				notif[ fn ]();
			}
		} );
	}

	/**
	 * Initialisation.
	 * Must only be called once, and not before the document is ready.
	 *
	 * @ignore
	 */
	function init() {
		var offset, notif,
			isFloating = false;

		function updateAreaMode() {
			var shouldFloat = window.pageYOffset > offset.top;
			if ( isFloating === shouldFloat ) {
				return;
			}
			isFloating = shouldFloat;
			$area
				.toggleClass( 'mw-notification-area-floating', isFloating )
				.toggleClass( 'mw-notification-area-layout', !isFloating );
		}

		// Write to the DOM:
		// Prepend the notification area to the content area and save its object.
		$area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
			// Pause auto-hide timers when the mouse is in the notification area.
			.on( {
				mouseenter: notification.pause,
				mouseleave: notification.resume
			} )
			// When clicking on a notification close it.
			.on( 'click', '.mw-notification', function () {
				var notif = $( this ).data( 'mw.notification' );
				if ( notif ) {
					notif.close();
				}
			} )
			// Stop click events from <a> tags from propogating to prevent clicking.
			// on links from hiding a notification.
			.on( 'click', 'a', function ( e ) {
				e.stopPropagation();
			} );

		mw.util.$content.prepend( $area );

		// Read from the DOM:
		// Must be in the next frame to avoid synchronous layout
		// computation from offset()/getBoundingClientRect().
		rAF( function () {
			offset = $area.offset();

			// Initial mode (reads, and then maybe writes)
			updateAreaMode();

			// Once we have the offset for where it would normally render, set the
			// initial state of the (currently empty) notification area to be hidden.
			$area.css( 'display', 'none' );

			$( window ).on( 'scroll', updateAreaMode );

			// Handle pre-ready queue.
			isPageReady = true;
			while ( preReadyNotifQueue.length ) {
				notif = preReadyNotifQueue.shift();
				notif.start();
			}
		} );
	}

	/**
	 * @class mw.notification
	 * @singleton
	 */
	notification = {
		/**
		 * Pause auto-hide timers for all notifications.
		 * Notifications will not auto-hide until resume is called.
		 *
		 * @see mw.Notification#pause
		 */
		pause: function () {
			callEachNotification(
				$area.children( '.mw-notification' ),
				'pause'
			);
		},

		/**
		 * Resume any paused auto-hide timers from the beginning.
		 * Only the first #autoHideLimit timers will be resumed.
		 */
		resume: function () {
			callEachNotification(
				// Only call resume on the first #autoHideLimit notifications.
				// Exclude noautohide notifications to avoid bugs where #autoHideLimit
				// `{ autoHide: false }` notifications are at the start preventing any
				// auto-hide notifications from being autohidden.
				$area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
				'resume'
			);
		},

		/**
		 * Display a notification message to the user.
		 *
		 * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
		 * @param {Object} options The options to use for the notification.
		 *  See #defaults for details.
		 * @return {mw.Notification} Notification object
		 */
		notify: function ( message, options ) {
			var notif;
			options = $.extend( {}, notification.defaults, options );

			notif = new Notification( message, options );

			if ( isPageReady ) {
				notif.start();
			} else {
				preReadyNotifQueue.push( notif );
			}

			return notif;
		},

		/**
		 * @property {Object}
		 * The defaults for #notify options parameter.
		 *
		 * - autoHide:
		 *   A boolean indicating whether the notifification should automatically
		 *   be hidden after shown. Or if it should persist.
		 *
		 * - autoHideSeconds:
		 *   Key to #autoHideSeconds for number of seconds for timeout of auto-hide
		 *   notifications.
		 *
		 * - tag:
		 *   An optional string. When a notification is tagged only one message
		 *   with that tag will be displayed. Trying to display a new notification
		 *   with the same tag as one already being displayed will cause the other
		 *   notification to be closed and this new notification to open up inside
		 *   the same place as the previous notification.
		 *
		 * - title:
		 *   An optional title for the notification. Will be displayed above the
		 *   content. Usually in bold.
		 *
		 * - type:
		 *   An optional string for the type of the message used for styling:
		 *   Examples: 'info', 'warn', 'error'.
		 *
		 * - visibleTimeout:
		 *   A boolean indicating if the autoHide timeout should be based on
		 *   time the page was visible to user. Or if it should use wall clock time.
		 */
		defaults: {
			autoHide: true,
			autoHideSeconds: 'short',
			tag: null,
			title: null,
			type: null,
			visibleTimeout: true
		},

		/**
		 * @private
		 * @property {Object}
		 */
		autoHideSeconds: {
			'short': 5,
			'long': 30
		},

		/**
		 * @property {number}
		 * Maximum number of simultaneous notifications to start auto-hide timers for.
		 * Only this number of notifications being displayed will be auto-hidden at one time.
		 * Any additional notifications in the list will only start counting their timeout for
		 * auto-hiding after the previous messages have been closed.
		 *
		 * This basically represents the minimal number of notifications the user should
		 * be able to process during the {@link #defaults default} #autoHideSeconds time.
		 */
		autoHideLimit: 3
	};

	$( init );

	mw.notification = notification;

}( mediaWiki, jQuery ) );