|
|
@ -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`.
|
|
|
|
* 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
|
|
|
|
* @callback AttachCanvasUnbind
|
|
|
|
* @returns {void}
|
|
|
|
* @returns {void}
|
|
|
|
**/
|
|
|
|
**/
|
|
|
@ -32,7 +33,7 @@ export const MAX_CANVAS_SIZE = 10000;
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* @param {HTMLCanvasElement} canvas
|
|
|
|
* @param {HTMLCanvasElement} canvas
|
|
|
|
* @param {AttachCanvasOptions} options
|
|
|
|
* @param {AttachCanvasOptions} options
|
|
|
|
* @returns {AttachCanvasUnbind}
|
|
|
|
* @returns {() => void}
|
|
|
|
**/
|
|
|
|
**/
|
|
|
|
export function attachCanvas(canvas, options = {}) {
|
|
|
|
export function attachCanvas(canvas, options = {}) {
|
|
|
|
const EPSILON = 0.001;
|
|
|
|
const EPSILON = 0.001;
|
|
|
@ -93,15 +94,15 @@ export function attachCanvas(canvas, options = {}) {
|
|
|
|
|
|
|
|
|
|
|
|
// Bind listeners
|
|
|
|
// Bind listeners
|
|
|
|
|
|
|
|
|
|
|
|
let unbind_dpr = listenPixelRatio(canvas, (dpr) => {
|
|
|
|
let unbindDpr = listenPixelRatio(canvas, (dpr) => {
|
|
|
|
resize(dpr);
|
|
|
|
resize(dpr);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let resize_observer = new ResizeObserver((entries) => {
|
|
|
|
let resizeObserver = new ResizeObserver((entries) => {
|
|
|
|
resize(window.devicePixelRatio ?? 1);
|
|
|
|
resize(window.devicePixelRatio ?? 1);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
resize_observer.observe(container, {
|
|
|
|
resizeObserver.observe(container, {
|
|
|
|
box: "border-box"
|
|
|
|
box: "border-box"
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
@ -109,22 +110,22 @@ export function attachCanvas(canvas, options = {}) {
|
|
|
|
resize(window.devicePixelRatio ?? 1);
|
|
|
|
resize(window.devicePixelRatio ?? 1);
|
|
|
|
}, 0);
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
|
|
|
|
return function unbind() {
|
|
|
|
return function detachCanvas() {
|
|
|
|
if (resize_observer) {
|
|
|
|
if (resizeObserver) {
|
|
|
|
resize_observer.disconnect();
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
// Drop resize_observer
|
|
|
|
// Drop resizeObserver
|
|
|
|
resize_observer = null;
|
|
|
|
resizeObserver = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (unbind_dpr) {
|
|
|
|
if (unbindDpr) {
|
|
|
|
unbind_dpr();
|
|
|
|
unbindDpr();
|
|
|
|
// Drop unbind_dpr
|
|
|
|
// Drop unbindDpr
|
|
|
|
unbind_dpr = null;
|
|
|
|
unbindDpr = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function listenPixelRatio(element, callback, fire_first = false) {
|
|
|
|
export function listenPixelRatio(element, callback, fireFirst = false) {
|
|
|
|
let first = true;
|
|
|
|
let first = true;
|
|
|
|
let media;
|
|
|
|
let media;
|
|
|
|
|
|
|
|
|
|
|
@ -132,7 +133,7 @@ export function listenPixelRatio(element, callback, fire_first = false) {
|
|
|
|
let dpr = window.devicePixelRatio ?? 1;
|
|
|
|
let dpr = window.devicePixelRatio ?? 1;
|
|
|
|
let dpr_str = (dpr * 100).toFixed(0);
|
|
|
|
let dpr_str = (dpr * 100).toFixed(0);
|
|
|
|
|
|
|
|
|
|
|
|
if (!first && fire_first) {
|
|
|
|
if (!first && fireFirst) {
|
|
|
|
callback(dpr, element);
|
|
|
|
callback(dpr, element);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
first = false;
|
|
|
|
first = false;
|
|
|
@ -144,7 +145,7 @@ export function listenPixelRatio(element, callback, fire_first = false) {
|
|
|
|
|
|
|
|
|
|
|
|
update();
|
|
|
|
update();
|
|
|
|
|
|
|
|
|
|
|
|
return function unbind_pixel_ratio() {
|
|
|
|
return function detachPixelRatio() {
|
|
|
|
media.removeEventListener("change", update);
|
|
|
|
media.removeEventListener("change", update);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@ -171,9 +172,9 @@ export function getContext2D(canvas) {
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* @typedef {object} AttachTouchOptions
|
|
|
|
* @typedef {object} AttachTouchOptions
|
|
|
|
* @prop {((touch: Touch, touches: Map<number, Touch>) => void)=} onDown
|
|
|
|
* @prop {((touch: Touch, touchMap: Map<number, Touch>) => void)=} onDown
|
|
|
|
* @prop {((touch: Touch[], touches: Map<number, Touch>) => void)=} onMove
|
|
|
|
* @prop {((affectedTouches: Touch[], touchMap: Map<number, Touch>) => void)=} onMove
|
|
|
|
* @prop {((touch: Touch | undefined, touches: Map<number, Touch>) => void)=} onUp
|
|
|
|
* @prop {((touch: Touch | undefined, touchMap: Map<number, Touch>) => void)=} onUp
|
|
|
|
* @prop {number=} downscale
|
|
|
|
* @prop {number=} downscale
|
|
|
|
* @prop {boolean=} preventDefault
|
|
|
|
* @prop {boolean=} preventDefault
|
|
|
|
**/
|
|
|
|
**/
|
|
|
@ -189,7 +190,7 @@ export function attachTouch(element, options = {}) {
|
|
|
|
const downscale = options.downscale ?? 1;
|
|
|
|
const downscale = options.downscale ?? 1;
|
|
|
|
|
|
|
|
|
|
|
|
let queued = null;
|
|
|
|
let queued = null;
|
|
|
|
let queued_touches = [];
|
|
|
|
let queuedTouches = [];
|
|
|
|
|
|
|
|
|
|
|
|
function get_dpr() {
|
|
|
|
function get_dpr() {
|
|
|
|
return window.devicePixelRatio ?? 1;
|
|
|
|
return window.devicePixelRatio ?? 1;
|
|
|
@ -241,14 +242,14 @@ export function attachTouch(element, options = {}) {
|
|
|
|
if (queued === null) {
|
|
|
|
if (queued === null) {
|
|
|
|
queued = setTimeout(() => {
|
|
|
|
queued = setTimeout(() => {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
options.onMove?.(queued_touches, touches);
|
|
|
|
options.onMove?.(queuedTouches, touches);
|
|
|
|
} finally {
|
|
|
|
} finally {
|
|
|
|
queued_touches = [];
|
|
|
|
queuedTouches = [];
|
|
|
|
queued = null;
|
|
|
|
queued = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, 0);
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
queued_touches.push(touch);
|
|
|
|
queuedTouches.push(touch);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -286,29 +287,226 @@ export function attachTouch(element, options = {}) {
|
|
|
|
element.addEventListener("pointerdown", onDown);
|
|
|
|
element.addEventListener("pointerdown", onDown);
|
|
|
|
element.addEventListener("pointermove", onMove);
|
|
|
|
element.addEventListener("pointermove", onMove);
|
|
|
|
element.addEventListener("pointerup", onUp);
|
|
|
|
element.addEventListener("pointerup", onUp);
|
|
|
|
|
|
|
|
element.addEventListener("pointerout", onUp);
|
|
|
|
element.addEventListener("pointercancel", onCancel);
|
|
|
|
element.addEventListener("pointercancel", onCancel);
|
|
|
|
|
|
|
|
|
|
|
|
function ontouchmove(event) {
|
|
|
|
function onTouchMove(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// preventDefault() on pointer events does not work as expected (eg. scrolling still happens), so this is needed instead
|
|
|
|
// preventDefault() on pointer events does not work as expected (eg. scrolling still happens), so this is needed instead
|
|
|
|
if (options.preventDefault) {
|
|
|
|
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("pointerdown", onDown);
|
|
|
|
element.removeEventListener("pointermove", onMove);
|
|
|
|
element.removeEventListener("pointermove", onMove);
|
|
|
|
element.removeEventListener("pointup", onUp);
|
|
|
|
element.removeEventListener("pointup", onUp);
|
|
|
|
element.removeEventListener("pointercancel", onCancel);
|
|
|
|
element.removeEventListener("pointercancel", onCancel);
|
|
|
|
|
|
|
|
|
|
|
|
if (options.preventDefault) {
|
|
|
|
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<number, Touch>} 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<number, Touch>} 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({
|
|
|
|
export const StrokeMode = Object.freeze({
|
|
|
|
DEFAULT: 0,
|
|
|
|
DEFAULT: 0,
|
|
|
|
INSIDE: 1,
|
|
|
|
INSIDE: 1,
|
|
|
|