Merge "registration: Only allow one extension to set a specific config setting"
[lhc/web/wiklou.git] / resources / src / jquery / jquery.textSelection.js
1 /**
2 * These plugins provide extra functionality for interaction with textareas.
3 */
4 ( function ( $ ) {
5 if ( document.selection && document.selection.createRange ) {
6 // On IE, patch the focus() method to restore the windows' scroll position
7 // (T34241)
8 $.fn.extend( {
9 focus: ( function ( jqFocus ) {
10 return function () {
11 var $w, state, result;
12 if ( arguments.length === 0 ) {
13 $w = $( window );
14 state = { top: $w.scrollTop(), left: $w.scrollLeft() };
15 result = jqFocus.apply( this, arguments );
16 window.scrollTo( state.top, state.left );
17 return result;
18 }
19 return jqFocus.apply( this, arguments );
20 };
21 }( $.fn.focus ) )
22 } );
23 }
24
25 $.fn.textSelection = function ( command, options ) {
26 var fn,
27 alternateFn,
28 context,
29 hasWikiEditor,
30 needSave,
31 retval;
32
33 /**
34 * Helper function to get an IE TextRange object for an element
35 *
36 * @param {HTMLElement} element
37 * @return {TextRange}
38 */
39 function rangeForElementIE( element ) {
40 var sel;
41 if ( element.nodeName.toLowerCase() === 'input' ) {
42 return element.createTextRange();
43 } else {
44 sel = document.body.createTextRange();
45 sel.moveToElementText( element );
46 return sel;
47 }
48 }
49
50 /**
51 * Helper function for IE for activating the textarea. Called only in the
52 * IE-specific code paths below; makes use of IE-specific non-standard
53 * function setActive() if possible to avoid screen flicker.
54 *
55 * @param {HTMLElement} element
56 */
57 function activateElementOnIE( element ) {
58 if ( element.setActive ) {
59 element.setActive(); // T34241: doesn't scroll
60 } else {
61 $( element ).focus(); // may scroll (but we patched it above)
62 }
63 }
64
65 fn = {
66 /**
67 * Get the contents of the textarea
68 *
69 * @return {string}
70 */
71 getContents: function () {
72 return this.val();
73 },
74 /**
75 * Set the contents of the textarea, replacing anything that was there before
76 *
77 * @param {string} content
78 */
79 setContents: function ( content ) {
80 this.val( content );
81 },
82 /**
83 * Get the currently selected text in this textarea. Will focus the textarea
84 * in some browsers (IE/Opera)
85 *
86 * @return {string}
87 */
88 getSelection: function () {
89 var retval, range,
90 el = this.get( 0 );
91
92 if ( !el || $( el ).is( ':hidden' ) ) {
93 retval = '';
94 } else if ( document.selection && document.selection.createRange ) {
95 activateElementOnIE( el );
96 range = document.selection.createRange();
97 retval = range.text;
98 } else if ( el.selectionStart || el.selectionStart === 0 ) {
99 retval = el.value.substring( el.selectionStart, el.selectionEnd );
100 }
101
102 return retval;
103 },
104 /**
105 * Ported from skins/common/edit.js by Trevor Parscal
106 * (c) 2009 Wikimedia Foundation (GPLv2) - http://www.wikimedia.org
107 *
108 * Inserts text at the beginning and end of a text selection, optionally
109 * inserting text at the caret when selection is empty.
110 *
111 * @param {Object} options Options
112 * FIXME document the options parameters
113 * @return {jQuery}
114 */
115 encapsulateSelection: function ( options ) {
116 return this.each( function () {
117 var selText, scrollTop, insertText,
118 isSample, range, range2, range3, startPos, endPos,
119 pre = options.pre,
120 post = options.post;
121
122 /**
123 * Check if the selected text is the same as the insert text
124 */
125 function checkSelectedText() {
126 if ( !selText ) {
127 selText = options.peri;
128 isSample = true;
129 } else if ( options.replace ) {
130 selText = options.peri;
131 } else {
132 while ( selText.charAt( selText.length - 1 ) === ' ' ) {
133 // Exclude ending space char
134 selText = selText.slice( 0, -1 );
135 post += ' ';
136 }
137 while ( selText.charAt( 0 ) === ' ' ) {
138 // Exclude prepending space char
139 selText = selText.slice( 1 );
140 pre = ' ' + pre;
141 }
142 }
143 }
144
145 /**
146 * Do the splitlines stuff.
147 *
148 * Wrap each line of the selected text with pre and post
149 *
150 * @param {string} selText Selected text
151 * @param {string} pre Text before
152 * @param {string} post Text after
153 * @return {string} Wrapped text
154 */
155 function doSplitLines( selText, pre, post ) {
156 var i,
157 insertText = '',
158 selTextArr = selText.split( '\n' );
159 for ( i = 0; i < selTextArr.length; i++ ) {
160 insertText += pre + selTextArr[ i ] + post;
161 if ( i !== selTextArr.length - 1 ) {
162 insertText += '\n';
163 }
164 }
165 return insertText;
166 }
167
168 isSample = false;
169 // Do nothing if display none
170 if ( this.style.display !== 'none' ) {
171 if ( document.selection && document.selection.createRange ) {
172 // IE
173
174 // Note that IE9 will trigger the next section unless we check this first.
175 // See bug T37201.
176
177 activateElementOnIE( this );
178 if ( options.selectionStart !== undefined ) {
179 $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
180 }
181
182 selText = $( this ).textSelection( 'getSelection' );
183 scrollTop = this.scrollTop;
184 range = document.selection.createRange();
185
186 checkSelectedText();
187 insertText = pre + selText + post;
188 if ( options.splitlines ) {
189 insertText = doSplitLines( selText, pre, post );
190 }
191 if ( options.ownline && range.moveStart ) {
192 range2 = document.selection.createRange();
193 range2.collapse();
194 range2.moveStart( 'character', -1 );
195 // FIXME: Which check is correct?
196 if ( range2.text !== '\r' && range2.text !== '\n' && range2.text !== '' ) {
197 insertText = '\n' + insertText;
198 pre += '\n';
199 }
200 range3 = document.selection.createRange();
201 range3.collapse( false );
202 range3.moveEnd( 'character', 1 );
203 if ( range3.text !== '\r' && range3.text !== '\n' && range3.text !== '' ) {
204 insertText += '\n';
205 post += '\n';
206 }
207 }
208
209 range.text = insertText;
210 if ( isSample && options.selectPeri && range.moveStart ) {
211 range.moveStart( 'character', -post.length - selText.length );
212 range.moveEnd( 'character', -post.length );
213 }
214 range.select();
215 // Restore the scroll position
216 this.scrollTop = scrollTop;
217 } else if ( this.selectionStart || this.selectionStart === 0 ) {
218 // Mozilla/Opera
219
220 $( this ).focus();
221 if ( options.selectionStart !== undefined ) {
222 $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
223 }
224
225 selText = $( this ).textSelection( 'getSelection' );
226 startPos = this.selectionStart;
227 endPos = this.selectionEnd;
228 scrollTop = this.scrollTop;
229 checkSelectedText();
230 if (
231 options.selectionStart !== undefined &&
232 endPos - startPos !== options.selectionEnd - options.selectionStart
233 ) {
234 // This means there is a difference in the selection range returned by browser and what we passed.
235 // This happens for Chrome in the case of composite characters. Ref bug #30130
236 // Set the startPos to the correct position.
237 startPos = options.selectionStart;
238 }
239
240 insertText = pre + selText + post;
241 if ( options.splitlines ) {
242 insertText = doSplitLines( selText, pre, post );
243 }
244 if ( options.ownline ) {
245 if ( startPos !== 0 && this.value.charAt( startPos - 1 ) !== '\n' && this.value.charAt( startPos - 1 ) !== '\r' ) {
246 insertText = '\n' + insertText;
247 pre += '\n';
248 }
249 if ( this.value.charAt( endPos ) !== '\n' && this.value.charAt( endPos ) !== '\r' ) {
250 insertText += '\n';
251 post += '\n';
252 }
253 }
254 this.value = this.value.slice( 0, startPos ) + insertText +
255 this.value.slice( endPos );
256 // Setting this.value scrolls the textarea to the top, restore the scroll position
257 this.scrollTop = scrollTop;
258 if ( window.opera ) {
259 pre = pre.replace( /\r?\n/g, '\r\n' );
260 selText = selText.replace( /\r?\n/g, '\r\n' );
261 post = post.replace( /\r?\n/g, '\r\n' );
262 }
263 if ( isSample && options.selectPeri && ( !options.splitlines || ( options.splitlines && selText.indexOf( '\n' ) === -1 ) ) ) {
264 this.selectionStart = startPos + pre.length;
265 this.selectionEnd = startPos + pre.length + selText.length;
266 } else {
267 this.selectionStart = startPos + insertText.length;
268 this.selectionEnd = this.selectionStart;
269 }
270 }
271 }
272 $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
273 options.replace, options.spitlines ] );
274 } );
275 },
276 /**
277 * Ported from Wikia's LinkSuggest extension
278 * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
279 * Some code copied from
280 * http://www.dedestruct.com/2008/03/22/howto-cross-browser-cursor-position-in-textareas/
281 *
282 * Get the position (in resolution of bytes not necessarily characters)
283 * in a textarea
284 *
285 * Will focus the textarea in some browsers (IE/Opera)
286 *
287 * @param {Object} options Options
288 * FIXME document the options parameters
289 * @return {number} Position
290 */
291 getCaretPosition: function ( options ) {
292 function getCaret( e ) {
293 var caretPos = 0,
294 endPos = 0,
295 preText, rawPreText, periText,
296 rawPeriText, postText,
297 // IE Support
298 preFinished,
299 periFinished,
300 postFinished,
301 // Range containing text in the selection
302 periRange,
303 // Range containing text before the selection
304 preRange,
305 // Range containing text after the selection
306 postRange;
307
308 if ( e && document.selection && document.selection.createRange ) {
309 // IE doesn't properly report non-selected caret position through
310 // the selection ranges when textarea isn't focused. This can
311 // lead to saving a bogus empty selection, which then screws up
312 // whatever we do later (T33847).
313 activateElementOnIE( e );
314
315 preFinished = false;
316 periFinished = false;
317 postFinished = false;
318 periRange = document.selection.createRange().duplicate();
319
320 preRange = rangeForElementIE( e );
321 // Move the end where we need it
322 preRange.setEndPoint( 'EndToStart', periRange );
323
324 postRange = rangeForElementIE( e );
325 // Move the start where we need it
326 postRange.setEndPoint( 'StartToEnd', periRange );
327
328 // Load the text values we need to compare
329 preText = rawPreText = preRange.text;
330 periText = rawPeriText = periRange.text;
331 postText = postRange.text;
332
333 /*
334 * Check each range for trimmed newlines by shrinking the range by 1
335 * character and seeing if the text property has changed. If it has
336 * not changed then we know that IE has trimmed a \r\n from the end.
337 */
338 do {
339 if ( !preFinished ) {
340 if ( preRange.compareEndPoints( 'StartToEnd', preRange ) === 0 ) {
341 preFinished = true;
342 } else {
343 preRange.moveEnd( 'character', -1 );
344 if ( preRange.text === preText ) {
345 rawPreText += '\r\n';
346 } else {
347 preFinished = true;
348 }
349 }
350 }
351 if ( !periFinished ) {
352 if ( periRange.compareEndPoints( 'StartToEnd', periRange ) === 0 ) {
353 periFinished = true;
354 } else {
355 periRange.moveEnd( 'character', -1 );
356 if ( periRange.text === periText ) {
357 rawPeriText += '\r\n';
358 } else {
359 periFinished = true;
360 }
361 }
362 }
363 if ( !postFinished ) {
364 if ( postRange.compareEndPoints( 'StartToEnd', postRange ) === 0 ) {
365 postFinished = true;
366 } else {
367 postRange.moveEnd( 'character', -1 );
368 if ( postRange.text !== postText ) {
369 postFinished = true;
370 }
371 }
372 }
373 } while ( ( !preFinished || !periFinished || !postFinished ) );
374 caretPos = rawPreText.replace( /\r\n/g, '\n' ).length;
375 endPos = caretPos + rawPeriText.replace( /\r\n/g, '\n' ).length;
376 } else if ( e && ( e.selectionStart || e.selectionStart === 0 ) ) {
377 // Firefox support
378 caretPos = e.selectionStart;
379 endPos = e.selectionEnd;
380 }
381 return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
382 }
383 return getCaret( this.get( 0 ) );
384 },
385 /**
386 * @param {Object} options options
387 * FIXME document the options parameters
388 * @return {jQuery}
389 */
390 setSelection: function ( options ) {
391 return this.each( function () {
392 var selection, length, newLines;
393 // Do nothing if hidden
394 if ( !$( this ).is( ':hidden' ) ) {
395 if ( this.selectionStart || this.selectionStart === 0 ) {
396 // Opera 9.0 doesn't allow setting selectionStart past
397 // selectionEnd; any attempts to do that will be ignored
398 // Make sure to set them in the right order
399 if ( options.start > this.selectionEnd ) {
400 this.selectionEnd = options.end;
401 this.selectionStart = options.start;
402 } else {
403 this.selectionStart = options.start;
404 this.selectionEnd = options.end;
405 }
406 } else if ( document.body.createTextRange ) {
407 selection = rangeForElementIE( this );
408 length = this.value.length;
409 // IE doesn't count \n when computing the offset, so we won't either
410 newLines = this.value.match( /\n/g );
411 if ( newLines ) {
412 length = length - newLines.length;
413 }
414 selection.moveStart( 'character', options.start );
415 selection.moveEnd( 'character', -length + options.end );
416
417 // This line can cause an error under certain circumstances (textarea empty, no selection)
418 // Silence that error
419 try {
420 selection.select();
421 } catch ( e ) { }
422 }
423 }
424 } );
425 },
426 /**
427 * Ported from Wikia's LinkSuggest extension
428 * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
429 *
430 * Scroll a textarea to the current cursor position. You can set the cursor
431 * position with setSelection()
432 *
433 * @param {Object} options options
434 * @cfg {boolean} [force=false] Whether to force a scroll even if the caret position
435 * is already visible.
436 * FIXME document the options parameters
437 * @return {jQuery}
438 */
439 scrollToCaretPosition: function ( options ) {
440 function getLineLength( e ) {
441 return Math.floor( e.scrollWidth / ( $.client.profile().platform === 'linux' ? 7 : 8 ) );
442 }
443 function getCaretScrollPosition( e ) {
444 // FIXME: This functions sucks and is off by a few lines most
445 // of the time. It should be replaced by something decent.
446 var i, j,
447 nextSpace,
448 text = e.value.replace( /\r/g, '' ),
449 caret = $( e ).textSelection( 'getCaretPosition' ),
450 lineLength = getLineLength( e ),
451 row = 0,
452 charInLine = 0,
453 lastSpaceInLine = 0;
454
455 for ( i = 0; i < caret; i++ ) {
456 charInLine++;
457 if ( text.charAt( i ) === ' ' ) {
458 lastSpaceInLine = charInLine;
459 } else if ( text.charAt( i ) === '\n' ) {
460 lastSpaceInLine = 0;
461 charInLine = 0;
462 row++;
463 }
464 if ( charInLine > lineLength ) {
465 if ( lastSpaceInLine > 0 ) {
466 charInLine = charInLine - lastSpaceInLine;
467 lastSpaceInLine = 0;
468 row++;
469 }
470 }
471 }
472 nextSpace = 0;
473 for ( j = caret; j < caret + lineLength; j++ ) {
474 if (
475 text.charAt( j ) === ' ' ||
476 text.charAt( j ) === '\n' ||
477 caret === text.length
478 ) {
479 nextSpace = j;
480 break;
481 }
482 }
483 if ( nextSpace > lineLength && caret <= lineLength ) {
484 charInLine = caret - lastSpaceInLine;
485 row++;
486 }
487 return ( $.client.profile().platform === 'mac' ? 13 : ( $.client.profile().platform === 'linux' ? 15 : 16 ) ) * row;
488 }
489 return this.each( function () {
490 var scroll, range, savedRange, pos, oldScrollTop;
491 // Do nothing if hidden
492 if ( !$( this ).is( ':hidden' ) ) {
493 if ( this.selectionStart || this.selectionStart === 0 ) {
494 // Mozilla
495 scroll = getCaretScrollPosition( this );
496 if ( options.force || scroll < $( this ).scrollTop() ||
497 scroll > $( this ).scrollTop() + $( this ).height() ) {
498 $( this ).scrollTop( scroll );
499 }
500 } else if ( document.selection && document.selection.createRange ) {
501 // IE / Opera
502 /*
503 * IE automatically scrolls the selected text to the
504 * bottom of the textarea at range.select() time, except
505 * if it was already in view and the cursor position
506 * wasn't changed, in which case it does nothing. To
507 * cover that case, we'll force it to act by moving one
508 * character back and forth.
509 */
510 range = document.body.createTextRange();
511 savedRange = document.selection.createRange();
512 pos = $( this ).textSelection( 'getCaretPosition' );
513 oldScrollTop = this.scrollTop;
514 range.moveToElementText( this );
515 range.collapse();
516 range.move( 'character', pos + 1 );
517 range.select();
518 if ( this.scrollTop !== oldScrollTop ) {
519 this.scrollTop += range.offsetTop;
520 } else if ( options.force ) {
521 range.move( 'character', -1 );
522 range.select();
523 }
524 savedRange.select();
525 }
526 }
527 $( this ).trigger( 'scrollToPosition' );
528 } );
529 }
530 };
531
532 alternateFn = $( this ).data( 'jquery.textSelection' );
533
534 // Apply defaults
535 switch ( command ) {
536 // case 'getContents': // no params
537 // case 'setContents': // no params with defaults
538 // case 'getSelection': // no params
539 case 'encapsulateSelection':
540 options = $.extend( {
541 pre: '', // Text to insert before the cursor/selection
542 peri: '', // Text to insert between pre and post and select afterwards
543 post: '', // Text to insert after the cursor/selection
544 ownline: false, // Put the inserted text on a line of its own
545 replace: false, // If there is a selection, replace it with peri instead of leaving it alone
546 selectPeri: true, // Select the peri text if it was inserted (but not if there was a selection and replace==false, or if splitlines==true)
547 splitlines: false, // If multiple lines are selected, encapsulate each line individually
548 selectionStart: undefined, // Position to start selection at
549 selectionEnd: undefined // Position to end selection at. Defaults to start
550 }, options );
551 break;
552 case 'getCaretPosition':
553 options = $.extend( {
554 // Return [start, end] instead of just start
555 startAndEnd: false
556 }, options );
557 // FIXME: We may not need character position-based functions if we insert markers in the right places
558 break;
559 case 'setSelection':
560 options = $.extend( {
561 // Position to start selection at
562 start: undefined,
563 // Position to end selection at. Defaults to start
564 end: undefined
565 }, options );
566
567 if ( options.end === undefined ) {
568 options.end = options.start;
569 }
570 // FIXME: We may not need character position-based functions if we insert markers in the right places
571 break;
572 case 'scrollToCaretPosition':
573 options = $.extend( {
574 force: false // Force a scroll even if the caret position is already visible
575 }, options );
576 break;
577 case 'register':
578 if ( alternateFn ) {
579 throw new Error( 'Another textSelection API was already registered' );
580 }
581 $( this ).data( 'jquery.textSelection', options );
582 // No need to update alternateFn as this command only stores the options.
583 // A command that uses it will set it again.
584 return;
585 case 'unregister':
586 $( this ).removeData( 'jquery.textSelection' );
587 return;
588 }
589
590 context = $( this ).data( 'wikiEditor-context' );
591 hasWikiEditor = ( context !== undefined && context.$iframe !== undefined );
592
593 // IE selection restore voodoo
594 needSave = false;
595 if ( hasWikiEditor && context.savedSelection !== null ) {
596 context.fn.restoreSelection();
597 needSave = true;
598 }
599 retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, options );
600 if ( hasWikiEditor && needSave ) {
601 context.fn.saveSelection();
602 }
603
604 return retval;
605 };
606
607 }( jQuery ) );