diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/canvases | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/canvases')
4 files changed, 441 insertions, 0 deletions
diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx new file mode 100644 index 0000000..03ae0f6 --- /dev/null +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -0,0 +1,240 @@ +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<HTMLDivElement | null>; + 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<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], + undefined + >; + onPointerMove: Exclude< + DOMAttributes<HTMLCanvasElement>["onPointerMove"], + undefined + >; + onPointerUp: Exclude< + DOMAttributes<HTMLCanvasElement>["onPointerUp"], + undefined + >; + onPointerCancel: Exclude< + DOMAttributes<HTMLCanvasElement>["onPointerCancel"], + undefined + >; + onTouchMove: Exclude< + DOMAttributes<HTMLCanvasElement>["onTouchMove"], + undefined + >; + onPointerDown: Exclude< + DOMAttributes<HTMLCanvasElement>["onPointerDown"], + undefined + >; + onDoubleClick: Exclude< + DOMAttributes<HTMLCanvasElement>["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 ( + <canvas + className="excalidraw__canvas interactive" + style={{ + width: props.appState.width, + height: props.appState.height, + cursor: props.appState.viewModeEnabled + ? CURSOR_TYPE.GRAB + : CURSOR_TYPE.AUTO, + }} + width={props.appState.width * props.scale} + height={props.appState.height * props.scale} + ref={props.handleCanvasRef} + onContextMenu={props.onContextMenu} + onPointerMove={props.onPointerMove} + onPointerUp={props.onPointerUp} + onPointerCancel={props.onPointerCancel} + onTouchMove={props.onTouchMove} + onPointerDown={props.onPointerDown} + onDoubleClick={ + props.appState.viewModeEnabled ? undefined : props.onDoubleClick + } + > + {t("labels.drawingCanvas")} + </canvas> + ); +}; + +const getRelevantAppStateProps = ( + appState: AppState, +): InteractiveCanvasAppState => ({ + zoom: appState.zoom, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + width: appState.width, + height: appState.height, + viewModeEnabled: appState.viewModeEnabled, + openDialog: appState.openDialog, + 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, + editingTextElement: appState.editingTextElement, + isCropping: appState.isCropping, + croppingElementId: appState.croppingElementId, + searchMatches: appState.searchMatches, +}); + +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); diff --git a/packages/excalidraw/components/canvases/NewElementCanvas.tsx b/packages/excalidraw/components/canvases/NewElementCanvas.tsx new file mode 100644 index 0000000..1815671 --- /dev/null +++ b/packages/excalidraw/components/canvases/NewElementCanvas.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import type { NonDeletedSceneElementsMap } from "../../element/types"; +import type { AppState } from "../../types"; +import type { + RenderableElementsMap, + StaticCanvasRenderConfig, +} from "../../scene/types"; +import type { RoughCanvas } from "roughjs/bin/canvas"; +import { renderNewElementScene } from "../../renderer/renderNewElementScene"; +import { isRenderThrottlingEnabled } from "../../reactUtils"; + +interface NewElementCanvasProps { + appState: AppState; + elementsMap: RenderableElementsMap; + allElementsMap: NonDeletedSceneElementsMap; + scale: number; + rc: RoughCanvas; + renderConfig: StaticCanvasRenderConfig; +} + +const NewElementCanvas = (props: NewElementCanvasProps) => { + const canvasRef = useRef<HTMLCanvasElement | null>(null); + useEffect(() => { + if (!canvasRef.current) { + return; + } + renderNewElementScene( + { + canvas: canvasRef.current, + scale: props.scale, + newElement: props.appState.newElement, + elementsMap: props.elementsMap, + allElementsMap: props.allElementsMap, + rc: props.rc, + renderConfig: props.renderConfig, + appState: props.appState, + }, + isRenderThrottlingEnabled(), + ); + }); + + return ( + <canvas + className="excalidraw__canvas" + style={{ + width: props.appState.width, + height: props.appState.height, + }} + width={props.appState.width * props.scale} + height={props.appState.height * props.scale} + ref={canvasRef} + /> + ); +}; + +export default NewElementCanvas; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx new file mode 100644 index 0000000..9185bdd --- /dev/null +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef } from "react"; +import type { RoughCanvas } from "roughjs/bin/canvas"; +import { renderStaticScene } from "../../renderer/staticScene"; +import { isShallowEqual } from "../../utils"; +import type { AppState, StaticCanvasAppState } from "../../types"; +import type { + RenderableElementsMap, + StaticCanvasRenderConfig, +} from "../../scene/types"; +import type { + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; +import { isRenderThrottlingEnabled } from "../../reactUtils"; + +type StaticCanvasProps = { + canvas: HTMLCanvasElement; + rc: RoughCanvas; + elementsMap: RenderableElementsMap; + allElementsMap: NonDeletedSceneElementsMap; + visibleElements: readonly NonDeletedExcalidrawElement[]; + sceneNonce: number | undefined; + selectionNonce: number | undefined; + scale: number; + appState: StaticCanvasAppState; + renderConfig: StaticCanvasRenderConfig; +}; + +const StaticCanvas = (props: StaticCanvasProps) => { + const wrapperRef = useRef<HTMLDivElement>(null); + const isComponentMounted = useRef(false); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) { + return; + } + + const canvas = props.canvas; + + if (!isComponentMounted.current) { + isComponentMounted.current = true; + + wrapper.replaceChildren(canvas); + canvas.classList.add("excalidraw__canvas", "static"); + } + + const widthString = `${props.appState.width}px`; + const heightString = `${props.appState.height}px`; + if (canvas.style.width !== widthString) { + canvas.style.width = widthString; + } + if (canvas.style.height !== heightString) { + canvas.style.height = heightString; + } + + const scaledWidth = props.appState.width * props.scale; + const scaledHeight = props.appState.height * props.scale; + // setting width/height resets the canvas even if dimensions not changed, + // which would cause flicker when we skip frame (due to throttling) + if (canvas.width !== scaledWidth) { + canvas.width = scaledWidth; + } + if (canvas.height !== scaledHeight) { + canvas.height = scaledHeight; + } + + renderStaticScene( + { + canvas, + rc: props.rc, + scale: props.scale, + elementsMap: props.elementsMap, + allElementsMap: props.allElementsMap, + visibleElements: props.visibleElements, + appState: props.appState, + renderConfig: props.renderConfig, + }, + isRenderThrottlingEnabled(), + ); + }); + + return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />; +}; + +const getRelevantAppStateProps = ( + appState: AppState, +): StaticCanvasAppState => ({ + zoom: appState.zoom, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + width: appState.width, + height: appState.height, + viewModeEnabled: appState.viewModeEnabled, + openDialog: appState.openDialog, + hoveredElementIds: appState.hoveredElementIds, + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + theme: appState.theme, + pendingImageElementId: appState.pendingImageElementId, + shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, + viewBackgroundColor: appState.viewBackgroundColor, + exportScale: appState.exportScale, + selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, + gridSize: appState.gridSize, + gridStep: appState.gridStep, + frameRendering: appState.frameRendering, + selectedElementIds: appState.selectedElementIds, + frameToHighlight: appState.frameToHighlight, + editingGroupId: appState.editingGroupId, + currentHoveredFontFamily: appState.currentHoveredFontFamily, + croppingElementId: appState.croppingElementId, +}); + +const areEqual = ( + prevProps: StaticCanvasProps, + nextProps: StaticCanvasProps, +) => { + if ( + 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 + ) { + return false; + } + + return ( + isShallowEqual( + // asserting AppState because we're being passed the whole AppState + // but resolve to only the StaticCanvas-relevant props + getRelevantAppStateProps(prevProps.appState as AppState), + getRelevantAppStateProps(nextProps.appState as AppState), + ) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig) + ); +}; + +export default React.memo(StaticCanvas, areEqual); diff --git a/packages/excalidraw/components/canvases/index.tsx b/packages/excalidraw/components/canvases/index.tsx new file mode 100644 index 0000000..b3956d7 --- /dev/null +++ b/packages/excalidraw/components/canvases/index.tsx @@ -0,0 +1,4 @@ +import InteractiveCanvas from "./InteractiveCanvas"; +import StaticCanvas from "./StaticCanvas"; + +export { InteractiveCanvas, StaticCanvas }; |
