summaryrefslogtreecommitdiff
path: root/extensions/WikiEditor/modules/jquery.wikiEditor.js
blob: def8b69e8ac3ce77f4fb205783df0d7636f2bc7a (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
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
/**
 * This plugin provides a way to build a wiki-text editing user interface around a textarea.
 *
 * @example To intialize without any modules:
 *     $( 'div#edittoolbar' ).wikiEditor();
 *
 * @example To initialize with one or more modules, or to add modules after it's already been initialized:
 *     $( 'textarea#wpTextbox1' ).wikiEditor( 'addModule', 'toolbar', { ... config ... } );
 *
 */
( function ( $ ) {

/**
 * Global static object for wikiEditor that provides generally useful functionality to all modules and contexts.
 */
$.wikiEditor = {
	/**
	 * For each module that is loaded, static code shared by all instances is loaded into this object organized by
	 * module name. The existance of a module in this object only indicates the module is available. To check if a
	 * module is in use by a specific context check the context.modules object.
	 */
	modules: {},

	/**
	 * A context can be extended, such as adding iframe support, on a per-wikiEditor instance basis.
	 */
	extensions: {},

	/**
	 * In some cases like with the iframe's HTML file, it's convienent to have a lookup table of all instances of the
	 * WikiEditor. Each context contains an instance field which contains a key that corrosponds to a reference to the
	 * textarea which the WikiEditor was build around. This way, by passing a simple integer you can provide a way back
	 * to a specific context.
	 */
	instances: [],

	/**
	 * For each browser name, an array of conditions that must be met are supplied in [operaton, value]-form where
	 * operation is a string containing a JavaScript compatible binary operator and value is either a number to be
	 * compared with $.browser.versionNumber or a string to be compared with $.browser.version. If a browser is not
	 * specifically mentioned, we just assume things will work.
	 */
	browsers: {
		// Left-to-right languages
		ltr: {
			// The toolbar layout is broken in IE6
			msie: [['>=', 7]],
			// Layout issues in FF < 2
			firefox: [['>=', 2]],
			// Text selection bugs galore - this may be a different situation with the new iframe-based solution
			opera: [['>=', 9.6]],
			// jQuery minimums
			safari: [['>=', 3]],
			chrome: [['>=', 3]],
			netscape: [['>=', 9]],
			blackberry: false,
			ipod: false,
			iphone: false
		},
		// Right-to-left languages
		rtl: {
			// The toolbar layout is broken in IE 7 in RTL mode, and IE6 in any mode
			msie: [['>=', 8]],
			// Layout issues in FF < 2
			firefox: [['>=', 2]],
			// Text selection bugs galore - this may be a different situation with the new iframe-based solution
			opera: [['>=', 9.6]],
			// jQuery minimums
			safari: [['>=', 3]],
			chrome: [['>=', 3]],
			netscape: [['>=', 9]],
			blackberry: false,
			ipod: false,
			iphone: false
		}
	},

	/**
	 * Path to images - this is a bit messy, and it would need to change if this code (and images) gets moved into the
	 * core - or anywhere for that matter...
	 */
	imgPath : mw.config.get( 'wgExtensionAssetsPath' ) + '/WikiEditor/modules/images/',

	/**
	 * Checks the current browser against the browsers object to determine if the browser has been black-listed or not.
	 * Because these rules are often very complex, the object contains configurable operators and can check against
	 * either the browser version number or string. This process also involves checking if the current browser is amung
	 * those which we have configured as compatible or not. If the browser was not configured as comptible we just go on
	 * assuming things will work - the argument here is to prevent the need to update the code when a new browser comes
	 * to market. The assumption here is that any new browser will be built on an existing engine or be otherwise so
	 * similar to another existing browser that things actually do work as expected. The merrits of this argument, which
	 * is essentially to blacklist rather than whitelist are debateable, but at this point we've decided it's the more
	 * "open-web" way to go.
	 * @param module Module object, defaults to $.wikiEditor
	 */
	isSupported: function ( module ) {
		// Fallback to the wikiEditor browser map if no special map is provided in the module
		var mod = module && 'browsers' in module ? module : $.wikiEditor;
		// Check for and make use of cached value and early opportunities to bail
		if ( typeof mod.supported !== 'undefined' ) {
			// Cache hit
			return mod.supported;
		}
		// Run a browser support test and then cache and return the result
		return mod.supported = $.client.test( mod.browsers );
	},

	/**
	 * Checks if a module has a specific requirement
	 * @param module Module object
	 * @param requirement String identifying requirement
	 */
	isRequired: function ( module, requirement ) {
		if ( typeof module.req !== 'undefined' ) {
			for ( var req in module.req ) {
				if ( module.req[req] == requirement ) {
					return true;
				}
			}
		}
		return false;
	},

	/**
	 * Provides a way to extract messages from objects. Wraps the mediaWiki.msg() function, which
	 * may eventually become a wrapper for some kind of core MW functionality.
	 *
	 * @param object Object to extract messages from
	 * @param property String of name of property which contains the message. This should be the base name of the
	 * property, which means that in the case of the object { this: 'that', fooMsg: 'bar' }, passing property as 'this'
	 * would return the raw text 'that', while passing property as 'foo' would return the internationalized message
	 * with the key 'bar'.
	 */
	autoMsg: function ( object, property ) {
		// Accept array of possible properties, of which the first one found will be used
		if ( typeof property == 'object' ) {
			for ( var i in property ) {
				if ( property[i] in object || property[i] + 'Msg' in object ) {
					property = property[i];
					break;
				}
			}
		}
		if ( property in object ) {
			return object[property];
		} else if ( property + 'Msg' in object ) {
			var p = object[property + 'Msg'];
			if ( $.isArray( p ) && p.length >= 2 ) {
				return mediaWiki.message.apply( mediaWiki.message, p ).plain();
			} else {
				return mediaWiki.message( p ).plain();
			}
		} else {
			return '';
		}
	},

	/**
	 * Provides a way to extract a property of an object in a certain language, falling back on the property keyed as
	 * 'default' or 'default-rtl'. If such key doesn't exist, the object itself is considered the actual value, which
	 * should ideally be the case so that you may use a string or object of any number of strings keyed by language
	 * with a default.
	 *
	 * @param object Object to extract property from
	 * @param lang Language code, defaults to wgUserLanguage
	 */
	autoLang: function ( object, lang ) {
		var defaultKey = $( 'body' ).hasClass( 'rtl' ) ? 'default-rtl' : 'default';
		return object[lang || mw.config.get( 'wgUserLanguage' )] || object[defaultKey] || object['default'] || object;
	},

	/**
	 * Provides a way to extract the path of an icon in a certain language, automatically appending a version number for
	 * caching purposes and prepending an image path when icon paths are relative.
	 *
	 * @param icon Icon object from e.g. toolbar config
	 * @param path Default icon path, defaults to $.wikiEditor.imgPath
	 * @param lang Language code, defaults to wgUserLanguage
	 */
	autoIcon: function ( icon, path, lang ) {
		var src = $.wikiEditor.autoLang( icon, lang );
		path = path || $.wikiEditor.imgPath;
		// Prepend path if src is not absolute
		if ( src.substr( 0, 7 ) != 'http://' && src.substr( 0, 8 ) != 'https://' && src[0] != '/' ) {
			src = path + src;
		}
		return src + '?' + mw.loader.version( 'jquery.wikiEditor' );
	},

	/**
	 * Get the sprite offset for a language if available, icon for a language if available, or the default offset or icon,
	 * in that order of preference.
	 * @param icon Icon object, see autoIcon()
	 * @param offset Offset object
	 * @param path Icon path, see autoIcon()
	 * @param lang Language code, defaults to wgUserLanguage
	 */
	autoIconOrOffset: function ( icon, offset, path, lang ) {
		lang = lang || mw.config.get( 'wgUserLanguage' );
		if ( typeof offset == 'object' && lang in offset ) {
			return offset[lang];
		} else if ( typeof icon == 'object' && lang in icon ) {
			return $.wikiEditor.autoIcon( icon, undefined, lang );
		} else {
			return $.wikiEditor.autoLang( offset, lang );
		}
	}
};

/**
 * jQuery plugin that provides a way to initialize a wikiEditor instance on a textarea.
 */
$.fn.wikiEditor = function () {

// Skip any further work when running in browsers that are unsupported
if ( !$.wikiEditor.isSupported() ) {
	return $(this);
}

/* Initialization */

// The wikiEditor context is stored in the element's data, so when this function gets called again we can pick up right
// where we left off
var context = $(this).data( 'wikiEditor-context' );
// On first call, we need to set things up, but on all following calls we can skip right to the API handling
if ( !context || typeof context == 'undefined' ) {

	// Star filling the context with useful data - any jQuery selections, as usual should be named with a preceding $
	context = {
		// Reference to the textarea element which the wikiEditor is being built around
		'$textarea': $(this),
		// Container for any number of mutually exclusive views that are accessible by tabs
		'views': {},
		// Container for any number of module-specific data - only including data for modules in use on this context
		'modules': {},
		// General place to shouve bits of data into
		'data': {},
		// Unique numeric ID of this instance used both for looking up and differentiating instances of wikiEditor
		'instance': $.wikiEditor.instances.push( $(this) ) - 1,
		// Array mapping elements in the textarea to character offsets
		'offsets': null,
		// Cache for context.fn.htmlToText()
		'htmlToTextMap': {},
		// The previous HTML of the iframe, stored to detect whether something really changed.
		'oldHTML': null,
		// Same for delayedChange()
		'oldDelayedHTML': null,
		// The previous selection of the iframe, stored to detect whether the selection has changed
		'oldDelayedSel': null,
		// Saved selection state for IE
		'savedSelection': null,
		// Stack of states in { html: [string] } form
		'history': [],
		// Current history state position - this is number of steps backwards, so it's always -1 or less
		'historyPosition': -1,
		/// The previous historyPosition, stored to detect if change events were due to an undo or redo action
		'oldDelayedHistoryPosition': -1,
		// List of extensions active on this context
		'extensions': []
	};

	/**
	 * Externally Accessible API
	 *
	 * These are available using calls to $(selection).wikiEditor( call, data ) where selection is a jQuery selection
	 * of the textarea that the wikiEditor instance was built around.
	 */

	context.api = {
		/**
		 * Activates a module on a specific context with optional configuration data.
		 *
		 * @param data Either a string of the name of a module to add without any additional configuration parameters,
		 * or an object with members keyed with module names and valued with configuration objects.
		 */
		'addModule': function ( context, data ) {
			var module, call,
				modules = {};
			if ( typeof data == 'string' ) {
				modules[data] = {};
			} else if ( typeof data == 'object' ) {
				modules = data;
			}
			for ( module in modules ) {
				// Check for the existance of an available / supported module with a matching name and a create function
				if ( typeof module == 'string' && typeof $.wikiEditor.modules[module] !== 'undefined' &&
						$.wikiEditor.isSupported( $.wikiEditor.modules[module] ) )
				{
					// Extend the context's core API with this module's own API calls
					if ( 'api' in $.wikiEditor.modules[module] ) {
						for ( call in $.wikiEditor.modules[module].api ) {
							// Modules may not overwrite existing API functions - first come, first serve
							if ( !( call in context.api ) ) {
								context.api[call] = $.wikiEditor.modules[module].api[call];
							}
						}
					}
					// Activate the module on this context
					if ( 'fn' in $.wikiEditor.modules[module] && 'create' in $.wikiEditor.modules[module].fn ) {
						// Add a place for the module to put it's own stuff
						context.modules[module] = {};
						// Tell the module to create itself on the context
						$.wikiEditor.modules[module].fn.create( context, modules[module] );
					}
				}
			}
		}
	};

	/**
	 * Event Handlers
	 *
	 * These act as filters returning false if the event should be ignored or returning true if it should be passed
	 * on to all modules. This is also where we can attach some extra information to the events.
	 */

	context.evt = {
		/* Empty until extensions add some; see jquery.wikiEditor.iframe.js for examples. */
	};

	/* Internal Functions */

	context.fn = {
		/**
		 * Executes core event filters as well as event handlers provided by modules.
		 */
		trigger: function ( name, event ) {
			// Event is an optional argument, but from here on out, at least the type field should be dependable
			if ( typeof event == 'undefined' ) {
				event = { 'type': 'custom' };
			}
			// Ensure there's a place for extra information to live
			if ( typeof event.data == 'undefined' ) {
				event.data = {};
			}

			// Allow filtering to occur
			if ( name in context.evt ) {
				if ( !context.evt[name]( event ) ) {
					return false;
				}
			}
			var returnFromModules = null; //they return null by default
			// Pass the event around to all modules activated on this context

			for ( var module in context.modules ) {
				if (
					module in $.wikiEditor.modules &&
					'evt' in $.wikiEditor.modules[module] &&
					name in $.wikiEditor.modules[module].evt
				) {
					var ret = $.wikiEditor.modules[module].evt[name]( context, event );
					if (ret !== null) {
						//if 1 returns false, the end result is false
						if( returnFromModules === null ) {
							returnFromModules = ret;
						} else {
							returnFromModules = returnFromModules && ret;
						}
					}
				}
			}
			if ( returnFromModules !== null ) {
				return returnFromModules;
			} else {
				return true;
			}
		},

		/**
		 * Adds a button to the UI
		 */
		addButton: function ( options ) {
			// Ensure that buttons and tabs are visible
			context.$controls.show();
			context.$buttons.show();
			return $( '<button>' )
				.text( $.wikiEditor.autoMsg( options, 'caption' ) )
				.click( options.action )
				.appendTo( context.$buttons );
		},

		/**
		 * Adds a view to the UI, which is accessed using a set of tabs. Views are mutually exclusive and by default a
		 * wikitext view will be present. Only when more than one view exists will the tabs will be visible.
		 */
		addView: function ( options ) {
			// Adds a tab
			function addTab( options ) {
				// Ensure that buttons and tabs are visible
				context.$controls.show();
				context.$tabs.show();
				// Return the newly appended tab
				return $( '<div>' )
					.attr( 'rel', 'wikiEditor-ui-view-' + options.name )
					.addClass( context.view == options.name ? 'current' : null )
					.append( $( '<a>' )
						.attr( 'href', '#' )
						.mousedown( function () {
							// No dragging!
							return false;
						} )
						.click( function ( event ) {
							context.$ui.find( '.wikiEditor-ui-view' ).hide();
							context.$ui.find( '.' + $(this).parent().attr( 'rel' ) ).show();
							context.$tabs.find( 'div' ).removeClass( 'current' );
							$(this).parent().addClass( 'current' );
							$(this).blur();
							if ( 'init' in options && typeof options.init == 'function' ) {
								options.init( context );
							}
							event.preventDefault();
							return false;
						} )
						.text( $.wikiEditor.autoMsg( options, 'title' ) )
					)
					.appendTo( context.$tabs );
			}
			// Automatically add the previously not-needed wikitext tab
			if ( !context.$tabs.children().length ) {
				addTab( { 'name': 'wikitext', 'titleMsg': 'wikieditor-wikitext-tab' } );
			}
			// Add the tab for the view we were actually asked to add
			addTab( options );
			// Return newly appended view
			return $( '<div>' )
				.addClass( 'wikiEditor-ui-view wikiEditor-ui-view-' + options.name )
				.hide()
				.appendTo( context.$ui );
		},

		/**
		 * Save scrollTop and cursor position for IE
		 */
		saveCursorAndScrollTop: function () {
			if ( $.client.profile().name === 'msie' ) {
				var IHateIE = {
					'scrollTop' : context.$textarea.scrollTop(),
					'pos': context.$textarea.textSelection( 'getCaretPosition', { startAndEnd: true } )
				};
				context.$textarea.data( 'IHateIE', IHateIE );
			}
		},

		/**
		 * Restore scrollTo and cursor position for IE
		 */
		restoreCursorAndScrollTop: function () {
			if ( $.client.profile().name === 'msie' ) {
				var IHateIE = context.$textarea.data( 'IHateIE' );
				if ( IHateIE ) {
					context.$textarea.scrollTop( IHateIE.scrollTop );
					context.$textarea.textSelection( 'setSelection', { start: IHateIE.pos[0], end: IHateIE.pos[1] } );
					context.$textarea.data( 'IHateIE', null );
				}
			}
		},

		/**
		 * Save text selection for IE
		 */
		saveSelection: function () {
			if ( $.client.profile().name === 'msie' ) {
				context.$textarea.focus();
				context.savedSelection = document.selection.createRange();
			}
		},

		/**
		 * Restore text selection for IE
		 */
		restoreSelection: function () {
			if ( $.client.profile().name === 'msie' && context.savedSelection !== null ) {
				context.$textarea.focus();
				context.savedSelection.select();
				context.savedSelection = null;
			}
		}
	};

	/**
	 * Base UI Construction
	 *
	 * The UI is built from several containers, the outer-most being a div classed as "wikiEditor-ui". These containers
	 * provide a certain amount of "free" layout, but in some situations procedural layout is needed, which is performed
	 * as a response to the "resize" event.
	 */

	// Assemble a temporary div to place over the wikiEditor while it's being constructed
	/* Disabling our loading div for now
	var $loader = $( '<div>' )
		.addClass( 'wikiEditor-ui-loading' )
		.append( $( '<span>' + mediaWiki.msg( 'wikieditor-loading' ) + '</span>' )
			.css( 'marginTop', context.$textarea.height() / 2 ) );
	*/
	// Encapsulate the textarea with some containers for layout
	context.$textarea
	/* Disabling our loading div for now
		.after( $loader )
		.add( $loader )
	*/
		.wrapAll( $( '<div>' ).addClass( 'wikiEditor-ui' ) )
		.wrapAll( $( '<div>' ).addClass( 'wikiEditor-ui-view wikiEditor-ui-view-wikitext' ) )
		.wrapAll( $( '<div>' ).addClass( 'wikiEditor-ui-left' ) )
		.wrapAll( $( '<div>' ).addClass( 'wikiEditor-ui-bottom' ) )
		.wrapAll( $( '<div>' ).addClass( 'wikiEditor-ui-text' ) );
	// Get references to some of the newly created containers
	context.$ui = context.$textarea.parent().parent().parent().parent().parent();
	context.$wikitext = context.$textarea.parent().parent().parent().parent();
	// Add in tab and button containers
	context.$wikitext
		.before(
			$( '<div>' ).addClass( 'wikiEditor-ui-controls' )
				.append( $( '<div>' ).addClass( 'wikiEditor-ui-tabs' ).hide() )
				.append( $( '<div>' ).addClass( 'wikiEditor-ui-buttons' ) )
		)
		.before( $( '<div style="clear: both;"></div>' ) );
	// Get references to some of the newly created containers
	context.$controls = context.$ui.find( '.wikiEditor-ui-buttons' ).hide();
	context.$buttons = context.$ui.find( '.wikiEditor-ui-buttons' );
	context.$tabs = context.$ui.find( '.wikiEditor-ui-tabs' );
	// Clear all floating after the UI
	context.$ui.after( $( '<div style="clear: both;"></div>' ) );
	// Attach a right container
	context.$wikitext.append( $( '<div>' ).addClass( 'wikiEditor-ui-right' ) );
	// Attach a top container to the left pane
	context.$wikitext.find( '.wikiEditor-ui-left' ).prepend( $( '<div>' ).addClass( 'wikiEditor-ui-top' ) );
	// Setup the intial view
	context.view = 'wikitext';
	// Trigger the "resize" event anytime the window is resized
	$( window ).resize( function ( event ) {
		context.fn.trigger( 'resize', event );
	} );
}

/* API Execution */

// Since javascript gives arguments as an object, we need to convert them so they can be used more easily
var args = $.makeArray( arguments );

// Dynamically setup core extensions for modules that are required
if ( args[0] == 'addModule' && typeof args[1] !== 'undefined' ) {
	var modules = args[1];
	if ( typeof modules !== "object" ) {
		modules = {};
		modules[args[1]] = '';
	}
	for ( var module in modules ) {
		// Only allow modules which are supported (and thus actually being turned on) affect the decision to extend
		if ( module in $.wikiEditor.modules && $.wikiEditor.isSupported( $.wikiEditor.modules[module] ) ) {
			// Activate all required core extensions on context
			for ( var e in $.wikiEditor.extensions ) {
				if (
					$.wikiEditor.isRequired( $.wikiEditor.modules[module], e ) &&
					$.inArray( e, context.extensions ) === -1
				) {
					context.extensions[context.extensions.length] = e;
					$.wikiEditor.extensions[e]( context );
				}
			}
			break;
		}
	}
}

// There would need to be some arguments if the API is being called
if ( args.length > 0 ) {
	// Handle API calls
	var call = args.shift();
	if ( call in context.api ) {
		context.api[call]( context, typeof args[0] === 'undefined' ? {} : args[0] );
	}
}

// Store the context for next time, and support chaining
return $(this).data( 'wikiEditor-context', context );

};

}( jQuery ) );