Feuilleter un livre avec l'API canvas - partie 3

Dans le premier article de cette série, nous avons mis en place le plus gros de la structure HTML et CSS nécessaire à la création de l’effet. Dans le second article, nous avons vu sur une petite démo comment créer cet effet à l’aide d’un canevas. Dans ce troisième et dernier article, nous allons fusionner tout cela dans un premier temps, puis modifier la façon dont l’utilisateur interagit avec le livre.

Insertion du canevas

Au niveau HTML, il suffira d’ajouter une balise <canvas> :

<div id="book">
    <canvas id="pageflip-canvas"></canvas>
    <section>
        <h2>History</h2>
        <p>Canvas was initially introduced by Apple ...</p>
    </section>
    ...
</div>

Pour la partie CSS, nous ajoutons une règle qui positionnera le canevas de manière absolue, tout comme son conteneur, le livre, l’a été. Nous nous assurons aussi que le canevas se trouve “au-dessus” du livre :

#pageflip-canvas {
    position: absolute;
    z-index: 100;
}

Enfin, nous initialisons quelques constantes JavaScript, et nous positionnons le canevas:

// Espace vertical entre le bord supérieur du livre et les pages
var PAGE_Y = ( BOOK_HEIGHT - PAGE_HEIGHT ) / 2;

// La taille du canevas est égale à celle du livre + cet espace
var CANVAS_PADDING = 60;

var canvas = document.getElementById( "pageflip-canvas" );
var context = canvas.getContext( "2d" );

// Redimensionner et déplacer le canevas pour qu'il entoure le livre
canvas.width = BOOK_WIDTH + ( CANVAS_PADDING * 2 );
canvas.height = BOOK_HEIGHT + ( CANVAS_PADDING * 2 );
canvas.style.top = -CANVAS_PADDING + "px";
canvas.style.left = -CANVAS_PADDING + "px";

Bien, mais pour l’instant ce canevas est parfaitement inutile. Nous allons maintenant ajouter la logique nécessaire.

Gestion de l’animation

Dans le premier article, nous passions d’une page à une autre en alternant les valeur 0 et PAGE_WIDTH pour la largeur des pages du livre, ce qui est instantané. Désormais nous voulons mettre en place une animation qui sera visible par le lecteur. Cette animation est composée de plusieurs éléments:

  • Nous ferons varier progressivement cette fois la largeur de la page.
  • Nous ajoutons le dessin dans le canevas que nous avons étudié la dernière fois

En résumé, là ou nous avions à modifier manuellement la valeur de la variable progress, nous aimerions maintenant qu’un clic sur le livre déclenche un processus qui modifiera automatiquement cette valeur, qui redimensionnera la page et qui dessinera dans le canevas en fonction de la valeur de cette variable.

Précédemment, nous avions créé un tableau d’objets flips qui nous avait servi à gérer les effets. Chaque objet de ce tableau n’avait qu’une propriété page qui était la page du livre associée à l’effet. Nous allons maintenant étoffer un peu ces objets, car notre effet est maintenant un tantinet plus complexe. Nous voulons notamment savoir:

  • Si l’effet associé à une page donnée est en cours ou terminé.
  • Où nous en sommes dans la progression de l’effet.
  • Quand on pourra considérer l’effet comme achevé.

Ajoutons tout cela à l’initialisation du tableau flips:

for ( var i = 0, len = pages.length; i < len; i++ ) {
    pages[i].style.zIndex = len - i;

    flips.push( {
        // Element du DOM concerné par l'effet
        page: pages[i],
        // Progression actuelle de l'animation de cette page
        progress: 1,
        // Cible que progress devra atteindre
        target: 1,
        // La page est-elle en train de tourner ?
        flipping: false
    } );
}

D’après les conventions que l’on a posé dès le début, une page pour laquelle progress vaut + 1 se trouve complètement à droite, et -1 complètement à gauche.

Ajoutons maintenant le code qui permet de prendre en compte ces nouvelles informations pour réaliser l’animation:

var FPS = 60;
// Afficher l'effet FPS fois par seconde
setInterval( render, 1000 / FPS );

...

function render() {
    // Effacer le canevas
    context.clearRect( 0, 0, canvas.width, canvas.height );

    // Parcours du tableau de gestion des effets
    for( var i = 0, len = flips.length; i < len; i++ ) {
        var flip = flips[i];
        if (flip.flipping) {
            // On progresse vers la cible
            flip.progress += ( flip.target - flip.progress ) * 0.1;
            // On dessine la page
            drawFlip( flip );
            // On vérifie si la cible est atteinte
            if (Math.abs(flip.target - flip.progress) < 0.003) {
                flip.flipping = false;
                flip.progress = flip.target;
            }
        }            
    }
}

La constante FPS représente le nombre de fois par seconde (théorique) où le dessin sera effectué par la fonction render(). On y parcourt le tableau flips et à chaque fois que l’on trouve un effet en cours:

  1. On le fait progresser vers sa cible
  2. On appelle la fonction drawFlip() qui dessine dans le canevas et qui modifie la largeur de la page
  3. On regarde si l’effet peut être considéré comme terminé ou non.

Maintenant, il faut modifier notre fonction qui gère le clic sur le livre. Là où elle ne faisait que modifier la largeur de la page, elle va désormais déclencher l’animation sur la page courante:

function mouseClickHandler( event ) {
    mouse.x = event.clientX - book.offsetLeft;
    mouse.y = event.clientY - book.offsetTop;

    // On s'assure que le pointeur de la souris est à l'intérieur du livre
    if (0 < mouse.x && mouse.x < BOOK_WIDTH && 0 < mouse.y && mouse.y < BOOK_HEIGHT) {
        if (mouse.x < PAGE_WIDTH && currentPage > 0) {
            flips[currentPage - 1].target = 1;
            flips[currentPage - 1].flipping = true;
            currentPage--;
        }
        else if (mouse.x >= PAGE_WIDTH && currentPage < flips.length - 1) {
            flips[currentPage].target = -1;
            flips[currentPage].flipping = true;
            currentPage++;
        }
    }
}

Quand un clic se produit sur la partie droite ou la partie gauche du livre, on positionne la cible de l’effet du côté inverse à celui où le clic s’est produit, puis on programme la prise en compte de l’effet par render() en passant la propriété flipping à true.

Si on teste tout çà, on a maintenant l’effet désiré, à un détail près: le texte des pages s’adapte progressivement à la largeur disponible. Vous conviendrez que cela n’arrive que rarement quand on feuillette un livre ! En fait, c’est un comportement tout à fait normal puisque nous modifions progressivement la largeur de l’élément <section> qui contient le texte. Pou corriger ce problème, nous allons insérer dans chaque <section> un <div>dans lequel nous déplacerons le texte et qui aura une taille fixe. De cette manière, la <section> agira comme un masque horizontal sur le contenu du <div>, qui lui ne sera pas affecté. Voici comment appliquer cette modification aux fichiers HTML:

<section>
    <div>
        <h2>History</h2>
        <p>Canvas was initially introduced by Apple...</p>
    </div>
</section>
<section>
    <div>
        ...
    </div>
</section>
<section>
    <div>
        ...
    </div>
</section>
<section>
    <div>
        ...
    </div>
</section>

Et voici pour la CSS:

section {
    background: url("paper.png") no-repeat;
    display: block;
    width: 400px;
    height: 250px;
    position: absolute;
    left: 415px;
    top: 5px;
    overflow: hidden;
    font-size: 12px;
}

section>div {
    display: block;
    width: 400px;
    height: 250px;
    font-size: 12px;
}

Tourner la page à la souris

Jusqu’à présent nous tournons les pages en cliquant sur le livre. Hors, sur 20thingsilearned, on a certes des boutons cliquables pour les pages précédente et suivante, mais il est aussi possible de “prendre” une page en cliquant sur son extrémité et en gardant le bouton de la souris appuyé, puis de tourner la page et finalement de la lâcher en relâchant le bouton de la souris. Voici les points importants du code qui rend cela possible:

Nous allons créer un objet mouse avec deux propriétés x et y, qui représentera les coordonnées du pointeur à un instant t, et qui sera constamment mis à jour via un écouteur sur mousemove.
Nous remplaçons la propriété flipping des objets contenus dans flips, qui représentait le fait qu’un effet était en cours d’animation, par la propriété dragging, qui représentera le fait que l’utilisateur est en train de manipuler une page via la souris. Cette propriété ne sera donc positionnée que pour une page au plus à un instant donné.
Sur l’événement mousedown, nous positionnons cette propriété à true, soit sur la page courante, soit sur la page précédente en fonction de la position de la souris.
Sur l’événement mouseup, nous retrouvons la page actuellement manipulée, et nous programmons l’animation pour qu’elle aille à son terme du côté duquel se trouve le pointeur de la souris au moment où l’utilisateur relâche la page. Nous devons aussi recalculer quand c’est nécessaire le numéro de page courante (à ce niveau, j’ai d’ailleurs corrigé un bug se trouvant dans le code de l’article original). Puis la propriété dragging est repositionnée à false.
L’animation de la page manipulée doit suivre le pointeur de souris. C’est la fonction render() qui va s’en charger en recalculant la propriété target de la page manipulée.
On obtient au final le code suivant:

var mouse = { x: 0, y: 0 };

function mouseMoveHandler( event ) {
    // Recalcul des coordonnées de la souris par rapport au bord supérieur de la reliure du livre
    mouse.x = event.clientX - book.offsetLeft - ( BOOK_WIDTH / 2 );
    mouse.y = event.clientY - book.offsetTop;
}

function mouseDownHandler( event ) {
    // On s'assure que le pointeur de la souris est à l'intérieur du livre
    if (Math.abs(mouse.x) < PAGE_WIDTH && 0 < mouse.y && mouse.y < BOOK_HEIGHT) {
        if (mouse.x < 0 && currentPage > 0) {
            // L'utilisateur commence à manipuler la page précédente
            flips[currentPage - 1].dragging = true;
        }
        else if (mouse.x > 0 && currentPage < flips.length - 1) {
            // L'utilisateur commence à manipuler la page courante
            flips[currentPage].dragging = true;
        }
    }

    // Empêcher la sélection du texte
    event.preventDefault();
}

function mouseUpHandler( event ) {
    for( var i = 0; i < flips.length; i++ ) {
        // Si cette page était manipulée, l'animer jusqu'à sa destination
        if( flips[i].dragging ) {
            if( mouse.x < 0 ) {
                // On a relâché à gauche et la page manipulée est la page courante
                if (i === currentPage) {
                    currentPage = Math.min( currentPage + 1, flips.length );
                }
                flips[i].target = -1;
            }
            else {
                // On a relâché à droite et la page manipulée n'était pas la page courante
                if (i !== currentPage) {
                    currentPage = Math.max( currentPage - 1, 0 );
                }
                flips[i].target = 1;
            }
        }
        // On a relaché le bouton: l'utilisateur ne manipule plus rien
        flips[i].dragging = false;
    }
}

function render() {
    // Effacer le canevas
    context.clearRect( 0, 0, canvas.width, canvas.height );

    // Parcours du tableau de gestion des effets
    for( var i = 0, len = flips.length; i < len; i++ ) {
        var flip = flips[i];
        if (flip.dragging) {
            // On détermine la cible de l'animation par rapport à la position de la souris
            // tout en s'assurant que l'on reste entre -1 et 1
            var ratio = mouse.x / PAGE_WIDTH;
            flip.target = Math.max( Math.min( ratio, 1 ), -1 );
        }
        // On progresse vers la cible
        flip.progress += ( flip.target - flip.progress ) * 0.2;
        if (flip.dragging || Math.abs( flip.progress ) < 0.997) {
            drawFlip( flip );
        }
    }
}

The end ?

Le but de cette série d’articles était de vous faire comprendre comment les concepteurs de cet effet on pû transformer une idée a priori farfelue en code finalement pas si compliqué que cela. J’espère que vous avez pris autant de plaisir que moi à plonger dans la réalisation de cet effet. Mais évidemment nous n’avons pas fait le tour de la question. Voici quelques questions non traitées, si vous voulez aller plus loin:

  • Nous n’avons pas abordé le problème de la couverture du livre, qui utilise un effet différent.
  • Nous n’avons pas parlé des problèmes de performances. Par exemple, nous effaçons entièrement le canevas à chaque frame. Cela a a impact important sur le temps passé à dessiner et il faudrait minimiser le plus possible la zone effacée.
  • D’une manière générale, je trouve que le code est par moment assez moche et confus. Je n’ai pas voulu trop m’éloigner de l’article d’origine (même si je l’ai un peu refactoré par ci, corrigé par là), mais il y aurait moyen de le rendre carrément plus lisible je pense.
comments powered by Disqus