From 8aff076782e36938c8e068882291f1027c153f53 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Sun, 6 Feb 2022 17:45:37 +0100 Subject: [PATCH] feat: throttle `pointermove` events per framerate (#4727) --- src/components/App.tsx | 15 +++++++--- src/types.ts | 4 +-- src/utils.ts | 62 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 61cb09e9..d77876e9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -212,6 +212,7 @@ import { tupleToCoors, viewportCoordsToSceneCoords, withBatchedUpdates, + withBatchedUpdatesThrottled, } from "../utils"; import ContextMenu, { ContextMenuOption } from "./ContextMenu"; import LayerUI from "./LayerUI"; @@ -2921,7 +2922,7 @@ class App extends React.Component { setCursor(this.canvas, CURSOR_TYPE.GRABBING); let { clientX: lastX, clientY: lastY } = event; - const onPointerMove = withBatchedUpdates((event: PointerEvent) => { + const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => { const deltaX = lastX - event.clientX; const deltaY = lastY - event.clientY; lastX = event.clientX; @@ -2984,6 +2985,7 @@ class App extends React.Component { window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); window.removeEventListener(EVENT.POINTER_UP, teardown); window.removeEventListener(EVENT.BLUR, teardown); + onPointerMove.flush(); }), ); window.addEventListener(EVENT.BLUR, teardown); @@ -3086,7 +3088,7 @@ class App extends React.Component { isDraggingScrollBar = true; pointerDownState.lastCoords.x = event.clientX; pointerDownState.lastCoords.y = event.clientY; - const onPointerMove = withBatchedUpdates((event: PointerEvent) => { + const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; @@ -3105,6 +3107,7 @@ class App extends React.Component { this.savePointer(event.clientX, event.clientY, "up"); window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); window.removeEventListener(EVENT.POINTER_UP, onPointerUp); + onPointerMove.flush(); }); lastPointerUp = onPointerUp; @@ -3640,8 +3643,8 @@ class App extends React.Component { private onPointerMoveFromPointerDownHandler( pointerDownState: PointerDownState, - ): (event: PointerEvent) => void { - return withBatchedUpdates((event: PointerEvent) => { + ) { + return withBatchedUpdatesThrottled((event: PointerEvent) => { // We need to initialize dragOffsetXY only after we've updated // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // event handler should hopefully ensure we're already working with @@ -4062,6 +4065,10 @@ class App extends React.Component { lastPointerUp = null; + if (pointerDownState.eventListeners.onMove) { + pointerDownState.eventListeners.onMove.flush(); + } + window.removeEventListener( EVENT.POINTER_MOVE, pointerDownState.eventListeners.onMove!, diff --git a/src/types.ts b/src/types.ts index f395336f..0fa47e95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,7 @@ import { LinearElementEditor } from "./element/linearElementEditor"; import { SuggestedBinding } from "./element/binding"; import { ImportedDataState } from "./data/types"; import type App from "./components/App"; -import type { ResolvablePromise } from "./utils"; +import type { ResolvablePromise, throttleRAF } from "./utils"; import { Spreadsheet } from "./charts"; import { Language } from "./i18n"; import { ClipboardData } from "./clipboard"; @@ -367,7 +367,7 @@ export type PointerDownState = Readonly<{ // We need to have these in the state so that we can unsubscribe them eventListeners: { // It's defined on the initial pointer down event - onMove: null | ((event: PointerEvent) => void); + onMove: null | ReturnType; // It's defined on the initial pointer down event onUp: null | ((event: PointerEvent) => void); // It's defined on the initial pointer down event diff --git a/src/utils.ts b/src/utils.ts index 738872e4..5fcbb579 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -119,6 +119,53 @@ export const debounce = ( return ret; }; +// throttle callback to execute once per animation frame +export const throttleRAF = (fn: (...args: T) => void) => { + let handle: number | null = null; + let lastArgs: T | null = null; + let callback: ((...args: T) => void) | null = null; + const ret = (...args: T) => { + if (process.env.NODE_ENV === "test") { + fn(...args); + return; + } + lastArgs = args; + callback = fn; + if (handle === null) { + handle = window.requestAnimationFrame(() => { + handle = null; + lastArgs = null; + callback = null; + fn(...args); + }); + } + }; + ret.flush = () => { + if (handle !== null) { + cancelAnimationFrame(handle); + handle = null; + } + if (lastArgs) { + const _lastArgs = lastArgs; + const _callback = callback; + lastArgs = null; + callback = null; + if (_callback !== null) { + _callback(..._lastArgs); + } + } + }; + ret.cancel = () => { + lastArgs = null; + callback = null; + if (handle !== null) { + cancelAnimationFrame(handle); + handle = null; + } + }; + return ret; +}; + // https://github.com/lodash/lodash/blob/es/chunk.js export const chunk = ( array: readonly T[], @@ -356,6 +403,21 @@ export const withBatchedUpdates = < unstable_batchedUpdates(func as TFunction, event); }) as TFunction; +/** + * barches React state updates and throttles the calls to a single call per + * animation frame + */ +export const withBatchedUpdatesThrottled = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => { + // @ts-ignore + return throttleRAF>(((event) => { + unstable_batchedUpdates(func, event); + }) as TFunction); +}; + //https://stackoverflow.com/a/9462382/8418 export const nFormatter = (num: number, digits: number): string => { const si = [