Merge "SpecialBlock [Vue]: add NamespacesField and PagesField components"
[mediawiki.git] / resources / src / jquery / jquery.textSelection.js
blob3ef27b31ad97cca2932d2838cb6a063444b7fe72
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) - https://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  */
11 /**
12  * Do things to the selection in a `<textarea>`, or a textarea-like editable element.
13  * Provided by the `jquery.textSelection` ResourceLoader module.
14  *
15  * @example
16  * mw.loader.using( 'jquery.textSelection' ).then( () => {
17  *     const contents = $( '#wpTextbox1' ).textSelection( 'getContents' );
18  * } );
19  *
20  * @module jquery.textSelection
21  */
22 ( function () {
23         /**
24          * Checks if you can try to use insertText (it might still fail).
25          *
26          * @ignore
27          * @return {boolean}
28          */
29         function supportsInsertText() {
30                 return $( this ).data( 'jquery.textSelection' ) === undefined &&
31                         typeof document.execCommand === 'function' &&
32                         typeof document.queryCommandSupported === 'function' &&
33                         document.queryCommandSupported( 'insertText' );
34         }
36         /**
37          * Insert text into textarea or contenteditable.
38          *
39          * @ignore
40          * @param {HTMLElement} field Field to select.
41          * @param {string} content Text to insert.
42          * @param {Function} fallback To execute as a fallback.
43          */
44         function execInsertText( field, content, fallback ) {
45                 let inserted = false;
47                 if (
48                         supportsInsertText() &&
49                         !(
50                                 // Support: Chrome, Safari
51                                 // Inserting multiple lines is very slow in Chrome/Safari (T343795)
52                                 // If this is ever fixed, remove the dependency on jquery.client
53                                 $.client.profile().layout === 'webkit' &&
54                                 content.split( '\n' ).length > 100
55                         )
56                 ) {
57                         field.focus();
58                         try {
59                                 if (
60                                         // Ensure the field was focused, otherwise we can't use execCommand() to change it.
61                                         // focus() can fail if e.g. the field is disabled, or its container is inert.
62                                         document.activeElement === field &&
63                                         // Try to insert
64                                         document.execCommand( 'insertText', false, content )
65                                 ) {
66                                         inserted = true;
67                                 }
68                         } catch ( e ) {}
69                 }
70                 // Fallback
71                 if ( !inserted ) {
72                         fallback.call( field, content );
73                 }
74         }
76         const fn = {
77                 /**
78                  * Get the contents of the textarea.
79                  *
80                  * @return {string}
81                  * @memberof module:jquery.textSelection
82                  */
83                 getContents: function () {
84                         return this.val();
85                 },
87                 /**
88                  * Set the contents of the textarea, replacing anything that was there before.
89                  *
90                  * @param {string} content
91                  * @return {jQuery}
92                  * @chainable
93                  * @memberof module:jquery.textSelection
94                  */
95                 setContents: function ( content ) {
96                         return this.each( function () {
97                                 const scrollTop = this.scrollTop;
98                                 this.select();
99                                 execInsertText( this, content, function () {
100                                         $( this ).val( content );
101                                 } );
102                                 // Setting this.value may scroll the textarea, restore the scroll position
103                                 this.scrollTop = scrollTop;
104                         } );
105                 },
107                 /**
108                  * Get the currently selected text in this textarea.
109                  *
110                  * @return {string}
111                  * @memberof module:jquery.textSelection
112                  */
113                 getSelection: function () {
114                         const el = this.get( 0 );
116                         let val;
117                         if ( !el ) {
118                                 val = '';
119                         } else {
120                                 val = el.value.slice( el.selectionStart, el.selectionEnd );
121                         }
123                         return val;
124                 },
126                 /**
127                  * Replace the selected text in the textarea with the given text, or insert it at the cursor.
128                  *
129                  * @param {string} value
130                  * @return {jQuery}
131                  * @chainable
132                  * @memberof module:jquery.textSelection
133                  */
134                 replaceSelection: function ( value ) {
135                         return this.each( function () {
136                                 execInsertText( this, value, function () {
137                                         const allText = $( this ).textSelection( 'getContents' );
138                                         const currSelection = $( this ).textSelection( 'getCaretPosition', { startAndEnd: true } );
139                                         const startPos = currSelection[ 0 ];
140                                         const endPos = currSelection[ 1 ];
142                                         $( this ).textSelection( 'setContents', allText.slice( 0, startPos ) + value +
143                                                 allText.slice( endPos ) );
144                                         $( this ).textSelection( 'setSelection', {
145                                                 start: startPos,
146                                                 end: startPos + value.length
147                                         } );
148                                 } );
149                         } );
150                 },
152                 /**
153                  * Insert text at the beginning and end of a text selection, optionally
154                  * inserting text at the caret when selection is empty.
155                  *
156                  * Also focusses the textarea.
157                  *
158                  * @param {Object} [options]
159                  * @param {string} [options.pre] Text to insert before the cursor/selection
160                  * @param {string} [options.peri] Text to insert between pre and post and select afterwards
161                  * @param {string} [options.post] Text to insert after the cursor/selection
162                  * @param {boolean} [options.ownline=false] Put the inserted text on a line of its own
163                  * @param {boolean} [options.replace=false] If there is a selection, replace it with peri
164                  *  instead of leaving it alone
165                  * @param {boolean} [options.selectPeri=true] Select the peri text if it was inserted (but not
166                  *  if there was a selection and replace==false, or if splitlines==true)
167                  * @param {boolean} [options.splitlines=false] If multiple lines are selected, encapsulate
168                  *  each line individually
169                  * @param {number} [options.selectionStart] Position to start selection at
170                  * @param {number} [options.selectionEnd=options.selectionStart] Position to end selection at
171                  * @return {jQuery}
172                  * @chainable
173                  * @memberof module:jquery.textSelection
174                  */
175                 encapsulateSelection: function ( options ) {
176                         return this.each( function () {
177                                 let selText, isSample,
178                                         pre = options.pre,
179                                         post = options.post;
181                                 /**
182                                  * Check if the selected text is the same as the insert text
183                                  *
184                                  * @ignore
185                                  */
186                                 function checkSelectedText() {
187                                         if ( !selText ) {
188                                                 selText = options.peri;
189                                                 isSample = true;
190                                         } else if ( options.replace ) {
191                                                 selText = options.peri;
192                                         } else {
193                                                 while ( selText.charAt( selText.length - 1 ) === ' ' ) {
194                                                         // Exclude ending space char
195                                                         selText = selText.slice( 0, -1 );
196                                                         post += ' ';
197                                                 }
198                                                 while ( selText.charAt( 0 ) === ' ' ) {
199                                                         // Exclude prepending space char
200                                                         selText = selText.slice( 1 );
201                                                         pre = ' ' + pre;
202                                                 }
203                                         }
204                                 }
206                                 /**
207                                  * Do the splitlines stuff.
208                                  *
209                                  * Wrap each line of the selected text with pre and post
210                                  *
211                                  * @ignore
212                                  * @param {string} text Selected text
213                                  * @param {string} preText Text before
214                                  * @param {string} postText Text after
215                                  * @return {string} Wrapped text
216                                  */
217                                 function doSplitLines( text, preText, postText ) {
218                                         const selTextArr = text.split( '\n' );
219                                         let insText = '';
220                                         for ( let i = 0; i < selTextArr.length; i++ ) {
221                                                 insText += preText + selTextArr[ i ] + postText;
222                                                 if ( i !== selTextArr.length - 1 ) {
223                                                         insText += '\n';
224                                                 }
225                                         }
226                                         return insText;
227                                 }
229                                 isSample = false;
230                                 $( this ).trigger( 'focus' );
231                                 if ( options.selectionStart !== undefined ) {
232                                         $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
233                                 }
235                                 selText = $( this ).textSelection( 'getSelection' );
236                                 const allText = $( this ).textSelection( 'getContents' );
237                                 const currSelection = $( this ).textSelection( 'getCaretPosition', { startAndEnd: true } );
238                                 let startPos = currSelection[ 0 ];
239                                 const endPos = currSelection[ 1 ];
240                                 checkSelectedText();
241                                 let combiningCharSelectionBug = false;
242                                 if (
243                                         options.selectionStart !== undefined &&
244                                         endPos - startPos !== options.selectionEnd - options.selectionStart
245                                 ) {
246                                         // This means there is a difference in the selection range returned by browser and what we passed.
247                                         // This happens for Safari 5.1, Chrome 12 in the case of composite characters. Ref T32130
248                                         // Set the startPos to the correct position.
249                                         startPos = options.selectionStart;
250                                         combiningCharSelectionBug = true;
251                                         // TODO: The comment above is from 2011. Is this still a problem for browsers we support today?
252                                         // Minimal test case: https://jsfiddle.net/z4q7a2ko/
253                                 }
255                                 let insertText = pre + selText + post;
256                                 if ( options.splitlines ) {
257                                         insertText = doSplitLines( selText, pre, post );
258                                 }
259                                 if ( options.ownline ) {
260                                         if ( startPos !== 0 && allText.charAt( startPos - 1 ) !== '\n' && allText.charAt( startPos - 1 ) !== '\r' ) {
261                                                 insertText = '\n' + insertText;
262                                                 pre += '\n';
263                                         }
264                                         if ( allText.charAt( endPos ) !== '\n' && allText.charAt( endPos ) !== '\r' ) {
265                                                 insertText += '\n';
266                                                 post += '\n';
267                                         }
268                                 }
269                                 if ( combiningCharSelectionBug ) {
270                                         $( this ).textSelection( 'setContents', allText.slice( 0, startPos ) + insertText +
271                                                 allText.slice( endPos ) );
272                                 } else {
273                                         $( this ).textSelection( 'replaceSelection', insertText );
274                                 }
275                                 if ( isSample && options.selectPeri && ( !options.splitlines || ( options.splitlines && selText.indexOf( '\n' ) === -1 ) ) ) {
276                                         $( this ).textSelection( 'setSelection', {
277                                                 start: startPos + pre.length,
278                                                 end: startPos + pre.length + selText.length
279                                         } );
280                                 } else {
281                                         $( this ).textSelection( 'setSelection', {
282                                                 start: startPos + insertText.length
283                                         } );
284                                 }
285                                 $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
286                                         options.replace, options.splitlines ] );
287                         } );
288                 },
290                 /**
291                  * Get the current cursor position (in UTF-16 code units) in a textarea.
292                  *
293                  * @param {Object} [options]
294                  * @param {Object} [options.startAndEnd=false] Return range of the selection rather than just start
295                  * @return {number|number[]}
296                  *  - When `startAndEnd` is `false`: number
297                  *  - When `startAndEnd` is `true`: array with two numbers, for start and end of selection
298                  * @memberof module:jquery.textSelection
299                  */
300                 getCaretPosition: function ( options ) {
301                         function getCaret( e ) {
302                                 let caretPos = 0,
303                                         endPos = 0;
304                                 if ( e ) {
305                                         caretPos = e.selectionStart;
306                                         endPos = e.selectionEnd;
307                                 }
308                                 return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
309                         }
310                         return getCaret( this.get( 0 ) );
311                 },
313                 /**
314                  * Set the current cursor position (in UTF-16 code units) in a textarea.
315                  *
316                  * @param {Object} [options]
317                  * @param {number} options.start
318                  * @param {number} [options.end=options.start]
319                  * @return {jQuery}
320                  * @chainable
321                  * @memberof module:jquery.textSelection
322                  */
323                 setSelection: function ( options ) {
324                         return this.each( function () {
325                                 // Opera 9.0 doesn't allow setting selectionStart past
326                                 // selectionEnd; any attempts to do that will be ignored
327                                 // Make sure to set them in the right order
328                                 if ( options.start > this.selectionEnd ) {
329                                         this.selectionEnd = options.end;
330                                         this.selectionStart = options.start;
331                                 } else {
332                                         this.selectionStart = options.start;
333                                         this.selectionEnd = options.end;
334                                 }
335                         } );
336                 },
338                 /**
339                  * Scroll a textarea to the current cursor position. You can set the cursor
340                  * position with {@link module:jquery.textSelection.setSelection setSelection}.
341                  *
342                  * @param {Object} [options]
343                  * @param {string} [options.force=false] Whether to force a scroll even if the caret position
344                  *  is already visible.
345                  * @return {jQuery}
346                  * @chainable
347                  * @memberof module:jquery.textSelection
348                  */
349                 scrollToCaretPosition: function ( options ) {
350                         return this.each( function () {
351                                 const clientHeight = this.clientHeight,
352                                         origValue = this.value,
353                                         origSelectionStart = this.selectionStart,
354                                         origSelectionEnd = this.selectionEnd,
355                                         origScrollTop = this.scrollTop;
357                                 // Delete all text after the selection and scroll the textarea to the end.
358                                 // This ensures the selection is visible (aligned to the bottom of the textarea).
359                                 // Then restore the text we deleted without changing scroll position.
360                                 this.value = this.value.slice( 0, this.selectionEnd );
361                                 this.scrollTop = this.scrollHeight;
362                                 // Chrome likes to adjust scroll position when changing value, so save and re-set later.
363                                 // Note that this is not equal to scrollHeight, it's scrollHeight minus clientHeight.
364                                 let calcScrollTop = this.scrollTop;
365                                 this.value = origValue;
366                                 this.selectionStart = origSelectionStart;
367                                 this.selectionEnd = origSelectionEnd;
369                                 if ( !options.force ) {
370                                         // Check if all the scrolling was unnecessary and if so, restore previous position.
371                                         // If the current position is no more than a screenful above the original,
372                                         // the selection was previously visible on the screen.
373                                         if ( calcScrollTop < origScrollTop && origScrollTop - calcScrollTop < clientHeight ) {
374                                                 calcScrollTop = origScrollTop;
375                                         }
376                                 }
378                                 this.scrollTop = calcScrollTop;
380                                 $( this ).trigger( 'scrollToPosition' );
381                         } );
382                 }
383         };
385         /**
386          * Register an alternative textSelection API for this element.
387          *
388          * @method register
389          * @param {Object} functions Functions to replace. Keys are command names (as in {@link module:jquery.textSelection.textSelection textSelection},
390          *  except 'register' and 'unregister'). Values are functions to execute when a given command is
391          *  called.
392          * @memberof module:jquery.textSelection
393          */
395         /**
396          * Unregister the alternative textSelection API for this element (see {@link module:jquery.textSelection.register register}).
397          *
398          * @method unregister
399          * @memberof module:jquery.textSelection
400          */
402         /**
403          * Execute a textSelection command about the element.
404          *
405          * @example
406          * var $textbox = $( '#wpTextbox1' );
407          * $textbox.textSelection( 'setContents', 'This is bold!' );
408          * $textbox.textSelection( 'setSelection', { start: 8, end: 12 } );
409          * $textbox.textSelection( 'encapsulateSelection', { pre: '<b>', post: '</b>' } );
410          * // Result: Textbox contains 'This is <b>bold</b>!', with cursor before the '!'
411          * @memberof module:jquery.textSelection
412          * @method
413          * @param {string} command Command to execute, one of:
414          *
415          *  - {@link module:jquery.textSelection.getContents getContents}
416          *  - {@link module:jquery.textSelection.setContents setContents}
417          *  - {@link module:jquery.textSelection.getSelection getSelection}
418          *  - {@link module:jquery.textSelection.replaceSelection replaceSelection}
419          *  - {@link module:jquery.textSelection.encapsulateSelection encapsulateSelection}
420          *  - {@link module:jquery.textSelection.getCaretPosition getCaretPosition}
421          *  - {@link module:jquery.textSelection.setSelection setSelection}
422          *  - {@link module:jquery.textSelection.scrollToCaretPosition scrollToCaretPosition}
423          *  - {@link module:jquery.textSelection.register register}
424          *  - {@link module:jquery.textSelection.unregister unregister}
425          * @param {any} [commandOptions] Options to pass to the command
426          * @return {any} Depending on the command
427          */
428         $.fn.textSelection = function ( command, commandOptions ) {
429                 const alternateFn = $( this ).data( 'jquery.textSelection' );
431                 // Prevent values of `undefined` overwriting defaults (T368102)
432                 for ( const key in commandOptions ) {
433                         if ( commandOptions[ key ] === undefined ) {
434                                 delete commandOptions[ key ];
435                         }
436                 }
438                 // Apply defaults
439                 switch ( command ) {
440                         // case 'getContents': // no params
441                         // case 'setContents': // no params with defaults
442                         // case 'getSelection': // no params
443                         // case 'replaceSelection': // no params with defaults
444                         case 'encapsulateSelection':
445                                 commandOptions = Object.assign( {
446                                         pre: '',
447                                         peri: '',
448                                         post: '',
449                                         ownline: false,
450                                         replace: false,
451                                         selectPeri: true,
452                                         splitlines: false,
453                                         selectionStart: undefined,
454                                         selectionEnd: undefined
455                                 }, commandOptions );
456                                 break;
457                         case 'getCaretPosition':
458                                 commandOptions = Object.assign( {
459                                         startAndEnd: false
460                                 }, commandOptions );
461                                 break;
462                         case 'setSelection':
463                                 commandOptions = Object.assign( {
464                                         start: undefined,
465                                         end: undefined
466                                 }, commandOptions );
467                                 if ( commandOptions.end === undefined ) {
468                                         commandOptions.end = commandOptions.start;
469                                 }
470                                 break;
471                         case 'scrollToCaretPosition':
472                                 commandOptions = Object.assign( {
473                                         force: false
474                                 }, commandOptions );
475                                 break;
476                         case 'register':
477                                 if ( alternateFn ) {
478                                         throw new Error( 'Another textSelection API was already registered' );
479                                 }
480                                 $( this ).data( 'jquery.textSelection', commandOptions );
481                                 // No need to update alternateFn as this command only stores the options.
482                                 // A command that uses it will set it again.
483                                 return;
484                         case 'unregister':
485                                 $( this ).removeData( 'jquery.textSelection' );
486                                 return;
487                 }
489                 const retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, commandOptions );
491                 return retval;
492         };
493 }() );