یک نمایشگر آجری با عنصر روشن بسازید

۱. مقدمه

اجزای وب

کامپوننت‌های وب مجموعه‌ای از استانداردهای وب هستند که به توسعه‌دهندگان اجازه می‌دهند HTML را با عناصر سفارشی گسترش دهند. در این آزمایشگاه کد، شما عنصر <brick-viewer> را تعریف خواهید کرد که قادر به نمایش مدل‌های آجری خواهد بود!

عنصر روشن

برای کمک به تعریف عنصر سفارشی <brick-viewer> ، از lit-element استفاده خواهیم کرد. lit-element یک کلاس پایه سبک است که به استاندارد کامپوننت‌های وب، ویژگی‌های نحوی اضافه می‌کند. این کار راه‌اندازی و کار با عنصر سفارشی ما را آسان می‌کند.

شروع کنید

ما در یک محیط آنلاین Stackblitz کدنویسی خواهیم کرد، بنابراین این لینک را در یک پنجره جدید باز کنید:

stackblitz.com/edit/brick-viewer

بیایید شروع کنیم!

۲. تعریف یک عنصر سفارشی

تعریف کلاس

برای تعریف یک عنصر سفارشی، یک کلاس ایجاد کنید که LitElement ارث‌بری کند و آن را با @customElement تزئین کنید. آرگومان ارسالی به @customElement نام عنصر سفارشی خواهد بود.

در فایل brick-viewer.ts، عبارت زیر را قرار دهید:

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

حالا، عنصر <brick-viewer></brick-viewer> آماده استفاده در HTML است. اما اگر آن را امتحان کنید، هیچ چیزی رندر نمی‌شود. بیایید این مشکل را حل کنیم.

روش رندر

برای پیاده‌سازی نمای کامپوننت، متدی به نام render تعریف کنید. این متد باید یک template literal با برچسب تابع html برگرداند. هر HTML که می‌خواهید را در template literal برچسب‌گذاری شده قرار دهید. این زمانی که از <brick-viewer> استفاده می‌کنید، رندر خواهد شد.

متد render را اضافه کنید:

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

۳. مشخص کردن فایل LDraw

تعریف یک ویژگی

خیلی خوب می‌شد اگر کاربر <brick-viewer> می‌توانست با استفاده از یک ویژگی، مانند این، مشخص کند که کدام مدل آجر نمایش داده شود:

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

از آنجایی که ما در حال ساخت یک عنصر HTML هستیم، می‌توانیم از API اعلانی بهره ببریم و یک ویژگی منبع (source attribute) تعریف کنیم، درست مانند یک تگ <img> یا <video> . با lit-element، به راحتی می‌توانید یک ویژگی کلاس را با @property تزئین کنید. گزینه type به شما امکان می‌دهد مشخص کنید که lit-element چگونه ویژگی را برای استفاده به عنوان یک ویژگی HTML تجزیه و تحلیل می‌کند.

ویژگی و ویژگی src را تعریف کنید:

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

<brick-viewer> اکنون یک ویژگی src دارد که می‌توانیم آن را در HTML تنظیم کنیم! مقدار آن به لطف lit-element از قبل از داخل کلاس BrickViewer قابل خواندن است.

نمایش مقادیر

می‌توانیم مقدار ویژگی src را با استفاده از آن در قالب متنی متد رندر نمایش دهیم. مقادیر را با استفاده از سینتکس ${value} در قالب متنی قرار دهید.

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

حالا، مقدار ویژگی src را در عنصر <brick-viewer> در پنجره می‌بینیم. این را امتحان کنید: ابزارهای توسعه‌دهنده مرورگر خود را باز کنید و ویژگی src را به صورت دستی تغییر دهید. امتحان کنید...

... آیا متوجه شدید که متن درون عنصر به طور خودکار به‌روزرسانی می‌شود؟ lit-element ویژگی‌های کلاس که با @property تزئین شده‌اند را مشاهده می‌کند و نما را برای شما دوباره رندر می‌کند! lit-element کارهای سنگین را انجام می‌دهد، بنابراین شما مجبور به انجام این کار نیستید.

۴. صحنه را با Three.js آماده کنید

نور، دوربین، رندر!

عنصر سفارشی ما از three.js برای رندر مدل‌های آجری سه‌بعدی استفاده خواهد کرد. مواردی وجود دارد که می‌خواهیم فقط یک بار برای هر نمونه از عنصر <brick-viewer> انجام دهیم، مانند تنظیم صحنه، دوربین و نورپردازی در three.js. ما این موارد را به سازنده کلاس 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}
    `;
  }
}

برای اینکه عنصر dom رندرکننده به طور کامل نمایش داده شود، باید خود عنصر <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 را نمایش می‌دهد:

یک عنصر نمایشگر آجر که صحنه‌ای رندر شده اما خالی را نمایش می‌دهد.

اما... خالی است. بیایید یک مدل برایش ارائه دهیم.

لودر آجر

ما ویژگی src که قبلاً تعریف کردیم را به LDrawLoader که همراه با three.js ارائه شده است، ارسال خواهیم کرد.

فایل‌های LDraw می‌توانند یک مدل آجری را به مراحل ساخت جداگانه تقسیم کنند. تعداد کل مراحل و قابلیت مشاهده آجرهای منفرد از طریق API LDrawLoader قابل دسترسی است.

این ویژگی‌ها، متد جدید _loadModel و خط جدید را در سازنده کپی کنید:

@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 ، ما ویژگی را در چرخه عمر به‌روزرسانی lit-element قرار داده‌ایم. هر زمان که مقدار یکی از این ویژگی‌های تزئین‌شده تغییر کند، مجموعه‌ای از متدها فراخوانی می‌شوند که می‌توانند به مقادیر جدید و قدیمی ویژگی‌ها دسترسی داشته باشند. متد چرخه عمری که ما به آن علاقه‌مندیم update نام دارد. متد update یک آرگومان PropertyValues ​​می‌گیرد که شامل اطلاعاتی در مورد هر ویژگی‌ای است که تازه تغییر کرده است. این مکان مناسبی برای فراخوانی _loadModel است.

متد update را اضافه کنید:

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

عنصر <brick-viewer> ما اکنون می‌تواند یک فایل آجری را که با ویژگی src مشخص شده است، نمایش دهد.

یک المان نمایشگر آجری که مدلی از یک ماشین را نمایش می‌دهد.

۵. نمایش مدل‌های جزئی

حالا، بیایید مرحله ساخت فعلی را قابل تنظیم کنیم. ما می‌خواهیم بتوانیم <brick-viewer step="5"></brick-viewer> مشخص کنیم و ببینیم مدل آجری در مرحله ساخت پنجم چگونه به نظر می‌رسد. برای انجام این کار، بیایید ویژگی step را با تزئین آن با @property به یک ویژگی مشاهده شده تبدیل کنیم.

تزئین ویژگی step :

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

حالا، یک متد کمکی اضافه می‌کنیم که فقط آجرها را تا مرحله ساخت فعلی قابل مشاهده می‌کند. ما این متد کمکی را در متد به‌روزرسانی فراخوانی می‌کنیم تا هر بار که ویژگی 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 یک عنصر brick-viewer، با ویژگی step که روی ۱۰ تنظیم شده است.

ببینید چه اتفاقی برای مدل رندر شده می‌افتد! می‌توانیم از ویژگی step برای کنترل میزان نمایش مدل استفاده کنیم. وقتی ویژگی step روی "10" تنظیم شده باشد، مدل به این شکل خواهد بود:

یک ماکت آجری که تنها ده مرحله ساختمانی روی آن ساخته شده است.

۶. ناوبری مجموعه آجر

دکمه-آیکون-mwc

کاربر نهایی <brick-viewer> ما همچنین باید بتواند از طریق رابط کاربری در مراحل ساخت پیمایش کند. بیایید دکمه‌هایی برای رفتن به مرحله بعد، مرحله قبل و مرحله اول اضافه کنیم. برای سهولت کار از کامپوننت وب button در طراحی متریال استفاده خواهیم کرد. از آنجایی که @material/mwc-icon-button از قبل وارد شده است، آماده‌ایم تا <mwc-icon-button></mwc-icon-button> را در آن قرار دهیم. می‌توانیم آیکونی را که می‌خواهیم استفاده کنیم با ویژگی icon مشخص کنیم، مانند این: <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>
    `;
  }
}

به لطف کامپوننت‌های وب، استفاده از طراحی متریال در صفحه ما به همین راحتی است!

مقیدسازی رویداد

این دکمه‌ها در واقع باید کاری انجام دهند. دکمه "reply" باید مرحله ساخت را به ۱ بازنشانی کند. دکمه "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 برمی‌گردیم. این استایل‌ها scoped هستند، به این معنی که فقط به عناصر درون این کامپوننت وب اعمال می‌شوند. این یکی از مزایای نوشتن کامپوننت‌های وب است: انتخابگرها می‌توانند ساده‌تر باشند و خواندن و نوشتن CSS آسان‌تر خواهد بود. خداحافظ BEM !

استایل‌ها را به‌روزرسانی کنید تا به این شکل درآیند:

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

یک عنصر نمایشگر آجری با دکمه‌های راه‌اندازی مجدد، عقب و جلو.

دکمه تنظیم مجدد دوربین

کاربران نهایی <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> استفاده خواهیم کرد.

اسلایدر mwc

عنصر slider به چند داده مهم نیاز دارد، مانند حداقل و حداکثر مقدار slider. حداقل مقدار slider همیشه می‌تواند "1" باشد. حداکثر مقدار slider باید this._numConstructionSteps باشد، اگر مدل بارگذاری شده باشد. می‌توانیم این مقادیر را از طریق ویژگی‌های آن به <mwc-slider> بگوییم. همچنین می‌توانیم از دستورالعمل 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>
    `;
  }
}

وای! می‌توانیم از اسلایدر برای تغییر مرحله نمایش داده شده استفاده کنیم.

داده‌ها «از کار افتاده»

یک نکته دیگر هم وجود دارد. وقتی از دکمه‌های "back" و "next" برای تغییر مرحله استفاده می‌شود، دسته اسلایدر باید به‌روزرسانی شود. ویژگی value مربوط به <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>
    `;
  }
}

تقریباً کار ما با اسلایدر تمام شده است. برای اینکه با سایر کنترل‌ها به خوبی هماهنگ شود، یک استایل flex اضافه کنید:

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

همچنین، باید layout روی خود عنصر slider فراخوانی کنیم. این کار را در متد lifecycle به نام firstUpdated انجام خواهیم داد که پس از اولین چیدمان DOM فراخوانی می‌شود. دکوراتور query می‌تواند به ما کمک کند تا ارجاعی به عنصر slider در قالب دریافت کنیم.

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

این هم محصول نهایی!

پیمایش مدل آجری خودرو با عنصر نمایشگر آجر

۷. نتیجه‌گیری

ما چیزهای زیادی در مورد نحوه استفاده از lit-element برای ساخت عنصر HTML خودمان یاد گرفتیم. ما یاد گرفتیم که چگونه:

  • تعریف یک عنصر سفارشی
  • اعلان یک API ویژگی
  • رندر کردن یک نما برای یک عنصر سفارشی
  • کپسوله‌سازی استایل‌ها
  • استفاده از رویدادها و ویژگی‌ها برای ارسال داده‌ها

اگر می‌خواهید درباره lit-element بیشتر بدانید، می‌توانید در سایت رسمی آن بیشتر بخوانید.

شما می‌توانید یک عنصر brick-viewer تکمیل‌شده را در stackblitz.com/edit/brick-viewer-complete مشاهده کنید.

brick-viewer همچنین روی NPM منتشر شده است و می‌توانید سورس آن را اینجا مشاهده کنید: مخزن گیت‌هاب .