Skip to content

Commit d8c3db4

Browse files
cienciaalistair3149
authored andcommitted
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
1 parent 91105aa commit d8c3db4

File tree

6 files changed

+237
-17
lines changed

6 files changed

+237
-17
lines changed

extension.json

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "TabberNeue",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"author": [
55
"alistair3149",
66
"Eric Fortin",
@@ -25,7 +25,17 @@
2525
"ResourceModules": {
2626
"ext.tabberNeue": {
2727
"packageFiles": [
28-
"ext.tabberNeue.js"
28+
"ext.tabberNeue.js",
29+
{
30+
"name": "config.json",
31+
"config": {
32+
"updateLocationOnTabChange": "TabberNeueUpdateLocationOnTabChange"
33+
}
34+
}
35+
],
36+
"messages": [
37+
"tabberneue-loading",
38+
"tabberneue-error"
2939
],
3040
"styles": [
3141
"ext.tabberNeue.less"
@@ -62,6 +72,14 @@
6272
"localBasePath": "modules",
6373
"remoteExtPath": "TabberNeue/modules"
6474
},
75+
"config_prefix": "wg",
76+
"config": {
77+
"TabberNeueUpdateLocationOnTabChange": {
78+
"value": true,
79+
"description": "If enabled, when a tab is selected, the URL displayed on the browser changes. Opening this URL makes that tab initially selected.",
80+
"public": true
81+
}
82+
},
6583
"Hooks": {
6684
"ParserFirstCallInit": [
6785
"TabberNeue\\TabberNeueHooks::onParserFirstCallInit"

i18n/en.json

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
"@metadata": {
33
"authors": [
44
"alistair3149",
5-
"Eric Fortin"
5+
"Eric Fortin",
6+
"Ciencia Al Poder"
67
]
78
},
8-
"tabberneue-desc": "Allows to create tabs within a page. Forked from [https://www.mediawiki.org/wiki/Extension:Tabber Extension:Tabber]."
9+
"tabberneue-desc": "Allows to create tabs within a page. Forked from [https://www.mediawiki.org/wiki/Extension:Tabber Extension:Tabber].",
10+
"tabberneue-loading": "Loading...",
11+
"tabberneue-error": "Error."
912
}

i18n/es.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
22
"@metadata": {
33
"authors": [
4-
"Armando-Martin"
4+
"Ciencia Al Poder"
55
]
66
},
7-
"tabberneue-desc": "Permite para fichas dentro de una página"
8-
}
7+
"tabberneue-desc": "Permite usar pestañas dentro de una página.",
8+
"tabberneue-loading": "Cargando...",
9+
"tabberneue-error": "Error."
10+
}

i18n/qqq.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
"Shirayuki"
55
]
66
},
7-
"tabberneue-desc": "{{desc|name=TabberNeue|url=http://www.mediawiki.org/wiki/Extension:TabberNeue}}"
7+
"tabberneue-desc": "{{desc|name=TabberNeue|url=http://www.mediawiki.org/wiki/Extension:TabberNeue}}",
8+
"tabberneue-loading": "Placeholder loading message for the tab content",
9+
"tabberneue-error": "Error message shown loading tab content"
810
}

includes/TabberNeueHooks.php

+111-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* TabberNeue Hooks Class
55
*
66
* @package TabberNeue
7-
* @author alistair3149, Eric Fortin, Alexia E. Smith
7+
* @author alistair3149, Eric Fortin, Alexia E. Smith, Ciencia Al Poder
88
* @license GPL-3.0-or-later
99
* @link https://www.mediawiki.org/wiki/Extension:TabberNeue
1010
*/
@@ -13,8 +13,11 @@
1313

1414
namespace TabberNeue;
1515

16+
use Hooks;
17+
use MediaWiki\MediaWikiServices;
1618
use Parser;
1719
use PPFrame;
20+
use Title;
1821

1922
class TabberNeueHooks {
2023
/**
@@ -24,6 +27,7 @@ class TabberNeueHooks {
2427
*/
2528
public static function onParserFirstCallInit( Parser $parser ) {
2629
$parser->setHook( 'tabber', [ __CLASS__, 'renderTabber' ] );
30+
$parser->setHook( 'tabbertransclude', [ __CLASS__, 'renderTabberTransclude' ] );
2731
}
2832

2933
/**
@@ -77,4 +81,110 @@ private static function buildTab( $tab, Parser $parser, PPFrame $frame ) {
7781

7882
return $tab;
7983
}
84+
85+
/**
86+
* Renders the necessary HTML for a <tabbertransclude> tag.
87+
*
88+
* @param string $input The input URL between the beginning and ending tags.
89+
* @param array $args Array of attribute arguments on that beginning tag.
90+
* @param Parser $parser Mediawiki Parser Object
91+
* @param PPFrame $frame Mediawiki PPFrame Object
92+
*
93+
* @return string HTML
94+
*/
95+
public static function renderTabberTransclude( $input, array $args, Parser $parser, PPFrame $frame ) {
96+
$parser->getOutput()->addModules( [ 'ext.tabberNeue' ] );
97+
$selected = true;
98+
99+
$arr = explode( "\n", $input );
100+
$htmlTabs = '';
101+
foreach ( $arr as $tab ) {
102+
$htmlTabs .= self::buildTabTransclude( $tab, $parser, $frame, $selected );
103+
}
104+
105+
$html = '<div class="tabber">' .
106+
'<section class="tabber__section">' . $htmlTabs . "</section></div>";
107+
108+
return $html;
109+
}
110+
111+
/**
112+
* Build individual tab.
113+
*
114+
* @param string $tab Tab information
115+
* @param Parser $parser Mediawiki Parser Object
116+
* @param PPFrame $frame Mediawiki PPFrame Object
117+
* @param bool $selected The tab is the selected one
118+
*
119+
* @return string HTML
120+
*/
121+
private static function buildTabTransclude( $tab, Parser $parser, PPFrame $frame, &$selected ) {
122+
$tab = trim( $tab );
123+
if ( empty( $tab ) ) {
124+
return '';
125+
}
126+
127+
$tabBody = '';
128+
$dataProps = [];
129+
// Use array_pad to make sure at least 2 array values are always returned
130+
list( $pageName, $tabName ) = array_pad( explode( '|', $tab, 2 ), 2, '' );
131+
$title = Title::newFromText( trim( $pageName ) );
132+
if ( !$title ) {
133+
if ( empty( $tabName ) ) {
134+
$tabName = $pageName;
135+
}
136+
$tabBody = sprintf( '<div class="error">Invalid title: %s</div>', $pageName );
137+
$pageName = '';
138+
} else {
139+
$pageName = $title->getPrefixedText();
140+
if ( empty( $tabName ) ) {
141+
$tabName = $pageName;
142+
}
143+
$dataProps['page-title'] = $pageName;
144+
if ( $selected ) {
145+
$tabBody = $parser->recursiveTagParseFully(
146+
sprintf( '{{:%s}}', $pageName ),
147+
$frame
148+
);
149+
} else {
150+
// Add a link placeholder, as a fallback if JavaScript doesn't execute
151+
$linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
152+
$tabBody = sprintf(
153+
'<div class="tabber__ajaxplaceholder">%s</div>',
154+
$linkRenderer->makeLink( $title, null, [ 'rel' => 'nofollow' ] )
155+
);
156+
$dataProps['pending-load'] = '1';
157+
// 1.37: $currentTitle = $parser->getPage();
158+
$currentTitle = $parser->getTitle();
159+
$query = sprintf(
160+
'?action=parse&format=json&formatversion=2&title=%s&text={{:%s}}&redirects=1&prop=text&disablelimitreport=1&disabletoc=1&wrapoutputclass=',
161+
urlencode( $currentTitle->getPrefixedText() ),
162+
urlencode( $pageName )
163+
);
164+
$dataProps['load-url'] = wfExpandUrl( wfScript( 'api' ) . $query, PROTO_CANONICAL );
165+
$oldTabBody = $tabBody;
166+
// Allow extensions to update the lazy loaded tab
167+
Hooks::run( 'TabberTranscludeRenderLazyLoadedTab', [ &$tabBody, &$dataProps, $parser, $frame ] );
168+
if ( $oldTabBody != $tabBody ) {
169+
$parser->getOutput()->recordOption( 'tabbertranscludelazyupdated' );
170+
}
171+
}
172+
// Register as a template
173+
$revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title );
174+
$parser->getOutput()->addTemplate(
175+
$title,
176+
$title->getArticleId(),
177+
$revRecord ? $revRecord->getId() : null
178+
);
179+
}
180+
181+
$tab = '<article class="tabber__panel" title="' . htmlspecialchars( $tabName ) . '"';
182+
$tab .= implode( array_map( static function ( $prop, $value ) {
183+
return sprintf( ' data-tabber-%s="%s"', $prop, htmlspecialchars( $value ) );
184+
}, array_keys( $dataProps ), $dataProps ) );
185+
$tab .= '>' . $tabBody . '</article>';
186+
$selected = false;
187+
188+
return $tab;
189+
}
80190
}

modules/ext.tabberNeue.js

+93-8
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
function initTabber( tabber, count ) {
88
var tabPanels = tabber.querySelectorAll( ':scope > .tabber__section > .tabber__panel' );
99

10-
var container = document.createElement( 'header' ),
10+
var config = require( './config.json' ),
11+
container = document.createElement( 'header' ),
1112
tabList = document.createElement( 'nav' ),
1213
prevButton = document.createElement( 'div' ),
1314
nextButton = document.createElement( 'div' );
@@ -149,22 +150,62 @@ function initTabber( tabber, count ) {
149150
updateButtons();
150151
} );
151152

152-
// Listen for window resize
153-
window.addEventListener( 'resize', mw.util.debounce( 250, setupButtons ) );
153+
// Listen for element resize
154+
if ( window.ResizeObserver ) {
155+
var tabListResizeObserver = new ResizeObserver( mw.util.debounce( 250, setupButtons ) );
156+
tabListResizeObserver.observe( tabList );
157+
}
154158
};
155159

160+
// NOTE: Are there better ways to scope them?
161+
var xhr = new XMLHttpRequest();
162+
var currentRequest = null, nextRequest = null;
163+
164+
/**
165+
* Loads page contents into tab
166+
*
167+
* @param {HTMLElement} tab panel
168+
* @param {string} api URL
169+
*/
170+
function loadPage( targetPanel, url ) {
171+
var requestData = {
172+
url: url,
173+
targetPanel: targetPanel
174+
};
175+
if ( currentRequest ) {
176+
if ( currentRequest.url != requestData.url ) {
177+
nextRequest = requestData;
178+
}
179+
// busy
180+
return;
181+
}
182+
xhr.open( 'GET', url );
183+
currentRequest = requestData;
184+
xhr.send( null );
185+
}
186+
156187
/**
157188
* Show panel based on target hash
158189
*
159190
* @param {string} targetHash
160191
*/
161-
function showPanel( targetHash ) {
192+
function showPanel( targetHash, allowRemoteLoad ) {
162193
var ACTIVETABCLASS = 'tabber__tab--active',
163194
ACTIVEPANELCLASS = 'tabber__panel--active',
164195
targetPanel = document.getElementById( targetHash ),
165196
targetTab = document.getElementById( 'tab-' + targetHash ),
166197
section = targetPanel.parentElement,
167-
activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS );
198+
activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS ),
199+
parentPanel, parentSection;
200+
201+
if ( allowRemoteLoad && targetPanel.dataset.tabberPendingLoad && targetPanel.dataset.tabberLoadUrl ) {
202+
var loading = document.createElement( 'div' );
203+
loading.setAttribute( 'class', 'tabber__loading' );
204+
loading.appendChild( document.createTextNode( mw.message( 'tabberneue-loading' ).text() ) );
205+
targetPanel.textContent = '';
206+
targetPanel.appendChild( loading );
207+
loadPage( targetPanel, targetPanel.dataset.tabberLoadUrl );
208+
}
168209

169210
/* eslint-disable mediawiki/class-doc */
170211
if ( activePanel ) {
@@ -212,6 +253,48 @@ function initTabber( tabber, count ) {
212253
/* eslint-enable mediawiki/class-doc */
213254
}
214255

256+
/**
257+
* Event handler for XMLHttpRequest where ends loading
258+
*/
259+
function onLoadEndPage() {
260+
var targetPanel = currentRequest.targetPanel;
261+
if ( xhr.status != 200 ) {
262+
var err = document.createElement( 'div' );
263+
err.setAttribute( 'class', 'tabber__error' );
264+
err.appendChild( document.createTextNode( mw.message( 'tabberneue-error' ).text() ) );
265+
targetPanel.textContent = '';
266+
targetPanel.appendChild( err );
267+
} else {
268+
var result = JSON.parse( xhr.responseText );
269+
targetPanel.innerHTML = result.parse.text;
270+
// wikipage.content hook requires a jQuery object
271+
mw.hook( 'wikipage.content' ).fire( $( targetPanel ) );
272+
delete targetPanel.dataset.tabberPendingLoad;
273+
delete targetPanel.dataset.tabberLoadUrl;
274+
}
275+
276+
var ACTIVEPANELCLASS = 'tabber__panel--active',
277+
targetHash = targetPanel.getAttribute( 'id' ),
278+
section = targetPanel.parentElement,
279+
activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS );
280+
281+
if ( nextRequest ) {
282+
currentRequest = nextRequest;
283+
nextRequest = null;
284+
xhr.open( 'GET', currentRequest.url );
285+
xhr.send( null );
286+
} else {
287+
currentRequest = null;
288+
}
289+
if ( activePanel ) {
290+
// Refresh height
291+
showPanel( targetHash, false );
292+
}
293+
}
294+
295+
xhr.timeout = 20000;
296+
xhr.addEventListener( 'loadend', onLoadEndPage );
297+
215298
/**
216299
* Retrieve target hash and trigger show panel
217300
* If no targetHash is invalid, use the first panel
@@ -244,9 +327,11 @@ function initTabber( tabber, count ) {
244327
tab.addEventListener( 'click', function( event ) {
245328
var targetHash = tab.getAttribute( 'href' ).substring( 1 );
246329
event.preventDefault();
247-
// Add hash to the end of the URL
248-
history.replaceState( null, null, '#' + targetHash );
249-
showPanel( targetHash );
330+
if ( !config || config.updateLocationOnTabChange ) {
331+
// Add hash to the end of the URL
332+
history.replaceState( null, null, '#' + targetHash );
333+
}
334+
showPanel( targetHash, true );
250335
} );
251336
} );
252337

0 commit comments

Comments
 (0)