Drawing editor
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
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
<!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 holding immutable snapshots
- batch() to move the pointer and the history together
- DOM.onKeydown with cleanup
- For for the shapes
- SVG circles and squares with bound positions