fix:Refactored and fixed highlight animation

This commit is contained in:
Mark Tolmacs
2025-09-17 18:37:56 +02:00
parent f0494ced4c
commit e0dd29aa36
3 changed files with 98 additions and 25 deletions

View File

@@ -5,8 +5,10 @@ import {
isShallowEqual, isShallowEqual,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type { import type {
ExcalidrawBindableElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -17,6 +19,7 @@ import { renderInteractiveScene } from "../../renderer/interactiveScene";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
InteractiveSceneRenderAnimationState,
RenderableElementsMap, RenderableElementsMap,
RenderInteractiveSceneCallback, RenderInteractiveSceneCallback,
} from "../../scene/types"; } from "../../scene/types";
@@ -78,6 +81,7 @@ type InteractiveCanvasProps = {
const InteractiveCanvas = (props: InteractiveCanvasProps) => { const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const lastSuggestedBinding = useRef<ExcalidrawBindableElement | null>(null);
useEffect(() => { useEffect(() => {
if (!isComponentMounted.current) { if (!isComponentMounted.current) {
@@ -156,9 +160,69 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
}, },
device: props.device, device: props.device,
callback: props.renderInteractiveSceneCallback, callback: props.renderInteractiveSceneCallback,
deltaTime: 0,
}, },
isRenderThrottlingEnabled(), isRenderThrottlingEnabled(),
); );
if (lastSuggestedBinding.current !== props.appState.suggestedBinding) {
lastSuggestedBinding.current = props.appState.suggestedBinding;
if (props.appState.suggestedBinding) {
AnimationController.cancel("bindingHighlight");
AnimationController.start<InteractiveSceneRenderAnimationState>(
"bindingHighlight",
({ deltaTime, state }) => {
if (
lastSuggestedBinding.current !== props.appState.suggestedBinding
) {
return null;
}
const nextAnimationState = 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,
// NOTE not memoized on so we don't rerender on cursor move
lastViewportPosition: props.app.lastViewportPosition,
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
animationState: state,
deltaTime,
},
false,
).animationState;
if (nextAnimationState) {
for (const key in nextAnimationState) {
if (
nextAnimationState[
key as keyof InteractiveSceneRenderAnimationState
] !== undefined
) {
return nextAnimationState;
}
}
return undefined;
}
},
);
}
}
}); });
return ( return (

View File

@@ -87,8 +87,6 @@ import {
strokeRectWithRotation, strokeRectWithRotation,
} from "./helpers"; } from "./helpers";
import { AnimationController } from "./animation";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig, InteractiveSceneRenderConfig,
@@ -198,7 +196,8 @@ const renderBindingHighlightForBindableElement = (
deltaTime: number, deltaTime: number,
state?: { runtime: number }, state?: { runtime: number },
) => { ) => {
const remainingTime = BIND_MODE_TIMEOUT - (state?.runtime ?? 0); const remainingTime =
BIND_MODE_TIMEOUT - (state?.runtime ?? BIND_MODE_TIMEOUT);
const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1); const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1);
const offset = element.strokeWidth / 2; const offset = element.strokeWidth / 2;
@@ -206,7 +205,6 @@ const renderBindingHighlightForBindableElement = (
case "magicframe": case "magicframe":
case "frame": case "frame":
context.save(); context.save();
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.translate( context.translate(
element.x + appState.scrollX, element.x + appState.scrollX,
@@ -243,7 +241,6 @@ const renderBindingHighlightForBindableElement = (
const cx = center[0] + appState.scrollX; const cx = center[0] + appState.scrollX;
const cy = center[1] + appState.scrollY; const cy = center[1] + appState.scrollY;
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(element.angle as Radians); context.rotate(element.angle as Radians);
context.translate(-cx, -cy); context.translate(-cx, -cy);
@@ -401,7 +398,7 @@ const renderBindingHighlightForBindableElement = (
} }
if ((state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { if ((state?.runtime ?? 0) > BIND_MODE_TIMEOUT) {
return null; return;
} }
return { return {
@@ -860,7 +857,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 };
} }
@@ -869,6 +873,7 @@ const _renderInteractiveScene = ({
canvas, canvas,
scale, scale,
); );
let nextAnimationState = animationState;
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
@@ -939,25 +944,22 @@ const _renderInteractiveScene = ({
} }
if (appState.isBindingEnabled && appState.suggestedBinding) { if (appState.isBindingEnabled && appState.suggestedBinding) {
AnimationController.start<{ runtime: number }>( nextAnimationState = {
"bindingHighlight", ...animationState,
({ deltaTime, state }) => { bindingHighlight: renderBindingHighlightForBindableElement(
if (!appState.suggestedBinding) { context,
return null; // Stop the animation appState.suggestedBinding,
} allElementsMap,
appState,
return renderBindingHighlightForBindableElement( deltaTime,
context, animationState?.bindingHighlight,
appState.suggestedBinding, ),
allElementsMap, };
appState,
deltaTime,
state,
);
},
);
} else { } else {
AnimationController.cancel("bindingHighlight"); nextAnimationState = {
...animationState,
bindingHighlight: undefined,
};
} }
if (appState.frameToHighlight) { if (appState.frameToHighlight) {
@@ -1329,6 +1331,7 @@ const _renderInteractiveScene = ({
scrollBars, scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0, atLeastOneVisibleElement: visibleElements.length > 0,
elementsMap, elementsMap,
animationState: nextAnimationState,
}; };
}; };

View File

@@ -89,6 +89,10 @@ export type StaticSceneRenderConfig = {
renderConfig: StaticCanvasRenderConfig; renderConfig: StaticCanvasRenderConfig;
}; };
export type InteractiveSceneRenderAnimationState = {
bindingHighlight: { runtime: number } | undefined;
};
export type InteractiveSceneRenderConfig = { export type InteractiveSceneRenderConfig = {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
@@ -100,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 = {