diff --git a/src/components/App.tsx b/src/components/App.tsx index a16fcdf5e..74df2312a 100644 --- a/src/components/App.tsx +++ b/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 { 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 { 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 diff --git a/src/renderer/index.ts b/src/renderer/index.ts deleted file mode 100644 index b71429b1a..000000000 --- a/src/renderer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { renderScene } from "./renderScene"; diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 9b2d7cd44..9df354078 100644 --- a/src/renderer/renderScene.ts +++ b/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) => void, + ) => { + const ret = renderScene( + elements, + appState, + selectionElement, + scale, + rc, + canvas, + renderConfig, + ); + callback?.(ret); + }, + { trailing: true }, +); + const renderTransformHandles = ( context: CanvasRenderingContext2D, renderConfig: RenderConfig, diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 3e1c74479..b30d28d4b 100644 --- a/src/tests/contextmenu.test.tsx +++ b/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(); diff --git a/src/tests/dragCreate.test.tsx b/src/tests/dragCreate.test.tsx index b39601eb5..a5a514cb8 100644 --- a/src/tests/dragCreate.test.tsx +++ b/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(); diff --git a/src/tests/move.test.tsx b/src/tests/move.test.tsx index 312b3eec6..398fd6bdc 100644 --- a/src/tests/move.test.tsx +++ b/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(); diff --git a/src/tests/multiPointCreate.test.tsx b/src/tests/multiPointCreate.test.tsx index 7437a1d48..054ffd790 100644 --- a/src/tests/multiPointCreate.test.tsx +++ b/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(); diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 3fbd6513b..9967281aa 100644 --- a/src/tests/regressionTests.test.tsx +++ b/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); diff --git a/src/tests/resize.test.tsx b/src/tests/resize.test.tsx index 4e553825e..8bec75a4b 100644 --- a/src/tests/resize.test.tsx +++ b/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(); diff --git a/src/tests/selection.test.tsx b/src/tests/selection.test.tsx index e2bcd1dbe..bb9b68190 100644 --- a/src/tests/selection.test.tsx +++ b/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(); diff --git a/src/utils.ts b/src/utils.ts index 2e651ef8f..29e0eb38b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -126,47 +126,54 @@ export const debounce = ( }; // throttle callback to execute once per animation frame -export const throttleRAF = (fn: (...args: T) => void) => { - let handle: number | null = null; +export const throttleRAF = ( + 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;