Merge pull request #281 from StarCitizenTools/dev

Implement collapsible section
This commit is contained in:
alistair3149 2021-03-02 19:36:29 -05:00 committed by GitHub
commit 1b7109b064
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 411 additions and 14 deletions

View file

@ -34,6 +34,7 @@ Note that:
Name | Description | Values | Default
:--- | :--- | :--- | :---
`$wgCitizenThemeDefault` | The default theme of the skin | `auto` - switch between light and dark according to OS/browser settings; `light`; `dark` | `auto`
`$wgCitizenEnableCollapsibleSections` | Enables or disable collapsible sections on content pages | `true` - enable; `false` - disable | `true`
`$wgCitizenShowPageTools` | The condition of page tools visibility | `true` - always visible; `login` - visible to logged-in users; `permission` - visible to users with the right permissions | `true`
`$wgCitizenEnableDrawerSubSearch` | Enables the drawer search box for menu entries | `true` - enable; `false` - disable | `false`
`$wgCitizenPortalAttach` | Label of the portal to attach links to upload and special pages to | string | `first`

View file

@ -0,0 +1,244 @@
<?php
/**
* Citizen - A responsive skin developed for the Star Citizen Wiki
*
* This file is part of Citizen.
*
* Citizen is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Citizen is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Citizen. If not, see <https://www.gnu.org/licenses/>.
*
* @file
* @ingroup Skins
*/
declare( strict_types=1 );
namespace Citizen\Partials;
use DOMDocument;
use DOMElement;
use DOMXpath;
use Html;
final class BodyContent extends Partial {
/**
* The code below is largely based on the extension MobileFrontend
* All credits go to the author and contributors of the project
*/
/**
* Class name for collapsible section wrappers
*/
public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'section-collapsible';
/**
* List of tags that could be considered as section headers.
* @var array
*/
private $topHeadingTags = [ "h1", "h2", "h3", "h4", "h5", "h6 " ];
/**
* Rebuild the body content
*
* @param string $out OutputPage
* @return string html
*/
public function buildBodyContent( $out ) {
$printSource = Html::rawElement( 'div', [ 'class' => 'printfooter' ], $this->skin->printSource() );
$htmlBodyContent = $bodyContent = $out->getHTML() . "\n" . $printSource;
$title = $out->getTitle();
// Check if it is not the main page
if ( !$title->isMainPage() ) {
$doc = new DOMDocument;
$doc->loadHTML( $bodyContent );
// Make top level sections
$this->makeSections( $doc, $this->getTopHeadings( $doc ) );
// Mark subheadings
$this->markSubHeadings( $this->getSubHeadings( $doc ) );
$htmlBodyContent = $doc->saveHTML();
}
$newBodyContent = $this->skin->wrapHTMLPublic( $title, $htmlBodyContent );
return $newBodyContent;
}
/**
* Actually splits splits the body of the document into sections
*
* @param DOMElement $doc representing the HTML of the current article. In the HTML the sections
* should not be wrapped.
* @param DOMElement[] $headings The headings returned by
* @return DOMDocument
*/
private function makeSections( DOMDocument $doc, array $headings ) {
$xpath = new DOMXpath( $doc );
$containers = $xpath->query( 'body/div[@class="mw-parser-output"][1]' );
// Return if no parser output is found
if ( !$containers->length ) {
return;
}
$container = $containers->item( 0 );
$containerChild = $container->firstChild;
$firstHeading = reset( $headings );
$firstHeadingName = $firstHeading ? $firstHeading->nodeName : false;
$sectionNumber = 0;
$sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber, false );
while ( $containerChild ) {
$node = $containerChild;
$containerChild = $containerChild->nextSibling;
// If we've found a top level heading, insert the previous section if
// necessary and clear the container div.
// Note well the use of DOMNode#nodeName here. Only DOMElement defines
// DOMElement#tagName. So, if there's trailing text - represented by
// DOMText - then accessing #tagName will trigger an error.
if ( $node->nodeName === $firstHeadingName ) {
// The heading we are transforming is always 1 section ahead of the
// section we are currently processing
/** @phan-suppress-next-line PhanTypeMismatchArgument DOMNode vs. DOMElement */
$this->prepareHeading( $doc, $node, $sectionNumber + 1 );
// Insert the previous section body and reset it for the new section
$container->insertBefore( $sectionBody, $node );
$sectionNumber += 1;
$sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber );
continue;
}
// If it is not a top level heading, keep appending the nodes to the
// section body container.
$sectionBody->appendChild( $node );
}
// Append the last section body.
$container->appendChild( $sectionBody );
return $doc;
}
/**
* Prepare section headings, add required classes
*
* @param DOMDocument $doc
* @param DOMElement $heading
* @param int $sectionNumber
*/
private function prepareHeading( DOMDocument $doc, DOMElement $heading, $sectionNumber ) {
$className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
$heading->setAttribute( 'class', $className . 'section-heading' );
// prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
$indicator = $doc->createElement( 'div' );
$indicator->setAttribute( 'class', 'section-toggle' );
$indicator->setAttribute( 'role', 'button' );
$heading->insertBefore( $indicator, $heading->firstChild );
}
/**
* Creates a Section body element
*
* @param DOMDocument $doc
* @param int $sectionNumber
*
* @return DOMElement
*/
private function createSectionBodyElement( DOMDocument $doc, $sectionNumber ) {
$sectionBody = $doc->createElement( 'section' );
$sectionBody->setAttribute( 'class', self::STYLE_COLLAPSIBLE_SECTION_CLASS );
$sectionBody->setAttribute( 'id', 'section-collapsible-' . $sectionNumber );
return $sectionBody;
}
/**
* Gets top headings in the document.
*
* @param DOMDocument $doc
* @return array An array first is the highest rank headings
*/
private function getTopHeadings( DOMDocument $doc ): array {
$headings = [];
foreach ( $this->topHeadingTags as $tagName ) {
$allTags = $doc->getElementsByTagName( $tagName );
foreach ( $allTags as $el ) {
if ( $el->parentNode->getAttribute( 'class' ) !== 'toctitle' ) {
$headings[] = $el;
}
}
if ( $headings ) {
return $headings;
}
}
return $headings;
}
/**
* Marks the subheadings for the approiate styles by adding
* the <code>section-subheading</code> class to each of them, if it
* hasn't already been added.
*
* @param DOMElement[] $headings Heading elements
*/
protected function markSubHeadings( array $headings ) {
foreach ( $headings as $heading ) {
$class = $heading->getAttribute( 'class' );
if ( strpos( $class, 'section-subheading' ) === false ) {
$heading->setAttribute(
'class',
ltrim( $class . ' section-subheading' )
);
}
}
}
/**
* Gets all subheadings in the document in rank order.
*
* @param DOMDocument $doc
* @return DOMElement[]
*/
private function getSubHeadings( DOMDocument $doc ): array {
$found = false;
$subheadings = [];
foreach ( $this->topHeadingTags as $tagName ) {
$allTags = $doc->getElementsByTagName( $tagName );
$elements = [];
foreach ( $allTags as $el ) {
if ( $el->parentNode->getAttribute( 'class' ) !== 'toctitle' ) {
$elements[] = $el;
}
}
if ( $elements ) {
if ( !$found ) {
$found = true;
} else {
$subheadings = array_merge( $subheadings, $elements );
}
}
}
return $subheadings;
}
}

View file

@ -22,6 +22,7 @@
*/
use Citizen\GetConfigTrait;
use Citizen\Partials\BodyContent;
use Citizen\Partials\Drawer;
use Citizen\Partials\Footer;
use Citizen\Partials\Header;
@ -51,6 +52,7 @@ class SkinCitizen extends SkinMustache {
public function __construct( $options = [] ) {
$skin = $this;
$out = $skin->getOutput();
$title = $out->getTitle();
$metadata = new Metadata( $this );
$skinTheme = new Theme( $this );
@ -60,6 +62,35 @@ class SkinCitizen extends SkinMustache {
// Theme handler
$skinTheme->setSkinTheme( $options );
// Only load in content pages
if ( $title->isContentPage() ) {
// Load Citizen collapsible sections modules if enabled
if ( $this->getConfigValue( 'CitizenEnableCollapsibleSections' ) === true ) {
$options['scripts'] = array_merge(
$options['scripts'],
[ 'skins.citizen.scripts.sections' ]
);
$options['styles'] = array_merge(
$options['styles'],
[
'skins.citizen.styles.sections',
'skins.citizen.icons.sections'
]
);
}
// Load table of content script if ToC presents
if ( $out->isTOCEnabled() ) {
// Add class to body that notifies the page has TOC
$out->addBodyClasses( 'skin-citizen-has-toc' );
// Disabled style condition loading due to pop in
$options['scripts'] = array_merge(
$options['scripts'],
[ 'skins.citizen.scripts.toc' ]
);
}
}
// Load Citizen search suggestion styles if enabled
if ( $this->getConfigValue( 'CitizenEnableSearch' ) === true ) {
$options['styles'] = array_merge(
@ -91,17 +122,6 @@ class SkinCitizen extends SkinMustache {
);
}
// Load table of content script if ToC presents
if ( $out->isTOCEnabled() ) {
// Add class to body that notifies the page has TOC
$out->addBodyClasses( 'skin-citizen-has-toc' );
// Disabled style condition loading due to pop in
$options['scripts'] = array_merge(
$options['scripts'],
[ 'skins.citizen.scripts.toc' ]
);
}
$options['templateDirectory'] = __DIR__ . '/templates';
parent::__construct( $options );
}
@ -116,6 +136,7 @@ class SkinCitizen extends SkinMustache {
$header = new Header( $this );
$drawer = new Drawer( $this );
$bodycontent = new BodyContent( $this );
$footer = new Footer( $this );
$tools = new PageTools( $this );
@ -161,6 +182,8 @@ class SkinCitizen extends SkinMustache {
'msg-tagline' => $this->msg( 'tagline' )->text(),
'html-body-content--formatted' => $bodycontent->buildBodyContent( $out ),
'data-footer' => $footer->getFooterData(),
];
}
@ -201,6 +224,17 @@ class SkinCitizen extends SkinMustache {
return parent::buildContentNavigationUrls();
}
/**
* Change access to public, as it is used in partials
*
* @param Title $title
* @param string $html body text
* @return array
*/
final public function wrapHTMLPublic( $title, $html ) {
return parent::wrapHTML( $title, $html );
}
/**
* @param string $label to be used to derive the id and human readable label of the menu
* If the key has an entry in the constant MENU_LABEL_KEYS then that message will be used for the

View file

@ -39,7 +39,7 @@
<div id="bodyContent" class="mw-body-content">
<div id="contentSub"{{{html-user-language-attributes}}}>{{{html-subtitle}}}</div>
{{#html-undelete-link}}<div id="contentSub2">{{{html-undelete-link}}}</div>{{/html-undelete-link}}
{{{html-body-content}}}
{{{html-body-content--formatted}}}
{{{html-categories}}}
</div>
</main>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<title>
collapse
</title>
<path d="M2.5 15.25l7.5-7.5 7.5 7.5 1.5-1.5-9-9-9 9z"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View file

@ -0,0 +1,19 @@
function bindClick( collToggle, collSection, i, j ) {
return function () {
j = i + 1;
this.classList.toggle( 'section-toggle--collapsed' );
collSection[ j ].classList.toggle( 'section-collapsible--collapsed' );
};
}
function main() {
var collSection = document.getElementsByClassName( 'section-collapsible' ),
collToggle = document.getElementsByClassName( 'section-toggle' ),
i, j;
for ( i = 0; i < collToggle.length; i++ ) {
collToggle[ i ].addEventListener( 'click', bindClick( collToggle, collSection, i, j ) );
}
}
main();

View file

@ -0,0 +1,64 @@
@import '../variables.less';
.section {
&-toggle {
width: 18px;
height: 18px;
padding: 5px;
margin-right: 10px;
background-position: center;
background-repeat: no-repeat;
background-size: 18px;
cursor: pointer;
opacity: var( --opacity-icon-base );
transition: @transition-opacity-quick, @transition-transform-quick;
&:hover {
opacity: var( --opacity-icon-base--hover );
}
&:active {
opacity: var( --opacity-icon-base--active );
}
&--collapsed {
transform: rotate3d( 1, 0, 0, 180deg );
~ .mw-headline {
color: var( --color-base );
}
}
}
&-heading {
.mw-headline {
transition: @transition-color-quick;
}
}
// Fix button alignment
&-heading,
&-subheading {
display: flex;
align-items: center;
}
&-collapsible {
&--collapsed {
display: none;
}
}
}
.skin-citizen-dark {
.section-toggle {
filter: invert( 1 );
}
}
// Hide toggle when client is noscript
.client-nojs {
.section-toggle {
display: none;
}
}

View file

@ -42,7 +42,7 @@
.mw-editsection {
display: flex;
margin-left: 10px;
margin-left: auto;
a {
.resource-loader-icon-link-small;

View file

@ -1,6 +1,6 @@
{
"name": "Citizen",
"version": "1.3.5",
"version": "1.4.0",
"author": [
"[https://www.mediawiki.org/wiki/User:Alistair3149 Alistair3149]",
"[https://www.mediawiki.org/wiki/User:Octfx Octfx]"
@ -139,6 +139,15 @@
"features": [],
"styles": [ "resources/skins.citizen.styles.toc/skins.citizen.styles.toc.less" ]
},
"skins.citizen.styles.sections": {
"class": "ResourceLoaderSkinModule",
"targets": [
"desktop",
"mobile"
],
"features": [],
"styles": [ "resources/skins.citizen.styles.sections/skins.citizen.styles.sections.less" ]
},
"skins.citizen.scripts": {
"packageFiles": [
"resources/skins.citizen.scripts/skin.js",
@ -189,6 +198,11 @@
"resources/skins.citizen.scripts.drawer/drawerSubSearch.js"
]
},
"skins.citizen.scripts.sections": {
"scripts": [
"resources/skins.citizen.scripts.sections/Sections.js"
]
},
"skins.citizen.icons": {
"class": "ResourceLoaderImageModule",
"selector": "{name}",
@ -306,6 +320,14 @@
"light": "resources/skins.citizen.icons/shared/moon.svg",
"dark": "resources/skins.citizen.icons/shared/bright.svg"
}
},
"skins.citizen.icons.sections": {
"class": "ResourceLoaderImageModule",
"selector": ".section-toggle",
"useDataURI": false,
"images": {
"collapse": "resources/skins.citizen.icons/shared/collapse.svg"
}
}
},
"ResourceFileModulePaths": {
@ -619,6 +641,12 @@
"description": "Enables to search the drawer for menu entries",
"descriptionmsg": "citizen-config-enabledrawersubsearch",
"public": true
},
"EnableCollapsibleSections": {
"value": true,
"description": "Enables or disable collapsible sections on content pages",
"descriptionmsg": "citizen-config-enablecollapsiblesections",
"public": true
}
},
"manifest_version": 2