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
This commit is contained in:
ciencia 2022-04-18 21:45:33 -04:00 committed by alistair3149
parent 91105aa338
commit d8c3db4e59
6 changed files with 237 additions and 17 deletions

View file

@ -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"

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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"
}

View file

@ -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 <tabbertransclude> 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 = '<div class="tabber">' .
'<section class="tabber__section">' . $htmlTabs . "</section></div>";
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( '<div class="error">Invalid title: %s</div>', $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(
'<div class="tabber__ajaxplaceholder">%s</div>',
$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 = '<article class="tabber__panel" title="' . htmlspecialchars( $tabName ) . '"';
$tab .= implode( array_map( static function ( $prop, $value ) {
return sprintf( ' data-tabber-%s="%s"', $prop, htmlspecialchars( $value ) );
}, array_keys( $dataProps ), $dataProps ) );
$tab .= '>' . $tabBody . '</article>';
$selected = false;
return $tab;
}
}

View file

@ -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 );
} );
} );