Merge "resourceloader: Simplify StringSet fallback"
[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#replaceSelection replaceSelection}
38 * - {@link jQuery.plugin.textSelection#encapsulateSelection encapsulateSelection}
39 * - {@link jQuery.plugin.textSelection#getCaretPosition getCaretPosition}
40 * - {@link jQuery.plugin.textSelection#setSelection setSelection}
41 * - {@link jQuery.plugin.textSelection#scrollToCaretPosition scrollToCaretPosition}
42 * - {@link jQuery.plugin.textSelection#register register}
43 * - {@link jQuery.plugin.textSelection#unregister unregister}
44 * @param {Mixed} [options] Options to pass to the command
45 * @return {Mixed} Depending on the command
46 */
47 $.fn.textSelection = function ( command, options ) {
48 var fn,
49 alternateFn,
50 retval;
51
52 fn = {
53 /**
54 * Get the contents of the textarea.
55 *
56 * @private
57 * @return {string}
58 */
59 getContents: function () {
60 return this.val();
61 },
62
63 /**
64 * Set the contents of the textarea, replacing anything that was there before.
65 *
66 * @private
67 * @param {string} content
68 * @return {jQuery}
69 * @chainable
70 */
71 setContents: function ( content ) {
72 return this.each( function () {
73 var scrollTop = this.scrollTop;
74 $( this ).val( content );
75 // Setting this.value may scroll the textarea, restore the scroll position
76 this.scrollTop = scrollTop;
77 } );
78 },
79
80 /**
81 * Get the currently selected text in this textarea.
82 *
83 * @private
84 * @return {string}
85 */
86 getSelection: function () {
87 var retval,
88 el = this.get( 0 );
89
90 if ( !el ) {
91 retval = '';
92 } else {
93 retval = el.value.substring( el.selectionStart, el.selectionEnd );
94 }
95
96 return retval;
97 },
98
99 /**
100 * Replace the selected text in the textarea with the given text, or insert it at the cursor.
101 *
102 * @private
103 * @param {string} value
104 * @return {jQuery}
105 * @chainable
106 */
107 replaceSelection: function ( value ) {
108 return this.each( function () {
109 var allText, currSelection, startPos, endPos;
110
111 allText = $( this ).textSelection( 'getContents' );
112 currSelection = $( this ).textSelection( 'getCaretPosition', { startAndEnd: true } );
113 startPos = currSelection[ 0 ];
114 endPos = currSelection[ 1 ];
115
116 $( this ).textSelection( 'setContents', allText.slice( 0, startPos ) + value +
117 allText.slice( endPos ) );
118 $( this ).textSelection( 'setSelection', {
119 start: startPos,
120 end: startPos + value.length
121 } );
122 } );
123 },
124
125 /**
126 * Insert text at the beginning and end of a text selection, optionally
127 * inserting text at the caret when selection is empty.
128 *
129 * Also focusses the textarea.
130 *
131 * @private
132 * @param {Object} [options]
133 * @param {string} [options.pre] Text to insert before the cursor/selection
134 * @param {string} [options.peri] Text to insert between pre and post and select afterwards
135 * @param {string} [options.post] Text to insert after the cursor/selection
136 * @param {boolean} [options.ownline=false] Put the inserted text on a line of its own
137 * @param {boolean} [options.replace=false] If there is a selection, replace it with peri
138 * instead of leaving it alone
139 * @param {boolean} [options.selectPeri=true] Select the peri text if it was inserted (but not
140 * if there was a selection and replace==false, or if splitlines==true)
141 * @param {boolean} [options.splitlines=false] If multiple lines are selected, encapsulate
142 * each line individually
143 * @param {number} [options.selectionStart] Position to start selection at
144 * @param {number} [options.selectionEnd=options.selectionStart] Position to end selection at
145 * @return {jQuery}
146 * @chainable
147 */
148 encapsulateSelection: function ( options ) {
149 return this.each( function () {
150 var selText, allText, currSelection, insertText,
151 combiningCharSelectionBug = false,
152 isSample, startPos, endPos,
153 pre = options.pre,
154 post = options.post;
155
156 /**
157 * @ignore
158 * Check if the selected text is the same as the insert text
159 */
160 function checkSelectedText() {
161 if ( !selText ) {
162 selText = options.peri;
163 isSample = true;
164 } else if ( options.replace ) {
165 selText = options.peri;
166 } else {
167 while ( selText.charAt( selText.length - 1 ) === ' ' ) {
168 // Exclude ending space char
169 selText = selText.slice( 0, -1 );
170 post += ' ';
171 }
172 while ( selText.charAt( 0 ) === ' ' ) {
173 // Exclude prepending space char
174 selText = selText.slice( 1 );
175 pre = ' ' + pre;
176 }
177 }
178 }
179
180 /**
181 * @ignore
182 * Do the splitlines stuff.
183 *
184 * Wrap each line of the selected text with pre and post
185 *
186 * @param {string} selText Selected text
187 * @param {string} pre Text before
188 * @param {string} post Text after
189 * @return {string} Wrapped text
190 */
191 function doSplitLines( selText, pre, post ) {
192 var i,
193 insertText = '',
194 selTextArr = selText.split( '\n' );
195 for ( i = 0; i < selTextArr.length; i++ ) {
196 insertText += pre + selTextArr[ i ] + post;
197 if ( i !== selTextArr.length - 1 ) {
198 insertText += '\n';
199 }
200 }
201 return insertText;
202 }
203
204 isSample = false;
205 $( this ).focus();
206 if ( options.selectionStart !== undefined ) {
207 $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
208 }
209
210 selText = $( this ).textSelection( 'getSelection' );
211 allText = $( this ).textSelection( 'getContents' );
212 currSelection = $( this ).textSelection( 'getCaretPosition', { startAndEnd: true } );
213 startPos = currSelection[ 0 ];
214 endPos = currSelection[ 1 ];
215 checkSelectedText();
216 if (
217 options.selectionStart !== undefined &&
218 endPos - startPos !== options.selectionEnd - options.selectionStart
219 ) {
220 // This means there is a difference in the selection range returned by browser and what we passed.
221 // This happens for Safari 5.1, Chrome 12 in the case of composite characters. Ref T32130
222 // Set the startPos to the correct position.
223 startPos = options.selectionStart;
224 combiningCharSelectionBug = true;
225 // TODO: The comment above is from 2011. Is this still a problem for browsers we support today?
226 // Minimal test case: https://jsfiddle.net/z4q7a2ko/
227 }
228
229 insertText = pre + selText + post;
230 if ( options.splitlines ) {
231 insertText = doSplitLines( selText, pre, post );
232 }
233 if ( options.ownline ) {
234 if ( startPos !== 0 && allText.charAt( startPos - 1 ) !== '\n' && allText.charAt( startPos - 1 ) !== '\r' ) {
235 insertText = '\n' + insertText;
236 pre += '\n';
237 }
238 if ( allText.charAt( endPos ) !== '\n' && allText.charAt( endPos ) !== '\r' ) {
239 insertText += '\n';
240 post += '\n';
241 }
242 }
243 if ( combiningCharSelectionBug ) {
244 $( this ).textSelection( 'setContents', allText.slice( 0, startPos ) + insertText +
245 allText.slice( endPos ) );
246 } else {
247 $( this ).textSelection( 'replaceSelection', insertText );
248 }
249 if ( isSample && options.selectPeri && ( !options.splitlines || ( options.splitlines && selText.indexOf( '\n' ) === -1 ) ) ) {
250 $( this ).textSelection( 'setSelection', {
251 start: startPos + pre.length,
252 end: startPos + pre.length + selText.length
253 } );
254 } else {
255 $( this ).textSelection( 'setSelection', {
256 start: startPos + insertText.length
257 } );
258 }
259 $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
260 options.replace, options.splitlines ] );
261 } );
262 },
263
264 /**
265 * Get the current cursor position (in UTF-16 code units) in a textarea.
266 *
267 * @private
268 * @param {Object} [options]
269 * @param {Object} [options.startAndEnd=false] Return range of the selection rather than just start
270 * @return {Mixed}
271 * - When `startAndEnd` is `false`: number
272 * - When `startAndEnd` is `true`: array with two numbers, for start and end of selection
273 */
274 getCaretPosition: function ( options ) {
275 function getCaret( e ) {
276 var caretPos = 0,
277 endPos = 0;
278 if ( e ) {
279 caretPos = e.selectionStart;
280 endPos = e.selectionEnd;
281 }
282 return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
283 }
284 return getCaret( this.get( 0 ) );
285 },
286
287 /**
288 * Set the current cursor position (in UTF-16 code units) in a textarea.
289 *
290 * @private
291 * @param {Object} [options]
292 * @param {number} options.start
293 * @param {number} [options.end=options.start]
294 * @return {jQuery}
295 * @chainable
296 */
297 setSelection: function ( options ) {
298 return this.each( function () {
299 // Opera 9.0 doesn't allow setting selectionStart past
300 // selectionEnd; any attempts to do that will be ignored
301 // Make sure to set them in the right order
302 if ( options.start > this.selectionEnd ) {
303 this.selectionEnd = options.end;
304 this.selectionStart = options.start;
305 } else {
306 this.selectionStart = options.start;
307 this.selectionEnd = options.end;
308 }
309 } );
310 },
311
312 /**
313 * Scroll a textarea to the current cursor position. You can set the cursor
314 * position with #setSelection.
315 *
316 * @private
317 * @param {Object} [options]
318 * @param {string} [options.force=false] Whether to force a scroll even if the caret position
319 * is already visible.
320 * @return {jQuery}
321 * @chainable
322 */
323 scrollToCaretPosition: function ( options ) {
324 return this.each( function () {
325 var
326 clientHeight = this.clientHeight,
327 origValue = this.value,
328 origSelectionStart = this.selectionStart,
329 origSelectionEnd = this.selectionEnd,
330 origScrollTop = this.scrollTop,
331 calcScrollTop;
332
333 // Delete all text after the selection and scroll the textarea to the end.
334 // This ensures the selection is visible (aligned to the bottom of the textarea).
335 // Then restore the text we deleted without changing scroll position.
336 this.value = this.value.slice( 0, this.selectionEnd );
337 this.scrollTop = this.scrollHeight;
338 // Chrome likes to adjust scroll position when changing value, so save and re-set later.
339 // Note that this is not equal to scrollHeight, it's scrollHeight minus clientHeight.
340 calcScrollTop = this.scrollTop;
341 this.value = origValue;
342 this.selectionStart = origSelectionStart;
343 this.selectionEnd = origSelectionEnd;
344
345 if ( !options.force ) {
346 // Check if all the scrolling was unnecessary and if so, restore previous position.
347 // If the current position is no more than a screenful above the original,
348 // the selection was previously visible on the screen.
349 if ( calcScrollTop < origScrollTop && origScrollTop - calcScrollTop < clientHeight ) {
350 calcScrollTop = origScrollTop;
351 }
352 }
353
354 this.scrollTop = calcScrollTop;
355
356 $( this ).trigger( 'scrollToPosition' );
357 } );
358 }
359 };
360
361 /**
362 * @method register
363 *
364 * Register an alternative textSelection API for this element.
365 *
366 * @private
367 * @param {Object} functions Functions to replace. Keys are command names (as in #textSelection,
368 * except 'register' and 'unregister'). Values are functions to execute when a given command is
369 * called.
370 */
371
372 /**
373 * @method unregister
374 *
375 * Unregister the alternative textSelection API for this element (see #register).
376 *
377 * @private
378 */
379
380 alternateFn = $( this ).data( 'jquery.textSelection' );
381
382 // Apply defaults
383 switch ( command ) {
384 // case 'getContents': // no params
385 // case 'setContents': // no params with defaults
386 // case 'getSelection': // no params
387 // case 'replaceSelection': // no params with defaults
388 case 'encapsulateSelection':
389 options = $.extend( {
390 pre: '',
391 peri: '',
392 post: '',
393 ownline: false,
394 replace: false,
395 selectPeri: true,
396 splitlines: false,
397 selectionStart: undefined,
398 selectionEnd: undefined
399 }, options );
400 break;
401 case 'getCaretPosition':
402 options = $.extend( {
403 startAndEnd: false
404 }, options );
405 break;
406 case 'setSelection':
407 options = $.extend( {
408 start: undefined,
409 end: undefined
410 }, options );
411 if ( options.end === undefined ) {
412 options.end = options.start;
413 }
414 break;
415 case 'scrollToCaretPosition':
416 options = $.extend( {
417 force: false
418 }, options );
419 break;
420 case 'register':
421 if ( alternateFn ) {
422 throw new Error( 'Another textSelection API was already registered' );
423 }
424 $( this ).data( 'jquery.textSelection', options );
425 // No need to update alternateFn as this command only stores the options.
426 // A command that uses it will set it again.
427 return;
428 case 'unregister':
429 $( this ).removeData( 'jquery.textSelection' );
430 return;
431 }
432
433 retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, options );
434
435 return retval;
436 };
437
438 /**
439 * @class jQuery
440 */
441 /**
442 * @method textSelection
443 * @inheritdoc jQuery.plugin.textSelection#textSelection
444 */
445
446 }() );