Создайте 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 и определить атрибут источника, как тег <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! Его значение уже можно прочитать из нашего класса BrickViewer благодаря элементуlit-element.

Отображение значений

Мы можем отобразить значение атрибута 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 выполняет тяжелую работу, поэтому вам не придется этого делать.

4. Установите сцену с помощью 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 могут разделить модель Brick на отдельные этапы сборки. Общее количество шагов и видимость отдельных блоков доступны через 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. Всякий раз, когда значение одного из этих декорированных свойств изменяется, вызывается ряд методов, которые могут получить доступ к новым и старым значениям свойств. Интересующий нас метод жизненного цикла называется 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-код элемента просмотра кирпичей с атрибутом шага, равным 10.

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

Кирпичная модель, состоящая всего из десяти этапов строительства.

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

mwc-значок-кнопка

Конечный пользователь нашего <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 .

Давайте добавим несколько кнопок-значков в метод рендеринга:

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 станет легче читать и писать. Прощай, БЭМ !

Обновите стили, чтобы они выглядели следующим образом:

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-слайдер

Элементу ползунка требуется несколько важных данных, таких как минимальное и максимальное значение ползунка. Минимальное значение ползунка всегда может быть «1». Максимальное значение ползунка должно быть this._numConstructionSteps , если модель загрузилась. Мы можем сообщить <mwc-slider> эти значения через его атрибуты. Мы также можем использовать директиву ifDefined -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>
   `;
 }
}

Вот конечный продукт!

Навигация по модели автомобиля с помощью элемента просмотра кирпичей

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

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

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

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

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

Brick-Viewer также поставляется на NPM, и вы можете просмотреть исходный код здесь: репозиторий Github .