Créer une visionneuse de briques avec LitElement

1. Introduction

Composants Web

Les composants Web sont un ensemble de normes Web qui permettent aux développeurs d'étendre le code HTML avec des éléments personnalisés. Dans cet atelier de programmation, vous allez définir l'élément <brick-viewer>, qui pourra afficher des modèles de briques.

Lit-element

Pour nous aider à définir notre élément personnalisé <brick-viewer>, nous allons utiliser lit-element. lit-element est une classe de base légère qui ajoute du sucre syntaxique à la norme des composants Web. Cela facilitera la prise en main de notre élément personnalisé.

Premiers pas

Nous allons coder dans un environnement Stackblitz en ligne. Ouvrez donc ce lien dans une nouvelle fenêtre :

stackblitz.com/edit/brick-viewer

C'est parti !

2. Définir un élément personnalisé

Définition de la classe

Pour définir un élément personnalisé, créez une classe qui étend LitElement et décorez-le avec @customElement. L'argument de @customElement est le nom de l'élément personnalisé.

Dans briques-viewer.ts, saisissez:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

L'élément <brick-viewer></brick-viewer> est maintenant prêt à être utilisé en HTML. Toutefois, si vous essayez, rien ne s'affiche. Résolvons ce problème.

Méthode de rendu

Pour implémenter la vue du composant, définissez une méthode nommée "render". Cette méthode doit renvoyer un littéral de modèle tagué avec la fonction html. Placez le code HTML de votre choix dans le littéral de modèle balisé. Celui-ci s'affichera lorsque vous utiliserez <brick-viewer>.

Ajoutez la méthode render :

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. Spécifier le fichier LDraw

Définir une propriété

Il serait idéal qu'un utilisateur de <brick-viewer> puisse spécifier le modèle de brique à afficher à l'aide d'un attribut, comme suit :

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

Étant donné que nous créons un élément HTML, nous pouvons tirer parti de l'API déclarative et définir un attribut source, comme une balise <img> ou <video>. Avec Litelement, il vous suffit de décorer une propriété de classe avec @property. L'option type vous permet de spécifier la manière dont Litelement analyse la propriété afin de l'utiliser en tant qu'attribut HTML.

Définissez la propriété et l'attribut src:

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

<brick-viewer> dispose désormais d'un attribut src que nous pouvons définir en HTML. Sa valeur est déjà lisible depuis notre classe BrickViewer grâce à lit-element.

Afficher les valeurs

Nous pouvons afficher la valeur de l'attribut src en l'utilisant dans la valeur littérale du modèle de la méthode de rendu. Interpolez des valeurs dans des littéraux de modèle à l'aide de la syntaxe ${value}.

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

Nous voyons maintenant la valeur de l'attribut src dans l'élément <brick-viewer> dans la fenêtre. Essayez ceci: ouvrez les outils pour les développeurs de votre navigateur et modifiez manuellement l'attribut src. Allez-y, essayez-le :

...Avez-vous remarqué que le texte de l'élément est automatiquement mis à jour ? lit-element observe les propriétés de classe décorées avec @property et regénère la vue pour vous. lit-element fait le gros du travail pour que vous n'ayez pas à le faire.

4. Plantez le décor avec Three.js

Lumière, caméra, rendu !

Notre élément personnalisé utilisera three.js pour afficher nos modèles de briques 3D. Vous pouvez effectuer certaines opérations une seule fois pour chaque instance d'un élément <brick-viewer>, comme configurer la scène, la caméra et l'éclairage de three.js. Nous allons les ajouter au constructeur de la classe BrickViewer. Nous conserverons certains objets en tant que propriétés de classe afin de pouvoir les utiliser ultérieurement: appareil photo, scène, commandes et moteur de rendu.

Ajoutez la configuration de la scène three.js:

export class BrickViewer extends LitElement {

  private _camera: THREE.PerspectiveCamera;
  private _scene: THREE.Scene;
  private _controls: OrbitControls;
  private _renderer: THREE.WebGLRenderer;

  constructor() {
    super();

    this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
    this._camera.position.set(150, 200, 250);

    this._scene = new THREE.Scene();
    this._scene.background = new THREE.Color(0xdeebed);

    const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
    this._scene.add( ambientLight );

    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1000, 1200, 1500);
    this._scene.add(directionalLight);

    this._renderer = new THREE.WebGLRenderer({antialias: true});
    this._renderer.setPixelRatio(window.devicePixelRatio);
    this._renderer.setSize(this.offsetWidth, this.offsetHeight);

    this._controls = new OrbitControls(this._camera, this._renderer.domElement);
    this._controls.addEventListener("change", () =>
      requestAnimationFrame(this._animate)
    );

    this._animate();

    const resizeObserver = new ResizeObserver(this._onResize);
    resizeObserver.observe(this);
  }

  private _onResize = (entries: ResizeObserverEntry[]) => {
    const { width, height } = entries[0].contentRect;
    this._renderer.setSize(width, height);
    this._camera.aspect = width / height;
    this._camera.updateProjectionMatrix();
    requestAnimationFrame(this._animate);
  };

  private _animate = () => {
    this._renderer.render(this._scene, this._camera);
  };
}

L'objet WebGLRenderer fournit un élément DOM qui affiche la scène three.js rendue. Il est accessible via la propriété domElement. Nous pouvons interpoler cette valeur dans le littéral de modèle de rendu à l'aide de la syntaxe ${value}.

Supprimez le message src que nous avions dans le modèle et insérez l'élément DOM du moteur de rendu:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

Pour permettre à l'élément DOM du moteur de rendu d'être affiché dans son intégralité, nous devons également définir l'élément <brick-viewer> lui-même sur display: block. Nous pouvons fournir des styles dans une propriété statique appelée styles, définie sur une littérale de modèle css.

Ajoutez ce style à la classe :

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

<brick-viewer> affiche maintenant une scène Three.js:

Élément de visionneuse de brique affichant une scène affichée, mais vide.

Mais... elle est vide. Donnons-lui un modèle.

Chargeur de briques

Nous allons transmettre la propriété src que nous avons définie précédemment à LDrawLoader, qui est livrée avec three.js.

Les fichiers LDraw peuvent diviser un modèle de briques en étapes de construction distinctes. Le nombre total d'étapes et la visibilité de chaque brique sont accessibles via l'API LDrawLoader.

Copiez ces propriétés, la nouvelle méthode _loadModel et la nouvelle ligne dans le constructeur:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
  private _loader = new LDrawLoader();
  private _model: any;
  private _numConstructionSteps?: number;
  step?: number;

  constructor() {
    // ...
    // Add this line right before this._animate();
    (this._loader as any).separateObjects = true;
    this._animate();
  }

  private _loadModel() {
    if (this.src === null) {
      return;
    }
    this._loader
        .setPath('')
        // Using our src property!
        .load(this.src, (newModel) => {

          if (this._model !== undefined) {
            this._scene.remove(this._model);
            this._model = undefined;
          }

          this._model = newModel;

          // Convert from LDraw coordinates: rotate 180 degrees around OX
          this._model.rotation.x = Math.PI;
          this._scene.add(this._model);

          this._numConstructionSteps = this._model.userData.numConstructionSteps;
          this.step = this._numConstructionSteps!;

          const bbox = new THREE.Box3().setFromObject(this._model);
          this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
          this._controls.update();
          this._controls.saveState();
        });
  }
}

Quand _loadModel doit-il être appelé ? Il doit être appelé chaque fois que l'attribut src change. En décorant la propriété src avec @property, nous l'avons incluse dans le cycle de vie de mise à jour de lit-element. Chaque fois que la valeur de l'une de ces propriétés décorées change, une série de méthodes sont appelées et peuvent accéder aux nouvelles et anciennes valeurs des propriétés. La méthode de cycle de vie qui nous intéresse s'appelle update. La méthode update utilise un argument PropertyValues, qui contient des informations sur les propriétés qui viennent d'être modifiées. C'est l'endroit idéal pour appeler _loadModel.

Ajoutez la méthode update :

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

Notre élément <brick-viewer> peut désormais afficher un fichier de briques, spécifié avec l'attribut src.

Élément de visualisation des briques affichant un modèle de voiture.

5. Afficher des modèles partiels

Rendez l'étape de construction actuelle configurable. Nous aimerions pouvoir spécifier <brick-viewer step="5"></brick-viewer>, et nous devrions voir à quoi ressemble le modèle de briques à la cinquième étape de construction. Pour ce faire, transformons la propriété step en propriété observée en la décorant avec @property.

Décorez la propriété step :

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

Nous allons maintenant ajouter une méthode d'assistance qui permet de rendre visibles uniquement les briques jusqu'à l'étape de construction actuelle. Nous appellerons l'aide dans la méthode de mise à jour afin qu'elle s'exécute chaque fois que la propriété step est modifiée.

Mettez à jour la méthode update et ajoutez la nouvelle méthode _updateBricksVisibility:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    if (changedProperties.has('step')) {
      this._updateBricksVisibility();
    }
    super.update(changedProperties);
  }

  private _updateBricksVisibility() {
    this._model && this._model.traverse((c: any) => {
      if (c.isGroup && this.step) {
        c.visible = c.userData.constructionStep <= this.step;
      }
    });
    requestAnimationFrame(this._animate);
  }
}

OK. Ouvrez maintenant les outils de développement de votre navigateur et inspectez l'élément <brick-viewer>. Ajoutez-y un attribut step, comme ceci:

Code HTML d&#39;un élément &quot;brique-viewer&quot;, avec un attribut de pas défini sur 10.

Observez ce qu'il advient du modèle rendu. Nous pouvons utiliser l'attribut step pour contrôler la partie du modèle qui est affichée. Voici ce à quoi cela devrait ressembler lorsque l'attribut step est défini sur "10":

Modèle de briques avec seulement dix étapes de construction.

6. Navigation dans les ensembles de briques

mwc-icon-button

L'utilisateur final de notre <brick-viewer> doit également pouvoir parcourir les étapes de compilation via l'interface utilisateur. Ajoutons des boutons pour passer à l'étape suivante, à l'étape précédente et à la première étape. Pour simplifier la tâche, nous allons utiliser le composant Web de bouton Material Design. Comme @material/mwc-icon-button a déjà été importé, nous sommes prêts à ajouter <mwc-icon-button></mwc-icon-button>. Nous pouvons spécifier l'icône que nous souhaitons utiliser avec l'attribut icon, comme suit : <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Toutes les icônes possibles sont accessibles à l'adresse suivante: material.io/resources/icons.

Ajoutons des boutons d'icône à la méthode de rendu:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button icon="replay"></mwc-icon-button>
        <mwc-icon-button icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

C'est aussi simple d'utiliser Material Design sur notre page grâce aux composants Web.

Liaisons d'événements

Ces boutons devraient faire quelque chose. Le bouton "Répondre" devrait réinitialiser l'étape de construction sur 1. Le bouton "Navigate_before" doit diminuer l'étape de construction, et le bouton "Navigate_next" doit l'incrémenter. Lit-element facilite l'ajout de cette fonctionnalité grâce à des liaisons d'événements. Dans votre littérale de modèle HTML, utilisez la syntaxe @eventname=${eventHandler} comme attribut d'élément. eventHandler s'exécutera désormais lorsqu'un événement eventname sera détecté sur cet élément. Par exemple, ajoutons des gestionnaires d'événements de clic à nos trois boutons d'icône :

export class BrickViewer extends LitElement {
  private _restart() {
    this.step! = 1;
  }

  private _stepBack() {
    this.step! -= 1;
  }

  private _stepForward() {
    this.step! += 1;
  }

  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Essayez de cliquer sur les boutons maintenant. Bravo !

Styles

Les boutons fonctionnent, mais ils ne sont pas très esthétiques. Ils sont tous entassés en bas. Appliquez un style à ces éléments pour les superposer à la scène.

Pour appliquer des styles à ces boutons, nous revenons à la propriété static styles. Ces styles sont délimités, ce qui signifie qu'ils ne s'appliquent qu'aux éléments de ce composant Web. C'est l'un des plaisirs de l'écriture des composants Web: les sélecteurs peuvent être plus simples, et le code CSS est plus facile à lire et à écrire. Au revoir, BEM !

Modifiez les styles pour qu'ils se présentent comme suit:

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

Élément de visualisation des briques avec des boutons &quot;Redémarrer&quot;, &quot;Précédent&quot; et &quot;Suivant&quot;.

Bouton de réinitialisation de l'appareil photo

Les utilisateurs finaux de notre <brick-viewer> peuvent faire pivoter la scène à l'aide des commandes de la souris. Lorsque nous ajoutons des boutons, ajoutons-en un pour rétablir la position par défaut de la caméra. Un autre <mwc-icon-button> avec une liaison d'événement de clic fera l'affaire.

export class BrickViewer extends LitElement {
  private _resetCamera() {
    this._controls.reset();
  }

  render() {
    return html`
      <div id="controls">
        <!-- ... -->
        <!-- Add this button: -->
        <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
      </div>
    `;
  }
}

Consultation plus rapide

Certains jeux de briques ont de nombreuses marches. Un utilisateur peut vouloir passer à une étape spécifique. L'ajout d'un curseur avec des numéros de pas peut faciliter la navigation rapide. Pour ce faire, nous utiliserons l'élément <mwc-slider>.

mwc-slider

L'élément curseur a besoin de quelques données importantes, comme les valeurs minimale et maximale du curseur. La valeur minimale du curseur peut toujours être "1". La valeur maximale du curseur doit être this._numConstructionSteps si le modèle a été chargé. Nous pouvons indiquer ces valeurs à <mwc-slider> via ses attributs. Nous pouvons également utiliser la directive lit-html ifDefined pour éviter de définir l'attribut max si la propriété _numConstructionSteps n'a pas été définie.

Ajoutez un <mwc-slider> entre les boutons "Retour" et "Avance" :

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ... backwards button -->
        <!-- Add this slider: -->
        <mwc-slider
            step="1"
            pin
            markers
            min="1"
            max=${ifDefined(this._numConstructionSteps)}
        ></mwc-slider>
        <!-- ... forwards button -->
      </div>
    `;
  }
}

Données "up"

Lorsqu'un utilisateur déplace le curseur, l'étape de construction en cours doit changer, et la visibilité du modèle doit être mise à jour en conséquence. L'élément "curseur" émet un événement d'entrée chaque fois que vous faites glisser le curseur. Ajoutez une liaison d'événement sur le curseur lui-même pour intercepter cet événement et modifier l'étape de construction.

Ajoutez la liaison d'événement :

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the @input event binding: -->
        <mwc-slider
            ...
            @input=${(e: CustomEvent) => this.step = e.detail.value}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Bravo ! Nous pouvons utiliser le curseur pour changer l'étape à afficher.

Données "en baisse"

Il y a encore une chose. Lorsque vous utilisez les boutons "Retour" et "Suivant" pour modifier l'étape, vous devez modifier la poignée du curseur. Liez l'attribut de valeur de <mwc-slider> à this.step.

Ajoutez la liaison value :

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the value property binding: -->
        <mwc-slider
            ...
            value=${ifDefined(this.step)}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Nous avons presque terminé avec le curseur. Ajoutez un style flex pour qu'il s'intègre bien aux autres commandes :

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

Nous devons également appeler layout sur l'élément du curseur lui-même. Pour ce faire, nous allons utiliser la méthode de cycle de vie firstUpdated, qui est appelée une fois le DOM positionné. Le décorateur query peut nous aider à obtenir une référence à l'élément curseur dans le modèle.

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

Voici tous les ajouts de curseurs (avec des attributs pin et markers supplémentaires sur le curseur pour plus de clarté):

export class BrickViewer extends LitElement {
 @query('mwc-slider')
 slider!: Slider|null;

 static styles = css`
   /* ... */
   mwc-slider {
     flex-grow: 1;
   }
 `;

 async firstUpdated() {
   if (this.slider) {
     await this.slider.updateComplete
     this.slider.layout();
   }
 }

 render() {
   return html`
     ${this._renderer.domElement}
     <div id="controls">
       <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
       <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
       <mwc-slider
         step="1"
         pin
         markers
         min="1"
         max=${ifDefined(this._numConstructionSteps)}
         ?disabled=${this._numConstructionSteps === undefined}
         value=${ifDefined(this.step)}
         @input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
       ></mwc-slider>
       <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
       <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
     </div>
   `;
 }
}

Voici le produit final !

Parcourir un modèle de voiture en briques avec l&#39;élément &quot;brique-viewer&quot;

7. Conclusion

Nous avons beaucoup appris sur l'utilisation de LitElement pour créer notre propre élément HTML. Nous avons appris à:

  • Définir un élément personnalisé
  • Déclarer une API d'attribut
  • Afficher une vue pour un élément personnalisé
  • Encapsuler les styles
  • Transmettre des données à l'aide d'événements et de propriétés

Pour en savoir plus sur lit-element, consultez son site Web officiel.

Vous pouvez consulter un élément brick-viewer terminé à l'adresse stackblitz.com/edit/brick-viewer-complete.

brick-viewer est également fourni sur NPM. Vous pouvez consulter le code source ici : dépôt GitHub.