diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index b315b32e0..efca26e4c 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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( - null, - ); - const animationInterval = useRef(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; diff --git a/packages/excalidraw/renderer/animation.ts b/packages/excalidraw/renderer/animation.ts new file mode 100644 index 000000000..278ab72ec --- /dev/null +++ b/packages/excalidraw/renderer/animation.ts @@ -0,0 +1,50 @@ +export type Animation = (params: { + deltaTime: number; + state?: R; +}) => R | null | undefined; + +export class AnimationController { + private static animations = new Map< + string, + { + animation: Animation; + lastTime: number; + state?: any; + } + >(); + + static start(key: string, animation: Animation) { + 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); + } +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index b17b2318c..269a174dd 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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 = ( 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) {