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