mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-17 11:14:23 +01:00
feat:Highlight animations
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
50
packages/excalidraw/renderer/animation.ts
Normal file
50
packages/excalidraw/renderer/animation.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user