fix:Animated binding highlight

This commit is contained in:
Mark Tolmacs
2025-09-18 19:30:54 +02:00
parent b8d1b8a5bd
commit 97fa922060
4 changed files with 147 additions and 124 deletions

View File

@@ -8,18 +8,17 @@ import {
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type {
ExcalidrawBindableElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { t } from "../../i18n";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderAnimationState,
InteractiveSceneRenderConfig,
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "../../scene/types";
@@ -79,9 +78,11 @@ type InteractiveCanvasProps = {
>;
};
export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene";
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false);
const lastSuggestedBinding = useRef<ExcalidrawBindableElement | null>(null);
const rendererParams = useRef(null as InteractiveSceneRenderConfig | null);
useEffect(() => {
if (!isComponentMounted.current) {
@@ -138,8 +139,8 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
)) ||
"#6965db";
renderInteractiveScene(
{
rendererParams.current = {
app: props.app,
canvas: props.canvas,
elementsMap: props.elementsMap,
visibleElements: props.visibleElements,
@@ -160,48 +161,21 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
deltaTime: 0,
animationState: {
bindingHighlight: undefined,
},
isRenderThrottlingEnabled(),
);
deltaTime: 0,
};
if (lastSuggestedBinding.current !== props.appState.suggestedBinding) {
lastSuggestedBinding.current = props.appState.suggestedBinding;
if (props.appState.suggestedBinding) {
AnimationController.cancel("bindingHighlight");
if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) {
AnimationController.start<InteractiveSceneRenderAnimationState>(
"bindingHighlight",
INTERACTIVE_SCENE_ANIMATION_KEY,
({ 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,
...rendererParams.current!,
deltaTime,
animationState: state,
},
false,
).animationState;
@@ -216,13 +190,12 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
return nextAnimationState;
}
}
}
return undefined;
}
},
);
}
}
});
return (

View File

@@ -1,24 +1,44 @@
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;
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() {
@@ -35,14 +55,28 @@ export class AnimationController {
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

@@ -76,7 +76,10 @@ import {
SCROLLBAR_WIDTH,
} from "../scene/scrollbars";
import { type InteractiveCanvasAppState } from "../types";
import {
type AppClassProperties,
type InteractiveCanvasAppState,
} from "../types";
import { getClientColor, renderRemoteCursors } from "../clients";
@@ -189,6 +192,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
};
const renderBindingHighlightForBindableElement = (
app: AppClassProperties,
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
allElementsMap: NonDeletedSceneElementsMap,
@@ -196,8 +200,12 @@ const renderBindingHighlightForBindableElement = (
deltaTime: number,
state?: { runtime: number },
) => {
const countdownInProgress =
app.state.bindMode === "orbit" && app.bindModeHandler !== null;
const remainingTime =
BIND_MODE_TIMEOUT - (state?.runtime ?? BIND_MODE_TIMEOUT);
BIND_MODE_TIMEOUT -
(state?.runtime ?? (countdownInProgress ? 0 : BIND_MODE_TIMEOUT));
const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1);
const offset = element.strokeWidth / 2;
@@ -364,20 +372,20 @@ const renderBindingHighlightForBindableElement = (
break;
}
// Middle indicator is not rendered after it expired
if (!countdownInProgress || (state?.runtime ?? 0) > BIND_MODE_TIMEOUT) {
return;
}
// Draw center snap area
if ((state?.runtime ?? 0) < BIND_MODE_TIMEOUT) {
context.save();
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
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;
const radius = 0.5 * (Math.min(element.width, element.height) / 2) * opacity;
context.fillStyle = "rgba(0, 0, 0, 0.04)";
@@ -394,12 +402,17 @@ const renderBindingHighlightForBindableElement = (
context.stroke();
context.fill();
context.restore();
}
// Draw countdown
context.font = `${radius / 2}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(
`${Math.round(remainingTime)}`,
element.width / 2,
element.height / 2,
);
if ((state?.runtime ?? 0) > BIND_MODE_TIMEOUT) {
return;
}
context.restore();
return {
runtime: (state?.runtime ?? 0) + deltaTime,
@@ -848,6 +861,7 @@ const renderTextBox = (
};
const _renderInteractiveScene = ({
app,
canvas,
elementsMap,
visibleElements,
@@ -947,6 +961,7 @@ const _renderInteractiveScene = ({
nextAnimationState = {
...animationState,
bindingHighlight: renderBindingHighlightForBindableElement(
app,
context,
appState.suggestedBinding,
allElementsMap,

View File

@@ -94,6 +94,7 @@ export type InteractiveSceneRenderAnimationState = {
};
export type InteractiveSceneRenderConfig = {
app: AppClassProperties;
canvas: HTMLCanvasElement | null;
elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];