diff --git a/package-lock.json b/package-lock.json index 327e0d6..7b92231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { - "name": "pptk", - "version": "0.1.0", + "name": "@shadryx/pptk", + "version": "0.1.6", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "pptk", - "version": "0.1.0", + "name": "@shadryx/pptk", + "version": "0.1.6", "license": "MIT", "devDependencies": { - "@rollup/plugin-typescript": "^11.0.0", "solid-js": "^1.6.2", "tslib": "^2.4.1", "typescript": "^4.9.0", @@ -575,60 +574,6 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, - "node_modules/@rollup/plugin-typescript": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz", - "integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.14.0||^3.0.0", - "tslib": "*", - "typescript": ">=3.7.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - }, - "tslib": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", - "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@types/estree": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true - }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -1160,12 +1105,6 @@ "node": ">=0.8.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1345,18 +1284,6 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.4.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", @@ -2030,33 +1957,6 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, - "@rollup/plugin-typescript": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz", - "integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^5.0.1", - "resolve": "^1.22.1" - } - }, - "@rollup/pluginutils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", - "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, - "requires": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" - } - }, - "@types/estree": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true - }, "@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -2349,12 +2249,6 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, - "estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, "fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -2482,12 +2376,6 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, "postcss": { "version": "8.4.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", diff --git a/package.json b/package.json index 9860042..911d54e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shadryx/pptk", - "version": "0.1.4", + "version": "0.1.6", "description": "Pixel-Perfect ToolKit, a library to help make pixel-perfect applications on high-DPI devices", "keywords": [ "pixel-perfect", @@ -44,7 +44,8 @@ "dev:solid": "vite dev test/solid", "dev:vanilla": "vite dev test/vanilla", "build": "npm run prepare-dev && vite build && tsc && cp src/index.d.ts dist/types/index.d.ts", - "clean": "rm -rf dist/ src/index.d.ts" + "clean": "rm -rf dist/ src/index.d.ts", + "publish": "npm i && npm run build && npm publish --access public" }, "repository": { "type": "git", diff --git a/src/index.js b/src/index.js index e7997a2..c6f5468 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ export const MAX_CANVAS_SIZE = 10000; /** * A function to call to unbind the event listeners attached to a canvas through `attachCanvas`. + * @deprecated This type will be removed in the next major release * @callback AttachCanvasUnbind * @returns {void} **/ @@ -32,7 +33,7 @@ export const MAX_CANVAS_SIZE = 10000; * * @param {HTMLCanvasElement} canvas * @param {AttachCanvasOptions} options - * @returns {AttachCanvasUnbind} + * @returns {() => void} **/ export function attachCanvas(canvas, options = {}) { const EPSILON = 0.001; @@ -93,15 +94,15 @@ export function attachCanvas(canvas, options = {}) { // Bind listeners - let unbind_dpr = listenPixelRatio(canvas, (dpr) => { + let unbindDpr = listenPixelRatio(canvas, (dpr) => { resize(dpr); }); - let resize_observer = new ResizeObserver((entries) => { + let resizeObserver = new ResizeObserver((entries) => { resize(window.devicePixelRatio ?? 1); }); - resize_observer.observe(container, { + resizeObserver.observe(container, { box: "border-box" }); @@ -109,22 +110,22 @@ export function attachCanvas(canvas, options = {}) { resize(window.devicePixelRatio ?? 1); }, 0); - return function unbind() { - if (resize_observer) { - resize_observer.disconnect(); - // Drop resize_observer - resize_observer = null; + return function detachCanvas() { + if (resizeObserver) { + resizeObserver.disconnect(); + // Drop resizeObserver + resizeObserver = null; } - if (unbind_dpr) { - unbind_dpr(); - // Drop unbind_dpr - unbind_dpr = null; + if (unbindDpr) { + unbindDpr(); + // Drop unbindDpr + unbindDpr = null; } }; } -export function listenPixelRatio(element, callback, fire_first = false) { +export function listenPixelRatio(element, callback, fireFirst = false) { let first = true; let media; @@ -132,7 +133,7 @@ export function listenPixelRatio(element, callback, fire_first = false) { let dpr = window.devicePixelRatio ?? 1; let dpr_str = (dpr * 100).toFixed(0); - if (!first && fire_first) { + if (!first && fireFirst) { callback(dpr, element); } first = false; @@ -144,7 +145,7 @@ export function listenPixelRatio(element, callback, fire_first = false) { update(); - return function unbind_pixel_ratio() { + return function detachPixelRatio() { media.removeEventListener("change", update); } } @@ -171,9 +172,9 @@ export function getContext2D(canvas) { /** * @typedef {object} AttachTouchOptions - * @prop {((touch: Touch, touches: Map) => void)=} onDown - * @prop {((touch: Touch[], touches: Map) => void)=} onMove - * @prop {((touch: Touch | undefined, touches: Map) => void)=} onUp + * @prop {((touch: Touch, touchMap: Map) => void)=} onDown + * @prop {((affectedTouches: Touch[], touchMap: Map) => void)=} onMove + * @prop {((touch: Touch | undefined, touchMap: Map) => void)=} onUp * @prop {number=} downscale * @prop {boolean=} preventDefault **/ @@ -189,7 +190,7 @@ export function attachTouch(element, options = {}) { const downscale = options.downscale ?? 1; let queued = null; - let queued_touches = []; + let queuedTouches = []; function get_dpr() { return window.devicePixelRatio ?? 1; @@ -241,14 +242,14 @@ export function attachTouch(element, options = {}) { if (queued === null) { queued = setTimeout(() => { try { - options.onMove?.(queued_touches, touches); + options.onMove?.(queuedTouches, touches); } finally { - queued_touches = []; + queuedTouches = []; queued = null; } }, 0); } - queued_touches.push(touch); + queuedTouches.push(touch); } } @@ -286,29 +287,226 @@ export function attachTouch(element, options = {}) { element.addEventListener("pointerdown", onDown); element.addEventListener("pointermove", onMove); element.addEventListener("pointerup", onUp); + element.addEventListener("pointerout", onUp); element.addEventListener("pointercancel", onCancel); - function ontouchmove(event) { + 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}); + element.addEventListener("touchmove", onTouchMove, {passive: false}); } - return function unbind() { + return function detachTouch() { 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}); + element.removeEventListener("touchmove", onTouchMove, {passive: false}); } } } +/** + * @typedef {object} PannableState + * An immutable object returned by `Pannable::getState()` + * @prop {number} dx + * @prop {number} dy + * @prop {number} scale + * @prop {number} logScale + * @prop {(x: number, y: number) => [x: number, y: number]} get + * Returns the transformed coordinates + **/ + +/** + * @typedef {object} PannableOptions + * @prop {number=} downscale + * @prop {number=} dx Defaults to 0 + * @prop {number=} dy Defaults to 0 + * @prop {number=} minScale + * @prop {number=} maxScale + **/ + +/** + * Handles panning and zooming + **/ +export class Pannable { + /** + * @param {PannableOptions} options + **/ + constructor(options) { + /** @type {number} **/ + this.dx = options?.dx || 0; + /** @type {number} **/ + this.dy = options?.dy || 0; + /** @type {number} **/ + this.logScale = 0.0; + /** @type {number} **/ + this.logMinScale = options?.minScale ? Math.log2(options.minScale) : -Infinity; + /** @type {number} **/ + this.logMaxScale = options?.maxScale ? Math.log2(options.maxScale) : Infinity; + + /** @private **/ + this.lastValues = [0, 0, 1]; + } + + /** @private **/ + _getValues(touches) { + // First-order moments of touches.x and touches.y + let mx = 0; + let my = 0; + // Second-order moments of touches.x and touches.y + let mx2 = 0; + let my2 = 0; + + for (const touch of touches.values()) { + mx += touch.x; + my += touch.y; + mx2 += touch.x * touch.x; + my2 += touch.y * touch.y; + } + + const n = touches.size; + if (n <= 1) { + return [mx, my, 1]; + } + + // Unbiased sample variance, from https://en.wikipedia.org/wiki/Bessel%27s_correction#Formula + const sx = mx2 / (n - 1) - (mx * mx) / (n * (n - 1)); + const sy = my2 / (n - 1) - (my * my) / (n * (n - 1)); + + return [mx / n, my / n, Math.sqrt(sx + sy)]; + } + + /** @returns {number} **/ + get scale() { + return Math.pow(2, this.logScale); + } + + /** @returns {PannableState} **/ + getState() { + const scale = this.scale; + const dx = this.dx; + const dy = this.dy; + return { + dx, + dy, + logScale: this.logScale, + scale, + get: (x, y) => { + return [x * scale + dx, y * scale + dy]; + } + }; + } + + /** + * @param {Map} touches + **/ + move(touches) { + const currentValues = this._getValues(touches); + let deltaLogScale = Math.log2(currentValues[2] / this.lastValues[2]); + if (Number.isNaN(deltaLogScale) || !Number.isFinite(deltaLogScale)) { + deltaLogScale = 0; + } + const oldLogScale = this.logScale; + this.logScale += deltaLogScale; + if (this.logScale <= this.logMinScale) this.logScale = this.logMinScale; + if (this.logScale >= this.logMaxScale) this.logScale = this.logMaxScale; + + const deltaScale = Math.pow(2, this.logScale - oldLogScale); + + this.dx = (this.dx - this.lastValues[0]) * deltaScale + currentValues[0]; + this.dy = (this.dy - this.lastValues[1]) * deltaScale + currentValues[1]; + + this.lastValues = currentValues; + } + + zoom(amount, cx = 0, cy = 0) { + const oldLogScale = this.logScale; + this.logScale += Math.log2(amount); + if (this.logScale <= this.logMinScale) this.logScale = this.logMinScale; + if (this.logScale >= this.logMaxScale) this.logScale = this.logMaxScale; + + const deltaScale = Math.pow(2, this.logScale - oldLogScale); + + this.dx = (this.dx - cx) * deltaScale + cx; + this.dy = (this.dy - cy) * deltaScale + cy; + } + + /** + * @param {Map} touches + **/ + update(touches) { + this.lastValues = this._getValues(touches); + } +} + +/** + * @typedef {PannableOptions & { + * onUpdate?: (state: PannableState) => void, + * wheelSensitivity?: number, + * emSize?: number, + * pageSize?: number, + * }} AttachPannableOptions + **/ + +/** + * A simple wrapper around `Pannable`, which handles touch input and scrollwheel input + * @param {HTMLElement} element + * @param {AttachPannableOptions} options + **/ +export function attachPannable(element, options) { + const pannable = new Pannable(options); + const wheelSensitivity = options?.wheelSensitivity ?? 0.005; + const emSize = options?.emSize ?? 12; + const pageSize = options?.pageSize ?? options?.emSize ?? 12; + setTimeout(() => options?.onUpdate?.(pannable.getState()), 0); + + const detachTouch = attachTouch(element, { + downscale: options.downscale, + preventDefault: true, + onDown(_, touches) { + pannable.update(touches); + }, + onUp(_, touches) { + pannable.update(touches); + }, + onMove(_, touches) { + pannable.move(touches); + options?.onUpdate?.(pannable.getState()); + } + }); + + const wheelListener = element.addEventListener("wheel", (event) => { + const dpr = window.devicePixelRatio || 1; + const bounding = element.getBoundingClientRect(); + + let deltaY = -event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + deltaY *= emSize; + } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + deltaY *= pageSize; + } + + pannable.zoom( + Math.pow(2, deltaY * wheelSensitivity * dpr), + (event.clientX - bounding.x) * dpr, + (event.clientY - bounding.y) * dpr + ); + + options?.onUpdate?.(pannable.getState()); + }); + + return function detachPannable() { + element.removeEventListener(wheelListener); + detachTouch(); + }; +} + export const StrokeMode = Object.freeze({ DEFAULT: 0, INSIDE: 1, diff --git a/test/vanilla/index.html b/test/vanilla/index.html index 5720ea3..c278b5f 100644 --- a/test/vanilla/index.html +++ b/test/vanilla/index.html @@ -6,30 +6,35 @@ PPTK test page