# Drawing editor

Source: https://signy.dev/examples/undo-redo/

Every change is a snapshot, so undo is a step back in a list.

The drawing is an immutable list of shapes. Each change pushes a new snapshot to the history, and undo and redo move a pointer through it. Ctrl+Z and Ctrl+Shift+Z work too, through a key listener the component removes when it unmounts.

## Preview

[Open the page on its own](https://signy.dev/examples/undo-redo/demo/index.html.md)

## Code

Save it as an `.html` file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

**Show the full page**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Drawing editor - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      .board {
        display: block;
        width: 100%;
        height: 22rem;
        border: 2px dashed var(--i-color-subtle-border);
        border-radius: var(--i-border-radius-large);
        touch-action: none;
        cursor: crosshair;
      }

      .shape {
        cursor: grab;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <main class="container" id="app"></main>

    <script>
      const { Component, Signal, Computed, application, html, For, batch, DOM } = Signy;

      const COLORS = { Violet: "#8b5cf6", Pink: "#ec4899", Amber: "#f59e0b", Teal: "#14b8a6" };
      // Shapes are SIZE px wide. Without a viewBox, one SVG unit is one pixel.
      const SIZE = 56;
      let nextId = 1;

      class Editor extends Component {
        // Every change pushes a new snapshot. index points at the one on screen.
        history = new Signal([[]]);
        index = new Signal(0);
        kind = new Signal("circle");
        color = new Signal("Violet");
        drag = new Signal(null);

        shapes = new Computed(() => this.history.value[this.index.value]);
        canUndo = new Computed(() => this.index.value > 0);
        canRedo = new Computed(() => this.index.value < this.history.value.length - 1);

        setup() {
          const undoOrRedo = (event) => {
            event.preventDefault();
            if (event.shiftKey) this.redo();
            else this.undo();
          };
          // Removed by the component on unmount
          DOM.onKeydown(document, ["z", "Z"], undoOrRedo, { ctrl: true });
          DOM.onKeydown(document, ["z", "Z"], undoOrRedo, { meta: true });
        }

        commit(shapes) {
          batch(() => {
            this.history.set((history) => [...history.slice(0, this.index.peek() + 1), shapes]);
            this.index.set((index) => index + 1);
          });
        }

        undo() {
          if (this.canUndo.peek()) this.index.set((index) => index - 1);
        }

        redo() {
          if (this.canRedo.peek()) this.index.set((index) => index + 1);
        }

        add(event) {
          if (event.target !== event.currentTarget) return;
          const box = event.currentTarget.getBoundingClientRect();
          this.commit([
            ...this.shapes.peek(),
            {
              id: nextId++,
              kind: this.kind.peek(),
              color: COLORS[this.color.peek()],
              x: Math.round(event.clientX - box.left - SIZE / 2),
              y: Math.round(event.clientY - box.top - SIZE / 2),
            },
          ]);
        }

        startDrag(event, id) {
          event.currentTarget.setPointerCapture(event.pointerId);
          this.drag.value = { id, startX: event.clientX, startY: event.clientY, dx: 0, dy: 0 };
        }

        moveDrag(event) {
          const drag = this.drag.peek();
          if (!drag) return;
          this.drag.value = { ...drag, dx: event.clientX - drag.startX, dy: event.clientY - drag.startY };
        }

        // A drag becomes one snapshot when it ends, so undo takes back the whole move
        endDrag() {
          const drag = this.drag.peek();
          this.drag.value = null;
          if (!drag || (!drag.dx && !drag.dy)) return;
          this.commit(
            this.shapes.peek().map((shape) =>
              shape.id === drag.id ? { ...shape, x: shape.x + drag.dx, y: shape.y + drag.dy } : shape,
            ),
          );
        }

        // Rows are keyed by id, so the position is looked up in the current snapshot
        position(id) {
          const shape = this.shapes.value.find((s) => s.id === id);
          if (!shape) return { x: 0, y: 0 };
          const drag = this.drag.value;
          const moving = drag?.id === id;
          return { x: shape.x + (moving ? drag.dx : 0), y: shape.y + (moving ? drag.dy : 0) };
        }

        // An SVG circle or rect, with its position bound to the snapshot and the drag
        shape(shape) {
          const events = {
            onPointerDown: (e) => this.startDrag(e, shape.id),
            onPointerMove: (e) => this.moveDrag(e),
            onPointerUp: () => this.endDrag(),
          };
          if (shape.kind === "circle") {
            return html`
              <circle class="shape circle" r=${SIZE / 2} fill=${shape.color} ...${events}
                cx=${() => this.position(shape.id).x + SIZE / 2}
                cy=${() => this.position(shape.id).y + SIZE / 2} />
            `;
          }
          return html`
            <rect class="shape square" width=${SIZE} height=${SIZE} rx="8" fill=${shape.color} ...${events}
              x=${() => this.position(shape.id).x}
              y=${() => this.position(shape.id).y} />
          `;
        }

        render() {
          return html`
            <h1>Drawing editor</h1>
            <form class="cluster" onSubmit=${(e) => e.preventDefault()}>
              <label><input type="radio" name="kind" checked onChange=${() => (this.kind.value = "circle")} /> Circle</label>
              <label><input type="radio" name="kind" onChange=${() => (this.kind.value = "square")} /> Square</label>
              <label>
                Color
                <select onChange=${(e) => (this.color.value = e.currentTarget.value)}>
                  ${Object.keys(COLORS).map((name) => html`<option>${name}</option>`)}
                </select>
              </label>
            </form>

            <svg class="board" onPointerDown=${(e) => this.add(e)}
              role="img" aria-label=${() => `Drawing with ${this.shapes.value.length} shapes`}>
              ${For({
                each: () => this.shapes.value,
                keyBy: "id",
                children: (shape) => this.shape(shape),
              })}
            </svg>
            <p><small>Click the board to add a shape, drag a shape to move it. Ctrl+Z undoes, Ctrl+Shift+Z redoes.</small></p>

            <div class="cluster">
              <button disabled=${() => !this.canUndo.value} onClick=${() => this.undo()}>Undo</button>
              <button disabled=${() => !this.canRedo.value} onClick=${() => this.redo()}>Redo</button>
              <button class="secondary outlined" disabled=${() => !this.shapes.value.length}
                onClick=${() => this.commit([])}>Clear</button>
              <span>Step ${() => this.index.value + 1} of ${() => this.history.value.length}</span>
            </div>
          `;
        }
      }

      application(Editor).mount("#app");
    </script>
  </body>
</html>

```

## What it uses

- [Signals](https://signy.dev/documentation/1/reactivity/signals/index.html.md) holding immutable snapshots
- [batch()](https://signy.dev/documentation/1/reactivity/batching/index.html.md) to move the pointer and the history together
- [DOM.onKeydown](https://signy.dev/documentation/1/components/dom-helpers/index.html.md) with cleanup
- [For](https://signy.dev/documentation/1/templates/for-and-index/index.html.md) for the shapes
- [SVG](https://signy.dev/documentation/1/templates/html-templates/index.html.md) circles and squares with bound positions
