wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/ui/widgets/ve.ui.MWTocWidget.js

Summary

Maintainability
C
1 day
Test Coverage
/*!
 * VisualEditor UserInterface MWTocWidget class.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Creates a ve.ui.MWTocWidget object.
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {ve.ui.Surface} surface
 * @param {Object} [config] Configuration options
 */
ve.ui.MWTocWidget = function VeUiMWTocWidget( surface, config ) {
    // Parent constructor
    ve.ui.MWTocWidget.super.call( this, config );

    // Properties
    this.surface = surface;
    this.doc = surface.getModel().getDocument();
    this.metaList = this.doc.getMetaList();
    // Topic level 0 lives inside of a toc item
    this.rootLength = 0;
    this.initialized = false;
    // Page settings cache
    this.mwTOCForce = false;
    this.mwTOCDisable = false;

    this.$tocList = $( '<ul>' );
    this.$element.addClass( 'toc ve-ui-mwTocWidget ve-ce-focusableNode' ).append(
        $( '<div>' ).addClass( 'toctitle' ).append(
            $( '<h2>' ).text( ve.msg( 'toc' ) )
        ),
        this.$tocList
    ).prop( 'contentEditable', 'false' );

    // Setup toggle link
    mw.hook( 'wikipage.content' ).fire( this.$element );

    // Events
    this.metaList.connect( this, {
        insert: 'onMetaListInsert',
        remove: 'onMetaListRemove'
    } );

    this.buildDebounced = ve.debounce( this.build.bind( this ) );

    this.initFromMetaList();
    this.build();
};

/* Inheritance */

OO.inheritClass( ve.ui.MWTocWidget, OO.ui.Widget );

/**
 * Bound to MetaList insert event to set TOC display options
 *
 * @param {ve.dm.MetaItem} metaItem
 */
ve.ui.MWTocWidget.prototype.onMetaListInsert = function ( metaItem ) {
    // Responsible for adding UI components
    if ( metaItem instanceof ve.dm.MWTOCMetaItem ) {
        const property = metaItem.getAttribute( 'property' );
        if ( property === 'mw:PageProp/forcetoc' ) {
            this.mwTOCForce = true;
        } else if ( property === 'mw:PageProp/notoc' ) {
            this.mwTOCDisable = true;
        }
    }
    this.updateVisibility();
};

/**
 * Bound to MetaList insert event to set TOC display options
 *
 * @param {ve.dm.MetaItem} metaItem
 */
ve.ui.MWTocWidget.prototype.onMetaListRemove = function ( metaItem ) {
    if ( metaItem instanceof ve.dm.MWTOCMetaItem ) {
        const property = metaItem.getAttribute( 'property' );
        if ( property === 'mw:PageProp/forcetoc' ) {
            this.mwTOCForce = false;
        } else if ( property === 'mw:PageProp/notoc' ) {
            this.mwTOCDisable = false;
        }
    }
    this.updateVisibility();
};

/**
 * Initialize TOC based on the presence of magic words
 */
ve.ui.MWTocWidget.prototype.initFromMetaList = function () {
    const items = this.metaList.getItemsInGroup( 'mwTOC' );
    if ( items.length === 0 ) {
        return;
    }
    for ( let i = 0; i < items.length; i++ ) {
        if ( items[ i ] instanceof ve.dm.MWTOCMetaItem ) {
            const property = items[ i ].getAttribute( 'property' );
            if ( property === 'mw:PageProp/forcetoc' ) {
                this.mwTOCForce = true;
            }
            if ( property === 'mw:PageProp/notoc' ) {
                this.mwTOCDisable = true;
            }
        }
    }
    this.updateVisibility();
};

/**
 * Hides or shows the TOC based on page and default settings
 */
ve.ui.MWTocWidget.prototype.updateVisibility = function () {
    // In MediaWiki if `__FORCETOC__` is anywhere TOC is always displayed
    // ... Even if there is a `__NOTOC__` in the article
    this.toggle( !this.mwTOCDisable && ( this.mwTOCForce || this.rootLength >= 3 ) );
};

/**
 * Rebuild TOC on ve.ce.MWHeadingNode teardown or setup
 *
 * Rebuilds on both teardown and setup of a node, so build is debounced
 */
ve.ui.MWTocWidget.prototype.rebuild = function () {
    if ( this.initialized ) {
        // Wait for transactions to process
        this.buildDebounced();
    }
};

/**
 * Update the text content of a specific heading node
 *
 * @param {ve.ce.MWHeadingNode} viewNode Heading node
 */
ve.ui.MWTocWidget.prototype.updateNode = function ( viewNode ) {
    if ( viewNode.$tocText ) {
        viewNode.$tocText.text( viewNode.$element.text() );
    }
};

/**
 * Build TOC from mwHeading dm nodes
 *
 * Based on generateTOC in Linker.php
 */
ve.ui.MWTocWidget.prototype.build = function () {
    const $newTocList = $( '<ul>' ),
        nodes = this.doc.getNodesByType( 'mwHeading', true ),
        surfaceView = this.surface.getView(),
        documentView = surfaceView.getDocument(),
        stack = [],
        url = new URL( location.href );

    function getItemIndex( $el, n ) {
        return $el.children( 'li' ).length + ( n === stack.length - 1 ? 1 : 0 );
    }

    function linkClickHandler( /* heading */ ) {
        surfaceView.focus();
        // TODO: Impement heading scroll
        return false;
    }

    let lastLevel = 0;
    for ( let i = 0, l = nodes.length; i < l; i++ ) {
        const modelNode = nodes[ i ];
        const level = modelNode.getAttribute( 'level' );

        if ( level > lastLevel ) {
            let $list;
            if ( stack.length ) {
                $list = $( '<ul>' );
                stack[ stack.length - 1 ].children().last().append( $list );
            } else {
                $list = $newTocList;
            }
            stack.push( $list );
        } else if ( level < lastLevel ) {
            let levelDiff = lastLevel - level;
            while ( levelDiff > 0 && stack.length > 1 ) {
                stack.pop();
                levelDiff--;
            }
        }

        const tocNumber = stack.map( getItemIndex ).join( '.' );
        const viewNode = documentView.getBranchNodeFromOffset( modelNode.getRange().start );
        url.searchParams.set( 'section', ( i + 1 ).toString() );
        // The following classes are used here:
        // * toclevel-1, toclevel-2, ...
        // * tocsection-1, tocsection-2, ...
        const $item = $( '<li>' ).addClass( 'toclevel-' + stack.length ).addClass( 'tocsection-' + ( i + 1 ) );
        const $link = $( '<a>' ).attr( 'href', url.toString() ).append(
            $( '<span>' ).addClass( 'tocnumber' ).text( tocNumber )
        );
        const $text = $( '<span>' ).addClass( 'toctext' );

        viewNode.$tocText = $text;
        this.updateNode( viewNode );

        stack[ stack.length - 1 ].append( $item.append( $link.append( $text ) ) );
        $link.on( 'click', linkClickHandler.bind( this, viewNode ) );

        lastLevel = level;
    }

    this.$tocList.empty().append( $newTocList.children() );

    if ( nodes.length ) {
        this.rootLength = this.$tocList.children().length;
        const tocBeforeNode = documentView.getBranchNodeFromOffset( nodes[ 0 ].getRange().start );
        tocBeforeNode.$element.before( this.$element );
    } else {
        this.rootLength = 0;
    }

    this.initialized = true;
    this.updateVisibility();
};