Przeglądaj źródła

feat: throttle scene rendering to animation framerate (#5422)

David Luzar 3 lat temu
rodzic
commit
b6bb74d08d

+ 18 - 16
src/components/App.tsx

@@ -166,7 +166,7 @@ import {
   isAndroid,
 } from "../keys";
 import { distance2d, getGridPoint, isPathALoop } from "../math";
-import { renderScene } from "../renderer";
+import { renderSceneThrottled } from "../renderer/renderScene";
 import { invalidateShapeForElement } from "../renderer/renderElement";
 import {
   calculateScrollCenter,
@@ -1193,7 +1193,8 @@ class App extends React.Component<AppProps, AppState> {
           element.id !== this.state.editingElement.id
         );
       });
-    const { atLeastOneVisibleElement, scrollBars } = renderScene(
+
+    renderSceneThrottled(
       renderingElements,
       this.state,
       this.state.selectionElement,
@@ -1216,24 +1217,25 @@ class App extends React.Component<AppProps, AppState> {
         isExporting: false,
         renderScrollbars: !this.device.isMobile,
       },
-    );
+      ({ atLeastOneVisibleElement, scrollBars }) => {
+        if (scrollBars) {
+          currentScrollBars = scrollBars;
+        }
+        const scrolledOutside =
+          // hide when editing text
+          isTextElement(this.state.editingElement)
+            ? false
+            : !atLeastOneVisibleElement && renderingElements.length > 0;
+        if (this.state.scrolledOutside !== scrolledOutside) {
+          this.setState({ scrolledOutside });
+        }
 
-    if (scrollBars) {
-      currentScrollBars = scrollBars;
-    }
-    const scrolledOutside =
-      // hide when editing text
-      isTextElement(this.state.editingElement)
-        ? false
-        : !atLeastOneVisibleElement && renderingElements.length > 0;
-    if (this.state.scrolledOutside !== scrolledOutside) {
-      this.setState({ scrolledOutside });
-    }
+        this.scheduleImageRefresh();
+      },
+    );
 
     this.history.record(this.state, this.scene.getElementsIncludingDeleted());
 
-    this.scheduleImageRefresh();
-
     // Do not notify consumers if we're still loading the scene. Among other
     // potential issues, this fixes a case where the tab isn't focused during
     // init, which would trigger onChange with empty elements, which would then

+ 0 - 1
src/renderer/index.ts

@@ -1 +0,0 @@
-export { renderScene } from "./renderScene";

+ 31 - 1
src/renderer/renderScene.ts

@@ -47,7 +47,11 @@ import {
   TransformHandles,
   TransformHandleType,
 } from "../element/transformHandles";
-import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils";
+import {
+  viewportCoordsToSceneCoords,
+  supportsEmoji,
+  throttleRAF,
+} from "../utils";
 import { UserIdleState } from "../types";
 import { THEME_FILTER } from "../constants";
 import {
@@ -568,6 +572,32 @@ export const renderScene = (
   return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
 };
 
+/** renderScene throttled to animation framerate */
+export const renderSceneThrottled = throttleRAF(
+  (
+    elements: readonly NonDeletedExcalidrawElement[],
+    appState: AppState,
+    selectionElement: NonDeletedExcalidrawElement | null,
+    scale: number,
+    rc: RoughCanvas,
+    canvas: HTMLCanvasElement,
+    renderConfig: RenderConfig,
+    callback?: (data: ReturnType<typeof renderScene>) => void,
+  ) => {
+    const ret = renderScene(
+      elements,
+      appState,
+      selectionElement,
+      scale,
+      rc,
+      canvas,
+      renderConfig,
+    );
+    callback?.(ret);
+  },
+  { trailing: true },
+);
+
 const renderTransformHandles = (
   context: CanvasRenderingContext2D,
   renderConfig: RenderConfig,

+ 1 - 1
src/tests/contextmenu.test.tsx

@@ -39,7 +39,7 @@ const mouse = new Pointer("mouse");
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 1 - 1
src/tests/dragCreate.test.tsx

@@ -14,7 +14,7 @@ import { reseed } from "../random";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 1 - 1
src/tests/move.test.tsx

@@ -16,7 +16,7 @@ import { KEYS } from "../keys";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 1 - 1
src/tests/multiPointCreate.test.tsx

@@ -14,7 +14,7 @@ import { reseed } from "../random";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 1 - 1
src/tests/regressionTests.test.tsx

@@ -20,7 +20,7 @@ import { t } from "../i18n";
 
 const { h } = window;
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 
 const mouse = new Pointer("mouse");
 const finger1 = new Pointer("touch", 1);

+ 1 - 1
src/tests/resize.test.tsx

@@ -18,7 +18,7 @@ const mouse = new Pointer("mouse");
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 1 - 1
src/tests/selection.test.tsx

@@ -16,7 +16,7 @@ import { Keyboard, Pointer } from "./helpers/ui";
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
-const renderScene = jest.spyOn(Renderer, "renderScene");
+const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
 beforeEach(() => {
   localStorage.clear();
   renderScene.mockClear();

+ 33 - 26
src/utils.ts

@@ -126,47 +126,54 @@ export const debounce = <T extends any[]>(
 };
 
 // throttle callback to execute once per animation frame
-export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
-  let handle: number | null = null;
+export const throttleRAF = <T extends any[]>(
+  fn: (...args: T) => void,
+  opts?: { trailing?: boolean },
+) => {
+  let timerId: number | null = null;
   let lastArgs: T | null = null;
-  let callback: ((...args: T) => void) | null = null;
+  let lastArgsTrailing: T | null = null;
+
+  const scheduleFunc = (args: T) => {
+    timerId = window.requestAnimationFrame(() => {
+      timerId = null;
+      fn(...args);
+      lastArgs = null;
+      if (lastArgsTrailing) {
+        lastArgs = lastArgsTrailing;
+        lastArgsTrailing = null;
+        scheduleFunc(lastArgs);
+      }
+    });
+  };
+
   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);
-      });
+    if (timerId === null) {
+      scheduleFunc(lastArgs);
+    } else if (opts?.trailing) {
+      lastArgsTrailing = args;
     }
   };
   ret.flush = () => {
-    if (handle !== null) {
-      cancelAnimationFrame(handle);
-      handle = null;
+    if (timerId !== null) {
+      cancelAnimationFrame(timerId);
+      timerId = null;
     }
     if (lastArgs) {
-      const _lastArgs = lastArgs;
-      const _callback = callback;
-      lastArgs = null;
-      callback = null;
-      if (_callback !== null) {
-        _callback(..._lastArgs);
-      }
+      fn(...(lastArgsTrailing || lastArgs));
+      lastArgs = lastArgsTrailing = null;
     }
   };
   ret.cancel = () => {
-    lastArgs = null;
-    callback = null;
-    if (handle !== null) {
-      cancelAnimationFrame(handle);
-      handle = null;
+    lastArgs = lastArgsTrailing = null;
+    if (timerId !== null) {
+      cancelAnimationFrame(timerId);
+      timerId = null;
     }
   };
   return ret;