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