wiki.techinc.nl/resources/jquery/jquery.textSelection.js
Timo Tijhof 8d306686cf Lint: Go-go-gadget jshint! Passing entire JS code base (again).
There were still some files not passing jshint, and for files
that did, we managed to screw 'em up again.

Added more explicit settings in .jshintrc to avoid relying on a
kind of default somewhere. There are too many default-factors:
closest(.jshintrc), ~/.jshintrc, IDE/editor, node-jshint..

Added node_modules/ and extensions/ to .jshintignore.
Previously "$ jshint ." would recurse over all kinds of
unrelated code. Extensions should have their own jshint
dotfiles. When linting from Jenkins this won't be a problem as
those will be ran per repo (so when linting core it will skip extensions and when in an extension dir, the core dotfiles
don't apply as they'll be out of scope).

Some of our modules are really messy and should be refactored
to be less spaghetti-ish and have more descriptive variable
names and more manageable function-level complexity.
But for this commit, I'm keeping it as much as-is as possible,
because its hard/large enough to review as it is.

A few errors are cited below to give an impression of the kind
of warnings I addressed (for cases where the diff isn't
so obvious):

* jquery.hidpi.js: line 110, col 15, Empty block.
* mediawiki.jqueryMsg.js: line 34, col 17, Too many var statements.
* mediawiki.jqueryMsg.js: line 145, col 33, Strings must use singlequote.
* mediawiki.action.edit.js: line 74, col 73, 'selectText' is defined but never used.
* startup.js: line 19, col 22, 'isCompatible' is defined but never used.
* jquery.byteLength.test.js: line 26, col 9, Identifier 'U_00A2' is not in camel case.
* jquery.localize.test.js: line 63, col 29, 'attrs' is defined but never used.
* mediawiki.cldr.test.js: line 72, col 27, 'mw' is not defined.
* mediawiki.jscompat.test.js: line 6, col 17, Strings must use singlequote.
* mediawiki.api.parse.test.js: line 9, col 17, Strings must use singlequote.
* mediawiki.api.parse.test.js: line 7, col 15, 'mw' is not defined.
* mediawiki.api.parse.test.js: line 14, col 24, '$' is not defined.
* mediawiki.api.test.js: line 43, col 28, 'data' is defined but never used.

Other fixes:
* Add closures fix implied global errors ($, mw and more),
  and prevents local variables from becoming globals.
* Avoid jQ magic map arg etc. (per code conventions).
* Fix incorrect usage of jQuery methods (prop instead of attr,
  prop false instead of removeProp, ..).
* Unquote keys in object literals for consistency, and
  enforce single quotes (no magic quotes in javascript, as much
  as we might think "\n" and '/n' are really exactly the same).
  Chose single quotes over double quotes since that's what most
  code already had and what conventions seemed to prefer
  (both the old generic ones and the new per-lang ones since
  2011/2012).
* Enforce camelCase variable names with jshint per code
  conventions.
* $foo.on('x', fn).trigger('x') -> $foo.on('x', fn); fn()
  (No event simulation overhead, unless intended of course)
* Incorrect indentation (ignore whitespace in the diff!).
* Avoid proprietary selectors like ':first' when .eq(0)
  afterwards is just as possible (significantly faster in
  jQuery due to mostly avoiding the Sizzle engine and going
  native in modern browsers).
* When at it, convert deprecated jQuery methods to new ones.
  Mostly just .delegate(sel, type, fn) -> .on(type, sel, fn).
* Addressed whitespace here and there.

Interesting:
* mediawiki.js: local function "compare" wasn't used anymore
  (hasn't been in a while!) removed per jshint warning.

* mediawiki.special.recentchanges.js: Was a mess, only a few
  lines of code, rewritten.

Pfew, let's hope it's the last one before we lint from Jenkins!

Change-Id: I23ad60a1d804c542d9b91454aaa20ce7be4ff289
2012-11-10 12:23:43 +01:00

573 lines
19 KiB
JavaScript

/**
* These plugins provide extra functionality for interaction with textareas.
*/
( function ( $ ) {
/*jshint noempty:false */
if ( document.selection && document.selection.createRange ) {
// On IE, patch the focus() method to restore the windows' scroll position
// (bug 32241)
$.fn.extend({
focus: ( function ( jqFocus ) {
return function () {
var $w, state, result;
if ( arguments.length === 0 ) {
$w = $( window );
state = {top: $w.scrollTop(), left: $w.scrollLeft()};
result = jqFocus.apply( this, arguments );
window.scrollTo( state.top, state.left );
return result;
}
return jqFocus.apply( this, arguments );
};
}( $.fn.focus ) )
});
}
$.fn.textSelection = function ( command, options ) {
var fn,
context,
hasIframe,
needSave,
retval;
/**
* Helper function to get an IE TextRange object for an element
*/
function rangeForElementIE( e ) {
if ( e.nodeName.toLowerCase() === 'input' ) {
return e.createTextRange();
} else {
var sel = document.body.createTextRange();
sel.moveToElementText( e );
return sel;
}
}
/**
* Helper function for IE for activating the textarea. Called only in the
* IE-specific code paths below; makes use of IE-specific non-standard
* function setActive() if possible to avoid screen flicker.
*/
function activateElementOnIE( element ) {
if ( element.setActive ) {
element.setActive(); // bug 32241: doesn't scroll
} else {
$( element ).focus(); // may scroll (but we patched it above)
}
}
fn = {
/**
* Get the contents of the textarea
*/
getContents: function () {
return this.val();
},
/**
* Get the currently selected text in this textarea. Will focus the textarea
* in some browsers (IE/Opera)
*/
getSelection: function () {
var retval, range,
el = this.get( 0 );
if ( $(el).is( ':hidden' ) ) {
// Do nothing
retval = '';
} else if ( document.selection && document.selection.createRange ) {
activateElementOnIE( el );
range = document.selection.createRange();
retval = range.text;
} else if ( el.selectionStart || el.selectionStart === 0 ) {
retval = el.value.substring( el.selectionStart, el.selectionEnd );
}
return retval;
},
/**
* Ported from skins/common/edit.js by Trevor Parscal
* (c) 2009 Wikimedia Foundation (GPLv2) - http://www.wikimedia.org
*
* Inserts text at the begining and end of a text selection, optionally
* inserting text at the caret when selection is empty.
*
* @fixme document the options parameters
*/
encapsulateSelection: function ( options ) {
return this.each( function () {
var selText, scrollTop, insertText,
isSample, range, range2, range3, startPos, endPos,
pre = options.pre,
post = options.post;
/**
* Check if the selected text is the same as the insert text
*/
function checkSelectedText() {
if ( !selText ) {
selText = options.peri;
isSample = true;
} else if ( options.replace ) {
selText = options.peri;
} else {
while ( selText.charAt( selText.length - 1 ) === ' ' ) {
// Exclude ending space char
selText = selText.substring( 0, selText.length - 1 );
post += ' ';
}
while ( selText.charAt( 0 ) === ' ' ) {
// Exclude prepending space char
selText = selText.substring( 1, selText.length );
pre = ' ' + pre;
}
}
}
/**
* Do the splitlines stuff.
*
* Wrap each line of the selected text with pre and post
*/
function doSplitLines( selText, pre, post ) {
var i,
insertText = '',
selTextArr = selText.split( '\n' );
for ( i = 0; i < selTextArr.length; i++ ) {
insertText += pre + selTextArr[i] + post;
if ( i !== selTextArr.length - 1 ) {
insertText += '\n';
}
}
return insertText;
}
isSample = false;
if ( this.style.display === 'none' ) {
// Do nothing
} else if ( document.selection && document.selection.createRange ) {
// IE
// Note that IE9 will trigger the next section unless we check this first.
// See bug 35201.
activateElementOnIE( this );
if ( context ) {
context.fn.restoreCursorAndScrollTop();
}
if ( options.selectionStart !== undefined ) {
$(this).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } );
}
selText = $(this).textSelection( 'getSelection' );
scrollTop = this.scrollTop;
range = document.selection.createRange();
checkSelectedText();
insertText = pre + selText + post;
if ( options.splitlines ) {
insertText = doSplitLines( selText, pre, post );
}
if ( options.ownline && range.moveStart ) {
range2 = document.selection.createRange();
range2.collapse();
range2.moveStart( 'character', -1 );
// FIXME: Which check is correct?
if ( range2.text !== '\r' && range2.text !== '\n' && range2.text !== '' ) {
insertText = '\n' + insertText;
pre += '\n';
}
range3 = document.selection.createRange();
range3.collapse( false );
range3.moveEnd( 'character', 1 );
if ( range3.text !== '\r' && range3.text !== '\n' && range3.text !== '' ) {
insertText += '\n';
post += '\n';
}
}
range.text = insertText;
if ( isSample && options.selectPeri && range.moveStart ) {
range.moveStart( 'character', - post.length - selText.length );
range.moveEnd( 'character', - post.length );
}
range.select();
// Restore the scroll position
this.scrollTop = scrollTop;
} else if ( this.selectionStart || this.selectionStart === 0 ) {
// Mozilla/Opera
$(this).focus();
if ( options.selectionStart !== undefined ) {
$(this).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } );
}
selText = $(this).textSelection( 'getSelection' );
startPos = this.selectionStart;
endPos = this.selectionEnd;
scrollTop = this.scrollTop;
checkSelectedText();
if ( options.selectionStart !== undefined
&& endPos - startPos !== options.selectionEnd - options.selectionStart )
{
// This means there is a difference in the selection range returned by browser and what we passed.
// This happens for Chrome in the case of composite characters. Ref bug #30130
// Set the startPos to the correct position.
startPos = options.selectionStart;
}
insertText = pre + selText + post;
if ( options.splitlines ) {
insertText = doSplitLines( selText, pre, post );
}
if ( options.ownline ) {
if ( startPos !== 0 && this.value.charAt( startPos - 1 ) !== '\n' && this.value.charAt( startPos - 1 ) !== '\r' ) {
insertText = '\n' + insertText;
pre += '\n';
}
if ( this.value.charAt( endPos ) !== '\n' && this.value.charAt( endPos ) !== '\r' ) {
insertText += '\n';
post += '\n';
}
}
this.value = this.value.substring( 0, startPos ) + insertText +
this.value.substring( endPos, this.value.length );
// Setting this.value scrolls the textarea to the top, restore the scroll position
this.scrollTop = scrollTop;
if ( window.opera ) {
pre = pre.replace( /\r?\n/g, '\r\n' );
selText = selText.replace( /\r?\n/g, '\r\n' );
post = post.replace( /\r?\n/g, '\r\n' );
}
if ( isSample && options.selectPeri && !options.splitlines ) {
this.selectionStart = startPos + pre.length;
this.selectionEnd = startPos + pre.length + selText.length;
} else {
this.selectionStart = startPos + insertText.length;
this.selectionEnd = this.selectionStart;
}
}
$(this).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
options.replace, options.spitlines ] );
});
},
/**
* Ported from Wikia's LinkSuggest extension
* https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
* Some code copied from
* http://www.dedestruct.com/2008/03/22/howto-cross-browser-cursor-position-in-textareas/
*
* Get the position (in resolution of bytes not nessecarily characters)
* in a textarea
*
* Will focus the textarea in some browsers (IE/Opera)
*
* @fixme document the options parameters
*/
getCaretPosition: function ( options ) {
function getCaret( e ) {
var caretPos = 0,
endPos = 0,
preText, rawPreText, periText,
rawPeriText, postText, rawPostText,
// IE Support
preFinished,
periFinished,
postFinished,
// Range containing text in the selection
periRange,
// Range containing text before the selection
preRange,
// Range containing text after the selection
postRange;
if ( document.selection && document.selection.createRange ) {
// IE doesn't properly report non-selected caret position through
// the selection ranges when textarea isn't focused. This can
// lead to saving a bogus empty selection, which then screws up
// whatever we do later (bug 31847).
activateElementOnIE( e );
preFinished = false;
periFinished = false;
postFinished = false;
periRange = document.selection.createRange().duplicate();
preRange = rangeForElementIE( e ),
// Move the end where we need it
preRange.setEndPoint( 'EndToStart', periRange );
postRange = rangeForElementIE( e );
// Move the start where we need it
postRange.setEndPoint( 'StartToEnd', periRange );
// Load the text values we need to compare
preText = rawPreText = preRange.text;
periText = rawPeriText = periRange.text;
postText = rawPostText = postRange.text;
/*
* Check each range for trimmed newlines by shrinking the range by 1
* character and seeing if the text property has changed. If it has
* not changed then we know that IE has trimmed a \r\n from the end.
*/
do {
if ( !preFinished ) {
if ( preRange.compareEndPoints( 'StartToEnd', preRange ) === 0 ) {
preFinished = true;
} else {
preRange.moveEnd( 'character', -1 );
if ( preRange.text === preText ) {
rawPreText += '\r\n';
} else {
preFinished = true;
}
}
}
if ( !periFinished ) {
if ( periRange.compareEndPoints( 'StartToEnd', periRange ) === 0 ) {
periFinished = true;
} else {
periRange.moveEnd( 'character', -1 );
if ( periRange.text === periText ) {
rawPeriText += '\r\n';
} else {
periFinished = true;
}
}
}
if ( !postFinished ) {
if ( postRange.compareEndPoints( 'StartToEnd', postRange ) === 0 ) {
postFinished = true;
} else {
postRange.moveEnd( 'character', -1 );
if ( postRange.text === postText ) {
rawPostText += '\r\n';
} else {
postFinished = true;
}
}
}
} while ( ( !preFinished || !periFinished || !postFinished ) );
caretPos = rawPreText.replace( /\r\n/g, '\n' ).length;
endPos = caretPos + rawPeriText.replace( /\r\n/g, '\n' ).length;
} else if ( e.selectionStart || e.selectionStart === 0 ) {
// Firefox support
caretPos = e.selectionStart;
endPos = e.selectionEnd;
}
return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
}
return getCaret( this.get( 0 ) );
},
/**
* @fixme document the options parameters
*/
setSelection: function ( options ) {
return this.each( function () {
var selection, length, newLines;
if ( $(this).is( ':hidden' ) ) {
// Do nothing
} else if ( this.selectionStart || this.selectionStart === 0 ) {
// Opera 9.0 doesn't allow setting selectionStart past
// selectionEnd; any attempts to do that will be ignored
// Make sure to set them in the right order
if ( options.start > this.selectionEnd ) {
this.selectionEnd = options.end;
this.selectionStart = options.start;
} else {
this.selectionStart = options.start;
this.selectionEnd = options.end;
}
} else if ( document.body.createTextRange ) {
selection = rangeForElementIE( this );
length = this.value.length;
// IE doesn't count \n when computing the offset, so we won't either
newLines = this.value.match( /\n/g );
if ( newLines ) {
length = length - newLines.length;
}
selection.moveStart( 'character', options.start );
selection.moveEnd( 'character', -length + options.end );
// This line can cause an error under certain circumstances (textarea empty, no selection)
// Silence that error
try {
selection.select();
} catch ( e ) { }
}
});
},
/**
* Ported from Wikia's LinkSuggest extension
* https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
*
* Scroll a textarea to the current cursor position. You can set the cursor
* position with setSelection()
* @param options boolean Whether to force a scroll even if the caret position
* is already visible. Defaults to false
*
* @fixme document the options parameters (function body suggests options.force is a boolean, not options itself)
*/
scrollToCaretPosition: function ( options ) {
function getLineLength( e ) {
return Math.floor( e.scrollWidth / ( $.client.profile().platform === 'linux' ? 7 : 8 ) );
}
function getCaretScrollPosition( e ) {
// FIXME: This functions sucks and is off by a few lines most
// of the time. It should be replaced by something decent.
var i, j,
nextSpace,
text = e.value.replace( /\r/g, '' ),
caret = $( e ).textSelection( 'getCaretPosition' ),
lineLength = getLineLength( e ),
row = 0,
charInLine = 0,
lastSpaceInLine = 0;
for ( i = 0; i < caret; i++ ) {
charInLine++;
if ( text.charAt( i ) === ' ' ) {
lastSpaceInLine = charInLine;
} else if ( text.charAt( i ) === '\n' ) {
lastSpaceInLine = 0;
charInLine = 0;
row++;
}
if ( charInLine > lineLength ) {
if ( lastSpaceInLine > 0 ) {
charInLine = charInLine - lastSpaceInLine;
lastSpaceInLine = 0;
row++;
}
}
}
nextSpace = 0;
for ( j = caret; j < caret + lineLength; j++ ) {
if (
text.charAt( j ) === ' ' ||
text.charAt( j ) === '\n' ||
caret === text.length
) {
nextSpace = j;
break;
}
}
if ( nextSpace > lineLength && caret <= lineLength ) {
charInLine = caret - lastSpaceInLine;
row++;
}
return ( $.client.profile().platform === 'mac' ? 13 : ( $.client.profile().platform === 'linux' ? 15 : 16 ) ) * row;
}
return this.each(function () {
var scroll, range, savedRange, pos, oldScrollTop;
if ( $(this).is( ':hidden' ) ) {
// Do nothing
} else if ( this.selectionStart || this.selectionStart === 0 ) {
// Mozilla
scroll = getCaretScrollPosition( this );
if ( options.force || scroll < $(this).scrollTop() ||
scroll > $(this).scrollTop() + $(this).height() ) {
$(this).scrollTop( scroll );
}
} else if ( document.selection && document.selection.createRange ) {
// IE / Opera
/*
* IE automatically scrolls the selected text to the
* bottom of the textarea at range.select() time, except
* if it was already in view and the cursor position
* wasn't changed, in which case it does nothing. To
* cover that case, we'll force it to act by moving one
* character back and forth.
*/
range = document.body.createTextRange();
savedRange = document.selection.createRange();
pos = $(this).textSelection( 'getCaretPosition' );
oldScrollTop = this.scrollTop;
range.moveToElementText( this );
range.collapse();
range.move( 'character', pos + 1);
range.select();
if ( this.scrollTop !== oldScrollTop ) {
this.scrollTop += range.offsetTop;
} else if ( options.force ) {
range.move( 'character', -1 );
range.select();
}
savedRange.select();
}
$(this).trigger( 'scrollToPosition' );
} );
}
};
// Apply defaults
switch ( command ) {
//case 'getContents': // no params
//case 'setContents': // no params with defaults
//case 'getSelection': // no params
case 'encapsulateSelection':
options = $.extend( {
pre: '', // Text to insert before the cursor/selection
peri: '', // Text to insert between pre and post and select afterwards
post: '', // Text to insert after the cursor/selection
ownline: false, // Put the inserted text on a line of its own
replace: false, // If there is a selection, replace it with peri instead of leaving it alone
selectPeri: true, // Select the peri text if it was inserted (but not if there was a selection and replace==false, or if splitlines==true)
splitlines: false, // If multiple lines are selected, encapsulate each line individually
selectionStart: undefined, // Position to start selection at
selectionEnd: undefined // Position to end selection at. Defaults to start
}, options );
break;
case 'getCaretPosition':
options = $.extend( {
// Return [start, end] instead of just start
startAndEnd: false
}, options );
// FIXME: We may not need character position-based functions if we insert markers in the right places
break;
case 'setSelection':
options = $.extend( {
// Position to start selection at
start: undefined,
// Position to end selection at. Defaults to start
end: undefined,
// Element to start selection in (iframe only)
startContainer: undefined,
// Element to end selection in (iframe only). Defaults to startContainer
endContainer: undefined
}, options );
if ( options.end === undefined ) {
options.end = options.start;
}
if ( options.endContainer === undefined ) {
options.endContainer = options.startContainer;
}
// FIXME: We may not need character position-based functions if we insert markers in the right places
break;
case 'scrollToCaretPosition':
options = $.extend( {
force: false // Force a scroll even if the caret position is already visible
}, options );
break;
}
context = $(this).data( 'wikiEditor-context' );
hasIframe = context !== undefined && context && context.$iframe !== undefined;
// IE selection restore voodoo
needSave = false;
if ( hasIframe && context.savedSelection !== null ) {
context.fn.restoreSelection();
needSave = true;
}
retval = ( hasIframe ? context.fn : fn )[command].call( this, options );
if ( hasIframe && needSave ) {
context.fn.saveSelection();
}
return retval;
};
}( jQuery ) );