wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/preinit/ve.init.mw.DesktopArticleTarget.init.js

Summary

Maintainability
F
4 days
Test Coverage
/*!
 * VisualEditor MediaWiki DesktopArticleTarget init.
 *
 * This file must remain as widely compatible as the base compatibility
 * for MediaWiki itself (see mediawiki/core:/resources/startup.js).
 * Avoid use of: SVG, HTML5 DOM, ContentEditable etc.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/* eslint-disable no-jquery/no-global-selector */
// TODO: ve.now and ve.track should be moved to mw.libs.ve
/* global ve */

/**
 * Platform preparation for the MediaWiki view page. This loads (when user needs it) the
 * actual MediaWiki integration and VisualEditor library.
 */
( function () {
    const configData = require( './data.json' ),
        veactionToMode = {
            edit: 'visual',
            editsource: 'source'
        },
        availableModes = [];
    let init = null,
        conf = null,
        tabMessages = null,
        pageExists = null,
        viewUrl = null,
        veEditUrl = null,
        tabPreference = null;
    let veEditSourceUrl, targetPromise, url,
        initialWikitext, oldId,
        isLoading, tempWikitextEditor, tempWikitextEditorData,
        $toolbarPlaceholder, $toolbarPlaceholderBar,
        contentTop, wasFloating,
        active = false,
        targetLoaded = false,
        plugins = [],
        welcomeDialogDisabled = false,
        educationPopupsDisabled = false,
        // Defined after document-ready below
        $targetContainer = null;

    if ( mw.config.get( 'wgMFMode' ) ) {
        mw.log.warn( 'Attempted to load desktop target on mobile.' );
        return;
    }

    /**
     * Show the loading progress bar
     */
    function showLoading() {
        if ( isLoading ) {
            return;
        }

        isLoading = true;

        $( 'html' ).addClass( 've-activated ve-loading' );
        if ( !init.$loading ) {
            init.progressBar = new mw.libs.ve.ProgressBarWidget();
            init.$loading = $( '<div>' )
                .addClass( 've-init-mw-desktopArticleTarget-loading-overlay' )
                .append( init.progressBar.$element );
        }
        $( document ).on( 'keydown', onDocumentKeyDown );

        $toolbarPlaceholderBar.append( init.$loading );
    }

    /**
     * Increment loading progress by one step
     *
     * See mw.libs.ve.ProgressBarWidget for steps.
     */
    function incrementLoadingProgress() {
        init.progressBar.incrementLoadingProgress();
    }

    /**
     * Clear and hide the loading progress bar
     */
    function clearLoading() {
        init.progressBar.clearLoading();
        isLoading = false;
        $( document ).off( 'keydown', onDocumentKeyDown );
        $( 'html' ).removeClass( 've-loading' );
        if ( init.$loading ) {
            init.$loading.detach();
        }

        if ( tempWikitextEditor ) {
            teardownTempWikitextEditor();
        }
        hideToolbarPlaceholder();
    }

    /**
     * Handle window scroll events
     *
     * @param {Event} e
     */
    function onWindowScroll() {
        const scrollTop = $( document.documentElement ).scrollTop();
        const floating = scrollTop > contentTop;
        if ( floating !== wasFloating ) {
            const width = $targetContainer.outerWidth();
            $toolbarPlaceholder.toggleClass( 've-init-mw-desktopArticleTarget-toolbarPlaceholder-floating', floating );
            $toolbarPlaceholderBar.css( 'width', width );
            wasFloating = floating;
        }
    }

    const onWindowScrollListener = mw.util.throttle( onWindowScroll, 250 );

    /**
     * Show a placeholder for the VE toolbar
     */
    function showToolbarPlaceholder() {
        if ( !$toolbarPlaceholder ) {
            // Create an equal-height placeholder for the toolbar to avoid vertical jump
            // when the real toolbar is ready.
            $toolbarPlaceholder = $( '<div>' ).addClass( 've-init-mw-desktopArticleTarget-toolbarPlaceholder' );
            $toolbarPlaceholderBar = $( '<div>' ).addClass( 've-init-mw-desktopArticleTarget-toolbarPlaceholder-bar' );
            $toolbarPlaceholder.append( $toolbarPlaceholderBar );
        }
        // Toggle -floating class before append (if required) to avoid content moving later
        contentTop = $targetContainer.offset().top;
        wasFloating = null;
        onWindowScroll();

        const scrollTopBefore = $( document.documentElement ).scrollTop();

        $targetContainer.prepend( $toolbarPlaceholder );

        window.addEventListener( 'scroll', onWindowScrollListener, { passive: true } );

        if ( wasFloating ) {
            // Browser might not support scroll anchoring:
            // https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring
            // ...so compute the new scroll offset ourselves.
            window.scrollTo( 0, scrollTopBefore + $toolbarPlaceholder.outerHeight() );
        }

        // Add class for transition after first render
        setTimeout( () => {
            $toolbarPlaceholder.addClass( 've-init-mw-desktopArticleTarget-toolbarPlaceholder-open' );
        } );
    }

    /**
     * Hide the placeholder for the VE toolbar
     */
    function hideToolbarPlaceholder() {
        if ( $toolbarPlaceholder ) {
            window.removeEventListener( 'scroll', onWindowScrollListener );
            $toolbarPlaceholder.detach();
            $toolbarPlaceholder.removeClass( 've-init-mw-desktopArticleTarget-toolbarPlaceholder-open' );
        }
    }

    /**
     * Create a temporary `<textarea>` wikitext editor while source mode loads
     *
     * @param {Object} data Initialisation data for VE
     */
    function setupTempWikitextEditor( data ) {
        let wikitext = data.content;
        // Add trailing linebreak to non-empty wikitext documents for consistency
        // with old editor and usability. Will be stripped on save. T156609
        if ( wikitext ) {
            wikitext += '\n';
        }
        tempWikitextEditor = new mw.libs.ve.MWTempWikitextEditorWidget( { value: wikitext } );
        tempWikitextEditorData = data;

        // Bring forward some transformations that show the editor is now ready
        // Grey out the page title if it is below the editing toolbar (depending on skin), to show it is uneditable.
        $( '.ve-init-mw-desktopArticleTarget-targetContainer #firstHeading' ).addClass( 've-init-mw-desktopArticleTarget-uneditableContent' );
        $( '#mw-content-text' )
            .before( tempWikitextEditor.$element )
            .addClass( 'oo-ui-element-hidden' );
        $( 'html' ).addClass( 've-tempSourceEditing' ).removeClass( 've-loading' );

        // Resize the textarea to fit content. We could do this more often (e.g. on change)
        // but hopefully this temporary textarea won't be visible for too long.
        tempWikitextEditor.adjustSize().moveCursorToStart();
        ve.track( 'editAttemptStep', { action: 'ready', mode: 'source', platform: 'desktop' } );
        mw.libs.ve.tempWikitextEditor = tempWikitextEditor;
        mw.hook( 've.wikitextInteractive' ).fire();
    }

    /**
     * Synchronise state of temporary wikitexteditor back to the VE initialisation data object
     */
    function syncTempWikitextEditor() {
        let wikitext = tempWikitextEditor.getValue();

        // Strip trailing linebreak. Will get re-added in ArticleTarget#parseDocument.
        if ( wikitext.slice( -1 ) === '\n' ) {
            wikitext = wikitext.slice( 0, -1 );
        }

        if ( wikitext !== tempWikitextEditorData.content ) {
            // Write changes back to response data object,
            // which will be used to construct the surface.
            tempWikitextEditorData.content = wikitext;
            // TODO: Consider writing changes using a
            // transaction so they can be undone.
            // For now, just mark surface as pre-modified
            tempWikitextEditorData.fromEditedState = true;
        }

        // Store the last-seen selection and pass to the target
        tempWikitextEditorData.initialSourceRange = tempWikitextEditor.getRange();

        tempWikitextEditor.$element.prop( 'readonly', true );
    }

    /**
     * Teardown the temporary wikitext editor
     */
    function teardownTempWikitextEditor() {
        // Destroy widget and placeholder
        tempWikitextEditor.$element.remove();
        mw.libs.ve.tempWikitextEditor = tempWikitextEditor = null;
        tempWikitextEditorData = null;

        $( '#mw-content-text' ).removeClass( 'oo-ui-element-hidden' );
        $( 'html' ).removeClass( 've-tempSourceEditing' );
    }

    /**
     * Abort loading the editor
     */
    function abortLoading() {
        $( 'html' ).removeClass( 've-activated' );
        active = false;
        updateTabs( false );
        // Push read tab URL to history
        if ( $( '#ca-view a' ).length ) {
            history.pushState( { tag: 'visualeditor' }, '', $( '#ca-view a' ).attr( 'href' ) );
        }
        clearLoading();
    }

    /**
     * Handle keydown events on the document
     *
     * @param {jQuery.Event} e Keydown event
     */
    function onDocumentKeyDown( e ) {
        if ( e.which === 27 /* OO.ui.Keys.ESCAPE */ ) {
            abortLoading();
            e.preventDefault();
        }
    }

    /**
     * Parse a section value from a query string object
     *
     *     @example
     *     parseSection( new URL( location.href ).searchParams.get( 'section' ) )
     *
     * @param {string|undefined} section Section value from query object
     * @return {string|null} Section if valid, null otherwise
     */
    function parseSection( section ) {
        // Section must be a number, 'new' or 'T-' prefixed
        if ( section && /^(new|\d+|T-\d+)$/.test( section ) ) {
            return section;
        }
        return null;
    }

    /**
     * Use deferreds to avoid loading and instantiating Target multiple times.
     *
     * @private
     * @param {string} mode Target mode: 'visual' or 'source'
     * @param {string} section Section to edit
     * @return {jQuery.Promise}
     */
    function getTarget( mode, section ) {
        if ( !targetPromise ) {
            // The TargetLoader module is loaded in the bottom queue, so it should have been
            // requested already but it might not have finished loading yet
            targetPromise = mw.loader.using( 'ext.visualEditor.targetLoader' )
                .then( () => {
                    mw.libs.ve.targetLoader.addPlugin(
                        // Run VisualEditorPreloadModules, but if they fail, we still want to continue
                        // loading, so convert failure to success
                        () => mw.loader.using( conf.preloadModules ).catch(
                            () => $.Deferred().resolve()
                        )
                    );
                    // Add modules specific to desktop (modules shared between desktop
                    // and mobile are already added by TargetLoader)
                    [
                        'ext.visualEditor.desktopArticleTarget',
                        // Add requested plugins
                        ...plugins
                    ].forEach( mw.libs.ve.targetLoader.addPlugin );
                    plugins = [];
                    return mw.libs.ve.targetLoader.loadModules( mode );
                } )
                .then( () => {
                    if ( !active ) {
                        // Loading was aborted
                        // TODO: Make loaders abortable instead of waiting
                        targetPromise = null;
                        return $.Deferred().reject().promise();
                    }

                    const target = ve.init.mw.targetFactory.create(
                        conf.contentModels[ mw.config.get( 'wgPageContentModel' ) ], {
                            modes: availableModes,
                            defaultMode: mode
                        }
                    );
                    target.on( 'deactivate', () => {
                        active = false;
                        updateTabs( false );
                    } );
                    target.on( 'reactivate', () => {
                        url = new URL( location.href );
                        activateTarget(
                            getEditModeFromUrl( url ),
                            parseSection( url.searchParams.get( 'section' ) )
                        );
                    } );
                    target.setContainer( $targetContainer );
                    targetLoaded = true;
                    return target;
                }, ( e ) => {
                    mw.log.warn( 'VisualEditor failed to load: ' + e );
                    return $.Deferred().reject( e ).promise();
                } );
        }

        targetPromise.then( ( target ) => {
            target.section = section;
        } );

        return targetPromise;
    }

    /**
     * @private
     * @param {Object} initData
     * @param {URL} [linkUrl]
     */
    function trackActivateStart( initData, linkUrl ) {
        if ( !linkUrl ) {
            linkUrl = url;
        }
        if ( linkUrl.searchParams.get( 'wvprov' ) === 'sticky-header' ) {
            initData.mechanism += '-sticky-header';
        }
        ve.track( 'trace.activate.enter', { mode: initData.mode } );
        initData.action = 'init';
        initData.integration = 'page';
        ve.track( 'editAttemptStep', initData );
        mw.libs.ve.activationStart = ve.now();
    }

    /**
     * Get the skin-specific message for an edit tab
     *
     * @param {string} tabMsg Base tab message key
     * @return {string} Message text
     */
    function getTabMessage( tabMsg ) {
        let tabMsgKey = tabMessages[ tabMsg ];
        const skinMsgKeys = {
            edit: 'edit',
            create: 'create',
            editlocaldescription: 'edit-local',
            createlocaldescription: 'create-local'
        };
        const key = skinMsgKeys[ tabMsg ];
        if ( !tabMsgKey && key ) {
            // Some skins don't use the default skin message keys.
            // The following messages can be used here:
            // * vector-view-edit
            // * vector-view-create
            // * vector-view-edit-local
            // * vector-view-create-local
            // * messages for other skins
            tabMsgKey = mw.config.get( 'skin' ) + '-view-' + key;
            if ( !mw.message( tabMsgKey ).exists() ) {
                // The following messages can be used here:
                // * skin-view-edit
                // * skin-view-create
                // * skin-view-edit-local
                // * skin-view-create-local
                tabMsgKey = 'skin-view-' + key;
            }
        }
        // eslint-disable-next-line mediawiki/msg-doc
        const msg = mw.message( tabMsgKey );
        if ( !msg.isParseable() ) {
            mw.log.warn( 'VisualEditor: MediaWiki:' + tabMsgKey + ' contains unsupported syntax. ' +
                'https://www.mediawiki.org/wiki/Manual:Messages_API#Feature_support_in_JavaScript' );
            return undefined;
        }
        return msg.text();
    }

    /**
     * Set the user's new preferred editor
     *
     * @param {string} editor Preferred editor, 'visualeditor' or 'wikitext'
     * @return {jQuery.Promise} Promise which resolves when the preference has been set
     */
    function setEditorPreference( editor ) {
        // If visual mode isn't available, don't set the editor preference as the
        // user has expressed no choice by opening this editor. (T246259)
        // Strictly speaking the same thing should happen if visual mode is
        // available but source mode isn't, but that is never the case.
        if ( !init.isVisualAvailable ) {
            return $.Deferred().resolve().promise();
        }

        if ( editor !== 'visualeditor' && editor !== 'wikitext' ) {
            throw new Error( 'setEditorPreference called with invalid option: ', editor );
        }

        let key = pageExists ? 'edit' : 'create',
            sectionKey = 'editsection';

        if (
            mw.config.get( 'wgVisualEditorConfig' ).singleEditTab &&
            tabPreference === 'remember-last'
        ) {
            if ( $( '#ca-view-foreign' ).length ) {
                key += 'localdescription';
            }
            if ( editor === 'wikitext' ) {
                key += 'source';
                sectionKey += 'source';
            }

            $( '#ca-edit a' ).text( getTabMessage( key ) );
            $( '.mw-editsection a' ).text( getTabMessage( sectionKey ) );
        }

        mw.cookie.set( 'VEE', editor, { path: '/', expires: 30 * 86400, prefix: '' } );

        // Save user preference if logged in
        if (
            mw.user.isNamed() &&
            mw.user.options.get( 'visualeditor-editor' ) !== editor
        ) {
            // Same as ve.init.target.getLocalApi()
            return new mw.Api().saveOption( 'visualeditor-editor', editor ).then( () => {
                mw.user.options.set( 'visualeditor-editor', editor );
            } );
        }
        return $.Deferred().resolve().promise();
    }

    /**
     * Update state of editing tabs
     *
     * @param {boolean} editing Whether the editor is loaded
     * @param {string} [mode='visual'] Edit mode ('visual' or 'source')
     * @param {boolean} [isNewSection] Adding a new section
     */
    function updateTabs( editing, mode, isNewSection ) {
        let $tab;

        if ( editing ) {
            if ( isNewSection ) {
                $tab = $( '#ca-addsection' );
            } else if ( $( '#ca-ve-edit' ).length ) {
                if ( !mode || mode === 'visual' ) {
                    $tab = $( '#ca-ve-edit' );
                } else {
                    $tab = $( '#ca-edit' );
                }
            } else {
                // Single edit tab
                $tab = $( '#ca-edit' );
            }
        } else {
            $tab = $( '#ca-view' );
        }

        // Deselect current mode (e.g. "view" or "history") in skins that have
        // separate tab sections for content actions and namespaces, like Vector.
        $( '#p-views' ).find( 'li.selected' ).removeClass( 'selected' );
        // In skins like MonoBook that don't have the separate tab sections,
        // deselect the known tabs for editing modes (when switching or exiting editor).
        $( '#ca-edit, #ca-ve-edit, #ca-addsection' ).not( $tab ).removeClass( 'selected' );

        $tab.addClass( 'selected' );
    }

    /**
     * Scroll to a specific heading before VE loads
     *
     * Similar to ve.init.mw.ArticleTarget.prototype.scrollToHeading
     *
     * @param {string} section Parsed section (string)
     */
    function scrollToSection( section ) {
        if ( section === '0' || section === 'new' ) {
            return;
        }

        let $heading;
        $( '#mw-content-text .mw-editsection a:not( .mw-editsection-visualeditor )' ).each( ( i, el ) => {
            const linkUrl = new URL( el.href );
            if ( section === parseSection( linkUrl.searchParams.get( 'section' ) ) ) {
                $heading = $( el ).closest( '.mw-heading, h1, h2, h3, h4, h5, h6' );
                return false;
            }
        } );
        // When loading on action=edit URLs, there is no page content
        if ( !$heading || !$heading.length ) {
            return;
        }

        let offset = 0;
        const enableVisualSectionEditing = mw.config.get( 'wgVisualEditorConfig' ).enableVisualSectionEditing;
        if ( enableVisualSectionEditing === true || enableVisualSectionEditing === 'desktop' ) {
            // Heading will jump to the top of the page in visual section editing.
            // This measurement already includes the height of $toolbarPlaceholder.
            offset = $( '#mw-content-text' ).offset().top;
        } else {
            // Align with top of heading margin. Doesn't apply in visual section editing as the margin collapses.
            offset = parseInt( $heading.css( 'margin-top' ) ) + $toolbarPlaceholder.outerHeight();
        }

        // Support for CSS `scroll-behavior: smooth;` and JS `window.scroll( { behavior: 'smooth' } )`
        // is correlated:
        // * https://caniuse.com/css-scroll-behavior
        // * https://caniuse.com/mdn-api_window_scroll_options_behavior_parameter
        const supportsSmoothScroll = 'scrollBehavior' in document.documentElement.style;
        const newScrollTop = $heading.offset().top - offset;
        if ( supportsSmoothScroll ) {
            window.scroll( {
                top: newScrollTop,
                behavior: 'smooth'
            } );
        } else {
            // Ideally we would use OO.ui.Element.static.getRootScrollableElement here
            // as it has slightly better browser support (Chrome < 60)
            const scrollContainer = document.documentElement;

            $( scrollContainer ).animate( {
                scrollTop: newScrollTop
            } );
        }
    }

    /**
     * Load and activate the target.
     *
     * If you need to call methods on the target before activate is called, call getTarget()
     * yourself, chain your work onto that promise, and pass that chained promise in as targetPromise.
     * E.g. `activateTarget( getTarget().then( function( target ) { target.doAThing(); } ) );`
     *
     * @private
     * @param {string} mode Target mode: 'visual' or 'source'
     * @param {string} [section] Section to edit.
     *  If visual section editing is not enabled, we will jump to the start of this section, and still
     *  the heading to prefix the edit summary.
     * @param {jQuery.Promise} [tPromise] Promise that will be resolved with a ve.init.mw.DesktopArticleTarget
     * @param {boolean} [modified=false] The page has been modified before loading (e.g. in source mode)
     */
    function activateTarget( mode, section, tPromise, modified ) {
        let dataPromise;

        updateTabs( true, mode, section === 'new' );

        // Only call requestPageData early if the target object isn't there yet.
        // If the target object is there, this is a second or subsequent load, and the
        // internal state of the target object can influence the load request.
        if ( !targetLoaded ) {
            // The TargetLoader module is loaded in the bottom queue, so it should have been
            // requested already but it might not have finished loading yet
            dataPromise = mw.loader.using( 'ext.visualEditor.targetLoader' )
                .then( () => mw.libs.ve.targetLoader.requestPageData( mode, mw.config.get( 'wgRelevantPageName' ), {
                    sessionStore: true,
                    section: section,
                    oldId: oldId,
                    // Should be ve.init.mw.DesktopArticleTarget.static.trackingName, but the
                    // class hasn't loaded yet.
                    // This is used for stats tracking, so do not change!
                    targetName: 'mwTarget',
                    modified: modified,
                    editintro: url.searchParams.get( 'editintro' ),
                    preload: url.searchParams.get( 'preload' ),
                    preloadparams: mw.util.getArrayParam( 'preloadparams', url.searchParams ),
                    // If switching to visual with modifications, check if we have wikitext to convert
                    wikitext: mode === 'visual' && modified ? $( '#wpTextbox1' ).textSelection( 'getContents' ) : undefined
                } ) );

            dataPromise
                .then( ( response ) => {
                    if (
                        // Check target promise hasn't already failed (isLoading=false)
                        isLoading &&
                        // TODO: Support tempWikitextEditor when section=new (T185633)
                        mode === 'source' && section !== 'new' &&
                        // Can't use temp editor when recovering an autosave
                        !( response.visualeditor && response.visualeditor.recovered )
                    ) {
                        setupTempWikitextEditor( response.visualeditor );
                    }
                } )
                .then( incrementLoadingProgress );
        }

        // Do this before section scrolling
        showToolbarPlaceholder();
        mw.hook( 've.activationStart' ).fire();

        let visibleSection = null;
        let visibleSectionOffset = null;
        if ( section === null ) {
            let firstVisibleEditSection = null;
            $( '#firstHeading, #mw-content-text .mw-editsection' ).each( ( i, el ) => {
                const top = el.getBoundingClientRect().top;
                if ( top > 0 ) {
                    firstVisibleEditSection = el;
                    // break
                    return false;
                }
            } );

            if ( firstVisibleEditSection && firstVisibleEditSection.id !== 'firstHeading' ) {
                const firstVisibleSectionLink = firstVisibleEditSection.querySelector( 'a' );
                const linkUrl = new URL( firstVisibleSectionLink.href );
                visibleSection = parseSection( linkUrl.searchParams.get( 'section' ) );

                const firstVisibleHeading = $( firstVisibleEditSection ).closest( '.mw-heading, h1, h2, h3, h4, h5, h6' )[ 0 ];
                visibleSectionOffset = firstVisibleHeading.getBoundingClientRect().top;
            }
        } else if ( mode === 'visual' ) {
            scrollToSection( section );
        }

        showLoading( mode );
        incrementLoadingProgress();
        active = true;

        tPromise = tPromise || getTarget( mode, section );
        tPromise
            .then( ( target ) => {
                target.visibleSection = visibleSection;
                target.visibleSectionOffset = visibleSectionOffset;

                incrementLoadingProgress();
                // If target was already loaded, ensure the mode is correct
                target.setDefaultMode( mode );
                // syncTempWikitextEditor modified the result object in the dataPromise
                if ( tempWikitextEditor ) {
                    syncTempWikitextEditor();
                }

                const deactivating = target.deactivatingDeferred || $.Deferred().resolve();
                return deactivating.then( () => {
                    target.currentUrl = new URL( location.href );
                    const activatePromise = target.activate( dataPromise );

                    // toolbarSetupDeferred resolves slightly before activatePromise, use done
                    // to run in the same paint cycle as the VE toolbar being drawn
                    target.toolbarSetupDeferred.done( () => {
                        hideToolbarPlaceholder();
                    } );

                    return activatePromise;
                } );
            } )
            .then( () => {
                if ( mode === 'visual' ) {
                    // `action: 'ready'` has already been fired for source mode in setupTempWikitextEditor
                    ve.track( 'editAttemptStep', { action: 'ready', mode: mode } );
                } else if ( !tempWikitextEditor ) {
                    // We're in source mode, but skipped the
                    // tempWikitextEditor, so make sure we do relevant
                    // tracking / hooks:
                    ve.track( 'editAttemptStep', { action: 'ready', mode: mode } );
                    mw.hook( 've.wikitextInteractive' ).fire();
                }
                ve.track( 'editAttemptStep', { action: 'loaded', mode: mode } );
            } )
            .always( clearLoading );
    }

    /**
     * @private
     * @param {string} mode Target mode: 'visual' or 'source'
     * @param {string} [section]
     * @param {boolean} [modified=false] The page has been modified before loading (e.g. in source mode)
     * @param {URL} [linkUrl] URL to navigate to, potentially with extra parameters
     */
    function activatePageTarget( mode, section, modified, linkUrl ) {
        trackActivateStart( { type: 'page', mechanism: mw.config.get( 'wgArticleId' ) ? 'click' : 'new', mode: mode }, linkUrl );

        if ( !active ) {
            // Replace the current state with one that is tagged as ours, to prevent the
            // back button from breaking when used to exit VE. FIXME: there should be a better
            // way to do this. See also similar code in the DesktopArticleTarget constructor.
            history.replaceState( { tag: 'visualeditor' }, '', url );
            // Set action=edit or veaction=edit/editsource
            // Use linkUrl to preserve parameters like 'editintro' (T56029)
            history.pushState( { tag: 'visualeditor' }, '', linkUrl || ( mode === 'source' ? veEditSourceUrl : veEditUrl ) );
            // Update URL instance
            url = linkUrl || veEditUrl;

            activateTarget( mode, section, undefined, modified );
        }
    }

    /**
     * Get the last mode a user used
     *
     * @return {string|null} 'visualeditor', 'wikitext' or null
     */
    function getLastEditor() {
        // This logic matches VisualEditorHooks::getLastEditor
        let editor = mw.cookie.get( 'VEE', '' );
        // Set editor to user's preference or site's default (ignore the cookie) if …
        if (
            // … user is logged in,
            mw.user.isNamed() ||
            // … no cookie is set, or
            !editor ||
            // value is invalid.
            !( editor === 'visualeditor' || editor === 'wikitext' )
        ) {
            editor = mw.user.options.get( 'visualeditor-editor' );
        }
        return editor;
    }

    /**
     * Get the preferred editor for this edit page
     *
     * For the preferred *available* editor, use getAvailableEditPageEditor.
     *
     * @return {string|null} 'visualeditor', 'wikitext' or null
     */
    function getEditPageEditor() {
        // This logic matches VisualEditorHooks::getEditPageEditor
        // !!+ casts '0' to false
        const isRedLink = !!+url.searchParams.get( 'redlink' );
        // On dual-edit-tab wikis, the edit page must mean the user wants wikitext,
        // unless following a redlink
        if ( !mw.config.get( 'wgVisualEditorConfig' ).singleEditTab && !isRedLink ) {
            return 'wikitext';
        }

        switch ( tabPreference ) {
            case 'prefer-ve':
                return 'visualeditor';
            case 'prefer-wt':
                return 'wikitext';
            case 'multi-tab':
                // 'multi-tab'
                // TODO: See VisualEditor.hooks.php
                return isRedLink ?
                    getLastEditor() :
                    'wikitext';
            case 'remember-last':
            default:
                return getLastEditor();
        }
    }

    /**
     * Get the preferred editor which is also available on this edit page
     *
     * @return {string} 'visual' or 'source'
     */
    function getAvailableEditPageEditor() {
        switch ( getEditPageEditor() ) {
            case 'visualeditor':
                if ( init.isVisualAvailable ) {
                    return 'visual';
                }
                if ( init.isWikitextAvailable ) {
                    return 'source';
                }
                return null;

            case 'wikitext':
            default:
                return init.isWikitextAvailable ? 'source' : null;
        }
    }

    /**
     * Check if a boolean preference is set in user options, mw.storage or a cookie
     *
     * @param {string} prefName Preference name
     * @param {string} storageKey mw.storage key
     * @param {string} cookieName Cookie name
     * @return {boolean} Preference is set
     */
    function checkPreferenceOrStorage( prefName, storageKey, cookieName ) {
        storageKey = storageKey || prefName;
        cookieName = cookieName || storageKey;
        return !!( mw.user.options.get( prefName ) ||
            (
                !mw.user.isNamed() && (
                    mw.storage.get( storageKey ) ||
                    mw.cookie.get( cookieName, '' )
                )
            )
        );
    }

    /**
     * Set a boolean preference to true in user options, mw.storage or a cookie
     *
     * @param {string} prefName Preference name
     * @param {string} storageKey mw.storage key
     * @param {string} cookieName Cookie name
     */
    function setPreferenceOrStorage( prefName, storageKey, cookieName ) {
        storageKey = storageKey || prefName;
        cookieName = cookieName || storageKey;
        if ( !mw.user.isNamed() ) {
            // Try local storage first; if that fails, set a cookie
            if ( !mw.storage.set( storageKey, 1 ) ) {
                mw.cookie.set( cookieName, 1, { path: '/', expires: 30 * 86400, prefix: '' } );
            }
        } else {
            new mw.Api().saveOption( prefName, '1' );
            mw.user.options.set( prefName, '1' );
        }
    }

    conf = mw.config.get( 'wgVisualEditorConfig' );
    tabMessages = conf.tabMessages;
    viewUrl = new URL( mw.util.getUrl( mw.config.get( 'wgRelevantPageName' ) ), location.href );
    url = new URL( location.href );
    // T156998: Don't trust 'oldid' query parameter, it'll be wrong if 'diff' or 'direction'
    // is set to 'next' or 'prev'.
    oldId = mw.config.get( 'wgRevisionId' ) || $( 'input[name=parentRevId]' ).val();
    if ( oldId === mw.config.get( 'wgCurRevisionId' ) || mw.config.get( 'wgEditLatestRevision' ) ) {
        // The page may have been edited by someone else after we loaded it, setting this to "undefined"
        // indicates that we should load the actual latest revision.
        oldId = undefined;
    }
    pageExists = !!mw.config.get( 'wgRelevantArticleId' );
    const isViewPage = mw.config.get( 'wgIsArticle' ) && !url.searchParams.has( 'diff' );
    const wgAction = mw.config.get( 'wgAction' );
    const isEditPage = wgAction === 'edit' || wgAction === 'submit';
    const pageCanLoadEditor = isViewPage || isEditPage;
    const pageIsProbablyEditable = mw.config.get( 'wgIsProbablyEditable' ) ||
        mw.config.get( 'wgRelevantPageIsProbablyEditable' );

    // Cast "0" (T89513)
    const enable = !!+mw.user.options.get( 'visualeditor-enable' );
    const tempdisable = !!+mw.user.options.get( 'visualeditor-betatempdisable' );
    const autodisable = !!+mw.user.options.get( 'visualeditor-autodisable' );
    tabPreference = mw.user.options.get( 'visualeditor-tabs' );

    /**
     * The only edit tab shown to the user is for visual mode
     *
     * @return {boolean}
     */
    function isOnlyTabVE() {
        return conf.singleEditTab && getAvailableEditPageEditor() === 'visual';
    }

    /**
     * The only edit tab shown to the user is for source mode
     *
     * @return {boolean}
     */
    function isOnlyTabWikitext() {
        return conf.singleEditTab && getAvailableEditPageEditor() === 'source';
    }

    init = {
        /**
         * Add a plugin module or function.
         *
         * Plugins are run after VisualEditor is loaded, but before it is initialized. This allows
         * plugins to add classes and register them with the factories and registries.
         *
         * The parameter to this function can be a ResourceLoader module name or a function.
         *
         * If it's a module name, it will be loaded together with the VisualEditor core modules when
         * VE is loaded. No special care is taken to ensure that the module runs after the VE
         * classes are loaded, so if this is desired, the module should depend on
         * ext.visualEditor.core .
         *
         * If it's a function, it will be invoked once the VisualEditor core modules and any
         * plugin modules registered through this function have been loaded, but before the editor
         * is intialized. The function can optionally return a jQuery.Promise . VisualEditor will
         * only be initialized once all promises returned by plugin functions have been resolved.
         *
         *     // Register ResourceLoader module
         *     mw.libs.ve.addPlugin( 'ext.gadget.foobar' );
         *
         *     // Register a callback
         *     mw.libs.ve.addPlugin( ( target ) => {
         *         ve.dm.Foobar = .....
         *     } );
         *
         *     // Register a callback that loads another script
         *     mw.libs.ve.addPlugin( () => $.getScript( 'http://example.com/foobar.js' ) );
         *
         * @param {string|Function} plugin Module name or callback that optionally returns a promise
         */
        addPlugin: function ( plugin ) {
            plugins.push( plugin );
        },

        /**
         * Adjust edit page links in the current document
         *
         * This will run multiple times in a page lifecycle, notably when the
         * page first loads and after post-save content replacement occurs. It
         * needs to avoid doing anything which will cause problems if it's run
         * twice or more.
         */
        setupEditLinks: function () {
            // NWE
            if ( init.isWikitextAvailable && !isOnlyTabVE() ) {
                $(
                    // Edit section links, except VE ones when both editors visible
                    '.mw-editsection a:not( .mw-editsection-visualeditor ),' +
                    // Edit tab
                    '#ca-edit a,' +
                    // Add section is currently a wikitext-only feature
                    '#ca-addsection a'
                ).each( ( i, el ) => {
                    if ( !el.href ) {
                        // Not a real link, probably added by a gadget or another extension (T328094)
                        return;
                    }

                    const linkUrl = new URL( el.href );
                    if ( linkUrl.searchParams.has( 'action' ) ) {
                        linkUrl.searchParams.delete( 'action' );
                        linkUrl.searchParams.set( 'veaction', 'editsource' );
                        $( el ).attr( 'href', linkUrl.toString() );
                    }
                } );
            }

            // Set up the tabs appropriately if the user has VE on
            if ( init.isAvailable ) {
                // … on two-edit-tab wikis, or single-edit-tab wikis, where the user wants both …
                if (
                    !init.isSingleEditTab && init.isVisualAvailable &&
                    // T253941: This option does not actually disable the editor, only leaves the tabs/links unchanged
                    !( conf.disableForAnons && mw.user.isAnon() )
                ) {
                    // … set the skin up with both tabs and both section edit links.
                    init.setupMultiTabSkin();
                } else if (
                    pageCanLoadEditor && (
                        ( init.isVisualAvailable && isOnlyTabVE() ) ||
                        ( init.isWikitextAvailable && isOnlyTabWikitext() )
                    )
                ) {
                    // … on single-edit-tab wikis, where VE or NWE is the user's preferred editor
                    // Handle section edit link clicks
                    $( '.mw-editsection a' ).off( '.ve-target' ).on( 'click.ve-target', ( e ) => {
                        // isOnlyTabVE is computed on click as it may have changed since load
                        init.onEditSectionLinkClick( isOnlyTabVE() ? 'visual' : 'source', e );
                    } );
                    // Allow instant switching to edit mode, without refresh
                    $( '#ca-edit' ).off( '.ve-target' ).on( 'click.ve-target', ( e ) => {
                        init.onEditTabClick( isOnlyTabVE() ? 'visual' : 'source', e );
                    } );
                }
            }
        },

        /**
         * Setup multiple edit tabs and section links (edit + edit source)
         */
        setupMultiTabSkin: function () {
            init.setupMultiTabs();
            init.setupMultiSectionLinks();
        },

        /**
         * Setup multiple edit tabs (edit + edit source)
         */
        setupMultiTabs: function () {
            // Minerva puts the '#ca-...' ids on <a> nodes, other skins put them on <li>
            const $caEdit = $( '#ca-edit' );
            const $caVeEdit = $( '#ca-ve-edit' );

            if ( pageCanLoadEditor ) {
                // Allow instant switching to edit mode, without refresh
                $caVeEdit.off( '.ve-target' ).on( 'click.ve-target', init.onEditTabClick.bind( init, 'visual' ) );
            }
            if ( pageCanLoadEditor ) {
                // Always bind "Edit source" tab, because we want to handle switching with changes
                $caEdit.off( '.ve-target' ).on( 'click.ve-target', init.onEditTabClick.bind( init, 'source' ) );
            }
            if ( pageCanLoadEditor && init.isWikitextAvailable ) {
                // Only bind "Add topic" tab if NWE is available, because VE doesn't support section
                // so we never have to switch from it when editing a section
                $( '#ca-addsection' ).off( '.ve-target' ).on( 'click.ve-target', init.onEditTabClick.bind( init, 'source' ) );
            }

            if ( init.isVisualAvailable ) {
                if ( conf.tabPosition === 'before' ) {
                    $caEdit.addClass( 'collapsible' );
                } else {
                    $caVeEdit.addClass( 'collapsible' );
                }
            }
        },

        /**
         * Setup multiple section links (edit + edit source)
         */
        setupMultiSectionLinks: function () {
            if ( pageCanLoadEditor ) {
                const $editsections = $( '#mw-content-text .mw-editsection' );

                // Only init without refresh if we're on a view page. Though section edit links
                // are rarely shown on non-view pages, they appear in one other case, namely
                // when on a diff against the latest version of a page. In that case we mustn't
                // init without refresh as that'd initialise for the wrong rev id (T52925)
                // and would preserve the wrong DOM with a diff on top.
                $editsections.find( '.mw-editsection-visualeditor' )
                    .off( '.ve-target' ).on( 'click.ve-target', init.onEditSectionLinkClick.bind( init, 'visual' ) );
                if ( init.isWikitextAvailable ) {
                    // TOOD: Make this less fragile
                    $editsections.find( 'a:not( .mw-editsection-visualeditor )' )
                        .off( '.ve-target' ).on( 'click.ve-target', init.onEditSectionLinkClick.bind( init, 'source' ) );
                }
            }
        },

        /**
         * Check whether a jQuery event represents a plain left click, without
         * any modifiers or a programmatically triggered click.
         *
         * This is a duplicate of a function in ve.utils, because this file runs
         * before any of VE core or OOui has been loaded.
         *
         * @param {jQuery.Event} e
         * @return {boolean} Whether it was an unmodified left click
         */
        isUnmodifiedLeftClick: function ( e ) {
            return e && ( (
                e.which && e.which === 1 && !( e.shiftKey || e.altKey || e.ctrlKey || e.metaKey )
            ) || e.isTrigger );
        },

        /**
         * Handle click events on an edit tab
         *
         * @param {string} mode Edit mode, 'visual' or 'source'
         * @param {Event} e Event
         */
        onEditTabClick: function ( mode, e ) {
            if ( !init.isUnmodifiedLeftClick( e ) ) {
                return;
            }
            if ( !active && mode === 'source' && !init.isWikitextAvailable ) {
                // We're not active so we don't need to manage a switch, and
                // we don't have source mode available so we don't need to
                // activate VE. Just follow the link.
                return;
            }
            e.preventDefault();
            if ( isLoading ) {
                return;
            }

            const section = $( e.target ).closest( '#ca-addsection' ).length ? 'new' : null;

            if ( active ) {
                targetPromise.done( ( target ) => {
                    if ( target.getDefaultMode() === 'source' ) {
                        if ( mode === 'visual' ) {
                            target.switchToVisualEditor();
                        } else if ( mode === 'source' ) {
                            // Requested section may have changed --
                            // switchToWikitextSection will do nothing if the
                            // section is unchanged.
                            target.switchToWikitextSection( section );
                        }
                    } else if ( target.getDefaultMode() === 'visual' ) {
                        if ( mode === 'source' ) {
                            if ( section ) {
                                // switching from visual via the "add section" tab
                                target.switchToWikitextSection( section );
                            } else {
                                target.editSource();
                            }
                        }
                        // Visual-to-visual doesn't need to do anything,
                        // because we don't have any section concerns. Just
                        // no-op it.
                    }
                } );
            } else {
                const link = $( e.target ).closest( 'a' )[ 0 ];
                const linkUrl = link && link.href ? new URL( link.href ) : null;
                if ( section !== null ) {
                    init.activateVe( mode, linkUrl, section );
                } else {
                    // Do not pass `section` to handle switching from section editing in WikiEditor if needed
                    init.activateVe( mode, linkUrl );
                }
            }
        },

        /**
         * Activate VE
         *
         * @param {string} mode Target mode: 'visual' or 'source'
         * @param {URL} [linkUrl] URL to navigate to, potentially with extra parameters
         * @param {string} [section]
         */
        activateVe: function ( mode, linkUrl, section ) {
            const wikitext = $( '#wpTextbox1' ).textSelection( 'getContents' ),
                modified = mw.config.get( 'wgAction' ) === 'submit' ||
                    (
                        mw.config.get( 'wgAction' ) === 'edit' &&
                        wikitext !== initialWikitext
                    );

            if ( section === undefined ) {
                const sectionVal = $( 'input[name=wpSection]' ).val();
                section = sectionVal !== '' && sectionVal !== undefined ? sectionVal : null;
            }

            // Close any open jQuery.UI dialogs (e.g. WikiEditor's find and replace)
            if ( $.fn.dialog ) {
                $( '.ui-dialog-content' ).dialog( 'close' );
            }

            // Release the edit warning on #wpTextbox1 which was setup in mediawiki.action.edit.editWarning.js
            $( window ).off( 'beforeunload.editwarning' );
            activatePageTarget( mode, section, modified, linkUrl );
        },

        /**
         * Handle section edit links being clicked
         *
         * @param {string} mode Edit mode
         * @param {jQuery.Event} e Click event
         * @param {string} [section] Override edit section, taken from link URL if not specified
         */
        onEditSectionLinkClick: function ( mode, e, section ) {
            const link = $( e.target ).closest( 'a' )[ 0 ];
            if ( !link || !link.href ) {
                // Not a real link, probably added by a gadget or another extension (T328094)
                return;
            }

            const linkUrl = new URL( link.href );
            const title = mw.Title.newFromText( linkUrl.searchParams.get( 'title' ) || '' );

            if (
                // Modified click (e.g. ctrl+click)
                !init.isUnmodifiedLeftClick( e ) ||
                // Not an edit action
                !( linkUrl.searchParams.has( 'action' ) || linkUrl.searchParams.has( 'veaction' ) ) ||
                // Edit target is on another host (e.g. commons file)
                linkUrl.host !== location.host ||
                // Title param doesn't match current page
                title && title.getPrefixedText() !== new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText()
            ) {
                return;
            }
            e.preventDefault();
            if ( isLoading ) {
                return;
            }

            trackActivateStart( { type: 'section', mechanism: section === 'new' ? 'new' : 'click', mode: mode }, linkUrl );

            if ( !active ) {
                // Replace the current state with one that is tagged as ours, to prevent the
                // back button from breaking when used to exit VE. FIXME: there should be a better
                // way to do this. See also similar code in the DesktopArticleTarget constructor.
                history.replaceState( { tag: 'visualeditor' }, '', url );
                // Use linkUrl to preserve the 'section' parameter and others like 'editintro' (T56029)
                history.pushState( { tag: 'visualeditor' }, '', linkUrl );
                // Update URL instance
                url = linkUrl;

                // Use section from URL
                if ( section === undefined ) {
                    section = parseSection( linkUrl.searchParams.get( 'section' ) );
                }
                const tPromise = getTarget( mode, section );
                activateTarget( mode, section, tPromise );
            }
        },

        /**
         * Check whether the welcome dialog should be shown.
         *
         * The welcome dialog can be disabled in configuration; or by calling disableWelcomeDialog();
         * or using a query string parameter; or if we've recorded that we've already shown it before
         * in a user preference, local storage or a cookie.
         *
         * @return {boolean}
         */
        shouldShowWelcomeDialog: function () {
            return !(
                // Disabled in config?
                !mw.config.get( 'wgVisualEditorConfig' ).showBetaWelcome ||
                // Disabled for the current request?
                this.isWelcomeDialogSuppressed() ||
                // Joining a collab session
                url.searchParams.has( 'collabSession' ) ||
                // Hidden using preferences, local storage or cookie?
                checkPreferenceOrStorage( 'visualeditor-hidebetawelcome', 've-beta-welcome-dialog' )
            );
        },

        /**
         * Check whether the welcome dialog is temporarily disabled.
         *
         * @return {boolean}
         */
        isWelcomeDialogSuppressed: function () {
            return !!(
                // Disabled by calling disableWelcomeDialog()?
                welcomeDialogDisabled ||
                // Hidden using URL parameter?
                new URL( location.href ).searchParams.has( 'vehidebetadialog' ) ||
                // Check for deprecated hidewelcomedialog parameter (T249954)
                new URL( location.href ).searchParams.has( 'hidewelcomedialog' )
            );
        },

        /**
         * Record that we've already shown the welcome dialog to this user, so that it won't be shown
         * to them again.
         *
         * Uses a preference for logged-in users; uses local storage or a cookie for anonymous users.
         */
        stopShowingWelcomeDialog: function () {
            setPreferenceOrStorage( 'visualeditor-hidebetawelcome', 've-beta-welcome-dialog' );
        },

        /**
         * Prevent the welcome dialog from being shown on this page view only.
         *
         * Causes shouldShowWelcomeDialog() to return false, but doesn't save anything to preferences
         * or local storage, so future page views are not affected.
         */
        disableWelcomeDialog: function () {
            welcomeDialogDisabled = true;
        },

        /**
         * Check whether the user education popups (ve.ui.MWEducationPopupWidget) should be shown.
         *
         * The education popups can be disabled by calling disableWelcomeDialog(), or if we've
         * recorded that we've already shown it before in a user preference, local storage or a cookie.
         *
         * @return {boolean}
         */
        shouldShowEducationPopups: function () {
            return !(
                // Disabled by calling disableEducationPopups()?
                educationPopupsDisabled ||
                // Hidden using preferences, local storage, or cookie?
                checkPreferenceOrStorage( 'visualeditor-hideusered', 've-hideusered' )
            );
        },

        /**
         * Record that we've already shown the education popups to this user, so that it won't be
         * shown to them again.
         *
         * Uses a preference for logged-in users; uses local storage or a cookie for anonymous users.
         */
        stopShowingEducationPopups: function () {
            setPreferenceOrStorage( 'visualeditor-hideusered', 've-hideusered' );
        },

        /**
         * Prevent the education popups from being shown on this page view only.
         *
         * Causes shouldShowEducationPopups() to return false, but doesn't save anything to
         * preferences or local storage, so future page views are not affected.
         */
        disableEducationPopups: function () {
            educationPopupsDisabled = true;
        }
    };

    init.isSingleEditTab = conf.singleEditTab && tabPreference !== 'multi-tab';

    // On a view page, extend the current URL so extra parameters are carried over
    // On a non-view page, use viewUrl
    veEditUrl = new URL( pageCanLoadEditor ? url : viewUrl );
    if ( oldId ) {
        veEditUrl.searchParams.set( 'oldid', oldId );
    }
    veEditUrl.searchParams.delete( 'veaction' );
    veEditUrl.searchParams.delete( 'action' );
    if ( init.isSingleEditTab ) {
        veEditUrl.searchParams.set( 'action', 'edit' );
        veEditSourceUrl = veEditUrl;
    } else {
        veEditSourceUrl = new URL( veEditUrl );
        veEditUrl.searchParams.set( 'veaction', 'edit' );
        veEditSourceUrl.searchParams.set( 'veaction', 'editsource' );
    }

    // Whether VisualEditor should be available for the current user, page, wiki, mediawiki skin,
    // browser etc.
    init.isAvailable = VisualEditorSupportCheck();
    // Extensions can disable VE in certain circumstances using the VisualEditorBeforeEditor hook (T174180)

    const enabledForUser = (
        // User has 'visualeditor-enable' preference enabled (for alpha opt-in)
        // User has 'visualeditor-betatempdisable' preference disabled
        // User has 'visualeditor-autodisable' preference disabled
        ( conf.isBeta ? enable : !tempdisable ) && !autodisable
    );

    // Duplicated in VisualEditor.hooks.php#isVisualAvailable()
    init.isVisualAvailable = (
        init.isAvailable &&

        // If forced by the URL parameter, skip the namespace check (T221892) and preference check
        ( url.searchParams.get( 'veaction' ) === 'edit' || (
            // Only in enabled namespaces
            conf.namespaces.indexOf( new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getNamespaceId() ) !== -1 &&

            // Enabled per user preferences
            enabledForUser
        ) ) &&

        // Only for pages with a supported content model
        Object.prototype.hasOwnProperty.call( conf.contentModels, mw.config.get( 'wgPageContentModel' ) )
    );

    // Duplicated in VisualEditor.hooks.php#isWikitextAvailable()
    init.isWikitextAvailable = (
        init.isAvailable &&

        // If forced by the URL parameter, skip the checks (T239796)
        ( url.searchParams.get( 'veaction' ) === 'editsource' || (
            // Enabled on site
            conf.enableWikitext &&

            // User preference
            mw.user.options.get( 'visualeditor-newwikitext' )
        ) ) &&

        // Only on wikitext pages
        mw.config.get( 'wgPageContentModel' ) === 'wikitext'
    );

    if ( init.isVisualAvailable ) {
        availableModes.push( 'visual' );
    }

    if ( init.isWikitextAvailable ) {
        availableModes.push( 'source' );
    }

    // FIXME: We should do this more elegantly
    init.setEditorPreference = setEditorPreference;

    init.updateTabs = updateTabs;

    // Note: Though VisualEditor itself only needed this exposure for a very small reason
    // (namely to access the old init.unsupportedList from the unit tests...) this has become one
    // of the nicest ways to easily detect whether the VisualEditor initialisation code is present.
    //
    // The VE global was once available always, but now that platform integration initialisation
    // is properly separated, it doesn't exist until the platform loads VisualEditor core.
    //
    // Most of mw.libs.ve is considered subject to change and private.  An exception is that
    // mw.libs.ve.isVisualAvailable is public, and indicates whether the VE editor itself can be loaded
    // on this page. See above for why it may be false.
    mw.libs.ve = $.extend( mw.libs.ve || {}, init );

    if ( init.isVisualAvailable ) {
        $( 'html' ).addClass( 've-available' );
    } else {
        $( 'html' ).addClass( 've-not-available' );
        // Don't return here because we do want the skin setup to consistently happen
        // for e.g. "Edit" > "Edit source" even when VE is not available.
    }

    /**
     * Check if a URL doesn't contain any params which would prevent VE from loading, e.g. 'undo'
     *
     * @param {URL} editUrl
     * @return {boolean} URL contains no unsupported params
     */
    function isSupportedEditPage( editUrl ) {
        return configData.unsupportedEditParams.every( ( param ) => !editUrl.searchParams.has( param ) );
    }

    /**
     * Get the edit mode for the given URL
     *
     * @param {URL} editUrl Edit URL
     * @return {string|null} 'visual' or 'source', null if the editor is not being loaded
     */
    function getEditModeFromUrl( editUrl ) {
        if ( mw.config.get( 'wgDiscussionToolsStartNewTopicTool' ) ) {
            // Avoid conflicts with DiscussionTools
            return null;
        }
        if ( isViewPage && init.isAvailable ) {
            // On view pages if veaction is correctly set
            const mode = veactionToMode[ editUrl.searchParams.get( 'veaction' ) ] ||
                // Always load VE visual mode if collabSession is set
                ( editUrl.searchParams.has( 'collabSession' ) ? 'visual' : null );
            if ( mode && availableModes.indexOf( mode ) !== -1 ) {
                return mode;
            }
        }
        // Edit pages
        if ( isEditPage && isSupportedEditPage( editUrl ) ) {
            // User has disabled VE, or we are in view source only mode, or we have landed here with posted data
            if ( !enabledForUser || $( '#ca-viewsource' ).length || mw.config.get( 'wgAction' ) === 'submit' ) {
                return null;
            }
            return getAvailableEditPageEditor();
        }
        return null;
    }

    $( () => {
        $targetContainer = $(
            document.querySelector( '[data-mw-ve-target-container]' ) ||
            document.getElementById( 'content' )
        );
        if ( pageCanLoadEditor ) {
            $targetContainer.addClass( 've-init-mw-desktopArticleTarget-targetContainer' );
        }

        let showWikitextWelcome = true;
        const numEditButtons = $( '#ca-edit, #ca-ve-edit' ).length,
            section = parseSection( url.searchParams.get( 'section' ) );

        const requiredSkinElements =
            $targetContainer.length &&
            $( '#mw-content-text' ).length &&
            // A link to open the editor is technically not necessary if it's going to open itself
            ( isEditPage || numEditButtons );

        if ( url.searchParams.get( 'action' ) === 'edit' && $( '#wpTextbox1' ).length ) {
            initialWikitext = $( '#wpTextbox1' ).textSelection( 'getContents' );
        }

        if ( ( init.isVisualAvailable || init.isWikitextAvailable ) &&
            pageCanLoadEditor &&
            pageIsProbablyEditable &&
            !requiredSkinElements
        ) {
            mw.log.warn(
                'Your skin is incompatible with VisualEditor. ' +
                'See https://www.mediawiki.org/wiki/Extension:VisualEditor/Skin_requirements for the requirements.'
            );
            // If the edit buttons are not there it's likely a browser extension or gadget for anonymous user
            // has removed them. We're not interested in errors from this scenario so don't log.
            // If they exist log the error so we can address the problem.
            if ( numEditButtons > 0 ) {
                const err = new Error( 'Incompatible with VisualEditor' );
                err.name = 'VeIncompatibleSkinWarning';
                mw.errorLogger.logError( err, 'error.visualeditor' );
            }
        } else if ( init.isAvailable ) {
            const mode = getEditModeFromUrl( url );
            if ( mode ) {
                showWikitextWelcome = false;
                trackActivateStart( {
                    type: section === null ? 'page' : 'section',
                    mechanism: ( section === 'new' || !mw.config.get( 'wgArticleId' ) ) ? 'url-new' : 'url',
                    mode: mode
                } );
                activateTarget( mode, section );
            } else if (
                init.isVisualAvailable &&
                pageCanLoadEditor &&
                init.isSingleEditTab
            ) {
                // In single edit tab mode we never have an edit tab
                // with accesskey 'v' so create one
                $( document.body ).append(
                    $( '<a>' )
                        .attr( { accesskey: mw.msg( 'accesskey-ca-ve-edit' ), href: veEditUrl } )
                        // Accesskey fires a click event
                        .on( 'click.ve-target', init.onEditTabClick.bind( init, 'visual' ) )
                        .addClass( 'oo-ui-element-hidden' )
                );
            }

            // Add the switch button to WikiEditor on edit pages
            if (
                init.isVisualAvailable &&
                isEditPage &&
                $( '#wpTextbox1' ).length
            ) {
                mw.loader.load( 'ext.visualEditor.switching' );
                mw.hook( 'wikiEditor.toolbarReady' ).add( ( $textarea ) => {
                    mw.loader.using( 'ext.visualEditor.switching' ).done( () => {
                        const showPopup = url.searchParams.has( 'veswitched' ) && !mw.user.options.get( 'visualeditor-hidesourceswitchpopup' ),
                            toolFactory = new OO.ui.ToolFactory(),
                            toolGroupFactory = new OO.ui.ToolGroupFactory();

                        toolFactory.register( mw.libs.ve.MWEditModeVisualTool );
                        toolFactory.register( mw.libs.ve.MWEditModeSourceTool );
                        const switchToolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory, {
                            classes: [ 've-init-mw-editSwitch' ]
                        } );

                        switchToolbar.on( 'switchEditor', ( m ) => {
                            if ( m === 'visual' ) {
                                $( '#wpTextbox1' ).trigger( 'wikiEditor-switching-visualeditor' );
                                init.activateVe( 'visual' );
                            }
                        } );

                        switchToolbar.setup( [ {
                            name: 'editMode',
                            type: 'list',
                            icon: 'edit',
                            title: mw.msg( 'visualeditor-mweditmode-tooltip' ),
                            label: mw.msg( 'visualeditor-mweditmode-tooltip' ),
                            invisibleLabel: true,
                            include: [ 'editModeVisual', 'editModeSource' ]
                        } ] );

                        const popup = new mw.libs.ve.SwitchPopupWidget( 'source' );

                        switchToolbar.tools.editModeVisual.toolGroup.$element.append( popup.$element );
                        switchToolbar.emit( 'updateState' );

                        $textarea.wikiEditor( 'addToToolbar', {
                            section: 'secondary',
                            group: 'default',
                            tools: {
                                veEditSwitch: {
                                    type: 'element',
                                    element: switchToolbar.$element
                                }
                            }
                        } );

                        popup.toggle( showPopup );

                        // Duplicate of this code in ve.init.mw.DesktopArticleTarget.js
                        // eslint-disable-next-line no-jquery/no-class-state
                        if ( $( '#ca-edit' ).hasClass( 'visualeditor-showtabdialog' ) ) {
                            $( '#ca-edit' ).removeClass( 'visualeditor-showtabdialog' );
                            // Set up a temporary window manager
                            const windowManager = new OO.ui.WindowManager();
                            $( OO.ui.getTeleportTarget() ).append( windowManager.$element );
                            const editingTabDialog = new mw.libs.ve.EditingTabDialog();
                            windowManager.addWindows( [ editingTabDialog ] );
                            windowManager.openWindow( editingTabDialog )
                                .closed.then( ( data ) => {
                                    // Detach the temporary window manager
                                    windowManager.destroy();

                                    if ( data && data.action === 'prefer-ve' ) {
                                        location.href = veEditUrl;
                                    } else if ( data && data.action === 'multi-tab' ) {
                                        location.reload();
                                    }
                                } );
                        }
                    } );
                } );

                // Remember that the user wanted wikitext, at least this time
                mw.libs.ve.setEditorPreference( 'wikitext' );

                // If the user has loaded WikiEditor, clear any auto-save state they
                // may have from a previous VE session
                // We don't have access to the VE session storage methods, but invalidating
                // the docstate is sufficient to prevent the data from being used.
                mw.storage.session.remove( 've-docstate' );
            }

            init.setupEditLinks();
        }

        if (
            pageCanLoadEditor &&
            showWikitextWelcome &&
            // At least one editor is available (T201928)
            ( init.isVisualAvailable || init.isWikitextAvailable || $( '#wpTextbox1' ).length ) &&
            isEditPage &&
            init.shouldShowWelcomeDialog() &&
            // Not on protected pages
            pageIsProbablyEditable
        ) {
            mw.loader.using( 'ext.visualEditor.welcome' ).done( () => {
                // Check shouldShowWelcomeDialog() again: any code that might have called
                // stopShowingWelcomeDialog() wouldn't have had an opportunity to do that
                // yet by the first time we checked
                if ( !init.shouldShowWelcomeDialog() ) {
                    return;
                }
                const windowManager = new OO.ui.WindowManager();
                const welcomeDialog = new mw.libs.ve.WelcomeDialog();
                $( OO.ui.getTeleportTarget() ).append( windowManager.$element );
                windowManager.addWindows( [ welcomeDialog ] );
                windowManager.openWindow(
                    welcomeDialog,
                    {
                        switchable: init.isVisualAvailable,
                        editor: 'source'
                    }
                )
                    .closed.then( ( data ) => {
                        windowManager.destroy();
                        if ( data && data.action === 'switch-ve' ) {
                            init.activateVe( 'visual' );
                        }
                    } );

                init.stopShowingWelcomeDialog();
            } );
        }

        if ( url.searchParams.has( 'venotify' ) ) {
            url.searchParams.delete( 'venotify' );
            // Get rid of the ?venotify= from the URL
            history.replaceState( null, '', url );
        }
    } );
}() );