1. Introducción
Componentes web
Los componentes web son un conjunto de estándares web que permiten a los desarrolladores extender el código HTML con elementos personalizados. En este codelab, definirás el elemento <brick-viewer>
, que podrá mostrar modelos de ladrillos.
elemento-iluminado
Para ayudarnos a definir nuestro elemento personalizado <brick-viewer>
, usaremos lit-element. lit-element es una clase base liviana que agrega algunos elementos sintácticos al estándar de componentes web. Esto nos permitirá ponernos en marcha fácilmente con nuestro elemento personalizado.
Comenzar
Programaremos en un entorno de Stackblitz en línea, así que abre este vínculo en una ventana nueva:
stackblitz.com/edit/brick-viewer
Comencemos.
2. Cómo definir un elemento personalizado
Definición de clase
Para definir un elemento personalizado, crea una clase que extienda LitElement
y decóralo con @customElement
. El argumento para @customElement
será el nombre del elemento personalizado.
En brick-viewer.ts, agrega lo siguiente:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}
Ahora, el elemento <brick-viewer></brick-viewer>
está listo para usarse en HTML. Sin embargo, si lo intentas, no se renderizará nada. Sin embargo, podemos solucionarlo.
Método de renderización
Para implementar la vista del componente, define un método llamado render. Este método debe mostrar un literal de plantilla etiquetado con la función html
. Coloca el código HTML que quieras en el literal de plantilla etiquetado. Esto se renderizará cuando uses <brick-viewer>
.
Agrega el método render
:
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick viewer</div>`;
}
}
3. Especifica el archivo LDraw
Define una propiedad
Sería genial si un usuario de <brick-viewer>
pudiera especificar qué modelo de ladrillo mostrar con un atributo, como el siguiente:
<brick-viewer src="path/to/model.ldraw"></brick-viewer>
Dado que estamos compilando un elemento HTML, podemos aprovechar la API declarativa y definir un atributo de origen, como una etiqueta <img>
o <video>
. Con lit-element, es tan fácil como decorar una propiedad de clase con @property
. La opción type
te permite especificar cómo lit-element analiza la propiedad para usarla como atributo HTML.
Define la propiedad y el atributo src
:
export class BrickViewer extends LitElement {
@property({type: String})
src: string|null = null;
}
<brick-viewer>
ahora tiene un atributo src
que podemos configurar en HTML. Su valor ya se puede leer desde nuestra clase BrickViewer
gracias a lit-element.
Muestra valores
Podemos mostrar el valor del atributo src
usándolo en el literal de plantilla del método de renderización. Interpola valores en literales de plantilla con la sintaxis ${value}
.
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick model: ${this.src}</div>`;
}
}
Ahora, vemos el valor del atributo src en el elemento <brick-viewer>
en la ventana. Prueba lo siguiente: Abre las herramientas para desarrolladores de tu navegador y cambia manualmente el atributo src. Adelante, pruébala…
…¿Notaste que el texto del elemento se actualiza automáticamente? lit-element observa las propiedades de la clase decoradas con @property
y vuelve a renderizar la vista por ti. lit-element hace el trabajo pesado para que no tengas que hacerlo.
4. Configura la escena con Three.js
Luces, cámara, renderización
Nuestro elemento personalizado usará three.js para renderizar nuestros modelos de ladrillos 3D. Hay algunas tareas que queremos hacer solo una vez para cada instancia de un elemento <brick-viewer>
, como configurar la escena, la cámara y la iluminación de tres.js. Los agregaremos al constructor de la clase BrickViewer. Conservaremos algunos objetos como propiedades de clase para poder usarlos más adelante: cámara, escena, controles y renderizador.
Agrega la configuración de la escena de 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);
};
}
El objeto WebGLRenderer
proporciona un elemento DOM que muestra la escena de three.js renderizada. Se puede acceder a él a través de la propiedad domElement
. Podemos interpolar este valor en el literal de plantilla de renderización, con la sintaxis ${value}
.
Quita el mensaje src
que teníamos en la plantilla y, luego, inserta el elemento DOM del procesador:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
`;
}
}
Para permitir que el elemento de dominio del procesador se muestre por completo, también debemos establecer el elemento <brick-viewer>
en display: block
. Podemos proporcionar estilos en una propiedad estática llamada styles
, configurada como un literal de plantilla css
.
Agrega el siguiente estilo a la clase:
export class BrickViewer extends LitElement {
static styles = css`
/* The :host selector styles the brick-viewer itself! */
:host {
display: block;
}
`;
}
Ahora, <brick-viewer>
muestra una escena renderizada de Three.js:
Pero… está vacía. Proporciónale un modelo.
Cargador de ladrillos
Pasaremos la propiedad src
que definimos antes a LDrawLoader, que se incluye con tres.js.
Los archivos LDraw pueden separar un modelo de ladrillo en pasos de compilación separados. Se puede acceder a la cantidad total de pasos y a la visibilidad de los ladrillos individuales a través de la API de LDrawLoader.
Copia estas propiedades, el nuevo método _loadModel
y la línea nueva en el 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();
});
}
}
¿Cuándo se debe llamar a _loadModel
? Se debe invocar cada vez que cambia el atributo src. Al decorar la propiedad src
con @property
, habilitamos la propiedad en el ciclo de vida de actualización de lit-element. Cada vez que cambia el valor de una de estas propiedades decoradas, se llama a una serie de métodos que pueden acceder a los valores nuevos y antiguos de las propiedades. El método de ciclo de vida que nos interesa se llama update
. El método update
toma un argumento PropertyValues
, que contendrá información sobre las propiedades que acaban de cambiar. Este es el lugar perfecto para llamar a _loadModel
.
Agrega el método update
:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
super.update(changedProperties);
}
}
Nuestro elemento <brick-viewer>
ahora puede mostrar un archivo de ladrillo, especificado con el atributo src
.
5. Visualización de modelos parciales
Ahora, hagamos que el paso de construcción actual sea configurable. Nos gustaría poder especificar <brick-viewer step="5"></brick-viewer>
y deberíamos ver cómo se ve el modelo de ladrillo en el quinto paso de construcción. Para ello, decoremos la propiedad step
con @property
para que sea una propiedad observada.
Decora la propiedad step
:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
Ahora, agregaremos un método de ayuda que solo hará visibles los ladrillos hasta el paso de construcción actual. Llamaremos al asistente en el método de actualización para que se ejecute cada vez que se modifique la propiedad step
.
Actualiza el método update
y agrega el nuevo método _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);
}
}
Muy bien, ahora abre las Herramientas para desarrolladores de tu navegador y, luego, inspecciona el elemento <brick-viewer>
. Agrega un atributo step
de la siguiente manera:
Observa lo que sucede con el modelo renderizado. Podemos usar el atributo step
para controlar qué parte del modelo se muestra. A continuación, se muestra cómo debería verse cuando el atributo step
se establece en "10"
:
6. Navegación del conjunto de ladrillos
mwc-icon-button
El usuario final de nuestro <brick-viewer>
también debería poder navegar por los pasos de compilación a través de la IU. Agreguemos botones para ir al paso siguiente, al paso anterior y al primer paso. Para facilitar el proceso, usaremos el componente web de botón de Material Design. Como @material/mwc-icon-button
ya se importó, estamos listos para agregar a <mwc-icon-button></mwc-icon-button>
. Podemos especificar el ícono que queremos usar con el atributo de ícono de la siguiente manera: <mwc-icon-button icon="thumb_up"></mwc-icon-button>
. Puedes encontrar todos los íconos posibles aquí: material.io/resources/icons.
Agreguemos algunos botones de íconos al método de renderización:
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>
`;
}
}
Usar Material Design en nuestra página es así de fácil, gracias a los componentes web.
Vinculaciones de eventos
Estos botones deberían realizar una acción. El botón “responder” debería restablecer el paso de construcción a 1. El botón "Navigate_before" debe disminuir el paso de construcción y el botón "Navigate_next" debe aumentarlo. lit-element facilita la adición de esta funcionalidad, con vinculaciones de eventos. En tu literal de plantilla HTML, usa la sintaxis @eventname=${eventHandler}
como un atributo del elemento. Ahora se ejecutará eventHandler
cuando se detecte un evento eventname
en ese elemento. A modo de ejemplo, agreguemos controladores de eventos de clic a nuestros tres botones de ícono:
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>
`;
}
}
Intenta hacer clic en los botones ahora. ¡Buen trabajo!
Estilos
Los botones funcionan, pero no se ven bien. Están todos juntos en la parte inferior. Démosles un estilo para que se superpongan en la escena.
Para aplicar estilos a estos botones, volvemos a la propiedad static styles
. Estos estilos tienen alcance, lo que significa que solo se aplicarán a elementos de este componente web. Ese es uno de los beneficios de escribir componentes web: los selectores pueden ser más simples, y CSS será más fácil de leer y escribir. ¡Adiós, BEM!
Actualiza los estilos para que se vean de la siguiente manera:
export class BrickViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
#controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
}
`;
}
Botón de restablecimiento de la cámara
Los usuarios finales de nuestro <brick-viewer>
pueden rotar la escena con los controles del mouse. Mientras agregamos botones, agreguemos uno para restablecer la cámara a su posición predeterminada. Otro <mwc-icon-button>
con una vinculación de evento de clic hará el trabajo.
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>
`;
}
}
Navegación más veloz
Algunos sets de ladrillos tienen muchos pasos. Es posible que un usuario quiera omitir un paso específico. Agregar un control deslizante con los números de los pasos puede ayudar con la navegación rápida. Para ello, usaremos el elemento <mwc-slider>
.
deslizador de mwc
El elemento de control deslizante necesita algunos datos importantes, como el valor mínimo y máximo del control deslizante. El valor mínimo del control deslizante siempre puede ser “1”. El valor máximo del control deslizante debe ser this._numConstructionSteps
si el modelo se cargó. Podemos indicarle a <mwc-slider>
estos valores mediante sus atributos. También podemos usar la directiva lit-html ifDefined
para evitar configurar el atributo max
si no se definió la propiedad _numConstructionSteps
.
Agrega un <mwc-slider>
entre los botones “atrás” y “adelante”:
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>
`;
}
}
Datos "en alza"
Cuando un usuario mueve el control deslizante, el paso de construcción actual debe cambiar, y la visibilidad del modelo debe actualizarse según corresponda. El elemento de control deslizante emitirá un evento de entrada cada vez que se arrastre el control deslizante. Agrega una vinculación de eventos en el propio control deslizante para detectar este evento y cambiar el paso de construcción.
Agrega la vinculación de eventos:
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>
`;
}
}
¡Bravo! Podemos usar el control deslizante para cambiar qué paso se muestra.
Los datos están "caídos"
Hay algo más. Cuando se usan los botones "atrás" y "siguiente" para cambiar el paso, se debe actualizar el control del control deslizante. Vincula el atributo de valor de <mwc-slider>
a this.step
.
Agrega la vinculación 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>
`;
}
}
Ya casi terminamos con el control deslizante. Agrega un estilo flexible para que se muestre bien con los otros controles:
export class BrickViewer extends LitElement {
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
}
Además, debemos llamar a layout
en el elemento del control deslizante. Lo haremos en el método de ciclo de vida firstUpdated
, al que se llama una vez que se organiza el DOM por primera vez. El decorador query
puede ayudarnos a obtener una referencia al elemento del control deslizante en la plantilla.
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
}
Aquí tienes todos los elementos agregados al control deslizante (con atributos pin
y markers
adicionales en el control deslizante para que se vea bien):
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>
`;
}
}
Este es el producto final.
7. Conclusión
Aprendimos mucho sobre cómo usar lit-element para compilar nuestro propio elemento HTML. Aprendimos a hacer lo siguiente:
- Cómo definir un elemento personalizado
- Cómo declarar una API de atributos
- Renderiza una vista para un elemento personalizado
- Encapsula los estilos
- Usa eventos y propiedades para pasar datos
Si quieres obtener más información acerca de lit-element, puedes leer el artículo en su sitio oficial.
Puedes ver un elemento brick-viewer completo en stackblitz.com/edit/brick-viewer-complete.
brick-viewer también se envía en NPM, y puedes ver la fuente aquí: repositorio de GitHub.