mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: partition main canvas vertically (#6759)
Co-authored-by: Marcel Mraz <marcel.mraz@adacta-fintech.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
3ea07076ad
commit
a376bd9495
69 changed files with 4348 additions and 2970 deletions
|
@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
|
|||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
|
@ -270,7 +270,7 @@ export const ShapesSwitcher = ({
|
|||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
|
|
@ -6,14 +6,14 @@ import { render, queryByTestId } from "../tests/test-utils";
|
|||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
|
|||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { KEYS } from "../keys";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
|
@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
|
|||
},
|
||||
false,
|
||||
);
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(
|
||||
metaStuffRef.current.selectedElements[0],
|
||||
|
|
|
@ -34,7 +34,7 @@ const JSONExportModal = ({
|
|||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
|
@ -100,7 +100,7 @@ export const JSONExportDialog = ({
|
|||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
|
|
|
@ -57,7 +57,8 @@ interface LayerUIProps {
|
|||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
|
@ -117,6 +118,7 @@ const LayerUI = ({
|
|||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
|
@ -272,7 +274,7 @@ const LayerUI = ({
|
|||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
|
@ -413,7 +415,7 @@ const LayerUI = ({
|
|||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
|
@ -464,7 +466,7 @@ const LayerUI = ({
|
|||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
@ -507,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
|
||||
const {
|
||||
canvas: _pC,
|
||||
interactiveCanvas: _pIC,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nC,
|
||||
interactiveCanvas: _nIC,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
|
|
|
@ -36,7 +36,7 @@ type MobileMenuProps = {
|
|||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
|
@ -58,7 +58,7 @@ export const MobileMenu = ({
|
|||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
|
@ -85,7 +85,7 @@ export const MobileMenu = ({
|
|||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
|
@ -202,7 +202,7 @@ export const MobileMenu = ({
|
|||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
|
222
src/components/canvases/InteractiveCanvas.tsx
Normal file
222
src/components/canvases/InteractiveCanvas.tsx
Normal file
|
@ -0,0 +1,222 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
||||
import {
|
||||
isRenderThrottlingEnabled,
|
||||
isShallowEqual,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "../../utils";
|
||||
import { CURSOR_TYPE } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
import type { DOMAttributes } from "react";
|
||||
import type { AppState, InteractiveCanvasAppState } from "../../types";
|
||||
import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
RenderInteractiveSceneCallback,
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type InteractiveCanvasProps = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
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 cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
|
||||
props.appState.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
if (!(id in remoteSelectedElementIds)) {
|
||||
remoteSelectedElementIds[id] = [];
|
||||
}
|
||||
remoteSelectedElementIds[id].push(socketId);
|
||||
}
|
||||
}
|
||||
if (!user.pointer) {
|
||||
return;
|
||||
}
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
});
|
||||
|
||||
const selectionColor = getComputedStyle(
|
||||
document.querySelector(".excalidraw")!,
|
||||
).getPropertyValue("--color-selection");
|
||||
|
||||
renderInteractiveScene(
|
||||
{
|
||||
canvas: props.canvas,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
selectedElements: props.selectedElements,
|
||||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
selectionColor,
|
||||
renderScrollbars: false,
|
||||
},
|
||||
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,
|
||||
): Omit<InteractiveCanvasAppState, "editingElement"> => ({
|
||||
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,
|
||||
openSidebar: appState.openSidebar,
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
});
|
||||
|
||||
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.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
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);
|
113
src/components/canvases/StaticCanvas.tsx
Normal file
113
src/components/canvases/StaticCanvas.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { renderStaticScene } from "../../renderer/renderScene";
|
||||
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
|
||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type StaticCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
rc: RoughCanvas;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: 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");
|
||||
}
|
||||
|
||||
canvas.style.width = `${props.appState.width}px`;
|
||||
canvas.style.height = `${props.appState.height}px`;
|
||||
canvas.width = props.appState.width * props.scale;
|
||||
canvas.height = props.appState.height * props.scale;
|
||||
|
||||
renderStaticScene(
|
||||
{
|
||||
canvas,
|
||||
rc: props.rc,
|
||||
scale: props.scale,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
appState: props.appState,
|
||||
renderConfig: props.renderConfig,
|
||||
},
|
||||
isRenderThrottlingEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
|
||||
};
|
||||
|
||||
const getRelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): Omit<
|
||||
StaticCanvasAppState,
|
||||
| "editingElement"
|
||||
| "selectedElementIds"
|
||||
| "editingGroupId"
|
||||
| "frameToHighlight"
|
||||
> => ({
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
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,
|
||||
frameRendering: appState.frameRendering,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
prevProps: StaticCanvasProps,
|
||||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
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),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StaticCanvas, areEqual);
|
4
src/components/canvases/index.tsx
Normal file
4
src/components/canvases/index.tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
import InteractiveCanvas from "./InteractiveCanvas";
|
||||
import StaticCanvas from "./StaticCanvas";
|
||||
|
||||
export { InteractiveCanvas, StaticCanvas };
|
Loading…
Add table
Add a link
Reference in a new issue