Sommaire d'articles

Ajout d’un sommaire aux articles

Cet article a été réactualisé il y a 3 ans jours. Il n'est pas nécessairement obsolète, mais gardez son ancienneté en tête lors de sa lecture.

Quand un article est relativement long, on peut apporter un plus pour y naviguer et avoir un aperçu de son contenu en fournissant une liste des titres et sous titres qui le compose.

Adaptation sur Pixi d’un tutoriel de développeur WP

L’application de cette fonctionnalité sur Pixiscreen me vient d’un tutoriel proposé par Willy Bahuaud : « Un sommaire pour vos articles, sans plugin » Je vous invite à suivre son tuto si cette implémentation à la « mano » vous intéresse, c’est riche d’enseignements.

Pour ma part, je ne vais m’attacher ici qu’à apporter des infos complémentaires relatives à son adaptation pour mon site.

Le principe de fonctionnement

Lors de la rédaction d’ articles nous utilisons des titres pour hiérarchiser le contenu. Le tutoriel de W. Bahuaud explique comment faire en sorte d’automatiser l’ajout d’une ancre sur ces titres en utilisant un filtre WordPress (add_filter) sur le contenu des posts qui va rechercher les différentes balises hn et placer un id (le titre lui même mais reformaté) sur ces balises. Ensuite une autre fonction permet de générer le sommaire à l’endroit souhaité dans la page.

J’aurais bien été incapable d’écrire ce code, merci à Willy de partager ça avec nous ! De quoi s’exercer à l’utilisation d’ expressions régulières, de fonctions php, de conditions, de boucles …

Fonctionnalité en plugin ou dans le thème ?

J’ai opté pour mettre cette fonctionnalité dans mon thème dans la mesure où la gestion du positionnement du sommaire et son apparence sont fortement liée à la structure html/css de mon site.

Le php

Pour ne pas surcharger le fichier functions.php, je place la gestion php du sommaire dans un dossier inc à la racine du thème, appelé dans functions.php via un require get_template_directory(). '/inc/post-summary.php';

<?php
/**
 * Créer un sommaire pour les articles sur page single
 *
 * @link https://wabeo.fr/sommaire-article-wordpress/
 */

/**
 * Génèse des ancres dans le contenu de l'article
 */
function replace_ca( $matches ) {
	return '<h' . $matches[1] . $matches[2] . ' id="' . sanitize_title( $matches[3] ) . '">' . $matches[3] . '</h' . $matches[4] . '>';
}

/**
 * Ajout d'un filtre sur le contenu pour ajouter les ID sur les hn.
 */
function add_anchor_to_title( $content ) {
	if ( is_singular( 'post' ) ) { // S'il s'agit d'un article.
		global $post;
		$pattern = '/<h([2-4])(.*?)>(.*?)<\/h([2-4])>/i'; // Option i pour ne pas faire de difference entre majuscules/minuscules

		$content = preg_replace_callback( $pattern, 'replace_ca', $content );
		return $content;
	} else {
		return $content;
	}
}
add_filter( 'the_content', 'add_anchor_to_title', 12 );

/**
 * Function automenu( $echo = false )
 * Générer le sommaire à afficher
 */
function automenu( $echo ) {
	global $post;
	$obj  = '<nav id="sommaire-article">';
	$original_content = $post->post_content;
	$patt = '/<h([2-4])(.*?)>(.*?)<\/h([2-4])>/i';
	preg_match_all( $patt, $original_content, $results );

	$lvl1 = 0;
	$lvl2 = 0;
	$lvl3 = 0;

	foreach ( $results[3] as $k => $r ) {
		switch($results[1][$k]) {
			case 2:
				$lvl1++;
				$niveau = '<span class="title_lvl">' . $lvl1 . '. </span>';
				$lvl2   = 0;
				$lvl3   = 0;
				break;

			case 3:
				$lvl2++;
				//$niveau = '<span class="title_lvl">'.base_convert(($lvl2+9),10,36).'.</span>'; // transforme en lettres
				$niveau = '<span class="title_lvl">' . $lvl1 . '.' . $lvl2 . '- </span>';
				$lvl3   = 0;
				break;

			case 4:
				$lvl3++;
				$niveau = '<span class="title_lvl">' . $lvl3 . ') </span>';
				break;
		}
		$obj .= '<a href="#' . sanitize_title( $r ) . '" class="title_lvl' . $results[1][$k] . '">' . $niveau . $r . '</a>';
	}

	$obj .= '</nav>';
	if ( $echo ) {
		echo $obj;
	} else {
		return $obj;
	}
}

Pour les explications de code référez vous au tutoriel d’origine.

Ensuite j’ai utilisé la fonction automenu(true); dans le template single.php pour ajouter le sommaire en barre latérale restant visible quand on fait défiler le contenu de l’article. Je n’avais pas besoin d’utiliser le shortcode proposé qui permet de placer le sommaire où on le souhaite dans le contenu de l’article.

Le js

Se déplacer de façon fluide dans la page

Le clic sur un item du sommaire fait que la transition vers la portion de l’article visée est brutale, ajouter une transition fluide permet de visualiser la montée ou descente de la page.

J’ai opté pour un scroll fluide en jQuery qui prend en charge TOUTES les ancres présentes dans le site, pas seulement celles du sommaire généré. Cette fois il s’agit d’un tutoriel présent sur CSSTricks : « Smooth scrolling« , qui a l’avantage de gérer aussi le focus pour l’accessibilité.

Petite parenthèse a11y, depuis que j’utilise l’insertion de code dans mes pages à l’aide de Advanced Gutenberg Blocks, j’ai un soucis au niveau de la navigation à la tabulation. Dès que j’arrive sur une portion de code, ma tabulation est capturée là et je ne peux plus ni reculer ni avancer. Il va falloir regarder ça de plus près.
https://axesslab.com/skip-links/

On en revient au JS, l’apparence de Pixi comporte quelques contraintes, la façon dont le scroll doit fonctionner dépend de la largeur de l’écran, le positionnement du sommaire aussi. Quand l’écran est large (media query>1280px) j’ai une barre en haut de page pour le fil d’ariane (breadcrumb). Quand l’écran est plus petit, c’est tout mon header qui prend le haut de page … Un casse tête que j’ai finit par résoudre à coup de css avec du positionnement relatif, absolu, et prise en compte des media query dans le JS …

Voici ce j’ai mis en place pour le scroll :

jQuery(document).ready(function ($) {
  /*****************************************
  *                                      *
  *   Scroll for all links with hashes   *
  *                                      *
  ****************************************/
 // Media Query to detect
  function screenMin1280() {
  'use strict';
  var mq = window.matchMedia("(min-width: 1280px)");
  return mq.matches;
  }

  // Select all links with hashes
  $('a[href*="#"]')
  // Remove links that don't actually link to anything
  .not('[href="#"]')
  .not('[href="#0"]')
  .click(function (event) {
    // On-page links
    if (
      location.pathname.replace(/^\//, '') == this.pathname.replace(/^\//, '') &&
      location.hostname == this.hostname
    ) {
      // Figure out element to scroll to
      var target = $(this.hash);
      target = target.length ? target : $('[name=' + this.hash.slice(1) + ']');
      // Does a scroll target exist?
      if (target.length) {
        // Only prevent default if animation is actually gonna happen
        event.preventDefault();

        if (screenMin1280()) {
          // offset -70 because of my breadcrumb
          $('html, body').animate({
              scrollTop: target.offset().top - 70
          }, 1000, function () {
            // Callback after animation
            // Must change focus!
            var $target = $(target);
            $target.focus();
            if ($target.is(":focus")) { // Checking if the target was focused
              return false;
            } else {
              $target.attr('tabindex', '-1'); // Adding tabindex for elements not focusable
              $target.focus(); // Set focus again
            }
          });

        } else {
          $('html, body').animate({
              scrollTop: target.offset().top
          }, 1000, function () {
            // Callback after animation
            // Must change focus!
            var $target = $(target);
            $target.focus();
            if ($target.is(":focus")) { // Checking if the target was focused
              return false;
            } else {
              $target.attr('tabindex', '-1'); // Adding tabindex for elements not focusable
              $target.focus(); // Set focus again
            }
          });
        }
      }
    }
  });
});

Je suis répétitive pour le coup du fait de la prise en compte de la présence de ma barre « breadcrumb » en haut de page ou non selon la taille de l’écran.

Comme toutes les ancres sont prises en charge par le code jQuery ci dessus, j’ai du quand même gérer à côte de ça le « Back to top » autrement.

jQuery(document).ready(function ($) {
  /******************************
   *                            *
   *   Scroll for Back to top   *
   *                            *
   *****************************/
  // My scroll link element
  var scrollElem = $('.topbutton');
  // When to show the scroll link
  // higher number = scroll link appears further down the page
  var bttOffset = 100;
  // Scroll Speed. Change the number to change the speed
  var bttSpeed = 250;
  // Scroll Duration. Change the number to change the duration
  var bttDuration = 500;

  $(window).scroll(function(){
    if ($(this).scrollTop() < bttOffset) {
     scrollElem .fadeOut(bttDuration);
      } else {
     scrollElem .fadeIn(bttDuration);
    }
  });
  scrollElem.on('click', function() {
    $('html, body').animate({scrollTop:0}, bttSpeed);
    return false;
  });
})

Highlight des liens du sommaire quand le texte est inview

Pas simple de trouver un titre concis là ! Bon, le truc c’est d’ajouter une classe css « inview » sur les liens du sommaire dont les contenus correspondant dans l’article sont réellement visibles à l’écran lors du scroll horizontal. Du coup on peut styliser avec du css ces liens dans le sommaire pour visualiser où on en est dans sa lecture d’article.

Il existe des librairies jQuery prêtes à l’emploi pour gérer ce « inview », Waypoints par exemple. Mais comme on vient de suivre un tuto pour faire un sommaire sans plugin, pour continuer dans la lancée j’ai essayé de comprendre le code qu’utilise W. Bahuaud dans son site. Comprendre ce n’est déjà pas une mince affaire, quant à l’écrire soi même … bon … je n’en suis pas là du tout ! Mais l’exercice est formateur tout de même.

Voici ce que j’ai ajouté comme script pour que cela fonctionne sur Pixi : summary-inview.js

// Add class inview to links in nav summary when titles (hn) in the entry content are visible on screen.
// Like that we can highlight links in summary with css.
( function( $ ) {

/**
 * Fonction constructeur Sommaire
 */
  function Sommaire() {

  // On crée une fontion d'initialisation pour pouvoir créer une instance de l'objet
  this.init = function() {
    // Si on a un conteneur #sommaire-article qui correspond à l'affichage d'une navigation via un sommaire
    if ( document.getElementById( 'sommaire-article' ) ) {
      // On applique alors ces méthodes pour l'instance de l'objet à créer
      this.sumMap();
      this.track();
      this.resize();
    }
  };

  // On crée un nouvel objet vide pour le moment
  // C'est cet objet qui contiendra les valeurs associées aux propriétés que l'on souhaite
  //this.sum = new Object();
  this.sum = {}; // La notation littérale est préférée pour des raisons de performances

  // La méthode sumMap
  this.sumMap = function () {
    this.sum.$sommaire = $( '#sommaire-article' );
    // On crée un tableau des items du sommaire
    var items = [];
    // La méthode each() passe en revue les éléments sélectionnés, ici on recherche avec la méthode find() tous les a
    $.each( this.sum.$sommaire.find( 'a' ), function( i, el ){
      // la méthode push() ajoute un ou plusieurs éléments à la fin d'un tableau
      items.push( {
        element: $(el),
        ancre : $( el ).attr( 'href' ), // Récupérer la valeur de l'attribut href
        top : $( el ).position().top, // Coordonnées Y par rapport au parent
        height : $( el ).outerHeight(), // Obtenir la hauteur externe calculée
        pos : i,
      } );
    } );
    this.sum.items = items;

    // On crée un tableau des sections de l'article
    var sections = [];
    // La méthode each() passe en revue les éléments sélectionnés
    $( '.entry-content h2, .entry-content h3, .entry-content h4' ).each( function( i, el ) {
      sections.push( {
      ancre : $( el ).attr( 'id' ), // Récupérer la valeur de l'attribut id
      top : $( el ).offset().top, // Coordonnées de l'élément par rapport au haut du document
      } );
    } );
    for ( var z in sections ) {
      var next = parseInt( z ) + 1;
      sections[z].bottom = typeof sections[ next ] !== 'undefined' ? sections[ next ].top : $('.entry-content').height() + 600; // 600 arbitraire ajouté pour garder inview plus longtemps sur la dernière section d'article
    }
    this.sum.sections = sections;
  };

  // On prend les mesures lors du scroll par rapport à la fenêtre pour ajouter ou supprimer la classe inview
  this.track = function() {
    var self = this;
    $( window ).scroll( function() {
      scrollTop = $(window).scrollTop();
      scrollBottom = scrollTop + $(window).height();

      $.each( self.sum.sections, function( i, el ) {
        if ( scrollBottom > el.top && ( el.bottom -100 ) > scrollTop ) {
          $(self.sum.items[ i ].element).addClass('inview');
        } else {
          $(self.sum.items[ i ].element).removeClass('inview');
        }
      });
    } );
    $( window ).scroll();
  };

  // Si l'utilisateur modifie la taille de la fenêtre, on recommence les calculs.
  this.resize = function() {
    var thing = this;
    $( window ).on( 'resize', function() {
      thing.sumMap();
      thing.track();
    } );
    $( window ).resize();
  };
}

// On crée une instance de notre objet constructeur Sommaire
var mySum = new Sommaire();
// On l'initialise avec la méthode présente dans le constructeur
mySum.init();

})(jQuery);

Je vous laisse le loisir de décortiquer, comprendre pour en tirer quelque chose à vous approprier.

Le css

Il ne reste plus qu’à ajouter du css sur les classes et id du sommaire :

  • #sommaire-article
  • .title_lvl / .title_lvl2 / .title_lvl3 / .title_lvl4
  • .inview
  • Pour une question de place et de gestion de l’espace je n’affiche le sommaire que pour des résolutions d’écran supérieures à 1024px.
  • Et tout ce qui vous conviendra …

Cette partie style dépend complètement de la façon dont vous insérez le sommaire dans la page, et du « design » que vous souhaitez lui conférer, donc je m’arrête là pour ce mémo !