summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/canvases
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/canvases
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/canvases')
-rw-r--r--packages/excalidraw/components/canvases/InteractiveCanvas.tsx240
-rw-r--r--packages/excalidraw/components/canvases/NewElementCanvas.tsx56
-rw-r--r--packages/excalidraw/components/canvases/StaticCanvas.tsx141
-rw-r--r--packages/excalidraw/components/canvases/index.tsx4
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 };