2 * These plugins provide extra functionality for interaction with textareas.
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)
12 * Do things to the selection in a `<textarea>`, or a textarea-like editable element.
13 * Provided by the `jquery.textSelection` ResourceLoader module.
16 * mw.loader.using( 'jquery.textSelection' ).then( () => {
17 * const contents = $( '#wpTextbox1' ).textSelection( 'getContents' );
20 * @module jquery.textSelection
24 * Checks if you can try to use insertText (it might still fail).
29 function supportsInsertText() {
30 return $( this ).data( 'jquery.textSelection' ) === undefined &&
31 typeof document.execCommand === 'function' &&
32 typeof document.queryCommandSupported === 'function' &&
33 document.queryCommandSupported( 'insertText' );
37 * Insert text into textarea or contenteditable.
40 * @param {HTMLElement} field Field to select.
41 * @param {string} content Text to insert.
42 * @param {Function} fallback To execute as a fallback.
44 function execInsertText( field, content, fallback ) {
48 supportsInsertText() &&
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
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 &&
64 document.execCommand( 'insertText', false, content )
72 fallback.call( field, content );
78 * Get the contents of the textarea.
81 * @memberof module:jquery.textSelection
83 getContents: function () {
88 * Set the contents of the textarea, replacing anything that was there before.
90 * @param {string} content
93 * @memberof module:jquery.textSelection
95 setContents: function ( content ) {
96 return this.each( function () {
97 const scrollTop = this.scrollTop;
99 execInsertText( this, content, function () {
100 $( this ).val( content );
102 // Setting this.value may scroll the textarea, restore the scroll position
103 this.scrollTop = scrollTop;
108 * Get the currently selected text in this textarea.
111 * @memberof module:jquery.textSelection
113 getSelection: function () {
114 const el = this.get( 0 );
120 val = el.value.slice( el.selectionStart, el.selectionEnd );
127 * Replace the selected text in the textarea with the given text, or insert it at the cursor.
129 * @param {string} value
132 * @memberof module:jquery.textSelection
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', {
146 end: startPos + value.length
153 * Insert text at the beginning and end of a text selection, optionally
154 * inserting text at the caret when selection is empty.
156 * Also focusses the textarea.
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
173 * @memberof module:jquery.textSelection
175 encapsulateSelection: function ( options ) {
176 return this.each( function () {
177 let selText, isSample,
182 * Check if the selected text is the same as the insert text
186 function checkSelectedText() {
188 selText = options.peri;
190 } else if ( options.replace ) {
191 selText = options.peri;
193 while ( selText.charAt( selText.length - 1 ) === ' ' ) {
194 // Exclude ending space char
195 selText = selText.slice( 0, -1 );
198 while ( selText.charAt( 0 ) === ' ' ) {
199 // Exclude prepending space char
200 selText = selText.slice( 1 );
207 * Do the splitlines stuff.
209 * Wrap each line of the selected text with pre and post
212 * @param {string} text Selected text
213 * @param {string} preText Text before
214 * @param {string} postText Text after
215 * @return {string} Wrapped text
217 function doSplitLines( text, preText, postText ) {
218 const selTextArr = text.split( '\n' );
220 for ( let i = 0; i < selTextArr.length; i++ ) {
221 insText += preText + selTextArr[ i ] + postText;
222 if ( i !== selTextArr.length - 1 ) {
230 $( this ).trigger( 'focus' );
231 if ( options.selectionStart !== undefined ) {
232 $( this ).textSelection( 'setSelection', { start: options.selectionStart, end: options.selectionEnd } );
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 ];
241 let combiningCharSelectionBug = false;
243 options.selectionStart !== undefined &&
244 endPos - startPos !== options.selectionEnd - options.selectionStart
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/
255 let insertText = pre + selText + post;
256 if ( options.splitlines ) {
257 insertText = doSplitLines( selText, pre, post );
259 if ( options.ownline ) {
260 if ( startPos !== 0 && allText.charAt( startPos - 1 ) !== '\n' && allText.charAt( startPos - 1 ) !== '\r' ) {
261 insertText = '\n' + insertText;
264 if ( allText.charAt( endPos ) !== '\n' && allText.charAt( endPos ) !== '\r' ) {
269 if ( combiningCharSelectionBug ) {
270 $( this ).textSelection( 'setContents', allText.slice( 0, startPos ) + insertText +
271 allText.slice( endPos ) );
273 $( this ).textSelection( 'replaceSelection', insertText );
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
281 $( this ).textSelection( 'setSelection', {
282 start: startPos + insertText.length
285 $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
286 options.replace, options.splitlines ] );
291 * Get the current cursor position (in UTF-16 code units) in a textarea.
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
300 getCaretPosition: function ( options ) {
301 function getCaret( e ) {
305 caretPos = e.selectionStart;
306 endPos = e.selectionEnd;
308 return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
310 return getCaret( this.get( 0 ) );
314 * Set the current cursor position (in UTF-16 code units) in a textarea.
316 * @param {Object} [options]
317 * @param {number} options.start
318 * @param {number} [options.end=options.start]
321 * @memberof module:jquery.textSelection
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;
332 this.selectionStart = options.start;
333 this.selectionEnd = options.end;
339 * Scroll a textarea to the current cursor position. You can set the cursor
340 * position with {@link module:jquery.textSelection.setSelection setSelection}.
342 * @param {Object} [options]
343 * @param {string} [options.force=false] Whether to force a scroll even if the caret position
344 * is already visible.
347 * @memberof module:jquery.textSelection
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;
378 this.scrollTop = calcScrollTop;
380 $( this ).trigger( 'scrollToPosition' );
386 * Register an alternative textSelection API for this element.
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
392 * @memberof module:jquery.textSelection
396 * Unregister the alternative textSelection API for this element (see {@link module:jquery.textSelection.register register}).
399 * @memberof module:jquery.textSelection
403 * Execute a textSelection command about the element.
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
413 * @param {string} command Command to execute, one of:
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
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 ];
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( {
453 selectionStart: undefined,
454 selectionEnd: undefined
457 case 'getCaretPosition':
458 commandOptions = Object.assign( {
463 commandOptions = Object.assign( {
467 if ( commandOptions.end === undefined ) {
468 commandOptions.end = commandOptions.start;
471 case 'scrollToCaretPosition':
472 commandOptions = Object.assign( {
478 throw new Error( 'Another textSelection API was already registered' );
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.
485 $( this ).removeData( 'jquery.textSelection' );
489 const retval = ( alternateFn && alternateFn[ command ] || fn[ command ] ).call( this, commandOptions );