jquery.textSelection: Rewrite 'scrollToCaretPosition'
[lhc/web/wiklou.git] / resources / src / jquery / jquery.textSelection.js
1 /*!
2 * These plugins provide extra functionality for interaction with textareas.
3 *
4 * - encapsulateSelection: Ported from skins/common/edit.js by Trevor Parscal
5 * © 2009 Wikimedia Foundation (GPLv2) - http://www.wikimedia.org
6 * - getCaretPosition, scrollToCaretPosition: Ported from Wikia's LinkSuggest extension
7 * https://github.com/Wikia/app/blob/c0cd8b763/extensions/wikia/LinkSuggest/js/jquery.wikia.linksuggest.js
8 * © 2010 Inez Korczyński (korczynski@gmail.com) & Jesús Martínez Novo (martineznovo@gmail.com) (GPLv2)
9 */
10
11 /**
12 * @class jQuery.plugin.textSelection
13 *
14 * Do things to the selection in a `<textarea>`, or a textarea-like editable element.
15 *
16 * var $textbox = $( '#wpTextbox1' );
17 * $textbox.textSelection( 'setContents', 'This is bold!' );
18 * $textbox.textSelection( 'setSelection', { start: 8, end: 12 } );
19 * $textbox.textSelection( 'encapsulateSelection', { pre: '<b>', post: '</b>' } );
20 * // Result: Textbox contains 'This is <b>bold</b>!', with cursor before the '!'
21 */
22 ( function ( $ ) {
23 /**
24 * Do things to the selection in a `<textarea>`, or a textarea-like editable element.
25 *
26 * var $textbox = $( '#wpTextbox1' );
27 * $textbox.textSelection( 'setContents', 'This is bold!' );
28 * $textbox.textSelection( 'setSelection', { start: 8, end: 12 } );
29 * $textbox.textSelection( 'encapsulateSelection', { pre: '<b>', post: '</b>' } );
30 * // Result: Textbox contains 'This is <b>bold</b>!', with cursor before the '!'
31 *
32 * @param {string} command Command to execute, one of:
33 *
34 * - {@link jQuery.plugin.textSelection#getContents getContents}
35 * - {@link jQuery.plugin.textSelection#setContents setContents}
36 * - {@link jQuery.plugin.textSelection#getSelection getSelection}
37 * - {@link jQuery.plugin.textSelection#encapsulateSelection encapsulateSelection}
38 * - {@link jQuery.plugin.textSelection#getCaretPosition getCaretPosition}
39 * - {@link jQuery.plugin.textSelection#setSelection setSelection}
40 * - {@link jQuery.plugin.textSelection#scrollToCaretPosition scrollToCaretPosition}
41 * - {@link jQuery.plugin.textSelection#register register}
42 * - {@link jQuery.plugin.textSelection#unregister unregister}
43 * @param {Mixed} [options] Options to pass to the command
44 * @return {Mixed} Depending on the command
45 */
46 $.fn.textSelection = function ( command, options ) {
47 var fn,
48 alternateFn,
49 retval;
50
51 fn = {
52 /**
53 * Get the contents of the textarea.
54 *
55 * @private
56 * @return {string}
57 */
58 getContents: function () {
59 return this.val();
60 },
61
62 /**
63 * Set the contents of the textarea, replacing anything that was there before.
64 *
65 * @private
66 * @param {string} content
67 */
68 setContents: function ( content ) {
69 this.val( content );
70 },
71
72 /**
73 * Get the currently selected text in this textarea.
74 *
75 * @private
76 * @return {string}
77 */
78 getSelection: function () {
79 var retval,
80 el = this.get( 0 );
81
82 if ( !el || $( el ).is( ':hidden' ) ) {
83 retval = '';
84 } else if ( el.selectionStart || el.selectionStart === 0 ) {
85 retval = el.value.substring( el.selectionStart, el.selectionEnd );
86 }
87
88 return retval;
89 },
90
91 /**
92 * Insert text at the beginning and end of a text selection, optionally
93 * inserting text at the caret when selection is empty.
94 *
95 * Also focusses the textarea.
96 *
97 * @private
98 * @param {Object} [options]
99 * @param {string} [options.pre] Text to insert before the cursor/selection
100 * @param {string} [options.peri] Text to insert between pre and post and select afterwards
101 * @param {string} [options.post] Text to insert after the cursor/selection
102 * @param {boolean} [options.ownline=false] Put the inserted text on a line of its own
103 * @param {boolean} [options.replace=false] If there is a selection, replace it with peri
104 * instead of leaving it alone
105 * @param {boolean} [options.selectPeri=true] Select the peri text if it was inserted (but not
106 * if there was a selection and replace==false, or if splitlines==true)
107 * @param {boolean} [options.splitlines=false] If multiple lines are selected, encapsulate
108 * each line individually
109 * @param {number} [options.selectionStart] Position to start selection at
110 * @param {number} [options.selectionEnd=options.selectionStart] Position to end selection at
111 * @return {jQuery}
112 * @chainable
113 */
114 encapsulateSelection: function ( options ) {
115 return this.each( function () {
116 var selText, scrollTop, insertText,
117 isSample, startPos, endPos,
118 pre = options.pre,
119 post = options.post;
120
121 /**
122 * @ignore
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 * @ignore
147 * Do the splitlines stuff.
148 *
149 * Wrap each line of the selected text with pre and post
150 *
151 * @param {string} selText Selected text
152 * @param {string} pre Text before
153 * @param {string} post Text after
154 * @return {string} Wrapped text
155 */
156 function doSplitLines( selText, pre, post ) {
157 var i,
158 insertText = '',
159 selTextArr = selText.split( '\n' );
160 for ( i = 0; i < selTextArr.length; i++ ) {
161 insertText += pre + selTextArr[ i ] + post;
162 if ( i !== selTextArr.length - 1 ) {
163 insertText += '\n';
164 }
165 }
166 return insertText;
167 }
168
169 isSample = false;
170 // Do nothing if display none
171 if ( this.style.display !== 'none' ) {
172 if ( this.selectionStart || this.selectionStart === 0 ) {
173 $( this ).focus();
174 if ( options.selectionStart !== undefined ) {
175 $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
176 }
177
178 selText = $( this ).textSelection( 'getSelection' );
179 startPos = this.selectionStart;
180 endPos = this.selectionEnd;
181 scrollTop = this.scrollTop;
182 checkSelectedText();
183 if (
184 options.selectionStart !== undefined &&
185 endPos - startPos !== options.selectionEnd - options.selectionStart
186 ) {
187 // This means there is a difference in the selection range returned by browser and what we passed.
188 // This happens for Chrome in the case of composite characters. Ref bug #30130
189 // Set the startPos to the correct position.
190 startPos = options.selectionStart;
191 }
192
193 insertText = pre + selText + post;
194 if ( options.splitlines ) {
195 insertText = doSplitLines( selText, pre, post );
196 }
197 if ( options.ownline ) {
198 if ( startPos !== 0 && this.value.charAt( startPos - 1 ) !== '\n' && this.value.charAt( startPos - 1 ) !== '\r' ) {
199 insertText = '\n' + insertText;
200 pre += '\n';
201 }
202 if ( this.value.charAt( endPos ) !== '\n' && this.value.charAt( endPos ) !== '\r' ) {
203 insertText += '\n';
204 post += '\n';
205 }
206 }
207 this.value = this.value.slice( 0, startPos ) + insertText +
208 this.value.slice( endPos );
209 // Setting this.value scrolls the textarea to the top, restore the scroll position
210 this.scrollTop = scrollTop;
211 if ( window.opera ) {
212 pre = pre.replace( /\r?\n/g, '\r\n' );
213 selText = selText.replace( /\r?\n/g, '\r\n' );
214 post = post.replace( /\r?\n/g, '\r\n' );
215 }
216 if ( isSample && options.selectPeri && ( !options.splitlines || ( options.splitlines && selText.indexOf( '\n' ) === -1 ) ) ) {
217 this.selectionStart = startPos + pre.length;
218 this.selectionEnd = startPos + pre.length + selText.length;
219 } else {
220 this.selectionStart = startPos + insertText.length;
221 this.selectionEnd = this.selectionStart;
222 }
223 }
224 }
225 $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
226 options.replace, options.splitlines ] );
227 } );
228 },
229
230 /**
231 * Get the current cursor position (in UTF-16 code units) in a textarea.
232 *
233 * @private
234 * @param {Object} [options]
235 * @param {Object} [options.startAndEnd=false] Return range of the selection rather than just start
236 * @return {Mixed}
237 * - When `startAndEnd` is `false`: number
238 * - When `startAndEnd` is `true`: array with two numbers, for start and end of selection
239 */
240 getCaretPosition: function ( options ) {
241 function getCaret( e ) {
242 var caretPos = 0,
243 endPos = 0;
244
245 if ( e && ( e.selectionStart || e.selectionStart === 0 ) ) {
246 caretPos = e.selectionStart;
247 endPos = e.selectionEnd;
248 }
249 return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
250 }
251 return getCaret( this.get( 0 ) );
252 },
253
254 /**
255 * Set the current cursor position (in UTF-16 code units) in a textarea.
256 *
257 * @private
258 * @param {Object} [options]
259 * @param {number} options.start
260 * @param {number} [options.end=options.start]
261 * @return {jQuery}
262 * @chainable
263 */
264 setSelection: function ( options ) {
265 return this.each( function () {
266 // Do nothing if hidden
267 if ( !$( this ).is( ':hidden' ) ) {
268 if ( this.selectionStart || this.selectionStart === 0 ) {
269 // Opera 9.0 doesn't allow setting selectionStart past
270 // selectionEnd; any attempts to do that will be ignored
271 // Make sure to set them in the right order
272 if ( options.start > this.selectionEnd ) {
273 this.selectionEnd = options.end;
274 this.selectionStart = options.start;
275 } else {
276 this.selectionStart = options.start;
277 this.selectionEnd = options.end;
278 }
279 }
280 }
281 } );
282 },
283
284 /**
285 * Scroll a textarea to the current cursor position. You can set the cursor
286 * position with #setSelection.
287 *
288 * @private
289 * @param {Object} [options]
290 * @param {string} [options.force=false] Whether to force a scroll even if the caret position
291 * is already visible.
292 * @return {jQuery}
293 * @chainable
294 */
295 scrollToCaretPosition: function ( options ) {
296 return this.each( function () {
297 var
298 clientHeight = this.clientHeight,
299 origValue = this.value,
300 origSelectionStart = this.selectionStart,
301 origSelectionEnd = this.selectionEnd,
302 origScrollTop = this.scrollTop,
303 calcScrollTop;
304
305 // Do nothing if hidden
306 if ( !$( this ).is( ':hidden' ) ) {
307 // Delete all text after the selection and scroll the textarea to the end.
308 // This ensures the selection is visible (aligned to the bottom of the textarea).
309 // Then restore the text we deleted without changing scroll position.
310 this.value = this.value.slice( 0, this.selectionEnd );
311 this.scrollTop = this.scrollHeight;
312 // Chrome likes to adjust scroll position when changing value, so save and re-set later.
313 // Note that this is not equal to scrollHeight, it's scrollHeight minus clientHeight.
314 calcScrollTop = this.scrollTop;
315 this.value = origValue;
316 this.selectionStart = origSelectionStart;
317 this.selectionEnd = origSelectionEnd;
318
319 if ( !options.force ) {
320 // Check if all the scrolling was unnecessary and if so, restore previous position.
321 // If the current position is no more than a screenful above the original,
322 // the selection was previously visible on the screen.
323 if ( calcScrollTop < origScrollTop && origScrollTop - calcScrollTop < clientHeight ) {
324 calcScrollTop = origScrollTop;
325 }
326 }
327
328 this.scrollTop = calcScrollTop;
329 }
330 $( this ).trigger( 'scrollToPosition' );
331 } );
332 }
333 };
334
335 /**
336 * @method register
337 *
338 * Register an alternative textSelection API for this element.
339 *
340 * @private
341 * @param {Object} functions Functions to replace. Keys are command names (as in #textSelection,
342 * except 'register' and 'unregister'). Values are functions to execute when a given command is
343 * called.
344 */
345
346 /**
347 * @method unregister
348 *
349 * Unregister the alternative textSelection API for this element (see #register).
350 *
351 * @private
352 */
353
354 alternateFn = $( this ).data( 'jquery.textSelection' );
355
356 // Apply defaults
357 switch ( command ) {
358 // case 'getContents': // no params
359 // case 'setContents': // no params with defaults
360 // case 'getSelection': // no params
361 case 'encapsulateSelection':
362 options = $.extend( {
363 pre: '',
364 peri: '',
365 post: '',
366 ownline: false,
367 replace: false,
368 selectPeri: true,
369 splitlines: false,
370 selectionStart: undefined,
371 selectionEnd: undefined
372 }, options );
373 break;
374 case 'getCaretPosition':
375 options = $.extend( {
376 startAndEnd: false
377 }, options );
378 break;
379 case 'setSelection':
380 options = $.extend( {
381 start: undefined,
382 end: undefined
383 }, options );
384 if ( options.end === undefined ) {
385 options.end = options.start;
386 }
387 break;
388 case 'scrollToCaretPosition':
389 options = $.extend( {
390 force: false
391 }, options );
392 break;
393 case 'register':
394 if ( alternateFn ) {
395 throw new Error( 'Another textSelection API was already registered' );
396 }
397 $( this ).data( 'jquery.textSelection', options );
398 // No need to update alternateFn as this command only stores the options.
399 // A command that uses it will set it again.
400 return;
401 case 'unregister':
402 $( this ).removeData( 'jquery.textSelection' );
403 return;
404 }
405
406 retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, options );
407
408 return retval;
409 };
410
411 /**
412 * @class jQuery
413 */
414 /**
415 * @method textSelection
416 * @inheritdoc jQuery.plugin.textSelection#textSelection
417 */
418
419 }( jQuery ) );