Создайте Brick Viewer с помощьюlit-element

1. Введение

Веб-компоненты

Веб-компоненты — это набор веб-стандартов, позволяющих разработчикам расширять HTML с помощью пользовательских элементов. В этом практическом занятии вы определите элемент <brick-viewer> , который сможет отображать модели кирпичей!

световой элемент

Чтобы определить наш пользовательский элемент <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-код, который вам нужен. Он будет отображаться при использовании <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 и определить атрибут `source`, как в тегах <img> или <video> . С lit-element это так же просто, как добавить к свойству `class` атрибут @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 , используя его в шаблонном литерале метода render. Интерполируйте значения в шаблонные литералы, используя синтаксис ${value} .

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

Теперь мы видим значение атрибута src в элементе <brick-viewer> в окне. Попробуйте следующее: откройте инструменты разработчика вашего браузера и вручную измените атрибут src. Давайте, попробуйте...

...Вы заметили, что текст в элементе обновляется автоматически? lit-element отслеживает свойства класса, помеченные атрибутом @property , и перерисовывает представление за вас! lit-element берет на себя всю сложную работу, чтобы вам не пришлось этого делать.

4. Подготовка к работе с Three.js

Свет, камера, рендеринг!

Наш пользовательский элемент будет использовать three.js для рендеринга наших 3D-моделей кирпичей. Некоторые действия нам нужно выполнить только один раз для каждого экземпляра элемента <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:

Элемент Brick-Viewer, отображающий отрисованную, но пустую сцену.

Но... оно пустое. Давайте создадим для него модель.

Погрузчик кирпича

Мы передадим определенное ранее свойство 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 .

Элемент интерфейса для просмотра кирпичиков, отображающий модель автомобиля.

5. Отображение частичных моделей

Теперь давайте сделаем текущий этап построения настраиваемым. Мы хотим иметь возможность указывать <brick-viewer step="5"></brick-viewer> , и нам нужно видеть, как выглядит модель кирпича на 5-м этапе построения. Для этого давайте сделаем свойство 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);
  }
}

Хорошо, теперь откройте инструменты разработчика в браузере и просмотрите элемент <brick-viewer> . Добавьте к нему атрибут step , вот так:

HTML-код элемента brick-viewer с атрибутом step, установленным на значение 10.

Посмотрите, что происходит с отображаемой моделью! Мы можем использовать атрибут step для управления тем, какая часть модели отображается. Вот как это должно выглядеть, когда атрибут step установлен на "10" :

Модель из кирпичиков, состоящая всего из десяти этапов сборки.

6. Навигация по набору кирпичиков

mwc-icon-button

Конечный пользователь нашего <brick-viewer> также должен иметь возможность перемещаться по этапам сборки через пользовательский интерфейс. Давайте добавим кнопки для перехода к следующему шагу, предыдущему шагу и первому шагу. Для удобства мы воспользуемся веб-компонентом кнопок Material Design. Поскольку @material/mwc-icon-button уже импортирован, мы готовы добавить <mwc-icon-button></mwc-icon-button> . Мы можем указать иконку, которую хотим использовать, с помощью атрибута icon, например так: <mwc-icon-button icon="thumb_up"></mwc-icon-button> . Все возможные иконки можно найти здесь: material.io/resources/icons .

Давайте добавим несколько кнопок с иконками в метод 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>
    `;
  }
}

Благодаря веб-компонентам, использовать Material Design на нашей странице очень просто!

Привязки событий

Эти кнопки должны что-то делать. Кнопка "Ответить" должна сбрасывать шаг построения до 1. Кнопка "Навигация_перед" должна уменьшать шаг построения, а кнопка "Навигация_следующая" — увеличивать его. 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;
    }
  `;
}

Элемент интерфейса для просмотра кирпичиков с кнопками перезапуска, возврата и перемотки вперед.

Кнопка сброса камеры

Пользователи нашего <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

Элементу слайдера необходимы некоторые важные данные, такие как минимальное и максимальное значения слайдера. Минимальное значение слайдера всегда может быть равно "1". Максимальное значение слайдера должно быть равно 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>
    `;
  }
}

Ура! Мы можем использовать ползунок, чтобы изменить отображаемый шаг.

Данные "сбиваются"

Есть ещё один момент. При использовании кнопок «назад» и «далее» для изменения шага необходимо обновить ползунок . Привяжите атрибут 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>
    `;
  }
}

Мы почти закончили с ползунком. Добавим гибкий стиль, чтобы он хорошо взаимодействовал с другими элементами управления:

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

Кроме того, нам нужно вызвать layout для самого элемента слайдера. Мы сделаем это в методе жизненного цикла firstUpdated , который вызывается после первой компоновки DOM. Декоратор 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>
   `;
 }
}

Вот и конечный результат!

Навигация по модели автомобиля с помощью элемента brick-viewer

7. Заключение

Мы много узнали о том, как использовать lit-element для создания собственного HTML-элемента. Мы научились:

  • Определите пользовательский элемент
  • Объявление атрибута API
  • Отобразить представление для пользовательского элемента
  • Инкапсулировать стили
  • Используйте события и свойства для передачи данных.

Если вы хотите узнать больше о Lit-Element, вы можете прочитать дополнительную информацию на его официальном сайте .

Вы можете просмотреть готовый элемент brick-viewer по адресу stackblitz.com/edit/brick-viewer-complete .

brick-viewer также доступен в NPM, и вы можете посмотреть исходный код здесь: репозиторий на Github .