From d8c3db4e5935476e496d979fb01f775d3d3282e6 Mon Sep 17 00:00:00 2001 From: ciencia Date: Mon, 18 Apr 2022 21:45:33 -0400 Subject: [PATCH] feat: allow tab content to be transclusions of other pages * Initial merge of the TabberTransclude extension from Ciencia * Tab content can now be fetched from other pages on the wiki. The content is lazy-loaded when the tab is selected through a XHR request. * Add a config option to disable setting URL hash on tab change --- extension.json | 22 ++++++- i18n/en.json | 7 ++- i18n/es.json | 8 ++- i18n/qqq.json | 4 +- includes/TabberNeueHooks.php | 112 ++++++++++++++++++++++++++++++++++- modules/ext.tabberNeue.js | 101 ++++++++++++++++++++++++++++--- 6 files changed, 237 insertions(+), 17 deletions(-) diff --git a/extension.json b/extension.json index 9b21537..ca8a0de 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "TabberNeue", - "version": "1.2.0", + "version": "1.3.0", "author": [ "alistair3149", "Eric Fortin", @@ -25,7 +25,17 @@ "ResourceModules": { "ext.tabberNeue": { "packageFiles": [ - "ext.tabberNeue.js" + "ext.tabberNeue.js", + { + "name": "config.json", + "config": { + "updateLocationOnTabChange": "TabberNeueUpdateLocationOnTabChange" + } + } + ], + "messages": [ + "tabberneue-loading", + "tabberneue-error" ], "styles": [ "ext.tabberNeue.less" @@ -62,6 +72,14 @@ "localBasePath": "modules", "remoteExtPath": "TabberNeue/modules" }, + "config_prefix": "wg", + "config": { + "TabberNeueUpdateLocationOnTabChange": { + "value": true, + "description": "If enabled, when a tab is selected, the URL displayed on the browser changes. Opening this URL makes that tab initially selected.", + "public": true + } + }, "Hooks": { "ParserFirstCallInit": [ "TabberNeue\\TabberNeueHooks::onParserFirstCallInit" diff --git a/i18n/en.json b/i18n/en.json index 19097f6..23ba297 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2,8 +2,11 @@ "@metadata": { "authors": [ "alistair3149", - "Eric Fortin" + "Eric Fortin", + "Ciencia Al Poder" ] }, - "tabberneue-desc": "Allows to create tabs within a page. Forked from [https://www.mediawiki.org/wiki/Extension:Tabber Extension:Tabber]." + "tabberneue-desc": "Allows to create tabs within a page. Forked from [https://www.mediawiki.org/wiki/Extension:Tabber Extension:Tabber].", + "tabberneue-loading": "Loading...", + "tabberneue-error": "Error." } diff --git a/i18n/es.json b/i18n/es.json index 27c2f3d..63457e7 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -1,8 +1,10 @@ { "@metadata": { "authors": [ - "Armando-Martin" + "Ciencia Al Poder" ] }, - "tabberneue-desc": "Permite para fichas dentro de una página" -} + "tabberneue-desc": "Permite usar pestañas dentro de una página.", + "tabberneue-loading": "Cargando...", + "tabberneue-error": "Error." +} \ No newline at end of file diff --git a/i18n/qqq.json b/i18n/qqq.json index b3fc3d0..1528fd4 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -4,5 +4,7 @@ "Shirayuki" ] }, - "tabberneue-desc": "{{desc|name=TabberNeue|url=http://www.mediawiki.org/wiki/Extension:TabberNeue}}" + "tabberneue-desc": "{{desc|name=TabberNeue|url=http://www.mediawiki.org/wiki/Extension:TabberNeue}}", + "tabberneue-loading": "Placeholder loading message for the tab content", + "tabberneue-error": "Error message shown loading tab content" } diff --git a/includes/TabberNeueHooks.php b/includes/TabberNeueHooks.php index 38ac99e..83b3ca0 100644 --- a/includes/TabberNeueHooks.php +++ b/includes/TabberNeueHooks.php @@ -4,7 +4,7 @@ * TabberNeue Hooks Class * * @package TabberNeue - * @author alistair3149, Eric Fortin, Alexia E. Smith + * @author alistair3149, Eric Fortin, Alexia E. Smith, Ciencia Al Poder * @license GPL-3.0-or-later * @link https://www.mediawiki.org/wiki/Extension:TabberNeue */ @@ -13,8 +13,11 @@ declare( strict_types=1 ); namespace TabberNeue; +use Hooks; +use MediaWiki\MediaWikiServices; use Parser; use PPFrame; +use Title; class TabberNeueHooks { /** @@ -24,6 +27,7 @@ class TabberNeueHooks { */ public static function onParserFirstCallInit( Parser $parser ) { $parser->setHook( 'tabber', [ __CLASS__, 'renderTabber' ] ); + $parser->setHook( 'tabbertransclude', [ __CLASS__, 'renderTabberTransclude' ] ); } /** @@ -77,4 +81,110 @@ class TabberNeueHooks { return $tab; } + + /** + * Renders the necessary HTML for a tag. + * + * @param string $input The input URL between the beginning and ending tags. + * @param array $args Array of attribute arguments on that beginning tag. + * @param Parser $parser Mediawiki Parser Object + * @param PPFrame $frame Mediawiki PPFrame Object + * + * @return string HTML + */ + public static function renderTabberTransclude( $input, array $args, Parser $parser, PPFrame $frame ) { + $parser->getOutput()->addModules( [ 'ext.tabberNeue' ] ); + $selected = true; + + $arr = explode( "\n", $input ); + $htmlTabs = ''; + foreach ( $arr as $tab ) { + $htmlTabs .= self::buildTabTransclude( $tab, $parser, $frame, $selected ); + } + + $html = '
' . + '
' . $htmlTabs . "
"; + + return $html; + } + + /** + * Build individual tab. + * + * @param string $tab Tab information + * @param Parser $parser Mediawiki Parser Object + * @param PPFrame $frame Mediawiki PPFrame Object + * @param bool $selected The tab is the selected one + * + * @return string HTML + */ + private static function buildTabTransclude( $tab, Parser $parser, PPFrame $frame, &$selected ) { + $tab = trim( $tab ); + if ( empty( $tab ) ) { + return ''; + } + + $tabBody = ''; + $dataProps = []; + // Use array_pad to make sure at least 2 array values are always returned + list( $pageName, $tabName ) = array_pad( explode( '|', $tab, 2 ), 2, '' ); + $title = Title::newFromText( trim( $pageName ) ); + if ( !$title ) { + if ( empty( $tabName ) ) { + $tabName = $pageName; + } + $tabBody = sprintf( '
Invalid title: %s
', $pageName ); + $pageName = ''; + } else { + $pageName = $title->getPrefixedText(); + if ( empty( $tabName ) ) { + $tabName = $pageName; + } + $dataProps['page-title'] = $pageName; + if ( $selected ) { + $tabBody = $parser->recursiveTagParseFully( + sprintf( '{{:%s}}', $pageName ), + $frame + ); + } else { + // Add a link placeholder, as a fallback if JavaScript doesn't execute + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $tabBody = sprintf( + '
%s
', + $linkRenderer->makeLink( $title, null, [ 'rel' => 'nofollow' ] ) + ); + $dataProps['pending-load'] = '1'; + // 1.37: $currentTitle = $parser->getPage(); + $currentTitle = $parser->getTitle(); + $query = sprintf( + '?action=parse&format=json&formatversion=2&title=%s&text={{:%s}}&redirects=1&prop=text&disablelimitreport=1&disabletoc=1&wrapoutputclass=', + urlencode( $currentTitle->getPrefixedText() ), + urlencode( $pageName ) + ); + $dataProps['load-url'] = wfExpandUrl( wfScript( 'api' ) . $query, PROTO_CANONICAL ); + $oldTabBody = $tabBody; + // Allow extensions to update the lazy loaded tab + Hooks::run( 'TabberTranscludeRenderLazyLoadedTab', [ &$tabBody, &$dataProps, $parser, $frame ] ); + if ( $oldTabBody != $tabBody ) { + $parser->getOutput()->recordOption( 'tabbertranscludelazyupdated' ); + } + } + // Register as a template + $revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title ); + $parser->getOutput()->addTemplate( + $title, + $title->getArticleId(), + $revRecord ? $revRecord->getId() : null + ); + } + + $tab = '
'; + $selected = false; + + return $tab; + } } diff --git a/modules/ext.tabberNeue.js b/modules/ext.tabberNeue.js index b4a7411..47b1868 100644 --- a/modules/ext.tabberNeue.js +++ b/modules/ext.tabberNeue.js @@ -7,7 +7,8 @@ function initTabber( tabber, count ) { var tabPanels = tabber.querySelectorAll( ':scope > .tabber__section > .tabber__panel' ); - var container = document.createElement( 'header' ), + var config = require( './config.json' ), + container = document.createElement( 'header' ), tabList = document.createElement( 'nav' ), prevButton = document.createElement( 'div' ), nextButton = document.createElement( 'div' ); @@ -149,22 +150,62 @@ function initTabber( tabber, count ) { updateButtons(); } ); - // Listen for window resize - window.addEventListener( 'resize', mw.util.debounce( 250, setupButtons ) ); + // Listen for element resize + if ( window.ResizeObserver ) { + var tabListResizeObserver = new ResizeObserver( mw.util.debounce( 250, setupButtons ) ); + tabListResizeObserver.observe( tabList ); + } }; + // NOTE: Are there better ways to scope them? + var xhr = new XMLHttpRequest(); + var currentRequest = null, nextRequest = null; + + /** + * Loads page contents into tab + * + * @param {HTMLElement} tab panel + * @param {string} api URL + */ + function loadPage( targetPanel, url ) { + var requestData = { + url: url, + targetPanel: targetPanel + }; + if ( currentRequest ) { + if ( currentRequest.url != requestData.url ) { + nextRequest = requestData; + } + // busy + return; + } + xhr.open( 'GET', url ); + currentRequest = requestData; + xhr.send( null ); + } + /** * Show panel based on target hash * * @param {string} targetHash */ - function showPanel( targetHash ) { + function showPanel( targetHash, allowRemoteLoad ) { var ACTIVETABCLASS = 'tabber__tab--active', ACTIVEPANELCLASS = 'tabber__panel--active', targetPanel = document.getElementById( targetHash ), targetTab = document.getElementById( 'tab-' + targetHash ), section = targetPanel.parentElement, - activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS ); + activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS ), + parentPanel, parentSection; + + if ( allowRemoteLoad && targetPanel.dataset.tabberPendingLoad && targetPanel.dataset.tabberLoadUrl ) { + var loading = document.createElement( 'div' ); + loading.setAttribute( 'class', 'tabber__loading' ); + loading.appendChild( document.createTextNode( mw.message( 'tabberneue-loading' ).text() ) ); + targetPanel.textContent = ''; + targetPanel.appendChild( loading ); + loadPage( targetPanel, targetPanel.dataset.tabberLoadUrl ); + } /* eslint-disable mediawiki/class-doc */ if ( activePanel ) { @@ -212,6 +253,48 @@ function initTabber( tabber, count ) { /* eslint-enable mediawiki/class-doc */ } + /** + * Event handler for XMLHttpRequest where ends loading + */ + function onLoadEndPage() { + var targetPanel = currentRequest.targetPanel; + if ( xhr.status != 200 ) { + var err = document.createElement( 'div' ); + err.setAttribute( 'class', 'tabber__error' ); + err.appendChild( document.createTextNode( mw.message( 'tabberneue-error' ).text() ) ); + targetPanel.textContent = ''; + targetPanel.appendChild( err ); + } else { + var result = JSON.parse( xhr.responseText ); + targetPanel.innerHTML = result.parse.text; + // wikipage.content hook requires a jQuery object + mw.hook( 'wikipage.content' ).fire( $( targetPanel ) ); + delete targetPanel.dataset.tabberPendingLoad; + delete targetPanel.dataset.tabberLoadUrl; + } + + var ACTIVEPANELCLASS = 'tabber__panel--active', + targetHash = targetPanel.getAttribute( 'id' ), + section = targetPanel.parentElement, + activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS ); + + if ( nextRequest ) { + currentRequest = nextRequest; + nextRequest = null; + xhr.open( 'GET', currentRequest.url ); + xhr.send( null ); + } else { + currentRequest = null; + } + if ( activePanel ) { + // Refresh height + showPanel( targetHash, false ); + } + } + + xhr.timeout = 20000; + xhr.addEventListener( 'loadend', onLoadEndPage ); + /** * Retrieve target hash and trigger show panel * If no targetHash is invalid, use the first panel @@ -244,9 +327,11 @@ function initTabber( tabber, count ) { tab.addEventListener( 'click', function( event ) { var targetHash = tab.getAttribute( 'href' ).substring( 1 ); event.preventDefault(); - // Add hash to the end of the URL - history.replaceState( null, null, '#' + targetHash ); - showPanel( targetHash ); + if ( !config || config.updateLocationOnTabChange ) { + // Add hash to the end of the URL + history.replaceState( null, null, '#' + targetHash ); + } + showPanel( targetHash, true ); } ); } );