Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.page.preview.js
blob17e5fc404839c15639320d185e4f215f4873e57c
1 ( function () {
2         const api = new mw.Api();
3         const util = require( 'mediawiki.util' );
5         /**
6          * Show the edit summary.
7          *
8          * @private
9          * @param {jQuery} $formNode
10          * @param {Object} response
11          */
12         function showEditSummary( $formNode, response ) {
13                 const $summaryPreview = $formNode.find( '.mw-summary-preview' ).empty();
14                 const parse = response.parse;
16                 if ( !parse || !parse.parsedsummary ) {
17                         return;
18                 }
20                 $summaryPreview.append(
21                         mw.message( 'summary-preview' ).parse(),
22                         ' ',
23                         $( '<span>' ).addClass( 'comment' ).html( parenthesesWrap( parse.parsedsummary ) )
24                 );
25         }
27         /**
28          * Wrap a string in parentheses.
29          *
30          * @private
31          * @param {string} str
32          * @return {string}
33          */
34         function parenthesesWrap( str ) {
35                 if ( str === '' ) {
36                         return str;
37                 }
38                 // There is no equivalent to rawParams
39                 return mw.message( 'parentheses' ).escaped()
40                         // Specify a function as the replacement,
41                         // so that "$" characters in str are not interpreted.
42                         .replace( '$1', () => str );
43         }
45         /**
46          * Show status indicators.
47          *
48          * @private
49          * @param {Array} indicators
50          */
51         function showIndicators( indicators ) {
52                 // eslint-disable-next-line no-jquery/no-map-util
53                 indicators = $.map( indicators, ( indicator, name ) => $( '<div>' )
54                         .addClass( 'mw-indicator' )
55                         .attr( 'id', mw.util.escapeIdForAttribute( 'mw-indicator-' + name ) )
56                         .html( indicator )
57                         .get( 0 ) );
58                 if ( indicators.length ) {
59                         mw.hook( 'wikipage.indicators' ).fire( $( indicators ) );
60                 }
62                 // Add whitespace between the <div>s because
63                 // they get displayed with display: inline-block
64                 const newList = [];
65                 indicators.forEach( ( indicator ) => {
66                         newList.push( indicator, document.createTextNode( '\n' ) );
67                 } );
69                 $( '.mw-indicators' ).empty().append( newList );
70         }
72         /**
73          * Show the templates used.
74          *
75          * The formatting here repeats what is done in includes/TemplatesOnThisPageFormatter.php
76          *
77          * @private
78          * @param {Array} templates List of template titles.
79          */
80         function showTemplates( templates ) {
81                 // The .templatesUsed div can be empty, if no templates are in use.
82                 // In that case, we have to create the required structure.
83                 const $parent = $( '.templatesUsed' );
85                 // Find or add the explanation text (the toggler for collapsing).
86                 let $explanation = $parent.find( '.mw-templatesUsedExplanation p' );
87                 if ( $explanation.length === 0 ) {
88                         $explanation = $( '<p>' );
89                         $parent.append( $( '<div>' )
90                                 .addClass( 'mw-templatesUsedExplanation' )
91                                 .append( $explanation ) );
92                 }
94                 // Find or add the list. The makeCollapsible() method is called on this
95                 // in resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js
96                 let $list = $parent.find( 'ul' );
97                 if ( $list.length === 0 ) {
98                         $list = $( '<ul>' ).addClass( [ 'mw-editfooter-list', 'mw-collapsible', 'mw-made-collapsible' ] );
99                         $parent.append( $list );
100                 }
102                 if ( templates.length === 0 ) {
103                         $explanation.msg( 'templatesusedpreview', 0 );
104                         $list.empty();
105                         return;
106                 }
108                 // Fetch info about all templates, batched because API is limited to 50 at a time.
109                 $parent.addClass( 'mw-preview-loading-elements-loading' );
110                 const batchSize = 50;
111                 const requests = [];
112                 for ( let batch = 0; batch < templates.length; batch += batchSize ) {
113                         // Build a list of template names for this batch.
114                         const titles = templates
115                                 .slice( batch, batch + batchSize )
116                                 .map( ( template ) => template.title );
117                         requests.push( api.post( {
118                                 action: 'query',
119                                 format: 'json',
120                                 formatversion: 2,
121                                 titles: titles,
122                                 prop: 'info',
123                                 // @todo Do we need inlinkcontext here?
124                                 inprop: 'linkclasses|protection',
125                                 intestactions: 'edit'
126                         } ) );
127                 }
128                 $.when( ...requests ).done( function () {
129                         const templatesAllInfo = [];
130                         // For the first batch, empty the list in preparation for either adding new items or not needing to.
131                         for ( let r = 0; r < arguments.length; r++ ) {
132                                 // Response is either the whole argument, or the 0th element of it.
133                                 const response = arguments[ r ][ 0 ] || arguments[ r ];
134                                 const templatesInfo = ( response.query && response.query.pages ) || [];
135                                 templatesInfo.forEach( ( ti ) => {
136                                         templatesAllInfo.push( {
137                                                 title: mw.Title.newFromText( ti.title ),
138                                                 apiData: ti
139                                         } );
140                                 } );
141                         }
142                         // Sort alphabetically.
143                         templatesAllInfo.sort( ( t1, t2 ) => {
144                                 // Compare titles with the same rules of Title::compare() in PHP.
145                                 if ( t1.title.getNamespaceId() !== t2.title.getNamespaceId() ) {
146                                         return t1.title.getNamespaceId() - t2.title.getNamespaceId();
147                                 } else {
148                                         return t1.title.getMain() === t2.title.getMain() ?
149                                                 0 :
150                                                 t1.title.getMain() < t2.title.getMain() ? -1 : 1;
151                                 }
152                         } );
154                         // Add new template list, and update the list header.
155                         const $listNew = $( '<ul>' );
156                         addItemToTemplateListPromise( $listNew, templatesAllInfo, 0 )
157                                 .then( () => {
158                                         $list.html( $listNew.html() );
159                                 } );
160                         $explanation.msg( 'templatesusedpreview', templatesAllInfo.length );
161                 } ).always( () => {
162                         $parent.removeClass( 'mw-preview-loading-elements-loading' );
163                 } );
164         }
166         /**
167          * Recursive function to add a template link to the list of templates in use.
168          * This is useful because addItemToTemplateList() might need to make extra API requests to fetch
169          * messages, but we don't want to send parallel requests for these (because they're often the
170          * for the same messages).
171          *
172          * @private
173          * @param {jQuery} $list The `<ul>` to add the item to.
174          * @param {Object} templatesInfo All templates' info, sorted by namespace and title.
175          * @param {number} templateIndex The current item in templatesInfo (0-indexed).
176          * @return {jQuery.Promise}
177          */
178         function addItemToTemplateListPromise( $list, templatesInfo, templateIndex ) {
179                 return addItemToTemplateList( $list, templatesInfo[ templateIndex ] ).then( () => {
180                         if ( templatesInfo[ templateIndex + 1 ] !== undefined ) {
181                                 return addItemToTemplateListPromise( $list, templatesInfo, templateIndex + 1 );
182                         }
183                 } );
184         }
186         /**
187          * Create list item with relevant links for the given template, and add it to the $list.
188          *
189          * @private
190          * @param {jQuery} $list The `<ul>` to add the item to.
191          * @param {Object} template Template info with which to construct the `<li>`.
192          * @return {jQuery.Promise}
193          */
194         function addItemToTemplateList( $list, template ) {
195                 const editable = template.apiData.ns >= 0;
196                 const canEdit = editable && template.apiData.actions.edit !== undefined;
197                 const linkClasses = template.apiData.linkclasses || [];
198                 if ( template.apiData.missing !== undefined && template.apiData.known === undefined ) {
199                         linkClasses.push( 'new' );
200                 }
201                 const $baseLink = $( '<a>' )
202                         // Additional CSS classes (e.g. link colors) used for links to this template.
203                         // The following classes might be used here:
204                         // * new
205                         // * mw-redirect
206                         // * any added by the GetLinkColours hook
207                         .addClass( linkClasses );
208                 const $link = $baseLink.clone()
209                         .attr( 'href', template.title.getUrl() )
210                         .text( template.title.getPrefixedText() );
212                 if ( editable ) {
213                         const $editLink = $baseLink.clone()
214                                 .attr( 'href', template.title.getUrl( { action: 'edit' } ) )
215                                 .append( mw.msg( canEdit ? 'editlink' : 'viewsourcelink' ) );
217                         const wordSep = mw.message( 'word-separator' ).escaped();
218                         return getRestrictionsText( template.apiData.protection || [] )
219                                 .then( ( restrictionsList ) => {
220                                         // restrictionsList is a comma-separated parentheses-wrapped localized list of restriction level names.
221                                         const editLinkParens = parenthesesWrap( $editLink[ 0 ].outerHTML );
222                                         const $li = $( '<li>' ).append( $link, wordSep, editLinkParens, wordSep, restrictionsList );
223                                         $list.append( $li );
224                                 } );
225                 } else {
226                         $list.append( $( '<li>' ).append( $link ) );
227                         return $.Deferred().resolve( '' );
228                 }
229         }
231         /**
232          * Get a localized string listing the restriction levels for a template.
233          *
234          * This should match the logic from TemplatesOnThisPageFormatter::getRestrictionsText().
235          *
236          * @private
237          * @param {Array} restrictions Set of protection info objects from the inprop=protection API.
238          * @return {jQuery.Promise}
239          */
240         function getRestrictionsText( restrictions ) {
241                 let msg = '';
242                 if ( !restrictions ) {
243                         return $.Deferred().resolve( msg );
244                 }
246                 // Record other restriction levels, in case it's protected for others.
247                 const restrictionLevels = [];
248                 restrictions.forEach( ( r ) => {
249                         if ( r.type !== 'edit' ) {
250                                 return;
251                         }
252                         if ( r.level === 'sysop' ) {
253                                 msg = mw.msg( 'template-protected' );
254                         } else if ( r.level === 'autoconfirmed' ) {
255                                 msg = mw.msg( 'template-semiprotected' );
256                         } else {
257                                 restrictionLevels.push( r.level );
258                         }
259                 } );
261                 // If sysop or autoconfirmed, use that.
262                 if ( msg !== '' ) {
263                         return $.Deferred().resolve( msg );
264                 }
266                 // Otherwise, if the edit restriction isn't one of the backwards-compatible ones,
267                 // use the (possibly custom) restriction-level-* messages.
268                 const msgs = [];
269                 restrictionLevels.forEach( ( level ) => {
270                         msgs.push( 'restriction-level-' + level );
271                 } );
272                 if ( msgs.length === 0 ) {
273                         return $.Deferred().resolve( '' );
274                 }
276                 // Custom restriction levels don't have their messages loaded, so we have to do that.
277                 return api.loadMessagesIfMissing( msgs ).then( () => {
278                         const localizedMessages = msgs.map(
279                                 // Messages that can be used here include:
280                                 // * restriction-level-sysop
281                                 // * restriction-level-autoconfirmed
282                                 ( m ) => mw.message( m ).parse()
283                         );
284                         // There's no commaList in JS, so just join with commas (doesn't handle the last item).
285                         return parenthesesWrap( localizedMessages.join( mw.msg( 'comma-separator' ) ) );
286                 } );
287         }
289         /**
290          * Show the language links (Vector-specific).
291          * TODO: Doesn't work in vector-2022 (maybe it doesn't need to?)
292          *
293          * @private
294          * @param {Array} langLinks
295          */
296         function showLanguageLinks( langLinks ) {
297                 const newList = langLinks.map( ( langLink ) => {
298                         const bcp47 = mw.language.bcp47( langLink.lang );
299                         // eslint-disable-next-line mediawiki/class-doc
300                         return $( '<li>' )
301                                 .addClass( 'interlanguage-link interwiki-' + langLink.lang )
302                                 .append( $( '<a>' )
303                                         .attr( {
304                                                 href: langLink.url,
305                                                 title: langLink.title + ' - ' + langLink.langname,
306                                                 lang: bcp47,
307                                                 hreflang: bcp47
308                                         } )
309                                         .text( langLink.autonym )
310                                 );
311                 } );
312                 const $list = $( '#p-lang ul' ),
313                         $parent = $list.parent();
314                 $list.detach().empty().append( newList ).prependTo( $parent );
315         }
317         /**
318          * Parse preview response and show a warning at the top of the preview.
319          *
320          * @private
321          * @param {Object} config
322          * @param {Object} response
323          */
324         function showPreviewNotes( config, response ) {
325                 const arrow = $( document.body ).css( 'direction' ) === 'rtl' ? '←' : '→';
326                 const $previewHeader = $( '<div>' )
327                         .addClass( 'previewnote' )
328                         .append( $( '<h2>' )
329                                 .attr( 'id', 'mw-previewheader' )
330                                 // TemplateSandbox will insert an HTML string here.
331                                 .append( config.previewHeader )
332                         );
334                 const warningContentElement = $( '<div>' )
335                         .append(
336                                 // TemplateSandbox will insert a jQuery here.
337                                 config.previewNote,
338                                 ' ',
339                                 $( '<span>' )
340                                         .addClass( 'mw-continue-editing' )
341                                         .append( $( '<a>' )
342                                                 .attr( 'href', '#' + config.$formNode.attr( 'id' ) )
343                                                 .text( arrow + ' ' + mw.msg( 'continue-editing' ) )
344                                         ),
345                                 response.parse.parsewarningshtml.map( ( warning ) => $( '<p>' ).append( warning ) )
346                         )[ 0 ];
347                 const warningMessageElement = util.messageBox(
348                         warningContentElement,
349                         'warning'
350                 );
351                 $previewHeader.append( warningMessageElement );
352                 config.$previewNode.prepend( $previewHeader );
353         }
355         /**
356          * Show an error message in place of a preview.
357          *
358          * @private
359          * @param {Object} config
360          * @param {jQuery} $message
361          */
362         function showError( config, $message ) {
363                 const errorContentElement = $( '<div>' )
364                         .append(
365                                 $( '<strong>' ).text( mw.msg( 'previewerrortext' ) ),
366                                 $message
367                         )[ 0 ];
368                 const errorMessageElement = util.messageBox( errorContentElement, 'error' );
369                 errorMessageElement.classList.add( 'mw-page-preview-error' );
370                 config.$previewNode.hide().before( errorMessageElement );
371                 if ( config.$diffNode ) {
372                         config.$diffNode.hide();
373                 }
374         }
376         /**
377          * Update the various bits of the page based on the response.
378          *
379          * @private
380          * @param {Object} config
381          * @param {Object} response
382          */
383         function handleParseResponse( config, response ) {
384                 let $content;
386                 // Js config variables and modules.
387                 if ( response.parse.jsconfigvars ) {
388                         mw.config.set( response.parse.jsconfigvars );
389                 }
390                 if ( response.parse.modules ) {
391                         mw.loader.load( response.parse.modules.concat(
392                                 response.parse.modulestyles
393                         ) );
394                 }
396                 // Indicators.
397                 showIndicators( response.parse.indicators );
399                 // Display title.
400                 if ( response.parse.displaytitle ) {
401                         $( '#firstHeadingTitle' ).html( response.parse.displaytitle );
402                 }
404                 // Categories.
405                 if ( response.parse.categorieshtml ) {
406                         $content = $( $.parseHTML( response.parse.categorieshtml ) );
407                         mw.hook( 'wikipage.categories' ).fire( $content );
408                         $( '.catlinks[data-mw="interface"]' ).replaceWith( $content );
409                 }
411                 // Table of contents.
412                 if ( response.parse.sections ) {
413                         /**
414                          * Fired when dynamic changes have been made to the table of contents.
415                          *
416                          * @event ~'wikipage.tableOfContents'
417                          * @memberof Hooks
418                          * @param {Object[]} sections Metadata about each section, as returned by
419                          *   [API:Parse]{@link https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext}.
420                          */
421                         mw.hook( 'wikipage.tableOfContents' ).fire(
422                                 response.parse.hidetoc ? [] : response.parse.sections
423                         );
424                 }
426                 // Templates.
427                 if ( response.parse.templates ) {
428                         showTemplates( response.parse.templates );
429                 }
431                 // Limit report.
432                 if ( response.parse.limitreporthtml ) {
433                         $( '.limitreport' ).html( response.parse.limitreporthtml )
434                                 .find( '.mw-collapsible' ).makeCollapsible();
435                 }
437                 // Language links.
438                 if ( response.parse.langlinks && mw.config.get( 'skin' ) === 'vector' ) {
439                         showLanguageLinks( response.parse.langlinks );
440                 }
442                 if ( !response.parse.text ) {
443                         return;
444                 }
446                 // Remove any previous preview
447                 config.$previewNode.children( '.mw-parser-output' ).remove();
448                 // Remove preview note, if present (added by Live Preview, etc.).
449                 config.$previewNode.find( '.previewnote' ).remove();
451                 if ( config.isLivePreview ) {
452                         showPreviewNotes( config, response );
453                 }
455                 $content = $( $.parseHTML( response.parse.text ) );
457                 config.$previewNode.append( $content ).show();
459                 mw.hook( 'wikipage.content' ).fire( $content );
460         }
462         /**
463          * Get the unresolved promise of the preview request.
464          *
465          * @private
466          * @param {Object} config
467          * @param {string|number} section
468          * @return {jQuery.Promise}
469          */
470         function getParseRequest( config, section ) {
471                 const params = {
472                         formatversion: 2,
473                         action: 'parse',
474                         summary: config.summary,
475                         prop: ''
476                 };
477                 params[ config.titleParam ] = config.title;
479                 if ( !config.showDiff ) {
480                         params[ config.textParam ] = config.$textareaNode.textSelection( 'getContents' );
481                         Object.assign( params, {
482                                 prop: 'text|indicators|displaytitle|modules|jsconfigvars|categorieshtml|sections|templates|langlinks|limitreporthtml|parsewarningshtml',
483                                 pst: true,
484                                 preview: true,
485                                 sectionpreview: section !== '',
486                                 disableeditsection: true,
487                                 useskin: mw.config.get( 'skin' ),
488                                 uselang: mw.config.get( 'wgUserLanguage' )
489                         } );
490                         if ( mw.config.get( 'wgUserVariant' ) ) {
491                                 params.variant = mw.config.get( 'wgUserVariant' );
492                         }
493                 }
494                 if ( section === 'new' ) {
495                         params.section = 'new';
496                         params.sectiontitle = params.summary;
497                         delete params.summary;
498                 }
500                 Object.assign( params, config.parseParams );
502                 return api.post( params, { headers: { 'Promise-Non-Write-API-Action': 'true' } } );
503         }
505         /**
506          * Get the required <table> structure for displaying diffs.
507          *
508          * @return {jQuery}
509          */
510         function getDiffTable() {
511                 return $( '<table>' ).addClass( 'diff' ).append(
512                         $( '<col>' ).addClass( 'diff-marker' ),
513                         $( '<col>' ).addClass( 'diff-content' ),
514                         $( '<col>' ).addClass( 'diff-marker' ),
515                         $( '<col>' ).addClass( 'diff-content' ),
516                         $( '<thead>' ).append(
517                                 $( '<tr>' ).addClass( 'diff-title' ).append(
518                                         $( '<td>' )
519                                                 .attr( 'colspan', 2 )
520                                                 .addClass( 'diff-otitle diff-side-deleted' )
521                                                 .text( mw.msg( 'currentrev' ) ),
522                                         $( '<td>' )
523                                                 .attr( 'colspan', 2 )
524                                                 .addClass( 'diff-ntitle diff-side-added' )
525                                                 .text( mw.msg( 'yourtext' ) )
526                                 )
527                         ),
528                         $( '<tbody>' )
529                 );
530         }
532         /**
533          * Show the diff from the response.
534          *
535          * @private
536          * @param {Object} config
537          * @param {Object} response
538          */
539         function handleDiffResponse( config, response ) {
540                 const $table = getDiffTable();
541                 config.$diffNode
542                         .hide()
543                         .empty()
544                         .append( $table );
546                 const diff = response.compare.bodies;
547                 if ( diff.main ) {
548                         $table.find( 'tbody' ).html( diff.main );
549                         mw.hook( 'wikipage.diff' ).fire( $table );
550                 } else {
551                         // The diff is empty.
552                         const $tableCell = $( '<td>' )
553                                 .attr( 'colspan', 4 )
554                                 .addClass( 'diff-notice' )
555                                 .append(
556                                         $( '<div>' )
557                                                 .addClass( 'mw-diff-empty' )
558                                                 .text( mw.msg( 'diff-empty' ) )
559                                 );
560                         $table.find( 'tbody' )
561                                 .empty()
562                                 .append(
563                                         $( '<tr>' ).append( $tableCell )
564                                 );
565                 }
566                 config.$diffNode.show();
567         }
569         /**
570          * Get the unresolved promise of the diff request.
571          *
572          * @private
573          * @param {Object} config
574          * @param {string|number} section
575          * @param {boolean} pageExists
576          * @return {jQuery.Promise}
577          */
578         function getDiffRequest( config, section, pageExists ) {
579                 let contents = config.$textareaNode.textSelection( 'getContents' ),
580                         sectionTitle = config.summary;
582                 if ( section === 'new' ) {
583                         // T293930: Hack to show live diff for new section creation.
585                         // We concatenate the section heading with the edit box text and pass it to
586                         // the diff API as the full input text. This is roughly what the server-side
587                         // does when difference is requested for section edit.
588                         // The heading is always prepended, we do not bother with editing old rev
589                         // at this point (`?action=edit&oldid=xxx&section=new`) -- which will require
590                         // mid-text insertion of the section -- because creation of new section is only
591                         // possible on latest revision.
593                         // The section heading text is unconditionally wrapped in <h2> heading and
594                         // ends with double newlines, except when it's empty. This is for parity with the
595                         // server-side rendering of the same case.
596                         sectionTitle = sectionTitle === '' ? '' : '== ' + sectionTitle + ' ==\n\n';
598                         // Prepend section heading to section text.
599                         contents = sectionTitle + contents;
600                 }
602                 const params = {
603                         action: 'compare',
604                         fromtitle: config.title,
605                         totitle: config.title,
606                         toslots: 'main',
607                         // Remove trailing whitespace for consistency with EditPage diffs.
608                         // TODO trimEnd() when we can use that.
609                         'totext-main': contents.replace( /\s+$/, '' ),
610                         'tocontentmodel-main': mw.config.get( 'wgPageContentModel' ),
611                         topst: true,
612                         slots: 'main',
613                         uselang: mw.config.get( 'wgUserLanguage' )
614                 };
615                 if ( mw.config.get( 'wgUserVariant' ) ) {
616                         params.variant = mw.config.get( 'wgUserVariant' );
617                 }
618                 if ( section ) {
619                         params[ 'tosection-main' ] = section;
620                 }
621                 if ( !pageExists ) {
622                         params.fromslots = 'main';
623                         params[ 'fromcontentmodel-main' ] = mw.config.get( 'wgPageContentModel' );
624                         params[ 'fromtext-main' ] = '';
625                 }
626                 return api.post( params );
627         }
629         /**
630          * Get the selectors of elements that should be grayed out while the preview is being generated.
631          *
632          * @memberof module:mediawiki.page.preview
633          * @return {string[]}
634          * @stable
635          */
636         function getLoadingSelectors() {
637                 return [
638                         // Main
639                         '.mw-indicators',
640                         '#firstHeading',
641                         '#wikiPreview',
642                         '#wikiDiff',
643                         '#catlinks',
644                         '#p-lang',
645                         // Editing-related
646                         '.templatesUsed',
647                         '.limitreport',
648                         '.mw-summary-preview',
649                         '.hiddencats'
650                 ];
651         }
653         /**
654          * Fetch and display a preview of the current editing area.
655          *
656          * @memberof module:mediawiki.page.preview
657          * @param {Object} config Configuration options.
658          * @param {jQuery} [config.$previewNode=$( '#wikiPreview' )] Where the preview should be displayed.
659          * @param {jQuery} [config.$diffNode=$( '#wikiDiff' )] Where diffs should be displayed (if showDiff is set).
660          * @param {jQuery} [config.$formNode=$( '#editform' )] The form node.
661          * @param {jQuery} [config.$textareaNode=$( '#wpTextbox1' )] The edit form's textarea.
662          * @param {jQuery} [config.$spinnerNode=$( '.mw-spinner-preview' )] The loading indicator. This will
663          *   be shown/hidden accordingly while waiting for the XMLHttpRequest to complete.
664          *   Ignored if it doesn't exist in the document and `createSpinner` is false.
665          * @param {string} [config.summary=null] The edit summary. If no value is given, the summary is
666          *   fetched from `$( '#wpSummaryWidget' )`.
667          * @param {boolean} [config.showDiff=false] Shows a diff in the preview area instead of the content.
668          * @param {boolean} [config.isLivePreview=false] Instructs the module to replicate the
669          *   server-side preview as much as possible. Specifically:
670          *   - Before initiating the preview, some alerts and error messages at the top of the page will
671          *     be removed, and the browser will scroll to the preview.
672          *   - After finishing the preview, a reminder that it's only a preview, or an error message in
673          *     case a request has failed, will be shown at the top of the preview.
674          * @param {Node|Node[]|jQuery|string} [config.previewHeader=null] Content of `<h2>` element at
675          *   the top of the preview notes. Required if `isLivePreview` is true.
676          * @param {Node|Node[]|jQuery|string} [config.previewNote=null] Main text of the first preview
677          *   note. Required if `isLivePreview` is true.
678          * @param {string} [config.title=mw.config.get( 'wgPageName' )] The title of the page being previewed.
679          * @param {string} [config.titleParam='title'] Name of the parse API parameter to pass `title` to.
680          * @param {string} [config.textParam='text'] Name of the parse API parameter to pass the content
681          *   of `$textareaNode` to. Ignored if `showDiff` is true.
682          * @param {Object} [config.parseParams=null] Additional parse API parameters. This can override
683          *   any parameter set by the module.
684          * @param {module:mediawiki.page.preview~responseHandler} [config.responseHandler=null] Callback
685          *   to run right after the API responses are received. This allows the config and response
686          *   objects to be modified before the preview is shown.
687          * @param {boolean} [config.createSpinner=false] Creates `$spinnerNode` and inserts it before
688          *   `$previewNode` if one doesn't already exist and the module `jquery.spinner` is loaded.
689          * @param {string[]} [config.loadingSelectors=getLoadingSelectors()] An array of query selectors
690          *   (i.e. '#catlinks') that should be grayed out while the preview is being generated.
691          * @return {jQuery.Promise|undefined} jQuery.Promise or `undefined` if no `$textareaNode` was provided in the config.
692          * @fires Hooks~'wikipage.categories'
693          * @fires Hooks~'wikipage.content'
694          * @fires Hooks~'wikipage.diff'
695          * @fires Hooks~'wikipage.indicators'
696          * @fires Hooks~'wikipage.tableOfContents'
697          * @stable
698          */
699         function doPreview( config ) {
700                 config = Object.assign( {
701                         $previewNode: $( '#wikiPreview' ),
702                         $diffNode: $( '#wikiDiff' ),
703                         $formNode: $( '#editform' ),
704                         $textareaNode: $( '#wpTextbox1' ),
705                         $spinnerNode: $( '.mw-spinner-preview' ),
706                         summary: null,
707                         showDiff: false,
708                         isLivePreview: false,
709                         previewHeader: null,
710                         previewNote: null,
711                         title: mw.config.get( 'wgPageName' ),
712                         titleParam: 'title',
713                         textParam: 'text',
714                         parseParams: null,
715                         responseHandler: null,
716                         createSpinner: false,
717                         loadingSelectors: getLoadingSelectors()
718                 }, config );
720                 const section = config.$formNode.find( '[name="wpSection"]' ).val();
722                 if ( !config.$textareaNode || config.$textareaNode.length === 0 ) {
723                         return;
724                 }
726                 // Fetch edit summary, if not already given.
727                 if ( !config.summary ) {
728                         const $summaryWidget = $( '#wpSummaryWidget' );
729                         if ( $summaryWidget.length ) {
730                                 config.summary = OO.ui.infuse( $summaryWidget ).getValue();
731                         }
732                 }
734                 if ( config.isLivePreview ) {
735                         // Not shown during normal preview, to be removed if present
736                         $( '.mw-newarticletext, .mw-page-preview-error' ).remove();
738                         // Show #wikiPreview if it's hidden to be able to scroll to it.
739                         // (If it is hidden, it's also empty, so nothing changes in the rendering.)
740                         config.$previewNode.show();
742                         // Jump to where the preview will appear
743                         config.$previewNode[ 0 ].scrollIntoView();
744                 }
746                 // Show or create the spinner if possible.
747                 if ( config.$spinnerNode && config.$spinnerNode.length ) {
748                         config.$spinnerNode.show();
749                 } else if ( config.createSpinner ) {
750                         if ( mw.loader.getState( 'jquery.spinner' ) === 'ready' ) {
751                                 config.$spinnerNode = $.createSpinner( {
752                                         size: 'large',
753                                         type: 'block'
754                                 } )
755                                         .addClass( 'mw-spinner-preview' )
756                                         .insertBefore( config.$previewNode );
757                         } else {
758                                 mw.log.warn( 'createSpinner requires the module jquery.spinner' );
759                         }
760                 }
762                 // Gray out the 'copy elements' while we wait for a response.
763                 const $loadingElements = $( config.loadingSelectors.join( ',' ) );
764                 $loadingElements.addClass( [ 'mw-preview-loading-elements', 'mw-preview-loading-elements-loading' ] );
766                 // Acquire a temporary user username before previewing or diffing, so that signatures and
767                 // user-related magic words display the temp user instead of IP user in the preview. (T331397)
768                 const tempUserNamePromise = mw.user.acquireTempUserName();
770                 let diffRequest;
772                 const parseRequest = tempUserNamePromise.then( () => getParseRequest( config, section ) );
774                 if ( config.showDiff ) {
775                         config.$previewNode.hide();
777                         // Add the diff node if it doesn't exist (directly after the preview node).
778                         if ( config.$diffNode.length === 0 && config.$previewNode.length > 0 ) {
779                                 const rtlDir = $( '#wpTextbox1' ).attr( 'dir' ) === 'rtl';
780                                 const alignStart = rtlDir ? 'right' : 'left';
781                                 config.$diffNode = $( '<div>' )
782                                         .attr( 'id', 'wikiDiff' )
783                                         // The following classes are used here:
784                                         // * diff-editfont-monospace
785                                         // * diff-editfont-sans-serif
786                                         // * diff-editfont-serif
787                                         .addClass( 'diff-editfont-' + mw.user.options.get( 'editfont' ) )
788                                         // The following classes are used here:
789                                         // * diff-contentalign-left
790                                         // * diff-contentalign-right
791                                         .addClass( 'diff-contentalign-' + alignStart );
792                                 config.$previewNode.after( config.$diffNode );
793                         }
795                         // Hide the table of contents, in case it was previously shown after previewing.
796                         mw.hook( 'wikipage.tableOfContents' ).fire( [] );
798                         // The compare API returns an error if the title doesn't exist and fromtext is not
799                         // specified. So we have to account for the possibility that the page was created or
800                         // deleted after the user started editing. Luckily the parse API returns pageid so we
801                         // can wait for that.
802                         // TODO: Show "Warning: This page was deleted after you started editing!"?
803                         diffRequest = parseRequest.then( ( parseResponse ) => getDiffRequest( config, section, parseResponse.parse.pageid !== 0 ) );
805                 } else if ( config.$diffNode ) {
806                         config.$diffNode.hide();
807                 }
809                 return $.when( parseRequest, diffRequest )
810                         .done( ( parseResponse, diffResponse ) => {
811                                 if ( config.responseHandler ) {
812                                         /**
813                                          * @callback module:mediawiki.page.preview~responseHandler
814                                          * @param {Object} config Options for live preview API
815                                          * @param {Object} parseResponse Parse API response
816                                          * @param {Object} [diffResponse] Compare API response
817                                          */
818                                         if ( config.showDiff ) {
819                                                 config.responseHandler( config, parseResponse[ 0 ], diffResponse[ 0 ] );
820                                         } else {
821                                                 config.responseHandler( config, parseResponse[ 0 ] );
822                                         }
823                                 }
825                                 showEditSummary( config.$formNode, parseResponse[ 0 ] );
827                                 if ( config.showDiff ) {
828                                         handleDiffResponse( config, diffResponse[ 0 ] );
829                                 } else {
830                                         handleParseResponse( config, parseResponse[ 0 ] );
831                                 }
833                                 mw.hook( 'wikipage.editform' ).fire( config.$formNode );
834                         } )
835                         .fail( ( _code, result ) => {
836                                 if ( config.isLivePreview ) {
837                                         // This just shows the error for whatever request failed first
838                                         showError( config, api.getErrorMessage( result ) );
839                                 }
840                         } )
841                         .always( () => {
842                                 if ( config.$spinnerNode && config.$spinnerNode.length ) {
843                                         config.$spinnerNode.hide();
844                                 }
845                                 $loadingElements.removeClass( 'mw-preview-loading-elements-loading' );
846                         } );
847         }
849         /**
850          * Fetch and display a preview of the current editing area.
851          *
852          * @example
853          * var preview = require( 'mediawiki.page.preview' );
854          * preview.doPreview();
855          *
856          * @exports mediawiki.page.preview
857          */
858         module.exports = {
859                 doPreview: doPreview,
860                 getLoadingSelectors: getLoadingSelectors
861         };
863 }() );