# Editing text

> TextEditor turns a SilkText into a text layer people click and type into: caret, selection, undo, clipboard, forms and input methods, on the canvas.

Source: https://pixi-silk.schmooky.dev/docs/editing-text/

`TextEditor` from `pixi-silk/ui/textedit` wraps a [SilkText](/docs/text/) with a caret, a
selection and keyboard input. It is a display object, so it pans and zooms with a
[camera](/docs/canvas-and-rulers/) like the rest of the scene.

## Make a text layer editable

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 `text-edit-canvas` (source):

```ts
import { Container } from 'pixi.js';
import { linear, SilkGraphics } from 'pixi-silk';
import { bindHistoryKeys, History } from 'pixi-silk/history';
import { SilkText, silkLabel } from 'pixi-silk/text';
import { Camera, ZoomControl } from 'pixi-silk/ui/camera';
import { TextEditor } from 'pixi-silk/ui/textedit';
import { defineDemo } from './runtime';

// Click a text to edit it, drag to select, double click a word. Pinch or ctrl + scroll to zoom in
// and keep typing: the caret, the selection and the glyphs stay sharp. Scroll pans.
export default defineDemo({
    title: 'Text editing on the canvas',
    size: [760, 440],
    // phones: a narrower frame, so the camera starts near 100%
    phone: { size: [360, 460] },
    touchAction: 'none',
    ownsCamera: true,
    setup({ silk, phone }) {
        // the card size, narrower on phones
        const [cw, ch] = phone ? [300, 330] : [420, 260];
        const app = silk.app;
        const world = new Container();
        const camera = new Camera({ world, element: app.canvas });
        const zoom = new ZoomControl(camera, { label: silkLabel() });
        // one undo stack for the whole canvas: text edits sit in it with everything else
        const history = new History();
        const font = 'Inter, system-ui, sans-serif';

        app.stage.addChild(world, zoom);

        const card = new SilkGraphics()
            .roundRect(0, 0, cw, ch, 28, 0.6)
            .fill(linear([0x2c2c30, 0x1c1c1f], { from: [0, 0], to: [0, 1] }))
            .roundRect(0.5, 0.5, cw - 1, ch - 1, 28, 0.6)
            .stroke({ color: 0xffffff, alpha: 0.08, width: 1 });
        const caption = new SilkText({ text: 'Frame 1', fontFamily: font, fontSize: 11, fill: 0x8e8e93 });

        caption.position.set(2, -18);
        world.addChild(card, caption);

        const title = new TextEditor({
            text: 'Edit me',
            fontFamily: font,
            fontSize: 30,
            fontWeight: 700,
            fill: 0xffffff,
            multiline: false,
            history,
            element: app.canvas,
        });
        const body = new TextEditor({
            text: 'Text on an infinite canvas. Zoom to 800% and the caret is still one crisp pixel and a half wide.\n\nDouble click a word, triple click a paragraph.',
            fontFamily: font,
            fontSize: 15,
            lineHeight: 22,
            fill: 0xc7c7cc,
            width: cw - 64,
            placeholder: 'Write something',
            history,
            element: app.canvas,
        });

        title.position.set(32, 30);
        body.position.set(32, 86);
        world.addChild(title, body);

        // a tiny caption follows the title as you type
        const count = new SilkText({ fontFamily: font, fontSize: 11, fill: 0x636366, tabularNumbers: true });

        count.position.set(32, ch - 30);
        world.addChild(count);
        const update = () => {
            const words = body.value.split(/\s+/).filter(Boolean).length;

            count.text = `${title.value.length} characters in the title, ${words} words below`;
        };
        const stops = [title.state.text.subscribe(update), body.state.text.subscribe(update), bindHistoryKeys(history)];

        update();
        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();
        // phones keep room under the card for the zoom control
        camera.fit({ x: -20, y: -40, width: cw + 40, height: ch + (phone ? 110 : 60) }, phone ? 8 : 24, false);

        return () => {
            for (const stop of stops) stop();
            app.renderer.off('resize', place);
            title.destroy();
            body.destroy();
            camera.destroy();
        };
    },
});
```

```ts

declare const world: Container;
declare const canvas: HTMLCanvasElement;

const note = new TextEditor({
    text: 'Edit me',
    fontSize: 15,
    width: 320, // wraps at 320, grows in height
    placeholder: 'Write something',
    element: canvas,
});

note.on('change', (text: string) => console.log('saved', text));
world.addChild(note);
```

The note wraps at 320 units and grows downwards as you type. `change` fires when the edit is
committed with a new value. Every SilkText style option works too: font, size, weight, fill,
letter spacing, line height and alignment.

Words for double click and word jumps come from `Intl.Segmenter`, so they follow the language.
Arrow keys move by grapheme, so an emoji or an accented letter is one step.

## How typing reaches the canvas

The keys do not go to the canvas. On focus, the editor puts a hidden `<textarea>` on the page at
the caret and keeps its text and selection in step. The browser types into it, and the editor
applies each change.

That is why the platform's own input works. Input methods for Japanese, Chinese and Korean show
the composed text inline with an underline, and it becomes one undo step when confirmed. Dead
keys, mobile keyboards, autocorrect, the emoji picker and the system clipboard work too.

The hidden field follows the caret as you type, pan or zoom, so input method popups open next to
the text. Pass `element` (usually `app.canvas`) to place it. Without it, the editor uses the
element of the first press.

## Build a form

Live demo `text-edit-form` (source):

```ts
import { Container } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { watch } from 'pixi-silk/signal';
import { SilkText } from 'pixi-silk/text';
import { TextEditor } from 'pixi-silk/ui/textedit';
import { defineDemo } from './runtime';

// Single-line fields drawn on the canvas. Tab moves to the next field, Enter in the last one sends
// the form. Japanese or Chinese input, dead keys and the emoji picker type into them as usual.
export default defineDemo({
    title: 'Text form',
    size: [520, 330],
    // phones: the button and the result go under the fields
    phone: { size: [360, 350] },
    setup({ silk, stage, phone }) {
        const font = 'Inter, system-ui, sans-serif';
        const fields: TextEditor[] = [];
        const stops: (() => void)[] = [];
        const field = (label: string, y: number, options: ConstructorParameters<typeof TextEditor>[0]) => {
            const row = new Container();
            const name = new SilkText({ text: label, fontFamily: font, fontSize: 12, fontWeight: 600, fill: 0x8e8e93 });
            const box = new SilkGraphics();
            const editor = new TextEditor({
                fontFamily: font,
                fontSize: 15,
                fill: 0xf2f2f7,
                multiline: false,
                width: 276,
                element: silk.app.canvas,
                ...options,
            });

            editor.position.set(12, 31);
            // a press anywhere on the box focuses the field
            box.eventMode = 'static';
            box.cursor = 'text';
            box.on('pointerdown', (e) => {
                (e.nativeEvent as Event).preventDefault();
                editor.focus();
            });
            row.addChild(name, box, editor);
            row.position.set(phone ? 30 : 40, y);
            // the box lights up while its field has focus
            stops.push(
                watch(editor.state.focused, (on) => {
                    box.clear()
                        .roundRect(0, 20, 300, 40, 10, 0.6)
                        .fill(on ? 0x1c1c22 : 0x18181b)
                        .roundRect(0, 20, 300, 40, 10, 0.6)
                        .stroke({ color: on ? 0x0a84ff : 0x3a3a3c, width: on ? 1.5 : 1, alignment: 'inside' });
                }),
            );
            stage.addChild(row);
            fields.push(editor);

            return editor;
        };

        const name = field('Name', 24, { placeholder: 'Ada Lovelace' });
        const email = field('Email', 104, { placeholder: 'ada@example.com' });
        const code = field('Promo code, 8 characters at most', 184, { placeholder: 'SILK2026', maxLength: 8 });
        const result = new SilkText({
            fontFamily: font,
            fontSize: 12,
            fill: 0x30d158,
            wordWrap: true,
            wordWrapWidth: 150,
        });
        const button = new Container();
        const pill = new SilkGraphics().pill(0, 0, 140, 40).fill(0x0a84ff);
        const label = new SilkText({ text: 'Send', fontFamily: font, fontSize: 14, fontWeight: 600, anchor: 0.5 });

        label.position.set(70, 20);
        button.addChild(pill, label);
        button.position.set(phone ? 30 : 360, phone ? 268 : 228);
        button.eventMode = 'static';
        button.cursor = 'pointer';
        result.position.set(phone ? 190 : 360, phone ? 264 : 52);
        stage.addChild(button, result);
        const send = () => {
            const values = [name.value || 'no name', email.value || 'no email', code.value || 'no code'];

            result.text = `Sent\n${values.join('\n')}`;
            TextEditor.active?.blur();
        };

        button.on('pointertap', send);
        code.on('submit', send);
        // Enter in the first fields moves on, like a sign-up form
        name.on('submit', () => email.focus(true));
        email.on('submit', () => code.focus(true));
        stops.push(TextEditor.chain(fields));

        return () => {
            for (const stop of stops) stop();
            for (const f of fields) f.destroy();
        };
    },
});
```

With `multiline: false` the editor is an input. Enter commits instead of breaking the line, and a
`width` clips the text and scrolls it to keep the caret in view. `TextEditor.chain()` links fields in
Tab order.

```ts

const email = new TextEditor({ multiline: false, width: 260, placeholder: 'ada@example.com' });
const code = new TextEditor({ multiline: false, width: 260, maxLength: 8 });

TextEditor.chain([email, code]);
code.on('submit', () => console.log(email.value, code.value));
```

| Option | Default | What it does |
| --- | --- | --- |
| `multiline` | true | A text area, or a single-line input |
| `width` | none | Wraps a text area, clips and scrolls an input. Without it the box grows with the text |
| `placeholder` | empty | Shown while the text is empty |
| `maxLength` | none | Longest text in UTF-16 code units, like `maxlength`. Never splits an emoji |
| `readOnly` | false | Selectable and copyable, not editable |
| `selectable` | true | False turns pointer input off |
| `enter` | `newline`, or `commit` for one line | `newline`, `commit` or `blur`. Shift + Enter breaks the line in a text area |
| `tab` | `next` | `next` emits `tab` and leaves, `insert` types a tab |
| `history` | its own | A `HistoryLike` to share the app's undo stack |
| `ariaLabel` | the placeholder | Name of the field for screen readers |
| `handles` | true | Drag handles at the ends of a touch selection |
| `caretWidth` | 1.5 | Caret width in screen pixels, whatever the zoom |
| `theme` | blue | Caret, selection and composition colours |

In a multi-line field, cmd or ctrl + Enter commits and leaves. Escape leaves any field.

## Show text people can copy

Live demo `text-edit-readonly` (source):

```ts
import { SilkText } from 'pixi-silk/text';
import { TextEditor } from 'pixi-silk/ui/textedit';
import { defineDemo } from './runtime';

// A read-only paragraph: select with the mouse or a long press, cmd or ctrl + C copies.
// There is no caret and typing does nothing.
export default defineDemo({
    title: 'Read-only text selection',
    size: [520, 260],
    // phones: a narrower column, so the paragraph keeps its 15 px
    phone: { size: [360, 300] },
    setup({ silk, stage, phone }) {
        const font = 'Inter, system-ui, sans-serif';
        const paragraph = new TextEditor({
            text: 'Release notes. Text layers now edit on the canvas. Selection, the clipboard and input methods go through a hidden text field, so they behave the way the platform does. Double click a word to select it.',
            fontFamily: font,
            fontSize: 15,
            lineHeight: 23,
            fill: 0xd1d1d6,
            width: phone ? 300 : 440,
            readOnly: true,
            element: silk.app.canvas,
            theme: { selection: 0xffd60a, selectionAlpha: 0.28 },
        });
        const readout = new SilkText({ fontFamily: font, fontSize: 12, fill: 0x8e8e93 });

        paragraph.position.set(phone ? 30 : 40, phone ? 24 : 36);
        readout.position.set(phone ? 30 : 40, phone ? 254 : 214);
        stage.addChild(paragraph, readout);
        const stop = paragraph.state.selection.subscribe(() => {
            const n = paragraph.selectedText.length;

            readout.text = n ? `${n} characters selected. Cmd or ctrl + C copies them.` : '';
        });

        return () => {
            stop();
            paragraph.destroy();
        };
    },
});
```

`readOnly: true` keeps clicking, selecting and copying, and drops the caret and editing. Use it for
release notes, logs or error messages.

## Events, signals and undo

The editor emits `focus`, `blur`, `input` on every edit, `change` on a commit with a new value,
`submit` on Enter in a single-line field, and `tab`. The same state is on `editor.state` as
[signals](/docs/radial-menus-and-buttons/#state-in-signals): `text`, `committed`, `focused`,
`selection` and `composing`.

Undo is built in. Consecutive typing folds into one step, and a click or an arrow key starts the
next. To keep text edits in the same stack as the rest of an editor, pass its history:

```ts

const history = new History();
const title = new TextEditor({ text: 'Frame 1', multiline: false, history });

bindHistoryKeys(history); // cmd + Z outside the field undoes text edits too
```

## Find a caret from a point

The caret math lives in the text layout, so other tools can use it. `caretAt(point)` returns the
caret nearest a local point. `caretRect(index)` gives where a caret sits and `rangeRects(from, to)`
the highlight boxes of a range. They follow wrapped lines, alignment, letter spacing and trailing
spaces.

```ts

const label = new SilkText({ text: 'Hello world', wordWrap: true, wordWrapWidth: 60 });
const hit = label.caretAt({ x: 30, y: 8 });
const caret = label.caretRect(hit.index, hit.upstream);

console.log(hit.index, caret.x, label.rangeRects(0, 5));
```

`pixi-silk/text` also exports these as functions on a `TextLayout`, with `graphemeStops`,
`wordAt` and the word jumps.

## Where it stops

Right-to-left and mixed-direction text is not supported. Arabic and Hebrew lay out left to right,
and the caret moves in source order. Emoji count as one caret step but draw as one-colour
silhouettes, and there are no ligatures, as in [SilkText](/docs/text/#where-it-stops).

Spell check underlines do not show, because the hidden field is invisible.
