mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/CodeEditor
synced 2024-11-30 18:04:27 +00:00
8fe95e1469
* Scroll up a few lines so that the user can see the error context * Defer processing of the fragment until the load event, otherwise the code editor is not fully initialised and does not properly respond to the navigateTo() call.
408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
/* Ace syntax-highlighting code editor extension for wikiEditor */
|
|
|
|
( 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 ) {
|
|
|
|
/*
|
|
* 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 = $.extend( 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 ) {
|
|
},
|
|
'change': function( event ) {
|
|
},
|
|
'delayedChange': function( event ) {
|
|
},
|
|
'cut': function( event ) {
|
|
},
|
|
'paste': function( event ) {
|
|
},
|
|
'ready': function( event ) {
|
|
},
|
|
'codeEditorSubmit': function( event ) {
|
|
context.$textarea.val( context.$textarea.textSelection( 'getContents' ) );
|
|
}
|
|
} );
|
|
|
|
var cookieEnabled = $.cookie('wikiEditor-' + context.instance + '-codeEditor-enabled');
|
|
context.codeEditorActive = (cookieEnabled != '0');
|
|
|
|
/**
|
|
* Internally used functions
|
|
*/
|
|
context.fn = $.extend( context.fn, {
|
|
'codeEditorToolbarIcon': function() {
|
|
// When loaded as a gadget, one may need to override the wiki's own assets path.
|
|
var iconPath = mw.config.get('wgCodeEditorAssetsPath', mw.config.get('wgExtensionAssetsPath')) + '/CodeEditor/images/';
|
|
return iconPath + (context.codeEditorActive ? 'code-selected.png' : 'code.png');
|
|
},
|
|
'setupCodeEditorToolbar': function() {
|
|
// Drop out some formatting that isn't relevant on these pages...
|
|
context.api.removeFromToolbar(context, {
|
|
'section': 'main',
|
|
'group': 'format',
|
|
'tool': 'bold'
|
|
});
|
|
context.api.removeFromToolbar(context, {
|
|
'section': 'main',
|
|
'group': 'format',
|
|
'tool': 'italic'
|
|
});
|
|
var callback = function( context ) {
|
|
context.codeEditorActive = !context.codeEditorActive;
|
|
$.cookie(
|
|
'wikiEditor-' + context.instance + '-codeEditor-enabled',
|
|
context.codeEditorActive ? 1 : 0,
|
|
{ expires: 30, path: '/' }
|
|
);
|
|
context.fn.toggleCodeEditorToolbar();
|
|
|
|
if (context.codeEditorActive) {
|
|
// set it back up!
|
|
context.fn.setupCodeEditor();
|
|
} else {
|
|
context.fn.disableCodeEditor();
|
|
}
|
|
}
|
|
context.api.addToToolbar( context, {
|
|
'section': 'main',
|
|
'group': 'format',
|
|
'tools': {
|
|
'codeEditor': {
|
|
'labelMsg': 'codeeditor-toolbar-toggle',
|
|
'type': 'button',
|
|
'icon': context.fn.codeEditorToolbarIcon(),
|
|
'action': {
|
|
'type': 'callback',
|
|
'execute': callback
|
|
}
|
|
}
|
|
}
|
|
} );
|
|
},
|
|
'toggleCodeEditorToolbar': function() {
|
|
var target = 'img.tool[rel=codeEditor]';
|
|
var $img = context.modules.toolbar.$toolbar.find( target );
|
|
$img.attr('src', context.fn.codeEditorToolbarIcon());
|
|
},
|
|
/**
|
|
* 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")
|
|
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
|
|
var container = context.$codeEditorContainer = $('<div style="position: relative"><div class="editor" style="line-height: 1.5em; top: 0px; left: 0px; right: 0px; bottom: 0px; border: 1px solid gray"></div></div>').insertAfter(box);
|
|
var editdiv = container.find('.editor');
|
|
|
|
box.css('display', 'none');
|
|
container.width(box.width())
|
|
.height(box.height());
|
|
|
|
editdiv.text(box.val());
|
|
context.codeEditor = ace.edit(editdiv[0]);
|
|
|
|
// Disable some annoying commands
|
|
context.codeEditor.commands.removeCommand('replace'); // ctrl+R
|
|
context.codeEditor.commands.removeCommand('transposeletters'); // ctrl+T
|
|
context.codeEditor.commands.removeCommand('gotoline'); // ctrl+L
|
|
|
|
// fakeout for bug 29328
|
|
context.$iframe = [
|
|
{
|
|
contentWindow: {
|
|
focus: function() {
|
|
context.codeEditor.focus();
|
|
}
|
|
}
|
|
}
|
|
];
|
|
box.closest('form').submit( context.evt.codeEditorSubmit );
|
|
context.codeEditor.getSession().setMode(new (require("ace/mode/" + lang).Mode));
|
|
|
|
// Force the box to resize horizontally to match in future :D
|
|
var resize = function() {
|
|
container.width(box.width());
|
|
};
|
|
$(window).resize(resize);
|
|
// Use jquery.ui.resizable so user can make the box taller too
|
|
container.resizable({
|
|
handles: 's',
|
|
minHeight: box.height(),
|
|
resize: function() {
|
|
context.codeEditor.resize();
|
|
}
|
|
});
|
|
|
|
var summary = $('#wpSummary');
|
|
if (summary.val() == '') {
|
|
summary.val('/* using [[mw:CodeEditor|CodeEditor]] */ ');
|
|
}
|
|
// 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').unbind('submit', context.evt.onCodeEditorSubmit );
|
|
|
|
// Save contents
|
|
context.$textarea.val(context.fn.getContents());
|
|
|
|
// @todo fetch cursor, scroll position
|
|
|
|
// Drop the fancy editor widget...
|
|
context.$codeEditorContainer.remove();
|
|
context.$codeEditorContainer = undefined;
|
|
context.$iframe = undefined;
|
|
context.codeEditor = undefined;
|
|
|
|
// Restore textarea
|
|
context.$textarea.show();
|
|
|
|
// @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;
|
|
}
|
|
|
|
// Line numbers in CodeEditor are zero-based
|
|
var line = parseInt( result[1] );
|
|
context.codeEditor.navigateTo( line - 1, 0 );
|
|
// Scroll up a bit to give some context
|
|
context.codeEditor.scrollToRow( line - 4 );
|
|
}
|
|
onHashChange();
|
|
$( window ).bind( 'hashchange', onHashChange );
|
|
}
|
|
|
|
});
|
|
|
|
/**
|
|
* Override the base functions in a way that lets
|
|
* us fall back to the originals when we turn off.
|
|
*/
|
|
var saveAndExtend = function( base, extended ) {
|
|
var saved = {};
|
|
// $.map doesn't handle objects in jQuery < 1.6; need this for compat with MW 1.17
|
|
var map = function( obj, callback ) {
|
|
for (var key in extended ) {
|
|
if ( obj.hasOwnProperty( key ) ) {
|
|
callback( obj[key], key );
|
|
}
|
|
}
|
|
};
|
|
map( extended, function( func, name ) {
|
|
if ( name in base ) {
|
|
var orig = base[name];
|
|
base[name] = function() {
|
|
if (context.codeEditorActive) {
|
|
return func.apply(this, arguments);
|
|
} else if (orig) {
|
|
return orig.apply(this, arguments);
|
|
} else {
|
|
throw new Error('CodeEditor: no original function to call for ' + name);
|
|
}
|
|
}
|
|
} else {
|
|
base[name] = func;
|
|
}
|
|
});
|
|
};
|
|
|
|
saveAndExtend( context.fn, {
|
|
'saveCursorAndScrollTop': function() {
|
|
// Stub out textarea behavior
|
|
return;
|
|
},
|
|
'restoreCursorAndScrollTop': function() {
|
|
// Stub out textarea behavior
|
|
return;
|
|
},
|
|
'saveSelection': function() {
|
|
mw.log('codeEditor stub function saveSelection called');
|
|
},
|
|
'restoreSelection': function() {
|
|
mw.log('codeEditor stub function restoreSelection called');
|
|
},
|
|
|
|
/* Needed for search/replace */
|
|
'getContents': function() {
|
|
return context.codeEditor.getSession().getValue();
|
|
},
|
|
|
|
/**
|
|
* Compatibility with the $.textSelection jQuery plug-in. When the iframe is in use, these functions provide
|
|
* equivilant functionality to the otherwise textarea-based functionality.
|
|
*/
|
|
|
|
'getElementAtCursor': function() {
|
|
mw.log('codeEditor stub function getElementAtCursor called');
|
|
},
|
|
|
|
/**
|
|
* Gets the currently selected text in the content
|
|
* DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead
|
|
*/
|
|
'getSelection': function() {
|
|
return context.codeEditor.getCopyText();
|
|
},
|
|
/**
|
|
* 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
|
|
*/
|
|
'encapsulateSelection': function( options ) {
|
|
// Does not yet handle 'ownline', 'splitlines' option
|
|
var sel = context.codeEditor.getSelection();
|
|
var range = sel.getRange();
|
|
var selText = context.fn.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
|
|
*/
|
|
'getCaretPosition': function( options ) {
|
|
mw.log('codeEditor stub function getCaretPosition called');
|
|
},
|
|
/**
|
|
* Sets the selection of the content
|
|
* DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead
|
|
*
|
|
* @param start Character offset of selection start
|
|
* @param end Character offset of selection end
|
|
* @param startContainer Element in iframe to start selection in. If not set, start is a character offset
|
|
* @param endContainer Element in iframe to end selection in. If not set, end is a character offset
|
|
*/
|
|
'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 = 0, col = 0;
|
|
var 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 ),
|
|
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
|
|
*/
|
|
'scrollToCaretPosition': function( options ) {
|
|
mw.log('codeEditor stub function scrollToCaretPosition called');
|
|
return context.$textarea;
|
|
},
|
|
/**
|
|
* Scroll an element to the top of the iframe
|
|
* DO NOT CALL THIS DIRECTLY, use $.textSelection( 'functionname', options ) instead
|
|
*
|
|
* @param $element jQuery object containing an element in the iframe
|
|
* @param force If true, scroll the element even if it's already visible
|
|
*/
|
|
'scrollToTop': function( $element, force ) {
|
|
mw.log('codeEditor stub function scrollToTop called');
|
|
}
|
|
} );
|
|
|
|
/* Setup the editor */
|
|
context.fn.setupCodeEditorToolbar();
|
|
if (context.codeEditorActive) {
|
|
context.fn.setupCodeEditor();
|
|
}
|
|
|
|
} } )( jQuery );
|