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:
Marcel Mraz 2023-08-12 22:56:59 +02:00 committed by GitHub
parent 3ea07076ad
commit a376bd9495
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 4348 additions and 2970 deletions

View file

@ -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,
});

View file

@ -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

View file

@ -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],

View file

@ -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(() => {

View file

@ -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(

View file

@ -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),
}));
}}
>

View 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);

View 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);

View file

@ -0,0 +1,4 @@
import InteractiveCanvas from "./InteractiveCanvas";
import StaticCanvas from "./StaticCanvas";
export { InteractiveCanvas, StaticCanvas };