From c862f04ebcb17e85de872d31133bd0a34b1dd034 Mon Sep 17 00:00:00 2001 From: Adrien Burgun Date: Sat, 27 Aug 2022 11:11:24 +0200 Subject: [PATCH] :tada: First commit --- README.md | 33 ++++ pptk.js | 453 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ test.html | 71 +++++++++ 3 files changed, 557 insertions(+) create mode 100644 README.md create mode 100644 pptk.js create mode 100644 test.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4ccf18 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Pixel-Perfect ToolKit + +This library aims to solve two problems in web-development, that are notoriously hard to both solve at once: +- support for mobile devices (high-DPI, multi-touch, etc.) +- pixel-perfect control, notably for [pixel fonts](https://github.com/adri326/online-pixel-font-creator) + +## Limitations + +### Canvases must be contained + +Because canvases must have an integer width and height, and because `window.devicePixelRatio` may be a floating-point value. + +For instance, if `dpr = 1.5` and the desired with is `81` CSS pixels, then the desired canvas width would be `81 * 1.5 = 121.5`. +Naively setting `canvas.width = 121.5` results in the browser truncating it to `121`, which only cover `121 / 1.5 ≈ 80.66` CSS pixels, resulting in blurred edges as the element has to be stretched horizontally by `0.4%`. + +Instead, we set `canvas.width = Math.ceil(121.5) = 122`, then set `canvas.style.width` to `122 / 1.5 ≈ 81.33px`. +This requires a parent "container" element to read the desired width from and to hide the overflowing `0.33` CSS pixel. + +This is what the container element should have as style: + +```css +.container { + /* Necessary properties */ + overflow: hidden; + + /* Optionally set a different background color to reduce flicker */ + background-color: /* ... */; + + /* Width and height should be set to anything besides `fit-content` */ + width: /* ... */; + height: /* ... */; +} +``` diff --git a/pptk.js b/pptk.js new file mode 100644 index 0000000..1ae727c --- /dev/null +++ b/pptk.js @@ -0,0 +1,453 @@ +export const MAX_CANVAS_DIMS = 10000; + +// export default function pptk(element, options = {}) { +// if (element instanceof Element && element.nodeName === "CANVAS") { +// return pptk.canvas(element, options); +// } else if (element instanceof CanvasRenderingContext2D) { +// return pptk.ctx_2d(element, options); +// } else { +// if (!options.silent) { +// console.warn("Unrecognized element: " + element); +// } +// return element; +// } +// } + +export function canvas(canvas, options = {}) { + canvas.style.imageRendering = "pixelated"; + let container = options.container ?? canvas.parentNode; + let update = options.update ?? (() => {}); + + let old_width = null; + let old_height = null; + let old_dpr = null; + + function resize(dpr) { + // Get dimensions of container + let {width, height} = container.getBoundingClientRect?.() ?? { + width: container.clientWidth, + height: container.clientHeight + }; + + // Computer dimensions of canvas + width = Math.ceil(width * dpr); + height = Math.ceil(height * dpr); + + if (width === old_width && height === old_height && dpr === old_dpr) { + // No update needed + return; + } + + old_width = width; + old_height = height; + old_dpr = dpr; + + // Safety measure for when the container size depends on the canvas' size + if (width > MAX_CANVAS_DIMS || height > MAX_CANVAS_DIMS) { + throw new Error( + `Prevented resizing canvas to ${width}x${height}; ` + + `is the container missing "overflow: hidden" or "width"/"height"?` + ); + } + + // Set image width and height + canvas.width = width; + canvas.height = height; + + // Set style width to guarantee perfect scaling + canvas.style.width = `${width / dpr}px`; + canvas.style.height = `${height / dpr}px`; + + update(canvas, width, height); + } + + // Bind listeners + + let unbind_dpr = bind_pixel_ratio(canvas, (dpr) => { + resize(dpr); + }); + + let resize_observer = new ResizeObserver((entries) => { + resize(window.devicePixelRatio ?? 1); + }); + + resize_observer.observe(container, { + box: "border-box" + }); + + setTimeout(() => { + resize(window.devicePixelRatio ?? 1); + }, 0); + + canvas.unbind = function unbind() { + if (resize_observer) { + resize_observer.disconnect(); + // Drop resize_observer + resize_observer = null; + } + + if (unbind_dpr) { + unbind_dpr(); + // Drop unbind_dpr + unbind_dpr = null; + } + }; + + return canvas; +} + +export function touch(element, options = {}) { + let noop = () => {}; + let start = options.start ?? noop; + let move = options.move ?? noop; + let end = options.end ?? noop; + + let touches = new Map(); + + let queued = null; + let queued_touches = []; + + function get_dpr() { + if (options.dpr) { + return window.devicePixelRatio ?? 1; + } else { + return 1; + } + } + + function ondown(event) { + if (options.preventDefault) event.preventDefault(); + + let dpr = get_dpr(); + let bounding = element.getBoundingClientRect(); + + let x = (event.clientX - bounding.x) * dpr; + let y = (event.clientY - bounding.y) * dpr; + + let touch = { + x, + y, + click_x: x, + click_y: y, + dx: 0, + dy: 0, + type: event.pointerType, + id: event.pointerId, + }; + + touches.set(event.pointerId, touch); + + start(touches, touch); + } + + function onmove(event) { + if (options.preventDefault) event.preventDefault(); + + let dpr = get_dpr(); + let bounding = element.getBoundingClientRect(); + + let x = (event.clientX - bounding.x) * dpr; + let y = (event.clientY - bounding.y) * dpr; + + if (touches.has(event.pointerId)) { + let touch = touches.get(event.pointerId); + + touch.dx = x - touch.x; + touch.dy = y - touch.y; + + touch.x = x; + touch.y = y; + + if (queued === null) { + queued = setTimeout(() => { + try { + move(touches, queued_touches); + } finally { + queued_touches = []; + queued = null; + } + }, 0); + } + queued_touches.push(touch); + } + } + + function onup(event) { + if (options.preventDefault) event.preventDefault(); + + let dpr = get_dpr(); + let bounding = element.getBoundingClientRect(); + + let x = (event.clientX - bounding.x) * dpr; + let y = (event.clientY - bounding.y) * dpr; + + let touch = touches.get(event.pointerId); + + touches.delete(event.pointerId); + + end(touches, touch); + } + + function oncancel(event) { + touches.delete(event.pointerId); + + // TODO: trigger end()? + } + + element.addEventListener("pointerdown", ondown); + element.addEventListener("pointermove", onmove); + element.addEventListener("pointerup", onup); + element.addEventListener("pointercancel", oncancel); + + function ontouchmove(event) { + event.preventDefault(); + } + + // preventDefault() on pointer events does not work as expected (eg. scrolling still happens), so this is needed instead + if (options.preventDefault) { + element.addEventListener("touchmove", ontouchmove, {passive: false}); + } + + return function unbind() { + element.removeEventListener("pointerdown", ondown); + element.removeEventListener("pointermove", onmove); + element.removeEventListener("pointup", onup); + element.removeEventListener("pointercancel", oncancel); + + if (options.preventDefault) { + element.removeEventListener("touchmove", ontouchmove, {passive: false}); + } + } +} + +export function pannable(element, options = {}) { + return new Pannable(element, options); +} + +export class Pannable { + constructor(element, options) { + this.update = options.update ?? (() => {}); + this.filter = options.filter ?? (() => true); + this.dpr = options.dpr ?? false; + this.zoom_min = options.zoom_min ?? 0; + this.zoom_max = options.zoom_max ?? 10; + this.wheel_zoom = options.wheel_zoom ?? -0.002; + this.preventDefault = options.preventDefault; + this.center = options.center ?? (() => [0, 0]); + + this.element = element; + + this.cx = 0; + this.cy = 0; + this.zoom = 0; + + this.last = [0, 0, 0]; + + this.unbind_touch = touch(element, { + preventDefault: options.preventDefault, + dpr: options.dpr, + start: this._change.bind(this), + move: this._move.bind(this), + end: this._change.bind(this), + }); + + this.element.addEventListener("wheel", this._onwheel.bind(this)); + } + + get(x, y) { + let center = (this.center)() ?? [0, 0]; + return [ + this.cx + center[0] + this.zoom_factor * x, + this.cy + center[1] + this.zoom_factor * y + ]; + } + + get_dpr() { + if (this.dpr) { + return window.devicePixelRatio ?? 1; + } else { + return 1; + } + } + + _calc(touches) { + let filtered = []; + for (let touch of touches.values()) { + if (this.filter(touch)) { + filtered.push(touch); + } + } + + if (filtered.length === 0) return [0, 0, 0]; + + let mx = 0; + let my = 0; + + for (let touch of filtered) { + mx += touch.x; + my += touch.y; + } + + mx /= filtered.length; + my /= filtered.length; + + let sigma = 0; + + for (let touch of filtered) { + let dx = (touch.x - mx); + let dy = (touch.y - my); + sigma += dx * dx + dy * dy; + } + + if (filtered.length > 1) sigma = Math.sqrt(sigma / (filtered.length - 1)); + + return [mx, my, sigma]; + } + + _change(touches) { + // TODO: make numerically stable + this.last = this._calc(touches); + } + + _move(touches) { + let [mx, my, sigma] = this._calc(touches); + + let dx = mx - this.last[0]; + let dy = my - this.last[1]; + let delta = (sigma === 0 || this.last[2] === 0) ? 1 : sigma / this.last[2]; + + let rect = this.element.getBoundingClientRect(); + + let old_zoom = this.zoom; + // let old_cx = this.cx; + // let old_cy = this.cy; + + this.zoom += Math.log2(delta); + if (this.zoom < this.zoom_min) this.zoom = this.zoom_min; + if (this.zoom > this.zoom_max) this.zoom = this.zoom_max; + delta = Math.pow(2, this.zoom) / Math.pow(2, old_zoom); + + let center = (this.center)() ?? [0, 0]; + + this.cx = (this.cx - (this.last[0] - center[0])) * delta + (mx - center[0]); + this.cy = (this.cy - (this.last[1] - center[1])) * delta + (my - center[1]); + + this.last = [mx, my, sigma]; + + (this.update)(this.element, this); + } + + _onwheel(event) { + if (this.preventDefault) event.preventDefault(); + + let rect = this.element.getBoundingClientRect(); + let x = (event.clientX - rect.x) * this.get_dpr(); + let y = (event.clientY - rect.y) * this.get_dpr(); + + let old_zoom = this.zoom; + + this.zoom += event.deltaY * this.wheel_zoom; + if (this.zoom < this.zoom_min) this.zoom = this.zoom_min; + if (this.zoom > this.zoom_max) this.zoom = this.zoom_max; + let delta = Math.pow(2, this.zoom) / Math.pow(2, old_zoom); + + let center = (this.center)() ?? [0, 0]; + + this.cx = (this.cx - (x - center[0])) * delta + (x - center[0]); + this.cy = (this.cy - (y - center[1])) * delta + (y - center[1]); + + (this.update)(this.element, this); + } + + unbind() { + this.unbind_touch(); + } + + get zoom_factor() { + return Math.pow(2, this.zoom); + } +} + +export function ctx_2d(ctx, options) { + ctx.imageSmoothingEnabled = false; + return ctx; + // return new Context2D(ctx); +} + +export class Context2D { + constructor(raw_ctx) { + raw_ctx.imageSmoothingEnabled = false; + this.raw = raw_ctx; + } + + get fillStyle() { + return this.raw.fillStyle; + } + + set fillStyle(value) { + this.raw.fillStyle = value; + } + + get strokeStyle() { + return this.raw.strokeStyle; + } + + set strokeStyle(value) { + this.raw.strokeStyle = value; + } + + get lineWidth() { + return this.raw.lineWidth; + } + + set lineWidth(value) { + this.raw.lineWidth = Math.round(value); + } + + beginPath() { + this.raw.beginPath(); + } + + fill() { + this.raw.fill(); + } + + fillRect(x, y, w, h) { + let lw = this.raw.lineWidth; + this.raw.fillRect( + x - lw / 2, + y - lw / 2, + w + lw, + h + lw, + ); + } + + ellipse(rx, ry, cx, cy, _change, end, offset) { + this.raw.ellipse(rx, ry, cx, cy, start, end, offset); + } +} + +export function bind_pixel_ratio(element, callback, fire_first = false) { + let first = true; + let media; + + function update() { + let dpr = window.devicePixelRatio ?? 1; + let dpr_str = (dpr * 100).toFixed(0); + + if (!first && fire_first) { + callback(dpr, element); + } + first = false; + + media = matchMedia(`(resolution: ${dpr}dppx)`); + + media.addEventListener("change", update, {once: true}); + } + + update(); + + return function unbind_pixel_ratio() { + media.removeEventListener("change", update); + } +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..e565204 --- /dev/null +++ b/test.html @@ -0,0 +1,71 @@ + + + + + + + PPTK test page + + + + +
+ + This test requires canvas support + +
+ +