wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.page.gallery.js

Summary

Maintainability
C
7 hrs
Test Coverage
/*!
 * Enhance MediaWiki galleries (from the `<gallery>` parser tag).
 *
 * - Toggle gallery captions when focused.
 * - Dynamically resize images to fill horizontal space.
 */
( function () {
    let $galleries,
        bound = false,
        lastWidth = window.innerWidth,
        justifyNeeded = false,
        // Is there a better way to detect a touchscreen? Current check taken from stack overflow.
        isTouchScreen = !!( window.ontouchstart !== undefined ||
            window.DocumentTouch !== undefined && document instanceof window.DocumentTouch
        );

    /**
     * Perform the layout justification.
     *
     * @ignore
     * @this HTMLElement A `ul.mw-gallery-*` element
     */
    function justify() {
        let lastTop,
            rows = [],
            $gallery = $( this );

        $gallery.children( 'li.gallerybox' ).each( function () {
            let $imageDiv, $img, imgWidth, imgHeight, outerWidth, captionWidth,
                // Math.floor, to be paranoid if things are off by 0.00000000001
                top = Math.floor( $( this ).position().top ),
                $this = $( this );

            if ( top !== lastTop ) {
                rows.push( [] );
                lastTop = top;
            }

            $imageDiv = $this.find( 'div.thumb' ).first();
            $img = $imageDiv.find( 'img, video' ).first();
            if ( $img.length && $img[ 0 ].height ) {
                imgHeight = $img[ 0 ].height;
                imgWidth = $img[ 0 ].width;
            } else {
                // If we don't have a real image, get the containing divs width/height.
                // Note that if we do have a real image, using this method will generally
                // give the same answer, but can be different in the case of a very
                // narrow image where extra padding is added.
                imgHeight = $imageDiv.height();
                imgWidth = $imageDiv.width();
            }

            // Hack to make an edge case work ok
            // (This happens for very small images, and for audio files)
            if ( imgHeight < 40 ) {
                // Don't try and resize this item.
                imgHeight = 0;
            }

            captionWidth = $this.find( 'div.gallerytextwrapper' ).width();
            outerWidth = $this.outerWidth();
            rows[ rows.length - 1 ].push( {
                $elm: $this,
                width: outerWidth,
                imgWidth: imgWidth,
                // FIXME: Deal with devision by 0.
                aspect: imgWidth / imgHeight,
                captionWidth: captionWidth,
                height: imgHeight
            } );

            // Save all boundaries so we can restore them on window resize
            $this.data( {
                imgWidth: imgWidth,
                imgHeight: imgHeight,
                width: outerWidth,
                captionWidth: captionWidth
            } );
        } );

        ( function () {
            let maxWidth,
                combinedAspect,
                combinedPadding,
                curRow,
                curRowHeight,
                wantedWidth,
                preferredHeight,
                newWidth,
                padding,
                $gallerybox,
                $outerDiv,
                $imageDiv,
                $imageElm,
                imageElm,
                $caption,
                i,
                j,
                avgZoom,
                totalZoom = 0;

            for ( i = 0; i < rows.length; i++ ) {
                maxWidth = $gallery.width();
                combinedAspect = 0;
                combinedPadding = 0;
                curRow = rows[ i ];
                curRowHeight = 0;

                for ( j = 0; j < curRow.length; j++ ) {
                    if ( curRowHeight === 0 ) {
                        if ( isFinite( curRow[ j ].height ) ) {
                            // Get the height of this row, by taking the first
                            // non-out of bounds height
                            curRowHeight = curRow[ j ].height;
                        }
                    }

                    if ( curRow[ j ].aspect === 0 || !isFinite( curRow[ j ].aspect ) ) {
                        // One of the dimensions are 0. Probably should
                        // not try to resize.
                        combinedPadding += curRow[ j ].width;
                    } else {
                        combinedAspect += curRow[ j ].aspect;
                        combinedPadding += curRow[ j ].width - curRow[ j ].imgWidth;
                    }
                }

                // Add some padding for inter-element spacing.
                combinedPadding += 5 * curRow.length;
                wantedWidth = maxWidth - combinedPadding;
                preferredHeight = wantedWidth / combinedAspect;

                if ( preferredHeight > curRowHeight * 1.5 ) {
                    // Only expand at most 1.5 times current size
                    // As that's as high a resolution as we have.
                    // Also on the off chance there is a bug in this
                    // code, would prevent accidentally expanding to
                    // be 10 billion pixels wide.
                    if ( i === rows.length - 1 ) {
                        // If its the last row, and we can't fit it,
                        // don't make the entire row huge.
                        avgZoom = totalZoom / ( rows.length - 1 );
                        if ( isFinite( avgZoom ) && avgZoom >= 1 && avgZoom <= 1.5 ) {
                            preferredHeight = avgZoom * curRowHeight;
                        } else {
                            // Probably a single row gallery
                            preferredHeight = curRowHeight;
                        }
                    } else {
                        preferredHeight = 1.5 * curRowHeight;
                    }
                }
                if ( !isFinite( preferredHeight ) ) {
                    // This *definitely* should not happen.
                    // Skip this row.
                    continue;
                }
                if ( preferredHeight < 5 ) {
                    // Well something clearly went wrong...
                    // Skip this row.
                    continue;
                }

                if ( preferredHeight / curRowHeight > 1 ) {
                    totalZoom += preferredHeight / curRowHeight;
                } else {
                    // If we shrink, still consider that a zoom of 1
                    totalZoom += 1;
                }

                for ( j = 0; j < curRow.length; j++ ) {
                    newWidth = preferredHeight * curRow[ j ].aspect;
                    padding = curRow[ j ].width - curRow[ j ].imgWidth;
                    $gallerybox = curRow[ j ].$elm;
                    // This wrapper is only present if ParserEnableLegacyMediaDOM is true
                    $outerDiv = $gallerybox.children( 'div:not( [class] )' ).first();
                    $imageDiv = $gallerybox.find( 'div.thumb' ).first();
                    $imageElm = $imageDiv.find( 'img, video' ).first();
                    $caption = $gallerybox.find( 'div.gallerytextwrapper' );

                    // Since we are going to re-adjust the height, the vertical
                    // centering margins need to be reset.
                    $imageDiv.children( 'div' ).css( 'margin', '0px auto' );

                    if ( newWidth < 60 || !isFinite( newWidth ) ) {
                        // Making something skinnier than this will mess up captions,
                        if ( newWidth < 1 || !isFinite( newWidth ) ) {
                            $outerDiv.height( preferredHeight );
                            // Don't even try and touch the image size if it could mean
                            // making it disappear.
                            continue;
                        }
                    } else {
                        $gallerybox.width( newWidth + padding );
                        $outerDiv.width( newWidth + padding );
                        $imageDiv.width( newWidth );
                        $caption.width( curRow[ j ].captionWidth + ( newWidth - curRow[ j ].imgWidth ) );
                    }

                    // We don't always have an img, e.g. in the case of an invalid file.
                    if ( $imageElm[ 0 ] ) {
                        imageElm = $imageElm[ 0 ];
                        imageElm.width = newWidth;
                        imageElm.height = preferredHeight;
                    } else {
                        // Not a file box.
                        $imageDiv.height( preferredHeight );
                    }
                }
            }
        }() );
    }

    function handleResizeStart() {
        // Only do anything if window width changed. We don't care about the height.
        if ( lastWidth === window.innerWidth ) {
            return;
        }

        justifyNeeded = true;
        // Temporarily set min-height, so that content following the gallery is not reflowed twice
        $galleries.css( 'min-height', function () {
            return $( this ).height();
        } );
        $galleries.children( 'li.gallerybox' ).each( function () {
            let imgWidth = $( this ).data( 'imgWidth' ),
                imgHeight = $( this ).data( 'imgHeight' ),
                width = $( this ).data( 'width' ),
                captionWidth = $( this ).data( 'captionWidth' ),
                // This wrapper is only present if ParserEnableLegacyMediaDOM is true
                $outerDiv = $( this ).children( 'div:not( [class] )' ).first(),
                $imageDiv = $( this ).find( 'div.thumb' ).first(),
                $imageElm, imageElm;

            // Restore original sizes so we can arrange the elements as on freshly loaded page
            $( this ).width( width );
            $outerDiv.width( width );
            $imageDiv.width( imgWidth );
            $( this ).find( 'div.gallerytextwrapper' ).width( captionWidth );

            $imageElm = $imageDiv.find( 'img, video' ).first();
            if ( $imageElm[ 0 ] ) {
                imageElm = $imageElm[ 0 ];
                imageElm.width = imgWidth;
                imageElm.height = imgHeight;
            } else {
                $imageDiv.height( imgHeight );
            }
        } );
    }

    function handleResizeEnd() {
        // If window width never changed during the resize, don't do anything.
        if ( justifyNeeded ) {
            justifyNeeded = false;
            lastWidth = window.innerWidth;
            $galleries
                // Remove temporary min-height
                .css( 'min-height', '' )
                // Recalculate layout
                .each( justify );
        }
    }

    mw.hook( 'wikipage.content' ).add( ( $content ) => {
        if ( isTouchScreen ) {
            // Always show the caption for a touch screen.
            $content.find( 'ul.mw-gallery-packed-hover' )
                .addClass( 'mw-gallery-packed-overlay' )
                .removeClass( 'mw-gallery-packed-hover' );
        } else {
            // Note use of just `a`, not `a.image`, since we also want this to trigger if a link
            // within the caption text receives focus.
            $content.find( 'ul.mw-gallery-packed-hover li.gallerybox' ).on( 'focus blur', 'a', function ( e ) {
                // Confusingly jQuery leaves e.type as focusout for delegated blur events
                const gettingFocus = e.type !== 'blur' && e.type !== 'focusout';
                $( this ).closest( 'li.gallerybox' ).toggleClass( 'mw-gallery-focused', gettingFocus );
            } );
        }

        $galleries = $content.find( 'ul.mw-gallery-packed-overlay, ul.mw-gallery-packed-hover, ul.mw-gallery-packed' );
        // Call the justification asynchronous because live preview fires the hook with detached $content.
        setTimeout( () => {
            $galleries.each( justify );

            // Bind here instead of in the top scope as the callbacks use $galleries.
            if ( !bound ) {
                bound = true;
                $( window )
                    .on( 'resize', mw.util.debounce( handleResizeStart, 300, true ) )
                    .on( 'resize', mw.util.debounce( handleResizeEnd, 300 ) );
            }
        } );
    } );
}() );