From 2b4540225d66ec4f71d622f5405f2b7b0ef3735c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 10 Nov 2025 22:31:23 +0100 Subject: [PATCH] feat: Animation support (#10042) --- packages/excalidraw/components/App.tsx | 1 + .../components/canvases/InteractiveCanvas.tsx | 96 +++++++++++++------ packages/excalidraw/renderer/animation.ts | 84 ++++++++++++++++ .../excalidraw/renderer/interactiveScene.ts | 32 +++---- packages/excalidraw/scene/Renderer.ts | 2 - packages/excalidraw/scene/types.ts | 7 ++ 6 files changed, 173 insertions(+), 49 deletions(-) create mode 100644 packages/excalidraw/renderer/animation.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index e7a61a34fa..f70c40ff40 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1813,6 +1813,7 @@ class App extends React.Component { /> )} void; @@ -71,8 +79,11 @@ type InteractiveCanvasProps = { >; }; +export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene"; + const InteractiveCanvas = (props: InteractiveCanvasProps) => { const isComponentMounted = useRef(false); + const rendererParams = useRef(null as InteractiveSceneRenderConfig | null); useEffect(() => { if (!isComponentMounted.current) { @@ -129,29 +140,58 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { )) || "#6965db"; - renderInteractiveScene( - { - canvas: props.canvas, - elementsMap: props.elementsMap, - visibleElements: props.visibleElements, - selectedElements: props.selectedElements, - allElementsMap: props.allElementsMap, - scale: window.devicePixelRatio, - appState: props.appState, - renderConfig: { - remotePointerViewportCoords, - remotePointerButton, - remoteSelectedElementIds, - remotePointerUsernames, - remotePointerUserStates, - selectionColor, - renderScrollbars: props.renderScrollbars, - }, - editorInterface: props.editorInterface, - callback: props.renderInteractiveSceneCallback, + rendererParams.current = { + app: props.app, + canvas: props.canvas, + elementsMap: props.elementsMap, + visibleElements: props.visibleElements, + selectedElements: props.selectedElements, + allElementsMap: props.allElementsMap, + scale: window.devicePixelRatio, + appState: props.appState, + renderConfig: { + remotePointerViewportCoords, + remotePointerButton, + remoteSelectedElementIds, + remotePointerUsernames, + remotePointerUserStates, + selectionColor, + renderScrollbars: props.renderScrollbars, }, - isRenderThrottlingEnabled(), - ); + editorInterface: props.editorInterface, + callback: props.renderInteractiveSceneCallback, + animationState: { + bindingHighlight: undefined, + }, + deltaTime: 0, + }; + + if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) { + AnimationController.start( + INTERACTIVE_SCENE_ANIMATION_KEY, + ({ deltaTime, state }) => { + const nextAnimationState = renderInteractiveScene({ + ...rendererParams.current!, + deltaTime, + animationState: state, + }).animationState; + + if (nextAnimationState) { + for (const key in nextAnimationState) { + if ( + nextAnimationState[ + key as keyof InteractiveSceneRenderAnimationState + ] !== undefined + ) { + return nextAnimationState; + } + } + } + + return undefined; + }, + ); + } }); return ( diff --git a/packages/excalidraw/renderer/animation.ts b/packages/excalidraw/renderer/animation.ts new file mode 100644 index 0000000000..5c98ac7671 --- /dev/null +++ b/packages/excalidraw/renderer/animation.ts @@ -0,0 +1,84 @@ +import { isRenderThrottlingEnabled } from "../reactUtils"; + +export type Animation = (params: { + deltaTime: number; + state?: R; +}) => R | null | undefined; + +export class AnimationController { + private static isRunning = false; + private static animations = new Map< + string, + { + animation: Animation; + lastTime: number; + state: any; + } + >(); + + static start(key: string, animation: Animation) { + const initialState = animation({ + deltaTime: 0, + state: undefined, + }); + + if (initialState) { + AnimationController.animations.set(key, { + animation, + lastTime: 0, + state: initialState, + }); + + if (!AnimationController.isRunning) { + AnimationController.isRunning = true; + + if (isRenderThrottlingEnabled()) { + requestAnimationFrame(AnimationController.tick); + } else { + setTimeout(AnimationController.tick, 0); + } + } + } + } + + private static tick() { + if (AnimationController.animations.size > 0) { + for (const [key, animation] of AnimationController.animations) { + const now = performance.now(); + const deltaTime = + animation.lastTime === 0 ? 0 : now - animation.lastTime; + + const state = animation.animation({ + deltaTime, + state: animation.state, + }); + + if (!state) { + AnimationController.animations.delete(key); + + if (AnimationController.animations.size === 0) { + AnimationController.isRunning = false; + return; + } + } else { + animation.lastTime = now; + animation.state = state; + } + } + + if (isRenderThrottlingEnabled()) { + requestAnimationFrame(AnimationController.tick); + } else { + setTimeout(AnimationController.tick, 0); + } + } + } + + static running(key: string) { + return AnimationController.animations.has(key); + } + + static cancel(key: string) { + AnimationController.animations.delete(key); + } +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index b948aa8c38..72caa6892d 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -13,7 +13,6 @@ import { FRAME_STYLE, invariant, THEME, - throttleRAF, } from "@excalidraw/common"; import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element"; @@ -735,7 +734,14 @@ const _renderInteractiveScene = ({ appState, renderConfig, editorInterface, -}: InteractiveSceneRenderConfig) => { + animationState, + deltaTime, +}: InteractiveSceneRenderConfig): { + scrollBars?: ReturnType; + atLeastOneVisibleElement: boolean; + elementsMap: RenderableElementsMap; + animationState?: typeof animationState; +} => { if (canvas === null) { return { atLeastOneVisibleElement: false, elementsMap }; } @@ -745,6 +751,8 @@ const _renderInteractiveScene = ({ scale, ); + const nextAnimationState = animationState; + const context = bootstrapCanvas({ canvas, scale, @@ -1198,34 +1206,20 @@ const _renderInteractiveScene = ({ scrollBars, atLeastOneVisibleElement: visibleElements.length > 0, elementsMap, + animationState: nextAnimationState, }; }; -/** throttled to animation framerate */ -export const renderInteractiveSceneThrottled = throttleRAF( - (config: InteractiveSceneRenderConfig) => { - const ret = _renderInteractiveScene(config); - config.callback?.(ret); - }, - { trailing: true }, -); - /** * Interactive scene is the ui-canvas where we render bounding boxes, selections * and other ui stuff. */ export const renderInteractiveScene = < U extends typeof _renderInteractiveScene, - T extends boolean = false, >( renderConfig: InteractiveSceneRenderConfig, - throttle?: T, -): T extends true ? void : ReturnType => { - if (throttle) { - renderInteractiveSceneThrottled(renderConfig); - return undefined as T extends true ? void : ReturnType; - } +): ReturnType => { const ret = _renderInteractiveScene(renderConfig); renderConfig.callback(ret); - return ret as T extends true ? void : ReturnType; + return ret as ReturnType; }; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 6a016ccc1d..56ae3e5c58 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -10,7 +10,6 @@ import type { import type { Scene } from "@excalidraw/element"; -import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; import { renderStaticSceneThrottled } from "../renderer/staticScene"; import type { RenderableElementsMap } from "./types"; @@ -150,7 +149,6 @@ export class Renderer { // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // safe to break TS contract here (for upstream cases) public destroy() { - renderInteractiveSceneThrottled.cancel(); renderStaticSceneThrottled.cancel(); this.getRenderableElements.clear(); } diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 01fae229c8..16afa1e804 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -87,7 +87,12 @@ export type StaticSceneRenderConfig = { renderConfig: StaticCanvasRenderConfig; }; +export type InteractiveSceneRenderAnimationState = { + bindingHighlight: { runtime: number } | undefined; +}; + export type InteractiveSceneRenderConfig = { + app: AppClassProperties; canvas: HTMLCanvasElement | null; elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; @@ -98,6 +103,8 @@ export type InteractiveSceneRenderConfig = { renderConfig: InteractiveCanvasRenderConfig; editorInterface: EditorInterface; callback: (data: RenderInteractiveSceneCallback) => void; + animationState?: InteractiveSceneRenderAnimationState; + deltaTime: number; }; export type NewElementSceneRenderConfig = {