diff --git a/README.md b/README.md index 9084e72e..c3ad5d0a 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/includes/Partials/BodyContent.php b/includes/Partials/BodyContent.php new file mode 100644 index 00000000..11ed2fa1 --- /dev/null +++ b/includes/Partials/BodyContent.php @@ -0,0 +1,244 @@ +. + * + * @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 section-subheading 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; + } +} diff --git a/includes/SkinCitizen.php b/includes/SkinCitizen.php index 7e77af0d..de28a439 100644 --- a/includes/SkinCitizen.php +++ b/includes/SkinCitizen.php @@ -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 diff --git a/includes/templates/skin.mustache b/includes/templates/skin.mustache index 243569eb..861f84c9 100644 --- a/includes/templates/skin.mustache +++ b/includes/templates/skin.mustache @@ -39,7 +39,7 @@
{{{html-subtitle}}}
{{#html-undelete-link}}
{{{html-undelete-link}}}
{{/html-undelete-link}} - {{{html-body-content}}} + {{{html-body-content--formatted}}} {{{html-categories}}}
diff --git a/resources/skins.citizen.icons/shared/collapse.svg b/resources/skins.citizen.icons/shared/collapse.svg new file mode 100644 index 00000000..9bbf6622 --- /dev/null +++ b/resources/skins.citizen.icons/shared/collapse.svg @@ -0,0 +1,7 @@ + + + + collapse + + + diff --git a/resources/skins.citizen.scripts.sections/sections.js b/resources/skins.citizen.scripts.sections/sections.js new file mode 100644 index 00000000..6be1767a --- /dev/null +++ b/resources/skins.citizen.scripts.sections/sections.js @@ -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(); diff --git a/resources/skins.citizen.styles.sections/skins.citizen.styles.sections.less b/resources/skins.citizen.styles.sections/skins.citizen.styles.sections.less new file mode 100644 index 00000000..9f83394c --- /dev/null +++ b/resources/skins.citizen.styles.sections/skins.citizen.styles.sections.less @@ -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; + } +} diff --git a/resources/skins.citizen.styles/common/content.less b/resources/skins.citizen.styles/common/content.less index bda076bb..13d79ff9 100644 --- a/resources/skins.citizen.styles/common/content.less +++ b/resources/skins.citizen.styles/common/content.less @@ -42,7 +42,7 @@ .mw-editsection { display: flex; - margin-left: 10px; + margin-left: auto; a { .resource-loader-icon-link-small; diff --git a/skin.json b/skin.json index 72ddc465..480d567e 100644 --- a/skin.json +++ b/skin.json @@ -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