Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Last active March 1, 2022 23:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rodydavis/4c30136716e4ef42c522dd96e1668571 to your computer and use it in GitHub Desktop.
Save rodydavis/4c30136716e4ef42c522dd96e1668571 to your computer and use it in GitHub Desktop.
HTML Canvas Controls with painting, zoom at cursor, clicking and keyboard shortcuts
import { drawInfiniteGrid } from "./infinite-grid";
import {
CanvasTransformer,
CanvasTransformerOptions,
defaultOptions,
} from "./transformer";
import { color } from "./utils";
export class CanvasController<
T extends CanvasWidget
> extends CanvasTransformer<T> {
constructor(
readonly canvas: HTMLCanvasElement,
readonly options: CanvasTransformerOptions = defaultOptions
) {
super(canvas, options);
}
children: T[] = [];
selection: T[] = [];
hovered: T[] = [];
canSelect = true;
canMove = true;
canDelete = true;
drawBackground() {
const { offset, scale } = this.info;
const { ctx, canvas } = this;
ctx.fillStyle = color(canvas, "--md-sys-color-background");
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawInfiniteGrid(canvas, ctx, {
offset,
scale,
backgroundColor: "--md-sys-color-background",
gridColor: "--md-sys-color-outline",
});
}
resize() {
const elem = this.canvas;
const style = getComputedStyle(elem);
const { canvas } = this;
canvas.width = parseInt(style.width, 10);
canvas.height = parseInt(style.height, 10);
}
paint() {
const { canvas, ctx } = this;
this.resize();
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background
this.drawBackground();
// Apply Transform
this.applyTransform(ctx);
// Draw content
this.drawContent(ctx);
requestAnimationFrame(() => this.paint());
}
drawContent(ctx: CanvasRenderingContext2D) {
for (const child of this.children) {
ctx.save();
ctx.translate(child.rect.x, child.rect.y);
child.draw(this.ctx);
ctx.restore();
if (this.selection.includes(child)) {
ctx.save();
this.drawOutline(ctx, child.rect, "--md-sys-color-primary");
ctx.restore();
} else if (this.hovered.includes(child)) {
ctx.save();
this.drawOutline(ctx, child.rect, "--md-sys-color-outline");
ctx.restore();
}
}
}
drawOutline(
ctx: CanvasRenderingContext2D,
rect: DOMRect,
outlineColor = "--md-sys-color-outline"
) {
const { canvas } = this;
ctx.strokeStyle = color(canvas, outlineColor);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
}
select(point: DOMPoint, max: number = 1) {
const localPoint = this.localPoint(point);
const selection: T[] = [];
const children = this.children.reverse();
for (const child of children) {
const rect = child.rect;
if (
localPoint.x >= rect.x &&
localPoint.x <= rect.x + rect.width &&
localPoint.y >= rect.y &&
localPoint.y <= rect.y + rect.height
) {
selection.push(child);
continue;
}
}
return selection.slice(0, max);
}
override onMouseDown(e: MouseEvent): void {
super.onMouseDown(e);
if (this.canSelect) {
this.updateSelection(this.select(this.mouse));
}
this.updateCursor();
}
override onMouseUp(e: MouseEvent): void {
super.onMouseUp(e);
if (this.canSelect) {
this.updateSelection(this.select(this.mouse));
}
this.updateCursor();
}
override onMouseMove(e: MouseEvent): void {
const currentMouse = this.mouse;
super.onMouseMove(e);
this.hovered = this.select(this.mouse);
if (this.mouseDown) {
const delta = {
x: this.mouse.x - currentMouse.x,
y: this.mouse.y - currentMouse.y,
};
if (this.spacePressed) {
this.pan(new DOMPoint(delta.x, delta.y));
} else if (this.canMove && this.selection.length > 0) {
const scale = this.info.scale;
for (const widget of this.selection) {
const rect = widget.rect;
rect.x += delta.x / scale;
rect.y += delta.y / scale;
widget.rect = rect;
this.notify();
}
}
}
this.updateCursor();
}
spacePressed = false;
override onKeyDownEvent(e: KeyboardEvent) {
super.onKeyDownEvent(e);
if (e.key === "Backspace") {
this.removeSelection();
}
this.spacePressed = e.key === " ";
this.updateCursor();
}
override onKeyUpEvent(e: KeyboardEvent): void {
super.onKeyUpEvent(e);
this.spacePressed = false;
this.updateCursor();
}
updateCursor() {
const { hovered, selection, spacePressed, canMove } = this;
const sameSelection =
selection.length > 0 &&
selection.every((widget) => hovered.includes(widget));
if (spacePressed) {
this.canvas.style.cursor = "grab";
} else if (selection.length > 0 && sameSelection && canMove) {
this.canvas.style.cursor = "move";
} else {
this.canvas.style.cursor = "default";
}
}
updateSelection(selection: T[]) {
this.selection = selection;
this.notify();
}
clearSelection() {
this.updateSelection([]);
}
removeSelection() {
if (!this.canDelete) return;
this.children = this.children.filter((w) => !this.selection.includes(w));
this.clearSelection();
}
addChild(widget: T) {
this.children.push(widget);
this.updateSelection([widget]);
}
}
export interface CanvasWidget {
rect: DOMRect;
draw(ctx: CanvasRenderingContext2D): void;
}
import { color } from "./utils";
export function drawInfiniteGrid(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
options: {
offset: { x: number; y: number };
scale: number;
backgroundColor: string;
gridColor: string;
}
) {
const { offset, scale, backgroundColor, gridColor } = options;
ctx.save();
const gridSize = 20 * scale;
const gridOffsetX = Math.round(offset.x) % gridSize;
const gridOffsetY = Math.round(offset.y) % gridSize;
ctx.fillStyle = color(canvas, backgroundColor);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = color(canvas, gridColor);
ctx.lineWidth = 0.23 * scale;
// ctx.setLineDash([5, 5]);
for (let x = gridOffsetX; x < canvas.width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = gridOffsetY; y < canvas.height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
ctx.restore();
}
type ListenableCallback<T> = (controller: Listenable<T>) => void;
export class Listenable<T> {
listeners: ListenableCallback<T>[] = [];
addListener(listener: ListenableCallback<T>) {
this.listeners.push(listener);
}
removeListener(listener: ListenableCallback<T>) {
this.listeners = this.listeners.filter((l) => l !== listener);
}
notifyListeners() {
for (const listener of this.listeners) {
listener(this);
}
}
}
import { Listenable } from "./listenable";
export interface CanvasTransformerOptions {
scale: number;
offset: DOMPoint;
rotation: number;
}
export const defaultOptions: CanvasTransformerOptions = {
scale: 1,
offset: new DOMPoint(0, 0),
rotation: 0,
};
export class CanvasTransformer<T> extends Listenable<T> {
constructor(
readonly canvas: HTMLCanvasElement,
readonly options: CanvasTransformerOptions = defaultOptions
) {
super();
this.ctx = this.canvas.getContext("2d")!;
this.transform = this.ctx.getTransform();
this.shouldNotify = false;
const { scale, offset, rotation } = options;
this.scale(scale);
this.pan(offset);
this.rotate(rotation);
this.shouldNotify = true;
// Scroll events
this.onWheelEvent = this.onWheelEvent.bind(this);
canvas.addEventListener("wheel", this.onWheelEvent, { passive: false });
// Mouse Down
this.onMouseDown = this.onMouseDown.bind(this);
canvas.addEventListener("mousedown", this.onMouseDown, false);
// Mouse Move
this.onMouseMove = this.onMouseMove.bind(this);
canvas.addEventListener("mousemove", this.onMouseMove, false);
// Mouse Up
this.onMouseUp = this.onMouseUp.bind(this);
canvas.addEventListener("mouseup", this.onMouseUp, false);
// Keyboard Events
this.onKeyDownEvent = this.onKeyDownEvent.bind(this);
this.onKeyUpEvent = this.onKeyUpEvent.bind(this);
document.addEventListener("keydown", this.onKeyDownEvent, false);
document.addEventListener("keyup", this.onKeyUpEvent, false);
}
mouse = new DOMPoint(0, 0);
mouseDown = false;
private transform: DOMMatrix;
get matrix() {
return this.transform;
}
set matrix(matrix: DOMMatrix) {
this.transform = matrix;
this.notify();
}
shouldNotify = true;
notify() {
if (!this.shouldNotify) return;
this.notifyListeners();
}
/** Canvas Context */
readonly ctx: CanvasRenderingContext2D;
minScale = 0.1;
maxScale = 10;
/**
* Transforms the mouse coordinates to the local coordinates of the canvas
*
* @param point X and Y coordinates of the vector
*/
localPoint(point: DOMPoint) {
const { scale, offset } = this.info;
const x = point.x / scale - offset.x / scale;
const y = point.y / scale - offset.y / scale;
return new DOMPoint(x, y);
// return point.matrixTransform(this.matrix.inverse());
}
/**
* Apply a new transform to the canvas
*
* @param ctx Canvas Rendering Context
*/
applyTransform(ctx: CanvasRenderingContext2D) {
ctx.setTransform(this.matrix);
}
/**
* Scale the canvas by a factor and origin in the viewport
*
* @param delta Scale delta
* @param origin Origin to scale at
*/
scale(scale: number, origin: DOMPoint = new DOMPoint(0, 0)) {
console.debug("scale", scale, origin);
const amount = scale * this.info.scale;
this.options.scale = amount;
// Make sure the scale is within bounds
if (amount < this.minScale || amount > this.maxScale) {
return;
}
const point = this.localPoint(origin);
this.matrix = this.matrix.scale(scale, scale, 0, point.x, point.y, point.z);
this.notify();
}
/**
* Pan the canvas in a given direction
*
* @param delta X and Y coordinates of the vector
*/
pan(delta: DOMPoint) {
const { scale } = this.info;
console.debug("pan", delta);
this.options.offset.x += delta.x / scale;
this.options.offset.y += delta.y / scale;
this.matrix = this.matrix.translate(
delta.x / scale,
delta.y / scale,
delta.z / scale
);
this.notify();
}
/**
* Rotate the canvas by a given angle
*
* @param delta Rotation delta
*/
rotate(delta: number) {
console.debug("rotate", delta);
this.matrix = this.matrix.rotate(delta, delta, delta);
this.notify();
}
get info() {
const { scaleX, scaleY, translateX, translateY, rotate } = decomposeMatrix(
this.matrix
);
return {
scale: Math.min(scaleX, scaleY),
offset: new DOMPoint(translateX, translateY),
rotation: rotate,
mouse: this.mouse,
mouseDown: this.mouseDown,
};
}
private onWheelEvent(e: WheelEvent) {
e.preventDefault();
const origin = new DOMPoint(e.offsetX, e.offsetY);
if (e.ctrlKey) {
let scale = 1;
if (e.deltaY < 0) {
scale = Math.min(this.maxScale, scale * 1.1);
} else {
scale = Math.max(this.minScale, scale * (1 / 1.1));
}
this.scale(scale, origin);
} else {
this.pan(new DOMPoint(-e.deltaX, -e.deltaY));
}
}
onMouseDown(e: MouseEvent) {
this.mouse = new DOMPoint(e.offsetX, e.offsetY);
this.mouseDown = true;
}
onMouseMove(e: MouseEvent) {
this.mouse = new DOMPoint(e.offsetX, e.offsetY);
}
onMouseUp(e: MouseEvent) {
this.mouse = new DOMPoint(e.offsetX, e.offsetY);
this.mouseDown = false;
}
onKeyDownEvent(e: KeyboardEvent) {
if (e.key === "ArrowLeft") {
this.pan(new DOMPoint(-10, 0));
} else if (e.key === "ArrowRight") {
this.pan(new DOMPoint(10, 0));
} else if (e.key === "ArrowUp") {
this.pan(new DOMPoint(0, -10));
} else if (e.key === "ArrowDown") {
this.pan(new DOMPoint(0, 10));
} else if (e.key === "=") {
this.scale(1.1, this.mouse);
} else if (e.key === "-") {
this.scale(1 / 1.1, this.mouse);
}
}
onKeyUpEvent(e: KeyboardEvent) {}
}
interface CanvasInfo {
scale: number;
offset: DOMPoint;
rotation: number;
mouse: DOMPoint;
mouseDown: boolean;
}
// https://gist.github.com/fwextensions/2052247
function decomposeMatrix(m: DOMMatrix) {
const E = (m.a + m.d) / 2;
const F = (m.a - m.d) / 2;
const G = (m.c + m.b) / 2;
const H = (m.c - m.b) / 2;
const Q = Math.sqrt(E * E + H * H);
const R = Math.sqrt(F * F + G * G);
const a1 = Math.atan2(G, F);
const a2 = Math.atan2(H, E);
const theta = (a2 - a1) / 2;
const phi = (a2 + a1) / 2;
return {
translateX: m.e,
translateY: m.f,
rotate: (-phi * 180) / Math.PI,
scaleX: Q + R,
scaleY: Q - R,
skew: (-theta * 180) / Math.PI,
};
}
export function color(elem: HTMLElement, value: string) {
if (value.startsWith("--")) {
const style = getComputedStyle(elem);
return style.getPropertyValue(value);
}
return value;
}
@rodydavis
Copy link
Author

rodydavis commented Mar 1, 2022

usage:

const canvas = document.createElement('canvas');
const controller = new CanvasController(canvas);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment