# Your first editor canvas

> Build an editor canvas in PixiJS v8 with the pixi-silk editor kit: a camera that pans and zooms, a grid, a selection store, outlines and a transform gizmo.

Source: https://pixi-silk.schmooky.dev/docs/first-editor-canvas/

This tutorial builds the core of a design tool canvas: a camera that pans and zooms, a grid,
click to select and a gizmo that moves the selected layer.

> **NOTE**
> Experimental in 1.0: the API may change in a minor release.

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `editor-canvas` (source):

```ts
import { Container } from 'pixi.js';
import { linear, SilkGraphics } from 'pixi-silk';
import { bindHistoryKeys, History, recordGizmo } from 'pixi-silk/history';
import { bindGizmo, bindMeasure, bindOutline, Selection } from 'pixi-silk/selection';
import { watch } from 'pixi-silk/signal';
import { SilkText, silkLabel } from 'pixi-silk/text';
import { Camera, ZoomControl } from 'pixi-silk/ui/camera';
import { TransformGizmo } from 'pixi-silk/ui/gizmo';
import { altDown } from 'pixi-silk/ui/keys';
import { Measure } from 'pixi-silk/ui/measure';
import { SelectionOutline } from 'pixi-silk/ui/outline';
import { defineDemo, INK } from './runtime';

// Scroll to pan, pinch or ctrl + scroll to zoom, right drag or Space + drag to pan.
// Click a layer to select it, Shift + click to add more. Hover another to measure the distance
// to it. Drag the arrows to move a layer, cmd or ctrl + Z undoes and Shift + cmd + Z redoes.
export default defineDemo({
    title: 'Editor canvas',
    size: [720, 480],
    // phones: a taller frame and a tighter fit, so the artboard starts near 85%
    phone: { size: [360, 600] },
    touchAction: 'none',
    ownsCamera: true,
    controls: {
        redlines: { type: 'select', label: 'redlines', options: ['on hover', 'with Alt'], value: 'on hover' },
    },
    setup({ silk, params, phone }) {
        const app = silk.app;
        const world = new Container();
        const overlay = new Container();
        const camera = new Camera({ world, element: app.canvas });
        const hover = new SelectionOutline({ ticker: app.ticker });
        const outline = new SelectionOutline({ ticker: app.ticker });
        const gizmo = new TransformGizmo({ ticker: app.ticker });
        // SilkText labels: distance fields stay sharp at any zoom
        const label = silkLabel();
        const measure = new Measure({ space: world, ticker: app.ticker, label });
        const zoom = new ZoomControl(camera, { label });
        // one selection store drives the outlines, the gizmo and the redlines
        const selection = new Selection<Container>();
        const history = new History();

        gizmo.state.mode.set('move');
        overlay.addChild(hover, outline, gizmo, measure, zoom);
        app.stage.addChild(world, overlay);
        app.stage.eventMode = 'static';
        app.stage.hitArea = app.screen;
        app.stage.on('pointerdown', (e) => e.target === app.stage && !e.shiftKey && selection.clear());

        const redlines = () => params.redlines === 'on hover' || altDown();
        const measured = bindMeasure(selection, measure, { between: redlines });
        // while measuring, the hovered layer gets the red outline instead of the blue one
        const syncHover = () => {
            const over = selection.state.hovered.value;
            const quiet = !over || selection.has(over) || (redlines() && selection.primary);

            hover.hover(quiet ? null : over);
        };
        const stops = [
            bindOutline(selection, outline),
            watch(selection.state.hovered, syncHover),
            watch(selection.state.items, syncHover),
            bindGizmo(selection, gizmo),
            measured.stop,
            // every gizmo drag is one undo step, and the keys undo it
            recordGizmo(gizmo, history),
            bindHistoryKeys(history),
        ];

        // an artboard with a few layers on it
        const title = new SilkText({
            text: 'Home',
            fontFamily: 'Inter, system-ui, sans-serif',
            fontSize: 13,
            fill: INK.muted,
        });

        title.position.set(0, -20);
        world.addChild(title);

        const layer = (x: number, y: number, draw: (g: SilkGraphics) => void) => {
            const item = new Container();
            const g = new SilkGraphics();

            draw(g);
            item.addChild(g);
            item.position.set(x, y);
            item.eventMode = 'static';
            item.cursor = 'pointer';
            item.on('pointerover', () => selection.state.hovered.set(item));
            item.on('pointerout', () => {
                if (selection.state.hovered.value === item) selection.state.hovered.set(null);
            });
            item.on('pointerdown', (e) => {
                e.stopPropagation();
                selection.pick(item, e.shiftKey ? 'toggle' : 'replace');
            });
            world.addChild(item);

            return item;
        };

        const board = layer(0, 0, (g) => g.rect(0, 0, 390, 620).fill(0xf4f4f6));

        layer(24, 24, (g) =>
            g.roundRect(0, 0, 342, 180, 24, 0.6).fill(linear([0x5e5ce6, 0xbf5af2], { from: [0, 0], to: [1, 1] })),
        );
        layer(24, 228, (g) =>
            g
                .roundRect(0, 0, 160, 120, 18, 0.6)
                .fill({ color: 0xffffff, innerShadow: { color: 0x000000, alpha: 0.06, y: -1, blur: 1 } }),
        );

        layer(206, 228, (g) => g.roundRect(0, 0, 160, 120, 18, 0.6).fill(0xffffff));
        layer(24, 372, (g) => g.roundRect(0, 0, 342, 64, 16, 0.6).fill(0xffffff));
        layer(48, 388, (g) => g.circle(16, 16, 16).fill(0x30d158));
        layer(24, 532, (g) => g.pill(0, 0, 342, 52).fill(0x1c1c1e));

        const place = () =>
            zoom.position.set(app.screen.width - zoom.size[0] - 12, app.screen.height - zoom.size[1] - 12);

        app.renderer.on('resize', place);
        place();
        camera.fit(board, phone ? 16 : 40, false);

        // Alt shows the redlines without moving the pointer
        const onKey = (e: KeyboardEvent) => {
            if (e.key !== 'Alt') return;
            measured.sync();
            syncHover();
        };

        addEventListener('keydown', onKey);
        addEventListener('keyup', onKey);

        return () => {
            for (const stop of stops) stop();
            removeEventListener('keydown', onKey);
            removeEventListener('keyup', onKey);
            camera.destroy();
        };
    },
});
```

The demo adds redlines and undo to what this page builds. Its source opens in Studio.

## The whole canvas

```ts

// 1. a world the camera moves, and an overlay that stays in screen space
const world = new Container();
const overlay = new Container();
const camera = new Camera({ world, element: app.canvas });
const grid = new CanvasGrid({ camera, ticker: app.ticker });

app.stage.addChild(grid, world, overlay);

// 2. one selection store, with an outline and a gizmo that follow it
const selection = new Selection<Container>();
const outline = new SelectionOutline({ ticker: app.ticker });
const gizmo = new TransformGizmo({ mode: 'move', ticker: app.ticker });

overlay.addChild(outline, gizmo);
bindOutline(selection, outline);
bindGizmo(selection, gizmo);

// 3. layers that select on click
function layer(x: number, y: number, draw: (g: SilkGraphics) => void) {
    const g = new SilkGraphics();

    draw(g);
    g.position.set(x, y);
    g.eventMode = 'static';
    g.cursor = 'pointer';
    g.on('pointerdown', (e) => {
        e.stopPropagation();
        selection.pick(g, e.shiftKey ? 'toggle' : 'replace');
    });
    world.addChild(g);

    return g;
}

const card = layer(0, 0, (g) => g.roundRect(0, 0, 200, 120, 18, 0.6).fill(0xffffff));

layer(240, 20, (g) => g.circle(40, 40, 40).fill(0x30d158));

// 4. a click on empty canvas clears the selection
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
app.stage.on('pointerdown', (e) => e.target === app.stage && selection.clear());

selection.pick(card);
camera.fit(world, 40, false);
```

## What each part does

The camera moves and scales `world`. Scroll pans it, and pinch or ctrl + scroll zooms around the
pointer. `camera.fit(world, 40, false)` frames the layers with 40 px of padding and no animation.

The grid sits behind the world and reads the camera, so its lines stay `lineWidth` screen pixels
wide (default 1) at every zoom. Finer levels fade in as you zoom in.

`Selection` is a store with no drawing in it. `bindOutline` and `bindGizmo` subscribe the outline
and the gizmo to it, and each returns a function that stops the binding. Everything that changes
the selection goes through `selection.pick()` or `selection.clear()`.

The outline and the gizmo live in `overlay`, outside the world. They follow their target's
screen position, so the handles keep their size when you zoom. Put overlays inside the world and
they scale with it.

## Options worth changing

| Option | Default | What it does |
|---|---|---|
| `Camera` `wheel` | `'pan'` | `'zoom'` makes the wheel zoom, like a map |
| `Camera` `zoomRange` | `[0.02, 256]` | smallest and largest zoom |
| `Camera` `touchPan` | `'two'` | `'one'` pans with one finger |
| `CanvasGrid` `style` | `'lines'` | `'dots'` draws a dot grid |
| `TransformGizmo` `mode` | `'universal'` | `'move'`, `'rotate'`, `'scale'` or `'box'` shows one kind of handle |

## Where it stops

The kit draws and tracks state. It does not save documents or decide what a layer is. The
selection holds any value you give it, so your own layer objects work as well as containers.

## Where next

- [Canvas & rulers](/docs/canvas-and-rulers/) adds rulers, guides, a marquee and spacing markers.
- [Gizmos & selection](/docs/gizmos-and-selection/) covers rotate and scale, snapping and undo.
- [Panels](/docs/panels/) adds a layer tree and an inspector.
