Last active
March 1, 2022 23:31
-
-
Save rodydavis/4c30136716e4ef42c522dd96e1668571 to your computer and use it in GitHub Desktop.
HTML Canvas Controls with painting, zoom at cursor, clicking and keyboard shortcuts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export function color(elem: HTMLElement, value: string) { | |
if (value.startsWith("--")) { | |
const style = getComputedStyle(elem); | |
return style.getPropertyValue(value); | |
} | |
return value; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
usage: