From e0dd29aa3652f687900ffc37b1226b8cf21e851c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 17 Sep 2025 18:37:56 +0200 Subject: [PATCH] fix:Refactored and fixed highlight animation --- .../components/canvases/InteractiveCanvas.tsx | 64 +++++++++++++++++++ .../excalidraw/renderer/interactiveScene.ts | 53 +++++++-------- packages/excalidraw/scene/types.ts | 6 ++ 3 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index efca26e4c6..cae51e5b2d 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -5,8 +5,10 @@ import { isShallowEqual, sceneCoordsToViewportCoords, } from "@excalidraw/common"; +import { AnimationController } from "@excalidraw/excalidraw/renderer/animation"; import type { + ExcalidrawBindableElement, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "@excalidraw/element/types"; @@ -17,6 +19,7 @@ import { renderInteractiveScene } from "../../renderer/interactiveScene"; import type { InteractiveCanvasRenderConfig, + InteractiveSceneRenderAnimationState, RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; @@ -78,6 +81,7 @@ type InteractiveCanvasProps = { const InteractiveCanvas = (props: InteractiveCanvasProps) => { const isComponentMounted = useRef(false); + const lastSuggestedBinding = useRef(null); useEffect(() => { if (!isComponentMounted.current) { @@ -156,9 +160,69 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { }, device: props.device, callback: props.renderInteractiveSceneCallback, + deltaTime: 0, }, isRenderThrottlingEnabled(), ); + + if (lastSuggestedBinding.current !== props.appState.suggestedBinding) { + lastSuggestedBinding.current = props.appState.suggestedBinding; + if (props.appState.suggestedBinding) { + AnimationController.cancel("bindingHighlight"); + AnimationController.start( + "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 ( diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 269a174dd1..f42a91cb99 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -87,8 +87,6 @@ import { strokeRectWithRotation, } from "./helpers"; -import { AnimationController } from "./animation"; - import type { InteractiveCanvasRenderConfig, InteractiveSceneRenderConfig, @@ -198,7 +196,8 @@ const renderBindingHighlightForBindableElement = ( deltaTime: 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 offset = element.strokeWidth / 2; @@ -206,7 +205,6 @@ const renderBindingHighlightForBindableElement = ( case "magicframe": case "frame": context.save(); - context.clearRect(0, 0, context.canvas.width, context.canvas.height); context.translate( element.x + appState.scrollX, @@ -243,7 +241,6 @@ const renderBindingHighlightForBindableElement = ( const cx = center[0] + appState.scrollX; const cy = center[1] + appState.scrollY; - context.clearRect(0, 0, context.canvas.width, context.canvas.height); context.translate(cx, cy); context.rotate(element.angle as Radians); context.translate(-cx, -cy); @@ -401,7 +398,7 @@ const renderBindingHighlightForBindableElement = ( } if ((state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { - return null; + return; } return { @@ -860,7 +857,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 }; } @@ -869,6 +873,7 @@ const _renderInteractiveScene = ({ canvas, scale, ); + let nextAnimationState = animationState; const context = bootstrapCanvas({ canvas, @@ -939,25 +944,22 @@ const _renderInteractiveScene = ({ } if (appState.isBindingEnabled && appState.suggestedBinding) { - AnimationController.start<{ runtime: number }>( - "bindingHighlight", - ({ deltaTime, state }) => { - if (!appState.suggestedBinding) { - return null; // Stop the animation - } - - return renderBindingHighlightForBindableElement( - context, - appState.suggestedBinding, - allElementsMap, - appState, - deltaTime, - state, - ); - }, - ); + nextAnimationState = { + ...animationState, + bindingHighlight: renderBindingHighlightForBindableElement( + context, + appState.suggestedBinding, + allElementsMap, + appState, + deltaTime, + animationState?.bindingHighlight, + ), + }; } else { - AnimationController.cancel("bindingHighlight"); + nextAnimationState = { + ...animationState, + bindingHighlight: undefined, + }; } if (appState.frameToHighlight) { @@ -1329,6 +1331,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 35775f089e..c36ee2dbed 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -89,6 +89,10 @@ export type StaticSceneRenderConfig = { renderConfig: StaticCanvasRenderConfig; }; +export type InteractiveSceneRenderAnimationState = { + bindingHighlight: { runtime: number } | undefined; +}; + export type InteractiveSceneRenderConfig = { canvas: HTMLCanvasElement | null; elementsMap: RenderableElementsMap; @@ -100,6 +104,8 @@ export type InteractiveSceneRenderConfig = { renderConfig: InteractiveCanvasRenderConfig; device: Device; callback: (data: RenderInteractiveSceneCallback) => void; + animationState?: InteractiveSceneRenderAnimationState; + deltaTime: number; }; export type NewElementSceneRenderConfig = {