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 <InteractiveCanvas
app={this}
containerRef={this.excalidrawContainerRef} containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas} canvas={this.interactiveCanvas}
elementsMap={elementsMap} elementsMap={elementsMap}

View File

@@ -5,6 +5,15 @@ import {
isShallowEqual, isShallowEqual,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderAnimationState,
InteractiveSceneRenderConfig,
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "@excalidraw/excalidraw/scene/types";
import type { import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@@ -12,15 +21,14 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene"; import { renderInteractiveScene } from "../../renderer/interactiveScene";
import type { import type {
InteractiveCanvasRenderConfig, AppClassProperties,
RenderableElementsMap, AppState,
RenderInteractiveSceneCallback, Device,
} from "../../scene/types"; InteractiveCanvasAppState,
import type { AppState, Device, InteractiveCanvasAppState } from "../../types"; } from "../../types";
import type { DOMAttributes } from "react"; import type { DOMAttributes } from "react";
type InteractiveCanvasProps = { type InteractiveCanvasProps = {
@@ -36,6 +44,7 @@ type InteractiveCanvasProps = {
appState: InteractiveCanvasAppState; appState: InteractiveCanvasAppState;
renderScrollbars: boolean; renderScrollbars: boolean;
device: Device; device: Device;
app: AppClassProperties;
renderInteractiveSceneCallback: ( renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback, data: RenderInteractiveSceneCallback,
) => void; ) => void;
@@ -70,8 +79,11 @@ type InteractiveCanvasProps = {
>; >;
}; };
export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene";
const InteractiveCanvas = (props: InteractiveCanvasProps) => { const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const rendererParams = useRef(null as InteractiveSceneRenderConfig | null);
useEffect(() => { useEffect(() => {
if (!isComponentMounted.current) { if (!isComponentMounted.current) {
@@ -128,29 +140,61 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
)) || )) ||
"#6965db"; "#6965db";
renderInteractiveScene( rendererParams.current = {
{ app: props.app,
canvas: props.canvas, canvas: props.canvas,
elementsMap: props.elementsMap, elementsMap: props.elementsMap,
visibleElements: props.visibleElements, visibleElements: props.visibleElements,
selectedElements: props.selectedElements, selectedElements: props.selectedElements,
allElementsMap: props.allElementsMap, allElementsMap: props.allElementsMap,
scale: window.devicePixelRatio, scale: window.devicePixelRatio,
appState: props.appState, appState: props.appState,
renderConfig: { renderConfig: {
remotePointerViewportCoords, remotePointerViewportCoords,
remotePointerButton, remotePointerButton,
remoteSelectedElementIds, remoteSelectedElementIds,
remotePointerUsernames, remotePointerUsernames,
remotePointerUserStates, remotePointerUserStates,
selectionColor, selectionColor,
renderScrollbars: props.renderScrollbars, renderScrollbars: props.renderScrollbars,
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
}, },
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 ( 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, appState,
renderConfig, renderConfig,
device, device,
}: InteractiveSceneRenderConfig) => { animationState,
deltaTime,
}: InteractiveSceneRenderConfig): {
scrollBars?: ReturnType<typeof getScrollBars>;
atLeastOneVisibleElement: boolean;
elementsMap: RenderableElementsMap;
animationState?: typeof animationState;
} => {
if (canvas === null) { if (canvas === null) {
return { atLeastOneVisibleElement: false, elementsMap }; return { atLeastOneVisibleElement: false, elementsMap };
} }
@@ -745,6 +752,8 @@ const _renderInteractiveScene = ({
scale, scale,
); );
const nextAnimationState = animationState;
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -1191,6 +1200,7 @@ const _renderInteractiveScene = ({
scrollBars, scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0, atLeastOneVisibleElement: visibleElements.length > 0,
elementsMap, elementsMap,
animationState: nextAnimationState,
}; };
}; };

View File

@@ -88,7 +88,12 @@ export type StaticSceneRenderConfig = {
renderConfig: StaticCanvasRenderConfig; renderConfig: StaticCanvasRenderConfig;
}; };
export type InteractiveSceneRenderAnimationState = {
bindingHighlight: { runtime: number } | undefined;
};
export type InteractiveSceneRenderConfig = { export type InteractiveSceneRenderConfig = {
app: AppClassProperties;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
@@ -99,6 +104,8 @@ export type InteractiveSceneRenderConfig = {
renderConfig: InteractiveCanvasRenderConfig; renderConfig: InteractiveCanvasRenderConfig;
device: Device; device: Device;
callback: (data: RenderInteractiveSceneCallback) => void; callback: (data: RenderInteractiveSceneCallback) => void;
animationState?: InteractiveSceneRenderAnimationState;
deltaTime: number;
}; };
export type NewElementSceneRenderConfig = { export type NewElementSceneRenderConfig = {