import React, { useEffect, useRef } from "react"; import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils"; import { CURSOR_TYPE } from "../../constants"; import { t } from "../../i18n"; import type { DOMAttributes } from "react"; import type { AppState, Device, InteractiveCanvasAppState } from "../../types"; import type { InteractiveCanvasRenderConfig, RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; import type { NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; import { renderInteractiveScene } from "../../renderer/interactiveScene"; type InteractiveCanvasProps = { containerRef: React.RefObject; canvas: HTMLCanvasElement | null; elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; allElementsMap: NonDeletedSceneElementsMap; sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; appState: InteractiveCanvasAppState; device: Device; renderInteractiveSceneCallback: ( data: RenderInteractiveSceneCallback, ) => void; handleCanvasRef: (canvas: HTMLCanvasElement | null) => void; onContextMenu: Exclude< DOMAttributes["onContextMenu"], undefined >; onPointerMove: Exclude< DOMAttributes["onPointerMove"], undefined >; onPointerUp: Exclude< DOMAttributes["onPointerUp"], undefined >; onPointerCancel: Exclude< DOMAttributes["onPointerCancel"], undefined >; onTouchMove: Exclude< DOMAttributes["onTouchMove"], undefined >; onPointerDown: Exclude< DOMAttributes["onPointerDown"], undefined >; onDoubleClick: Exclude< DOMAttributes["onDoubleClick"], undefined >; }; const InteractiveCanvas = (props: InteractiveCanvasProps) => { const isComponentMounted = useRef(false); useEffect(() => { if (!isComponentMounted.current) { isComponentMounted.current = true; return; } const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] = new Map(); const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = new Map(); const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] = new Map(); const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] = new Map(); const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] = new Map(); props.appState.collaborators.forEach((user, socketId) => { if (user.selectedElementIds) { for (const id of Object.keys(user.selectedElementIds)) { if (!remoteSelectedElementIds.has(id)) { remoteSelectedElementIds.set(id, []); } remoteSelectedElementIds.get(id)!.push(socketId); } } if (!user.pointer || user.pointer.renderCursor === false) { return; } if (user.username) { remotePointerUsernames.set(socketId, user.username); } if (user.userState) { remotePointerUserStates.set(socketId, user.userState); } remotePointerViewportCoords.set( socketId, sceneCoordsToViewportCoords( { sceneX: user.pointer.x, sceneY: user.pointer.y, }, props.appState, ), ); remotePointerButton.set(socketId, user.button); }); const selectionColor = (props.containerRef?.current && getComputedStyle(props.containerRef.current).getPropertyValue( "--color-selection", )) || "#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: false, }, device: props.device, callback: props.renderInteractiveSceneCallback, }, isRenderThrottlingEnabled(), ); }); return ( {t("labels.drawingCanvas")} ); }; const getRelevantAppStateProps = ( appState: AppState, ): InteractiveCanvasAppState => ({ zoom: appState.zoom, scrollX: appState.scrollX, scrollY: appState.scrollY, width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, editingGroupId: appState.editingGroupId, editingLinearElement: appState.editingLinearElement, selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, offsetLeft: appState.offsetLeft, offsetTop: appState.offsetTop, theme: appState.theme, pendingImageElementId: appState.pendingImageElementId, selectionElement: appState.selectionElement, selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, isBindingEnabled: appState.isBindingEnabled, suggestedBindings: appState.suggestedBindings, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, collaborators: appState.collaborators, // Necessary for collab. sessions activeEmbeddable: appState.activeEmbeddable, snapLines: appState.snapLines, zenModeEnabled: appState.zenModeEnabled, editingElement: appState.editingElement, }); const areEqual = ( prevProps: InteractiveCanvasProps, nextProps: InteractiveCanvasProps, ) => { // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation if ( prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.scale !== nextProps.scale || // we need to memoize on elementsMap because they may have renewed // even if sceneNonce didn't change (e.g. we filter elements out based // on appState) prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements || prevProps.selectedElements !== nextProps.selectedElements ) { return false; } // Comparing the interactive appState for changes in case of some edge cases return isShallowEqual( // asserting AppState because we're being passed the whole AppState // but resolve to only the InteractiveCanvas-relevant props getRelevantAppStateProps(prevProps.appState as AppState), getRelevantAppStateProps(nextProps.appState as AppState), ); }; export default React.memo(InteractiveCanvas, areEqual);