/** * This plugin provides a way to build a wiki-text editing user interface around a textarea. * * @example To intialize without any modules: * $j( 'div#edittoolbar' ).wikiEditor(); * * @example To initialize with one or more modules, or to add modules after it's already been initialized: * $j( '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' : wgScriptPath + '/extensions/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 ( 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.get() 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 ( p instanceof Array && p.length >= 2 ) { return mediaWiki.msg.get( object[property + 'Msg' ][0], { 'parameters': [object[property + 'Msg' ][1]] } ); } else { return mediaWiki.msg.get( p ); } } else { return ''; } }, /** * Provides a way to extract a property of an object in a certain language, falling back on the property keyed as * 'default'. 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 ) { return object[lang || wgUserLanguage] || 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 + '?' + mediaWiki.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 || 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 ( !$j.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 $j(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 modules = {}; if ( typeof data == 'string' ) { modules[data] = {}; } else if ( typeof data == 'object' ) { modules = data; } for ( var module in modules ) { // Check for the existance of an available / supported module with a matching name and a create function if ( typeof module == 'string' && $.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 ( var 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 = { /** * Filters change events, which occur when the user interacts with the contents of the iframe. The goal of this * function is to both classify the scope of changes as 'division' or 'character' and to prevent further * processing of events which did not actually change the content of the iframe. */ 'keydown': function( event ) { switch ( event.which ) { case 90: // z case 89: // y if ( event.which == 89 && !$.browser.msie ) { // only handle y events for IE return true; } else if ( ( event.ctrlKey || event.metaKey ) && context.history.length ) { // HistoryPosition is a negative number between -1 and -context.history.length, in other words // it's the number of steps backwards from the latest state. var newPosition; if ( event.shiftKey || event.which == 89 ) { // Redo newPosition = context.historyPosition + 1; } else { // Undo newPosition = context.historyPosition - 1; } // Only act if we are switching to a valid state if ( newPosition >= ( context.history.length * -1 ) && newPosition < 0 ) { // Make sure we run the history storing code before we make this change context.fn.updateHistory( context.oldDelayedHTML != context.$content.html() ); context.oldDelayedHistoryPosition = context.historyPosition; context.historyPosition = newPosition; // Change state // FIXME: Destroys event handlers, will be a problem with template folding context.$content.html( context.history[context.history.length + context.historyPosition].html ); context.fn.purgeOffsets(); if( context.history[context.history.length + context.historyPosition].sel ) { context.fn.setSelection( { start: context.history[context.history.length + context.historyPosition].sel[0], end: context.history[context.history.length + context.historyPosition].sel[1] } ); } } // Prevent the browser from jumping in and doing its stuff return false; } break; // Intercept all tab events to provide consisten behavior across browsers // Webkit browsers insert tab characters by default into the iframe rather than changing input focus case 9: //tab // if any modifier keys are pressed, allow the browser to do it's thing if ( event.ctrlKey || event.altKey || event.shiftKey ) { return true; } else { var $tabindexList = $j( '[tabindex]:visible' ).sort( function( a, b ) { return a.tabIndex - b.tabIndex; } ); for( var i=0; i < $tabindexList.length; i++ ) { if( $tabindexList.eq( i ).attr('id') == context.$iframe.attr( 'id' ) ) { $tabindexList.get( i + 1 ).focus(); break; } } return false; } break; case 86: //v if ( event.ctrlKey && $.browser.msie && 'paste' in context.evt ) { //paste, intercepted for IE context.evt.paste( event ); } break; } return true; } }; /* 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 $( '' ) .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 $( '
' ) .attr( 'rel', 'wikiEditor-ui-view-' + options.name ) .addClass( context.view == options.name ? 'current' : null ) .append( $( '' ) .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().size() ) { 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 $( '' ) .addClass( 'wikiEditor-ui-view wikiEditor-ui-view-' + options.name ) .hide() .appendTo( context.$ui ); }, /** * Save scrollTop and cursor position for IE */ 'saveCursorAndScrollTop': function() { if ( $.client.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.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.name === 'msie' ) { context.$textarea.focus(); context.savedSelection = document.selection.createRange(); } }, /** * Restore text selection for IE */ 'restoreSelection': function() { if ( $.client.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 = $( '' ) .addClass( 'wikiEditor-ui-loading' ) .append( $( '' + mediaWiki.msg.get( 'wikieditor-loading' ) + '' ) .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( $( '' ).addClass( 'wikiEditor-ui' ) ) .wrapAll( $( '' ).addClass( 'wikiEditor-ui-view wikiEditor-ui-view-wikitext' ) ) .wrapAll( $( '' ).addClass( 'wikiEditor-ui-left' ) ) .wrapAll( $( '' ).addClass( 'wikiEditor-ui-bottom' ) ) .wrapAll( $( '' ).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( $( '' ).addClass( 'wikiEditor-ui-controls' ) .append( $( '' ).addClass( 'wikiEditor-ui-tabs' ).hide() ) .append( $( '' ).addClass( 'wikiEditor-ui-buttons' ) ) ) .before( $( '' ) ); // 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( $( '' ) ); // Attach a right container context.$wikitext.append( $( '' ).addClass( 'wikiEditor-ui-right' ) ); // Attach a top container to the left pane context.$wikitext.find( '.wikiEditor-ui-left' ).prepend( $( '' ).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 ( 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 ( e in $.wikiEditor.extensions ) { if ( $.wikiEditor.isRequired( $.wikiEditor.modules[module], e ) && context.extensions.indexOf( e ) === -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 );