/* Ace syntax-highlighting code editor extension for wikiEditor */ /* global ace */ /* eslint-disable no-jquery/no-global-selector */ ( function () { $.wikiEditor.modules.codeEditor = { /** * Core Requirements */ req: [ 'codeEditor' ], /** * Configuration */ cfg: { // }, /** * API accessible functions */ api: { // }, /** * Event handlers */ evt: { // }, /** * Internally used functions */ fn: { } }; $.wikiEditor.extensions.codeEditor = function ( context ) { var saveAndExtend, textSelectionFn, hasErrorsOnSave = false, selectedLine = 0, returnFalse = function () { return false; }, api = new mw.Api(); // Initialize state var cookieEnabled = parseInt( mw.cookie.get( 'codeEditor-' + context.instance + '-showInvisibleChars' ), 10 ); context.showInvisibleChars = ( cookieEnabled === 1 ); cookieEnabled = parseInt( mw.cookie.get( 'codeEditor-' + context.instance + '-lineWrappingActive' ), 10 ); context.lineWrappingActive = ( cookieEnabled === 1 ); /* * Event Handlers * * WikiEditor inspects the 'evt' object for event names and uses them if present as additional * event handlers that fire before the default handling. * To prevent WikiEditor from running its own handling, handlers should return false. * * This is also where we can attach some extra information to the events. */ context.evt = Object.assign( context.evt, { keydown: returnFalse, change: returnFalse, delayedChange: returnFalse, cut: returnFalse, paste: returnFalse, ready: returnFalse, codeEditorSubmit: function () { var form = this; context.evt.codeEditorSync(); if ( hasErrorsOnSave ) { hasErrorsOnSave = false; OO.ui.confirm( mw.msg( 'codeeditor-save-with-errors' ) ).done( function ( confirmed ) { if ( confirmed ) { // Programmatic submit doesn't retrigger this event listener form.submit(); } } ); return false; } return true; }, codeEditorSave: function () { if ( context.codeEditor.getSession().getAnnotations().some( function ( ann ) { return ann.type === 'error'; } ) ) { hasErrorsOnSave = true; } }, codeEditorSync: function () { context.$textarea.val( context.$textarea.textSelection( 'getContents' ) ); } } ); // Make sure to cast '0' to false context.codeEditorActive = !!Number( mw.user.options.get( 'usecodeeditor' ) ); /** * Internally used functions */ context.fn = Object.assign( context.fn, { isCodeEditorActive: function () { return context.codeEditorActive; }, isShowInvisibleChars: function () { return context.showInvisibleChars; }, isLineWrappingActive: function () { return context.lineWrappingActive; }, changeCookieValue: function ( cookieName, value ) { mw.cookie.set( 'codeEditor-' + context.instance + '-' + cookieName, value ); }, aceGotoLineColumn: function () { OO.ui.prompt( mw.msg( 'codeeditor-gotoline-prompt' ), { textInput: { placeholder: mw.msg( 'codeeditor-gotoline-placeholder' ) } } ).done( function ( result ) { if ( !result ) { return; } var matches = result.split( ':' ); var line = 0; var column = 0; if ( matches.length > 0 ) { line = +matches[ 0 ]; if ( isNaN( line ) ) { return; } else { // Lines are zero-indexed line--; } } if ( matches.length > 1 ) { column = +matches[ 1 ]; if ( isNaN( column ) ) { column = 0; } } context.codeEditor.navigateTo( line, column ); // Scroll up a bit to give some context context.codeEditor.scrollToRow( line - 4 ); } ); }, setupCodeEditorToolbar: function () { var toggleEditor = function ( ctx ) { ctx.codeEditorActive = !ctx.codeEditorActive; ctx.fn.setCodeEditorPreference( ctx.codeEditorActive ); ctx.fn.updateCodeEditorToolbarButton(); if ( ctx.codeEditorActive ) { // set it back up! ctx.fn.setupCodeEditor(); } else { ctx.fn.disableCodeEditor(); } }; var toggleInvisibleChars = function ( ctx ) { ctx.showInvisibleChars = !ctx.showInvisibleChars; ctx.fn.changeCookieValue( 'showInvisibleChars', ctx.showInvisibleChars ? 1 : 0 ); ctx.fn.updateInvisibleCharsButton(); ctx.codeEditor.setShowInvisibles( ctx.showInvisibleChars ); }; var toggleSearchReplace = function ( ctx ) { var searchBox = ctx.codeEditor.searchBox; if ( searchBox && $( searchBox.element ).css( 'display' ) !== 'none' ) { searchBox.hide(); } else { ctx.codeEditor.execCommand( ctx.codeEditor.getReadOnly() ? 'find' : 'replace' ); } }; var toggleLineWrapping = function ( ctx ) { ctx.lineWrappingActive = !ctx.lineWrappingActive; ctx.fn.changeCookieValue( 'lineWrappingActive', ctx.lineWrappingActive ? 1 : 0 ); ctx.fn.updateLineWrappingButton(); ctx.codeEditor.getSession().setUseWrapMode( ctx.lineWrappingActive ); }; var indent = function ( ctx ) { ctx.codeEditor.execCommand( 'indent' ); }; var outdent = function ( ctx ) { ctx.codeEditor.execCommand( 'outdent' ); }; var gotoLine = function ( ctx ) { ctx.codeEditor.execCommand( 'gotolinecolumn' ); }; context.api.addToToolbar( context, { section: 'main', groups: { 'codeeditor-main': { tools: { codeEditor: { label: mw.msg( 'codeeditor-toolbar-toggle' ), type: 'toggle', oouiIcon: 'markup', action: { type: 'callback', execute: toggleEditor } } } }, 'codeeditor-format': { tools: { indent: { label: mw.msg( 'codeeditor-indent' ), type: 'button', oouiIcon: 'indent', action: { type: 'callback', execute: indent } }, outdent: { label: mw.msg( 'codeeditor-outdent' ), type: 'button', oouiIcon: 'outdent', action: { type: 'callback', execute: outdent } } } }, 'codeeditor-style': { tools: { invisibleChars: { label: mw.msg( 'codeeditor-invisibleChars-toggle' ), type: 'toggle', oouiIcon: 'pilcrow', action: { type: 'callback', execute: toggleInvisibleChars } }, lineWrapping: { label: mw.msg( 'codeeditor-lineWrapping-toggle' ), type: 'toggle', oouiIcon: 'wrapping', action: { type: 'callback', execute: toggleLineWrapping } }, gotoLine: { label: mw.msg( 'codeeditor-gotoline' ), type: 'button', oouiIcon: 'gotoLine', action: { type: 'callback', execute: gotoLine } }, toggleSearchReplace: { label: mw.msg( 'codeeditor-searchReplace-toggle' ), type: 'button', oouiIcon: 'articleSearch', action: { type: 'callback', execute: toggleSearchReplace } } } } } } ); context.fn.updateCodeEditorToolbarButton(); context.fn.updateInvisibleCharsButton(); context.fn.updateLineWrappingButton(); $( '.group-codeeditor-style' ).prependTo( '.section-main' ); $( '.group-codeeditor-format' ).prependTo( '.section-main' ); $( '.group-codeeditor-main' ).prependTo( '.section-main' ); }, updateButtonIcon: function ( targetName, iconFn ) { var target = '.tool[rel=' + targetName + ']', $button = context.modules.toolbar.$toolbar.find( target ); $button.data( 'setActive' )( iconFn() ); }, updateCodeEditorToolbarButton: function () { context.fn.updateButtonIcon( 'codeEditor', context.fn.isCodeEditorActive ); }, updateInvisibleCharsButton: function () { context.fn.updateButtonIcon( 'invisibleChars', context.fn.isShowInvisibleChars ); }, updateLineWrappingButton: function () { context.fn.updateButtonIcon( 'lineWrapping', context.fn.isLineWrappingActive ); }, setCodeEditorPreference: function ( prefValue ) { // Abort any previous request api.abort(); api.saveOption( 'usecodeeditor', prefValue ? 1 : 0 ) .fail( function ( code, result ) { if ( code === 'http' && result.textStatus === 'abort' ) { // Request was aborted. Ignore error return; } if ( code === 'notloggedin' ) { // Expected for non-registered users return; } var message = 'Failed to set code editor preference: ' + code; if ( result.error && result.error.info ) { message += '\n' + result.error.info; } mw.log.warn( message ); } ); }, /** * Sets up the iframe in place of the textarea to allow more advanced operations */ setupCodeEditor: function () { var $box = context.$textarea; var lang = mw.config.get( 'wgCodeEditorCurrentLanguage' ); var basePath = mw.config.get( 'wgExtensionAssetsPath', '' ); if ( basePath.slice( 0, 2 ) === '//' ) { // ACE uses web workers, which have importScripts, which don't like relative links. // This is a problem only when the assets are on another server, so this rewrite should suffice // Protocol relative basePath = window.location.protocol + basePath; } ace.config.set( 'basePath', basePath + '/CodeEditor/modules/lib/ace' ); if ( lang ) { // Ace doesn't like replacing a textarea directly. // We'll stub this out to sit on top of it... // line-height is needed to compensate for oddity in WikiEditor extension, which zeroes the line-height on a parent container // eslint-disable-next-line no-jquery/no-parse-html-literal var container = context.$codeEditorContainer = $( '
' ).insertAfter( $box ); var editdiv = container.find( '.editor' ); $box.css( 'display', 'none' ); container.height( $box.height() ); // Non-lazy loaded dependencies: Enable code completion ace.require( 'ace/ext/language_tools' ); // Load the editor now context.codeEditor = ace.edit( editdiv[ 0 ] ); context.codeEditor.getSession().setValue( $box.val() ); $box.textSelection( 'register', textSelectionFn ); // Disable some annoying keybindings context.codeEditor.commands.bindKeys( { 'Ctrl-T': null, 'Ctrl-L': null, 'Command-L': null } ); context.codeEditor.setReadOnly( $box.prop( 'readonly' ) ); context.codeEditor.setShowInvisibles( context.showInvisibleChars ); var htmlClasses = document.documentElement.classList; var inDarkMode = htmlClasses.contains( 'skin-theme-clientpref-night' ) || ( htmlClasses.contains( 'skin-theme-clientpref-os' ) && window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches ); // The options to enable context.codeEditor.setOptions( { enableBasicAutocompletion: true, enableLiveAutocompletion: true, enableSnippets: true, theme: inDarkMode ? 'ace/theme/monokai' : 'ace/theme/textmate' } ); context.codeEditor.commands.addCommand( { name: 'gotolinecolumn', bindKey: { mac: 'Command-Shift-L', windows: 'Ctrl-Alt-L' }, exec: context.fn.aceGotoLineColumn, readOnly: true } ); $box.closest( 'form' ) .on( 'submit', context.evt.codeEditorSubmit ) .find( '#wpSave' ).on( 'click', context.evt.codeEditorSave ); var session = context.codeEditor.getSession(); // Use proper tabs session.setUseSoftTabs( false ); session.setUseWrapMode( context.lineWrappingActive ); // Configure any workers session.on( 'changeMode', function ( e, session2 ) { // eslint-disable-next-line no-jquery/variable-pattern var mode = session2.getMode().$id; if ( mode === 'ace/mode/javascript' ) { session2.$worker.send( 'changeOptions', [ { maxerr: 1000, globals: { mw: true, mediaWiki: true, $: true, jQuery: true, OO: true } } ] ); } } ); mw.hook( 'codeEditor.configure' ).fire( session ); // Add an Ace change handler to pass changes to Edit Recovery. mw.hook( 'editRecovery.loadEnd' ).add( function ( data ) { session.on( 'change', data.fieldChangeHandler ); } ); ace.config.loadModule( 'ace/ext/modelist', function ( modelist ) { if ( !modelist || !modelist.modesByName[ lang ] ) { lang = 'text'; } session.setMode( 'ace/mode/' + lang ); } ); // Use jQuery UI resizable() so that users can make the box taller container.resizable( { handles: 's', minHeight: $box.height(), resize: function () { context.codeEditor.resize(); } } ); $( '.wikiEditor-ui-toolbar' ).addClass( 'codeEditor-ui-toolbar' ); if ( selectedLine > 0 ) { // Line numbers in CodeEditor are zero-based context.codeEditor.navigateTo( selectedLine - 1, 0 ); // Scroll up a bit to give some context context.codeEditor.scrollToRow( selectedLine - 4 ); } context.fn.setupStatusBar(); document.body.classList.remove( 'codeeditor-loading' ); // Let modules know we're ready to start working with the content context.fn.trigger( 'ready' ); } }, /** * Turn off the code editor view and return to the plain textarea. * May be needed by some folks with funky browsers, or just to compare. */ disableCodeEditor: function () { // Kills it! context.$textarea.closest( 'form' ) .off( 'submit', context.evt.codeEditorSubmit ) .find( '#wpSave' ).off( 'click', context.evt.codeEditorSave ); // Save contents context.$textarea.textSelection( 'unregister' ); context.$textarea.val( textSelectionFn.getContents() ); // @todo fetch cursor, scroll position // Drop the fancy editor widget... context.fn.removeStatusBar(); context.$codeEditorContainer.remove(); context.$codeEditorContainer = undefined; context.codeEditor = undefined; // Restore textarea context.$textarea.show(); // Restore toolbar $( '.wikiEditor-ui-toolbar' ).removeClass( 'codeEditor-ui-toolbar' ); // @todo restore cursor, scroll position }, /** * Start monitoring the fragment of the current window for hash change * events. If the hash is already set, handle it as a new event. */ codeEditorMonitorFragment: function () { function onHashChange() { var regexp = /#mw-ce-l(\d+)/; var result = regexp.exec( window.location.hash ); if ( result === null ) { return; } selectedLine = parseInt( result[ 1 ], 10 ); if ( context.codeEditor && selectedLine > 0 ) { // Line numbers in CodeEditor are zero-based context.codeEditor.navigateTo( selectedLine - 1, 0 ); // Scroll up a bit to give some context context.codeEditor.scrollToRow( selectedLine - 4 ); } } onHashChange(); $( window ).on( 'hashchange', onHashChange ); }, /** * This creates a Statusbar, that allows you to see a count of the * errors, warnings and the warning of the current line, as well as * the position of the cursor. */ setupStatusBar: function () { var shouldUpdateAnnotations, shouldUpdateSelection, shouldUpdateLineInfo, nextAnnotation, delayedUpdate, editor = context.codeEditor, lang = ace.require( 'ace/lib/lang' ), $errors = $( '' ).addClass( 'codeEditor-status-worker-cell ace_gutter-cell ace_error' ).text( '0' ), $warnings = $( '' ).addClass( 'codeEditor-status-worker-cell ace_gutter-cell ace_warning' ).text( '0' ), $infos = $( '' ).addClass( 'codeEditor-status-worker-cell ace_gutter-cell ace_info' ).text( '0' ), $message = $( '
' ).addClass( 'codeEditor-status-message' ), $lineAndMode = $( '
' ).addClass( 'codeEditor-status-line' ), $workerStatus = $( '
' ) .addClass( 'codeEditor-status-worker' ) .attr( 'title', mw.msg( 'codeeditor-next-annotation' ) ) .append( $errors ) .append( $warnings ) .append( $infos ); context.$statusBar = $( '
' ) .addClass( 'codeEditor-status' ) .append( $workerStatus ) .append( $message ) .append( $lineAndMode ); /* Help function to concatenate strings with different separators */ function addToStatus( status, str, separator ) { if ( str ) { status.push( str, separator || '|' ); } } /** * Update all the information in the status bar */ function updateStatusBar() { var errors = 0, warnings = 0, infos = 0, distance, shortestDistance = Infinity, closestAnnotation, currentLine = editor.selection.lead.row, annotations = editor.getSession().getAnnotations(), closestType; // Reset the next annotation nextAnnotation = null; for ( var i = 0; i < annotations.length; i++ ) { var annotation = annotations[ i ]; distance = Math.abs( currentLine - annotation.row ); if ( distance < shortestDistance ) { shortestDistance = distance; closestAnnotation = annotation; } if ( nextAnnotation === null && annotation.row > currentLine ) { nextAnnotation = annotation; } switch ( annotations[ i ].type ) { case 'error': errors++; break; case 'warning': warnings++; break; case 'info': infos++; break; } } // Wrap around to the beginning for nextAnnotation if ( nextAnnotation === null && annotations.length > 0 ) { nextAnnotation = annotations[ 0 ]; } // Update the annotation counts if ( shouldUpdateAnnotations ) { $errors.text( errors ); $warnings.text( warnings ); $infos.text( infos ); } // Show the message of the current line, if we have not already done so if ( closestAnnotation && currentLine === closestAnnotation.row && closestAnnotation !== $message.data( 'annotation' ) ) { $message.data( 'annotation', closestAnnotation ); closestType = closestAnnotation.type.charAt( 0 ).toUpperCase() + closestAnnotation.type.slice( 1 ); $message.text( closestType + ': ' + closestAnnotation.text ); } else if ( $message.data( 'annotation' ) !== null && ( !closestAnnotation || currentLine !== closestAnnotation.row ) ) { // If we are on a different line without an annotation, then blank the message $message.data( 'annotation', null ); $message.text( '' ); } // The cursor position has changed if ( shouldUpdateSelection || shouldUpdateLineInfo ) { // Adapted from Ajax.org's ace/ext/statusbar module var status = []; if ( editor.$vimModeHandler ) { addToStatus( status, editor.$vimModeHandler.getStatusText() ); } else if ( editor.commands.recording ) { addToStatus( status, 'REC' ); } var c = editor.selection.lead; addToStatus( status, ( c.row + 1 ) + ':' + c.column, '' ); if ( !editor.selection.isEmpty() ) { var r = editor.getSelectionRange(); addToStatus( status, '(' + ( r.end.row - r.start.row ) + ':' + ( r.end.column - r.start.column ) + ')' ); } status.pop(); $lineAndMode.text( status.join( '' ) ); } shouldUpdateLineInfo = shouldUpdateSelection = shouldUpdateAnnotations = false; } // Function to delay/debounce updates for the StatusBar delayedUpdate = lang.delayedCall( function () { updateStatusBar( editor ); } ); /** * Click handler that allows you to skip to the next annotation */ $workerStatus.on( 'click', function ( e ) { if ( nextAnnotation ) { context.codeEditor.navigateTo( nextAnnotation.row, nextAnnotation.column ); // Scroll up a bit to give some context context.codeEditor.scrollToRow( nextAnnotation.row - 3 ); e.preventDefault(); } } ); editor.getSession().on( 'changeAnnotation', function () { shouldUpdateAnnotations = true; delayedUpdate.schedule( 100 ); } ); editor.on( 'changeStatus', function () { shouldUpdateLineInfo = true; delayedUpdate.schedule( 100 ); } ); editor.on( 'changeSelection', function () { shouldUpdateSelection = true; delayedUpdate.schedule( 100 ); } ); // Force update shouldUpdateLineInfo = shouldUpdateSelection = shouldUpdateAnnotations = true; updateStatusBar( editor ); context.$statusBar.insertAfter( $( '.wikiEditor-ui-view-wikitext .wikiEditor-ui-bottom' ) ); }, removeStatusBar: function () { context.codeEditor.getSession().removeListener( 'changeAnnotation' ); context.codeEditor.removeListener( 'changeSelection' ); context.codeEditor.removeListener( 'changeStatus' ); context.nextAnnotation = null; context.$statusBar = null; $( '.codeEditor-status' ).remove(); } } ); /** * Override the base functions in a way that lets * us fall back to the originals when we turn off. * * @param {Object} base * @param {Object} extended */ saveAndExtend = function ( base, extended ) { // eslint-disable-next-line no-jquery/no-map-util $.map( extended, function ( func, name ) { if ( name in base ) { var orig = base[ name ]; base[ name ] = function () { if ( context.codeEditorActive ) { return func.apply( this, arguments ); } if ( orig ) { return orig.apply( this, arguments ); } throw new Error( 'CodeEditor: no original function to call for ' + name ); }; } else { base[ name ] = func; } } ); }; saveAndExtend( context.fn, { saveSelection: function () { mw.log( 'codeEditor stub function saveSelection called' ); }, restoreSelection: function () { mw.log( 'codeEditor stub function restoreSelection called' ); }, /** * Scroll an element to the top of the iframe */ scrollToTop: function () { mw.log( 'codeEditor stub function scrollToTop called' ); } } ); /** * Compatibility with the $.textSelection jQuery plug-in. When the iframe is in use, these functions provide * equivalant functionality to the otherwise textarea-based functionality. */ textSelectionFn = { /* Needed for search/replace */ getContents: function () { return context.codeEditor.getSession().getValue(); }, setContents: function ( newContents ) { context.codeEditor.getSession().setValue( newContents ); }, /** * Gets the currently selected text in the content * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @return {string} */ getSelection: function () { return context.codeEditor.getCopyText(); }, /** * Replace the current selection with the given text. * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @param {string} text */ replaceSelection: function ( text ) { context.codeEditor.insert( text ); }, /** * Inserts text at the begining and end of a text selection, optionally inserting text at the caret when * selection is empty. * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @param {Object} options * @return {jQuery} */ encapsulateSelection: function ( options ) { // Does not yet handle 'ownline', 'splitlines' option var sel = context.codeEditor.getSelection(); var range = sel.getRange(); var selText = textSelectionFn.getSelection(); var isSample = false; if ( !selText ) { selText = options.peri; isSample = true; } else if ( options.replace ) { selText = options.peri; } var text = options.pre; text += selText; text += options.post; context.codeEditor.insert( text ); if ( isSample && options.selectPeri && !options.splitlines ) { // May esplode if anything has newlines, be warned. :) range.setStart( range.start.row, range.start.column + options.pre.length ); range.setEnd( range.start.row, range.start.column + selText.length ); sel.setSelectionRange( range ); } return context.$textarea; }, /** * Gets the position (in resolution of bytes not nessecarily characters) in a textarea * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @param {Object} options * @param {Object} [options.startAndEnd=false] Return range of the selection rather than just start * @return {number|number[]} If options.startAndEnd is true, returns an array holding the start and * end of the selection, else returns only the start of the selection as a single number. */ getCaretPosition: function ( options ) { var selection = context.codeEditor.getSelection(), range = selection.getRange(), doc = context.codeEditor.getSession().getDocument(), startOffset = doc.positionToIndex( range.start ); if ( options.startAndEnd ) { var endOffset = doc.positionToIndex( range.end ); return [ startOffset, endOffset ]; } return startOffset; }, /** * Sets the selection of the content * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @param {Object} options * @return {jQuery} */ setSelection: function ( options ) { // Ace stores positions for ranges as row/column pairs. // To convert from character offsets, we'll need to iterate through the document var doc = context.codeEditor.getSession().getDocument(); var lines = doc.getAllLines(); var offsetToPos = function ( offset ) { var row, col, pos; row = 0; col = 0; pos = 0; while ( row < lines.length && pos + lines[ row ].length < offset ) { pos += lines[ row ].length; pos++; // for the newline row++; } col = offset - pos; return { row: row, column: col }; }; var start = offsetToPos( options.start ); var end = offsetToPos( options.end ); var sel = context.codeEditor.getSelection(); var range = sel.getRange(); range.setStart( start.row, start.column ); range.setEnd( end.row, end.column ); sel.setSelectionRange( range ); return context.$textarea; }, /** * Scroll a textarea to the current cursor position. You can set the cursor position with setSelection() * DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead * * @return {jQuery} */ scrollToCaretPosition: function () { mw.log( 'codeEditor stub function scrollToCaretPosition called' ); return context.$textarea; } }; /* Setup the editor */ context.fn.setupCodeEditorToolbar(); if ( context.codeEditorActive ) { context.fn.setupCodeEditor(); } }; }() );