diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index cae51e5b2..4a1c08aa7 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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(null); + const rendererParams = useRef(null as InteractiveSceneRenderConfig | null); useEffect(() => { if (!isComponentMounted.current) { @@ -138,90 +139,62 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { )) || "#6965db"; - 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, - deltaTime: 0, + rendererParams.current = { + app: props.app, + 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, }, - isRenderThrottlingEnabled(), - ); + device: props.device, + callback: props.renderInteractiveSceneCallback, + animationState: { + bindingHighlight: undefined, + }, + deltaTime: 0, + }; - if (lastSuggestedBinding.current !== props.appState.suggestedBinding) { - lastSuggestedBinding.current = props.appState.suggestedBinding; - if (props.appState.suggestedBinding) { - AnimationController.cancel("bindingHighlight"); - AnimationController.start( - "bindingHighlight", - ({ deltaTime, state }) => { - if ( - lastSuggestedBinding.current !== props.appState.suggestedBinding - ) { - return null; - } + if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) { + AnimationController.start( + INTERACTIVE_SCENE_ANIMATION_KEY, + ({ deltaTime, state }) => { + const nextAnimationState = renderInteractiveScene( + { + ...rendererParams.current!, + deltaTime, + animationState: state, + }, + false, + ).animationState; - 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, - deltaTime, - }, - false, - ).animationState; - - if (nextAnimationState) { - for (const key in nextAnimationState) { - if ( - nextAnimationState[ - key as keyof InteractiveSceneRenderAnimationState - ] !== undefined - ) { - return nextAnimationState; - } + if (nextAnimationState) { + for (const key in nextAnimationState) { + if ( + nextAnimationState[ + key as keyof InteractiveSceneRenderAnimationState + ] !== undefined + ) { + return nextAnimationState; } - - return undefined; } - }, - ); - } + } + + return undefined; + }, + ); } }); diff --git a/packages/excalidraw/renderer/animation.ts b/packages/excalidraw/renderer/animation.ts index 278ab72ec..5c98ac767 100644 --- a/packages/excalidraw/renderer/animation.ts +++ b/packages/excalidraw/renderer/animation.ts @@ -1,24 +1,44 @@ +import { isRenderThrottlingEnabled } from "../reactUtils"; + export type Animation = (params: { deltaTime: number; state?: R; }) => R | null | undefined; export class AnimationController { + private static isRunning = false; private static animations = new Map< string, { animation: Animation; lastTime: number; - state?: any; + state: any; } >(); static start(key: string, animation: Animation) { - AnimationController.animations.set(key, { - animation, - lastTime: 0, + const initialState = animation({ + deltaTime: 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() { @@ -35,15 +55,29 @@ 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; } } - 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) { AnimationController.animations.delete(key); } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index f42a91cb9..7f0716511 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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 = ( }; 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,43 +372,48 @@ 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) { + // Middle indicator is not rendered after it expired + if (!countdownInProgress || (state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { 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 { 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, diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index c36ee2dbe..3d4bec69e 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -94,6 +94,7 @@ export type InteractiveSceneRenderAnimationState = { }; export type InteractiveSceneRenderConfig = { + app: AppClassProperties; canvas: HTMLCanvasElement | null; elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[];