Created
August 16, 2023 00:28
-
-
Save pfgithub/afee093bb8a0ba2476fc29ac5657af78 to your computer and use it in GitHub Desktop.
canvas pan & zoom
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 { Accessor, createEffect, createRoot, JSX, onCleanup, untrack } from "solid-js"; | |
import { vec2, Vec2 } from "../util/vec2"; | |
// polyfill roundRect, does not support radius arrays | |
CanvasRenderingContext2D.prototype.roundRect ??= function (this: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { | |
if (width < 2 * radius) radius = width / 2; | |
if (height < 2 * radius) radius = height / 2; | |
this.beginPath(); | |
this.moveTo(x + radius, y); | |
this.arcTo(x + width, y, x + width, y + height, radius); | |
this.arcTo(x + width, y + height, x, y + height, radius); | |
this.arcTo(x, y + height, x, y, radius); | |
this.arcTo(x, y, x + width, y, radius); | |
this.closePath(); | |
return this; | |
}; | |
export type World = { | |
canvas: HTMLCanvasElement, | |
ctx: CanvasRenderingContext2D, | |
center_transform: DOMMatrixReadOnly, | |
setstate: () => void, | |
el: HTMLElement, | |
canvas_w: number, | |
canvas_h: number, | |
}; | |
function setupCanvas<T>(mount: HTMLElement, state: Accessor<T>, setState: () => void, renderChild: RenderChild<T>) { | |
const canvas = document.createElement("canvas"); | |
canvas.style.width = "100%"; | |
canvas.style.height = "100%"; | |
canvas.style.display = "block"; | |
// canvas.style.objectFit = "none"; // breaks when changing pixel ratio because it needs to stretch | |
canvas.style.objectPosition = "top left"; | |
canvas.classList.toggle("maincanvas"); | |
const subdiv = document.createElement("div"); | |
subdiv.tabIndex = 0; | |
canvas.appendChild(subdiv); | |
mount.appendChild(canvas); | |
onCleanup(() => {canvas.remove()}); | |
const err_el = document.createElement("div"); | |
err_el.style.display = "none"; | |
err_el.style.backgroundColor = "rgba(0,0,0,0.3)"; | |
err_el.style.color = "#ffd3e0"; | |
err_el.style.height = "100%"; | |
err_el.style.pointerEvents = "none"; | |
err_el.style.position = "absolute"; | |
err_el.style.inset = "0"; | |
err_el.style.zIndex = "100000"; | |
const eech = document.createElement("div"); | |
eech.style.padding = "1rem"; | |
eech.style.position = "absolute"; | |
eech.style.inset = "0"; | |
eech.style.transformOrigin = "top left"; | |
eech.style.willChange = "transform"; | |
eech.style.display = "flex"; | |
eech.style.alignItems = "center"; | |
eech.style.justifyContent = "center"; | |
eech.style.fontSize = "3vw"; | |
eech.innerHTML = "<div class='shadow' style='background-color: rgb(41,85,244); padding: 3vw 2vw; border-radius: 1vw'>The page is zoomed. Zoom out to continue.</div>"; | |
err_el.appendChild(eech); | |
mount.appendChild(err_el); | |
onCleanup(() => err_el.remove()); | |
const stylel = document.createElement("style"); | |
stylel.textContent = ` | |
html:not(.e-zoomed), html:not(.e-zoomed) > .maincanvas { | |
touch-action: none; | |
} | |
html, body { | |
overflow: hidden; | |
} | |
`; | |
document.head.appendChild(stylel); | |
onCleanup(() => stylel.remove()); | |
let disable_ev_lsn = false; | |
function onVisualViewportChange() { | |
if(visualViewport == null) return; | |
const value = visualViewport.scale < 0.999 || visualViewport.scale > 1.001; | |
document.documentElement.classList.toggle("e-zoomed", value); | |
// canvas.style.display = value ? "none" : ""; | |
err_el.style.display = value ? "" : "none"; | |
disable_ev_lsn = value; | |
const offset_left = visualViewport.offsetLeft; | |
const offset_top = visualViewport.offsetTop; | |
eech.style.transform = "translate(" + offset_left + "px," + offset_top + "px) " + "scale(" + 1/visualViewport.scale + ")"; | |
} | |
if(visualViewport != null) { | |
visualViewport.addEventListener("resize", onVisualViewportChange); | |
visualViewport.addEventListener("scroll", onVisualViewportChange); | |
onCleanup(() => { | |
if(visualViewport == null) return; | |
visualViewport.removeEventListener("resize", onVisualViewportChange); | |
visualViewport.removeEventListener("scroll", onVisualViewportChange); | |
document.documentElement.classList.remove("e-zoomed"); | |
// cleanupGesRec(); | |
}); | |
} | |
onVisualViewportChange(); | |
onCleanup(() => { | |
disable_ev_lsn = true; | |
}) | |
// window.udm = (cb) => {transform = cb(transform)}; | |
const onwheel = (e: WheelEvent) => { | |
if(disable_ev_lsn) return; | |
e.preventDefault(); | |
recvScrollEvent(world, e); | |
}; | |
const onpointerdown = (e: PointerEvent) => { | |
if(disable_ev_lsn) return; | |
}; | |
let end_capture: null | ((e: MouseEvent) => void) = null; | |
let mouse_pos: null | Vec2 = null; | |
let clickaction_override: null | ClickCallback = null; | |
const onmousemove = (e: MouseEvent) => { | |
if(disable_ev_lsn) return; | |
if(end_capture != null) { | |
mouse_pos = null; | |
return; | |
} | |
const target_pos = screenToWorldPos(world, ...offsets(world, e)); | |
mouse_pos = [target_pos.x, target_pos.y]; | |
rench.onmove(target_pos); | |
}; | |
const dragview: ClickCallback = (pos, ev): ClickCallbackRet => { | |
if(ev == null) throw new Error("bad"); | |
const start_pos = offsets(world, ev); | |
const start_matrix = world.center_transform; | |
const transform = (pos: Vec2): Vec2 => { | |
const res = start_matrix.inverse().transformPoint({x: pos[0], y: pos[1]}); | |
return [res.x, res.y]; | |
}; | |
const update = (end_pos: Vec2) => { | |
const start_transform = transform(start_pos); | |
const end_transform = transform(end_pos); | |
const offset = vec2.sub(end_transform, start_transform); | |
world.center_transform = start_matrix.translate(...offset); | |
}; | |
return { | |
onmove(pos, ev) { | |
if(ev == null) throw new Error("bad"); | |
const end_pos = offsets(world, ev); | |
update(end_pos); | |
}, | |
onrelease(pos, ev) { | |
if(ev == null) throw new Error("bad"); | |
const end_pos = offsets(world, ev); | |
update(end_pos); | |
}, | |
}; | |
}; | |
const scaleview: ClickCallback = (pos, ev): ClickCallbackRet => { | |
if(ev == null) throw new Error("bad"); | |
const start_pos = offsets(world, ev); | |
const start_matrix = world.center_transform; | |
const update = (end_pos: Vec2) => { | |
const offset = vec2.sub(end_pos, start_pos); | |
const wheel = offset[1] / 60; | |
const zoom = Math.pow(1 + Math.abs(wheel)/2 , wheel > 0 ? 1 : -1); | |
const cpos = screenToWorldPos(world, ...start_pos); | |
world.center_transform = new DOMMatrixReadOnly().scale(zoom).multiply(start_matrix); | |
const fpos = screenToWorldPos(world, ...start_pos); | |
world.center_transform = world.center_transform.translate(fpos.x - cpos.x, fpos.y - cpos.y); | |
}; | |
return { | |
onmove(pos, ev) { | |
if(ev == null) throw new Error("bad"); | |
const end_pos = offsets(world, ev); | |
update(end_pos); | |
}, | |
onrelease(pos, ev) { | |
if(ev == null) throw new Error("bad"); | |
const end_pos = offsets(world, ev); | |
update(end_pos); | |
}, | |
}; | |
}; | |
const onmousedown = (e: MouseEvent) => createRoot(cleanup => { | |
if(disable_ev_lsn) return; | |
const should_drag = e.button !== 0 || e.ctrlKey || e.metaKey; | |
const should_scale = e.altKey; | |
const target_handler: ClickCallback = (should_scale ? scaleview : should_drag ? dragview : clickaction_override ?? rench.onclick); | |
const clkhl = target_handler(screenToWorldPos(world, ...offsets(world, e)), e); | |
const onmousemove = (e: MouseEvent) => { | |
if(disable_ev_lsn) return; | |
clkhl.onmove(screenToWorldPos(world, ...offsets(world, e)), e); | |
}; | |
const onmouseup = (e: MouseEvent) => { | |
if(disable_ev_lsn) return; | |
clkhl.onrelease(screenToWorldPos(world, ...offsets(world, e)), e); | |
cleanup(); | |
}; | |
document.addEventListener("mousemove", onmousemove, {capture: true}); | |
onCleanup(() => document.removeEventListener("mousemove", onmousemove, {capture: true})); | |
document.addEventListener("mouseup", onmouseup, {once: true, capture: true}); | |
onCleanup(() => document.removeEventListener("mouseup", onmouseup, {capture: true})); | |
if(end_capture != null) { | |
end_capture(e); | |
end_capture = null; | |
} | |
end_capture = onmouseup; | |
onCleanup(() => { | |
if(end_capture === onmouseup) end_capture = null; | |
}); | |
}); | |
let touchData: {initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly}; | |
const ontouchstart = (ev: TouchEvent) => { | |
if(disable_ev_lsn) return; | |
ev.preventDefault(); | |
touchData = initTouchData(world, ev); | |
}; | |
const ontouchmove = (ev: TouchEvent) => { | |
if(disable_ev_lsn) return; | |
ev.preventDefault(); | |
recvTouchEvent(world, ev, touchData); | |
}; | |
const ontouchend = (ev: TouchEvent) => { | |
if(disable_ev_lsn) return; | |
ev.preventDefault(); | |
}; | |
const oncontextmenu = (ev: Event) => { | |
if(disable_ev_lsn) return; | |
ev.preventDefault(); | |
return false; | |
}; | |
canvas.addEventListener("wheel", onwheel, {passive: false}); | |
onCleanup(() => canvas.removeEventListener("wheel", onwheel)); | |
canvas.addEventListener("pointerdown", onpointerdown); | |
onCleanup(() => canvas.removeEventListener("pointerdown", onpointerdown)); | |
canvas.addEventListener("mousedown", onmousedown); | |
onCleanup(() => canvas.removeEventListener("mousedown", onmousedown)); | |
canvas.addEventListener("mousemove", onmousemove); | |
onCleanup(() => canvas.removeEventListener("mousemove", onmousemove)); | |
canvas.addEventListener("touchstart", ontouchstart, {passive: false}); | |
onCleanup(() => canvas.removeEventListener("touchstart", ontouchstart)); | |
canvas.addEventListener("touchmove", ontouchmove, {passive: false}); | |
onCleanup(() => canvas.removeEventListener("touchmove", ontouchmove)); | |
canvas.addEventListener("touchend", ontouchend, {passive: false}); | |
onCleanup(() => canvas.removeEventListener("touchend", ontouchend)); | |
canvas.addEventListener("contextmenu", oncontextmenu, {passive: false}); | |
onCleanup(() => canvas.removeEventListener("contextmenu", oncontextmenu)); | |
const root_ctx = canvas.getContext("2d")!; | |
let running = true; | |
onCleanup(() => running = false); | |
let new_frame_requested: null | number = null; | |
onCleanup(() => { | |
if(new_frame_requested != null) cancelAnimationFrame(new_frame_requested); | |
}); | |
function rerender() { | |
if(new_frame_requested != null) return; | |
if(!running) return; | |
new_frame_requested = requestAnimationFrame(() => { | |
new_frame_requested = null; | |
rerender(); | |
}); | |
root_ctx.save(); | |
const w = canvas.clientWidth; | |
const h = canvas.clientHeight; | |
const dpr = window.devicePixelRatio || 1; | |
canvas.width = w * dpr; | |
canvas.height = h * dpr; | |
root_ctx.scale(dpr, dpr); | |
render(); | |
root_ctx.restore(); | |
} | |
const world: World = { | |
ctx: root_ctx, | |
canvas, | |
center_transform: new DOMMatrixReadOnly(), | |
setstate: setState, | |
el: subdiv, | |
get canvas_w() { | |
return canvas.width / (window.devicePixelRatio || 1); | |
}, | |
get canvas_h() { | |
return canvas.height / (window.devicePixelRatio || 1); | |
}, | |
}; | |
const rench = renderChild(world, untrack(() => state())); | |
const render = () => { | |
const {ctx} = world; | |
const imev: ImEv = { | |
mouse: { | |
pos: mouse_pos ?? undefined, | |
captured: !!end_capture, | |
}, | |
ctx: ctx, | |
}; | |
rench.render(imev); | |
if(imev.mouse.action != null) { | |
canvas.style.cursor = imev.mouse.action.cursor; | |
clickaction_override = imev.mouse.action.callback; | |
}else{ | |
if(end_capture == null) canvas.style.cursor = ""; | |
else if(canvas.style.cursor === "grab") canvas.style.cursor = "grabbing"; | |
clickaction_override = null; | |
} | |
// ctx.fillStyle = "white"; | |
// const pos = screenToWorldPos(10, 10); | |
// ctx.fillRect(pos.x, pos.y, 10, 10); | |
}; | |
if(!('requestIdleCallback' in window) || (false as true)) { | |
window.requestIdleCallback = cb => (cb({ | |
didTimeout: false, | |
timeRemaining: () => 0, | |
}), 0); | |
window.cancelIdleCallback = () => {/**/}; | |
} | |
let rsic: undefined | number; | |
new ResizeObserver((itms) => { | |
itms.forEach(itm => { | |
if(itm.target === canvas) { | |
if(rsic != null) cancelIdleCallback(rsic); | |
rsic = requestIdleCallback(() => { | |
rerender(); | |
}, { | |
timeout: 500, | |
}); | |
} | |
}); | |
}).observe(canvas); | |
rerender(); | |
} | |
function offsets(world: World, e: {clientX: number, clientY: number}): [number, number] { | |
const canv_pos = world.canvas.getBoundingClientRect(); | |
return [e.clientX - canv_pos.left, e.clientY - canv_pos.top]; | |
}; | |
function recvScrollEvent(world: World, ev: WheelEvent): void { | |
if(ev.ctrlKey || ev.metaKey || ev.altKey) { | |
// scale or rotate | |
const wheel = -ev.deltaY / 60; | |
const zoom = Math.pow(1 + Math.abs(wheel)/2 , wheel > 0 ? 1 : -1); | |
const [fsetx, fsety] = offsets(world, ev); | |
const cpos = screenToWorldPos(world, fsetx, fsety); | |
if(ev.altKey) { | |
// rotate | |
world.center_transform = world.center_transform.rotate(-ev.deltaY / 10); | |
}else{ | |
// scale | |
world.center_transform = world.center_transform.scale(zoom); | |
} | |
const fpos = screenToWorldPos(world, fsetx, fsety); | |
world.center_transform = world.center_transform.translate(fpos.x - cpos.x, fpos.y - cpos.y); | |
}else if(ev.shiftKey) { | |
// pan horizontal | |
world.center_transform = new DOMMatrixReadOnly().translate(-ev.deltaY + -ev.deltaX, 0).multiply(world.center_transform); | |
}else{ | |
// pan | |
world.center_transform = new DOMMatrixReadOnly().translate(-ev.deltaX, -ev.deltaY).multiply(world.center_transform); | |
} | |
}; | |
/** | |
* Initialize touch event data | |
* @param ev The touch event to process | |
*/ | |
function initTouchData(world: World, ev: TouchEvent): { initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly } { | |
const initialTouches = ev.touches; | |
const initialCenterTransform = world.center_transform; | |
return { initialTouches, initialCenterTransform }; | |
} | |
/** | |
* Receives a touch event and updates the transform matrix of a 2D world | |
* to implement zooming, panning, and rotating behavior. | |
* @param world The 2D world to update | |
* @param ev The touch event to process | |
* @param touchData The initial touch event data | |
*/ | |
function recvTouchEvent(world: World, ev: TouchEvent, touchData: { initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly }): void { | |
if (ev.touches.length === touchData.initialTouches.length) { | |
/*if (ev.touches.length === 1) { | |
// Single touch: pan view | |
const dx = ev.touches[0].clientX - touchData.initialTouches[0].clientX; | |
const dy = ev.touches[0].clientY - touchData.initialTouches[0].clientY; | |
world.center_transform = new DOMMatrixReadOnly().translate(dx, dy).multiply(touchData.initialCenterTransform); | |
} else */ | |
if (ev.touches.length === 2) { | |
// Two fingers touch: pan, zoom, and rotate view | |
const touch1 = ev.touches[0]; | |
const touch2 = ev.touches[1]; | |
const initialTouch1 = touchData.initialTouches[0]; | |
const initialTouch2 = touchData.initialTouches[1]; | |
const [initialTouch1_X, initialTouch1_Y] = offsets(world, initialTouch1); | |
const [initialTouch2_X, initialTouch2_Y] = offsets(world, initialTouch2); | |
const [touch1_X, touch1_Y] = offsets(world, touch1); | |
const [touch2_X, touch2_Y] = offsets(world, touch2); | |
// Calculate the initial and current midpoint of the two fingers | |
const initialMidX = (initialTouch1_X + initialTouch2_X) / 2; | |
const initialMidY = (initialTouch1_Y + initialTouch2_Y) / 2; | |
const currentMidX = (touch1_X + touch2_X) / 2; | |
const currentMidY = (touch1_Y + touch2_Y) / 2; | |
// Calculate the initial and current distance between the two fingers | |
const initialDist = Math.hypot(initialTouch1_X - initialTouch2_X, initialTouch1_Y - initialTouch2_Y); | |
const currentDist = Math.hypot(touch1_X - touch2_X, touch1_Y - touch2_Y); | |
// Calculate the zoom scale | |
const zoom = currentDist / initialDist; | |
// Calculate the rotation angle in degrees | |
const initialAngle = Math.atan2(initialTouch1_Y - initialTouch2_Y, initialTouch1_X - initialTouch2_X); | |
const currentAngle = Math.atan2(touch1_Y - touch2_Y, touch1_X - touch2_X); | |
const rotation = (currentAngle - initialAngle) * 180 / Math.PI; | |
// Calculate the pan translation | |
const dx = currentMidX - initialMidX; | |
const dy = currentMidY - initialMidY; | |
// Combine pan, zoom, and rotation to update the transform matrix | |
world.center_transform = new DOMMatrixReadOnly() | |
.translate(dx, dy) | |
.translate(initialMidX, initialMidY) | |
.translate(-world.canvas_w / 2, -world.canvas_h / 2) | |
.rotate(0, 0, rotation) | |
.scale(zoom) | |
.translate(world.canvas_w / 2, world.canvas_h / 2) | |
.translate(-initialMidX, -initialMidY) | |
.multiply(touchData.initialCenterTransform); | |
} else { | |
// Ignore events with more than two fingers | |
return; | |
} | |
} | |
}; | |
export function getTransform(world: World): DOMMatrixReadOnly { | |
return new DOMMatrixReadOnly().translate(world.canvas_w / 2, world.canvas_h / 2).multiply(world.center_transform); | |
} | |
export function screenToWorldPos(world: World, spx: number, spy: number): {x: number, y: number} { | |
const res = getTransform(world).inverse().transformPoint({x: spx, y: spy}); | |
return {x: res.x, y: res.y}; | |
} | |
export function deltaTransformPoint(matrix: DOMMatrixReadOnly, point: {x: number, y: number}) { | |
var dx = point.x * matrix.a + point.y * matrix.c + 0; | |
var dy = point.x * matrix.b + point.y * matrix.d + 0; | |
return { x: dx, y: dy }; | |
} | |
export function decomposeMatrix(matrix: DOMMatrixReadOnly) { | |
// calculate delta transform point | |
var px = deltaTransformPoint(matrix, { x: 0, y: 1 }); | |
var py = deltaTransformPoint(matrix, { x: 1, y: 0 }); | |
// calculate skew | |
var skewX = ((180 / Math.PI) * Math.atan2(px.y, px.x) - 90); | |
var skewY = ((180 / Math.PI) * Math.atan2(py.y, py.x)); | |
return { | |
translateX: matrix.e, | |
translateY: matrix.f, | |
scaleX: Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b), | |
scaleY: Math.sqrt(matrix.c * matrix.c + matrix.d * matrix.d), | |
skewX: skewX, | |
skewY: skewY, | |
rotation: skewX // rotation is the same as skew x | |
}; | |
} | |
export type Point = [number, number]; | |
export type RenderChild<T> = (world: World, state: T) => RenderChildResult; | |
export type RenderChildResult = { | |
render: (imev: ImEv) => void, | |
onclick: ClickCallback, | |
onmove: (pos: {x: number, y: number}) => void, | |
}; | |
export type ClickCallback = (pos: {x: number, y: number}, _ev?: MouseEvent | null) => ClickCallbackRet; | |
export type ClickCallbackRet = { | |
onmove: (pos: {x: number, y: number}, _ev?: MouseEvent) => void, | |
onrelease: (pos: {x: number, y: number}, _ev?: MouseEvent | null) => void, | |
}; | |
export type ImEv = { | |
mouse: { | |
pos?: undefined | Vec2, | |
captured: boolean, | |
action?: undefined | { | |
cursor: string, | |
callback: ClickCallback, | |
}, | |
}, | |
ctx: CanvasRenderingContext2D, | |
}; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment