Membuat Penampil Bata dengan elemen lit-element

1. Pengantar

Komponen web

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

lit-element

Untuk membantu menentukan elemen kustom <brick-viewer>, kita akan menggunakan lit-element. lit-element adalah class dasar ringan yang menambahkan beberapa sugar sintaksis 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 Khusus

Definisi class

Untuk menentukan elemen kustom, buat class yang memperluas LitElement dan hias 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 render

Untuk mengimplementasikan tampilan komponen, definisikan metode bernama render. Metode ini akan menampilkan literal template yang diberi tag dengan fungsi html. Masukkan HTML apa pun yang Anda inginkan dalam literal template yang diberi tag. 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 sangat baik jika pengguna <brick-viewer> dapat menentukan model bata mana yang akan ditampilkan menggunakan atribut, seperti ini:

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

Karena sedang membuat elemen HTML, kita dapat memanfaatkan API deklaratif dan menentukan atribut sumber, sama seperti tag <img> atau <video>. Dengan elemen lit, keduanya sama mudahnya dengan 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> sekarang memiliki atribut src yang dapat kita setel di HTML. Nilainya sudah dapat dibaca dari dalam class BrickViewer berkat elemen lit.

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, cobalah...

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

4. Menetapkan Scene dengan Three.js

Lampu, Kamera, Render!

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

Tambahkan penyiapan scene 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 tampilan 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 kita miliki di template, dan masukkan elemen DOM perender:

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

Agar elemen dom perender ditampilkan secara keseluruhan, kita juga perlu menetapkan elemen <brick-viewer> itu sendiri ke display: block. Kita dapat menyediakan gaya di 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 scene Three.js yang dirender:

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

Tapi... kosong. Mari kita berikan model.

Loader brick

Kita akan meneruskan properti src yang telah ditentukan sebelumnya ke LDrawLoader, yang dikirimkan dengan tiga.js.

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

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 sebaiknya _loadModel dipanggil? Fungsi ini harus dipanggil setiap kali atribut src berubah. Dengan mendekorasi properti src dengan @property, kita telah mengikutsertakan properti ke dalam siklus proses update lit-element. Setiap kali salah satu nilai properti yang didekorasi ini berubah, serangkaian metode akan dipanggil yang dapat mengakses nilai baru dan lama properti. Metode siklus proses yang kita minati disebut update. Metode update menggunakan argumen PropertyValues, yang akan berisi informasi tentang properti apa pun 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> sekarang dapat menampilkan file brick, yang ditentukan dengan atribut src.

Elemen penampil bata yang menampilkan model mobil.

5. Menampilkan Model Sebagian

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

Hiasi properti step:

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

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

Update 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 atribut tersebut, seperti ini:

Kode HTML untuk 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 jumlah model yang ditampilkan. Berikut adalah tampilannya saat atribut step disetel ke "10":

Model bata yang hanya terdiri dari sepuluh anak tangga.

6. Navigasi Setelan Bata

tombol-ikon-mwc

Pengguna akhir <brick-viewer> juga harus dapat menavigasi langkah-langkah build melalui UI. Mari kita tambahkan tombol untuk menuju ke langkah berikutnya, langkah sebelumnya, dan langkah pertama. Kita akan menggunakan komponen web tombol Desain Material untuk mempermudahnya. Karena @material/mwc-icon-button sudah diimpor, kita siap menghapus <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 kita 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 kita semudah itu, berkat komponen web.

Binding peristiwa

Tombol-tombol ini seharusnya melakukan sesuatu. Tombol "balas" akan mereset langkah konstruksi ke 1. Tombol "navigate_before" akan mengurangi langkah konstruksi, dan tombol "navigate_next" akan menambahnya. 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 kita 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 tombol sekarang. Bagus!

Gaya

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

Untuk menerapkan gaya ke tombol ini, kita kembali ke properti static styles. Gaya ini memiliki cakupan, yang berarti gaya tersebut hanya akan berlaku pada elemen dalam komponen web ini. Itulah salah satu kesenangan menulis komponen web: pemilih bisa lebih sederhana, dan CSS akan lebih mudah dibaca dan ditulis. Sampai jumpa, 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 penampil brick dengan tombol mulai ulang, mundur, dan maju.

Tombol reset kamera

Pengguna akhir <brick-viewer> dapat memutar adegan menggunakan kontrol mouse. Saat kita menambahkan tombol, mari tambahkan satu 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 balok memiliki banyak anak tangga. Pengguna mungkin ingin melewati ke langkah tertentu. Menambahkan penggeser dengan nomor langkah dapat membantu navigasi cepat. Kita akan menggunakan elemen <mwc-slider> untuk ini.

penggeser-mwc

Elemen penggeser memerlukan beberapa 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 perintah lit-html ifDefined untuk menghindari penetapan 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 "up"

Saat pengguna memindahkan penggeser, langkah konstruksi saat ini akan berubah, dan visibilitas model harus diperbarui. Elemen penggeser akan memunculkan 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 {i>slider <i}untuk mengubah langkah mana yang ditampilkan.

Data "down"

Ada satu hal lagi. Saat tombol "kembali" dan "berikutnya" digunakan untuk mengubah langkah, handle 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 dengan {i>slider<i}. Tambahkan gaya yang fleksibel agar dapat berfungsi dengan baik dengan kontrol lain:

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

Ini produk akhirnya!

Menavigasi model brick mobil dengan elemen brick-viewer

7. Kesimpulan

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

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

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

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

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