בונים מכשיר צפייה לבנים עם רכיב מואר

1. מבוא

רכיבי אינטרנט

רכיבי אינטרנט הם אוסף של סטנדרטים לאינטרנט שמאפשרים למפתחים להרחיב HTML באמצעות רכיבים מותאמים אישית. ב-Codelab הזה מגדירים את הרכיב <brick-viewer>, שיוכל להציג מודלים של לבנים!

lit-element

כדי לעזור לנו להגדיר את האלמנט בהתאמה אישית <brick-viewer>, נשתמש ב-lit-element.‏ lit-element היא סוג בסיס קל שמוסיף קצת תכונות פונקציונליות לתקן של רכיבי האינטרנט. כך יהיה לנו קל יותר להתחיל לעבוד עם הרכיב המותאם אישית שלנו.

תחילת העבודה

נתכנת בסביבת Stackblitz אונליין. לכן צריך לפתוח את הקישור הזה בחלון חדש:

stackblitz.com/edit/brick-viewer

קדימה, מתחילים!

2. הגדרת רכיב מותאם אישית

הגדרת הכיתה

כדי להגדיר אלמנט מותאם אישית, יוצרים כיתה שמרחיבה את LitElement ומקשטים אותה באמצעות @customElement. הארגומנט של @customElement יהיה השם של הרכיב המותאם אישית.

בקובץ brick-viewer.ts, מוסיפים את הטקסט הבא:

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

עכשיו הרכיב <brick-viewer></brick-viewer> מוכן לשימוש ב-HTML. אבל אם תנסו, לא תראו שום דבר. יחד נפתור את זה.

שיטת העיבוד

כדי להטמיע את התצוגה של הרכיב, מגדירים שיטה בשם render. השיטה הזו אמורה להחזיר לטימר של תבנית שמתויג בפונקציה html. מוסיפים את ה-HTML הרצוי לביטוי ה-template המתויג. הפעולה הזו תוצג כשתשתמשו ב-<brick-viewer>.

מוסיפים את השיטה render:

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

3. ציון קובץ LDraw

הגדרת נכס

יהיה נהדר אם משתמש ב-<brick-viewer> יוכל לציין איזה מודל לבנים להציג באמצעות מאפיין, כך:

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

מכיוון שאנחנו יוצרים רכיב HTML, אנחנו יכולים להשתמש ב-API הדקלרטיבי ולהגדיר מאפיין מקור, בדיוק כמו תג <img> או <video>. עם אלמנט מואר, ממש כמו לקשט נכס כיתתי באמצעות @property. האפשרות type מאפשרת לכם לציין איך הרכיב lit-element מנתח את הנכס לשימוש כמאפיין HTML.

מגדירים את המאפיין והמאפיין src:

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

עכשיו יש ב-<brick-viewer> מאפיין src שאפשר להגדיר ב-HTML. כבר אפשר לקרוא את הערך שלו מתוך הכיתה BrickViewer, בזכות הרכיב lit-element.

הצגת ערכים

נוכל להציג את הערך של המאפיין src על ידי שימוש בו בליטרל התבנית של שיטת העיבוד. אפשר להפוך ערכים לליטרלים של תבנית באמצעות תחביר ${value}.

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

עכשיו אנחנו רואים את הערך של המאפיין src ברכיב <brick-viewer> שבחלון. אפשר לנסות את הפעולות הבאות: פותחים את הכלים למפתחים בדפדפן ומשנים את מאפיין ה-src באופן ידני. קדימה, אפשר לנסות...

...האם הבחנתם שהטקסט ברכיב מתעדכן באופן אוטומטי? האלמנט המחודש מזהה את מאפייני הכיתה שמעוטרים ב-@property ומעבד מחדש את התצוגה בשבילכם!

4. הגדרת הסצנה באמצעות Three.js

תאורה, מצלמה, רינדור!

הרכיב המותאם אישית שלנו ישתמש ב-three.js כדי ליצור מודלים תלת-ממדיים של לבנים. יש כמה דברים שאנחנו רוצים לעשות רק פעם אחת עבור כל מופע של רכיב <brick-viewer>, כמו להגדיר את הסצנה שלושה.js, את המצלמה ואת התאורה. נוסיף אותם ל-constructor של המחלקה BrickViewer. חלק מהאובייקטים יישמרו כמאפייני הכיתה כדי שנוכל להשתמש בהם מאוחר יותר: מצלמה, סצנה, אמצעי בקרה וכלי לעיבוד.

מוסיפים את ההגדרה של 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);
  };
}

האובייקט WebGLRenderer מספק רכיב DOM שבו מוצגת סצנת three.js שעברה רינדור. הגישה אליו מתבצעת דרך המאפיין domElement. אנחנו יכולים להתאים את הערך הזה לליטרל של תבנית העיבוד באמצעות תחביר ${value}.

מסירים את הודעת ה-src שהייתה לנו בתבנית ומוסיפים את רכיב ה-DOM של כלי הרינדור:

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

כדי לאפשר הצגה של רכיב ברירת המחדל של כלי הרינדור בשלמותו, אנחנו צריכים גם להגדיר את רכיב <brick-viewer> עצמו ל-display: block. אנחנו יכולים לספק סגנונות בנכס סטטי בשם styles, שמוגדר לליטרל של תבנית css.

מוסיפים לכיתה את הסגנון הזה:

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

עכשיו בדף <brick-viewer> מוצגת סצנה של three.js שעברה רינדור:

רכיב מסוג &#39;צופה לבנים&#39; שמציג סצנה שעברה רינדור, אבל ריקה.

אבל... הוא ריק. נוסיף לו מודל.

טעינה של לבנים

נעביר את המאפיין src שהגדרנו קודם ל-LDrawLoader, שנשלח עם שלושה.js.

קובצי LDraw יכולים להפריד מודל של לבנים לשלבי בנייה נפרדים. אפשר לגשת למספר השלבים הכולל ולחשיפה של כל לבנה ספציפית דרך LDrawLoader API.

מעתיקים את המאפיינים הבאים, את השיטה החדשה _loadModel ואת השורה החדשה ב-constructor:

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

מתי צריך להתקשר אל _loadModel? צריך להפעיל אותו בכל פעם שמאפיין ה-src משתנה. קישוטנו את הנכס ב-src באמצעות @property, וצירפנו את הנכס למחזור החיים של העדכון המואר. בכל פעם שאחד מהערכים של המאפיינים האלה משתנה, מתבצעת קריאה לסדרה של שיטות שיש להן גישה לערכים החדשים והישנים של המאפיינים. שיטת מחזור החיים שאנחנו מתעניינים בה נקראת update. ה-method update מקבלת ארגומנט PropertyValues, שיכיל מידע על כל המאפיינים שהשתנו עכשיו. זהו המקום המושלם להתקשר אל _loadModel.

מוסיפים את השיטה update:

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

עכשיו אפשר להציג קובץ לבנה ברכיב <brick-viewer>, שמצוין באמצעות המאפיין src.

אלמנט של צופה מלבנים שמציג דגם של מכונית.

5. הצגת מודלים חלקיים

עכשיו נגדיר את שלב ה-build הנוכחי כניתן להתאמה אישית. אנחנו רוצים לציין את <brick-viewer step="5"></brick-viewer>, ונראה איך נראה מודל הלבנים בשלב הבנייה החמישי. כדי לעשות את זה, נהפוך את הנכס step לנכס שזוהה על ידי קישוט שלו באמצעות @property.

מקשטים את המאפיין step:

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

עכשיו נוסיף שיטה מסייעת שתוציא את כל הלבנים עד לשלב ה-build הנוכחי. נפעיל את ה-helper בשיטת העדכון כדי שהוא יפעל בכל פעם שנכס step ישתנה.

מעדכנים את השיטה update ומוסיפים את השיטה החדשה _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);
  }
}

עכשיו פותחים את devtools של הדפדפן ובודקים את הרכיב <brick-viewer>. מוסיפים לו את המאפיין step, כך:

קוד HTML של רכיב צופה לבנים, עם מאפיין השלב שמוגדר ל-10.

כדאי לראות מה קורה למודל שעבר רינדור! ניתן להשתמש במאפיין step כדי לקבוע כמה מהמודל יוצג. כך זה אמור להיראות כשהמאפיין step מוגדר כ-"10":

מודל לבנים שנבנה רק בעשרה שלבי בנייה.

6. ניווט בערכת לבנים

mwc-icon-button

למשתמש הקצה ב-<brick-viewer> צריכה להיות גם אפשרות לנווט בשלבי ה-build דרך ממשק המשתמש. נוסיף לחצנים כדי לעבור לשלב הבא, לשלב הקודם ולשלב הראשון. כדי לפשט את התהליך, נשתמש ברכיב האינטרנט של הלחצנים של Material Design. מכיוון ש-@material/mwc-icon-button כבר יובא, אנחנו מוכנים להוסיף את <mwc-icon-button></mwc-icon-button>. אפשר לציין את הסמל שבו רוצים להשתמש עם מאפיין הסמל, למשל: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. כל הסמלים האפשריים מפורטים כאן: material.io/resources/icons.

נוסיף כמה לחצני סמלים לשיטת הרינדור:

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

באמצעות רכיבי אינטרנט, קל מאוד להשתמש ב-Material Design בדף שלנו!

קישורי אירועים

הלחצנים האלה אמורים לעשות משהו. הלחצן 'תשובה' אמור לאפס את שלב הבנייה ל-1. הלחצן navigate_before צריך להקטין את שלב הבנייה, והלחצן navigate_next צריך להגדיל אותו. באמצעות lit-element קל להוסיף את הפונקציונליות הזו באמצעות קישורי אירועים. בליטרל של תבנית ה-HTML, צריך להשתמש בתחביר @eventname=${eventHandler} בתור מאפיין הרכיב. eventHandler יפעל עכשיו כשיזוהה אירוע eventname ברכיב הזה! לדוגמה, נוסיף פונקציות טיפול באירועי לחיצה לשלושת לחצני הסמלים שלנו:

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

מנסים ללחוץ על הלחצנים עכשיו. יפה מאוד!

סגנונות

הלחצנים פועלים, אבל הם לא נראים טוב. כולם מקובצים למטה. נגדיר אותם כשכבת-על מעל לסצנה.

כדי להחיל סגנונות על הלחצנים האלה, חוזרים למאפיין static styles. הסגנונות האלה מוגדרים ברמת ההיקף, כלומר הם יחולו רק על רכיבים בתוך רכיב האינטרנט הזה. זו אחת מההנאות של כתיבת רכיבי אינטרנט: הבוררים יכולים להיות פשוטים יותר, וקל יותר לקרוא ולכתוב CSS. להתראות, BEM

מעדכנים את הסגנונות כך שייראו כך:

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

רכיב מסוג &#39;צופה לבנים&#39; עם לחצנים להפעלה מחדש, אחורה וקדימה.

לחצן האיפוס של המצלמה

משתמשי הקצה של <brick-viewer> שלנו יכולים לסובב את הסצנה באמצעות פקדי העכבר. בזמן שאנחנו מוסיפים לחצנים, כדאי להוסיף לחצן לאיפוס המצלמה למיקום ברירת המחדל שלה. <mwc-icon-button> אחר עם קישור אירוע מסוג קליק יסיים את הפעולה.

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

ניווט מהיר יותר

בחלק מהלבנים יש הרבה שלבים. יכול להיות שהמשתמש ירצה לדלג לשלב ספציפי. הוספת פס הזזה עם מספרי שלבים יכולה לעזור לנווט במהירות. לשם כך נשתמש ברכיב <mwc-slider>.

פס הזזה

לרכיב פס ההזזה נדרשים כמה נתונים חשובים, כמו הערך המינימלי והמקסימלי של פס ההזזה. הערך המינימלי של פס ההזזה יכול תמיד להיות '1'. אם המודל נטען, הערך המקסימלי של פס ההזזה צריך להיות this._numConstructionSteps. אנחנו יכולים להעביר את הערכים האלה ל-<mwc-slider> באמצעות המאפיינים שלו. אפשר גם להשתמש בdirective ifDefined lit-html כדי להימנע מהגדרת המאפיין max אם המאפיין _numConstructionSteps לא הוגדר.

מוסיפים <mwc-slider> בין הלחצנים 'הקודם' ו'הבא':

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

נתונים 'למעלה'

כשמשתמש מזיז את פס ההזזה, שלב הבנייה הנוכחי אמור להשתנות, והחשיפה של המודל אמורה להתעדכן בהתאם. רכיב פס ההזזה ישמיע אירוע קלט בכל פעם שתגרור את פס ההזזה. צריך להוסיף קישור לאירוע בפס ההזזה עצמו כדי לזהות את האירוע ולשנות את שלב הבנייה.

מוסיפים את קישור האירועים:

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

יש! אנחנו יכולים להשתמש בפס ההזזה כדי לשנות את השלב שמוצג.

נתונים 'ירידה'

יש עוד דבר אחד. כשמשתמשים בלחצנים 'הקודם' ו'הבא' כדי לשנות את השלב, צריך לעדכן את הידית של פס ההזזה. קישור מאפיין הערך של <mwc-slider> ל-this.step.

מוסיפים את קישור 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>
    `;
  }
}

כמעט סיימנו עם פס ההזזה. מוסיפים סגנון גמישות כדי שהרכיב יתאים לשאר אמצעי הבקרה:

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

כמו כן, אנחנו צריכים לקרוא לפונקציה layout ברכיב המחוון עצמו. נעשה את זה בשיטה firstUpdated של מחזור החיים, שנקראת אחרי שמגדירים את ה-DOM בפעם הראשונה. בעזרת ה-decorator‏ query נוכל לקבל הפניה לרכיב הפס ההזזה בתבנית.

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

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

כאן מוצגים כל התוספים לפס ההזזה (עם מאפייני pin ו-markers נוספים בפס ההזזה כדי שייראה מגניב):

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

הנה המוצר הסופי!

ניווט בדגם לבנים של מכונית עם רכיב &#39;צופה לבנים&#39;

7. סיכום

למדנו הרבה על השימוש ב-lit-element כדי ליצור רכיב HTML משלכם. למדנו איך:

  • הגדרת רכיב מותאם אישית
  • הצהרת API למאפיין
  • עיבוד (רנדר) של תצוגה עבור רכיב בהתאמה אישית
  • כיסוי סגנונות
  • שימוש באירועים ובנכסים להעברת נתונים

מידע נוסף על lit-element זמין באתר הרשמי.

אפשר לראות רכיב brick-viewer שהושלם בכתובת stackblitz.com/edit/brick-viewer-complete.

brick-viewer נשלח גם כן ב-NPM, ואפשר לראות את המקור כאן: מאגר GitHub.