vlens
Version:
Data Centric Routing & Rendering Mini-Framework
1,019 lines (862 loc) • 27.8 kB
text/typescript
import * as preact from "preact";
import * as css from "./css";
import * as rpc from "./rpc";
import * as cache from "./cache";
import * as refs from "./refs";
import * as geom from "./geom";
let global = window as any;
export let dragging: any = null; // for user code to set to anything!
export function setDragging(d: any) {
dragging = d;
}
// Claude
function _isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
// Claude 3.5 Sonnet
function _isDragAndDropSupported() {
const div = document.createElement('div');
return ('draggable' in div) || ('ondragstart' in div && 'ondrop' in div);
}
export const supportsDragDrop = _isDragAndDropSupported();
export const isTouchDevice = _isTouchDevice()
let _debugVars: Record<string, any> = {};
export function debugVar(data: any) {
Object.assign(_debugVars, data);
}
let frameMessages = new Map<string, any>()
export function postFrameMessage(key: string, value: any) {
frameMessages.set(key, value)
}
export function consumeFrameMessage<T = any>(key: string): T | null {
let value = frameMessages.get(key)
frameMessages.delete(key)
return value
}
export let rootDiv = document.body;
export let event: Event | null = null; // stores current event
function _redraw(): number {
// returns the time it took to render in ms
if (rootDiv === null) {
return 0;
}
if (_block_router) {
return 0;
}
const t1 = performance.now();
const vdom = renderPageAndModals(gRootPage, gModalStack);
preact.render(vdom, rootDiv);
event = null;
gesture = zeroFrameGesture()
_debugVars = {};
frameMessages.clear()
mousePrev = structuredClone(mouse)
const t2 = performance.now();
const duration = t2 - t1;
// if (duration > 5) {
// console.info(`redraw ${(duration).toFixed(2)}ms`);
// }
return duration;
}
// global.redraw = redraw;
let _redrawSchedule = 0;
function _animationFrameRedraw() {
_redrawSchedule = 0;
_redraw();
}
// schedules redraw as soon as possible
//
// this is so that the caller can do a bunch of stuff and then have
// the redraw called only after they're done with everything or when
// they have to yield for an async call
//
// Safe to schedule multiple times per frame; only the first time will schedule
export function scheduleRedraw() {
if (_redrawSchedule === 0) {
_redrawSchedule = requestAnimationFrame(_animationFrameRedraw);
}
}
let _deferredRedrawScheduled = 0;
let _deferredRedrawDuration = 30;
function _deferredRedraw() {
_deferredRedrawScheduled = 0;
let duration = _redraw();
_deferredRedrawDuration = Math.max(30, Math.round(duration * 4));
}
export function deferRedraw() {
if (_deferredRedrawScheduled == 0) {
_deferredRedrawScheduled = setTimeout(
_deferredRedraw,
_deferredRedrawDuration,
);
}
}
// @ts-ignore
window.scheduleRedraw = scheduleRedraw;
// @ts-ignore
window.refs = refs;
export function debugVarsPanel(): preact.ComponentChild {
if (DEBUG) {
return preact.h("div", { class: _clsDebugVars }, [
_showDebugEntries(_debugVars),
]);
} else {
return preact.h(preact.Fragment, {})
}
}
function _showDebugEntries(data: any, prefix = ""): preact.ComponentChild {
return preact.h(
preact.Fragment,
{},
Object.entries(data).map(([key, value]) => {
if (typeof value === "object") {
return _showDebugEntries(value, prefix + key + ".");
} else {
return preact.h("div", {}, [`${prefix + key}: ${value}`]);
}
}),
);
}
const _clsDebugVars = css.cls("debug-vars", {
position: "fixed",
zIndex: "100000000",
top: "0px",
left: "0px",
width: "content-fit",
height: "content-fit",
padding: "2px 4px",
borderBottomRightRadius: "4px",
fontFamily: "monospace",
background: "hsla(0, 0%, 0%, 0.6)",
color: "white",
textShadow: "0px 0px 2px black",
fontSize: "14px",
pointerEvents: "none",
":empty": {
display: "none",
},
});
function nodeCustomEvent(node: Node, eventName: string, options?: object) {
const event = new CustomEvent(eventName, options);
node.dispatchEvent(event);
// console.log(eventName, node)
}
function recursivelyDispatchCreateEvent(node: Node) {
if (node instanceof Element && node.hasAttribute("listen-create")) {
nodeCustomEvent(node, "create");
}
node.childNodes.forEach(recursivelyDispatchCreateEvent);
}
const observer = new MutationObserver((mutationsList: MutationRecord[]) => {
const t1 = performance.now();
let targets: Element[] = []
for (const mutation of mutationsList) {
if (mutation.target instanceof Element) {
if (mutation.target.hasAttribute("listen-mutate")) {
nodeCustomEvent(mutation.target, "mutate", { bubbles: true });
targets.push(mutation.target)
}
}
mutation.addedNodes.forEach(recursivelyDispatchCreateEvent);
// mutation.removedNodes.forEach(node => nodeCustomEvent(node, 'destroy'));
}
/*
const dur = performance.now() - t1;
if (dur > 1) {
console.log(`mutation dispatch ${dur.toFixed(2)}ms`, targets);
}
*/
});
observer.observe(rootDiv, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
export function captureEvent(e: Event) {
if (e instanceof KeyboardEvent) {
if (e.isComposing) {
return;
}
}
if (e instanceof MouseEvent) {
captureMouseLocation(e)
}
if (isTouchEvent(e)) {
processFrameGesture(e)
}
event = e; // make it available to all renderers!
scheduleRedraw();
}
export let mouse = geom.zeroPoint();
export let mousePrev = geom.zeroPoint()
// window.addEventListener("click", captureEvent)
window.addEventListener("touch", captureEvent);
window.addEventListener("touchstart", captureEvent);
window.addEventListener("touchend", captureEvent);
window.addEventListener("touchcancel", captureEvent);
window.addEventListener("touchenter", captureEvent);
window.addEventListener("touchleave", captureEvent);
window.addEventListener("touchmove", captureEvent, { passive: false });
window.addEventListener("mousemove", captureEvent, { passive: false });
window.addEventListener("wheel", captureEvent);
window.addEventListener("mousedown", captureEvent);
window.addEventListener("mouseup", captureEvent);
document.addEventListener("input", captureEvent);
document.addEventListener("keydown", captureEvent);
document.fonts.addEventListener('loadingdone', scheduleRedraw);
function hasMouseDevice() {
// js wtf
return matchMedia('(pointer:fine)').matches
}
function isActuallyTouchEvent(e: MouseEvent) {
return !hasMouseDevice() || (e as any).sourceCapabilities?.firesTouchEvents === true
}
function captureMouseLocation(e: MouseEvent) {
if (isActuallyTouchEvent(e)) return
mouse.x = e.clientX;
mouse.y = e.clientY;
}
function passiveCapture(_event: Event) {
deferRedraw();
}
document.addEventListener("scroll", passiveCapture, { passive: true });
window.addEventListener("resize", passiveCapture, { passive: true });
export function isUnderMouse(el: Element | null | undefined, cursor = mouse): boolean {
if (!el) {
return false;
}
let underMouse = document.elementFromPoint(cursor.x, cursor.y);
return el === underMouse || el.contains(underMouse);
}
export function elementRect(element: Element | null | undefined): geom.Rect {
if (!element) {
return geom.zeroRect();
} else {
let r0 = element.getBoundingClientRect();
return {
x: r0.x,
y: r0.y,
width: r0.width,
height: r0.height,
};
}
}
export type ClickLocation = "inside" | "outside" | "na";
export function clickLocationRelativeTo(el: HTMLElement | null, eventType: string = "mousedown") {
if (
el &&
event &&
event.target instanceof HTMLElement &&
event.type === "mousedown"
) {
if (el.contains(event.target)) {
return "inside";
} else {
return "outside";
}
} else {
return "na";
}
}
export function clickOutside(...elements: (HTMLElement|null)[]): boolean {
if (
event instanceof MouseEvent &&
event.target instanceof HTMLElement &&
event.type === "mousedown" &&
event.buttons === 1
) {
for (let el of elements) {
if (el && el.contains(event.target)) {
return false
}
}
return true;
} else {
// there's no click ..
return false;
}
}
export function keydownOn(el: HTMLElement | null): string {
if (event && event.type === "keydown" && event.target === el) {
return (event as KeyboardEvent).key;
} else {
return "";
}
}
export function eventTargetId(type: string, targetId: string): boolean {
return Boolean(event && event.type === type && event.target === document.getElementById(targetId))
}
export function inputEventOn(el: HTMLElement | null) {
return el && event && event.type === "input" && event.target === el;
}
export function getEventTarget(): HTMLElement | null {
if (event && event.target instanceof HTMLElement) {
return event.target;
} else {
return null;
}
}
// from https://web.dev/articles/canvas-hidipi
export function getContext2D(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
// Get the device pixel ratio, falling back to 1.
var dpr = window.devicePixelRatio || 1;
// Get the size of the canvas in CSS pixels.
var rect = canvas.getBoundingClientRect();
// Give the canvas pixel dimensions of their CSS
// size * the device pixel ratio.
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
var ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, rect.width, rect.height);
// Scale all drawing operations by the dpr, so you
// don't have to worry about the difference.
ctx.scale(dpr, dpr);
return ctx;
}
export function getWindowSize(): geom.Size {
return {
width: Math.min(window.outerWidth, window.innerWidth),
height: Math.min(window.outerHeight, window.innerHeight),
};
}
export function getScreenSize(): geom.Size {
return {
width: screen.availWidth,
height: screen.availHeight,
}
}
/*
getImageSize fetches the image if its size is not already known.
It's a good idea to call it as soon as possible even if the result is not going to be needed yet.
*/
const _imageSizes = new Map<string, geom.Size>();
const _waitingImages = new Set<string>();
export function getImageSize(url: string): geom.Size {
const storedSize = _imageSizes.get(url);
if (storedSize !== undefined) {
return storedSize;
}
// we cannot find, lets fetch it
// but if it's already being fetched, no need to fetch it again.
if (!_waitingImages.has(url)) {
_waitingImages.add(url);
const img = new Image();
img.onload = () => {
_waitingImages.delete(url);
_imageSizes.set(url, {
width: img.naturalWidth,
height: img.naturalHeight,
});
scheduleRedraw();
// console.log("downloaded", url)
};
img.src = url;
}
return { width: 0, height: 0 };
}
export type ResolverFn<R> = (v: R) => void;
export type ModalViewFn<T = any, R = any> = (
vm: T,
resolve: ResolverFn<R>,
) => preact.ComponentChild;
interface RootPage<Data = any> {
route: string;
prefix: string;
data: Data;
view: (route: string, prefix: string, data: Data) => preact.ComponentChild;
}
let gRootPage: RootPage | null = null;
export function setRootPage(r: RootPage) {
gRootPage = r;
}
global.getRoot = () => gRootPage;
export function getPageData(): unknown {
return gRootPage?.data;
}
const clsModalBackdrop = css.cls("modal_backdrop", {
position: "fixed",
zIndex: "100000",
top: "0",
left: "0",
right: "0",
bottom: "0",
background: "hsla(0, 0%, 0%, 0.5)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
});
function modalContainer(
zIndex: number,
content: preact.ComponentChild,
resolver: Function,
clickOutsideValue?: any,
): preact.ComponentChild {
const id = "modal_" + zIndex;
// const style = `z-index: ${zIndex}`; // FIXME is this needed?
const onMouseDown = (event: MouseEvent) => {
// ignore events caused by event propagation
// console.log("target", event.target, "currentTarget", event.currentTarget);
if (event.target !== event.currentTarget) {
return;
}
if (clickOutsideValue !== undefined) {
resolver(clickOutsideValue);
scheduleRedraw();
}
};
return preact.h("div", { id: id, class: clsModalBackdrop, onMouseDown }, [
content,
]);
}
function renderPageAndModals(
rootPage: RootPage | null,
modalStack: ModalEntry[],
): preact.ComponentChild {
let main: preact.ComponentChild;
let modals: preact.ComponentChild[] = [];
if (rootPage !== null) {
main = rootPage.view(rootPage.route, rootPage.prefix, rootPage.data);
} else {
main = preact.h(preact.Fragment, {});
}
const base_z_index = 10000;
for (const [index, modalItem] of modalStack.entries()) {
const modalContent = modalItem.view(modalItem.vm, modalItem.resolve);
modals.push(
modalContainer(
base_z_index + 1 + index,
modalContent,
modalItem.resolve,
modalItem.clickOutsideValue,
),
);
}
let modalsFragment = preact.h(preact.Fragment, {}, modals)
let debugPanels = debugVarsPanel()
return preact.h(preact.Fragment, {}, [main, modalsFragment, debugPanels]);
}
export type RouteHandler<Data = any> = {
fetch: (route: string, prefix: string) => Promise<rpc.Response<Data>>;
view: (route: string, prefix: string, data: Data) => preact.ComponentChild;
}
type FetchFn<Data> = RouteHandler<Data>["fetch"]
type ViewFn<Data> = RouteHandler<Data>["view"]
export interface RouteEntry<Data = any> {
prefix: string;
handler: () => Promise<RouteHandler<Data>>
}
export function routeEntry<Data>(prefix: string, fetch: FetchFn<Data>, view: ViewFn<Data>): RouteEntry<Data> {
let handler = () => Promise.resolve({ fetch, view })
return { prefix, handler };
}
export function routeHandler<Data>(prefix: string, handler: RouteEntry['handler']): RouteEntry<Data> {
return { prefix, handler };
}
let errorView: (route: string, prefix: string, error: string) => preact.ComponentChild;
errorView = (_r, _p, e: string) => preact.h("h1", { children: e }); // default dummy error view
export function setErrorView(
pErrorView: (r: string, p: string, e: string) => preact.ComponentChild,
) {
errorView = pErrorView;
}
export function setRoute(route: string) {
// handle special case when view is trying to change the root
// and it takes time but because view functions are called multiple times
// it tries to set the same route again and again
// TODO: perhaps setting route should block re-rendering until the route
// data fetching is done
if (_route_in_transition === route) {
return
}
history.pushState({ ts: Date.now() }, "", route);
onRouteChange();
}
export function replaceRoute(route: string) {
history.replaceState({ ts: Date.now() }, "", route);
onRouteChange();
}
export function getRoute(): string {
return decodeURI(location.pathname) + location.search;
}
export type ParsedRoute = {
pathname: string;
searchParams: URLSearchParams;
};
export function parseRoute(route: string): ParsedRoute {
let url = new URL(route, location.origin);
return {
pathname: url.pathname,
searchParams: url.searchParams,
};
}
export function getRouteParsed(): ParsedRoute {
return parseRoute(getRoute());
}
let gRoutes: RouteEntry[] = [];
interface ModalEntry<T = any, R = any> {
vm: T;
resolve: ResolverFn<R>;
view: ModalViewFn<T, R>;
clickOutsideValue?: R;
}
let gModalStack: ModalEntry[] = [];
export function openModal<T = any, R = any>(
vm: T,
view: ModalViewFn<T, R>,
clickOutsideValue?: R,
): Promise<R> {
const result = new Promise<R>((resolve) => {
const entryIndex = gModalStack.length;
const resolveAndClose = (result: R): void => {
resolve(result);
gModalStack.splice(entryIndex, 1);
scheduleRedraw();
};
const entry: ModalEntry<T, R> = {
vm,
resolve: resolveAndClose,
view,
clickOutsideValue,
};
gModalStack.push(entry);
// Note: resolve will be called by the view
});
scheduleRedraw();
return result;
}
export function closeTopModal() {
let topIndex = gModalStack.length-1
gModalStack.splice(topIndex, 1);
scheduleRedraw();
}
let cleanupFunctions: Function[] = [];
// register a cleanup function to be called when a page changes
// for modules that can't be imported here due circularity
export function registerCleanupFunction(fn: Function) {
cleanupFunctions.push(fn);
}
// state to helps us set/restore scroll position across navigations
let preNavStateId = 0;
let postNavStateId = 0;
let _block_router = false
export function blockAndReload(url: string = "/") {
_block_router = true
location.href = url
// location.reload()
}
let _route_in_transition: string | null = null
async function onRouteChange() {
if (_block_router) {
return
}
if (_route_in_transition) {
return
}
// console.log({preNavStateId, postNavStateId})
const routes = gRoutes;
let route = getRoute();
let entry = routes.find(entry => route.startsWith(entry.prefix))
if (!entry) {
// TODO: set a 404 or something
return
}
_route_in_transition = route
storePageScroll(preNavStateId, window.scrollY);
let handler = await entry.handler()
let [data, error] = await handler.fetch(route, entry.prefix)
_route_in_transition = null
// reset page data ...
cache.clearCache();
refs.clearRefs();
gModalStack.splice(0) // remove all items (reset slice)
for (let fn of cleanupFunctions) {
fn();
}
if (data) {
setRootPage({
route,
prefix: entry.prefix,
data: data,
view: handler.view,
});
} else {
setRootPage({
route,
prefix: entry.prefix,
data: error,
view: errorView,
});
}
scheduleRedraw();
let desiredYScroll = retrievePageScroll(postNavStateId);
requestAnimationFrame(() => {
window.scrollTo(0, desiredYScroll);
});
}
let _scrolls = new Map<number, number>();
function storePageScroll(stateId: number, value: number) {
_scrolls.set(stateId, value);
}
function retrievePageScroll(stateId: number) {
return _scrolls.get(stateId) ?? 0;
}
// @ts-ignore
window._scrolls = _scrolls
function onPopState(event: PopStateEvent) {
console.log("popstate:", event)
if (event.state) {
preNavStateId = postNavStateId;
postNavStateId = event.state.ts;
}
onRouteChange()
}
export function initRoutes(routes: RouteEntry[]) {
gRoutes = routes;
window.addEventListener("popstate", onPopState);
// initial state; we use 0 becuase that's the default value for preNavStateId
history.replaceState({ ts: Date.now() }, "");
history.scrollRestoration = "auto";
// rewrite legacy urls
if (window.location.pathname === '/' &&
!window.location.search &&
window.location.hash &&
window.location.hash.startsWith('#/')) {
var newPath = window.location.hash.slice(1);
history.replaceState({ ts: Date.now() }, '', newPath);
}
// handle link clicks
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) {
return
}
const link = event.target.closest('a[href]');
if (!link) {
return
}
const href = link.getAttribute('href') ?? '';
if (href.startsWith('/')) {
event.preventDefault();
let ts = Date.now()
history.pushState({ ts }, '', href);
preNavStateId = postNavStateId;
postNavStateId = ts;
onRouteChange();
}
});
onRouteChange();
}
interface Queryable {
[key: string]: number | string;
}
export function queryString(params: Queryable): string {
const elements: string[] = [];
for (const key in params) {
const value = String(params[key]);
elements.push(key + "=" + encodeURIComponent(value));
}
return elements.join("&");
}
export function classes(...names: Array<string | false | undefined>): string {
let stringNames: string[] = [];
for (let name of names) {
if (typeof name === "string") stringNames.push(name);
}
return stringNames.join(" ");
}
export async function waitUntil(t: number) {
let now = Date.now();
let dur = t - now;
if (dur <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, dur));
}
export function localStorageRead<T>(key: string): T | undefined {
let raw = localStorage.getItem(key);
if (raw === null) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
export function localStorageSet<T>(key: string, value: T) {
localStorage.setItem(key, JSON.stringify(value))
}
export function focusAfterFrame<T extends HTMLElement>(
ref: refs.Ref<T | null>,
) {
setTimeout(() => {
let el = refs.get(ref);
if (el) {
el.focus();
scheduleRedraw();
}
});
}
export function watchElementSize(ref: refs.Ref<HTMLElement | null>) {
let height = elementRect(refs.get(ref)).height
requestAnimationFrame(() => {
let height2 = elementRect(refs.get(ref)).height
if (height2 !== height) {
scheduleRedraw()
}
})
}
// ====== i18n ======
export type LANG = string;
export const EN: LANG = "en";
export const AR: LANG = "ar";
export const JA: LANG = "ja";
export let lang = EN;
export function setLang(v: string) {
lang = v;
}
export type LocalizedTextMap = [LANG, string][];
export function selectLocalizedTextByLang(map: LocalizedTextMap, lang: string) {
for (let [lang0, text0] of map) {
if (lang === lang0) {
return text0;
}
}
return map[0][1];
}
export function selectLocalizedText(map: LocalizedTextMap): string {
return selectLocalizedTextByLang(map, lang);
}
// =================================================================
// ============ Gestures ===================================
// =================================================================
type ITouch = {
identifier: number
clientX: number
clientY: number
radiusX: number
radiusY: number
rotationAngle: number
force: number
}
type TouchData = {
position: geom.Point
radius: geom.Size
force: number
}
function getTouchData(touch: ITouch): TouchData {
return {
position: { x: touch.clientX, y: touch.clientY },
radius: { width: touch.radiusX, height: touch.radiusY },
force: touch.force,
}
}
// internal; for accumulating gesture data across frames
export type GestureInfo = {
target: Element,
// internal
panId: number,
zoomId0: number,
zoomId1: number,
// internal
panPointPrev: geom.Point,
zoomVecPrev: geom.Point, // the (x,y) delta between the two touch points
}
export type FrameGesture = {
target: Element
isPanning: boolean
isZooming: boolean
/** How much in absolute pixels the scroll position should change */
panDelta: geom.Point,
/** How much in absolute pixels did the distance between the touch points increase */
zoomDelta: number
/** The center point between the two touch points */
zoomCenter: geom.Point
}
function zeroGesture(): GestureInfo {
return {
target: document.body,
panId: -1,
zoomId0: -1,
zoomId1: -1,
panPointPrev: geom.zeroPoint(),
zoomVecPrev: geom.zeroPoint(),
}
}
function zeroFrameGesture(): FrameGesture {
return {
target: document.body,
isPanning: false,
isZooming: false,
panDelta: geom.zeroPoint(),
zoomDelta: 0,
zoomCenter: geom.zeroPoint(),
}
}
export let _gesture = zeroGesture()
export let gesture = zeroFrameGesture()
function isTouchEvent(event: Event): event is TouchEvent {
return (window['TouchEvent'] && event instanceof TouchEvent)
}
function touchClientPoint(t: Touch): geom.Point {
return { x: t.clientX, y: t.clientY }
}
function getTouch(event: TouchEvent, touchId: number): Touch | null {
return Array.from(event.touches).find(t => t.identifier === touchId) ?? null
}
function getZoomVecAndCenter(touch0: Touch, touch1: Touch): [geom.Point, geom.Point] {
let p0 = touchClientPoint(touch0)
let p1 = touchClientPoint(touch1)
let vec = geom.pointMinus(p1, p0)
let center = geom.pointMiddle(p0, p1)
return [vec, center]
}
// Generated by Claude
function processFrameGesture(event: TouchEvent) {
if (event.touches.length === 0) {
_gesture = zeroGesture()
return
}
gesture.target = _gesture.target
// Handle touch start
if (event.type === 'touchstart') {
if (event.touches.length === 1) {
_gesture.target = event.target as Element;
}
if (event.touches.length === 1) {
let touch = event.touches.item(0)!
_gesture.panId = touch.identifier
_gesture.panPointPrev = touchClientPoint(touch)
gesture.isPanning = true;
} else if (event.touches.length === 2) {
let touch0 = event.touches.item(0)!
let touch1 = event.touches.item(1)!
_gesture.zoomId0 = touch0.identifier
_gesture.zoomId1 = touch1.identifier
let [zoomVec, _center] = getZoomVecAndCenter(touch0, touch1)
_gesture.zoomVecPrev = zoomVec
gesture.isZooming = true;
}
}
if (event.type === 'touchmove') {
let pan = getTouch(event, _gesture.panId)
let zoom0 = getTouch(event, _gesture.zoomId0)
let zoom1 = getTouch(event, _gesture.zoomId1)
if (pan) {
let point = touchClientPoint(pan)
gesture.isPanning = true
gesture.panDelta = geom.pointMinus(point, _gesture.panPointPrev)
_gesture.panPointPrev = point
}
if (zoom0 && zoom1) {
gesture.isZooming = true
let [zoomVec, zoomCenter] = getZoomVecAndCenter(zoom0, zoom1)
const prevDist = geom.vectorLength(_gesture.zoomVecPrev)
const currDist = geom.vectorLength(zoomVec)
gesture.zoomDelta = currDist - prevDist
gesture.zoomCenter = zoomCenter
_gesture.zoomVecPrev = zoomVec;
}
}
}