wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/init/targets/ve.init.mw.Target.js

Summary

Maintainability
D
1 day
Test Coverage
/*!
 * VisualEditor MediaWiki Initialization Target class.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Initialization MediaWiki target.
 *
 * @class
 * @extends ve.init.Target
 *
 * @constructor
 * @param {Object} config
 * @param {string[]} [config.surfaceClasses=[]] Surface classes to apply
 */
ve.init.mw.Target = function VeInitMwTarget( config ) {
    this.surfaceClasses = config.surfaceClasses || [];

    // Parent constructor
    ve.init.mw.Target.super.call( this, config );

    this.active = false;
    this.pageName = mw.config.get( 'wgRelevantPageName' );
    this.recovered = false;
    this.fromEditedState = false;
    this.originalHtml = null;

    // Initialization
    this.$element.addClass( 've-init-mw-target' );
};

/* Inheritance */

OO.inheritClass( ve.init.mw.Target, ve.init.Target );

/* Static Properties */

/**
 * Symbolic name for this target class.
 *
 * @static
 * @property {string}
 * @inheritable
 */
ve.init.mw.Target.static.name = null;

ve.init.mw.Target.static.toolbarGroups = [
    {
        name: 'history',
        include: [ { group: 'history' } ]
    },
    {
        name: 'format',
        type: 'menu',
        title: OO.ui.deferMsg( 'visualeditor-toolbar-format-tooltip' ),
        include: [ { group: 'format' } ],
        promote: [ 'paragraph' ],
        demote: [ 'preformatted', 'blockquote', 'heading1' ]
    },
    {
        name: 'style',
        type: 'list',
        icon: 'textStyle',
        title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
        label: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
        invisibleLabel: true,
        include: [ { group: 'textStyle' } ],
        forceExpand: [ 'bold', 'italic', 'clear' ],
        promote: [ 'bold', 'italic', 'superscript', 'subscript' ],
        demote: [ 'clear' ]
    },
    {
        name: 'link',
        include: [ 'link' ]
    },
    // Placeholder for reference tools (e.g. Cite and/or Citoid)
    {
        name: 'reference'
    },
    {
        name: 'structure',
        type: 'list',
        icon: 'listBullet',
        title: OO.ui.deferMsg( 'visualeditor-toolbar-structure' ),
        label: OO.ui.deferMsg( 'visualeditor-toolbar-structure' ),
        invisibleLabel: true,
        include: [ { group: 'structure' } ],
        demote: [ 'outdent', 'indent' ]
    },
    {
        name: 'insert',
        label: OO.ui.deferMsg( 'visualeditor-toolbar-insert' ),
        title: OO.ui.deferMsg( 'visualeditor-toolbar-insert' ),
        narrowConfig: {
            invisibleLabel: true,
            icon: 'add'
        },
        include: '*',
        forceExpand: [ 'media', 'transclusion', 'insertTable' ],
        promote: [ 'media', 'transclusion', 'insertTable' ]
    },
    {
        name: 'specialCharacter',
        include: [ 'specialCharacter' ]
    }
];

ve.init.mw.Target.static.importRules = ve.copy( ve.init.mw.Target.static.importRules );

ve.init.mw.Target.static.importRules.external.removeOriginalDomElements = true;

ve.init.mw.Target.static.importRules.external.blacklist = ve.extendObject( {
    // Annotations
    'textStyle/underline': true,
    'meta/language': true,
    'textStyle/datetime': true,
    'link/mwExternal': !mw.config.get( 'wgVisualEditorConfig' ).allowExternalLinkPaste,
    // Node
    article: true,
    section: true
}, ve.init.mw.Target.static.importRules.external.blacklist );

ve.init.mw.Target.static.importRules.external.htmlBlacklist.remove = ve.extendObject( {
    // TODO: Create a plugin system for extending the blacklist, so this code
    // can be moved to the Cite extension.
    // Remove reference numbers copied from MW read mode (T150418)
    'sup.reference:not( [typeof] )': true,
    // ...sometimes we need a looser match if the HTML has been mangled
    // in a third-party editor e.g. LibreOffice (T232461)
    'a[ href *= "#cite_note" ]': true
}, ve.init.mw.Target.static.importRules.external.htmlBlacklist.remove );

// This is required to prevent an invalid insertion (as mwHeading can only be at the root) (T339155)
// TODO: This should be handled by the DM based on ve.dm.MWHeadingNode.static.suggestedParentNodeTypes,
// rather than just throwing an exception.
// This would also not prevent pasting from a VE standalone editor as that is considered
// an internal paste.
ve.init.mw.Target.static.importRules.external.htmlBlacklist.unwrap = ve.extendObject( {
    'li h1, li h2, li h3, li h4, li h5, li h6': true,
    'blockquote h1, blockquote h2, blockquote h3, blockquote h4, blockquote h5, blockquote h6': true
}, ve.init.mw.Target.static.importRules.external.htmlBlacklist.unwrap );

/**
 * Type of integration. Used for event tracking.
 *
 * @static
 * @property {string}
 * @inheritable
 */
ve.init.mw.Target.static.integrationType = null;

/**
 * Type of platform. Used for event tracking.
 *
 * @static
 * @property {string}
 * @inheritable
 */
ve.init.mw.Target.static.platformType = null;

/* Static Methods */

/**
 * Fix the base URL from Parsoid if necessary.
 *
 * Absolutizes the base URL if it's relative, and sets a base URL based on wgArticlePath
 * if there was no base URL at all.
 *
 * @param {HTMLDocument} doc Parsoid document
 */
ve.init.mw.Target.static.fixBase = function ( doc ) {
    ve.fixBase( doc, document, ve.resolveUrl(
        // Don't replace $1 with the page name, because that'll break if
        // the page name contains a slash
        mw.config.get( 'wgArticlePath' ).replace( '$1', '' ),
        document
    ) );
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.static.createModelFromDom = function ( doc, mode, options ) {
    const conf = mw.config.get( 'wgVisualEditor' );

    options = ve.extendObject( {
        lang: conf.pageLanguageCode,
        dir: conf.pageLanguageDir
    }, options );

    // Parent method
    return ve.init.mw.Target.super.static.createModelFromDom.call( this, doc, mode, options );
};

// Deprecated alias
ve.init.mw.Target.prototype.createModelFromDom = function () {
    return this.constructor.static.createModelFromDom.apply( this.constructor.static, arguments );
};

/**
 * @inheritdoc
 * @param {string} documentString
 * @param {string} mode
 * @param {string|null} [section] Section. Use null to unwrap all sections.
 * @param {boolean} [onlySection=false] Only return the requested section, otherwise returns the
 *  whole document with just the requested section still wrapped (visual mode only).
 * @return {HTMLDocument|string} HTML document, or document string (source mode)
 */
ve.init.mw.Target.static.parseDocument = function ( documentString, mode, section, onlySection ) {
    let doc;
    if ( mode === 'source' ) {
        // Parent method
        doc = ve.init.mw.Target.super.static.parseDocument.call( this, documentString, mode );
    } else {
        doc = ve.createDocumentFromHtml( documentString );
        if ( section !== undefined ) {
            if ( onlySection ) {
                const sectionNode = doc.body.querySelector( '[data-mw-section-id="' + section + '"]' );
                doc.body.innerHTML = '';
                if ( sectionNode ) {
                    doc.body.appendChild( sectionNode );
                }
            } else {
                // Strip Parsoid sections
                mw.libs.ve.unwrapParsoidSections( doc.body, section );
            }
        }
        // Strip legacy IDs, for example in section headings
        mw.libs.ve.stripParsoidFallbackIds( doc.body );
        // Re-duplicate deduplicated TemplateStyles, for correct rendering when editing a section or
        // when templates are removed during the edit
        mw.libs.ve.reduplicateStyles( doc.body );
        // Fix relative or missing base URL if needed
        this.fixBase( doc );
        // Test: Remove tags injected by plugins during parse (T298147)
        Array.prototype.forEach.call( doc.querySelectorAll( 'script' ), ( element ) => {
            function truncate( text, l ) {
                return text.length > l ? text.slice( 0, l ) + '…' : text;
            }
            const errorMessage = 'DOM content matching deny list found during parse:\n' + truncate( element.outerHTML, 100 ) +
                '\nContext:\n' + truncate( element.parentNode.outerHTML, 200 );
            mw.log.error( errorMessage );
            const err = new Error( errorMessage );
            err.name = 'VeDomDenyListWarning';
            mw.errorLogger.logError( err, 'error.visualeditor' );
            element.parentNode.removeChild( element );
        } );
    }

    return doc;
};

/* Methods */

/**
 * Handle both DOM and modules being loaded and ready.
 *
 * @param {HTMLDocument|string} doc HTML document or source text
 */
ve.init.mw.Target.prototype.documentReady = function ( doc ) {
    this.setupSurface( doc );
};

/**
 * Once surface is ready, initialize the UI
 *
 * @fires ve.init.Target#surfaceReady
 */
ve.init.mw.Target.prototype.surfaceReady = function () {
    this.emit( 'surfaceReady' );
};

/**
 * @deprecated Moved to mw.libs.ve.targetSaver.getHtml
 * @param {HTMLDocument} newDoc
 * @param {HTMLDocument} [oldDoc]
 * @return {string}
 */
ve.init.mw.Target.prototype.getHtml = function ( newDoc, oldDoc ) {
    OO.ui.warnDeprecation( 've.init.mw.Target#getHtml is deprecated. Use mw.libs.ve.targetSaver.getHtml.' );
    return mw.libs.ve.targetSaver.getHtml( newDoc, oldDoc );
};

/**
 * Track an event
 *
 * @param {string} name Event name
 */
ve.init.mw.Target.prototype.track = function () {};

/**
 * Get a list of CSS classes to be added to surfaces, and target widget surfaces
 *
 * @return {string[]} CSS classes
 */
ve.init.mw.Target.prototype.getSurfaceClasses = function () {
    return this.surfaceClasses;
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.createTargetWidget = function ( config ) {
    return new ve.ui.MWTargetWidget( ve.extendObject( {
        // Reset to visual mode for target widgets
        modes: [ 'visual' ],
        defaultMode: 'visual',
        toolbarGroups: this.toolbarGroups.filter( ( group ) => group.align !== 'after' ),
        surfaceClasses: this.getSurfaceClasses()
    }, config ) );
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.createSurface = function ( dmDoc, config ) {
    if ( config && config.mode === 'source' ) {
        const importRules = ve.copy( this.constructor.static.importRules );
        importRules.all = importRules.all || {};
        // Preserve empty linebreaks on paste in source editor
        importRules.all.keepEmptyContentBranches = true;
        config = this.getSurfaceConfig( ve.extendObject( {}, config, {
            importRules: importRules
        } ) );
        return new ve.ui.MWWikitextSurface( this, dmDoc, config );
    }

    return new ve.ui.MWSurface( this, dmDoc, this.getSurfaceConfig( config ) );
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.getSurfaceConfig = function ( config ) {
    // If we're not asking for a specific mode's config, use the default mode.
    config = ve.extendObject( { mode: this.defaultMode }, config );
    // eslint-disable-next-line mediawiki/class-doc
    return ve.init.mw.Target.super.prototype.getSurfaceConfig.call( this, ve.extendObject( {
        // Provide the wikitext versions of the registries, if we're using source mode
        commandRegistry: config.mode === 'source' ? ve.ui.wikitextCommandRegistry : ve.ui.commandRegistry,
        sequenceRegistry: config.mode === 'source' ? ve.ui.wikitextSequenceRegistry : ve.ui.sequenceRegistry,
        dataTransferHandlerFactory: config.mode === 'source' ? ve.ui.wikitextDataTransferHandlerFactory : ve.ui.dataTransferHandlerFactory,
        classes: this.getSurfaceClasses()
    }, config ) );
};

/**
 * Switch to editing mode.
 *
 * @param {HTMLDocument|string} doc HTML document or source text
 */
ve.init.mw.Target.prototype.setupSurface = function ( doc ) {
    setTimeout( () => {
        // Build model
        this.track( 'trace.convertModelFromDom.enter' );
        const dmDoc = this.constructor.static.createModelFromDom( doc, this.getDefaultMode() );
        this.track( 'trace.convertModelFromDom.exit' );

        // Build DM tree now (otherwise it gets lazily built when building the CE tree)
        this.track( 'trace.buildModelTree.enter' );
        dmDoc.buildNodeTree();
        this.track( 'trace.buildModelTree.exit' );

        setTimeout( () => {
            this.addSurface( dmDoc );
        } );
    } );
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.addSurface = function () {
    // Clear dummy surfaces
    // TODO: Move to DesktopArticleTarget
    this.clearSurfaces();

    // Create ui.Surface (also creates ce.Surface and dm.Surface and builds CE tree)
    this.track( 'trace.createSurface.enter' );
    // Parent method
    const surface = ve.init.mw.Target.super.prototype.addSurface.apply( this, arguments );
    // Add classes specific to surfaces attached directly to the target,
    // as opposed to TargetWidget surfaces
    if ( !surface.inTargetWidget ) {
        surface.$element.addClass( 've-init-mw-target-surface' );
    }
    this.track( 'trace.createSurface.exit' );

    this.setSurface( surface );

    setTimeout( () => {
        // Initialize surface
        this.track( 'trace.initializeSurface.enter' );

        this.active = true;
        // Now that the surface is attached to the document and ready,
        // let it initialize itself
        surface.initialize();

        this.track( 'trace.initializeSurface.exit' );
        this.surfaceReady();
    } );

    return surface;
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.setSurface = function ( surface ) {
    if ( !surface.$element.parent().length ) {
        this.$element.append( surface.$element );
    }

    // Parent method
    ve.init.mw.Target.super.prototype.setSurface.apply( this, arguments );
};

/**
 * Intialise autosave, recovering changes if applicable
 *
 * @param {Object} [config] Configuration options
 * @param {boolean} [config.suppressNotification=false] Don't notify the user if changes are recovered
 * @param {string} [config.docId] Document ID for storage grouping
 * @param {ve.init.SafeStorage} [config.storage] Storage interface
 * @param {number} [config.storageExpiry] Storage expiry time in seconds (optional)
 */
ve.init.mw.Target.prototype.initAutosave = function ( config ) {
    // Old function signature
    // TODO: Remove after fixed downstream
    if ( typeof config === 'boolean' ) {
        config = { suppressNotification: config };
    } else {
        config = config || {};
    }

    const surfaceModel = this.getSurface().getModel();

    if ( config.docId ) {
        surfaceModel.setAutosaveDocId( config.docId );
    }

    if ( config.storage ) {
        surfaceModel.setStorage( config.storage, config.storageExpiry );
    }

    if ( this.recovered ) {
        // Restore auto-saved transactions if document state was recovered
        try {
            surfaceModel.restoreChanges();
            if ( !config.suppressNotification ) {
                ve.init.platform.notify(
                    ve.msg( 'visualeditor-autosave-recovered-text' ),
                    ve.msg( 'visualeditor-autosave-recovered-title' )
                );
            }
        } catch ( e ) {
            mw.log.warn( e );
            ve.init.platform.notify(
                ve.msg( 'visualeditor-autosave-not-recovered-text' ),
                ve.msg( 'visualeditor-autosave-not-recovered-title' ),
                { type: 'error' }
            );
        }
    } else {
        // ...otherwise store this document state for later recovery
        if ( this.fromEditedState ) {
            // Store immediately if the document was previously edited
            // (e.g. in a different mode)
            this.storeDocState( this.originalHtml );
        } else {
            // Only store after the first change if this is an unmodified document
            surfaceModel.once( 'undoStackChange', () => {
                // Check the surface hasn't been destroyed
                if ( this.getSurface() ) {
                    this.storeDocState( this.originalHtml );
                }
            } );
        }
    }
    // Start auto-saving transactions
    surfaceModel.startStoringChanges();
    // TODO: Listen to autosaveFailed event to notify user
};

/**
 * Store a snapshot of the current document state.
 *
 * @param {string} [html] Document HTML, will generate from current state if not provided
 */
ve.init.mw.Target.prototype.storeDocState = function ( html ) {
    const mode = this.getSurface().getMode();
    this.getSurface().getModel().storeDocState( { mode: mode }, html );
};

/**
 * Clear any stored document state
 */
ve.init.mw.Target.prototype.clearDocState = function () {
    if ( this.getSurface() ) {
        this.getSurface().getModel().removeDocStateAndChanges();
    }
};

/**
 * @inheritdoc
 */
ve.init.mw.Target.prototype.teardown = function () {
    // If target is closed cleanly (after save or deliberate close) then remove autosave state
    this.clearDocState();

    // Parent method
    return ve.init.mw.Target.super.prototype.teardown.call( this );
};

/**
 * Refresh our knowledge about the logged-in user.
 *
 * This should be called in response to a user assertion error, to look up
 * the new user name, and update the current user variables in mw.config.
 *
 * @param {ve.dm.Document} [doc] Document to associate with the API request
 * @return {jQuery.Promise} Promise resolved with new username, or null if anonymous
 */
ve.init.mw.Target.prototype.refreshUser = function ( doc ) {
    return this.getContentApi( doc ).get( {
        action: 'query',
        meta: 'userinfo'
    } ).then( ( data ) => {
        const userInfo = data.query && data.query.userinfo;

        if ( userInfo.anon !== undefined ) {
            // New session is an anonymous user
            mw.config.set( {
                // wgUserId is unset for anonymous users, not set to null
                wgUserId: undefined,
                // wgUserName is explicitly set to null for anonymous users,
                // functions like mw.user.isAnon rely on this.
                wgUserName: null
            } );

            // Call this only after clearing wgUserId, otherwise it does nothing
            return mw.user.acquireTempUserName();
        } else {
            // New session is a logged in user (or a temporary user)
            mw.config.set( {
                wgUserId: userInfo.id,
                wgUserName: userInfo.name
            } );

            return mw.user.getName();
        }
    } );
};

/**
 * Get a wikitext fragment from a document
 *
 * @param {ve.dm.Document} doc
 * @param {boolean} [useRevision=true] Whether to use the revision ID + ETag
 * @return {jQuery.Promise} Abortable promise which resolves with a wikitext string
 */
ve.init.mw.Target.prototype.getWikitextFragment = function ( doc, useRevision ) {
    // Shortcut for empty document
    if ( !doc.data.hasContent() ) {
        return ve.createDeferred().resolve( '' );
    }

    const params = {
        action: 'visualeditoredit',
        paction: 'serialize',
        html: mw.libs.ve.targetSaver.getHtml(
            ve.dm.converter.getDomFromModel( doc )
        ),
        page: this.getPageName()
    };

    if ( useRevision === undefined || useRevision ) {
        params.oldid = this.revid;
        params.etag = this.etag;
    }

    const xhr = this.getContentApi( doc ).postWithToken( 'csrf',
        params,
        { contentType: 'multipart/form-data' }
    );

    return xhr.then( ( response ) => {
        if ( response.visualeditoredit ) {
            return response.visualeditoredit.content;
        }
        return ve.createDeferred().reject();
    } ).promise( { abort: xhr.abort } );
};

/**
 * Parse a fragment of wikitext into HTML
 *
 * @param {string} wikitext
 * @param {boolean} pst Perform pre-save transform
 * @param {ve.dm.Document} [doc] Parse for a specific document, defaults to current surface's
 * @return {jQuery.Promise} Abortable promise
 */
ve.init.mw.Target.prototype.parseWikitextFragment = function ( wikitext, pst, doc ) {
    let abortable, aborted;
    const abortedPromise = ve.createDeferred().reject( 'http',
        { textStatus: 'abort', exception: 'abort' } ).promise();

    function abort() {
        aborted = true;
        if ( abortable && abortable.abort ) {
            abortable.abort();
        }
    }

    // Acquire a temporary user username before previewing or diffing, so that signatures and
    // user-related magic words display the temp user instead of IP user in the preview. (T331397)
    let tempUserNamePromise;
    if ( pst ) {
        tempUserNamePromise = mw.user.acquireTempUserName();
    } else {
        tempUserNamePromise = ve.createDeferred().resolve( null );
    }

    return tempUserNamePromise
        .then( () => {
            if ( aborted ) {
                return abortedPromise;
            }
            return ( abortable = this.getContentApi( doc ).post( {
                action: 'visualeditor',
                paction: 'parsefragment',
                page: this.getPageName( doc ),
                wikitext: wikitext,
                pst: pst
            } ) );
        } )
        .promise( { abort: abort } );
};

/**
 * Get the page name associated with a specific document
 *
 * @param {ve.dm.Document} [doc] Document, defaults to current surface's
 * @return {string} Page name
 */
ve.init.mw.Target.prototype.getPageName = function () {
    return this.pageName;
};

/**
 * Get an API object associated with the wiki where the document
 * content is hosted.
 *
 * This would be overridden if editing content on another wiki.
 *
 * @param {ve.dm.Document} [doc] API for a specific document, should default to document of current surface.
 * @param {Object} [options] API options
 * @param {Object} [options.parameters] Default query parameters for all API requests. Defaults
 *  include action=query, format=json, and formatversion=2 if not specified otherwise.
 * @return {mw.Api}
 */
ve.init.mw.Target.prototype.getContentApi = function ( doc, options ) {
    options = options || {};
    options.parameters = ve.extendObject( { formatversion: 2 }, options.parameters );
    return new mw.Api( options );
};

/**
 * Get an API object associated with the local wiki.
 *
 * For example you would always use getLocalApi for actions
 * associated with the current user.
 *
 * @param {Object} [options] API options
 * @return {mw.Api}
 */
ve.init.mw.Target.prototype.getLocalApi = function ( options ) {
    options = options || {};
    options.parameters = ve.extendObject( { formatversion: 2 }, options.parameters );
    return new mw.Api( options );
};