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 React, { useEffect, useRef } from "react";
import { import {
BIND_MODE_TIMEOUT,
CURSOR_TYPE, CURSOR_TYPE,
isShallowEqual, isShallowEqual,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
ExcalidrawBindableElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -81,54 +79,6 @@ type InteractiveCanvasProps = {
const InteractiveCanvas = (props: InteractiveCanvasProps) => { const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false); 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(() => { useEffect(() => {
if (!isComponentMounted.current) { if (!isComponentMounted.current) {
isComponentMounted.current = true; 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 { import {
arrayToMap, arrayToMap,
BIND_MODE_TIMEOUT,
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE, FRAME_STYLE,
invariant, invariant,
@@ -86,6 +87,8 @@ import {
strokeRectWithRotation, strokeRectWithRotation,
} from "./helpers"; } from "./helpers";
import { AnimationController } from "./animation";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig, InteractiveSceneRenderConfig,
@@ -190,17 +193,21 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
const renderBindingHighlightForBindableElement = ( const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: RenderableElementsMap,
allElementsMap: NonDeletedSceneElementsMap, allElementsMap: NonDeletedSceneElementsMap,
appState: InteractiveCanvasAppState, 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; const offset = element.strokeWidth / 2;
switch (element.type) { switch (element.type) {
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,
element.y + appState.scrollY, element.y + appState.scrollY,
@@ -208,7 +215,9 @@ const renderBindingHighlightForBindableElement = (
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = 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) { if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath(); context.beginPath();
@@ -234,6 +243,7 @@ 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);
@@ -247,7 +257,9 @@ const renderBindingHighlightForBindableElement = (
clamp(2.5, element.strokeWidth * 1.75, 4) / clamp(2.5, element.strokeWidth * 1.75, 4) /
Math.max(0.25, appState.zoom.value); Math.max(0.25, appState.zoom.value);
context.strokeStyle = 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) { switch (element.type) {
case "ellipse": case "ellipse":
@@ -354,6 +366,47 @@ const renderBindingHighlightForBindableElement = (
break; 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 = { type ElementSelectionBorder = {
@@ -886,14 +939,25 @@ const _renderInteractiveScene = ({
} }
if (appState.isBindingEnabled && appState.suggestedBinding) { if (appState.isBindingEnabled && appState.suggestedBinding) {
renderBindingHighlightForBindableElement( AnimationController.start<{ runtime: number }>(
"bindingHighlight",
({ deltaTime, state }) => {
if (!appState.suggestedBinding) {
return null; // Stop the animation
}
return renderBindingHighlightForBindableElement(
context, context,
appState.suggestedBinding, appState.suggestedBinding,
elementsMap,
allElementsMap, allElementsMap,
appState, appState,
renderConfig, deltaTime,
state,
); );
},
);
} else {
AnimationController.cancel("bindingHighlight");
} }
if (appState.frameToHighlight) { if (appState.frameToHighlight) {