Mar 02, 2020 Javascript

Les 7 GUIS

Pour clôturer cette série d’articles sur Javascript je vais montrer une implémentation des 7GUIS avec des Web Components. Les 7GUIS sont une initiative d’un développeur qui voulait montrer des approches différentes selon les langages concernant 7 tâches prédéfinies de la plus simple à la plus complexe. On peut ainsi aller voir les réalisations avec diverses approches. Je me suis dit qu’il serait intéressant de réaliser ces tâches en Vanilla Javascript avec des Web Components. Je ne suis tout de même pas allé au bout de cet exercice parce que la septième tâche consiste à créer un tableur réaliste et ça m’a semblé disproportionné pour cette démonstration.

J’ai mis en ligne une démonstration des 6 tâches réalisées. Il y a également un bouton pour télécharger le code complet.

Le site de démonstration

Pour le site de démonstration j’ai opté pour le framework Materialize pour simplifier le visuel et me focaliser essentiellement sur le Javascript. On dispose d’une barre de navigation :

Responsive :

Le site est une SPA avec un routage élémentaire pour naviguer entre les tâches.

Le code est architecturé de façon systématique :

Le fichier index.html comporte le code HTML de base avec l’appel à Materialize et au fichier Javascript d’entrée (app/index.js) d’entrée :

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="7 GUIS in Vanilla Javascript and Web Components">    
  <title>7 GUIS</title> 
 
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
  <script type="module" src="app/index.js"></script>
</head>
<body>
  <gui-app></gui-app>
</body>
</html>

L’entrée de l’application se situe au niveau de la balise personnalisée <gui-app>. On appelle là le composant principal GuiApp dont le code d’entrée est dans app/index.js :

import * as view from './view.js';
import GuiCounter from './counter/index.js';
import GuiTemperature from './temperature/index.js';
import GuiFlight from './flight/index.js';
import GuiTimer from './timer/index.js';
import GuiCrud from './crud/index.js';
import GuiCircle from './circle/index.js';

export default class GuiApp extends HTMLElement {

  constructor() {
    super();
    this.routes = {
      'counter': GuiCounter,
      'temperature-converter': GuiTemperature,
      'flight-booker': GuiFlight,
      'timer': GuiTimer,
      'crud': GuiCrud,
      'circle-drawer': GuiCircle
    };   
  }

  connectedCallback() 
  {
    this.innerHTML = view.html();
    this.loadPage(window.location.hash.substr(1));
    this.addEventListener('click', e => this.onLinkClick(e));

    // For menu
    M.Sidenav.init(document.querySelectorAll('.sidenav'));
  }

  onLinkClick(e) {
    if(e.target.className === 'link') {
      this.loadPage(e.target.href.split('/').slice(-1)[0].substring(1));
    }
  }

  loadPage(route) {
    if(route === '' || route === 'home') {
      this.querySelector('#splash').innerHTML = view.home();
    } else {
      const div = document.createElement('div');
      div.setAttribute('id', 'splash');
      div.appendChild(new this.routes[route]);
      this.querySelector('#splash').replaceWith(div);
    }
  }
}

if (!customElements.get('gui-app')) {
  customElements.define('gui-app', GuiApp);
}

Les routes sont définies dans le constructeur :

constructor() {
  super();
  this.routes = {
    'counter': GuiCounter,
    'temperature-converter': GuiTemperature,
    'flight-booker': GuiFlight,
    'timer': GuiTimer,
    'crud': GuiCrud,
    'circle-drawer': GuiCircle
  };   
}

Le code HTML se situe dans le fichier app/view.js :

const html = () => `
  <nav>
    <div class="nav-wrapper">
      <a href="#" class="brand-logo">The 7 GUIS</a>
      <a href="#" data-target="mobile-demo" class="sidenav-trigger"><i class="material-icons">menu</i></a>
      <ul class="right hide-on-med-and-down">
        <li><a href="#home" class="link">Home</a></li>
        <li><a href="#counter" class="link">Counter</a></li>
        <li><a href="#temperature-converter" class="link">Temperature Converter</a></li>
        <li><a href="#flight-booker" class="link">Flight Booker</a></li>
        <li><a href="#timer" class="link">Timer</a></li>
        <li><a href="#crud" class="link">CRUD</a></li>
        <li><a href="#circle-drawer" class="link">Circle Drawer</a></li>
      </ul>
    </div>
  </nav>

  <ul class="sidenav" id="mobile-demo">
    <li><a href="#home" class="link">Home</a></li>
    <li><a href="#counter" class="link">Counter</a></li>
    <li><a href="#temperature-converter" class="link">Temperature Converter</a></li>
    <li><a href="#flight-booker" class="link">Flight Booker</a></li>
    <li><a href="#timer" class="link">Timer</a></li>
    <li><a href="#crud" class="link">CRUD</a></li>
    <li><a href="#circle-drawer" class="link">Circle Drawer</a></li>
  </ul>

  <div class="container">
    <div id="splash"></div>
  </div>
`;

const home = () => `
  <h1 class="splash-head center">The <a href="https://eugenkiss.github.io/7guis/" target="_blank">7 GUIS</a> in Vanilla Javascript and Web Components</h1>
  <p class="splash-subhead center">But only 6 because <a href="https://eugenkiss.github.io/7guis/tasks#cells" target="_blank">Cells</a> is overelaborate !</p>
  <div class="center"><a id="redo" class="waves-effect waves-light btn" href="/7guis.zip">Upload code</a></div>
  `
  
export {
  html,
  home
}

On trouve le layout dans html et la page d’accueil dans home.

Au niveau de ce composant on a essentiellement le routage. Au chargement on a ce code :

connectedCallback() 
{
  this.innerHTML = view.html();
  this.loadPage(window.location.hash.substr(1));
  this.addEventListener('click', e => this.onLinkClick(e));
  M.Sidenav.init(document.querySelectorAll('.sidenav'));
}

Donc on charge le layout (view.html), ensuite on charge la page selon le contenu de l’adresse. Par exemple si on a :

https://guis.sillo.org/#timer

On va lire la partie timer et on charge la page en conséquence avec la fonction loadPage :

loadPage(route) {
  if(route === '' || route === 'home') {
    this.querySelector('#splash').innerHTML = view.home();
  } else {
    const div = document.createElement('div');
    div.setAttribute('id', 'splash');
    div.appendChild(new this.routes[route]);
    this.querySelector('#splash').replaceWith(div);
  }
}

Pour que le changement de page fonctionne on place une écoute sur les liens :

this.addEventListener('click', e => this.onLinkClick(e));
onLinkClick(e) {
  if(e.target.className === 'link') {
    this.loadPage(e.target.href.split('/').slice(-1)[0].substring(1));
  }
}

On voit qu’il est relativement facile de mettre en place un routage en vanilla Javascript pour une SPA.

Cette partie de code :

M.Sidenav.init(document.querySelectorAll('.sidenav'));

Est juste pour Materialise pour que la barre de navigation soit responsive.

Le compteur

La première tâche est un compteur élémentaire. Comme j’ai opté de créer un composant par tâche le composant est ici :

Cette tâche est juste une mise en jambes parce qu’elle est très facile à réaliser. Au niveau de la vue (view.js) on a juste le titre, une zone de saisie et un bouton :

export default () => `
  <h1>Counter</h1>
  <input type="number" value="0">
  <a class="waves-effect waves-light btn">Count</a>
`;

Le traitement se fait dans le fichier index.js :

import html from './view.js';

export default class GuiCounter extends HTMLElement {

  connectedCallback() 
  {
    this.innerHTML = html();
    this.querySelector('a.btn').addEventListener('click', () => this.handleClick());
  }

  handleClick() {
    const input = this.querySelector('input');
    input.value = parseInt(input.value) + 1;
  }
}

if (!customElements.get('gui-counter')) {
  customElements.define('gui-counter', GuiCounter);
}

Au chargement du composant (connectedCallback) on met en place le HTML et une écoute d’un click sur le bouton.

A chaque click on appelle donc la fonction handleClick :

handleClick() {
  const input = this.querySelector('input');
  input.value = parseInt(input.value) + 1;
}

Là on récupère la valeur actuelle dans la zone de saisie et on la réaffecte en ajoutant la valeur 1.

Comme je le disais plus haut cette tâche est juste un petit rodage…

Convertisseur de température

La deuxième tâche est un convertisseur de température celsius/farenheit. Le composant est ici :

Là encore rien de bien difficile, juste une intéraction réciproque.

Au niveau de la vue on a deux zones de saisie :

export default () => `
  <h1>Temperature Converter</h1>
  <input id="c" type="number" value="0" style="width: 5em; margin: 1em"> 
  Celsius = 
  <input id="f" type="number" value="0" style="width: 5em; margin: 1em"">
  Fahrenheit
`;

Voici le code du traitement :

import html from './view.js';

export default class GuiTemperature extends HTMLElement {

  connectedCallback() 
  {
    this.innerHTML = html();

    this.querySelector('#c').addEventListener('input', e => this.handleInputC(e));
    this.querySelector('#f').addEventListener('input', e => this.handleInputF(e));
  }

  handleInputC(e) {
    this.querySelector('#f').value = 9 / 5 * e.target.value + 32;
  }

  handleInputF(e) {
    this.querySelector('#c').value = ((e.target.value - 32) * (5 / 9)).toFixed(2);
  }

}

if (!customElements.get('gui-temperature')) {
  customElements.define('gui-temperature', GuiTemperature);
}

On se contente de mettre en place l’écoute du changement de valeur (input) dans les deux zones de saisie. On a deux handles etpour chacun on effectue le calcul avec la formule et on change la vaeur dans la zone. pour éviter des nombres à ralonge j’ai limité à deux décimales.

Réservation de vol

La troisième tâche est une réservation pour un vol aller ou aller-retour. Le composant est ici :

Au niveau de la vue on a ce code :

export default () => `
  <h1>Flight Booker</h1>
  
  <select>
    <option value="one">One-way flight</option>
    <option value="two">Return flight</option>
  </select>

  <input id="go" type="date" value=>
  <input id="back" type="date" disabled>

  <a class="waves-effect waves-light btn">Book</a>
`;

Donc hormis le titre :

  • une liste déroulante avec deux intitulés : One-way flight (aller simple) et Return flight (aller-retour)
  • une zone de saisie pour la date de l’aller
  • une zone de saisie pour la date retour

Voilà le traitement de la tâche :

import html from './view.js';

export default class GuiFlight extends HTMLElement {

  connectedCallback() 
  {
    this.innerHTML = html();
    
    M.FormSelect.init(document.querySelectorAll('select'));

    this.querySelector('#go').valueAsDate = new Date();
    this.querySelector('#back').valueAsDate = new Date();

    this.querySelector('select').addEventListener('change', e => this.handleSelectChange(e));
    this.querySelector('#go').addEventListener('change', () => this.handleInputChange());
    this.querySelector('#back').addEventListener('change', () => this.handleInputChange());
    this.querySelector('a').addEventListener('click', () => this.handleClick());
  }

  handleSelectChange(e) {
    this.querySelector('#back').disabled = e.target.value === 'one';
    this.handleInputChange();
  }

  handleInputChange() {
    if(this.querySelector('select').value === 'one' || 
      this.querySelector('select').value === 'two' && 
      this.querySelector('#go').valueAsDate < this.querySelector('#back').valueAsDate
    ) {
      this.querySelector('a').classList.remove('disabled');
    } else {
      this.querySelector('a').classList.add('disabled');
    }
  }

  handleClick() {
    const type = this.querySelector('select').value;
    let message = `You have booked a ${ type === 'one' ? 'one-way' : '' } flight on ${ this.querySelector('#go').value }`;
    if (type === 'two') {
      message += ` with a return flight on ${ this.querySelector('#back').value }`;
    }
    alert(message);
  }
}

if (!customElements.get('gui-flight')) {
  customElements.define('gui-flight', GuiFlight);
}

Là on a un peu plus de travail. Déjà on doit écouter plusieurs événements :

  • le changement (change) dans la liste déroulante pour le passage aller simple <-> aller-retour
  • le changement (change) de la date aller
  • le changement (change) de la date retour
  • la validation (click) sur le bouton

Une contrainte est que la zone de saisie de la date retour soit invalide si on a juste un aller simple. Cette réactivité est réalisée ici :

handleSelectChange(e) {
  this.querySelector('#back').disabled = e.target.value === 'one';
  this.handleInputChange();
}

Une autre contrainte est que le bouton de validation ne soit valide que si soit :

  • on est en aller simple
  • on est en aller-retour et la date du retour est postérieure à la date de l’aller

C’est géré ici :

handleInputChange() {
  if(this.querySelector('select').value === 'one' || 
    this.querySelector('select').value === 'two' && 
    this.querySelector('#go').valueAsDate < this.querySelector('#back').valueAsDate
  ) {
    this.querySelector('a').classList.remove('disabled');
  } else {
    this.querySelector('a').classList.add('disabled');
  }
}

Pour la validation de la réservation on se contente d’une alerte :

handleClick() {
  const type = this.querySelector('select').value;
  let message = `You have booked a ${ type === 'one' ? 'one-way' : '' } flight on ${ this.querySelector('#go').value }`;
  if (type === 'two') {
    message += ` with a return flight on ${ this.querySelector('#back').value }`;
  }
  alert(message);
}

Un timer

La quatrième tâche consiste à créer un timer. On veut une progression visuelle, la possiblité de modifier la valeur de la temporisation et un bouton de réinitialisation.

Le composant est ici :

Au niveau de la vue on a ce code :

export default () => `
  <h1>Timer</h1>
    <div class="row">
      <div class="col s4 m2">
        Elapsed time
      </div>
      <div class="col s8 m10">
        <progress style="vertical-align: sub; width: 100%" max="100" value="0"></progress>
      </div>
    </div>
    <div class="row">
      <div class="col">
        Elapsed <span id="elapsed">1</span> s
      </div>
    </div>
    <div class="row">
      <div class="col s4 m2">
        Duration
      </div>
      <div class="col s8 m10">
        <input style="margin: 0" type="range" id="range" min="0" max="20000" value="5000" />
      </div>
    </div>
    <div class="row">
      <div class="col s12">
        <a style="width: 100%" class="waves-effect waves-light btn">RESET</a>
      </div>
    </div>
</div>  
`;
  • une barre de progression
  • un simple texte pour afficher la valeur actuelle
  • un curseur pour régler la valeur
  • un bouton de reset

Et voici le traitement :

import html from './view.js';

export default class GuiTimer extends HTMLElement {

  constructor() {
    super();
    this.elapsed = 0;
    this.duration = 5000;
    this.animationStartTime = 0;
  }

  connectedCallback() 
  {
    this.innerHTML = html();    
    this.querySelector('a').addEventListener('click', () => this.handleClick());
    this.querySelector('#range').addEventListener('change', e => this.handleChange(e));
    this.animationStartTime = window.performance.now();
    requestAnimationFrame(() => this.animate());
  }

  animate() {
    const time = window.performance.now();
    this.elapsed += Math.min(time - this.animationStartTime, this.duration - this.elapsed);
    this.animationStartTime = time;
    this.querySelector('progress').value = 100 * this.elapsed / this.duration;
    this.querySelector('#elapsed').textContent = (this.elapsed / 1000).toFixed(1);
    requestAnimationFrame(() => this.animate());
  }

  handleClick() {
    this.elapsed = 0;
  }

  handleChange(e) {
    this.duration = e.target.value;
  }
}

if (!customElements.get('gui-timer')) {
  customElements.define('gui-timer', GuiTimer);
}

La difficulté ici réside dans la gestion du temps et de l’affichage dynamique. Pour l’animation on utilise requestAnimationFrame. Cette fonction permet de demander au navigateur de faire un changement d’affichage après le prochain rafraîchissement de la page. On lui donne une fonction de callback qui doit être appelée pour gérer l’animation. On fonctionne en boucle pour avoir une animation continue.

Pour la gestion du temps on utilise performance.now qui nous donne une valeur précise du temps écoulé.

Ons e retrouve avec cette fonction d’animation :

animate() {
  const time = window.performance.now();
  this.elapsed += Math.min(time - this.animationStartTime, this.duration - this.elapsed);
  this.animationStartTime = time;
  this.querySelector('progress').value = 100 * this.elapsed / this.duration;
  this.querySelector('#elapsed').textContent = (this.elapsed / 1000).toFixed(1);
  requestAnimationFrame(() => this.animate());
}

On cacule le temps écoulé, on redéfini le temps de départ, on actualise la barre de progression et la valeur textuelle, et on repart pour un tour.

CRUD

La cinquième tâche est un CRUD avec des noms. Le composant est ici :

Cette fois la nouveauté est qu’on doit gérer des données, j’ai donc ajouté un modèle.

La vue

Au niveau de la vue on a ce code :

export default () => `
  <h1>CRUD</h1>
  <div class=row">
    <div class="col s12">
      <div class="input-field col s6">
      <input placeholder="Filter" id="filter" type="text">
      <label for="filter">Filter</label>
    </div>
  </div>
  <div class=row">
    <div class="input-field col s12">
      <select class="browser-default">
      </select>
    </div>
  </div>
  <div class=row">
    <div class="col s12">
      <div class="input-field col s6">
      <input placeholder="Name" id="name" type="text">
      <label for="name">Name</label>
    </div>
  </div>
  <div class=row">
    <div class="col s12">
      <div class="input-field col s6">
      <input placeholder="Surname" id="surname" type="text">
      <label for="surname">Surname</label>
    </div>
  </div>
  <a id="create" class="waves-effect waves-light btn">Create</a>
  <a id="update" class="waves-effect waves-light btn">Update</a>
  <a id="delete" class="waves-effect waves-light btn">Delete</a> 
`;
  • une zone de saisie pour un filtrage des noms
  • une liste déroulante pour les noms
  • une zone de saisie pour le prénom
  • une zone de saisie pour le nom
  • un bouton de création
  • un bouton de modification
  • un bouton de suppression

Le modèle

Pour gérer les données voici le code du modèle :

let people = [
  { surname: 'Hans', name: 'Emil' },
  { surname: 'Max', name: 'Mustermann' },
  { surname: 'Roman', name: 'Tisch' },
];

let filtered = [];

const allFiltered = () => filtered;

const filter = (filter) => {
  filtered = people.filter(person => {
    const name = `${person.name}, ${person.surname}`;
    return name.toLowerCase().startsWith(filter);
  });
}

const filteredId = (id) => filtered[id];

const peopleId = (id) => people[id];

const del = person => people.splice(people.indexOf(person), 1);

const update = (person, newVersion) => people[people.indexOf(person)] = newVersion;

const create = person => people.push(person);
 
export {
  allFiltered,
  filter,
  peopleId,
  filteredId,
  del,
  update,
  create
}

Les noms sont mémorisés dans un tableau d’objets people. d’autre part étant donné que les données peuvent être filtrées il y a un second tableau filtered. On accède d’ailleurs seulement à ce dernier tableau avec la méthode allFiltered.

On a aussi les méthodes classiques de mise à jour : create, update et del. S’y ajoutent deux méthodes pour récupérer une personne particulière dans les deux tableaux.

Le contrôleur

Pour la gestion de tout ça le contrôleur est dans index.js :

import html from './view.js';
import * as model from './model.js';

export default class GuiCrud extends HTMLElement {

  constructor() {
    super();
    this.selected = model.peopleId(0);    
  }

  connectedCallback() 
  {
    this.innerHTML = html();    
    this.populate();
    this.querySelector('select').addEventListener('change', e => this.handleChange(e));
    this.querySelector('#delete').addEventListener('click', () => this.handleDelete());
    this.querySelector('#update').addEventListener('click', () => this.handleUpdate());
    this.querySelector('#create').addEventListener('click', () => this.handleCreate());
    this.querySelector('#filter').addEventListener('input', () => this.populate());
    this.querySelector('#name').addEventListener('input', () => this.handleInput());
    this.querySelector('#surname').addEventListener('input', () => this.handleInput());
  }

  populate(index = false) {

    model.filter(this.querySelector('#filter').value.toLowerCase());
    const filtered = model.allFiltered();

    const select = this.querySelector('select');
    select.innerHTML = '';

    if(filtered.length) {
      this.querySelectorAll('a').forEach(button => button.classList.remove('disabled'));
      filtered.map(person => {
        const option = document.createElement('option');
        option.text = `${ person.name }, ${ person.surname }`;
        select.add(option); 
      });
    } else {
      this.querySelectorAll('a').forEach(button => button.classList.add('disabled'));
    }

    if(index && filtered.length) {
      select.options[filtered.indexOf(this.selected)].selected = true;
    } else {
      this.selected = filtered[0];
      this.changeName();
    }
  }

  changeName() {
    if(this.selected) {
      this.querySelector('#name').value = this.selected.name;
      this.querySelector('#surname').value = this.selected.surname;
    } else {
      this.querySelector('#name').value = '';
      this.querySelector('#surname').value = '';   
    }
  }

  handleChange(e) {
    this.selected = model.filteredId(e.target.selectedIndex);
    this.changeName();
  }

  handleDelete() {
    model.del(this.selected);
    this.populate();
  }

  handleUpdate() {
    const newVersion = this.inputName();
    model.update(this.selected, newVersion);
    this.selected = newVersion;
    this.populate(true);
  }

  handleCreate() {
    this.selected = this.inputName();
    model.create(this.selected); 
    this.populate(true);
  }

  inputName() {
    return {
      name: this.querySelector('#name').value,
      surname: this.querySelector('#surname').value
    };
  }

  handleInput() {
    if(this.querySelector('#name').value !== '' && this.querySelector('#surname').value !== '') {
      this.querySelector('#create').classList.remove('disabled');
    } else {
      this.querySelector('#create').classList.add('disabled');
    }
  }
}

if (!customElements.get('gui-crud')) {
  customElements.define('gui-crud', GuiCrud);
}

On commence par générer la vue :

this.innerHTML = html();

Ensuite on remplit la liste :

this.populate();

Je ne rentre pas dans le détail de la fontion populate qui est un peu chargée. En gros :

  • on demande au modèle de filtrer les noms
  • on remplit la liste avec les noms filtrés

Ensuite on met en place toutes les écoutes :

this.querySelector('select').addEventListener('change', e => this.handleChange(e));
this.querySelector('#delete').addEventListener('click', () => this.handleDelete());
this.querySelector('#update').addEventListener('click', () => this.handleUpdate());
this.querySelector('#create').addEventListener('click', () => this.handleCreate());
this.querySelector('#filter').addEventListener('input', () => this.populate());
this.querySelector('#name').addEventListener('input', () => this.handleInput());
this.querySelector('#surname').addEventListener('input', () => this.handleInput());

En effet on a besoin de savoir :

  • si on sélectionne un nom dans la liste pour actualiser les deux zones de saisie
  • si on clique sur un bouton : create, update ou delete
  • si on change la valeur du filtre
  • si on change la valeur d’une des deux zones de saisie du nom

Le bouton CREATE ne doit être valide que si les deux zones de saisie sont renseignées :

Pour le reste je vous laisse analyser le code…

Les cercles

Pour la sixième tâche, la dernière en ce qui me concerne, il faut pouvoir générer des cercles avec un clic, pouvoir les sélectionner et changer leur rayon avec une boîte de dialogue. Voici le composant dédié :

La vue

Voici le code de la vue :

export default () => `
  <style>
    .center {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%,-50%);
      width: 50%;      
      padding: 1em;
      text-align: center;
      background-color: rgba(245,245,245,0.7);
      border-radius: 10px;
    }
  </style>
  <h1>Circle Drawer</h1>
  <div class="row">
    <div class="col s12">
      <div class="card medium">  
        <div id="modal" class="center hide">
          <p>Adjust radius of circle (<span id="coords"></span>)</p>
          <p class="range-field">
            <input type="range" id="range" min="10" max="100">
          </p>
        </div>
        <svg style="width: 100%; height: 100%"></svg>
      </div>
      <div class="center-align">       
        <a id="undo" class="waves-effect waves-light btn disabled" href="#">UNDO</a>
        <a id="redo" class="waves-effect waves-light btn disabled" href="#">REDO</a>
      </div>
    </div>
  </div>
`;

Pour ce code je me suis inspiré de l’exemple en Svelte adapté avec Materialize :

Donc une carte avec une zone SVG qui l’occupe entièrement (on pourrait aussi utiliser Canvas). Dna sla partie inférieure deux boutons pour l’annulation de la dernière action ou son renouvellement.

A chaque clic on doit générer un cercle à l’emplacement du curseur :

Le dernier cercle généré est automatiquement sélectionné (grisé). Un clic droit sur le cercle sélectionné fait apparaître la boîte de dialogue pour le changement du rayon :

On peut donc avoir des cercles de toutes tailles :

Les bouton UNDO et REDO doivent s’adapter au contexte :

Le modèle

Pour mémoriser et gérer les cercles j’ai créé un modèle :

let stack = [];
let index = -1;

const all = () => stack[index];

const add = circle => {
  stack = stack.slice(0, ++index);
  const circles = stack.length ? [...stack[index - 1]] : [];
  circles.push(circle);
  stack.push(circles);
}

const undo = () => index--;
const redo = () => index++;

const checkUndo = () => index + 1;
const checkRedo = () => stack.length - index - 1;

const update = (circle, r) => {
  const id = stack[index].indexOf(circle);
  const circles = JSON.parse(JSON.stringify(stack[index++]));
  circles[id].r = r;
  stack.push(circles);
}

export {
  all,
  add,
  undo,
  redo,
  checkUndo,
  checkRedo,
  update
}

On a une pile LIFO (Last In First Out) stack qui permet de mémorisé chaque étape, ce qui est indispensable pour l’action des boutons UNDO et REDO. On a un index qui nous indique où on en est en cas de UNDO/REDO.

On a les méthode classiques all, add et update. Pour les undo/redo on se contente de mettre à jour l’index.

On a aussi deux méthodes pour savoir si les boutons UNDO et REDO doivent être valides.

Le contrôleur

Cette tâche est plus délicate que les précédentes et le contrôleur est un peu chargé :

import html from './view.js';
import * as model from './model.js';

export default class GuiCircle extends HTMLElement {

  constructor() {
    super();
    this.svg = null;
    this.selected = null;
    this.adjust = 0;
    this.circles = [];
  }

  connectedCallback() 
  {
    this.innerHTML = html();
    this.svg = this.querySelector('svg'); 
    this.svg.addEventListener('click', e => this.handleClick(e));
    this.querySelector('#range').addEventListener('input', e => this.handleRange(e));
    this.querySelector('#range').addEventListener('change', e => this.handleChange(e));
    this.querySelector('#undo').addEventListener('click', e => this.handleUndo(e));
    this.querySelector('#redo').addEventListener('click', e => this.handleRedo(e));
  }

  handleRange(e) {
    this.svg.innerHTML = '';
    this.circles[this.adjust].r = e.target.value;
    this.circles.map(circle => this.drawCircle(circle));  
  }

  handleChange(e) {
    model.update(this.selected, e.target.value);
  }

  handleClick(e) {
    if(this.checkAdjust()) {
      this.selected = this.createCircle(e);
      model.add(this.selected);      
      this.refresh();
    }
  }

  handleSelect(circle, e) {
    e.stopPropagation();
    if(this.checkAdjust()) {
      this.selected = circle;
      this.refresh();
    }
  }

  checkAdjust() {
    if(this.adjust) {
      this.querySelector('#modal').classList.add('hide');
      this.adjust = false;
      return false;
    }
    return true;
  }

  createCircle(e) {
    const pt = this.svg.createSVGPoint();
    pt.x = e.clientX;
    pt.y = e.clientY;
    const cpt =  pt.matrixTransform(this.svg.getScreenCTM().inverse());
    return { cx: cpt.x,	cy: cpt.y, r: 50 };
  }

  drawCircle(circle) {
    const domCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    domCircle.setAttribute('cx', circle.cx);
    domCircle.setAttribute('cy', circle.cy);
    domCircle.setAttribute('r', circle.r);
    domCircle.setAttribute('stroke-width', 1);
    domCircle.setAttribute('stroke', 'black');
    domCircle.setAttribute('fill-opacity', '0.7');
    domCircle.setAttribute('fill', circle === this.selected || circle === this.circles[this.adjust] ? 'lightgrey' : 'white');
    domCircle.addEventListener('click', e => this.handleSelect(circle, e));
    if(circle === this.selected) {
      domCircle.addEventListener('contextmenu', e => this.contextmenu(circle, e));
    }  
    this.svg.appendChild(domCircle);    
  }

  contextmenu(circle, e) {
    e.stopPropagation();
    e.preventDefault();
    this.querySelector('#modal').classList.remove('hide');
    this.querySelector('#range').value = this.selected.r;
    this.querySelector('#coords').textContent = `x: ${ parseInt(this.selected.cx) } / y: ${ parseInt(this.selected.cy) }`;
    this.adjust = model.all().indexOf(this.selected);
    this.circles = JSON.parse(JSON.stringify(model.all()));
  }

  refresh() {
    this.svg.innerHTML = '';
    const circles = model.all();
    if(circles) {      
      circles.map(circle => this.drawCircle(circle));      
    }
    this.updateButtons();
  }

  updateButtons() {
    if(model.checkUndo()) {
      this.querySelector('#undo').classList.remove('disabled');
    } else {
      this.querySelector('#undo').classList.add('disabled');
    }
    if(model.checkRedo()) {
      this.querySelector('#redo').classList.remove('disabled');
    } else {
      this.querySelector('#redo').classList.add('disabled');
    }    
  }

  handleUndo(e) {
    e.preventDefault();
    model.undo();
    this.selected = null;
    this.checkAdjust();
    this.refresh();    
  }

  handleRedo(e) {
    e.preventDefault();
    model.redo();
    this.checkAdjust();
    this.refresh();   
  }
}

if (!customElements.get('gui-circle')) {
  customElements.define('gui-circle', GuiCircle);
}

On a une fonction pour créer un cercle :

createCircle(e) {
  const pt = this.svg.createSVGPoint();
  pt.x = e.clientX;
  pt.y = e.clientY;
  const cpt =  pt.matrixTransform(this.svg.getScreenCTM().inverse());
  return { cx: cpt.x,	cy: cpt.y, r: 50 };
}

La détermination des coodonnées avec SVG est un peu particulière et nécessite une transformation. Par défaut la rayon est de 50.

On a aussi un fonction pour dessiner un cercle :

drawCircle(circle) {
  const domCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  domCircle.setAttribute('cx', circle.cx);
  domCircle.setAttribute('cy', circle.cy);
  domCircle.setAttribute('r', circle.r);
  domCircle.setAttribute('stroke-width', 1);
  domCircle.setAttribute('stroke', 'black');
  domCircle.setAttribute('fill-opacity', '0.7');
  domCircle.setAttribute('fill', circle === this.selected || circle === this.circles[this.adjust] ? 'lightgrey' : 'white');
  domCircle.addEventListener('click', e => this.handleSelect(circle, e));
  if(circle === this.selected) {
    domCircle.addEventListener('contextmenu', e => this.contextmenu(circle, e));
  }  
  this.svg.appendChild(domCircle);    
}

Là aussi pour créer un élément SVG on n’utilise pas le classique createElement mais createElementNS. On ajoute deux écoutes d’événement :

  • click : pour sélectionner un cercle
  • contextmenu (clic droit) : uniquement pour le cercle sélectionné pour ouvrir le dialogue d’ajustement du rayon

Cette boîte de dialogue est activée avec la fonction contextmenu :

contextmenu(circle, e) {
  e.stopPropagation();
  e.preventDefault();
  this.querySelector('#modal').classList.remove('hide');
  this.querySelector('#range').value = this.selected.r;
  this.querySelector('#coords').textContent = `x: ${ parseInt(this.selected.cx) } / y: ${ parseInt(this.selected.cy) }`;
  this.adjust = model.all().indexOf(this.selected);
  this.circles = JSON.parse(JSON.stringify(model.all()));
}

On rend la boîte visible (classe hide), on ajuste la valeur du curceur selon le rayon actuel, on affiche les coordonnées du cercle. Ensuite on garde une référence du cercle en cours d’ajustement (this.adjust) et on fait un clone des cercles actuels (this.circles). En effet quand on va jouer avec le curceur on doit avoir l’effet en temps réel et on va utiliserr ce clone pour l’affichage.

Je vous laisse analyser le reste du code…

Conclusion

On a vu dans cet article qu’on peut réaliser des applications réactives sans trop de difficulté avec du Vanilla Javascript et de bien organiser le code avec des Web Components. La question se pose donc de savoit à quel moment il devient judicieux d’utiliser une librairie spécifique comme Svelte, Vue ou React. Peut-être le fait que ces librairies bénéficient de très nombreux plugins peut s’avérer incitatif.

Laisser un commentaire