Membuat Penampil Bata dengan elemen lit-element

1. Pengantar

Komponen web

Komponen web adalah kumpulan standar web yang memungkinkan developer memperluas HTML dengan elemen kustom. Dalam codelab ini, Anda akan menentukan elemen <brick-viewer>, yang akan dapat menampilkan model brick.

lit-element

Untuk membantu kita menentukan elemen kustom <brick-viewer>, kita akan menggunakan lit-element. lit-element adalah class dasar ringan yang menambahkan beberapa sintaksis yang lebih mudah ke standar komponen web. Hal ini akan memudahkan kita untuk memulai dan menjalankan elemen kustom.

Mulai

Kita akan melakukan coding di lingkungan Stackblitz online, jadi buka link ini di jendela baru:

stackblitz.com/edit/brick-viewer

Mari kita mulai!

2. Menentukan Elemen Kustom

Definisi class

Untuk menentukan elemen kustom, buat class yang memperluas LitElement dan hiasi dengan @customElement. Argumen untuk @customElement akan menjadi nama elemen kustom.

Di brick-viewer.ts, masukkan:

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

Sekarang, elemen <brick-viewer></brick-viewer> siap digunakan di HTML. Namun, jika Anda mencobanya, tidak ada yang akan dirender. Ayo perbaiki.

Metode rendering

Untuk menerapkan tampilan komponen, tentukan metode bernama render. Metode ini harus menampilkan literal template yang diberi tag dengan fungsi html. Masukkan HTML apa pun yang Anda inginkan dalam literal template yang diberi tag. Hal ini akan dirender saat Anda menggunakan <brick-viewer>.

Tambahkan metode render:

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

3. Menentukan File LDraw

Menentukan properti

Akan lebih baik jika pengguna <brick-viewer> dapat menentukan model balok yang akan ditampilkan menggunakan atribut, seperti ini:

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

Karena kita membuat elemen HTML, kita dapat memanfaatkan API deklaratif dan menentukan atribut sumber, seperti tag <img> atau <video>. Dengan lit-element, semudah mendekorasi properti class dengan @property. Opsi type memungkinkan Anda menentukan cara lit-element mengurai properti untuk digunakan sebagai atribut HTML.

Tentukan properti dan atribut src:

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

<brick-viewer> kini memiliki atribut src yang dapat kita tetapkan di HTML. Nilainya sudah dapat dibaca dari dalam class BrickViewer berkat lit-element.

Menampilkan nilai

Kita dapat menampilkan nilai atribut src dengan menggunakannya dalam literal template metode render. Lakukan interpolasi nilai ke dalam literal template menggunakan sintaksis ${value}.

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

Sekarang, kita melihat nilai atribut src di elemen <brick-viewer> di jendela. Coba ini: buka alat developer browser Anda dan ubah atribut src secara manual. Silakan, coba...

...Apakah Anda melihat bahwa teks dalam elemen diperbarui secara otomatis? lit-element mengamati properti class yang dihiasi dengan @property dan merender ulang tampilan untuk Anda. lit-element melakukan tugas berat sehingga Anda tidak perlu melakukannya.

4. Menyiapkan Latar dengan Three.js

Cahaya, Kamera, Render!

Elemen kustom kita akan menggunakan three.js untuk merender model batu bata 3D. Ada beberapa hal yang ingin kita lakukan hanya sekali untuk setiap instance elemen <brick-viewer>, seperti menyiapkan tampilan three.js, kamera, dan pencahayaan. Kita akan menambahkannya ke konstruktor class BrickViewer. Kita akan menyimpan beberapa objek sebagai properti class sehingga kita dapat menggunakannya nanti: kamera, adegan, kontrol, dan perender.

Tambahkan penyiapan tampilan 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);
  };
}

Objek WebGLRenderer menyediakan elemen DOM yang menampilkan adegan three.js yang dirender. Fungsi ini diakses melalui properti domElement. Kita dapat menginterpolasi nilai ini ke dalam literal template render, menggunakan sintaksis ${value}.

Hapus pesan src yang ada di template, lalu masukkan elemen DOM perender:

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

Agar elemen DOM perender dapat ditampilkan secara keseluruhan, kita juga perlu menyetel elemen <brick-viewer> itu sendiri ke display: block. Kita dapat memberikan gaya dalam properti statis yang disebut styles, yang ditetapkan ke literal template css.

Tambahkan gaya ini ke class:

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

Sekarang <brick-viewer> menampilkan tampilan three.js yang dirender:

Elemen brick-viewer yang menampilkan adegan yang dirender, tetapi kosong.

Tapi... kosong. Mari kita berikan model.

Pemuat batu bata

Kita akan meneruskan properti src yang kita tentukan sebelumnya ke LDrawLoader, yang dikirimkan dengan three.js.

File LDraw dapat memisahkan model Brick menjadi langkah-langkah pembuatan yang terpisah. Jumlah total langkah dan visibilitas setiap bagian dapat diakses melalui LDrawLoader API.

Salin properti ini, metode _loadModel baru, dan baris baru di konstruktor:

@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();
        });
  }
}

Kapan _loadModel harus dipanggil? Fungsi ini harus dipanggil setiap kali atribut src berubah. Dengan mendekorasi properti src menggunakan @property, kita telah mengikutsertakan properti tersebut dalam siklus proses pembaruan lit-element. Setiap kali nilai salah satu properti yang dihias ini berubah, serangkaian metode yang dapat mengakses nilai baru dan lama properti akan dipanggil. Metode siklus proses yang kita minati disebut update. Metode update mengambil argumen PropertyValues, yang akan berisi informasi tentang properti yang baru saja berubah. Ini adalah tempat yang tepat untuk memanggil _loadModel.

Tambahkan metode update:

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

Elemen <brick-viewer> kita kini dapat menampilkan file brick, yang ditentukan dengan atribut src.

Elemen brick-viewer yang menampilkan model mobil.

5. Menampilkan Model Parsial

Sekarang, mari kita buat langkah konstruksi saat ini dapat dikonfigurasi. Kita ingin dapat menentukan <brick-viewer step="5"></brick-viewer>, dan kita harus melihat seperti apa model brick pada langkah konstruksi ke-5. Untuk melakukannya, mari jadikan properti step sebagai properti yang diamati dengan mendekorasinya menggunakan @property.

Menghias properti step:

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

Sekarang, kita akan menambahkan metode helper yang membuat hanya brick hingga langkah pembuatan saat ini yang terlihat. Kita akan memanggil helper dalam metode update sehingga berjalan setiap kali properti step diubah.

Perbarui metode update, dan tambahkan metode _updateBricksVisibility baru:

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);
  }
}

Oke, sekarang buka devtools browser Anda, dan periksa elemen <brick-viewer>. Tambahkan atribut step ke dalamnya, seperti ini:

Kode HTML elemen brick-viewer, dengan atribut langkah yang ditetapkan ke 10.

Lihat apa yang terjadi pada model yang dirender. Kita dapat menggunakan atribut step untuk mengontrol seberapa banyak model ditampilkan. Berikut tampilannya jika atribut step ditetapkan ke "10":

Model balok dengan hanya sepuluh langkah pembangunan.

6. Navigasi Set Bata

mwc-icon-button

Pengguna akhir <brick-viewer> kita juga harus dapat menavigasi langkah-langkah build melalui UI. Mari tambahkan tombol untuk membuka langkah berikutnya, langkah sebelumnya, dan langkah pertama. Kita akan menggunakan komponen web tombol Material Design untuk mempermudahnya. Karena @material/mwc-icon-button sudah diimpor, kita siap memasukkan <mwc-icon-button></mwc-icon-button>. Kita dapat menentukan ikon yang ingin digunakan dengan atribut ikon, seperti ini: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Semua kemungkinan ikon dapat ditemukan di sini: material.io/resources/icons.

Mari tambahkan beberapa tombol ikon ke metode render:

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>
    `;
  }
}

Menggunakan Desain Material di halaman kami semudah itu, berkat komponen web.

Binding peristiwa

Tombol ini harus melakukan sesuatu. Tombol "balas" akan mereset langkah pembuatan ke 1. Tombol "navigate_before" harus mengurangi langkah konstruksi, dan tombol "navigate_next" harus menambahkannya. lit-element memudahkan penambahan fungsi ini, dengan binding peristiwa. Dalam literal template html, gunakan sintaksis @eventname=${eventHandler} sebagai atribut elemen. eventHandler kini akan berjalan saat peristiwa eventname terdeteksi pada elemen tersebut. Sebagai contoh, mari tambahkan pengendali peristiwa klik ke tiga tombol ikon kita:

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>
    `;
  }
}

Coba klik tombolnya sekarang. Bagus!

Gaya

Tombol berfungsi, tetapi tidak terlihat bagus. Semuanya berkumpul di bagian bawah. Mari kita beri gaya untuk menempatkannya di atas adegan.

Untuk menerapkan gaya pada tombol ini, kita kembali ke properti static styles. Gaya ini memiliki cakupan, yang berarti hanya akan diterapkan ke elemen dalam komponen web ini. Itulah salah satu keunggulan menulis komponen web: pemilih bisa lebih sederhana, dan CSS akan lebih mudah dibaca dan ditulis. Selamat tinggal, BEM!

Perbarui gaya agar terlihat seperti ini:

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

Elemen brick-viewer dengan tombol mulai ulang, mundur, dan maju.

Tombol reset kamera

Pengguna akhir <brick-viewer> kami dapat memutar adegan menggunakan kontrol mouse. Saat menambahkan tombol, mari kita tambahkan tombol untuk mereset kamera ke posisi defaultnya. <mwc-icon-button> lain dengan binding peristiwa klik akan menyelesaikan tugas.

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>
    `;
  }
}

Navigasi lebih cepat

Beberapa set balok memiliki banyak langkah. Pengguna mungkin ingin melewati langkah tertentu. Menambahkan penggeser dengan angka langkah dapat membantu navigasi cepat. Kita akan menggunakan elemen <mwc-slider> untuk melakukannya.

mwc-slider

Elemen penggeser memerlukan beberapa bagian data penting, seperti nilai penggeser minimum dan maksimum. Nilai penggeser minimum selalu dapat berupa "1". Nilai penggeser maksimum harus this._numConstructionSteps, jika model telah dimuat. Kita dapat memberi tahu <mwc-slider> nilai ini melalui atributnya. Kita juga dapat menggunakan direktif lit-html ifDefined untuk menghindari penyetelan atribut max jika properti _numConstructionSteps belum ditentukan.

Tambahkan <mwc-slider> di antara tombol "kembali" dan "maju":

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>
    `;
  }
}

Data "naik"

Saat pengguna menggerakkan penggeser, langkah konstruksi saat ini akan berubah, dan visibilitas model akan diperbarui sesuai dengan itu. Elemen penggeser akan memancarkan peristiwa input setiap kali penggeser ditarik. Tambahkan binding peristiwa pada penggeser itu sendiri untuk menangkap peristiwa ini dan mengubah langkah konstruksi.

Tambahkan binding acara:

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>
    `;
  }
}

Hore! Kita dapat menggunakan penggeser untuk mengubah langkah yang ditampilkan.

Data "tidak tersedia"

Ada satu hal lagi. Saat tombol "kembali" dan "berikutnya" digunakan untuk mengubah langkah, tuas penggeser perlu diperbarui. Ikat atribut nilai <mwc-slider> ke this.step.

Tambahkan binding 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>
    `;
  }
}

Kita hampir selesai membuat penggeser. Tambahkan gaya flex agar dapat berinteraksi dengan baik dengan kontrol lainnya:

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

Selain itu, kita perlu memanggil layout pada elemen penggeser itu sendiri. Kita akan melakukannya dalam metode siklus proses firstUpdated, yang dipanggil setelah DOM pertama kali ditata. Dekorator query dapat membantu kita mendapatkan referensi ke elemen penggeser dalam template.

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

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

Berikut semua penambahan penggeser yang digabungkan (dengan atribut pin dan markers tambahan pada penggeser agar terlihat keren):

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>
   `;
 }
}

Berikut produk akhirnya.

Menavigasi model brick mobil dengan elemen brick-viewer

7. Kesimpulan

Kita telah mempelajari banyak hal tentang cara menggunakan lit-element untuk membuat elemen HTML kita sendiri. Kita telah mempelajari cara:

  • Menentukan elemen kustom
  • Mendeklarasikan API atribut
  • Merender tampilan untuk elemen kustom
  • Mengenkapsulasi gaya
  • Menggunakan peristiwa dan properti untuk meneruskan data

Jika Anda ingin mempelajari lit-element lebih lanjut, Anda dapat membaca selengkapnya di situs resminya.

Anda dapat melihat elemen brick-viewer yang telah selesai di stackblitz.com/edit/brick-viewer-complete.

brick-viewer juga dikirimkan di NPM, dan Anda dapat melihat sumbernya di sini: repositori GitHub.