Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.53% covered (warning)
78.53%
150 / 191
63.64% covered (warning)
63.64%
14 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
78.53% covered (warning)
78.53%
150 / 191
63.64% covered (warning)
63.64%
14 / 22
100.02
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onUserGetDefaultOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 onGetPreferences
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 shouldHandleClicks
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 isMobileFrontendView
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getModules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 maybeAddMobileCarousel
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 shouldUseMobileCarousel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 shouldPageGetMobileCarousel
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 isBetaFeatureEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getCurrentRequestSkinName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractCarouselImageElements
56.67% covered (warning)
56.67%
17 / 30
0.00% covered (danger)
0.00%
0 / 1
10.99
 buildCarouselItemsHtml
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
4
 buildCarouselHtml
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getCarouselLabel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 onBeforePageDisplay
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 onCategoryPageView
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onGetDoubleUnderscoreIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onMakeGlobalVariablesScript
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getCommonConfig
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 onThumbnailBeforeProduceHTML
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This file is part of the MediaWiki extension MultimediaViewer.
4 *
5 * MultimediaViewer is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * MultimediaViewer is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with MultimediaViewer.  If not, see <http://www.gnu.org/licenses/>.
17 *
18 * @file
19 * @ingroup extensions
20 * @author Mark Holmquist <mtraceur@member.fsf.org>
21 * @copyright Copyright © 2013, Mark Holmquist
22 */
23
24namespace MediaWiki\Extension\MultimediaViewer;
25
26use MediaWiki\Category\Category;
27use MediaWiki\Config\Config;
28use MediaWiki\Config\ConfigException;
29use MediaWiki\Context\RequestContext;
30use MediaWiki\Extension\BetaFeatures\BetaFeatures;
31use MediaWiki\Hook\GetDoubleUnderscoreIDsHook;
32use MediaWiki\Html\Html;
33use MediaWiki\MainConfigNames;
34use MediaWiki\Media\Hook\ThumbnailBeforeProduceHTMLHook;
35use MediaWiki\Media\ThumbnailImage;
36use MediaWiki\MediaWikiServices;
37use MediaWiki\Output\Hook\BeforePageDisplayHook;
38use MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook;
39use MediaWiki\Output\OutputPage;
40use MediaWiki\Page\CategoryPage;
41use MediaWiki\Page\Hook\CategoryPageViewHook;
42use MediaWiki\Page\PageProps;
43use MediaWiki\Parser\ParserOptions;
44use MediaWiki\Parser\ParserOutput;
45use MediaWiki\Parser\ParserOutputLinkTypes;
46use MediaWiki\Preferences\Hook\GetPreferencesHook;
47use MediaWiki\Registration\ExtensionRegistry;
48use MediaWiki\ResourceLoader\Context;
49use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
50use MediaWiki\Skin\Skin;
51use MediaWiki\SpecialPage\SpecialPageFactory;
52use MediaWiki\Title\Title;
53use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
54use MediaWiki\User\Options\UserOptionsLookup;
55use MediaWiki\User\User;
56use MobileContext;
57use Wikimedia\Parsoid\Core\DOMCompat;
58use Wikimedia\Parsoid\Utils\DOMUtils;
59
60class Hooks implements
61    MakeGlobalVariablesScriptHook,
62    UserGetDefaultOptionsHook,
63    GetPreferencesHook,
64    BeforePageDisplayHook,
65    CategoryPageViewHook,
66    ResourceLoaderGetConfigVarsHook,
67    GetDoubleUnderscoreIDsHook,
68    ThumbnailBeforeProduceHTMLHook
69{
70    // Minimum number of images in a wiki page to enable the carousel.
71    private const MIN_CAROUSEL_IMAGES = 3;
72    // Page property that represents the __NOMEDIAVIEWERCAROUSEL__ magic word / behavior switch.
73    // When `__NOMEDIAVIEWERCAROUSEL__` is in the page content,
74    // the `nomediaviewercarousel` page property is set during parse.
75    // Checking page props lets request-time carousel decisions reuse the
76    // parser output metadata without reparsing the page content.
77    private const DISABLE_MOBILE_CAROUSEL_PAGE_PROPERTY = 'nomediaviewercarousel';
78    // Beta features key, used to populate a checkbox in the user's beta preferences.
79    // Must be registered in the production allowlist:
80    // https://github.com/wikimedia/operations-mediawiki-config/blob/22cee2b5dfc729e9dd49ae5cd878c7735f2bf66c/wmf-config/InitialiseSettings.php#L6532
81    public const BETA_FEATURES_KEY = 'multimediaviewer-beta';
82
83    public function __construct(
84        private readonly Config $config,
85        private readonly SpecialPageFactory $specialPageFactory,
86        private readonly UserOptionsLookup $userOptionsLookup,
87        private readonly PageProps $pageProps,
88        private readonly ?MobileContext $mobileContext,
89    ) {
90    }
91
92    /**
93     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetDefaultOptions
94     * @param array &$defaultOptions
95     */
96    public function onUserGetDefaultOptions( &$defaultOptions ) {
97        if ( $this->config->get( 'MediaViewerEnableByDefault' ) ) {
98            $defaultOptions['multimediaviewer-enable'] = 1;
99        }
100    }
101
102    /**
103     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
104     * Adds a default-enabled preference to gate the feature
105     * @param User $user
106     * @param array &$prefs
107     */
108    public function onGetPreferences( $user, &$prefs ) {
109        $prefs['multimediaviewer-enable'] = [
110            'type' => 'toggle',
111            'label-message' => 'multimediaviewer-optin-pref',
112            'section' => 'rendering/files',
113        ];
114    }
115
116    /**
117     * Checks the context for whether to load the viewer.
118     * @param User $performer
119     * @return bool
120     */
121    protected function shouldHandleClicks( User $performer ): bool {
122        if ( $performer->isNamed() ) {
123            return (bool)$this->userOptionsLookup->getOption( $performer, 'multimediaviewer-enable' );
124        }
125
126        return (bool)(
127            $this->config->get( 'MediaViewerEnableByDefaultForAnonymous' ) ??
128            $this->config->get( 'MediaViewerEnableByDefault' )
129        );
130    }
131
132    /**
133     * Whether the current request is being served through MobileFrontend's mobile view.
134     *
135     * @return bool
136     */
137    protected function isMobileFrontendView(): bool {
138        return ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) &&
139            $this->mobileContext &&
140            $this->mobileContext->shouldDisplayMobileView();
141    }
142
143    /**
144     * Handler for all places where we add the modules
145     * Could be on article pages or on Category pages
146     * @param OutputPage $out
147     */
148    protected function getModules( OutputPage $out ) {
149        // Desktop view: always load the viewer.
150        if ( !$this->isMobileFrontendView() ) {
151            $out->addModules( 'mmv.bootstrap' );
152            return;
153        }
154
155        // Mobile view: only load the viewer if the user has opted in to the
156        // beta with ?mmvBeta=1 (T427679). Otherwise load nothing here; the
157        // carousel module is handled by maybeAddMobileCarousel().
158        if ( $out->getRequest()->getFuzzyBool( 'mmvBeta' ) ) {
159            $out->addModules( 'mmv.bootstrap' );
160        }
161    }
162
163    /**
164     * Render the mobile carousel server-side and load its JS module.
165     *
166     * The module is added only when carousel markup is actually rendered
167     * (a qualifying request and enough thumbnails), so the client module
168     * can assume carousel items exist in the DOM.
169     *
170     * @param OutputPage $out
171     */
172    private function maybeAddMobileCarousel( OutputPage $out ): void {
173        if ( !$this->shouldUseMobileCarousel( $out ) ) {
174            return;
175        }
176
177        $thumbExtractor = new ThumbExtractor(
178            array_keys( $this->config->get( 'MediaViewerExtensions' ) ),
179            $this->config->get( 'MediaViewerExcludedImageSelectors' ),
180            100,
181            100,
182            $this->config->get( MainConfigNames::ArticlePath )
183        );
184        $context = $out->getContext();
185        $carouselItems = $this->extractCarouselImageElements(
186            $thumbExtractor,
187            $out->getHTML(),
188            $context->getWikiPage()->getParserOutput(
189                ParserOptions::newFromContext( $context )
190            ) ?: null
191        );
192        if ( count( $carouselItems ) < self::MIN_CAROUSEL_IMAGES ) {
193            return;
194        }
195
196        $out->addModules( 'mmv.carousel' );
197        $out->addModuleStyles( 'mmv.carousel.styles' );
198        $out->prependHTML( $this->buildCarouselHtml( $carouselItems, $out->getTitle()->getText() ) );
199    }
200
201    /**
202     * Whether the request should use the mobile carousel entrypoint.
203     *
204     * Conditions:
205     *  - request is served through MobileFrontend's mobile view
206     *  - MediaViewerMobileCarousel config flag is enabled
207     *  - user has opted in via beta feature preferences
208     *  - page is a suitable candidate
209     *
210     * @param OutputPage $out
211     * @return bool
212     */
213    protected function shouldUseMobileCarousel( OutputPage $out ): bool {
214        return (
215            // Mobile view
216            $this->isMobileFrontendView() &&
217            // Config flag
218            $this->config->get( 'MediaViewerMobileCarousel' ) &&
219            // Beta feature opt-in
220            $this->isBetaFeatureEnabled( $out->getUser() ) &&
221            // Candidate page
222            $this->shouldPageGetMobileCarousel( $out )
223        );
224    }
225
226    /**
227     * Whether the page should get the mobile carousel.
228     *
229     * Conditions:
230     * - plain view action: not a diff (which is still action=view), not an
231     *   old revision, and not history/edit/etc. (T428701). Old revisions are
232     *   excluded because thumbnails are extracted from the current revision's
233     *   parser output and would not match the displayed content.
234     * - article page
235     * - not the main page
236     * - real page, e.g., not special
237     * - content does not have the __NOMEDIAVIEWERCAROUSEL__ magic word
238     *
239     * @param OutputPage $out
240     * @return bool
241     */
242    protected function shouldPageGetMobileCarousel( OutputPage $out ): bool {
243        $title = $out->getTitle();
244
245        return (
246            // Plain view: not history, edit, etc.
247            $out->getActionName() === 'view' &&
248            // Not a diff, which is served as part of the view action
249            !$out->getRequest()->getCheck( 'diff' ) &&
250            // Not an old revision (?oldid=) view
251            $out->isRevisionCurrent() &&
252            // Article
253            $title->getNamespace() === NS_MAIN &&
254            // Not the main page
255            !$title->isMainPage() &&
256            // Real page
257            $title->canExist() &&
258            // No __NOMEDIAVIEWERCAROUSEL__
259            $this->pageProps->getProperties(
260                $title, self::DISABLE_MOBILE_CAROUSEL_PAGE_PROPERTY
261            ) === []
262        );
263    }
264
265    /**
266     * Whether the beta feature is enabled.
267     *
268     * Conditions:
269     * - MediaViewerBetaFeature config flag is enabled
270     * - BetaFeatures extension is loaded
271     * - user has opted in
272     *
273     * @param User $user
274     * @return bool
275     */
276    protected function isBetaFeatureEnabled( User $user ): bool {
277        return $this->config->get( 'MediaViewerBetaFeature' ) &&
278            ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' ) &&
279            BetaFeatures::isFeatureEnabled( $user, self::BETA_FEATURES_KEY );
280    }
281
282    /**
283     * The current request skin name, including temporary `?useskin=` overrides.
284     *
285     * @return string
286     */
287    protected function getCurrentRequestSkinName(): string {
288        return RequestContext::getMain()->getSkin()->getSkinName();
289    }
290
291    /**
292     * Extract thumbnail image data from the parser output of a wiki page.
293     * The parser cache is used if possible.
294     *
295     * @param ThumbExtractor $thumbExtractor
296     * @param string $html
297     * @param ?ParserOutput $parserOutput
298     * @return array{title: Title, thumb: \Wikimedia\Parsoid\DOM\Element}[]
299     */
300    protected function extractCarouselImageElements(
301        ThumbExtractor $thumbExtractor,
302        string $html,
303        ?ParserOutput $parserOutput = null
304    ): array {
305        $doc = DOMCompat::newDocument( true );
306        $body = DOMUtils::parseHTMLToFragment( $doc, $html );
307
308        $thumbs = $thumbExtractor->findThumbs( $body );
309        $carouselItems = [];
310        foreach ( $thumbs as $thumb ) {
311            $anchor = DOMCompat::getParentElement( $thumb );
312            $title = $thumbExtractor->extractTitleFromAnchorElement( $anchor );
313            if ( !$title ) {
314                continue;
315            }
316
317            // Guard against duplicates/overwrites
318            $prefixedText = $title->getPrefixedText();
319            if ( isset( $carouselItems[$prefixedText] ) ) {
320                continue;
321            }
322
323            $carouselItems[$prefixedText] = [
324                'title' => $title,
325                'thumb' => $thumb,
326            ];
327        }
328        $carouselItems = array_values( $carouselItems );
329
330        if ( $parserOutput ) {
331            // Doublecheck thumbs against media known in ParserOutput.
332            // Note: this code path with not be run for external content
333            // served through MobileFrontendContentProvider, for which
334            // we don't have parser output.
335            $fileNames = [];
336            foreach ( $parserOutput->getLinkList( ParserOutputLinkTypes::MEDIA ) as $medium ) {
337                $fileNames[] = $medium['link']->getText();
338            }
339            $files = MediaWikiServices::getInstance()->getRepoGroup()->findFiles( $fileNames );
340
341            $carouselItems = array_values( array_filter(
342                $carouselItems,
343                static function ( $item ) use ( $files ) {
344                    $filename = $item['title']->getDBkey();
345                    return isset( $files[$filename] ) && $files[$filename]->exists();
346                }
347            ) );
348        }
349
350        return $carouselItems;
351    }
352
353    /**
354     * Build the HTML for carousel thumbnail items.
355     *
356     * @param array{title: Title, thumb: \Wikimedia\Parsoid\DOM\Element}[] $carouselItems
357     * @return string
358     */
359    private function buildCarouselItemsHtml( array $carouselItems ): string {
360        $html = '';
361        foreach ( $carouselItems as $i => $item ) {
362            $html .= Html::rawElement(
363                'li',
364                [
365                    'class' => 'mmv-carousel__item',
366                    'data-mmv-title' => $item['title']->getPrefixedText(),
367                    'data-mmv-position' => (string)( $i + 1 ),
368                ],
369                Html::rawElement(
370                    'a',
371                    [
372                        'href' => $item['title']->getLocalURL(),
373                        'class' => 'mmv-carousel__item-link mw-file-description',
374                        // Give each link an explicit accessible name. Reuse the
375                        // image alt text when present, otherwise fall back to the
376                        // cleaned-up filename.
377                        'aria-label' => DOMCompat::getAttribute( $item['thumb'], 'alt' ) ?:
378                            preg_replace( '/\.[^.]+$/', '', $item['title']->getText() ),
379                    ],
380                    Html::element(
381                        'img',
382                        [
383                            'src' => DOMCompat::getAttribute( $item['thumb'], 'src' ) ?:
384                                DOMCompat::getAttribute( $item['thumb'], 'data-mw-src' ),
385                            'srcset' => DOMCompat::getAttribute( $item['thumb'], 'srcset' ) ??
386                                DOMCompat::getAttribute( $item['thumb'], 'data-mw-srcset' ) ??
387                                false,
388                            'width' => DOMCompat::getAttribute( $item['thumb'], 'width' ),
389                            'height' => DOMCompat::getAttribute( $item['thumb'], 'height' ),
390                            'alt' => DOMCompat::getAttribute( $item['thumb'], 'alt' ),
391                            'class' => 'mmv-carousel__item-image',
392                            'loading' => 'lazy',
393                        ]
394                    )
395                )
396            );
397        }
398        return $html;
399    }
400
401    /**
402     * Build the server-rendered carousel shell so the client module can
403     * progressively enhance it.
404     * @param array{title: Title, thumb: \Wikimedia\Parsoid\DOM\Element}[] $carouselItems carousel thumbnails
405     * @param string $pageTitle display title of the page, used in the carousel's accessible label
406     * @return string
407     */
408    private function buildCarouselHtml( array $carouselItems, string $pageTitle ): string {
409        return Html::rawElement(
410            'div',
411            [
412                'id' => 'mmv-carousel-root',
413                'class' => 'mw-mmv-wrapper mmv-carousel'
414            ],
415            Html::rawElement(
416                'ul',
417                [
418                    'class' => 'mmv-carousel__items',
419                    'aria-label' => $this->getCarouselLabel( $pageTitle, count( $carouselItems ) ),
420                    // Explicit role preserves list semantics when list-style:none
421                    // causes some browsers to strip them.
422                    'role' => 'list',
423                ],
424                $this->buildCarouselItemsHtml( $carouselItems )
425            )
426        );
427    }
428
429    /**
430     * Build the accessible label for the carousel's list container.
431     *
432     * Keep the article title when available, and only fall back to a generic
433     * noun when the title is empty. The item count is already known and cheap
434     * to include, so always expose it.
435     *
436     * @param string $pageTitle
437     * @param int $itemCount
438     * @return string
439     */
440    private function getCarouselLabel( string $pageTitle, int $itemCount ): string {
441        $labelTitle = trim( $pageTitle ) !== ''
442            ? $pageTitle
443            : wfMessage( 'multimediaviewer-carousel-label-article' )->text();
444
445        return wfMessage( 'multimediaviewer-carousel-label', $labelTitle )
446            ->numParams( $itemCount )
447            ->text();
448    }
449
450    /**
451     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
452     * Add JavaScript to the page when an image is on it
453     * and the user has enabled the feature
454     * @param OutputPage $out
455     * @param Skin $skin
456     */
457    public function onBeforePageDisplay( $out, $skin ): void {
458        $pageIsSpecialPage = $out->getTitle()->inNamespace( NS_SPECIAL );
459        $fileRelatedSpecialPages = [ 'Newimages', 'Listfiles', 'Mostimages',
460            'MostGloballyLinkedFiles', 'Uncategorizedimages', 'Unusedimages', 'Search' ];
461        $pageIsFileRelatedSpecialPage = $out->getTitle()->inNamespace( NS_SPECIAL ) &&
462            in_array(
463                $this->specialPageFactory->resolveAlias( $out->getTitle()->getDBkey() )[0],
464                $fileRelatedSpecialPages
465            );
466
467        if ( !$pageIsSpecialPage || $pageIsFileRelatedSpecialPage ) {
468            $this->getModules( $out );
469            $this->maybeAddMobileCarousel( $out );
470        }
471    }
472
473    /**
474     * @see https://www.mediawiki.org/wiki/Manual:Hooks/CategoryPageView
475     * Add JavaScript to the page if there are images in the category
476     * @param CategoryPage $catPage
477     */
478    public function onCategoryPageView( $catPage ) {
479        $title = $catPage->getTitle();
480        $cat = Category::newFromTitle( $title );
481        if ( $cat->getFileCount() > 0 ) {
482            $out = $catPage->getContext()->getOutput();
483            $this->getModules( $out );
484        }
485    }
486
487    /**
488     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderGetConfigVars
489     * Export variables used in both PHP and JS to keep DRY
490     * @param array &$vars
491     * @param string $skin
492     * @param Config $config
493     */
494    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
495        $vars['wgMediaViewer'] = true;
496    }
497
498    /**
499     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetDoubleUnderscoreIDs
500     * @see https://www.mediawiki.org/wiki/Help:Magic_words#Behavior_switches
501     *
502     * Registers the __NOMEDIAVIEWERCAROUSEL__ behavior switch so MediaWiki
503     * recognizes it during parse and records its presence as a page property.
504     * That page property is later used to suppress the mobile carousel for
505     * specific pages.
506     *
507     * @param string[] &$doubleUnderscoreIDs
508     * @return void
509     */
510    public function onGetDoubleUnderscoreIDs( &$doubleUnderscoreIDs ) {
511        $doubleUnderscoreIDs[] = self::DISABLE_MOBILE_CAROUSEL_PAGE_PROPERTY;
512    }
513
514    /**
515     * @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
516     * Export variables which depend on the current user
517     * @param array &$vars
518     * @param OutputPage $out
519     * @return void
520     */
521    public function onMakeGlobalVariablesScript( &$vars, $out ): void {
522        $user = $out->getUser();
523        $isMultimediaViewerEnable = $this->userOptionsLookup->getDefaultOption(
524            'multimediaviewer-enable',
525            $user
526        );
527
528        $vars['wgMediaViewerOnClick'] = $this->shouldHandleClicks( $user );
529        // needed because of T71942; could be different for anon and logged-in
530        $vars['wgMediaViewerEnabledByDefault'] = (bool)$isMultimediaViewerEnable;
531    }
532
533    /**
534     * ResourceLoader callback to resolve thumbnail width buckets.
535     * Uses $wgThumbnailSteps if configured, otherwise falls back to
536     * $wgMediaViewerThumbnailBucketSizes.
537     *
538     * @param Context|null $context
539     * @param Config $config
540     * @return array
541     */
542    public static function getCommonConfig( ?Context $context, Config $config ): array {
543        $steps = $config->get( MainConfigNames::ThumbnailSteps );
544        $downloadSizes = $config->get( 'MediaViewerDownloadSizes' );
545        if ( $steps && $downloadSizes ) {
546            foreach ( $downloadSizes as $k => $v ) {
547                if ( !in_array( $v, $steps ) ) {
548                    throw new ConfigException( "MediaViewerThumbnailBucketSizes $k=$v not in ThumbnailSteps" );
549                }
550            }
551        }
552        return [
553            'downloadSizes' => $downloadSizes,
554            'thumbnailBucketSizes' => $steps ?: $config->get( 'MediaViewerThumbnailBucketSizes' ),
555        ];
556    }
557
558    /**
559     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ThumbnailBeforeProduceHTML
560     * Modify thumbnail DOM
561     * @param ThumbnailImage $thumbnail
562     * @param array &$attribs Attributes of the <img> element
563     * @param array|bool &$linkAttribs Attributes of the wrapping <a> element
564     */
565    public function onThumbnailBeforeProduceHTML(
566        $thumbnail,
567        &$attribs,
568        &$linkAttribs
569    ) {
570        $file = $thumbnail->getFile();
571
572        if ( $file ) {
573            $attribs['data-file-width'] = $file->getWidth();
574            $attribs['data-file-height'] = $file->getHeight();
575        }
576    }
577}