feat: Animation and interactive scene support added

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-09-30 19:40:14 +02:00
parent 7c41944856
commit 058580f875
5 changed files with 175 additions and 29 deletions

View File

@@ -1805,6 +1805,7 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
<InteractiveCanvas
app={this}
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}

View File

@@ -5,6 +5,15 @@ import {
isShallowEqual,
sceneCoordsToViewportCoords,
} from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderAnimationState,
InteractiveSceneRenderConfig,
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "@excalidraw/excalidraw/scene/types";
import type {
NonDeletedExcalidrawElement,
@@ -12,15 +21,14 @@ import type {
} from "@excalidraw/element/types";
import { t } from "../../i18n";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
import type {
InteractiveCanvasRenderConfig,
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "../../scene/types";
import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
AppClassProperties,
AppState,
Device,
InteractiveCanvasAppState,
} from "../../types";
import type { DOMAttributes } from "react";
type InteractiveCanvasProps = {
@@ -36,6 +44,7 @@ type InteractiveCanvasProps = {
appState: InteractiveCanvasAppState;
renderScrollbars: boolean;
device: Device;
app: AppClassProperties;
renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback,
) => 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<InteractiveSceneRenderAnimationState>(
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 (

View File

@@ -0,0 +1,84 @@
import { isRenderThrottlingEnabled } from "../reactUtils";
export type Animation<R extends object> = (params: {
deltaTime: number;
state?: R;
}) => R | null | undefined;
export class AnimationController {
private static isRunning = false;
private static animations = new Map<
string,
{
animation: Animation<any>;
lastTime: number;
state: any;
}
>();
static start<R extends object>(key: string, animation: Animation<R>) {
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);
}
}

View File

@@ -735,7 +735,14 @@ const _renderInteractiveScene = ({
appState,
renderConfig,
device,
}: InteractiveSceneRenderConfig) => {
animationState,
deltaTime,
}: InteractiveSceneRenderConfig): {
scrollBars?: ReturnType<typeof getScrollBars>;
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,
};
};

View File

@@ -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 = {