How to shorten Canvas API code in Svelte

Developer from development consultancy This Dot Labs explains how to use canvas in Svelte and how to turn the verbose Canvas API into a concise, more declarative one. Details before the start of our frontend course.


Element <canvas> and the Canvas API let you draw in JavaScript, and with Svelte, you can convert its imperative API to a declarative one. This will require you to know Renderless components – components that do not render.

Renderless

All sections of the file .svelte, including template, are optional. Therefore, it is possible to create a component that is not rendered, but contains logic in a tag <script>.

Let’s create a new Svelte project using Vite:

npm init vite

 Project name: canvas-svelte
 Select a framework: › svelte
 Select a variant: › svelte-ts

cd canvas-svelte
npm i

And a new component – Renderless:

<!-- src/lib/Renderless.svelte -->
<script>
    console.log("No template");
</script>

After the component is initialized, we will display a message. To do this, we rewrite the entry point App:

// src/main.ts
// import App from './App.svelte'
import Renderless from './lib/Renderless.svelte'

const app = new Renderless({
  target: document.getElementById('app')
})

export default app

Now we start the server, open the developer tools – and we see the message:

Works.

Note that the bean’s lifecycle methods are still available, meaning the bean behaves like a normal bean even when it doesn’t have a template.

Let’s check it out:

<!-- src/lib/Renderless.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("No template");
    onMount(() => {
        console.log("Component mounted");
    });
</script>

After mounting, Renderless displays a second message, both messages appear in the expected order:

This means Renderless can be used like any other Svelte component. Revert changes main.ts and “draw” the component inside App:

// src/main.ts
import App from './App.svelte'

const app = new App({
  target: document.getElementById('app')
})

export default app
<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";

  import Renderless from "./lib/Renderless.svelte";
  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
  <Renderless />
</main>

Let’s rewrite Renderless to log important messages:

<!-- src/lib/Renderless.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("Renderless: initialized");
    onMount(() => {
        console.log("Renderless: mounted");
    });
</script>

When creating non-rendered and canvas components, it is important to pay attention to the order in which components are initialized and mounted.

Another way to mount a component is to pass it as a child of another component. This transfer is called content projection. We make this projection using slot.

Let’s write a component Containerwhich will render the elements added to the slot:

<!-- src/lib/Container.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("Container: initialized");
    onMount(() => {
        console.log("Container: mounted");
    });
</script>

<h1>The container of things</h1>
<slot />
<p>invisible things</p>

By using prop add to component Renderless identifier:

<!-- src/lib/Renderless.svelte -->
<script lang="ts">
    import { onMount } from "svelte";
    export let id:string = "NoId"
    console.log(`Renderless ${id}: initialized`);
    onMount(() => {
        console.log(`Renderless ${id}: mounted`);
    });
</script>

Let’s rewrite App for the container, then pass in App several copies Renderless:

<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import Container from "./lib/Container.svelte";

  import Renderless from "./lib/Renderless.svelte";
  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
  <Container>
    <Renderless id="Foo"/>
    <Renderless id="Bar"/>
    <Renderless id="Baz"/>
  </Container>
</main>

Seen below and Containerand components without rendering, which write to the log during initialization and mounting:

Now let’s use components without rendering in combination with <canvas>.

HTML canvas and Canvas API

Element canvas cannot contain any child elements other than a backing element for rendering. Everything that you want to show in the canvas must be written in the imperative API.

Let’s create a new Canvas component and draw the canvas:

<!-- src/lib/Canvas.svelte -->
<script>
    import { onMount } from "svelte";

    console.log("Canvas: initialized");
    onMount(() => {
        console.log("Canvas: mounted");
    });
</script>

<canvas />

Let’s update Appto take advantage Canvas:

<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import Canvas from "./lib/Canvas.svelte";

  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
 <Canvas />
</main>

And now open the developer tools:

Drawing elements inside canvas

As already mentioned, you cannot add elements directly to the canvas. To draw, you need to work with the API.

We get a link to the element through bind:this. It is important to understand that in order to work with the API, the element must be available, that is, you will have to draw after mounting the component:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    console.log("1", canvasElement) // undefined!!!
    console.log("Canvas: initialized");
    onMount(() => {
        console.log("2", canvasElement) // OK!!!
        console.log("Canvas: mounted");
    });
</script>

<canvas bind:this={canvasElement}/>

Let’s draw a line. For clarity, I removed all logging:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d")

        // draw line
        ctx.beginPath();
        ctx.moveTo(10, 20); // line will start here
        ctx.lineTo(150, 100); // line ends here
        ctx.stroke(); // draw it
    });
</script>

<canvas bind:this={canvasElement}/>

Canvas needs a context to draw, so you can only do this after the component has been mounted:

If you want to add a second line, you will have to add a new block of code:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d")

        // draw first line
        ctx.beginPath();
        ctx.moveTo(10, 20); // line will start here
        ctx.lineTo(150, 100); // line ends here
        ctx.stroke(); // draw it

       // draw second line
        ctx.beginPath();
        ctx.moveTo(10, 40); // line will start here
        ctx.lineTo(150, 120); // line ends here
        ctx.stroke(); // draw it
    });
</script>

We draw simple shapes – and there is more and more code in the component. You can write helper functions that shorten the line code:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement;
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d");

        // draw first line
        drawLine(ctx, [10, 20], [150, 100]);

        // draw second line
        drawLine(ctx, [10, 40], [150, 120]);
    });

    type Point = [number, number];
    function drawLine(ctx: CanvasRenderingContext2D, start: Point, end: Point) {
        ctx.beginPath();
        ctx.moveTo(...start); // line will start here
        ctx.lineTo(...end); // line ends here
        ctx.stroke(); // draw it
    }
</script>

<canvas bind:this={canvasElement} />

Code is easier to read, but responsibility is still delegated Canvas, and this leads to a large complexity of the component. To avoid a lot of complexity, components without rendering and the Context API will help.

And here’s what we already know:

  • For drawing, we need a Canvas.

  • You can get the context after mounting the component.

  • Child components are mounted before the parent component.

  • Parent components are initialized before child components.

  • Child components can be used when mounted.

Our component needs to be divided into several components. Here you want to Line painted himself.

Canvas and Line connected. Line can’t be drawn without Canvas and needs a context canvas. But the context is not available when the child component is mounted, because Line mounted before Canvas. Therefore, a different approach is needed.

Instead of passing a context to draw itself, let’s tell the parent component to draw the child component. Canvas and Line connect through Context.

Context is the way in which two or more components interact. It can only be set or retrieved during initialization, which is what we need: Canvas initialized before Line.

First, let’s move line drawing to a separate component, and some types to their own file to make them common to components:

// src/types.ts
export type Point = [number, number];
export type DrawFn = (ctx: CanvasRenderingContext2D) => void;
export type CanvasContext = {
  addDrawFn: (fn: DrawFn) => void;
  removeDrawFn: (fn: DrawFn) => void;
};
<!-- src/lib/Line.svelte -->
<script lang="ts">
    import type { Point } from "./types";

    export let start: Point;
    export let end: Point;

    function draw(ctx: CanvasRenderingContext2D) {
        ctx.beginPath();
        ctx.moveTo(...start);
        ctx.lineTo(...end);
        ctx.stroke();
    }
</script>

This is very similar to what was Canvas, but abstracted to a component. Now we need to organize communication Canvas and Line.

Canvas will work as an orchestrator for the entire rendering. It initializes all child components, collects rendering functions, and renders when needed:

<script lang="ts">
  import { onMount, setContext } from "svelte";
  import type { DrawFn } from "./types";

  let canvasElement: HTMLCanvasElement;
  let fnsToDraw = [] as DrawFn[];

  setContext("canvas", {
    addDrawFn: (fn: DrawFn) => {
      fnsToDraw.push(fn);
    },
    removeDrawFn: (fn: DrawFn) => {
        let index = fnsToDraw.indexOf(fn);
        if (index > -1){
        fnsToDraw.splice(index, 1);
        }
    },
  });

  onMount(() => {
    // get canvas context
    let ctx = canvasElement.getContext("2d");
    draw(ctx);
  });

  function draw(ctx){
    fnsToDraw.forEach(draw => draw(ctx));
  }
</script>

<canvas bind:this={canvasElement} />
<slot />

The first thing to note is that the template has changed, next to canvas an element appeared <slot>. It will be used to mount any child elements that are passed in canvasare the components Line. These Line will not add any HTML elements.

array let fnsToDraw = [] as DrawFn[] in <script> stores all rendering functions.

We have also established a new context. This must be done during initialization. Canvas initialized to Lineso two methods are set here – to add and remove a function from DrawFn[]. After that, any of their child components will have access to this context and call its methods. This is exactly what is done in Line:

<script lang="ts">
  import { getContext, onDestroy, onMount } from "svelte";
  import type { Point, CanvasContext } from "./types";

  export let start: Point;
  export let end: Point;

  let canvasContext = getContext("canvas") as CanvasContext;

  onMount(() => {
    canvasContext.addDrawFn(draw);
  });

  onDestroy(() => {
    canvasContext.removeDrawFn(draw);
  });

  function draw(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();
    ctx.moveTo(...start);
    ctx.lineTo(...end);
    ctx.stroke();
  }
</script>

The function is registered using the context set by the Canvas when the component is mounted. It was also possible to register during initialization, because the context is available anyway, but I prefer to do this after mounting the component. When an element is destroyed, it removes itself from the list of rendering functions.

Now let’s add App components Canvas and Line:

<script lang="ts">
  import Canvas from "./lib/Canvas.svelte";
  import Line from "./lib/Line.svelte";
</script>

<main>
  <Canvas>
    <Line start={[10, 20]} end={[150, 100]} />
    <Line start={[10, 40]} end={[150, 120]} />
  </Canvas>
</main>

Component Canvas updated for declarative programming, but we only draw once when it’s mounted.

And we want the canvas to be drawn frequently and updated on changes, unless you want the opposite. Note that frequent rendering would have to be done with or without the chosen approach.

And here is a common way to update content canvas:

<script lang="ts">
  // NOTE: some code removed for readability
  // ...
  let frameId: number

  // ...

  onMount(() => {
    // get canvas context
    let ctx = canvasElement.getContext("2d");
    frameId = requestAnimationFrame(() => draw(ctx));
  });

  onDestroy(() => {
    if (frameId){
        cancelAnimationFrame(frameId)
    }
  })

  function draw(ctx: CanvasRenderingContext2D) {
if clearFrames {
    ctx.clearRect(0,0,canvasElement.width, canvasElement.width)
}
    fnsToDraw.forEach((fn) => fn(ctx));
    frameId = requestAnimationFrame(() => draw(ctx))
  }
</script>

This is achieved by redrawing canvas through requestAnimationFrame. The passed function runs before being redrawn by the browser. New variable for current frameId will be required when canceling the animation. Then, when the component is mounted, it is called requestAnimationFrameand the returned identifier is assigned to our variable.

So far the end result is the same as before. The difference is in the draw function, which requests a new animation frame after each draw. Canvas is cleared, otherwise, during animation, each frame is drawn on top of the other. This effect may be desired – then set clearFrame in false. Our Canvas will update every frame until the component is destroyed and the current animation is canceled using the stored id.

More functionality

The basic functionality of the components works, but we may want more.

This example shows the events onmousemove and onmouseleave. To make them work, change canvas like this:

<canvas on:mousemove on:mouseleave bind:this={canvasElement} />

Now these events can be handled in App:

<script lang="ts">
  import Canvas from "./lib/Canvas.svelte";
  import Line from "./lib/Line.svelte";
  import type { Point } from "./lib/types";

  function followMouse(e) {
    let rect = e.target.getBoundingClientRect();
    end = [e.clientX - rect.left, e.clientY - rect.top];
  }
  let start = [0, 0] as Point;
  let end = [0, 0] as Point;
</script>

<main>
  <Canvas
    on:mousemove={(e) => followMouse(e)}
    on:mouseleave={() => {
      end = [0, 0];
    }}
  >
    <Line {start} {end} />
  </Canvas>
</main>

Svelte is responsible for updating the end position of the line. But Canvas is used to update canvas content via requestAnimationFrame:

Results

I hope this guide will help you as an introduction to using canvas in Svelte, as well as help you understand how to turn a library with an imperative API into a more declarative one.

There are more difficult examples, such as svelte-cubed or svelte-leaflet. From documentation svelte-cubed:

It:

import * as THREE from 'three';

function render(element) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
45,
element.clientWidth / element.clientHeight,
0.1,
2000
);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(element.clientWidth / element.clientHeight);
element.appendChild(renderer.domElement);

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const box = new THREE.Mesh(geometry, material);
scene.add(box);

camera.position.x = 2;
camera.position.y = 2;
camera.position.z = 5;

camera.lookAt(new THREE.Vector3(0, 0, 0));

renderer.render(scene, camera);
}

Turns into:

<script>
import * as THREE from 'three';
import * as SC from 'svelte-cubed';
</script>

<SC.Canvas>
<SC.Mesh geometry={new THREE.BoxGeometry()} />
<SC.PerspectiveCamera position={[1, 1, 3]} />
</SC.Canvas>

The Canvas API can be extended to even create a library.

And we will help you upgrade your skills or master a profession that is relevant at any time from the very beginning:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *