/** * This plugin provides a way to build a wiki-text editing user interface around a textarea. * * @example To initialize without any modules, * overqualified `div#edittoolbar` to avoid MediaWiki's heading to id automatism: * $( '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 ... } ); */ /** * @private */ const hasOwn = Object.prototype.hasOwnProperty, /** * Array of language codes. * * @private */ fallbackChain = ( function () { // eslint-disable-next-line no-jquery/no-class-state const isRTL = $( document.body ).hasClass( 'rtl' ), chain = mw.language.getFallbackLanguageChain(); // Do not fallback to 'en' if ( chain.length >= 2 && !/^en-/.test( chain[ chain.length - 2 ] ) ) { chain.pop(); } if ( isRTL ) { chain.push( 'default-rtl' ); } chain.push( 'default' ); return chain; }() ); /** * Helper function to mark the automatic message functionality in * the autoMsg and autoSafeMsg functions as deprecated. * * @private * @param {string} property * @param {string} key */ function deprecateAutoMsg( property, key ) { const searchParam = mw.config.get( 'wgSearchType' ) === 'CirrusSearch' ? 'insource:/' + property + 'Msg: \'' + key + '\'/' : property + 'Msg: ' + key; let searchUri = mw.config.get( 'wgServer' ) + mw.util.getUrl( 'Special:Search', { search: searchParam, ns2: 1, ns8: 1 } ); if ( searchUri.slice( 0, 2 ) === '//' ) { searchUri = location.protocol + searchUri; } let messageMethod; if ( property === 'html' || property === 'text' || property === 'title' ) { messageMethod = 'mw.message( ' + JSON.stringify( key ) + ' ).parse()'; } else { messageMethod = 'mw.msg( ' + JSON.stringify( key ) + ' )'; } const deprecationMsg = mw.log.makeDeprecated( 'wikiEditor_autoMsg', 'WikiEditor: Use `' + property + ': ' + messageMethod + '` instead of `' + property + 'Msg: ' + JSON.stringify( key ) + '`.\nSearch: ' + searchUri ); deprecationMsg(); } /** * 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 existence 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: { toolbar: require( './jquery.wikiEditor.toolbar.js' ), dialogs: require( './jquery.wikiEditor.dialogs.js' ) }, /** * 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 convenient to have a lookup table of all instances of the * WikiEditor. Each context contains an instance field which contains a key that corresponds 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: [], /** * 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 if a module has a specific requirement * * @param {Object} module Module object * @param {string} requirement String identifying requirement * @return {boolean} */ isRequired: function ( module, requirement ) { if ( typeof module.req !== 'undefined' ) { for ( const req in module.req ) { if ( module.req[ req ] === requirement ) { return true; } } } return false; }, /** * Provides a way to extract messages from objects. Wraps a mw.message( ... ).text() call. * * FIXME: This is a security nightmare. Only use is for the help toolbar panel. Inline the * special need instead? * FIXME: Also, this is ludicrously complex. Just use mw.message().text() directly. * * @deprecated Since v0.5.4. Use mw.message() directly instead of Msg * * @param {Object} object Object to extract messages from * @param {string} 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'. * @return {string} */ autoMsg: function ( object, property ) { // Accept array of possible properties, of which the first one found will be used if ( typeof property === 'object' ) { for ( const 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 ) { const p = object[ property + 'Msg' ]; if ( Array.isArray( p ) && p.length >= 2 ) { deprecateAutoMsg( property, p[ 0 ] ); return mw.message.apply( mw.message, p ).text(); } else { deprecateAutoMsg( property, p ); // eslint-disable-next-line mediawiki/msg-doc return mw.message( p ).text(); } } else { return ''; } }, /** * Provides a way to extract messages from objects. Wraps a mw.message( ... ).escaped() call. * * FIXME: This is ludicrously complex. Just use mw.message().escaped() directly. * * @deprecated Since v0.5.4. Use mw.message() directly instead of Msg * * @param {Object} object Object to extract messages from * @param {string} 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'. This is then escaped. * @return {string} */ autoSafeMsg: function ( object, property ) { // Accept array of possible properties, of which the first one found will be used if ( typeof property === 'object' ) { for ( const 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 ) { const p = object[ property + 'Msg' ]; if ( Array.isArray( p ) && p.length >= 2 ) { deprecateAutoMsg( property, p[ 0 ] ); return mw.message.apply( mw.message, p ).escaped(); } else { deprecateAutoMsg( property, p ); // eslint-disable-next-line mediawiki/msg-doc return mw.message( p ).escaped(); } } 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 Object to extract property from * @return {Object} */ autoLang: function ( object ) { for ( let i = 0; i < fallbackChain.length; i++ ) { const key = fallbackChain[ i ]; if ( hasOwn.call( object, key ) ) { return object[ key ]; } } return 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 {Object} icon Icon object from e.g. toolbar config * @param {string} path Default icon path, defaults to $.wikiEditor.imgPath * @return {Object} */ autoIcon: function ( icon, path ) { path = path || $.wikiEditor.imgPath; for ( let i = 0; i < fallbackChain.length; i++ ) { const key = fallbackChain[ i ]; if ( icon && hasOwn.call( icon, key ) ) { let src = icon[ key ]; // Return a data URL immediately if ( src.slice( 0, 5 ) === 'data:' ) { return src; } // Prepend path if src is not absolute if ( src.slice( 0, 7 ) !== 'http://' && src.slice( 0, 8 ) !== 'https://' && src[ 0 ] !== '/' ) { src = path + src; } return src; } } return icon; } }; /** * jQuery plugin that provides a way to initialize a wikiEditor instance on a textarea. * * @return {jQuery} */ $.fn.wikiEditor = function () { /* 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 let 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 ) { // 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 ), // Reference to the focused element before the dialog opens, so it can be restored once it closes $focusedElem: null, // 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 shove 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, // Saved selection state for old IE (<=10) savedSelection: null, // 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 ( ctx, data ) { let modules = {}; if ( typeof data === 'string' ) { modules[ data ] = {}; } else if ( typeof data === 'object' ) { modules = data; } for ( const module in modules ) { // Check for the existence of an available module with a matching name and a create function if ( typeof module === 'string' && typeof $.wikiEditor.modules[ module ] !== 'undefined' ) { // Extend the context's core API with this module's own API calls if ( 'api' in $.wikiEditor.modules[ module ] ) { for ( const call in $.wikiEditor.modules[ module ].api ) { // Modules may not overwrite existing API functions - first come, first serve if ( !( call in ctx.api ) ) { ctx.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 && typeof ctx.modules[ module ] === 'undefined' ) { // Add a place for the module to put it's own stuff ctx.modules[ module ] = {}; // Tell the module to create itself on the context $.wikiEditor.modules[ module ].fn.create( ctx, 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. * * @param {string} name * @param {Object} event * @return {boolean} */ trigger: function ( name, event ) { // Event is an optional argument, but from here on out, at least the type field should be dependable event = event || { type: 'custom' }; // Ensure there's a place for extra information to live event.data = event.data || {}; // Allow filtering to occur if ( name in context.evt ) { if ( !context.evt[ name ]( event ) ) { return false; } } let returnFromModules = true; // Pass the event around to all modules activated on this context for ( const module in context.modules ) { if ( module in $.wikiEditor.modules && 'evt' in $.wikiEditor.modules[ module ] && name in $.wikiEditor.modules[ module ].evt ) { const ret = $.wikiEditor.modules[ module ].evt[ name ]( context, event ); if ( ret !== null ) { // if 1 returns false, the end result is false returnFromModules = returnFromModules && ret; } } } return returnFromModules; }, /** * Save text selection */ saveSelection: function () { context.$focusedElem = $( ':focus' ); context.$textarea.trigger( 'focus' ); context.savedSelection = context.$textarea.textSelection( 'getCaretPosition', { startAndEnd: true } ); }, /** * Restore text selection */ restoreSelection: function () { if ( context.savedSelection ) { context.$textarea.trigger( 'focus' ); context.$textarea.textSelection( 'setSelection', { start: context.savedSelection[ 0 ], end: context.savedSelection[ 1 ] } ); context.savedSelection = null; } if ( context.$focusedElem ) { context.$focusedElem.trigger( 'focus' ); } } }; /** * 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. */ /* Preserving cursor and focus state, which will get lost due to wrapAll */ const hasFocus = context.$textarea.is( ':focus' ); const cursorPos = context.$textarea.textSelection( 'getCaretPosition', { startAndEnd: true } ); // Encapsulate the textarea with some containers for layout context.$textarea .wrapAll( $( '
' ).addClass( 'wikiEditor-ui' ) ) .wrapAll( $( '
' ).addClass( 'wikiEditor-ui-view' ) ) .wrapAll( $( '
' ).addClass( 'wikiEditor-ui-left' ) ) .wrapAll( $( '
' ).addClass( 'wikiEditor-ui-bottom' ) ) .wrapAll( $( '
' ).addClass( 'wikiEditor-ui-text' ) ); // Restore scroll position after this wrapAll (tracked by mediawiki.action.edit) context.$textarea.prop( 'scrollTop', $( '#wpScrolltop' ).val() ); // Restore focus and cursor if needed if ( hasFocus ) { context.$textarea.trigger( 'focus' ); context.$textarea.textSelection( 'setSelection', { start: cursorPos[ 0 ], end: cursorPos[ 1 ] } ); } // 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(); // Clear all floating after the UI context.$ui.after( $( '
' ).addClass( 'wikiEditor-ui-clear' ) ); // Attach a right container context.$wikitext.append( $( '
' ).addClass( 'wikiEditor-ui-right' ), $( '
' ).addClass( 'wikiEditor-ui-clear' ) ); // Attach a top container to the left pane context.$wikitext.find( '.wikiEditor-ui-left' ).prepend( $( '
' ).addClass( 'wikiEditor-ui-top' ) ); // Setup the initial view context.view = 'wikitext'; // Trigger the "resize" event anytime the window is resized $( window ).on( 'resize', ( 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 const args = $.makeArray( arguments ); // Dynamically setup core extensions for modules that are required if ( args[ 0 ] === 'addModule' && typeof args[ 1 ] !== 'undefined' ) { let modulesArg = args[ 1 ]; if ( typeof modulesArg !== 'object' ) { modulesArg = {}; modulesArg[ args[ 1 ] ] = ''; } for ( const m in modulesArg ) { if ( m in $.wikiEditor.modules ) { // Activate all required core extensions on context for ( const extension in $.wikiEditor.extensions ) { if ( $.wikiEditor.isRequired( $.wikiEditor.modules[ m ], extension ) && context.extensions.indexOf( extension ) === -1 ) { context.extensions[ context.extensions.length ] = extension; $.wikiEditor.extensions[ extension ]( context ); } } break; } } } // There would need to be some arguments if the API is being called if ( args.length > 0 ) { // Handle API calls const callArg = args.shift(); if ( callArg in context.api ) { context.api[ callArg ]( context, args[ 0 ] || {} ); } } // Store the context for next time, and support chaining return $( this ).data( 'wikiEditor-context', context ); };