Compare commits

...

27 Commits

Author SHA1 Message Date
dwelle
2b33fa1ae6 factor out shape cache into ShapeCache class
and more non-renderScene shape cache retrieval more resilient
2023-08-10 23:58:35 +02:00
dwelle
aedcee6c7e stop inlining throttled function to keep them stable 2023-08-10 23:45:01 +02:00
dwelle
bf3fac68db supply static canvas from the editor
Makes it easy for the editor to control it and have it available in constructor.
2023-08-10 19:39:37 +02:00
dwelle
69d751ffe4 revert debug 2023-08-10 13:27:23 +02:00
dwelle
6aa383bf58 revert registering events using react 2023-08-10 13:27:14 +02:00
dwelle
a582684f2c [debug] 2023-08-10 12:13:26 +02:00
dwelle
27ddc3d9bc move callbacks to canvas compo & refactor types 2023-08-10 11:17:19 +02:00
dwelle
2e6863cb83 revert sync flushing 2023-08-10 00:15:43 +02:00
dwelle
3b65f2aefa make InteractiveCanvas callbacks mandatory and move viewMode check inside 2023-08-09 19:34:19 +02:00
dwelle
540e2422fd Merge branch 'master' into mrazator/canvas-partitioning
# Conflicts:
#	src/utils.ts
2023-08-09 18:40:26 +02:00
dwelle
db72eeba71 fallback index in case element not found 2023-08-09 00:08:19 +02:00
dwelle
a502e49f78 Merge branch 'master' into mrazator/canvas-partitioning
# Conflicts:
#	yarn.lock
2023-08-06 22:10:19 +02:00
Marcel Mraz
e58e9ec6d5 Removed unnessary context.restore() calls a using cached selectedElements in the interactive canvas 2023-08-06 21:48:40 +02:00
dwelle
58dcafe8f6 remove debug 2023-08-06 21:45:05 +02:00
dwelle
b1d982b988 remove throttling react 17 hack & disable on < 18 2023-08-06 21:41:37 +02:00
dwelle
23ef690222 rename isVisibleElement -> isElementInViewport 2023-08-06 13:48:09 +02:00
dwelle
dbaa13ab97 fix export 2023-08-06 13:47:30 +02:00
dwelle
4c2824c484 bump vitest & fix tests 2023-08-06 11:58:20 +02:00
dwelle
eb9342fe27 memoize getRenderableElements() also on versionNonce
and drop memoization of individual filtering functions, as the elements array may not renew on mutation and we might filter out elements based on individual elements
2023-08-06 00:27:08 +02:00
dwelle
da1a235064 move canvas elements filtering into Renderer class 2023-08-05 23:46:47 +02:00
dwelle
40dccfcdd1 narrow down & consolidate group selecting helper 2023-08-05 12:59:47 +02:00
dwelle
2f940fefc0 remove more AppState assertions 2023-08-05 12:06:58 +02:00
dwelle
40cc901d56 remove AppState assertion related to group actions 2023-08-04 23:25:34 +02:00
dwelle
21d757c892 Merge branch 'master' into mrazator/canvas-partitioning
# Conflicts:
#	src/components/App.test.tsx
#	src/renderer/renderScene.ts
#	src/tests/__snapshots__/contextmenu.test.tsx.snap
#	src/tests/__snapshots__/linearElementEditor.test.tsx.snap
#	src/tests/__snapshots__/regressionTests.test.tsx.snap
#	src/tests/contextmenu.test.tsx
#	src/tests/dragCreate.test.tsx
#	src/tests/linearElementEditor.test.tsx
#	src/tests/move.test.tsx
#	src/tests/multiPointCreate.test.tsx
#	src/tests/packages/excalidraw.test.tsx
#	src/tests/regressionTests.test.tsx
#	src/tests/resize.test.tsx
#	src/tests/selection.test.tsx
2023-07-30 19:16:06 +02:00
dwelle
be7cc402d3 Merge branch 'master' into mrazator/canvas-partitioning
# Conflicts:
#	src/components/App.tsx
#	src/renderer/renderElement.ts
#	src/renderer/renderScene.ts
2023-07-25 23:46:35 +02:00
Marcel Mraz
7792f69782 Extracting common calculations to the canvases wrapper 2023-07-23 17:42:45 +02:00
Marcel Mraz
903c94d2ca Introducing interactive canvas
Separation of Appstate and RenderConfig for InteractiveCanvas

Sepration of static canvas

Fixing test type-errors, removing original RenderConfig

Deduplication of canvases AppState and RenderConfig

Added mutation hook for shared computation between canvases

Moved interaction handlers to interactive canvas and closed some fixes

Added CanvasWrapper and first render optimisations

Optimising selection + frame selection bottlenecks with cache/improved algo

Static canvas rendering bottlenecks WIP

Cursors regression moved to interactive canvas

Regression, adding back render interactive scene callback, adding back throttleRAF to both canvases

Fix for scroll back to content & scrollbars

Separating renderInteractiveScene and renderScene

Common canvas context bootstrap

Groups cache fix, mutation elements fix and other smaller fixes

Remove getSelectedElements cache

Fixing broken tests

Updated tests with expected # of renderStaticScene calls, adding group selection edge-case test, other smaller fixes
2023-07-23 13:41:51 +02:00
69 changed files with 4331 additions and 2970 deletions

View File

@ -90,7 +90,7 @@
"vite-plugin-ejs": "1.6.4",
"vite-plugin-pwa": "0.16.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "0.32.2",
"vitest": "0.34.1",
"vitest-canvas-mock": "0.3.2"
},
"engines": {

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),

127
src/scene/Renderer.ts Normal file
View File

@ -0,0 +1,127 @@
import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks";
import { NonDeletedExcalidrawElement } from "../element/types";
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 };
},
);
})();
public destroy() {
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;
};
})();

185
yarn.lock
View File

@ -2813,40 +2813,39 @@
test-exclude "^6.0.0"
v8-to-istanbul "^9.1.0"
"@vitest/expect@0.32.2":
version "0.32.2"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.32.2.tgz#8111f6ab1ff3b203efbe3a25e8bb2d160ce4b720"
integrity sha512-6q5yzweLnyEv5Zz1fqK5u5E83LU+gOMVBDuxBl2d2Jfx1BAp5M+rZgc5mlyqdnxquyoiOXpXmFNkcGcfFnFH3Q==
"@vitest/expect@0.34.1":
version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.1.tgz#2ba6cb96695f4b4388c6d955423a81afc79b8da0"
integrity sha512-q2CD8+XIsQ+tHwypnoCk8Mnv5e6afLFvinVGCq3/BOT4kQdVQmY6rRfyKkwcg635lbliLPqbunXZr+L1ssUWiQ==
dependencies:
"@vitest/spy" "0.32.2"
"@vitest/utils" "0.32.2"
"@vitest/spy" "0.34.1"
"@vitest/utils" "0.34.1"
chai "^4.3.7"
"@vitest/runner@0.32.2":
version "0.32.2"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.32.2.tgz#18dd979ce4e8766bcc90948d11b4c8ae6ed90b89"
integrity sha512-06vEL0C1pomOEktGoLjzZw+1Fb+7RBRhmw/06WkDrd1akkT9i12su0ku+R/0QM69dfkIL/rAIDTG+CSuQVDcKw==
"@vitest/runner@0.34.1":
version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.1.tgz#23c21ba1db8bff610988c72744db590d0fb6c4ba"
integrity sha512-YfQMpYzDsYB7yqgmlxZ06NI4LurHWfrH7Wy3Pvf/z/vwUSgq1zLAb1lWcItCzQG+NVox+VvzlKQrYEXb47645g==
dependencies:
"@vitest/utils" "0.32.2"
concordance "^5.0.4"
"@vitest/utils" "0.34.1"
p-limit "^4.0.0"
pathe "^1.1.0"
pathe "^1.1.1"
"@vitest/snapshot@0.32.2":
version "0.32.2"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.32.2.tgz#500b6453e88e4c50a0aded39839352c16b519b9e"
integrity sha512-JwhpeH/PPc7GJX38vEfCy9LtRzf9F4er7i4OsAJyV7sjPwjj+AIR8cUgpMTWK4S3TiamzopcTyLsZDMuldoi5A==
"@vitest/snapshot@0.34.1":
version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.1.tgz#814c65f8e714eaf255f47838541004b2a2ba28e6"
integrity sha512-0O9LfLU0114OqdF8lENlrLsnn024Tb1CsS9UwG0YMWY2oGTQfPtkW+B/7ieyv0X9R2Oijhi3caB1xgGgEgclSQ==
dependencies:
magic-string "^0.30.0"
pathe "^1.1.0"
pretty-format "^27.5.1"
magic-string "^0.30.1"
pathe "^1.1.1"
pretty-format "^29.5.0"
"@vitest/spy@0.32.2":
version "0.32.2"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.32.2.tgz#f3ef7afe0d34e863b90df7c959fa5af540a6aaf9"
integrity sha512-Q/ZNILJ4ca/VzQbRM8ur3Si5Sardsh1HofatG9wsJY1RfEaw0XKP8IVax2lI1qnrk9YPuG9LA2LkZ0EI/3d4ug==
"@vitest/spy@0.34.1":
version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.1.tgz#2f77234a3d554c5dea664943f2caaab92d304f3c"
integrity sha512-UT4WcI3EAPUNO8n6y9QoEqynGGEPmmRxC+cLzneFFXpmacivjHZsNbiKD88KUScv5DCHVDgdBsLD7O7s1enFcQ==
dependencies:
tinyspy "^2.1.0"
tinyspy "^2.1.1"
"@vitest/ui@0.32.2":
version "0.32.2"
@ -2870,6 +2869,15 @@
loupe "^2.3.6"
pretty-format "^27.5.1"
"@vitest/utils@0.34.1":
version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.34.1.tgz#e5545c6618775fb9a2dae2a80d94fc2f35222233"
integrity sha512-/ql9dsFi4iuEbiNcjNHQWXBum7aL8pyhxvfnD9gNtbjR9fUKAjxhj4AA3yfLXg6gJpMGGecvtF8Au2G9y3q47Q==
dependencies:
diff-sequences "^29.4.3"
loupe "^2.3.6"
pretty-format "^29.5.0"
abab@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
@ -3236,11 +3244,6 @@ blob@0.0.5:
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
blueimp-md5@^2.10.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@ -3510,20 +3513,6 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concordance@^5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/concordance/-/concordance-5.0.4.tgz#9896073261adced72f88d60e4d56f8efc4bbbbd2"
integrity sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==
dependencies:
date-time "^3.1.0"
esutils "^2.0.3"
fast-diff "^1.2.0"
js-string-escape "^1.0.1"
lodash "^4.17.15"
md5-hex "^3.0.1"
semver "^7.3.2"
well-known-symbols "^2.0.0"
confusing-browser-globals@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
@ -3638,13 +3627,6 @@ data-urls@^4.0.0:
whatwg-mimetype "^3.0.0"
whatwg-url "^12.0.0"
date-time@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/date-time/-/date-time-3.1.0.tgz#0d1e934d170579f481ed8df1e2b8ff70ee845e1e"
integrity sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==
dependencies:
time-zone "^1.0.0"
debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
@ -4294,7 +4276,7 @@ estree-walker@^2.0.2:
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
esutils@^2.0.2, esutils@^2.0.3:
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
@ -4347,11 +4329,6 @@ fast-diff@^1.1.2:
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-diff@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
fast-glob@^3.2.12, fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@ -5245,11 +5222,6 @@ jotai@1.13.1:
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.13.1.tgz#20cc46454cbb39096b12fddfa635b873b3668236"
integrity sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==
js-string-escape@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
integrity sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -5561,13 +5533,6 @@ magic-string@^0.27.0:
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
magic-string@^0.30.0:
version "0.30.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.0.tgz#fd58a4748c5c4547338a424e90fa5dd17f4de529"
integrity sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
magic-string@^0.30.1:
version "0.30.2"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.2.tgz#dcf04aad3d0d1314bc743d076c50feb29b3c7aca"
@ -5582,13 +5547,6 @@ make-dir@^4.0.0:
dependencies:
semver "^7.5.3"
md5-hex@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c"
integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==
dependencies:
blueimp-md5 "^2.10.0"
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -5660,7 +5618,7 @@ mkdirp@^0.5.6:
dependencies:
minimist "^1.2.6"
mlly@^1.2.0:
mlly@^1.2.0, mlly@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==
@ -6525,7 +6483,7 @@ semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.2.1, semver@^7.3.2, semver@^7.3.7:
semver@^7.2.1, semver@^7.3.7:
version "7.4.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318"
integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==
@ -6703,7 +6661,7 @@ stackback@0.0.2:
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
std-env@^3.3.2, std-env@^3.3.3:
std-env@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe"
integrity sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==
@ -6930,11 +6888,6 @@ through@^2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
time-zone@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/time-zone/-/time-zone-1.0.0.tgz#99c5bf55958966af6d06d83bdf3800dc82faec5d"
integrity sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==
tiny-invariant@^1.1.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
@ -6945,12 +6898,12 @@ tinybench@^2.5.0:
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.0.tgz#4711c99bbf6f3e986f67eb722fed9cddb3a68ba5"
integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==
tinypool@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.5.0.tgz#3861c3069bf71e4f1f5aa2d2e6b3aaacc278961e"
integrity sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==
tinypool@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021"
integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==
tinyspy@^2.1.0:
tinyspy@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.1.1.tgz#9e6371b00c259e5c5b301917ca18c01d40ae558c"
integrity sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==
@ -7231,15 +7184,15 @@ v8-to-istanbul@^9.1.0:
"@types/istanbul-lib-coverage" "^2.0.1"
convert-source-map "^1.6.0"
vite-node@0.32.2:
version "0.32.2"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.2.tgz#bfccdfeb708b2309ea9e5fe424951c75bb9c0096"
integrity sha512-dTQ1DCLwl2aEseov7cfQ+kDMNJpM1ebpyMMMwWzBvLbis8Nla/6c9WQcqpPssTwS6Rp/+U6KwlIj8Eapw4bLdA==
vite-node@0.34.1:
version "0.34.1"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.1.tgz#144900ca4bd54cc419c501d671350bcbc07eb1ee"
integrity sha512-odAZAL9xFMuAg8aWd7nSPT+hU8u2r9gU3LRm9QKjxBEF2rRdWpMuqkrkjvyVQEdNFiBctqr2Gg4uJYizm5Le6w==
dependencies:
cac "^6.7.14"
debug "^4.3.4"
mlly "^1.2.0"
pathe "^1.1.0"
mlly "^1.4.0"
pathe "^1.1.1"
picocolors "^1.0.0"
vite "^3.0.0 || ^4.0.0"
@ -7321,35 +7274,34 @@ vitest-canvas-mock@0.3.2:
dependencies:
jest-canvas-mock "~2.4.0"
vitest@0.32.2:
version "0.32.2"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.32.2.tgz#758ce2220f609e240ac054eca7ad11a5140679ab"
integrity sha512-hU8GNNuQfwuQmqTLfiKcqEhZY72Zxb7nnN07koCUNmntNxbKQnVbeIS6sqUgR3eXSlbOpit8+/gr1KpqoMgWCQ==
vitest@0.34.1:
version "0.34.1"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.1.tgz#3ad7f845e7a9fb0d72ab703cae832a54b8469e1e"
integrity sha512-G1PzuBEq9A75XSU88yO5G4vPT20UovbC/2osB2KEuV/FisSIIsw7m5y2xMdB7RsAGHAfg2lPmp2qKr3KWliVlQ==
dependencies:
"@types/chai" "^4.3.5"
"@types/chai-subset" "^1.3.3"
"@types/node" "*"
"@vitest/expect" "0.32.2"
"@vitest/runner" "0.32.2"
"@vitest/snapshot" "0.32.2"
"@vitest/spy" "0.32.2"
"@vitest/utils" "0.32.2"
acorn "^8.8.2"
"@vitest/expect" "0.34.1"
"@vitest/runner" "0.34.1"
"@vitest/snapshot" "0.34.1"
"@vitest/spy" "0.34.1"
"@vitest/utils" "0.34.1"
acorn "^8.9.0"
acorn-walk "^8.2.0"
cac "^6.7.14"
chai "^4.3.7"
concordance "^5.0.4"
debug "^4.3.4"
local-pkg "^0.4.3"
magic-string "^0.30.0"
pathe "^1.1.0"
magic-string "^0.30.1"
pathe "^1.1.1"
picocolors "^1.0.0"
std-env "^3.3.2"
std-env "^3.3.3"
strip-literal "^1.0.1"
tinybench "^2.5.0"
tinypool "^0.5.0"
tinypool "^0.7.0"
vite "^3.0.0 || ^4.0.0"
vite-node "0.32.2"
vite-node "0.34.1"
why-is-node-running "^2.2.2"
vscode-jsonrpc@6.0.0:
@ -7437,11 +7389,6 @@ webworkify@^1.5.0:
resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
well-known-symbols@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/well-known-symbols/-/well-known-symbols-2.0.0.tgz#e9c7c07dbd132b7b84212c8174391ec1f9871ba5"
integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==
whatwg-encoding@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53"
@ -7534,9 +7481,9 @@ why-is-node-running@^2.2.2:
stackback "0.0.2"
word-wrap@^1.2.3:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
workbox-background-sync@7.0.0:
version "7.0.0"