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

@ -423,7 +423,7 @@ export const actionToggleHandTool = register({
type: "hand",
lastActiveToolBeforeEraser: appState.activeTool,
});
setCursor(app.canvas, CURSOR_TYPE.GRAB);
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
}
return {

View file

@ -259,23 +259,25 @@ const duplicateElements = (
return {
elements: finalElements,
appState: selectGroupsForSelectedElements(
{
...appState,
selectedGroupIds: {},
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
},
};
};

View file

@ -19,7 +19,12 @@ import { AppState } from "../types";
export const actionFinalize = register({
name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
perform: (
elements,
appState,
_,
{ interactiveCanvas, focusContainer, scene },
) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@ -132,7 +137,7 @@ export const actionFinalize = register({
appState.activeTool.type !== "freedraw") ||
!multiPointElement
) {
resetCursor(canvas);
resetCursor(interactiveCanvas);
}
let activeTool: AppState["activeTool"];

View file

@ -108,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
});
setCursorForShape(app.canvas, {
setCursorForShape(app.interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});

View file

@ -149,11 +149,14 @@ export const actionGroup = register({
];
return {
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
appState: {
...appState,
...selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
},
elements: nextElements,
commitToHistory: true,
};
@ -212,7 +215,7 @@ export const actionUngroup = register({
});
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
appState,
getNonDeletedElements(nextElements),
appState,
null,
@ -243,7 +246,7 @@ export const actionUngroup = register({
);
return {
appState: updateAppState,
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
};

View file

@ -28,22 +28,24 @@ export const actionSelectAll = register({
}, {});
return {
appState: selectGroupsForSelectedElements(
{
...appState,
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
editingGroupId: null,
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: null,
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
},
commitToHistory: true,
};
},

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 };

View file

@ -3,8 +3,9 @@
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
--zIndex-interactiveCanvas: 2;
--zIndex-wysiwyg: 3;
--zIndex-layerUI: 4;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
@ -69,10 +70,19 @@
z-index: var(--zIndex-canvas);
&.interactive {
z-index: var(--zIndex-interactiveCanvas);
}
// Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
}
&__canvas-wrapper,
&__canvas.static {
pointer-events: none;
}
&__canvas {
position: absolute;
}

View file

@ -144,11 +144,7 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(
data.elements || [],
localAppState,
null,
)
? calculateScrollCenter(data.elements || [], localAppState)
: {}),
},
files: data.files,

View file

@ -25,10 +25,7 @@ import {
} from "react";
import clsx from "clsx";
import { KEYS } from "../keys";
import {
DEFAULT_LINK_SIZE,
invalidateShapeForElement,
} from "../renderer/renderElement";
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
import { rotate } from "../math";
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
import { Bounds } from "./bounds";
@ -42,6 +39,7 @@ import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks";
import { ShapeCache } from "../scene/ShapeCache";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@ -115,7 +113,7 @@ export const Hyperlink = ({
validated: false,
link,
});
invalidateShapeForElement(element);
ShapeCache.delete(element);
} else {
const { width, height } = element;
const embedLink = getEmbedLink(link);
@ -147,7 +145,7 @@ export const Hyperlink = ({
validated: true,
link,
});
invalidateShapeForElement(element);
ShapeCache.delete(element);
if (embeddableLinkCache.has(element.id)) {
embeddableLinkCache.delete(element.id);
}
@ -393,7 +391,7 @@ export const getContextMenuLabel = (
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
appState: UIAppState,
appState: Pick<UIAppState, "zoom">,
): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value;

View file

@ -474,6 +474,7 @@ const maybeCalculateNewGapWhenScaling = (
return { elementId, gap: newGap, focus };
};
// TODO: this is a bottleneck, optimise
export const getEligibleElementsForBinding = (
elements: NonDeleted<ExcalidrawElement>[],
): SuggestedBinding[] => {

View file

@ -10,10 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types";
import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
import { generateRoughOptions } from "../renderer/renderElement";
import {
isArrowElement,
isFreeDrawElement,
@ -24,6 +21,7 @@ import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
export type RectangleBox = {
x: number;
@ -621,7 +619,7 @@ const getLinearElementRotatedBounds = (
}
// first element is always the curve
const cachedShape = getShapeForElement(element)?.[0];
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>

View file

@ -39,7 +39,6 @@ import {
import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import {
hasBoundTextElement,
isEmbeddableElement,
@ -50,6 +49,7 @@ import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@ -489,7 +489,7 @@ const hitTestFreeDrawElement = (
B = element.points[i + 1];
}
const shape = getShapeForElement(element);
const shape = ShapeCache.get(element);
// for filled freedraw shapes, support
// selecting from inside
@ -502,7 +502,7 @@ const hitTestFreeDrawElement = (
const hitTestLinear = (args: HitTestArgs): boolean => {
const { element, threshold } = args;
if (!getShapeForElement(element)) {
if (!ShapeCache.get(element)) {
return false;
}
@ -520,7 +520,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
}
const [relX, relY] = GAPoint.toTuple(point);
const shape = getShapeForElement(element as ExcalidrawLinearElement);
const shape = ShapeCache.get(element as ExcalidrawLinearElement);
if (!shape) {
return false;

View file

@ -25,7 +25,12 @@ import {
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { Point, AppState, PointerCoords } from "../types";
import {
Point,
AppState,
PointerCoords,
InteractiveCanvasAppState,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
@ -39,9 +44,9 @@ import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
const editorMidPointsCache: {
version: number | null;
@ -398,7 +403,7 @@ export class LinearElementEditor {
static getEditorMidPoints = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
appState: InteractiveCanvasAppState,
): typeof editorMidPointsCache["points"] => {
const boundText = getBoundTextElement(element);
@ -422,7 +427,7 @@ export class LinearElementEditor {
static updateEditorMidPointsCache = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
appState: InteractiveCanvasAppState,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
@ -1418,7 +1423,7 @@ export class LinearElementEditor {
let y1;
let x2;
let y2;
if (element.points.length < 2 || !getShapeForElement(element)) {
if (element.points.length < 2 || !ShapeCache.get(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
@ -1437,7 +1442,7 @@ export class LinearElementEditor {
x2 = maxX + element.x;
y2 = maxY + element.y;
} else {
const shape = getShapeForElement(element)!;
const shape = ShapeCache.generateElementShape(element);
// first element is always the curve
const ops = getCurvePathOps(shape[0]);

View file

@ -1,11 +1,11 @@
import { ExcalidrawElement } from "./types";
import { invalidateShapeForElement } from "../renderer/renderElement";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@ -89,7 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
typeof fileId != "undefined" ||
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
ShapeCache.delete(element);
}
element.version++;

View file

@ -2,7 +2,9 @@ import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
import { AppState } from "../types";
import { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils";
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
@ -13,6 +15,42 @@ export const isInvisiblySmallElement = (
return element.width === 0 && element.height === 0;
};
export const isElementInViewport = (
element: ExcalidrawElement,
width: number,
height: number,
viewTransformations: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
) => {
const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft,
clientY: viewTransformations.offsetTop,
},
viewTransformations,
);
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + width,
clientY: viewTransformations.offsetTop + height,
},
viewTransformations,
);
return (
topLeftSceneCoords.x <= x2 &&
topLeftSceneCoords.y <= y2 &&
bottomRightSceneCoords.x >= x1 &&
bottomRightSceneCoords.y >= y1
);
};
/**
* Makes a perfect shape or diagonal/horizontal/vertical line
*/

View file

@ -759,7 +759,7 @@ describe("textWysiwyg", () => {
expect(h.elements[1].type).toBe("text");
API.setSelectedElements([h.elements[0], h.elements[1]]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,
@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
mouse.clickAt(10, 20);
mouse.down();
mouse.up();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,
@ -1154,7 +1154,7 @@ describe("textWysiwyg", () => {
h.elements = [container, text];
API.setSelectedElements([container, text]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,
@ -1168,7 +1168,7 @@ describe("textWysiwyg", () => {
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,
@ -1406,7 +1406,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([textElement]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,

View file

@ -116,7 +116,7 @@ export const textWysiwyg = ({
}) => void;
getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawTextElement;
canvas: HTMLCanvasElement | null;
canvas: HTMLCanvasElement;
excalidrawContainer: HTMLDivElement | null;
app: App;
}) => {

View file

@ -6,7 +6,7 @@ import {
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { AppState, Zoom } from "../types";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene";
@ -276,8 +276,8 @@ export const getTransformHandles = (
};
export const shouldShowBoundingBox = (
elements: NonDeletedExcalidrawElement[],
appState: AppState,
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
if (appState.editingLinearElement) {
return false;

View file

@ -16,14 +16,24 @@ export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
const timestamp = Date.now();
localStorage.setItem(type, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[type] = timestamp;
try {
localStorage.setItem(type, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[type] = timestamp;
} catch (error) {
console.error("error while updating browser state verison", error);
}
};
export const resetBrowserStateVersions = () => {
for (const key of Object.keys(LOCAL_STATE_VERSIONS) as BrowserStateTypes[]) {
const timestamp = -1;
localStorage.setItem(key, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[key] = timestamp;
try {
for (const key of Object.keys(
LOCAL_STATE_VERSIONS,
) as BrowserStateTypes[]) {
const timestamp = -1;
localStorage.setItem(key, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[key] = timestamp;
}
} catch (error) {
console.error("error while resetting browser state verison", error);
}
};

View file

@ -598,7 +598,7 @@ const ExcalidrawWrapper = () => {
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
canvas: HTMLCanvasElement | null,
canvas: HTMLCanvasElement,
) => {
if (exportedElements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas"));

View file

@ -16,7 +16,7 @@ import {
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState } from "./types";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
@ -469,9 +469,16 @@ export const addElementsToFrame = (
}
let nextElements = allElements.slice();
// Optimisation since findIndex on "newElements" is slow
const nextElementsIndex = nextElements.reduce(
(acc: Record<string, number | undefined>, element, index) => {
acc[element.id] = index;
return acc;
},
{},
);
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
@ -485,8 +492,8 @@ export const addElementsToFrame = (
false,
);
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
const frameIndex = nextElementsIndex[frame.id] ?? -1;
const elementIndex = nextElementsIndex[element.id] ?? -1;
if (elementIndex < frameBoundary) {
nextElements = [
@ -648,7 +655,7 @@ export const omitGroupsContainingFrames = (
*/
export const getTargetFrame = (
element: ExcalidrawElement,
appState: AppState,
appState: StaticCanvasAppState,
) => {
const _element = isTextElement(element)
? getContainerElement(element) || element
@ -660,11 +667,12 @@ export const getTargetFrame = (
: getContainingFrame(_element);
};
// TODO: this a huge bottleneck for large scenes, optimise
// given an element, return if the element is in some frame
export const isElementInFrame = (
element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
appState: StaticCanvasAppState,
) => {
const frame = getTargetFrame(element, appState);
const _element = isTextElement(element)

View file

@ -4,27 +4,40 @@ import {
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppClassProperties, AppState } from "./types";
import {
AppClassProperties,
AppState,
InteractiveCanvasAppState,
} from "./types";
import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = (
groupId: GroupId,
appState: AppState,
appState: InteractiveCanvasAppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
): AppState => {
const elementsInGroup = elements.filter((element) =>
element.groupIds.includes(groupId),
): Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "selectedElementIds" | "editingGroupId"
> => {
const elementsInGroup = elements.reduce(
(acc: Record<string, true>, element) => {
if (element.groupIds.includes(groupId)) {
acc[element.id] = true;
}
return acc;
},
{},
);
if (elementsInGroup.length < 2) {
if (Object.keys(elementsInGroup).length < 2) {
if (
appState.selectedGroupIds[groupId] ||
appState.editingGroupId === groupId
) {
return {
...appState,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
editingGroupId: null,
};
@ -33,104 +46,184 @@ export const selectGroup = (
}
return {
...appState,
editingGroupId: appState.editingGroupId,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
selectedElementIds: {
...appState.selectedElementIds,
...Object.fromEntries(
elementsInGroup.map((element) => [element.id, true]),
),
...elementsInGroup,
},
};
};
export const selectGroupsForSelectedElements = (function () {
type SelectGroupsReturnType = Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
>;
let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
null;
let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
let lastReturnValue: SelectGroupsReturnType | null = null;
const _selectGroups = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
elements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
): SelectGroupsReturnType => {
if (
lastReturnValue !== undefined &&
elements === lastElements &&
selectedElements === lastSelectedElements &&
appState.editingGroupId === lastReturnValue?.editingGroupId
) {
return lastReturnValue;
}
const selectedGroupIds: Record<GroupId, boolean> = {};
// Gather all the groups withing selected elements
for (const selectedElement of selectedElements) {
let groupIds = selectedElement.groupIds;
if (appState.editingGroupId) {
// handle the case where a group is nested within a group
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const lastSelectedGroup = groupIds[groupIds.length - 1];
selectedGroupIds[lastSelectedGroup] = true;
}
}
// Gather all the elements within selected groups
const groupElementsIndex: Record<GroupId, string[]> = {};
const selectedElementIdsInGroups = elements.reduce(
(acc: Record<string, true>, element) => {
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
if (groupId) {
acc[element.id] = true;
// Populate the index
if (!Array.isArray(groupElementsIndex[groupId])) {
groupElementsIndex[groupId] = [element.id];
} else {
groupElementsIndex[groupId].push(element.id);
}
}
return acc;
},
{},
);
for (const groupId of Object.keys(groupElementsIndex)) {
// If there is one element in the group, and the group is selected or it's being edited, it's not a group
if (groupElementsIndex[groupId].length < 2) {
if (selectedGroupIds[groupId]) {
selectedGroupIds[groupId] = false;
}
}
}
lastElements = elements;
lastSelectedElements = selectedElements;
lastReturnValue = {
editingGroupId: appState.editingGroupId,
selectedGroupIds,
selectedElementIds: {
...appState.selectedElementIds,
...selectedElementIdsInGroups,
},
};
return lastReturnValue;
};
/**
* When you select an element, you often want to actually select the whole group it's in, unless
* you're currently editing that group.
*/
const selectGroupsForSelectedElements = (
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
elements: readonly NonDeletedExcalidrawElement[],
prevAppState: InteractiveCanvasAppState,
/**
* supply null in cases where you don't have access to App instance and
* you don't care about optimizing selectElements retrieval
*/
app: AppClassProperties | null,
): Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
> => {
const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
if (!selectedElements.length) {
return {
selectedGroupIds: {},
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
appState.selectedElementIds,
prevAppState,
),
};
}
return _selectGroups(selectedElements, elements, appState);
};
selectGroupsForSelectedElements.clearCache = () => {
lastElements = null;
lastSelectedElements = null;
lastReturnValue = null;
};
return selectGroupsForSelectedElements;
})();
/**
* If the element's group is selected, don't render an individual
* selection border around it.
*/
export const isSelectedViaGroup = (
appState: AppState,
appState: InteractiveCanvasAppState,
element: ExcalidrawElement,
) => getSelectedGroupForElement(appState, element) != null;
export const getSelectedGroupForElement = (
appState: AppState,
appState: InteractiveCanvasAppState,
element: ExcalidrawElement,
) =>
element.groupIds
.filter((groupId) => groupId !== appState.editingGroupId)
.find((groupId) => appState.selectedGroupIds[groupId]);
export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
export const getSelectedGroupIds = (
appState: InteractiveCanvasAppState,
): GroupId[] =>
Object.entries(appState.selectedGroupIds)
.filter(([groupId, isSelected]) => isSelected)
.map(([groupId, isSelected]) => groupId);
/**
* When you select an element, you often want to actually select the whole group it's in, unless
* you're currently editing that group.
*/
export const selectGroupsForSelectedElements = (
appState: AppState,
elements: readonly NonDeletedExcalidrawElement[],
prevAppState: AppState,
/**
* supply null in cases where you don't have access to App instance and
* you don't care about optimizing selectElements retrieval
*/
app: AppClassProperties | null,
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
if (!selectedElements.length) {
return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
}
for (const selectedElement of selectedElements) {
let groupIds = selectedElement.groupIds;
if (appState.editingGroupId) {
// handle the case where a group is nested within a group
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1];
nextAppState = selectGroup(groupId, nextAppState, elements);
}
}
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState;
};
// given a list of elements, return the the actual group ids that should be selected
// or used to update the elements
export const selectGroupsFromGivenElements = (
elements: readonly NonDeleted<ExcalidrawElement>[],
appState: AppState,
appState: InteractiveCanvasAppState,
) => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
let nextAppState: InteractiveCanvasAppState = {
...appState,
selectedGroupIds: {},
};
for (const element of elements) {
let groupIds = element.groupIds;
@ -142,7 +235,10 @@ export const selectGroupsFromGivenElements = (
}
if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1];
nextAppState = selectGroup(groupId, nextAppState, elements);
nextAppState = {
...nextAppState,
...selectGroup(groupId, nextAppState, elements),
};
}
}

View file

@ -10,9 +10,9 @@ import {
ExcalidrawLinearElement,
NonDeleted,
} from "./element/types";
import { getShapeForElement } from "./renderer/renderElement";
import { getCurvePathOps } from "./element/bounds";
import { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache";
export const rotate = (
x1: number,
@ -303,7 +303,7 @@ export const getControlPointsForBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const shape = getShapeForElement(element as ExcalidrawLinearElement);
const shape = ShapeCache.generateElementShape(element);
if (!shape) {
return null;
}

View file

@ -26,7 +26,7 @@ import { Drawable, Options } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { RenderConfig } from "../scene/types";
import { StaticCanvasRenderConfig } from "../scene/types";
import {
distance,
getFontString,
@ -36,7 +36,13 @@ import {
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import { AppState, BinaryFiles, Zoom } from "../types";
import {
AppState,
StaticCanvasAppState,
BinaryFiles,
Zoom,
InteractiveCanvasAppState,
} from "../types";
import { getDefaultAppState } from "../appState";
import {
BOUND_TEXT_PADDING,
@ -61,6 +67,7 @@ import {
} from "../element/embeddable";
import { getContainingFrame } from "../frame";
import { normalizeLink, toValidURL } from "../data/url";
import { ShapeCache } from "../scene/ShapeCache";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@ -72,17 +79,18 @@ const defaultAppState = getDefaultAppState();
const isPendingImageElement = (
element: ExcalidrawElement,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
) =>
isInitializedImageElement(element) &&
!renderConfig.imageCache.has(element.fileId);
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
return (
renderConfig.theme === "dark" &&
appState.theme === "dark" &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
@ -99,9 +107,9 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
theme: RenderConfig["theme"];
theme: AppState["theme"];
scale: number;
zoomValue: RenderConfig["zoom"]["value"];
zoomValue: AppState["zoom"]["value"];
canvasOffsetX: number;
canvasOffsetY: number;
boundTextElementVersion: number | null;
@ -165,7 +173,8 @@ const cappedElementCanvasSize = (
const generateElementCanvas = (
element: NonDeletedExcalidrawElement,
zoom: Zoom,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@ -205,17 +214,17 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig)) {
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore();
return {
element,
canvas,
theme: renderConfig.theme,
theme: appState.theme,
scale,
zoomValue: zoom.value,
canvasOffsetX,
@ -262,11 +271,13 @@ const drawImagePlaceholder = (
size,
);
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@ -277,7 +288,7 @@ const drawElementOnCanvas = (
case "ellipse": {
context.lineJoin = "round";
context.lineCap = "round";
rc.draw(getShapeForElement(element)!);
rc.draw(ShapeCache.get(element)!);
break;
}
case "arrow":
@ -285,7 +296,7 @@ const drawElementOnCanvas = (
context.lineJoin = "round";
context.lineCap = "round";
getShapeForElement(element)!.forEach((shape) => {
ShapeCache.get(element)!.forEach((shape) => {
rc.draw(shape);
});
break;
@ -296,7 +307,7 @@ const drawElementOnCanvas = (
context.fillStyle = element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = getShapeForElement(element);
const fillShape = ShapeCache.get(element);
if (fillShape) {
rc.draw(fillShape);
@ -321,7 +332,7 @@ const drawElementOnCanvas = (
element.height,
);
} else {
drawImagePlaceholder(element, context, renderConfig.zoom.value);
drawImagePlaceholder(element, context, appState.zoom.value);
}
break;
}
@ -378,33 +389,6 @@ const elementWithCanvasCache = new WeakMap<
ExcalidrawElementWithCanvas
>();
const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
type ElementShape = Drawable | Drawable[] | null;
type ElementShapes = {
freedraw: Drawable | null;
arrow: Drawable[];
line: Drawable[];
text: null;
image: null;
};
export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
shapeCache.get(element) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: Drawable | null | undefined;
export const setShapeForElement = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => shapeCache.set(element, shape);
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
shapeCache.delete(element);
export const generateRoughOptions = (
element: ExcalidrawElement,
continuousPath = false,
@ -494,16 +478,22 @@ const modifyEmbeddableForRoughOptions = (
* @param element
* @param generator
*/
const generateElementShape = (
export const generateElementShape = (
element: NonDeletedExcalidrawElement,
generator: RoughGenerator,
isExporting: boolean = false,
) => {
let shape = isExporting ? undefined : shapeCache.get(element);
): Drawable | Drawable[] | null => {
const cachedShape = isExporting ? undefined : ShapeCache.get(element);
if (cachedShape) {
return cachedShape;
}
// `null` indicates no rc shape applicable for this element type
// (= do not generate anything)
if (shape === undefined) {
if (cachedShape === undefined) {
let shape: Drawable | Drawable[] | null = null;
elementWithCanvasCache.delete(element);
switch (element.type) {
@ -539,7 +529,7 @@ const generateElementShape = (
),
);
}
setShapeForElement(element, shape);
ShapeCache.set(element, shape);
break;
}
@ -589,7 +579,7 @@ const generateElementShape = (
generateRoughOptions(element),
);
}
setShapeForElement(element, shape);
ShapeCache.set(element, shape);
break;
}
@ -601,7 +591,7 @@ const generateElementShape = (
element.height,
generateRoughOptions(element),
);
setShapeForElement(element, shape);
ShapeCache.set(element, shape);
break;
case "line":
@ -726,7 +716,7 @@ const generateElementShape = (
}
}
setShapeForElement(element, shape);
ShapeCache.set(element, shape);
break;
}
@ -742,36 +732,39 @@ const generateElementShape = (
} else {
shape = null;
}
setShapeForElement(element, shape);
ShapeCache.set(element, shape);
break;
}
case "text":
case "image": {
// just to ensure we don't regenerate element.canvas on rerenders
setShapeForElement(element, null);
ShapeCache.set(element, null);
break;
}
}
return shape;
}
return null;
};
const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
const prevElementWithCanvas = elementWithCanvasCache.get(element);
const shouldRegenerateBecauseZoom =
prevElementWithCanvas &&
prevElementWithCanvas.zoomValue !== zoom.value &&
!renderConfig?.shouldCacheIgnoreZoom;
!appState?.shouldCacheIgnoreZoom;
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
if (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== renderConfig.theme ||
prevElementWithCanvas.theme !== appState.theme ||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
) {
@ -779,6 +772,7 @@ const generateElementWithCanvas = (
element,
zoom,
renderConfig,
appState,
);
elementWithCanvasCache.set(element, elementWithCanvas);
@ -790,9 +784,9 @@ const generateElementWithCanvas = (
const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
@ -807,8 +801,8 @@ const drawElementFromCanvas = (
y2 = Math.ceil(y2);
}
const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
@ -906,9 +900,9 @@ const drawElementFromCanvas = (
context.drawImage(
elementWithCanvas.canvas!,
(x1 + renderConfig.scrollX) * window.devicePixelRatio -
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + renderConfig.scrollY) * window.devicePixelRatio -
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
@ -926,8 +920,8 @@ const drawElementFromCanvas = (
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
(coords.x + appState.scrollX) * window.devicePixelRatio,
(coords.y + appState.scrollY) * window.devicePixelRatio,
getBoundTextMaxWidth(element) * window.devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
);
@ -938,40 +932,38 @@ const drawElementFromCanvas = (
// Clear the nested element we appended to the DOM
};
export const renderSelectionElement = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
context.save();
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
// render from 0.5px offset to get 1px wide line
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
// TODO can be be improved by offseting to the negative when user selects
// from right to left
const offset = 0.5 / appState.zoom.value;
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)";
context.strokeRect(offset, offset, element.width, element.height);
context.restore();
};
export const renderElement = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: AppState,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const generator = rc.generator;
switch (element.type) {
case "selection": {
// do not render selection when exporting
if (!renderConfig.isExporting) {
context.save();
context.translate(
element.x + renderConfig.scrollX,
element.y + renderConfig.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
// render from 0.5px offset to get 1px wide line
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
// TODO can be be improved by offseting to the negative when user selects
// from right to left
const offset = 0.5 / renderConfig.zoom.value;
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / renderConfig.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)";
context.strokeRect(offset, offset, element.width, element.height);
context.restore();
}
break;
}
case "frame": {
if (
!renderConfig.isExporting &&
@ -980,12 +972,12 @@ export const renderElement = (
) {
context.save();
context.translate(
element.x + renderConfig.scrollX,
element.y + renderConfig.scrollY,
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = 2 / renderConfig.zoom.value;
context.lineWidth = 2 / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) {
@ -995,7 +987,7 @@ export const renderElement = (
0,
element.width,
element.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
FRAME_STYLE.radius / appState.zoom.value,
);
context.stroke();
context.closePath();
@ -1012,22 +1004,28 @@ export const renderElement = (
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
const shiftX = (x2 - x1) / 2 - (element.x - x1);
const shiftY = (y2 - y1) / 2 - (element.y - y1);
context.save();
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore();
} else {
const elementWithCanvas = generateElementWithCanvas(
element,
renderConfig,
appState,
);
drawElementFromCanvas(
elementWithCanvas,
context,
renderConfig,
appState,
);
drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
}
break;
@ -1043,8 +1041,8 @@ export const renderElement = (
generateElementShape(element, generator, renderConfig.isExporting);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
@ -1062,7 +1060,7 @@ export const renderElement = (
context.save();
context.translate(cx, cy);
if (shouldResetImageFilter(element, renderConfig)) {
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = "none";
}
const boundTextElement = getBoundTextElement(element);
@ -1096,7 +1094,13 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY);
@ -1133,7 +1137,7 @@ export const renderElement = (
}
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
}
context.restore();
@ -1143,6 +1147,7 @@ export const renderElement = (
const elementWithCanvas = generateElementWithCanvas(
element,
renderConfig,
appState,
);
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
@ -1150,7 +1155,7 @@ export const renderElement = (
if (
// do not disable smoothing during zoom as blurry shapes look better
// on low resolution (while still zooming in) than sharp ones
!renderConfig?.shouldCacheIgnoreZoom &&
!appState?.shouldCacheIgnoreZoom &&
// angle is 0 -> always disable smoothing
(!element.angle ||
// or check if angle is a right angle in which case we can still
@ -1167,7 +1172,12 @@ export const renderElement = (
context.imageSmoothingEnabled = false;
}
drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
drawElementFromCanvas(
elementWithCanvas,
context,
renderConfig,
appState,
);
// reset
context.imageSmoothingEnabled = currentImageSmoothingStatus;
@ -1273,7 +1283,7 @@ export const renderElementToSvg = (
generateElementShape(element, generator);
const node = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element)!,
ShapeCache.get(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
@ -1303,7 +1313,7 @@ export const renderElementToSvg = (
generateElementShape(element, generator, true);
const node = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element)!,
ShapeCache.get(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
@ -1337,7 +1347,7 @@ export const renderElementToSvg = (
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element)!,
ShapeCache.get(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
@ -1450,7 +1460,7 @@ export const renderElementToSvg = (
}
group.setAttribute("stroke-linecap", "round");
getShapeForElement(element)!.forEach((shape) => {
ShapeCache.get(element)!.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
@ -1493,7 +1503,7 @@ export const renderElementToSvg = (
case "freedraw": {
generateElementShape(element, generator);
generateFreeDrawShape(element);
const shape = getShapeForElement(element);
const shape = ShapeCache.get(element);
const node = shape
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,9 @@ import { isTextElement, refreshTextDimensions } from "../element";
import { newElementWith } from "../element/mutateElement";
import { isBoundToContainer } from "../element/typeChecks";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { getFontString } from "../utils";
import type Scene from "./Scene";
import { ShapeCache } from "./ShapeCache";
export class Fonts {
private scene: Scene;
@ -54,7 +54,7 @@ export class Fonts {
this.scene.mapElements((element) => {
if (isTextElement(element) && !isBoundToContainer(element)) {
invalidateShapeForElement(element);
ShapeCache.delete(element);
didUpdate = true;
return newElementWith(element, {
...refreshTextDimensions(element),

131
src/scene/Renderer.ts Normal file
View file

@ -0,0 +1,131 @@
import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks";
import { NonDeletedExcalidrawElement } from "../element/types";
import { cancelRender } from "../renderer/renderScene";
import { AppState } from "../types";
import { memoize } from "../utils";
import Scene from "./Scene";
export class Renderer {
private scene: Scene;
constructor(scene: Scene) {
this.scene = scene;
}
public getRenderableElements = (() => {
const getVisibleCanvasElements = ({
elements,
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
}: {
elements: readonly NonDeletedExcalidrawElement[];
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] => {
return elements.filter((element) =>
isElementInViewport(element, width, height, {
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
}),
);
};
const getCanvasElements = ({
editingElement,
elements,
pendingImageElementId,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingElement: AppState["editingElement"];
pendingImageElementId: AppState["pendingImageElementId"];
}) => {
return elements.filter((element) => {
if (isImageElement(element)) {
if (
// => not placed on canvas yet (but in elements array)
pendingImageElementId === element.id
) {
return false;
}
}
// we don't want to render text element that's being currently edited
// (it's rendered on remote only)
return (
!editingElement ||
editingElement.type !== "text" ||
element.id !== editingElement.id
);
});
};
return memoize(
({
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
editingElement,
pendingImageElementId,
// unused but serves we cache on it to invalidate elements if they
// get mutated
versionNonce: _versionNonce,
}: {
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
editingElement: AppState["editingElement"];
pendingImageElementId: AppState["pendingImageElementId"];
versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
}) => {
const elements = this.scene.getNonDeletedElements();
const canvasElements = getCanvasElements({
elements,
editingElement,
pendingImageElementId,
});
const visibleElements = getVisibleCanvasElements({
elements: canvasElements,
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
});
return { canvasElements, visibleElements };
},
);
})();
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
// safe to break TS contract here (for upstream cases)
public destroy() {
cancelRender();
this.getRenderableElements.clear();
}
}

View file

@ -14,6 +14,7 @@ import { isFrameElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import { AppState } from "../types";
import { Assert, SameType } from "../utility-types";
import { randomInteger } from "../random";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -105,6 +106,7 @@ class Scene {
elements: null,
cache: new Map(),
};
private versionNonce: number | undefined;
getElementsIncludingDeleted() {
return this.elements;
@ -172,6 +174,10 @@ class Scene {
return (this.elementsMap.get(id) as T | undefined) || null;
}
getVersionNonce() {
return this.versionNonce;
}
getNonDeletedElement(
id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null {
@ -230,6 +236,8 @@ class Scene {
}
informMutation() {
this.versionNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) {
callback();
}

61
src/scene/ShapeCache.ts Normal file
View file

@ -0,0 +1,61 @@
import { Drawable } from "roughjs/bin/core";
import { RoughGenerator } from "roughjs/bin/generator";
import { ExcalidrawElement } from "../element/types";
import { generateElementShape } from "../renderer/renderElement";
type ElementShape = Drawable | Drawable[] | null;
type ElementShapes = {
freedraw: Drawable | null;
arrow: Drawable[];
line: Drawable[];
text: null;
image: null;
};
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: Drawable | null | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* return cached shape.
*/
public static generateElementShape = <T extends ExcalidrawElement>(
element: T,
) => {
const shape = generateElementShape(
element,
ShapeCache.rg,
/* so it prefers cache */ false,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

View file

@ -1,7 +1,7 @@
import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
@ -54,26 +54,23 @@ export const exportToCanvas = async (
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({
elements,
appState,
scale,
rc: rough.canvas(canvas),
renderStaticScene({
canvas,
renderConfig: {
rc: rough.canvas(canvas),
elements,
visibleElements: elements,
scale,
appState: {
...appState,
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
theme: appState.exportWithDarkMode ? "dark" : "light",
},
renderConfig: {
imageCache,
renderScrollbars: false,
renderSelection: false,
renderGrid: false,
isExporting: true,
},

View file

@ -11,11 +11,7 @@ import {
viewportCoordsToSceneCoords,
} from "../utils";
const isOutsideViewPort = (
appState: AppState,
canvas: HTMLCanvasElement | null,
cords: Array<number>,
) => {
const isOutsideViewPort = (appState: AppState, cords: Array<number>) => {
const [x1, y1, x2, y2] = cords;
const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
{ sceneX: x1, sceneY: y1 },
@ -49,7 +45,6 @@ export const centerScrollOn = ({
export const calculateScrollCenter = (
elements: readonly ExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
): { scrollX: number; scrollY: number } => {
elements = getVisibleElements(elements);
@ -61,7 +56,7 @@ export const calculateScrollCenter = (
}
let [x1, y1, x2, y2] = getCommonBounds(elements);
if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
if (isOutsideViewPort(appState, [x1, y1, x2, y2])) {
[x1, y1, x2, y2] = getClosestElementBounds(
elements,
viewportCoordsToSceneCoords(

View file

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element";
import { Zoom } from "../types";
import { InteractiveCanvasAppState } from "../types";
import { ScrollBars } from "./types";
import { getGlobalCSSVariable } from "../utils";
import { getLanguage } from "../i18n";
@ -13,15 +13,7 @@ export const getScrollBars = (
elements: readonly ExcalidrawElement[],
viewportWidth: number,
viewportHeight: number,
{
scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: Zoom;
},
appState: InteractiveCanvasAppState,
): ScrollBars => {
if (elements.length === 0) {
return {
@ -34,8 +26,8 @@ export const getScrollBars = (
getCommonBounds(elements);
// Apply zoom
const viewportWidthWithZoom = viewportWidth / zoom.value;
const viewportHeightWithZoom = viewportHeight / zoom.value;
const viewportWidthWithZoom = viewportWidth / appState.zoom.value;
const viewportHeightWithZoom = viewportHeight / appState.zoom.value;
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
@ -50,8 +42,10 @@ export const getScrollBars = (
const isRTL = getLanguage().rtl;
// The viewport is the rectangle currently visible for the user
const viewportMinX = -scrollX + viewportWidthDiff / 2 + safeArea.left;
const viewportMinY = -scrollY + viewportHeightDiff / 2 + safeArea.top;
const viewportMinX =
-appState.scrollX + viewportWidthDiff / 2 + safeArea.left;
const viewportMinY =
-appState.scrollY + viewportHeightDiff / 2 + safeArea.top;
const viewportMaxX = viewportMinX + viewportWidthWithZoom - safeArea.right;
const viewportMaxY = viewportMinY + viewportHeightWithZoom - safeArea.bottom;

View file

@ -3,7 +3,7 @@ import {
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types";
import { AppState, InteractiveCanvasAppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
@ -146,7 +146,7 @@ export const getCommonAttributeOfSelectedElements = <T>(
export const getSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
opts?: {
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;

View file

@ -1,33 +1,63 @@
import { ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { RoughCanvas } from "roughjs/bin/canvas";
import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
AppClassProperties,
InteractiveCanvasAppState,
StaticCanvasAppState,
} from "../types";
export type RenderConfig = {
// AppState values
// ---------------------------------------------------------------------------
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
/** null indicates transparent bg */
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
zoom: AppState["zoom"];
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
theme: AppState["theme"];
// collab-related state
// ---------------------------------------------------------------------------
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string };
export type StaticCanvasRenderConfig = {
// extra options passed to the renderer
// ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"];
renderScrollbars?: boolean;
renderSelection?: boolean;
renderGrid?: boolean;
renderGrid: boolean;
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters), and we disable render optimizations for best output */
CSS filters), and we disable render optimizations for best output */
isExporting: boolean;
};
export type InteractiveCanvasRenderConfig = {
// collab-related state
// ---------------------------------------------------------------------------
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
remotePointerUserStates: { [id: string]: string };
remotePointerUsernames: { [id: string]: string };
remotePointerButton?: { [id: string]: string | undefined };
selectionColor?: string;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
renderScrollbars?: boolean;
};
export type RenderInteractiveSceneCallback = {
atLeastOneVisibleElement: boolean;
elements: readonly NonDeletedExcalidrawElement[];
scrollBars?: ScrollBars;
};
export type StaticSceneRenderConfig = {
canvas: HTMLCanvasElement;
rc: RoughCanvas;
elements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly NonDeletedExcalidrawElement[];
scale: number;
appState: StaticCanvasAppState;
renderConfig: StaticCanvasRenderConfig;
};
export type InteractiveSceneRenderConfig = {
canvas: HTMLCanvasElement | null;
elements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
scale: number;
appState: InteractiveCanvasAppState;
renderConfig: InteractiveCanvasRenderConfig;
callback: (data: RenderInteractiveSceneCallback) => void;
};
export type SceneScroll = {

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -42,7 +42,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"type": "arrow",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@ -69,14 +69,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,
@ -103,14 +103,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,
@ -148,7 +148,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -157,7 +157,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"type": "line",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@ -184,14 +184,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": {
"type": 3,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 30,
"y": 20,

View file

@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>

View file

@ -18,14 +18,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
"roundness": {
"type": 3,
},
"seed": 401146281,
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"versionNonce": 238820263,
"width": 30,
"x": 30,
"y": 20,
@ -50,14 +50,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
"roundness": {
"type": 3,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1150084233,
"versionNonce": 1604849351,
"width": 30,
"x": -10,
"y": 60,
@ -82,14 +82,14 @@ exports[`move element > rectangle 1`] = `
"roundness": {
"type": 3,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 453191,
"versionNonce": 1150084233,
"width": 30,
"x": 0,
"y": 40,
@ -119,14 +119,14 @@ exports[`move element > rectangles with binding arrow 1`] = `
"roundness": {
"type": 3,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1014066025,
"versionNonce": 81784553,
"width": 100,
"x": 0,
"y": 0,
@ -156,14 +156,14 @@ exports[`move element > rectangles with binding arrow 2`] = `
"roundness": {
"type": 3,
},
"seed": 449462985,
"seed": 2019559783,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 6,
"versionNonce": 1723083209,
"versionNonce": 927333447,
"width": 300,
"x": 201,
"y": 2,
@ -205,7 +205,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
"roundness": {
"type": 2,
},
"seed": 401146281,
"seed": 238820263,
"startArrowhead": null,
"startBinding": {
"elementId": "id0",
@ -218,7 +218,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
"type": "line",
"updated": 1,
"version": 11,
"versionNonce": 1006504105,
"versionNonce": 1051383431,
"width": 81,
"x": 110,
"y": 49.981789081137734,

View file

@ -38,7 +38,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -47,7 +47,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"type": "arrow",
"updated": 1,
"version": 7,
"versionNonce": 1150084233,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,
@ -92,7 +92,7 @@ exports[`multi point mode in linear elements > line 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -101,7 +101,7 @@ exports[`multi point mode in linear elements > line 1`] = `
"type": "line",
"updated": 1,
"version": 7,
"versionNonce": 1150084233,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,

File diff suppressed because it is too large Load diff

View file

@ -31,7 +31,7 @@ exports[`select single element on the scene > arrow 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -40,7 +40,7 @@ exports[`select single element on the scene > arrow 1`] = `
"type": "arrow",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@ -78,7 +78,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -87,7 +87,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
"type": "line",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@ -112,14 +112,14 @@ exports[`select single element on the scene > diamond 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
@ -144,14 +144,14 @@ exports[`select single element on the scene > ellipse 1`] = `
"roundness": {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
@ -176,14 +176,14 @@ exports[`select single element on the scene > rectangle 1`] = `
"roundness": {
"type": 3,
},
"seed": 337897,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 1278240551,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,

View file

@ -24,7 +24,7 @@ import { LibraryItem } from "../types";
import { vi } from "vitest";
const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot(
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
@ -40,10 +40,10 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -52,7 +52,7 @@ const { h } = window;
describe("contextMenu element", () => {
beforeEach(async () => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
@ -75,7 +75,7 @@ describe("contextMenu element", () => {
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -105,7 +105,7 @@ describe("contextMenu element", () => {
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -159,7 +159,7 @@ describe("contextMenu element", () => {
API.setSelectedElements([rect1]);
// lower z-index
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 100,
clientY: 100,
@ -169,7 +169,7 @@ describe("contextMenu element", () => {
// higher z-index
API.setSelectedElements([rect2]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 100,
clientY: 100,
@ -193,7 +193,7 @@ describe("contextMenu element", () => {
mouse.click(20, 0);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -246,7 +246,7 @@ describe("contextMenu element", () => {
Keyboard.keyPress(KEYS.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -285,7 +285,7 @@ describe("contextMenu element", () => {
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -333,7 +333,7 @@ describe("contextMenu element", () => {
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 40,
clientY: 40,
@ -346,7 +346,7 @@ describe("contextMenu element", () => {
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 10,
clientY: 10,
@ -370,7 +370,7 @@ describe("contextMenu element", () => {
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -386,7 +386,7 @@ describe("contextMenu element", () => {
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -407,7 +407,7 @@ describe("contextMenu element", () => {
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -430,7 +430,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 40,
clientY: 40,
@ -452,7 +452,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 10,
clientY: 10,
@ -474,7 +474,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 40,
clientY: 40,
@ -495,7 +495,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 10,
clientY: 10,
@ -520,7 +520,7 @@ describe("contextMenu element", () => {
mouse.click(10, 10);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -550,7 +550,7 @@ describe("contextMenu element", () => {
Keyboard.keyPress(KEYS.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,

View file

@ -15,10 +15,13 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -32,7 +35,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -43,7 +46,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -63,7 +67,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("ellipse");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -74,7 +78,9 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -94,7 +100,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -105,7 +111,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -125,7 +132,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("arrow");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -136,7 +143,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -160,7 +168,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("line");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -171,7 +179,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -203,7 +212,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -211,7 +220,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -222,7 +232,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("ellipse");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -230,7 +240,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -241,7 +252,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -249,7 +260,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -260,7 +272,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("arrow");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -273,7 +285,8 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -284,7 +297,7 @@ describe("Test dragCreate", () => {
const tool = getByToolName("line");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
@ -297,7 +310,8 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});

View file

@ -279,7 +279,7 @@ export class API {
};
static drop = async (blob: Blob) => {
const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
@ -306,6 +306,6 @@ export class API {
},
},
});
fireEvent(GlobalTestState.canvas, fileDropEvent);
fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
};
}

View file

@ -107,7 +107,7 @@ export class Pointer {
restorePosition(x = 0, y = 0) {
this.clientX = x;
this.clientY = y;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
}
private getEvent() {
@ -129,18 +129,18 @@ export class Pointer {
if (dx !== 0 || dy !== 0) {
this.clientX += dx;
this.clientY += dy;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
}
}
down(dx = 0, dy = 0) {
this.move(dx, dy);
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
}
up(dx = 0, dy = 0) {
this.move(dx, dy);
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
}
click(dx = 0, dy = 0) {
@ -150,7 +150,7 @@ export class Pointer {
doubleClick(dx = 0, dy = 0) {
this.move(dx, dy);
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
}
// absolute coords
@ -159,19 +159,19 @@ export class Pointer {
moveTo(x: number = this.clientX, y: number = this.clientY) {
this.clientX = x;
this.clientY = y;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
}
downAt(x = this.clientX, y = this.clientY) {
this.clientX = x;
this.clientY = y;
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
}
upAt(x = this.clientX, y = this.clientY) {
this.clientX = x;
this.clientY = y;
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
}
clickAt(x: number, y: number) {
@ -180,7 +180,7 @@ export class Pointer {
}
rightClickAt(x: number, y: number) {
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: x,
clientY: y,
@ -189,7 +189,7 @@ export class Pointer {
doubleClickAt(x: number, y: number) {
this.moveTo(x, y);
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
}
// ---------------------------------------------------------------------------
@ -327,6 +327,13 @@ export class UI {
});
}
static ungroup(elements: ExcalidrawElement[]) {
mouse.select(elements);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.G);
});
}
static queryContextMenu = () => {
return GlobalTestState.renderResult.container.querySelector(
".context-menu",

View file

@ -26,26 +26,28 @@ import * as textElementUtils from "../element/textElement";
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const { h } = window;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
describe("Test Linear Elements", () => {
let container: HTMLElement;
let canvas: HTMLCanvasElement;
let interactiveCanvas: HTMLCanvasElement;
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
const comp = await render(<ExcalidrawApp />);
h.state.width = 1000;
h.state.height = 1000;
container = comp.container;
canvas = container.querySelector("canvas")!;
canvas.width = 1000;
canvas.height = 1000;
interactiveCanvas = container.querySelector("canvas.interactive")!;
});
const p1: Point = [20, 20];
@ -120,26 +122,26 @@ describe("Test Linear Elements", () => {
};
const drag = (startPoint: Point, endPoint: Point) => {
fireEvent.pointerDown(canvas, {
fireEvent.pointerDown(interactiveCanvas, {
clientX: startPoint[0],
clientY: startPoint[1],
});
fireEvent.pointerMove(canvas, {
fireEvent.pointerMove(interactiveCanvas, {
clientX: endPoint[0],
clientY: endPoint[1],
});
fireEvent.pointerUp(canvas, {
fireEvent.pointerUp(interactiveCanvas, {
clientX: endPoint[0],
clientY: endPoint[1],
});
};
const deletePoint = (point: Point) => {
fireEvent.pointerDown(canvas, {
fireEvent.pointerDown(interactiveCanvas, {
clientX: point[0],
clientY: point[1],
});
fireEvent.pointerUp(canvas, {
fireEvent.pointerUp(interactiveCanvas, {
clientX: point[0],
clientY: point[1],
});
@ -172,12 +174,14 @@ describe("Test Linear Elements", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
[
@ -199,14 +203,14 @@ describe("Test Linear Elements", () => {
it("should allow entering and exiting line editor via context menu", () => {
createTwoPointerLinearElement("line");
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
});
// Enter line editor
let contextMenu = document.querySelector(".context-menu");
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
@ -216,13 +220,13 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
// Exiting line editor
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
});
contextMenu = document.querySelector(".context-menu");
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
@ -270,7 +274,8 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
@ -307,7 +312,9 @@ describe("Test Linear Elements", () => {
// update roundness
fireEvent.click(screen.getByTitle("Round"));
expect(renderScene).toHaveBeenCalledTimes(12);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
h.elements[0] as ExcalidrawLinearElement,
h.state,
@ -351,7 +358,9 @@ describe("Test Linear Elements", () => {
// Move the element
drag(startPoint, endPoint);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect([line.x, line.y]).toEqual([
points[0][0] + deltaX,
points[0][1] + deltaY,
@ -408,7 +417,9 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -447,7 +458,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -473,7 +485,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -507,7 +520,8 @@ describe("Test Linear Elements", () => {
// delete 3rd point
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderScene).toHaveBeenCalledTimes(22);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
line,
@ -553,8 +567,8 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
expect(renderScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -629,7 +643,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -870,10 +885,10 @@ describe("Test Linear Elements", () => {
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@ -904,10 +919,10 @@ describe("Test Linear Elements", () => {
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
it("should not bind text to line when double clicked", async () => {
@ -1046,9 +1061,9 @@ describe("Test Linear Elements", () => {
`);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made easy"
`);
"Online whiteboard
collaboration made easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
[
@ -1206,7 +1221,7 @@ describe("Test Linear Elements", () => {
const container = h.elements[0];
API.setSelectedElements([container, text]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,
@ -1231,7 +1246,7 @@ describe("Test Linear Elements", () => {
mouse.up();
API.setSelectedElements([h.elements[0], h.elements[1]]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 20,
clientY: 30,

View file

@ -17,10 +17,13 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -29,7 +32,7 @@ const { h } = window;
describe("move element", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@ -39,20 +42,23 @@ describe("move element", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
}
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(3);
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
expect(renderStaticScene).toHaveBeenCalledTimes(2);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect([h.elements[0].x, h.elements[0].y]).toEqual([0, 40]);
@ -78,7 +84,8 @@ describe("move element", () => {
// select the second rectangles
new Pointer("mouse").clickOn(rectB);
expect(renderScene).toHaveBeenCalledTimes(23);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(16);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@ -87,7 +94,8 @@ describe("move element", () => {
expect([line.x, line.y]).toEqual([110, 50]);
expect([line.width, line.height]).toEqual([80, 80]);
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
// Move selected rectangle
Keyboard.keyDown(KEYS.ARROW_RIGHT);
@ -95,7 +103,8 @@ describe("move element", () => {
Keyboard.keyDown(KEYS.ARROW_DOWN);
// Check that the arrow size has been changed according to moving the rectangle
expect(renderScene).toHaveBeenCalledTimes(3);
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@ -111,7 +120,7 @@ describe("move element", () => {
describe("duplicate element on move when ALT is clicked", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@ -121,13 +130,15 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
}
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
@ -141,7 +152,8 @@ describe("duplicate element on move when ALT is clicked", () => {
// TODO: This used to be 4, but binding made it go up to 5. Do we need
// that additional render?
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(2);

View file

@ -15,10 +15,13 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -39,11 +42,12 @@ describe("remove shape in non linear elements", () => {
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
@ -53,11 +57,12 @@ describe("remove shape in non linear elements", () => {
const tool = getByToolName("ellipse");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
@ -67,11 +72,12 @@ describe("remove shape in non linear elements", () => {
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0);
});
});
@ -83,7 +89,7 @@ describe("multi point mode in linear elements", () => {
const tool = getByToolName("arrow");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// first point is added on pointer down
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
@ -103,7 +109,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@ -126,7 +133,7 @@ describe("multi point mode in linear elements", () => {
const tool = getByToolName("line");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
// first point is added on pointer down
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
@ -146,7 +153,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;

View file

@ -1,6 +1,6 @@
import { fireEvent, GlobalTestState, toggleMenu, render } from "../test-utils";
import { Excalidraw, Footer, MainMenu } from "../../packages/excalidraw/index";
import { queryByText, queryByTestId, screen } from "@testing-library/react";
import { queryByText, queryByTestId } from "@testing-library/react";
import { GRID_SIZE, THEME } from "../../constants";
import { t } from "../../i18n";
import { useMemo } from "react";
@ -23,7 +23,7 @@ describe("<Excalidraw/>", () => {
).toBe(0);
expect(h.state.zenModeEnabled).toBe(false);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -42,8 +42,8 @@ describe("<Excalidraw/>", () => {
container.getElementsByClassName("disable-zen-mode--visible").length,
).toBe(0);
expect(h.state.zenModeEnabled).toBe(true);
screen.debug();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -95,7 +95,7 @@ describe("<Excalidraw/>", () => {
expect(
container.getElementsByClassName("disable-zen-mode--visible").length,
).toBe(0);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -114,7 +114,7 @@ describe("<Excalidraw/>", () => {
expect(
container.getElementsByClassName("disable-zen-mode--visible").length,
).toBe(0);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,

View file

@ -21,7 +21,7 @@ import { vi } from "vitest";
const { h } = window;
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1);
@ -33,7 +33,7 @@ const finger2 = new Pointer("touch", 2);
* to debug where a test failure came from.
*/
const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot(
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
@ -48,7 +48,7 @@ beforeEach(async () => {
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
@ -1056,6 +1056,28 @@ describe("regression tests", () => {
expect(API.getSelectedElements()).toEqual(selectedElements_prev);
});
it("deleting last but one element in editing group should unselect the group", () => {
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 50 });
UI.group([rect1, rect2]);
mouse.doubleClickOn(rect1);
Keyboard.keyDown(KEYS.DELETE);
// Clicking on the deleted element, hence in the empty space
mouse.clickOn(rect1);
expect(h.state.selectedGroupIds).toEqual({});
expect(API.getSelectedElements()).toEqual([]);
// Clicking back in and expecting no group selection
mouse.clickOn(rect2);
expect(h.state.selectedGroupIds).toEqual({ [rect2.groupIds[0]]: false });
expect(API.getSelectedElements()).toEqual([rect2.get()]);
});
it("Cmd/Ctrl-click exclusively select element under pointer", () => {
const rect1 = UI.createElement("rectangle", { x: 0 });
const rect2 = UI.createElement("rectangle", { x: 30 });

View file

@ -14,10 +14,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});

View file

@ -18,10 +18,13 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = vi.spyOn(Renderer, "renderScene");
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -201,7 +204,7 @@ describe("inner box-selection", () => {
});
h.elements = [rect1, rect2, rect3];
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.x - 20);
mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
@ -220,10 +223,11 @@ describe("selection element", () => {
const tool = getByToolName("selection");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
expect(renderScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -240,11 +244,12 @@ describe("selection element", () => {
const tool = getByToolName("selection");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -261,12 +266,13 @@ describe("selection element", () => {
const tool = getByToolName("selection");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull();
});
});
@ -282,7 +288,7 @@ describe("select single element on the scene", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
const tool = getByToolName("rectangle");
@ -301,7 +307,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -311,7 +318,7 @@ describe("select single element on the scene", () => {
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
const tool = getByToolName("diamond");
@ -330,7 +337,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -340,7 +348,7 @@ describe("select single element on the scene", () => {
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
const tool = getByToolName("ellipse");
@ -359,7 +367,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -369,7 +378,7 @@ describe("select single element on the scene", () => {
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
const tool = getByToolName("arrow");
@ -401,7 +410,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -410,7 +420,7 @@ describe("select single element on the scene", () => {
it("arrow escape", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!;
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
const tool = getByToolName("line");
@ -442,7 +452,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View file

@ -49,15 +49,30 @@ const renderApp: TestRenderFn = async (ui, options) => {
// child App component isn't likely mounted yet (and thus canvas not
// present in DOM)
get() {
return renderResult.container.querySelector("canvas")!;
return renderResult.container.querySelector("canvas.static")!;
},
});
Object.defineProperty(GlobalTestState, "interactiveCanvas", {
// must be a getter because at the time of ExcalidrawApp render the
// child App component isn't likely mounted yet (and thus canvas not
// present in DOM)
get() {
return renderResult.container.querySelector("canvas.interactive")!;
},
});
await waitFor(() => {
const canvas = renderResult.container.querySelector("canvas");
const canvas = renderResult.container.querySelector("canvas.static");
if (!canvas) {
throw new Error("not initialized yet");
}
const interactiveCanvas =
renderResult.container.querySelector("canvas.interactive");
if (!interactiveCanvas) {
throw new Error("not initialized yet");
}
});
return renderResult;
@ -81,11 +96,17 @@ export class GlobalTestState {
*/
static renderResult: RenderResult<typeof customQueries> = null!;
/**
* retrieves canvas for currently rendered app instance
* retrieves static canvas for currently rendered app instance
*/
static get canvas(): HTMLCanvasElement {
return null!;
}
/**
* retrieves interactive canvas for currently rendered app instance
*/
static get interactiveCanvas(): HTMLCanvasElement {
return null!;
}
}
const initLocalStorage = (data: ImportedDataState) => {

View file

@ -17,7 +17,9 @@ describe("view mode", () => {
it("after switching to view mode cursor type should be pointer", async () => {
h.setState({ viewModeEnabled: true });
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.GRAB,
);
});
it("after switching to view mode, moving, clicking, and pressing space key cursor type should be pointer", async () => {
@ -29,7 +31,9 @@ describe("view mode", () => {
pointer.move(100, 100);
pointer.click();
Keyboard.keyPress(KEYS.SPACE);
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.GRAB,
);
});
});
@ -45,13 +49,19 @@ describe("view mode", () => {
pointer.moveTo(50, 50);
// eslint-disable-next-line dot-notation
if (pointerType["pointerType"] === "mouse") {
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.MOVE);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.MOVE,
);
} else {
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.GRAB,
);
}
h.setState({ viewModeEnabled: true });
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.GRAB,
);
});
});
});

View file

@ -94,7 +94,7 @@ const populateElements = (
),
...appState,
selectedElementIds,
});
} as AppState);
return selectedElementIds;
};

View file

@ -104,6 +104,52 @@ export type LastActiveTool =
export type SidebarName = string;
export type SidebarTabName = string;
export type CommonCanvasAppState = {
zoom: AppState["zoom"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
width: AppState["width"];
height: AppState["height"];
viewModeEnabled: AppState["viewModeEnabled"];
editingElement: AppState["editingElement"];
editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible
selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible
frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
theme: AppState["theme"];
pendingImageElementId: AppState["pendingImageElementId"];
};
export type StaticCanvasAppState = CommonCanvasAppState & {
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
/** null indicates transparent bg */
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
exportScale: AppState["exportScale"];
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
gridSize: AppState["gridSize"];
frameRendering: AppState["frameRendering"];
};
export type InteractiveCanvasAppState = CommonCanvasAppState & {
// renderInteractiveScene
activeEmbeddable: AppState["activeEmbeddable"];
editingLinearElement: AppState["editingLinearElement"];
selectionElement: AppState["selectionElement"];
selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElement: AppState["selectedLinearElement"];
multiElement: AppState["multiElement"];
isBindingEnabled: AppState["isBindingEnabled"];
suggestedBindings: AppState["suggestedBindings"];
isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"];
// App
openSidebar: AppState["openSidebar"];
showHyperlinkPopup: AppState["showHyperlinkPopup"];
// Collaborators
collaborators: AppState["collaborators"];
};
export type AppState = {
contextMenu: {
items: ContextMenuItems;
@ -407,13 +453,13 @@ export type ExportOpts = {
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState,
files: BinaryFiles,
canvas: HTMLCanvasElement | null,
canvas: HTMLCanvasElement,
) => void;
renderCustomUI?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState,
files: BinaryFiles,
canvas: HTMLCanvasElement | null,
canvas: HTMLCanvasElement,
) => JSX.Element;
};
@ -458,7 +504,8 @@ export type AppProps = Merge<
* in the app, eg Manager. Factored out into a separate type to keep DRY. */
export type AppClassProperties = {
props: AppProps;
canvas: HTMLCanvasElement | null;
canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
imageCache: Map<

View file

@ -20,6 +20,7 @@ import { unstable_batchedUpdates } from "react-dom";
import { SHAPES } from "./shapes";
import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types";
import React from "react";
let mockDateTime: string | null = null;
@ -399,22 +400,25 @@ export const updateActiveTool = (
};
};
export const resetCursor = (canvas: HTMLCanvasElement | null) => {
if (canvas) {
canvas.style.cursor = "";
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
if (interactiveCanvas) {
interactiveCanvas.style.cursor = "";
}
};
export const setCursor = (canvas: HTMLCanvasElement | null, cursor: string) => {
if (canvas) {
canvas.style.cursor = cursor;
export const setCursor = (
interactiveCanvas: HTMLCanvasElement | null,
cursor: string,
) => {
if (interactiveCanvas) {
interactiveCanvas.style.cursor = cursor;
}
};
let eraserCanvasCache: any;
let previewDataURL: string;
export const setEraserCursor = (
canvas: HTMLCanvasElement | null,
interactiveCanvas: HTMLCanvasElement | null,
theme: AppState["theme"],
) => {
const cursorImageSizePx = 20;
@ -446,7 +450,7 @@ export const setEraserCursor = (
}
setCursor(
canvas,
interactiveCanvas,
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
cursorImageSizePx / 2
}, auto`,
@ -454,23 +458,23 @@ export const setEraserCursor = (
};
export const setCursorForShape = (
canvas: HTMLCanvasElement | null,
interactiveCanvas: HTMLCanvasElement | null,
appState: Pick<AppState, "activeTool" | "theme">,
) => {
if (!canvas) {
if (!interactiveCanvas) {
return;
}
if (appState.activeTool.type === "selection") {
resetCursor(canvas);
resetCursor(interactiveCanvas);
} else if (isHandToolActive(appState)) {
canvas.style.cursor = CURSOR_TYPE.GRAB;
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
} else if (isEraserActive(appState)) {
setEraserCursor(canvas, appState.theme);
setEraserCursor(interactiveCanvas, appState.theme);
// do nothing if image tool is selected which suggests there's
// a image-preview set as the cursor
// Ignore custom type as well and let host decide
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
}
};
@ -927,3 +931,74 @@ export const assertNever = (
throw new Error(message);
};
/**
* Memoizes on values of `opts` object (strict equality).
*/
export const memoize = <T extends Record<string, any>, R extends any>(
func: (opts: T) => R,
) => {
let lastArgs: Map<string, any> | undefined;
let lastResult: R | undefined;
const ret = function (opts: T) {
const currentArgs = Object.entries(opts);
if (lastArgs) {
let argsAreEqual = true;
for (const [key, value] of currentArgs) {
if (lastArgs.get(key) !== value) {
argsAreEqual = false;
break;
}
}
if (argsAreEqual) {
return lastResult;
}
}
const result = func(opts);
lastArgs = new Map(currentArgs);
lastResult = result;
return result;
};
ret.clear = () => {
lastArgs = undefined;
lastResult = undefined;
};
return ret as typeof func & { clear: () => void };
};
export const isRenderThrottlingEnabled = (() => {
// we don't want to throttle in react < 18 because of #5439 and it was
// getting more complex to maintain the fix
let IS_REACT_18_AND_UP: boolean;
try {
const version = React.version.split(".");
IS_REACT_18_AND_UP = Number(version[0]) > 17;
} catch {
IS_REACT_18_AND_UP = false;
}
let hasWarned = false;
return () => {
if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
if (!IS_REACT_18_AND_UP) {
if (!hasWarned) {
hasWarned = true;
console.warn(
"Excalidraw: render throttling is disabled on React versions < 18.",
);
}
return false;
}
return true;
}
return false;
};
})();