mediawiki-extensions-CodeEd.../modules/jquery.codeEditor.js
Derk-Jan Hartman 262bfde28e Ask for confirmation when saving document with errors
Bug: 63202
Change-Id: Ifbdf976f3c0f27da67d7cf034c040d4279d62217
2014-03-30 00:47:10 +01:00

467 lines
14 KiB
JavaScript

/* Ace syntax-highlighting code editor extension for wikiEditor */
/*global require, ace, confirm */
(function ( $, mw ) {
$.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 cookieEnabled, saveAndExtend;
/*
* 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 ( ) {
},
'change': function ( ) {
},
'delayedChange': function ( ) {
},
'cut': function ( ) {
},
'paste': function ( ) {
},
'ready': function ( ) {
},
'codeEditorSubmit': function ( ) {
context.evt.codeEditorSync();
var i,
hasError = false,
annotations = context.codeEditor.getSession().getAnnotations();
for( i = 0; i < annotations.length; i++ ) {
if ( annotations[i].type === 'error' ) {
hasError = true;
break;
}
}
if ( hasError ) {
return confirm( mw.msg( 'codeeditor-save-with-errors' ) );
}
},
'codeEditorSync': function ( ) {
context.$textarea.val( context.$textarea.textSelection( 'getContents' ) );
}
} );
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;
target = 'img.tool[rel=codeEditor]';
$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, lang, basePath, container, editdiv, session, resize, summary, AceLangMode;
box = context.$textarea;
lang = mw.config.get( 'wgCodeEditorCurrentLanguage' );
basePath = mw.config.get( 'wgExtensionAssetsPath', '' );
if ( basePath.substring( 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/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
container = context.$codeEditorContainer = $( '<div style="position: relative"><div class="editor" style="line-height: 1.5em; top: 0; left: 0; right: 0; bottom: 0; position: absolute;"></div></div>' ).insertAfter( box );
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 );
session = context.codeEditor.getSession();
// Use proper tabs
session.setUseSoftTabs( false );
// Bug 47235: Update text field for LivePreview
if ( mw.hook ) {
mw.hook( 'codeEditor.configure' ).fire( session );
}
// Old, deprecated style for backwards compat
// Do this even if mw.hook exists, because the caller wasn't
// updated right away to actually use the new style.
$( mw ).bind( 'LivePreviewPrepare', context.evt.codeEditorSubmit );
AceLangMode = require( 'ace/mode/' + lang ).Mode;
session.setMode( new AceLangMode() );
// Force the box to resize horizontally to match in future :D
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();
}
} );
summary = $( '#wpSummary' );
// 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.codeEditorSubmit );
$( mw ).unbind( 'LivePreviewPrepare', context.evt.codeEditorSubmit ); // deprecated
// 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, result, line;
regexp = /#mw-ce-l(\d+)/;
result = regexp.exec( window.location.hash );
if ( result === null ) {
return;
}
// Line numbers in CodeEditor are zero-based
line = parseInt( result[1], 10 );
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.
*/
saveAndExtend = function ( base, extended ) {
var saved, map;
saved = {};
// $.map doesn't handle objects in jQuery < 1.6; need this for compat with MW 1.17
map = function ( obj, callback ) {
var key;
for ( 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 ) {
var sel, range, selText, isSample, text;
// Does not yet handle 'ownline', 'splitlines' option
sel = context.codeEditor.getSelection();
range = sel.getRange();
selText = context.fn.getSelection();
isSample = false;
if ( !selText ) {
selText = options.peri;
isSample = true;
} else if ( options.replace ) {
selText = options.peri;
}
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 ( ) {
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 ) {
var doc, lines, offsetToPos, start, end, sel, range;
// Ace stores positions for ranges as row/column pairs.
// To convert from character offsets, we'll need to iterate through the document
doc = context.codeEditor.getSession().getDocument();
lines = doc.getAllLines();
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};
};
start = offsetToPos( options.start );
end = offsetToPos( options.end );
sel = context.codeEditor.getSelection();
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 ( ) {
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 () {
mw.log( 'codeEditor stub function scrollToTop called' );
}
} );
/* Setup the editor */
context.fn.setupCodeEditorToolbar();
if ( context.codeEditorActive ) {
context.fn.setupCodeEditor();
}
};
})( jQuery, mediaWiki );