src/mobile.init/editor.js
/* 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 );
}
};