feat:Highlight animations

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-09-17 10:46:36 +02:00
parent 345e3f68f1
commit f0494ced4c
3 changed files with 125 additions and 61 deletions

View File

@@ -1,14 +1,12 @@
import React, { useEffect, useRef } from "react";
import {
BIND_MODE_TIMEOUT,
CURSOR_TYPE,
isShallowEqual,
sceneCoordsToViewportCoords,
} from "@excalidraw/common";
import type {
ExcalidrawBindableElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
@@ -81,54 +79,6 @@ type InteractiveCanvasProps = {
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false);
// START - Binding highlight timeout animation
const currentSuggestedBinding = useRef<ExcalidrawBindableElement | null>(
null,
);
const animationInterval = useRef<NodeJS.Timeout | null>(null);
const [animationFrameCount, triggerAnnimationRerender] = React.useState(0);
if (props.app.state.suggestedBinding === null && animationInterval.current) {
clearInterval(animationInterval.current);
animationInterval.current = null;
triggerAnnimationRerender(0);
}
if (currentSuggestedBinding.current !== props.appState.suggestedBinding) {
if (animationInterval.current !== null) {
currentSuggestedBinding.current = props.appState.suggestedBinding;
clearInterval(animationInterval.current);
animationInterval.current = null;
triggerAnnimationRerender(0);
}
}
if (
animationFrameCount > BIND_MODE_TIMEOUT / 10 &&
animationInterval.current
) {
clearInterval(animationInterval.current);
animationInterval.current = null;
triggerAnnimationRerender(0);
} else if (
props.app.state.bindMode === "orbit" &&
props.app.bindModeHandler // Timeout is running
) {
if (animationInterval.current === null) {
animationInterval.current = setInterval(() => {
triggerAnnimationRerender((count) => count + 1);
}, 1000 / 60 /* 60 FPS animation */);
}
} else {
// eslint-disable-next-line no-lonely-if
if (animationInterval.current) {
clearInterval(animationInterval.current);
animationInterval.current = null;
triggerAnnimationRerender(0);
}
}
// END - Binding highlight timeout animation
useEffect(() => {
if (!isComponentMounted.current) {
isComponentMounted.current = true;

View File

@@ -0,0 +1,50 @@
export type Animation<R extends object> = (params: {
deltaTime: number;
state?: R;
}) => R | null | undefined;
export class AnimationController {
private static animations = new Map<
string,
{
animation: Animation<any>;
lastTime: number;
state?: any;
}
>();
static start<R extends object>(key: string, animation: Animation<R>) {
AnimationController.animations.set(key, {
animation,
lastTime: 0,
});
requestAnimationFrame(AnimationController.tick);
}
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);
} else {
animation.lastTime = now;
animation.state = state;
}
}
requestAnimationFrame(AnimationController.tick);
}
}
static cancel(key: string) {
AnimationController.animations.delete(key);
}
}

View File

@@ -10,6 +10,7 @@ import oc from "open-color";
import {
arrayToMap,
BIND_MODE_TIMEOUT,
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
invariant,
@@ -86,6 +87,8 @@ import {
strokeRectWithRotation,
} from "./helpers";
import { AnimationController } from "./animation";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig,
@@ -190,17 +193,21 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: RenderableElementsMap,
allElementsMap: NonDeletedSceneElementsMap,
appState: InteractiveCanvasAppState,
renderConfig: InteractiveCanvasRenderConfig,
deltaTime: number,
state?: { runtime: number },
) => {
const remainingTime = BIND_MODE_TIMEOUT - (state?.runtime ?? 0);
const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1);
const offset = element.strokeWidth / 2;
switch (element.type) {
case "magicframe":
case "frame":
context.save();
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
@@ -208,7 +215,9 @@ const renderBindingHighlightForBindableElement = (
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
appState.theme === THEME.DARK ? "#035da1" : "#6abdfc";
appState.theme === THEME.DARK
? `rgba(3, 93, 161, ${opacity})`
: `rgba(106, 189, 252, ${opacity})`;
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
@@ -234,6 +243,7 @@ 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);
@@ -247,7 +257,9 @@ const renderBindingHighlightForBindableElement = (
clamp(2.5, element.strokeWidth * 1.75, 4) /
Math.max(0.25, appState.zoom.value);
context.strokeStyle =
appState.theme === THEME.DARK ? "#035da1" : "#6abdfc";
appState.theme === THEME.DARK
? `rgba(3, 93, 161, ${0.5 + opacity / 2})`
: `rgba(106, 189, 252, ${0.5 + opacity / 2})`;
switch (element.type) {
case "ellipse":
@@ -354,6 +366,47 @@ const renderBindingHighlightForBindableElement = (
break;
}
// Draw center snap area
if ((state?.runtime ?? 0) < BIND_MODE_TIMEOUT) {
context.save();
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.strokeStyle = "rgba(0, 0, 0, 0.2)";
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]);
context.lineDashOffset = 0;
const radius =
0.5 * (Math.min(element.width, element.height) / 2) * opacity;
context.fillStyle = "rgba(0, 0, 0, 0.04)";
context.beginPath();
context.ellipse(
element.width / 2,
element.height / 2,
radius,
radius,
0,
0,
2 * Math.PI,
);
context.stroke();
context.fill();
context.restore();
}
if ((state?.runtime ?? 0) > BIND_MODE_TIMEOUT) {
return null;
}
return {
runtime: (state?.runtime ?? 0) + deltaTime,
};
};
type ElementSelectionBorder = {
@@ -886,14 +939,25 @@ const _renderInteractiveScene = ({
}
if (appState.isBindingEnabled && appState.suggestedBinding) {
renderBindingHighlightForBindableElement(
context,
appState.suggestedBinding,
elementsMap,
allElementsMap,
appState,
renderConfig,
AnimationController.start<{ runtime: number }>(
"bindingHighlight",
({ deltaTime, state }) => {
if (!appState.suggestedBinding) {
return null; // Stop the animation
}
return renderBindingHighlightForBindableElement(
context,
appState.suggestedBinding,
allElementsMap,
appState,
deltaTime,
state,
);
},
);
} else {
AnimationController.cancel("bindingHighlight");
}
if (appState.frameToHighlight) {