Skip to content

Lesson 7 - Web UI

In this lesson, you will learn the following:

  • Developing Web UI with Lit and Shoelace
  • Implementing a canvas component
  • Implementing a zoom toolbar component

Web UI with Lit and Shoelace

When choosing a component library, I didn't want it to be tied to a specific framework implementation. Web components is a good choice, and Lit provides reactive state, scoped styles, and a declarative template system to make the development process easier. Shoelace is an UI library based on Lit. Using them, we can make our canvas components framework-agnostic by supporting React, Vue, and Angular at the same time.

Since this tutorial static site is written using VitePress, Vue instructions is used in the example pages, but no Vue syntax will be used in the components.

Next, we'll take the simple functionality that we already have and wrap it into UI components.

Canvas component

A canvas component is declared using Lit, and to avoid potential naming conflicts, we use ic as the component prefix:

ts
import { LitElement } from 'lit';

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

We define renderer property with decorator, so that we can use it with such syntax: <ic-canvas renderer="webgl" />.

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

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

We want the canvas to follow the page's width and height, and Shoelace provides an out-of-the-box Resize Observer that throws a custom event sl-resize when the element's width or height changes. We declare the HTML in the render lifecycle provided by Lit, and the actual HTMLCanvasElement can be quickly queried via query:

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

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

Listens for size changes during the connectedCallback lifecycle and unlistsens during the disconnectedCallback lifecycle:

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

Lifecycle for canvas initialization

The question of when to create the canvas has been bugging me for a while, trying to get <canvas> in the connectedCallback lifecycle would return undefined since the CustomElement had not been added to the document yet, so naturally I couldn't query it via the DOM API. In the end I found that firstUpdated was a good time to trigger the custom event ic-ready after creating the canvas and bring the canvas instance in the event object, and to trigger the custom event ic-frame on each 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();
    }
}

Is there a better solution than this hack?

Use async task

For this kind of scenario where an asynchronous task is executed and then rendered, React provides <Suspense>:

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

Lit also provides similar Async Tasks so that we can display the loading and error states before the asynchronous task completes and when it errors out. When creating an asynchronous task with Task, you need to specify the parameters and include the created <canvas> as a return value, so that it can be retrieved and rendered in the complete hook of the render function.

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

For example, in Safari, which does not support WebGPU, the following error message will be displayed:

Initialize canvas failed
WebGPU is not supported by the browser.

So our canvas component is written. the framework-agnostic nature of web components allows us to use them in a consistent way. Take Vue and React for example:

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

Get the canvas instance by listening to the ic-ready custom event when the canvas is loaded:

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

Here we will implement the camera zoom component.

Zoom component

The following figure shows the zoom component of Miro:

miro zoom toolbar

Let's create an ic-zoom-toolbar first:

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

Add it to canvas component:

html
<sl-resize-observer>
    <canvas></canvas>
    <ic-zoom-toolbar zoom="${this.zoom}"></ic-zoom-toolbar>
</sl-resize-observer>

The internal structure of this component looks like this, and you can see that Lit uses a template syntax that is very close to 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>

In order to pass the canvas instance from the canvas component to its children, we use Lit Context, which is saved to the context after instantiation:

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

It can then be consumed in the child component via the context, and since the canvas creation is asynchronous, it needs to be subscribe to allow us to keep track of the context in real time:

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

Add a callback function to the camera that is triggered every time the camera or projection matrix changes:

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

The responsive variable zoom is modified when the callback is triggered.

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

We won't go into the details of the UI implementation later.

Released under the MIT License.