Skip to content

Lección 7 - Web UI ​

En esta lección aprenderás lo siguiente:

  • Desarrollar una Web UI con Lit y Shoelace
  • Implementar un componente de lienzo
  • Implementar un componente de barra de herramientas de zoom

Web UI con Lit y Shoelace ​

Al elegir una biblioteca de componentes, no quería que estuviera atada a la implementación de un framework concreto; consulta Los Web Components no son componentes de framework, y eso está bien. Los Web Components son una buena elección, y Lit ofrece estado reactivo, estilos con alcance y un sistema de plantillas declarativo que facilita el proceso de desarrollo. Shoelace es una biblioteca de UI basada en Lit, y Web Awesome es su nuevo nombre, más llamativo. Con ellos podemos hacer que nuestros componentes de lienzo sean independientes del framework, al admitir React, Vue y Angular al mismo tiempo. Cabe destacar que la biblioteca de componentes spectrum-web-components que usa Photoshop Web también se basa en Lit; consulta ¡Photoshop ya está en la web!.

Photoshop forma parte del ecosistema más amplio Creative Cloud de Adobe. Usar una estrategia estandarizada de Web Components construida sobre Lit permite mantener la coherencia de la UI entre aplicaciones.

Como el sitio estático de este tutorial está escrito con VitePress, en las páginas de ejemplo se usan las instrucciones de Vue, pero en los componentes no se usará sintaxis de Vue.

A continuación tomaremos la funcionalidad sencilla que ya tenemos y la envolveremos en componentes de UI.

Componente de lienzo ​

Un componente de lienzo se declara con Lit y, para evitar posibles conflictos de nombres, usamos ic como prefijo del componente:

ts
import { LitElement } from 'lit';

@customElement('ic-canvas')
export class InfiniteCanvas extends LitElement {}

Definimos la propiedad renderer con un decorador, de modo que podamos usarla con una sintaxis como esta: <ic-canvas renderer="webgl" />.

ts
import { property } from 'lit/decorators.js';

export class InfiniteCanvas extends LitElement {
    @property()
    renderer = 'webgl';
}

Queremos que el lienzo siga el ancho y el alto de la página, y Shoelace ofrece un Resize Observer listo para usar que emite un evento personalizado sl-resize cuando cambia el ancho o el alto del elemento. Declaramos el HTML en el ciclo de vida render que ofrece Lit, y el HTMLCanvasElement real puede consultarse con rapidez mediante query:

ts
export class InfiniteCanvas extends LitElement {
    @query('canvas', true)
    $canvas: HTMLCanvasElement;

    render() {
        return html`
            <sl-resize-observer>
                <canvas></canvas>
            </sl-resize-observer>
        `;
    }
}

Escucha los cambios de tamaño durante el ciclo de vida connectedCallback y deja de escuchar durante el ciclo de vida disconnectedCallback:

ts
export class InfiniteCanvas extends LitElement {
    connectedCallback() {
        this.addEventListener('sl-resize', this.resize);
    }
    disconnectedCallback() {
        this.removeEventListener('sl-resize', this.resize);
    }
}

Ciclo de vida de la inicialización del lienzo ​

La pregunta de cuándo crear el lienzo me ha estado rondando un tiempo. Intentar obtener <canvas> en el ciclo de vida connectedCallback devolvía undefined, porque el CustomElement todavía no se había añadido al documento, así que, naturalmente, no podía consultarlo mediante la DOM API. Al final descubrí que firstUpdated era un buen momento para disparar el evento personalizado ic-ready después de crear el lienzo y llevar la instancia del lienzo en el objeto de evento, y para disparar el evento personalizado ic-frame en cada tick:

ts
export class InfiniteCanvas extends LitElement {
    async firstUpdated() {
        this.#canvas = await new Canvas({
            canvas: this.$canvas,
            renderer: this.renderer as 'webgl' | 'webgpu',
        }).initialized;

        this.dispatchEvent(
            new CustomEvent('ic-ready', { detail: this.#canvas }),
        );

        const animate = (time?: DOMHighResTimeStamp) => {
            this.dispatchEvent(new CustomEvent('ic-frame', { detail: time }));
            this.#canvas.render();
            this.#rafHandle = window.requestAnimationFrame(animate);
        };
        animate();
    }
}

¿Hay una solución mejor que este apaño?

Usar una tarea asíncrona ​

Para este tipo de escenario, en el que se ejecuta una tarea asíncrona y después se renderiza, React ofrece <Suspense>:

tsx
<Suspense fallback={<Loading />}>
    <SomeComponent />
</Suspense>

Lit también ofrece unas tareas asíncronas parecidas, de modo que podemos mostrar los estados de carga y de error antes de que la tarea asíncrona termine y cuando falla. Al crear una tarea asíncrona con Task, tienes que especificar los parámetros e incluir el <canvas> creado como valor de retorno, para poder recuperarlo y renderizarlo en el hook complete de la función de renderizado.

ts
private initCanvas = new Task(this, {
    task: async ([renderer]) => {
      return canvas.getDOM();
    },
    args: () => [this.renderer as 'webgl' | 'webgpu'] as const,
});

render() {
  return this.initCanvas.render({
    pending: () => html`<sl-spinner></sl-spinner>`,
    complete: ($canvas) => html`
      <sl-resize-observer>
        ${$canvas}
        <ic-zoom-toolbar-lesson7 zoom=${this.zoom}></ic-zoom-toolbar-lesson7>
      </sl-resize-observer>
    `,
    error: (e) => html`<sl-alert variant="danger" open>${e}</sl-alert>`,
  });
}

Por ejemplo, en Safari, que no admite WebGPU, se mostrará el mensaje de error siguiente:

Error al inicializar el lienzo
El navegador no admite WebGPU.

Así queda escrito nuestro componente de lienzo. El carácter independiente del framework de los Web Components nos permite usarlos de una forma coherente. Tomemos Vue y React como ejemplo:

vue
<template>
    <ic-canvas renderer="webgl"></ic-canvas>
</template>
tsx
<div>
    <ic-canvas renderer="webgl"></ic-canvas>
</div>

Obtén la instancia del lienzo escuchando el evento personalizado ic-ready cuando el lienzo se ha cargado:

ts
const $canvas = document.querySelector('ic-canvas');
$canvas.addEventListener('ic-ready', (e) => {
    const canvas = e.detail;
    // 创建场景图
    canvas.appendChild(circle);
});

Aquí implementaremos el componente de zoom de la cámara.

Componente de zoom ​

La figura siguiente muestra el componente de zoom de Miro:

barra de zoom de Miro

Creemos primero un ic-zoom-toolbar:

ts
@customElement('ic-zoom-toolbar')
export class ZoomToolbar extends LitElement {}

Añádelo al componente de lienzo:

html
<sl-resize-observer>
    <canvas></canvas>
    <ic-zoom-toolbar zoom="${this.zoom}"></ic-zoom-toolbar> // [!code ++]
</sl-resize-observer>

La estructura interna de este componente tiene este aspecto, y puedes ver que Lit usa una sintaxis de plantillas muy cercana a la de Vue:

html
<sl-button-group label="Zoom toolbar">
    <sl-tooltip content="Zoom out">
        <sl-icon-button
            name="dash-lg"
            label="Zoom out"
            @click="${this.zoomOut}"
        ></sl-icon-button>
    </sl-tooltip>
    <span>${this.zoom}%</span>
    <sl-tooltip content="Zoom in">
        <sl-icon-button
            name="plus-lg"
            label="Zoom in"
            @click="${this.zoomIn}"
        ></sl-icon-button>
    </sl-tooltip>
</sl-button-group>

Para pasar la instancia del lienzo desde el componente de lienzo a sus hijos usamos Lit Context, que se guarda en el contexto después de la instanciación:

ts
const canvasContext = createContext<Canvas>(Symbol('canvas'));

export class InfiniteCanvas extends LitElement {
    #provider = new ContextProvider(this, { context: canvasContext });

    private initCanvas = new Task(this, {
      task: async ([renderer]) => {
        // ...省略实例化画布过程
        this.#provider.setValue(this.#canvas);
      }
    });
}

Después puede consumirse en el componente hijo a través del contexto y, como la creación del lienzo es asíncrona, hay que hacer subscribe para poder seguir el contexto en tiempo real:

ts
export class ZoomToolbar extends LitElement {
    @consume({ context: canvasContext, subscribe: true })
    canvas: Canvas;
}

Añade a la cámara una función callback que se dispara cada vez que cambian la cámara o la matriz de proyección:

ts
export class Camera {
    onchange: () => void;
    private updateViewProjectionMatrix() {
        if (this.onchange) {
            this.onchange();
        }
    }
}

La variable reactiva zoom se modifica cuando se dispara el callback.

ts
this.#canvas.camera.onchange = () => {
    this.zoom = Math.round(this.#canvas.camera.zoom * 100);
};

Tema ​

El tema debe aplicarse a los componentes de UI y al lienzo. Empecemos por los primeros.

Componentes de UI ​

Shoelace ofrece la funcionalidad de Temas, que puede cambiarse con facilidad. Primero importa las hojas de estilo de los dos temas:

ts
import '@shoelace-style/shoelace/dist/themes/light.css';
import '@shoelace-style/shoelace/dist/themes/dark.css';

Al cambiar al tema oscuro, basta con añadir una clase identificadora al elemento raíz:

ts
this.classList.toggle('sl-theme-dark', this.theme === 'dark');

Por último, al escribir los estilos del componente, usa variables CSS en lugar de valores fijos, para que se apliquen de forma automática al cambiar de tema:

ts
:host {
    background: var(--sl-panel-background-color);
}

En la Lección 18 - Refactorización con ECS posterior sustituimos la implementación de Web Components de Shoelace por Spectrum, que también ofrece la posibilidad de alternar entre tema claro y tema oscuro:

Tema oscuro después de la lección 18

Fondo y cuadrícula del lienzo ​

El fondo y la Cuadrícula del lienzo también tienen que asociarse con el color del tema. Tomando como referencia Colores de tema en DGM.js, admitimos pasar los valores de color de los temas claro y oscuro al crear el lienzo:

ts
enum Theme {
    LIGHT,
    DARK,
}

interface ThemeColors {
    background: string;
    grid: string;
}

interface CanvasConfig {
    themeColors?: Partial<{
        [Theme.LIGHT]: Partial<ThemeColors>; 
        [Theme.DARK]: Partial<ThemeColors>; 
    }>; 
}

Admite el cambio en tiempo de ejecución:

ts
canvas.theme = Theme.DARK;

Más adelante no entraremos en los detalles de la implementación de la UI.

Internacionalización ​

Lit ofrece una solución de Localización; puede automatizarse con las herramientas adecuadas.

Construir una plantilla localizada ​

Primero tienes que envolver con el método msg las cadenas que requieran internacionalización. Tomando el menú principal como ejemplo:

ts
import { msg, str } from '@lit/localize';

<sp-tooltip slot="tooltip" self-managed placement="bottom">
    ${msg(str`Main menu`)}
</sp-tooltip>;

A continuación ejecuta el comando siguiente para extraer del código fuente las cadenas de idioma que se usan:

bash
lit-localize extract

En este punto obtendrás una serie de archivos xlf. Envía estos archivos xlf a un servicio de traducción (o tradúcelos a mano, por supuesto) y recibirás resultados parecidos a los siguientes:

xml
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="zh-Hans" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="sa10f310edb35fa9f">
  <source>Main menu</source>
  <target>主菜单</target>
</trans-unit>
</body>
</file>
</xliff>

Después ejecuta el comando siguiente para compilar las cadenas traducidas en componentes y colocarlos en la carpeta del código fuente:

bash
lit-localize build

Cambiar el locale ​

Este sitio está construido con VitePress. Puedes encontrar un componente de cambio de idioma en la esquina superior derecha.

ts
import { localized } from '@lit/localize';

@customElement('ic-spectrum-top-navbar')
@localized()
export class TopNavbar extends LitElement {}

Puede cargarse bajo demanda en tiempo de ejecución:

ts
import { configureLocalization } from '@lit/localize';
// Generated via output.localeCodesModule
import { sourceLocale, targetLocales } from './generated/locale-codes.js';

export const { getLocale, setLocale } = configureLocalization({
    sourceLocale,
    targetLocales,
    loadLocale: (locale) => import(`/locales/${locale}.js`),
});

Con fines de demostración hemos optado por usar importaciones estáticas. Por supuesto, este enfoque carga todos los archivos de idioma, lo que afecta al tiempo hasta el primer contenido.

Componer con slot ​

En la Lección 18 - Refactorización con ECS posterior implementamos una barra de herramientas en el lado izquierdo del lienzo. Si queremos añadir una herramienta nueva a la barra de herramientas, podemos lograrlo con facilidad mediante el elemento <slot> de los Web Components.

En muchos plugins extensibles (como el Plugin de borrador) hemos implementado un uso parecido:

html
<ic-spectrum-canvas>
    <ic-spectrum-penbar-eraser slot="penbar-item"><ic-spectrum-penbar-eraser />
</ic-spectrum-canvas>

Reserva posiciones ampliables dentro de los componentes de Lit mediante <slot>:

ts
export class Penbar extends LitElement {
    render() {
        return html`<sp-action-group class="penbar">
            <slot name="penbar-item"></slot>
        </sp-action-group>`;
    }
}

Conviene señalar que, al pasar un <slot> a través del árbol de componentes, en el componente padre hay que usar un slot con nombre.

html
<!-- ic-spectrum-canvas -->
<ic-spectrum-penbar>
    <slot name="penbar-item" slot="penbar-item"></slot>
</ic-spectrum-penbar>

Lectura adicional ​

Released under the MIT License.