wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
src/mobile.init/editor.js

Summary

Maintainability
D
2 days
Test Coverage
/* global $ */
const M = require( '../mobile.startup/moduleLoaderSingleton' ),
    util = require( '../mobile.startup/util' ),
    editorLoadingOverlay = require( './editorLoadingOverlay' ),
    OverlayManager = require( '../mobile.startup/OverlayManager' ),
    // #ca-edit, .mw-editsection are standard MediaWiki elements
    // .edit-link can be added to links anywhere to trigger the editor (e.g. MobileFrontend
    // user page creation CTA, edit-full-page overflow menu item)
    // Links in content are handled separately to allow reloading the content (T324686)
    $editTab = $( '#ca-edit, #ca-editsource, #ca-viewsource, #ca-ve-edit, #ca-ve-create, #ca-createsource' ),
    hasTwoEditIcons = $editTab.length > 1,
    EDITSECTION_SELECTOR = '.mw-editsection a, .edit-link',
    user = mw.user,
    CtaDrawer = require( '../mobile.startup/CtaDrawer' ),
    veConfig = mw.config.get( 'wgVisualEditorConfig' ),
    editorPath = /^\/editor\/(\d+|T-\d+|all)$/;

let editorOverride = null;

/**
 * Event handler for edit link clicks. Will prevent default link
 * behaviour and will not allow propagation
 *
 * @method
 * @ignore
 * @param {HTMLElement} elem
 * @param {jQuery.Event} ev
 * @param {Router} router
 */
function onEditLinkClick( elem, ev, router ) {
    let section;
    if ( $( EDITSECTION_SELECTOR ).length === 0 ) {
        // If section edit links are not available, the only edit link
        // should allow editing the whole page (T232170)
        section = 'all';
    } else {
        section = mw.util.getParamValue( 'section', elem.href ) || 'all';
    }
    // Don't do anything for section edit links for different pages (transcluded)
    if ( mw.config.get( 'wgPageName' ) !== mw.util.getParamValue( 'title', elem.href ) ) {
        return;
    }
    if ( hasTwoEditIcons ) {
        if ( elem.id === 'ca-ve-edit' || elem.id === 'ca-ve-create' ) {
            // "Edit" tab loads the visual editor
            editorOverride = 'VisualEditor';
        } else if ( elem.id === 'ca-editsource' || elem.id === 'ca-createsource' ) {
            // "Edit source" tab loads the source editor
            editorOverride = 'SourceEditor';
        } else {
            // Any other edit links (e.g. for sections) load the preferred editor
        }
    }
    router.navigate( '#/editor/' + section );
    // DO NOT USE stopPropagation or you'll break click tracking in WikimediaEvents
    // You DO NOT NEED to
    // prevent folding section when clicking Edit by stopping propagation
    // as this is a concern of the Toggler class and taken care of by inspecting
    // !ev.target.href (see Toggler.js)
    // avoid navigating to ?action=edit
    ev.preventDefault();
}

/**
 * Retrieve the user's preferred editor setting. If none is set, return the default
 * editor for this wiki.
 *
 * @method
 * @ignore
 * @return {string} Either 'VisualEditor' or 'SourceEditor'
 */
function getPreferredEditor() {
    if ( editorOverride ) {
        // Temporary override, set via the URL for this request
        // or by clicking the chosen mode when both tabs are shown
        return editorOverride;
    }
    const preferredEditor = mw.user.options.get( 'mobile-editor' ) || mw.storage.get( 'preferredEditor' );
    if ( preferredEditor ) {
        return preferredEditor;
    }
    switch ( mw.config.get( 'wgMFDefaultEditor' ) ) {
        case 'source':
            return 'SourceEditor';
        case 'visual':
            return 'VisualEditor';
        case 'preference':
            // First check if the user has actually used the desktop editor.
            // This is done hackily by checking if they have the preference
            // set to suppress the welcome dialog or user education popups. (T261423)
            if ( mw.user.options.get( 'visualeditor-hidebetawelcome' ) || mw.user.options.get( 'visualeditor-hideusered' ) ) {
                return mw.user.options.get( 'visualeditor-editor' ) === 'visualeditor' ? 'VisualEditor' : 'SourceEditor';
            } else {
                // When there is no desktop preference, we use MFFallbackEditor.
                return mw.config.get( 'wgMFFallbackEditor' ) === 'visual' ? 'VisualEditor' : 'SourceEditor';
            }
    }
    // In the event of misconfiguration, fall back to source
    return 'SourceEditor';
}

/**
 * Initialize the edit button so that it launches the editor interface when clicked.
 *
 * @method
 * @ignore
 * @param {Page} page The page to edit.
 * @param {Skin} skin
 * @param {module:mobile.startup/PageHTMLParser} currentPageHTMLParser
 * @param {Router} router
 */
function setupEditor( page, skin, currentPageHTMLParser, router ) {
    const
        overlayManager = OverlayManager.getSingleton(),
        isNewPage = page.id === 0;

    $editTab.add( '.edit-link' ).on( 'click.mfeditlink', function ( ev ) {
        onEditLinkClick( this, ev, overlayManager.router );
    } );
    mw.hook( 'wikipage.content' ).add( function ( $content ) {
        // make sure that any .edit-link links in here don't get double-handled
        $content.find( EDITSECTION_SELECTOR ).off( 'click.mfeditlink' ).on( 'click.mfeditlink', function ( ev ) {
            onEditLinkClick( this, ev, overlayManager.router );
        } );
    } );

    overlayManager.add( editorPath, function ( sectionId ) {
        const
            scrollTop = window.pageYOffset,
            $contentText = $( '#mw-content-text' ),
            url = new URL( location.href ),
            editorOptions = {
                overlayManager: overlayManager,
                currentPageHTMLParser: currentPageHTMLParser,
                fakeScroll: 0,
                api: new mw.Api(),
                licenseMsg: skin.getLicenseMsg(),
                title: page.title,
                titleObj: page.titleObj,
                isAnon: user.isAnon(),
                isNewPage: isNewPage,
                oldId: mw.util.getParamValue( 'oldid' ),
                contentLang: $contentText.attr( 'lang' ),
                contentDir: $contentText.attr( 'dir' ),
                // Arrange preload content if we're on a page with those URL parameters
                preload: url.searchParams.get( 'preload' ),
                preloadparams: mw.util.getArrayParam( 'preloadparams', url.searchParams ),
                editintro: url.searchParams.get( 'editintro' )
            },
            visualAbortPromise = $.Deferred();

        const animationDelayDeferred = util.Deferred();

        let abortableDataPromise, overlayPromise,
            initMechanism = mw.util.getParamValue( 'redlink' ) ? 'new' : 'click';

        if ( sectionId !== 'all' ) {
            editorOptions.sectionId = page.isWikiText() ? sectionId : undefined;
        }

        function showLoading() {
            let $sectionTop, fakeScroll, enableVisualSectionEditing;

            $( document.body ).addClass( 've-loading' );

            const $page = $( '#mw-mf-page-center' );
            const $content = $( '#content' );
            if ( sectionId === '0' || sectionId === 'all' ) {
                $sectionTop = $( '#bodyContent' );
            } else {
                $sectionTop = $(
                    // ends with section=N
                    'a[href$="section=' + sectionId + '"],' +
                    // contains section=N&...
                    'a[href*="section=' + sectionId + '&"]'
                ).closest( '.mw-heading, h1, h2, h3, h4, h5, h6' );
                // When loading on action=edit URLs, there is no page content
                if ( !$sectionTop.length ) {
                    $sectionTop = $( '#bodyContent' );
                }
            }
            // Pretend that we didn't just scroll the page to the top.
            $page.prop( 'scrollTop', scrollTop );
            // Then, pretend that we're scrolling to the position of the clicked heading.
            fakeScroll = $sectionTop[0].getBoundingClientRect().top;
            // Adjust for height of the toolbar.
            fakeScroll -= 48;
            if ( shouldLoadVisualEditor() ) {
                enableVisualSectionEditing = veConfig.enableVisualSectionEditing === true ||
                    // === ve.init.mw.MobileArticleTarget.static.trackingName
                    veConfig.enableVisualSectionEditing === 'mobile';
                if ( sectionId === '0' || sectionId === 'all' || enableVisualSectionEditing ) {
                    // Adjust for surface padding. Only needed if we're at the beginning of the doc.
                    fakeScroll -= 16;
                }
            } else {
                if ( sectionId === '0' || sectionId === 'all' ) {
                    fakeScroll -= 16;
                }
            }
            $content.css( {
                // Use transform instead of scroll for smoother animation (via CSS transitions).
                transform: 'translate( 0, ' + -fakeScroll + 'px )',
                // If the clicked heading is near the end of the page, we might need to insert
                // some extra space to allow us to scroll "beyond the end" of the page.
                'padding-bottom': '+=' + fakeScroll,
                'margin-bottom': '-=' + fakeScroll
            } );
            editorOptions.fakeScroll = fakeScroll;
            setTimeout( animationDelayDeferred.resolve, 500 );
        }

        function clearLoading() {
            if ( abortableDataPromise && abortableDataPromise.abort ) {
                abortableDataPromise.abort();
            }

            $( '#content' ).css( {
                transform: '',
                'padding-bottom': '',
                'margin-bottom': ''
            } );

            $( document.body ).removeClass( 've-loading' );
        }

        function loadBasicEditor() {
            // Note that this option was used when logging a wikitext init later
            initMechanism = 'tooslow';

            // This restarts the loading (whether it was aborted when loading the code or the data)
            visualAbortPromise.reject();
            if ( abortableDataPromise && abortableDataPromise.abort ) {
                abortableDataPromise.abort();
            }
        }

        /**
         * Log init event to edit schema.
         * Need to log this from outside the Overlay object because that module
         * won't have loaded yet.
         *
         * @private
         * @ignore
         * @param {string} editor name e.g. wikitext or visualeditor
         * @method
         */
        function logInit( editor ) {
            mw.track( 'editAttemptStep', {
                action: 'init',
                type: 'section',
                mechanism: initMechanism,
                integration: 'page',
                /* eslint-disable camelcase */
                editor_interface: editor
                /* eslint-enable camelcase */
            } );
        }

        /**
         * Check whether VisualEditor should be loaded
         *
         * @private
         * @ignore
         * @method
         * @return {bool}
         */
        function shouldLoadVisualEditor() {
            const preferredEditor = getPreferredEditor();

            return page.isVESourceAvailable() || (
                page.isVEVisualAvailable() &&
                // If the user prefers visual mode or the user has no preference and
                // the visual mode is the default editor for this wiki
                preferredEditor === 'VisualEditor'
            );
        }

        /**
         * Load source editor
         *
         * @ignore
         * @method
         * @return {jQuery.Promise} Promise resolved with the editor overlay
         * @fires mobileFrontend.editorOpening
         */
        function loadSourceEditor() {
            logInit( 'wikitext' );
            // Inform other interested code that we're loading the editor
            /**
             * Internal for use in GrowthExperiments only.
             *
             * @event ~'mobileFrontend.editorOpening'
             * @memberof Hooks
             */
            mw.hook( 'mobileFrontend.editorOpening' ).fire();

            return mw.loader.using( 'mobile.editor.overlay' ).then( function () {
                const SourceEditorOverlay = M.require( 'mobile.editor.overlay/SourceEditorOverlay' );
                return new SourceEditorOverlay( editorOptions );
            } );
        }

        /**
         * Load visual editor. If it fails to load for any reason, load the source editor instead.
         *
         * @private
         * @ignore
         * @method
         * @return {jQuery.Promise} Promise resolved with the editor overlay
         */
        function loadVisualEditorMaybe() {
            logInit( 'visualeditor' );
            // Inform other interested code that we're loading the editor
            /**
             * Internal for use in GrowthExperiments only.
             *
             * @event ~'mobileFrontend.editorOpening'
             * @memberof Hooks
             */
            mw.hook( 'mobileFrontend.editorOpening' ).fire();

            editorOptions.mode = mw.config.get( 'wgMFEnableVEWikitextEditor' ) && getPreferredEditor() === 'SourceEditor' ?
                'source' :
                'visual';
            editorOptions.dataPromise = mw.loader.using( 'ext.visualEditor.targetLoader' ).then( function () {
                abortableDataPromise = mw.libs.ve.targetLoader.requestPageData(
                    editorOptions.mode,
                    editorOptions.titleObj.getPrefixedDb(),
                    {
                        sessionStore: true,
                        section: editorOptions.sectionId === undefined ?
                            null : editorOptions.sectionId,
                        oldId: editorOptions.oldId || undefined,
                        preload: editorOptions.preload,
                        preloadparams: editorOptions.preloadparams,
                        editintro: editorOptions.editintro,
                        // Should be ve.init.mw.MobileArticleTarget.static.trackingName,
                        // but the class hasn't loaded yet.
                        targetName: 'mobile'
                    } );
                return abortableDataPromise;
            } );

            const visualLoadingPromise = mw.loader.using( 'ext.visualEditor.targetLoader' )
                .then( function () {
                    // Load 'mobile.editor.overlay' separately, so that if we fall back to basic
                    // editor, we can display it without waiting for the visual code
                    return mw.loader.using( 'mobile.editor.overlay' ).then( function () {
                        mw.libs.ve.targetLoader.addPlugin( 'ext.visualEditor.mobileArticleTarget' );
                        if ( mw.config.get( 'wgMFEnableVEWikitextEditor' ) ) {
                            // Target loader only loads wikitext editor if the desktop
                            // preference is set.
                            // TODO: Have a cleaner API for this instead of duplicating
                            // the module name here.
                            mw.libs.ve.targetLoader.addPlugin( 'ext.visualEditor.mwwikitext' );
                        }
                        return mw.libs.ve.targetLoader.loadModules( editorOptions.mode );
                    } );
                } );

            // Continue when loading is completed or aborted
            const visualPromise = $.Deferred();
            visualLoadingPromise.then( visualPromise.resolve, visualPromise.reject );
            visualAbortPromise.then( visualPromise.reject, visualPromise.reject );

            return visualPromise
                .then( function () {
                    const VisualEditorOverlay = M.require( 'mobile.editor.overlay/VisualEditorOverlay' ),
                        SourceEditorOverlay = M.require( 'mobile.editor.overlay/SourceEditorOverlay' );
                    editorOptions.SourceEditorOverlay = SourceEditorOverlay;
                    return new VisualEditorOverlay( editorOptions );
                }, function () {
                    return loadSourceEditor();
                } );
        }

        // showLoading() has to run after the overlay has opened, which disables page scrolling.
        // clearLoading() has to run after the loading overlay is hidden in any way
        // (either when loading is aborted, or when the editor overlay is shown instead).
        const loadingOverlay = editorLoadingOverlay(
            showLoading, clearLoading, shouldLoadVisualEditor() ? loadBasicEditor : null
        );

        if ( shouldLoadVisualEditor() ) {
            overlayPromise = loadVisualEditorMaybe();
        } else {
            overlayPromise = loadSourceEditor();
        }

        // Wait for the scroll animation to finish before we show the editor overlay
        util.Promise.all( [ overlayPromise, animationDelayDeferred ] ).then( function ( overlay ) {
            // Wait for the data to load before we show the editor overlay
            overlay.getLoadingPromise().catch( function ( error ) {
                if ( visualAbortPromise.state() === 'rejected' ) {
                    return loadSourceEditor().then( function ( sourceOverlay ) {
                        overlay = sourceOverlay;
                        return overlay.getLoadingPromise();
                    } );
                }
                return $.Deferred().reject( error ).promise();
            } ).then( function () {
                // Make sure the user did not close the loading overlay while we were waiting
                const overlayData = overlayManager.stack[0];
                if ( !overlayData || overlayData.overlay !== loadingOverlay ) {
                    return;
                }
                // Show the editor!
                overlayManager.replaceCurrent( overlay );
            }, function ( error, apiResponse ) {
                // Could not load the editor.
                overlayManager.router.back();
                if ( error.show ) {
                    // Probably a blockMessageDrawer returned because the user is blocked.
                    document.body.appendChild( error.$el[ 0 ] );
                    error.show();
                } else if ( apiResponse ) {
                    mw.notify( editorOptions.api.getErrorMessage( apiResponse ) );
                } else {
                    mw.notify( mw.msg( 'mobile-frontend-editor-error-loading' ) );
                }
            } );
        } );

        // Reset the temporary override for the next load
        editorOverride = null;

        return loadingOverlay;
    } );

    $( '#ca-edit a, a#ca-edit, #ca-editsource a, a#ca-editsource' ).prop( 'href', function ( i, href ) {
        const editUrl = new URL( href, location.href );
        // By default the editor opens section 0 (lead section), rather than the whole article.
        // This might be changed in the future (T210659).
        editUrl.searchParams.set( 'section', '0' );
        return editUrl.toString();
    } );

    // We use wgAction instead of getParamValue('action') as the former can be
    // overridden by hooks to stop the editor loading automatically.
    if ( !router.getPath() && ( mw.util.getParamValue( 'veaction' ) || mw.config.get( 'wgAction' ) === 'edit' ) ) {
        if ( mw.util.getParamValue( 'veaction' ) === 'edit' ) {
            editorOverride = 'VisualEditor';
        } else if ( mw.util.getParamValue( 'veaction' ) === 'editsource' ) {
            editorOverride = 'SourceEditor';
        }
        // else: action=edit, for which we allow the default to take effect
        const fragment = '#/editor/' + ( mw.util.getParamValue( 'section' ) || ( mw.config.get( 'wgAction' ) === 'edit' ? 'all' : '0' ) );
        // eslint-disable-next-line no-restricted-properties
        if ( window.history && history.pushState ) {
            // We're reformatting the action=edit URL into a view URL and
            // replacing it into the history, and then will fall through to
            // router.navigate which will move us to the editing URL for the
            // mobile site. We do this because the editor overlay deeply
            // expects to have been opened on top of an actual page, and e.g.
            // closing the editor via the X will produce unexpected behavior
            // otherwise.
            const url = new URL( location.href );
            url.searchParams.delete( 'action' );
            url.searchParams.delete( 'veaction' );
            url.searchParams.delete( 'section' );
            history.replaceState( null, document.title, url );
        }
        util.docReady( function () {
            router.navigate( fragment );
        } );
    }
}

/**
 * Hide any section id icons in the page. This will not hide the edit icon in the page action
 * menu.
 *
 * @method
 * @ignore
 * @param {module:mobile.startup/PageHTMLParser} currentPageHTMLParser
 */
function hideSectionEditIcons( currentPageHTMLParser ) {
    currentPageHTMLParser.$el.find( '.mw-editsection' ).hide();
}

/**
 * Show a drawer with log in / sign up buttons.
 *
 * @method
 * @ignore
 * @param {Router} router
 */
function bindEditLinksLoginDrawer( router ) {
    let drawer;
    function showLoginDrawer() {
        if ( !drawer ) {
            drawer = new CtaDrawer( {
                content: mw.msg( 'mobile-frontend-editor-disabled-anon' ),
                signupQueryParams: {
                    warning: 'mobile-frontend-watchlist-signup-action'
                }
            } );
            document.body.appendChild( drawer.$el[ 0 ] );
        }
        drawer.show();
    }
    $editTab.on( 'click', function ( ev ) {
        showLoginDrawer();
        ev.preventDefault();
    } );
    mw.hook( 'wikipage.content' ).add( function ( $content ) {
        $content.find( EDITSECTION_SELECTOR ).on( 'click', function ( ev ) {
            showLoginDrawer();
            ev.preventDefault();
        } );
    } );
    router.addRoute( editorPath, function () {
        showLoginDrawer();
    } );
    router.checkRoute();
}

/**
 * Setup the editor if the user can edit the page otherwise show a sorry toast.
 *
 * @method
 * @ignore
 * @param {Page} currentPage
 * @param {module:mobile.startup/PageHTMLParser} currentPageHTMLParser
 * @param {Skin} skin
 * @param {Router} router
 */
function init( currentPage, currentPageHTMLParser, skin, router ) {
    let editErrorMessage, editRestrictions;
    // see: https://www.mediawiki.org/wiki/Manual:Interface/JavaScript#Page-specific
    const isReadOnly = mw.config.get( 'wgMinervaReadOnly' );
    const isEditable = !isReadOnly && mw.config.get( 'wgIsProbablyEditable' );

    if ( isEditable ) {
        // Edit button updated in setupEditor.
        setupEditor( currentPage, skin, currentPageHTMLParser, router );
    } else {
        hideSectionEditIcons( currentPageHTMLParser );
        editRestrictions = mw.config.get( 'wgRestrictionEdit' );
        if ( mw.user.isAnon() && Array.isArray( editRestrictions ) && !editRestrictions.length ) {
            bindEditLinksLoginDrawer( router );
        } else {
            const $link = $( '<a>' ).attr( 'href', mw.util.getUrl( mw.config.get( 'wgPageName' ), { action: 'edit' } ) );
            editErrorMessage = isReadOnly ? mw.msg( 'apierror-readonly' ) : mw.message( 'mobile-frontend-editor-disabled', $link ).parseDom();
            bindEditLinksSorryToast( editErrorMessage, router );
        }
    }
}

/**
 * Wire up events that ensure we
 * show a toast message with sincere condolences when user navigates to
 * #/editor or clicks on an edit button
 *
 * @method
 * @ignore
 * @param {string} msg Message for sorry message
 * @param {Router} router
 */
function bindEditLinksSorryToast( msg, router ) {
    $editTab.on( 'click', function ( ev ) {
        mw.notify( msg );
        ev.preventDefault();
    } );
    mw.hook( 'wikipage.content' ).add( function ( $content ) {
        $content.find( EDITSECTION_SELECTOR ).on( 'click', function ( ev ) {
            mw.notify( msg );
            ev.preventDefault();
        } );
    } );
    router.addRoute( editorPath, function () {
        mw.notify( msg );
    } );
    router.checkRoute();
}

module.exports = function ( currentPage, currentPageHTMLParser, skin ) {
    const router = __non_webpack_require__( 'mediawiki.router' );

    if ( currentPage.inNamespace( 'file' ) && currentPage.id === 0 ) {
        // Is a new file page (enable upload image only) T60311
        bindEditLinksSorryToast( mw.msg( 'mobile-frontend-editor-uploadenable' ), router );
    } else {
        // Edit button is currently hidden. A call to init() will update it as needed.
        init( currentPage, currentPageHTMLParser, skin, router );
    }
};