# Gizmos and selection

> Move, rotate and resize PixiJS objects with a gizmo, snap them to smart guides, track the selection and undo every drag with a history stack.

Source: https://pixi-silk.schmooky.dev/docs/gizmos-and-selection/

> **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 `gizmo` (source):

```ts
import { Container } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { type GizmoMode, type GizmoSpace, TransformGizmo } from 'pixi-silk/ui/gizmo';
import { SelectionOutline } from 'pixi-silk/ui/outline';
import { defineDemo } from './runtime';

// Click a shape to select it, then drag the handles. Hold Shift to snap.
export default defineDemo({
    title: 'Transform gizmo',
    size: [640, 420],
    touchAction: 'none',
    controls: {
        mode: {
            type: 'select',
            label: 'mode',
            options: ['universal', 'move', 'rotate', 'scale', 'box'],
            value: 'universal',
        },
        space: { type: 'select', label: 'space', options: ['local', 'world'], value: 'local' },
        snap: { type: 'toggle', label: 'always snap', value: false },
    },
    setup({ silk, stage, params, tick }) {
        const ticker = silk.app.ticker;
        const scene = new Container();
        const overlay = new Container();
        const background = new Container();
        const hover = new SelectionOutline({ ticker });
        const selection = new SelectionOutline({ ticker });
        const gizmo = new TransformGizmo({ ticker });

        background.eventMode = 'static';
        background.hitArea = { contains: () => true };
        background.on('pointerdown', () => {
            gizmo.attach(null);
            selection.select(null);
        });
        stage.addChild(background, scene, overlay);
        overlay.addChild(hover, selection, gizmo);

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

            draw(g);
            item.addChild(g);
            item.position.set(x, y);
            item.rotation = rotation;
            item.eventMode = 'static';
            item.cursor = 'pointer';
            item.on('pointerover', () => hover.hover(item));
            item.on('pointerout', () => hover.hover(null));
            item.on('pointerdown', (e) => {
                e.stopPropagation();
                selection.select(item);
                gizmo.attach(item);
            });
            scene.addChild(item);

            return item;
        };

        const card = shape(190, 210, -0.2, (g) => g.roundRect(-70, -45, 140, 90, 18, 0.6).fill(0x30d158));

        shape(400, 150, 0.3, (g) => g.star(0, 0, 5, 52, undefined, 0, 6).fill(0xffd60a));
        shape(460, 300, 0, (g) => g.roundRect(-55, -30, 110, 60, 30).fill(0xbf5af2));
        selection.select(card);
        gizmo.attach(card);

        tick(() => {
            gizmo.state.mode.set(params.mode as GizmoMode);
            gizmo.state.space.set(params.space as GizmoSpace);
            // the box mode draws its own frame
            selection.alpha = params.mode === 'box' ? 0 : 1;
            gizmo.snap.enabled = params.snap;
        });

        return undefined;
    },
});
```

A `TransformGizmo` moves, rotates and scales one container. A `SelectionOutline` frames what is
selected, and a `SnapEngine` says where a moving box should land. A `Selection` store and a
`History` tie them together. Each piece works alone and has its own entry point.

## Attach a gizmo

```ts

const overlay = new Container();
const shape = new Container();
const gizmo = new TransformGizmo({
    mode: 'universal',
    snap: { translate: 10, angle: Math.PI / 12, scale: 0.1 },
    onChange: (t) => console.log(t.x, t.y, t.rotation, t.scaleX, t.scaleY),
});

overlay.addChild(gizmo);
gizmo.attach(shape);
gizmo.state.mode.set('rotate');
```

Put the gizmo in an overlay above the scene. It reads the target's transform every frame, so it
follows the target wherever it sits in the tree. It writes the new transform back into the target
unless you pass `apply: false`.

| Mode | Handles |
|---|---|
| `move` | Arrows lock to one axis, the centre square moves freely. |
| `rotate` | A ring turns the object about its pivot, with the swept angle in a pill. |
| `scale` | Cube handles stretch one axis, the centre ring scales evenly. |
| `box` | Eight handles resize in the object's own frame. Shift keeps the aspect, Alt resizes from the centre. A drag right outside a corner rotates. |
| `universal` | Move, rotate and scale handles together. This is the default. |

| Option | Default | What it does |
|---|---|---|
| `space` | `'local'` | Moves along the object's own axes, or `'world'`. Scale handles always follow the object's axes. |
| `snap` | `{ translate: 10, angle: 15 degrees, scale: 0.1 }` | Steps Shift snaps to. `snap.enabled: true` snaps by default, and Shift then turns it off. |
| `snapEngine`, `snapGuides` | none | Smart guides for free moves, below. |
| `readout` | `true` | Shows the angle, size or offset in a pill while dragging. |
| `transition` | `'pop'` | When the gizmo moves to another object, the old handles shrink away and the new ones spring in. `'glide'` slides them over. |
| `apply` | `true` | `false` leaves the target alone, so you apply the change in `onChange`, for undo stacks or networked editors. |

`gizmo.grab(event)` starts a move from a press on the object itself, so one gesture selects and
drags an unselected shape. The box handles assume the object is not mirrored.

## Outline the selection

```ts

const [shape, a, b] = [new Container(), new Container(), new Container()];
const hover = new SelectionOutline();
const selected = new SelectionOutline({ dash: [5, 4], march: 16, padding: 4 });

shape.on('pointerover', () => hover.hover(shape));
shape.on('pointerout', () => hover.hover(null));
selected.select([a, b]);
```

`hover()` draws a quiet line. `select()` draws marching dashes and corner brackets. A rotated
object gets a rotated box, and several objects get one box around all of them. `march: 0` keeps
the dashes still.

## Snap to smart guides

Live demo `snap` (source):

```ts
import { Container } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { TransformGizmo } from 'pixi-silk/ui/gizmo';
import { boundsQuad, SelectionOutline } from 'pixi-silk/ui/outline';
import { SnapEngine, SnapGuides } from 'pixi-silk/ui/snap';

import { defineDemo } from './runtime';

// Drag a card: edges and centres line up with the others, and equal gaps show their size.
// Hold Ctrl or Cmd to move without guides.
export default defineDemo({
    title: 'Snapping',
    size: [640, 380],
    touchAction: 'none',
    controls: {
        spacing: { type: 'toggle', label: 'equal spacing', value: true },
        threshold: { type: 'range', label: 'threshold', min: 2, max: 16, step: 1, value: 6 },
    },
    setup({ silk, stage, params, tick }) {
        const ticker = silk.app.ticker;
        const scene = new Container();
        const engine = new SnapEngine({ lines: { x: [320], y: [190] } });
        const guides = new SnapGuides({ ticker });
        const selection = new SelectionOutline({ ticker });
        const gizmo = new TransformGizmo({ ticker, mode: 'box', snapEngine: engine, snapGuides: guides });
        const cards: Container[] = [];
        const colors = [0x0a84ff, 0xff9f0a, 0x30d158, 0xbf5af2];
        const select = (card: Container | null) => {
            selection.select(card);
            gizmo.attach(card);
        };

        stage.eventMode = 'static';
        stage.hitArea = { contains: () => true };
        stage.on('pointerdown', () => select(null));
        stage.addChild(scene, guides, selection, gizmo);
        [
            [70, 80],
            [250, 80],
            [430, 120],
            [140, 240],
        ].forEach(([x, y], i) => {
            const card = new Container();
            const g = new SilkGraphics();

            g.roundRect(0, 0, i === 2 ? 140 : 110, 80, 14, 0.6).fill(colors[i]);
            card.addChild(g);
            card.position.set(x, y);
            card.eventMode = 'static';
            card.cursor = 'move';
            card.on('pointerdown', (e) => {
                e.stopPropagation();
                select(card);
                // press and drag in one go, even on a card that was not selected yet
                gizmo.grab(e);
            });
            scene.addChild(card);
            cards.push(card);
        });
        select(cards[3]);

        // what to snap against: every card, read as rectangles in the scene's space
        const refresh = () => {
            engine.targets = cards.map((c) => {
                const q = boundsQuad(c, scene);
                const xs = q.map((p) => p.x);
                const ys = q.map((p) => p.y);

                return {
                    x: Math.min(...xs),
                    y: Math.min(...ys),
                    width: Math.max(...xs) - Math.min(...xs),
                    height: Math.max(...ys) - Math.min(...ys),
                };
            });
        };

        gizmo.state.dragging.subscribe((on) => on && refresh());
        refresh();
        tick(() => {
            engine.spacing = params.spacing;
            engine.threshold = params.threshold;
        });

        return undefined;
    },
});
```

```ts

const others = [{ x: 0, y: 0, width: 100, height: 60 }];
const engine = new SnapEngine({ threshold: 6, targets: others, lines: { x: [320] } });
const box = { x: 103, y: 40, width: 80, height: 60 };
const r = engine.snap(box);

box.x += r.dx;
box.y += r.dy;
```

`SnapEngine` is maths with no drawing. It aligns edges with edges and centres with centres, and it
offers equal spacing: centred between two neighbours, or at a gap that already exists in the row.
`SnapGuides` draws the result as dashed lines with gap sizes in pills. Pass both to a gizmo as
`snapEngine` and `snapGuides`, and its free moves snap on their own.

## Move several objects at once

```ts

declare const world: Container, overlay: Container, selected: Container[];

const group = new SelectionGroup();
const gizmo = new TransformGizmo({ mode: 'box', ...group.hooks() });

world.addChild(group);
overlay.addChild(gizmo);
group.set(selected);
gizmo.attach(group.target);
```

`SelectionGroup` is an invisible box around its objects. Each change of the box applies to every
object about the common pivot, whatever their parents. With one object selected, `group.target`
is that object, so it keeps its own turned frame. Call `refit()` after the objects move on their
own.

## Track the selection

Live demo `selection-history` (source):

```ts
import { Container, Text } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { bindHistoryKeys, History, recordGizmo, recordSignals } from 'pixi-silk/history';
import { bindGizmo, bindOutline, Selection } from 'pixi-silk/selection';
import { type Signal, signal, watch } from 'pixi-silk/signal';
import { SilkButton } from 'pixi-silk/ui/button';
import { TransformGizmo } from 'pixi-silk/ui/gizmo';
import { SelectionOutline } from 'pixi-silk/ui/outline';
import { defineDemo } from './runtime';

const COLORS = [0x0a84ff, 0x30d158, 0xff9f0a, 0xff375f, 0xbf5af2, 0x64d2ff];

// Click a shape, Shift + click to add more, then drag the gizmo. Circles selects by a test.
// Tint recolours the selection through signals, recorded without writing a command.
// Every step lands in the list on the right. Cmd or ctrl + Z undoes, Shift + cmd + Z redoes.
export default defineDemo({
    title: 'Selection and history',
    size: [760, 420],
    // phones: the history list goes under the shapes
    phone: { size: [360, 650] },
    touchAction: 'none',
    setup({ stage, silk, width, height, phone }) {
        const ticker = silk.app.ticker;
        const scene = new Container();
        const overlay = new Container();
        const history = new History();
        const selection = new Selection<Container>();
        const outline = new SelectionOutline({ ticker });
        const hover = new SelectionOutline({ ticker });
        const gizmo = new TransformGizmo({ ticker, readout: false });
        const colors = new Map<Container, Signal<number>>();

        overlay.addChild(hover, outline, gizmo);
        stage.addChild(scene, overlay);
        stage.eventMode = 'static';
        stage.hitArea = { contains: (x: number, y: number) => x >= 0 && y >= 0 && x <= width && y <= height };
        // a press on the background (not on a button or a shape) clears the selection
        stage.on('pointerdown', (e) => e.target === stage && !e.shiftKey && selection.clear());

        const shape = (kind: 'circle' | 'square', x: number, y: number, i: number) => {
            const item = new Container({ label: kind });
            const g = new SilkGraphics();
            const color = signal(COLORS[i % COLORS.length]);

            watch(color, (c) => {
                g.clear();
                if (kind === 'circle') g.circle(0, 0, 38).fill(c);
                else g.roundRect(-36, -36, 72, 72, 18, 0.6).fill(c);
            });
            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', () => selection.state.hovered.set(null));
            item.on('pointerdown', (e) => {
                e.stopPropagation();
                selection.pick(item, e.shiftKey ? 'toggle' : 'replace');
                // press and drag in one go
                if (!e.shiftKey) gizmo.grab(e);
            });
            scene.addChild(item);
            colors.set(item, color);

            return item;
        };
        // two rows of three, 130 px apart (110 on phones, under the Studio link)
        const [x0, dx, y0, dy] = phone ? [70, 110, 176, 120] : [110, 130, 150, 140];
        const items = [0, 1, 2, 3, 4, 5].map((i) =>
            shape(i % 2 ? 'square' : 'circle', x0 + (i % 3) * dx, y0 + Math.floor(i / 3) * dy, i),
        );
        const stops = [
            bindOutline(selection, outline, hover),
            bindGizmo(selection, gizmo),
            // one undo step per drag, and colour changes recorded from the signals
            recordGizmo(gizmo, history),
            recordSignals(history, [...colors.values()], { label: 'Tint' }).stop,
            bindHistoryKeys(history),
        ];

        // buttons
        const dark = { fill: 0x2c2c2e, fillHover: 0x3a3a3c, fillPress: 0x1c1c1e, fillDisabled: 0x232325 };
        let tint = 0;
        const button = (label: string, x: number, onPress: () => void) => {
            const b = new SilkButton({ ticker, label, width: phone ? 76 : 84, height: 32, theme: dark, onPress });

            b.position.set(x, phone ? 76 : 40);
            overlay.addChild(b);

            return b;
        };

        // button centres: 94 px apart, 82 on phones
        const bx = (i: number) => (phone ? 57 + i * 82 : 66 + i * 94);

        button('Circles', bx(0), () => selection.selectWhere(items, (s) => s.label === 'circle'));
        button('Tint', bx(1), () => {
            tint++;
            // one handler, one step: every colour set here undoes together
            for (const item of selection.items)
                colors.get(item)?.set(COLORS[(COLORS.indexOf(colors.get(item)?.value ?? 0) + tint) % COLORS.length]);
        });
        const undo = button('Undo', bx(2), () => history.undo());
        const redo = button('Redo', bx(3), () => history.redo());

        stops.push(
            watch(history.state.canUndo, (v) => undo.state.enabled.set(v)),
            watch(history.state.canRedo, (v) => redo.state.enabled.set(v)),
        );

        // the stack, newest at the bottom, undone steps greyed out
        const panel = new SilkGraphics();
        const list = new Container();

        const [px, py] = phone ? [16, 384] : [470, 24];

        panel.roundRect(px, py, phone ? 328 : 266, phone ? 250 : 372, 18, 0.6).fill(0x1c1c1e);
        stage.addChildAt(panel, 0);
        stage.addChild(list);
        const style = (fill: number) => ({ fontFamily: 'Inter, system-ui, sans-serif', fontSize: 13, fill });
        const render = () => {
            list.removeChildren().forEach((c) => {
                c.destroy();
            });
            const rows = [
                ...history.past.slice(phone ? -5 : -8).map((s) => ({ label: s.label, done: true })),
                ...history.future.slice(0, phone ? 2 : 4).map((s) => ({ label: s.label, done: false })),
            ];
            const title = new Text({ text: 'History', style: style(0x8e8e93), resolution: 4 });

            title.position.set(px + 24, py + 18);
            list.addChild(title);
            if (!rows.length) rows.push({ label: 'Nothing yet', done: false });
            rows.forEach((row, i) => {
                const t = new Text({ text: row.label, style: style(row.done ? 0xf2f2f7 : 0x636366), resolution: 4 });

                t.position.set(px + 24, py + 50 + i * 26);
                list.addChild(t);
            });
        };

        stops.push(history.onChange(render));
        render();
        selection.pick(items[1]);

        return () => {
            for (const stop of stops) stop();
        };
    },
});
```

```ts

declare const layers: Container[];
declare const outline: SelectionOutline, hover: SelectionOutline;
declare const gizmo: TransformGizmo, measure: Measure;

const selection = new Selection<Container>();

for (const layer of layers) {
    layer.on('pointerdown', (e) => selection.pick(layer, e.shiftKey ? 'toggle' : 'replace'));
    layer.on('pointerover', () => selection.state.hovered.set(layer));
}
bindOutline(selection, outline, hover);
bindGizmo(selection, gizmo);
bindMeasure(selection, measure);
```

`Selection` keeps the items in the order they were picked, the primary item (the last one picked)
and the item under the pointer. All three are signals. `pick()` does what a click does,
`selectWhere(pool, test)` selects by a test and `focus(item)` changes the primary item without
changing the set. `filter` keeps locked layers out, and `multiple: false` allows one item at most.
The store takes any item type: containers, ids or your own records.

The `bind*` adapters make the widgets follow the store, and each returns the function that stops
it. `bindGizmo` attaches the gizmo to the primary item. `bindMeasure` shows its size and the
distances to the hovered item. The selection module draws nothing itself.

## Undo and redo

```ts

declare const gizmo: TransformGizmo, layer: { name: string };

const history = new History();

recordGizmo(gizmo, history);
bindHistoryKeys(history);
history.execute({ label: 'Rename', do: () => (layer.name = 'Hero'), undo: () => (layer.name = 'Layer 1') });
```

`History` is a stack of commands, each with `do()` and `undo()`. `recordGizmo` turns each gizmo
drag into one step labelled Move, Rotate, Scale or Resize. `bindHistoryKeys` binds the usual
shortcuts on `window` and leaves keys typed into inputs alone.

| Call | What it does |
|---|---|
| `execute(command)` | Runs a command and stores it. |
| `record(command)` | Stores a change that already happened, such as the end of a drag. |
| `begin()`, `end()` | Everything recorded in between becomes one step. They nest. |
| `transaction(label, fn)` | `begin()` and `end()` around `fn`, and reverts the changes if `fn` throws. |
| `merge(next)` on a command | Folds the next command into this one, so typing or nudges become one step. |

A new change drops the steps you could redo. `maxDepth` caps the stack at 200 steps by default.
`state.canUndo`, `state.canRedo`, `state.undoLabel` and `state.redoLabel` are signals for buttons
and menus.

### Record signals instead of commands

```ts

const history = new History();
const doc = { title: signal('Untitled'), zoom: signal(1) };

recordSignals(history, doc);
doc.title.value = 'Poster';
history.undo();
```

You keep writing to the signals and undo works. Changes made in one handler become one step. The
default `patch` mode stores only the signals that changed, and `snapshot` stores all of them, for
values that depend on each other.

### Use your own store

Widgets take any `HistoryLike`: an object with `record`, `begin` and `end`. An adapter over your
own state library sends the same edits there.

```ts

declare const store: { push(entry: { label?: string; undo(): void; redo(): void }): void };

const history: HistoryLike = {
    record: (c) => store.push({ label: c.label, undo: () => c.undo(), redo: () => c.do() }),
    begin: () => {},
    end: () => {},
};
```
