From 058580f8751afb17de28372bff4e12a76767e886 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 30 Sep 2025 19:40:14 +0200 Subject: [PATCH] feat: Animation and interactive scene support added Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 1 + .../components/canvases/InteractiveCanvas.tsx | 100 +++++++++++++----- packages/excalidraw/renderer/animation.ts | 84 +++++++++++++++ .../excalidraw/renderer/interactiveScene.ts | 12 ++- packages/excalidraw/scene/types.ts | 7 ++ 5 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 packages/excalidraw/renderer/animation.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index af888b1921..7b545f6134 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1805,6 +1805,7 @@ class App extends React.Component { /> )} void; @@ -70,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) { @@ -128,29 +140,61 @@ 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, - }, - device: props.device, - 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(), - ); + device: props.device, + 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, + }, + false, + ).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 e071d47aaf..aa1d08eb68 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -735,7 +735,14 @@ const _renderInteractiveScene = ({ appState, renderConfig, device, -}: InteractiveSceneRenderConfig) => { + animationState, + deltaTime, +}: InteractiveSceneRenderConfig): { + scrollBars?: ReturnType; + atLeastOneVisibleElement: boolean; + elementsMap: RenderableElementsMap; + animationState?: typeof animationState; +} => { if (canvas === null) { return { atLeastOneVisibleElement: false, elementsMap }; } @@ -745,6 +752,8 @@ const _renderInteractiveScene = ({ scale, ); + const nextAnimationState = animationState; + const context = bootstrapCanvas({ canvas, scale, @@ -1191,6 +1200,7 @@ const _renderInteractiveScene = ({ scrollBars, atLeastOneVisibleElement: visibleElements.length > 0, elementsMap, + animationState: nextAnimationState, }; }; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 12a5e27a8e..a7af87d39b 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -88,7 +88,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[]; @@ -99,6 +104,8 @@ export type InteractiveSceneRenderConfig = { renderConfig: InteractiveCanvasRenderConfig; device: Device; callback: (data: RenderInteractiveSceneCallback) => void; + animationState?: InteractiveSceneRenderAnimationState; + deltaTime: number; }; export type NewElementSceneRenderConfig = {