mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-20 07:49:59 +02:00
fix:Animated binding highlight
This commit is contained in:
@@ -8,18 +8,17 @@ import {
|
|||||||
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
|
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
|
||||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
InteractiveSceneRenderAnimationState,
|
InteractiveSceneRenderAnimationState,
|
||||||
|
InteractiveSceneRenderConfig,
|
||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
RenderInteractiveSceneCallback,
|
RenderInteractiveSceneCallback,
|
||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
@@ -79,9 +78,11 @@ type InteractiveCanvasProps = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene";
|
||||||
|
|
||||||
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const lastSuggestedBinding = useRef<ExcalidrawBindableElement | null>(null);
|
const rendererParams = useRef(null as InteractiveSceneRenderConfig | null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isComponentMounted.current) {
|
if (!isComponentMounted.current) {
|
||||||
@@ -138,90 +139,62 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
)) ||
|
)) ||
|
||||||
"#6965db";
|
"#6965db";
|
||||||
|
|
||||||
renderInteractiveScene(
|
rendererParams.current = {
|
||||||
{
|
app: props.app,
|
||||||
canvas: props.canvas,
|
canvas: props.canvas,
|
||||||
elementsMap: props.elementsMap,
|
elementsMap: props.elementsMap,
|
||||||
visibleElements: props.visibleElements,
|
visibleElements: props.visibleElements,
|
||||||
selectedElements: props.selectedElements,
|
selectedElements: props.selectedElements,
|
||||||
allElementsMap: props.allElementsMap,
|
allElementsMap: props.allElementsMap,
|
||||||
scale: window.devicePixelRatio,
|
scale: window.devicePixelRatio,
|
||||||
appState: props.appState,
|
appState: props.appState,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
remotePointerViewportCoords,
|
remotePointerViewportCoords,
|
||||||
remotePointerButton,
|
remotePointerButton,
|
||||||
remoteSelectedElementIds,
|
remoteSelectedElementIds,
|
||||||
remotePointerUsernames,
|
remotePointerUsernames,
|
||||||
remotePointerUserStates,
|
remotePointerUserStates,
|
||||||
selectionColor,
|
selectionColor,
|
||||||
renderScrollbars: props.renderScrollbars,
|
renderScrollbars: props.renderScrollbars,
|
||||||
// NOTE not memoized on so we don't rerender on cursor move
|
// NOTE not memoized on so we don't rerender on cursor move
|
||||||
lastViewportPosition: props.app.lastViewportPosition,
|
lastViewportPosition: props.app.lastViewportPosition,
|
||||||
},
|
|
||||||
device: props.device,
|
|
||||||
callback: props.renderInteractiveSceneCallback,
|
|
||||||
deltaTime: 0,
|
|
||||||
},
|
},
|
||||||
isRenderThrottlingEnabled(),
|
device: props.device,
|
||||||
);
|
callback: props.renderInteractiveSceneCallback,
|
||||||
|
animationState: {
|
||||||
|
bindingHighlight: undefined,
|
||||||
|
},
|
||||||
|
deltaTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
if (lastSuggestedBinding.current !== props.appState.suggestedBinding) {
|
if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) {
|
||||||
lastSuggestedBinding.current = props.appState.suggestedBinding;
|
AnimationController.start<InteractiveSceneRenderAnimationState>(
|
||||||
if (props.appState.suggestedBinding) {
|
INTERACTIVE_SCENE_ANIMATION_KEY,
|
||||||
AnimationController.cancel("bindingHighlight");
|
({ deltaTime, state }) => {
|
||||||
AnimationController.start<InteractiveSceneRenderAnimationState>(
|
const nextAnimationState = renderInteractiveScene(
|
||||||
"bindingHighlight",
|
{
|
||||||
({ deltaTime, state }) => {
|
...rendererParams.current!,
|
||||||
if (
|
deltaTime,
|
||||||
lastSuggestedBinding.current !== props.appState.suggestedBinding
|
animationState: state,
|
||||||
) {
|
},
|
||||||
return null;
|
false,
|
||||||
}
|
).animationState;
|
||||||
|
|
||||||
const nextAnimationState = renderInteractiveScene(
|
if (nextAnimationState) {
|
||||||
{
|
for (const key in nextAnimationState) {
|
||||||
canvas: props.canvas,
|
if (
|
||||||
elementsMap: props.elementsMap,
|
nextAnimationState[
|
||||||
visibleElements: props.visibleElements,
|
key as keyof InteractiveSceneRenderAnimationState
|
||||||
selectedElements: props.selectedElements,
|
] !== undefined
|
||||||
allElementsMap: props.allElementsMap,
|
) {
|
||||||
scale: window.devicePixelRatio,
|
return nextAnimationState;
|
||||||
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 undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,24 +1,44 @@
|
|||||||
|
import { isRenderThrottlingEnabled } from "../reactUtils";
|
||||||
|
|
||||||
export type Animation<R extends object> = (params: {
|
export type Animation<R extends object> = (params: {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
state?: R;
|
state?: R;
|
||||||
}) => R | null | undefined;
|
}) => R | null | undefined;
|
||||||
|
|
||||||
export class AnimationController {
|
export class AnimationController {
|
||||||
|
private static isRunning = false;
|
||||||
private static animations = new Map<
|
private static animations = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
animation: Animation<any>;
|
animation: Animation<any>;
|
||||||
lastTime: number;
|
lastTime: number;
|
||||||
state?: any;
|
state: any;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
static start<R extends object>(key: string, animation: Animation<R>) {
|
static start<R extends object>(key: string, animation: Animation<R>) {
|
||||||
AnimationController.animations.set(key, {
|
const initialState = animation({
|
||||||
animation,
|
deltaTime: 0,
|
||||||
lastTime: 0,
|
state: undefined,
|
||||||
});
|
});
|
||||||
requestAnimationFrame(AnimationController.tick);
|
|
||||||
|
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() {
|
private static tick() {
|
||||||
@@ -35,15 +55,29 @@ export class AnimationController {
|
|||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
AnimationController.animations.delete(key);
|
AnimationController.animations.delete(key);
|
||||||
|
|
||||||
|
if (AnimationController.animations.size === 0) {
|
||||||
|
AnimationController.isRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
animation.lastTime = now;
|
animation.lastTime = now;
|
||||||
animation.state = state;
|
animation.state = state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestAnimationFrame(AnimationController.tick);
|
|
||||||
|
if (isRenderThrottlingEnabled()) {
|
||||||
|
requestAnimationFrame(AnimationController.tick);
|
||||||
|
} else {
|
||||||
|
setTimeout(AnimationController.tick, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static running(key: string) {
|
||||||
|
return AnimationController.animations.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
static cancel(key: string) {
|
static cancel(key: string) {
|
||||||
AnimationController.animations.delete(key);
|
AnimationController.animations.delete(key);
|
||||||
}
|
}
|
||||||
|
@@ -76,7 +76,10 @@ import {
|
|||||||
SCROLLBAR_WIDTH,
|
SCROLLBAR_WIDTH,
|
||||||
} from "../scene/scrollbars";
|
} from "../scene/scrollbars";
|
||||||
|
|
||||||
import { type InteractiveCanvasAppState } from "../types";
|
import {
|
||||||
|
type AppClassProperties,
|
||||||
|
type InteractiveCanvasAppState,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
import { getClientColor, renderRemoteCursors } from "../clients";
|
import { getClientColor, renderRemoteCursors } from "../clients";
|
||||||
|
|
||||||
@@ -189,6 +192,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderBindingHighlightForBindableElement = (
|
const renderBindingHighlightForBindableElement = (
|
||||||
|
app: AppClassProperties,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
allElementsMap: NonDeletedSceneElementsMap,
|
allElementsMap: NonDeletedSceneElementsMap,
|
||||||
@@ -196,8 +200,12 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
deltaTime: number,
|
deltaTime: number,
|
||||||
state?: { runtime: number },
|
state?: { runtime: number },
|
||||||
) => {
|
) => {
|
||||||
|
const countdownInProgress =
|
||||||
|
app.state.bindMode === "orbit" && app.bindModeHandler !== null;
|
||||||
|
|
||||||
const remainingTime =
|
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 opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1);
|
||||||
const offset = element.strokeWidth / 2;
|
const offset = element.strokeWidth / 2;
|
||||||
|
|
||||||
@@ -364,43 +372,48 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw center snap area
|
// Middle indicator is not rendered after it expired
|
||||||
if ((state?.runtime ?? 0) < BIND_MODE_TIMEOUT) {
|
if (!countdownInProgress || (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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw center snap area
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runtime: (state?.runtime ?? 0) + deltaTime,
|
runtime: (state?.runtime ?? 0) + deltaTime,
|
||||||
};
|
};
|
||||||
@@ -848,6 +861,7 @@ const renderTextBox = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const _renderInteractiveScene = ({
|
const _renderInteractiveScene = ({
|
||||||
|
app,
|
||||||
canvas,
|
canvas,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
visibleElements,
|
visibleElements,
|
||||||
@@ -947,6 +961,7 @@ const _renderInteractiveScene = ({
|
|||||||
nextAnimationState = {
|
nextAnimationState = {
|
||||||
...animationState,
|
...animationState,
|
||||||
bindingHighlight: renderBindingHighlightForBindableElement(
|
bindingHighlight: renderBindingHighlightForBindableElement(
|
||||||
|
app,
|
||||||
context,
|
context,
|
||||||
appState.suggestedBinding,
|
appState.suggestedBinding,
|
||||||
allElementsMap,
|
allElementsMap,
|
||||||
|
@@ -94,6 +94,7 @@ export type InteractiveSceneRenderAnimationState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type InteractiveSceneRenderConfig = {
|
export type InteractiveSceneRenderConfig = {
|
||||||
|
app: AppClassProperties;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
Reference in New Issue
Block a user