feat: partition main canvas vertically (#6759)

Co-authored-by: Marcel Mraz <marcel.mraz@adacta-fintech.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Marcel Mraz 2023-08-12 22:56:59 +02:00 committed by GitHub
parent 3ea07076ad
commit a376bd9495
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 4348 additions and 2970 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
}; };
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
canvas, interactiveCanvas,
activeTool, activeTool,
setAppState, setAppState,
onImageAction, onImageAction,
appState, appState,
}: { }: {
canvas: HTMLCanvasElement | null; interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"]; activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
@ -270,7 +270,7 @@ export const ShapesSwitcher = ({
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, { setCursorForShape(interactiveCanvas, {
...appState, ...appState,
activeTool: nextActiveTool, activeTool: nextActiveTool,
}); });

View File

@ -6,14 +6,14 @@ import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { vi } from "vitest"; import { vi } from "vitest";
const renderScene = vi.spyOn(Renderer, "renderScene"); const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
describe("Test <App/>", () => { describe("Test <App/>", () => {
beforeEach(async () => { beforeEach(async () => {
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderStaticScene.mockClear();
reseed(7); 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 { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick"; import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { ShapeCache } from "../scene/ShapeCache";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App"; import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import "./EyeDropper.scss"; import "./EyeDropper.scss";
@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
}, },
false, false,
); );
invalidateShapeForElement(element); ShapeCache.delete(element);
} }
Scene.getScene( Scene.getScene(
metaStuffRef.current.selectedElements[0], metaStuffRef.current.selectedElements[0],

View File

@ -34,7 +34,7 @@ const JSONExportModal = ({
actionManager: ActionManager; actionManager: ActionManager;
onCloseRequest: () => void; onCloseRequest: () => void;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement;
}) => { }) => {
const { onExportToBackend } = exportOpts; const { onExportToBackend } = exportOpts;
return ( return (
@ -100,7 +100,7 @@ export const JSONExportDialog = ({
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {

View File

@ -57,7 +57,8 @@ interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: UIAppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void; onLockToggle: () => void;
@ -117,6 +118,7 @@ const LayerUI = ({
setAppState, setAppState,
elements, elements,
canvas, canvas,
interactiveCanvas,
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
@ -272,7 +274,7 @@ const LayerUI = ({
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool} activeTool={appState.activeTool}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
@ -413,7 +415,7 @@ const LayerUI = ({
onLockToggle={onLockToggle} onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle} onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
canvas={canvas} interactiveCanvas={interactiveCanvas}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
@ -464,7 +466,7 @@ const LayerUI = ({
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState),
})); }));
}} }}
> >
@ -507,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false; return false;
} }
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps; const {
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps; canvas: _pC,
interactiveCanvas: _pIC,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nC,
interactiveCanvas: _nIC,
appState: nextAppState,
...next
} = nextProps;
return ( return (
isShallowEqual( isShallowEqual(

View File

@ -36,7 +36,7 @@ type MobileMenuProps = {
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null; interactiveCanvas: HTMLCanvasElement | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
@ -58,7 +58,7 @@ export const MobileMenu = ({
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
canvas, interactiveCanvas,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
@ -85,7 +85,7 @@ export const MobileMenu = ({
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
canvas={canvas} interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool} activeTool={appState.activeTool}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
@ -202,7 +202,7 @@ export const MobileMenu = ({
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ 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 { :root {
--zIndex-canvas: 1; --zIndex-canvas: 1;
--zIndex-wysiwyg: 2; --zIndex-interactiveCanvas: 2;
--zIndex-layerUI: 3; --zIndex-wysiwyg: 3;
--zIndex-layerUI: 4;
--zIndex-modal: 1000; --zIndex-modal: 1000;
--zIndex-popup: 1001; --zIndex-popup: 1001;
@ -69,10 +70,19 @@
z-index: var(--zIndex-canvas); z-index: var(--zIndex-canvas);
&.interactive {
z-index: var(--zIndex-interactiveCanvas);
}
// Remove the main canvas from document flow to avoid resizeObserver // Remove the main canvas from document flow to avoid resizeObserver
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379) // feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
} }
&__canvas-wrapper,
&__canvas.static {
pointer-events: none;
}
&__canvas { &__canvas {
position: absolute; position: absolute;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,9 @@ import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; 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 = ( export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
@ -13,6 +15,42 @@ export const isInvisiblySmallElement = (
return element.width === 0 && element.height === 0; 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 * Makes a perfect shape or diagonal/horizontal/vertical line
*/ */

View File

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

View File

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

View File

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

View File

@ -16,14 +16,24 @@ export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
export const updateBrowserStateVersion = (type: BrowserStateTypes) => { export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
const timestamp = Date.now(); const timestamp = Date.now();
localStorage.setItem(type, JSON.stringify(timestamp)); try {
LOCAL_STATE_VERSIONS[type] = timestamp; 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 = () => { export const resetBrowserStateVersions = () => {
for (const key of Object.keys(LOCAL_STATE_VERSIONS) as BrowserStateTypes[]) { try {
const timestamp = -1; for (const key of Object.keys(
localStorage.setItem(key, JSON.stringify(timestamp)); LOCAL_STATE_VERSIONS,
LOCAL_STATE_VERSIONS[key] = timestamp; ) 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[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement,
) => { ) => {
if (exportedElements.length === 0) { if (exportedElements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas")); return window.alert(t("alerts.cannotExportEmptyCanvas"));

View File

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

View File

@ -4,27 +4,40 @@ import {
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { AppClassProperties, AppState } from "./types"; import {
AppClassProperties,
AppState,
InteractiveCanvasAppState,
} from "./types";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement"; import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection"; import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = ( export const selectGroup = (
groupId: GroupId, groupId: GroupId,
appState: AppState, appState: InteractiveCanvasAppState,
elements: readonly NonDeleted<ExcalidrawElement>[], elements: readonly NonDeleted<ExcalidrawElement>[],
): AppState => { ): Pick<
const elementsInGroup = elements.filter((element) => InteractiveCanvasAppState,
element.groupIds.includes(groupId), "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 ( if (
appState.selectedGroupIds[groupId] || appState.selectedGroupIds[groupId] ||
appState.editingGroupId === groupId appState.editingGroupId === groupId
) { ) {
return { return {
...appState, selectedElementIds: appState.selectedElementIds,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false }, selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
editingGroupId: null, editingGroupId: null,
}; };
@ -33,104 +46,184 @@ export const selectGroup = (
} }
return { return {
...appState, editingGroupId: appState.editingGroupId,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true }, selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
selectedElementIds: { selectedElementIds: {
...appState.selectedElementIds, ...appState.selectedElementIds,
...Object.fromEntries( ...elementsInGroup,
elementsInGroup.map((element) => [element.id, true]),
),
}, },
}; };
}; };
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 * If the element's group is selected, don't render an individual
* selection border around it. * selection border around it.
*/ */
export const isSelectedViaGroup = ( export const isSelectedViaGroup = (
appState: AppState, appState: InteractiveCanvasAppState,
element: ExcalidrawElement, element: ExcalidrawElement,
) => getSelectedGroupForElement(appState, element) != null; ) => getSelectedGroupForElement(appState, element) != null;
export const getSelectedGroupForElement = ( export const getSelectedGroupForElement = (
appState: AppState, appState: InteractiveCanvasAppState,
element: ExcalidrawElement, element: ExcalidrawElement,
) => ) =>
element.groupIds element.groupIds
.filter((groupId) => groupId !== appState.editingGroupId) .filter((groupId) => groupId !== appState.editingGroupId)
.find((groupId) => appState.selectedGroupIds[groupId]); .find((groupId) => appState.selectedGroupIds[groupId]);
export const getSelectedGroupIds = (appState: AppState): GroupId[] => export const getSelectedGroupIds = (
appState: InteractiveCanvasAppState,
): GroupId[] =>
Object.entries(appState.selectedGroupIds) Object.entries(appState.selectedGroupIds)
.filter(([groupId, isSelected]) => isSelected) .filter(([groupId, isSelected]) => isSelected)
.map(([groupId, isSelected]) => groupId); .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 // given a list of elements, return the the actual group ids that should be selected
// or used to update the elements // or used to update the elements
export const selectGroupsFromGivenElements = ( export const selectGroupsFromGivenElements = (
elements: readonly NonDeleted<ExcalidrawElement>[], elements: readonly NonDeleted<ExcalidrawElement>[],
appState: AppState, appState: InteractiveCanvasAppState,
) => { ) => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} }; let nextAppState: InteractiveCanvasAppState = {
...appState,
selectedGroupIds: {},
};
for (const element of elements) { for (const element of elements) {
let groupIds = element.groupIds; let groupIds = element.groupIds;
@ -142,7 +235,10 @@ export const selectGroupsFromGivenElements = (
} }
if (groupIds.length > 0) { if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1]; 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, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
} from "./element/types"; } from "./element/types";
import { getShapeForElement } from "./renderer/renderElement";
import { getCurvePathOps } from "./element/bounds"; import { getCurvePathOps } from "./element/bounds";
import { Mutable } from "./utility-types"; import { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache";
export const rotate = ( export const rotate = (
x1: number, x1: number,
@ -303,7 +303,7 @@ export const getControlPointsForBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point, endPoint: Point,
) => { ) => {
const shape = getShapeForElement(element as ExcalidrawLinearElement); const shape = ShapeCache.generateElementShape(element);
if (!shape) { if (!shape) {
return null; return null;
} }

View File

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

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

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

View File

@ -14,6 +14,7 @@ import { isFrameElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { AppState } from "../types"; import { AppState } from "../types";
import { Assert, SameType } from "../utility-types"; import { Assert, SameType } from "../utility-types";
import { randomInteger } from "../random";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@ -105,6 +106,7 @@ class Scene {
elements: null, elements: null,
cache: new Map(), cache: new Map(),
}; };
private versionNonce: number | undefined;
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this.elements; return this.elements;
@ -172,6 +174,10 @@ class Scene {
return (this.elementsMap.get(id) as T | undefined) || null; return (this.elementsMap.get(id) as T | undefined) || null;
} }
getVersionNonce() {
return this.versionNonce;
}
getNonDeletedElement( getNonDeletedElement(
id: ExcalidrawElement["id"], id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null { ): NonDeleted<ExcalidrawElement> | null {
@ -230,6 +236,8 @@ class Scene {
} }
informMutation() { informMutation() {
this.versionNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) { for (const callback of Array.from(this.callbacks)) {
callback(); 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 rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils"; import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants"; import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
@ -54,26 +54,23 @@ export const exportToCanvas = async (
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({ renderStaticScene({
elements,
appState,
scale,
rc: rough.canvas(canvas),
canvas, canvas,
renderConfig: { rc: rough.canvas(canvas),
elements,
visibleElements: elements,
scale,
appState: {
...appState,
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding), scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding), scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
theme: appState.exportWithDarkMode ? "dark" : "light", theme: appState.exportWithDarkMode ? "dark" : "light",
},
renderConfig: {
imageCache, imageCache,
renderScrollbars: false,
renderSelection: false,
renderGrid: false, renderGrid: false,
isExporting: true, isExporting: true,
}, },

View File

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

View File

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

View File

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

View File

@ -1,33 +1,63 @@
import { ExcalidrawTextElement } from "../element/types"; import { RoughCanvas } from "roughjs/bin/canvas";
import { AppClassProperties, AppState } from "../types"; import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
AppClassProperties,
InteractiveCanvasAppState,
StaticCanvasAppState,
} from "../types";
export type RenderConfig = { export type StaticCanvasRenderConfig = {
// 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 };
// extra options passed to the renderer // extra options passed to the renderer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"]; imageCache: AppClassProperties["imageCache"];
renderScrollbars?: boolean; renderGrid: boolean;
renderSelection?: boolean;
renderGrid?: boolean;
/** when exporting the behavior is slightly different (e.g. we can't use /** 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; 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; 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 = { 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": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -42,7 +42,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -69,14 +69,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -103,14 +103,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "ellipse", "type": "ellipse",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -148,7 +148,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -157,7 +157,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -184,14 +184,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" 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" tabindex="0"
wrap="off" wrap="off"
/> />

View File

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

View File

@ -38,7 +38,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -47,7 +47,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 1150084233, "versionNonce": 1505387817,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@ -92,7 +92,7 @@ exports[`multi point mode in linear elements > line 1`] = `
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -101,7 +101,7 @@ exports[`multi point mode in linear elements > line 1`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 1150084233, "versionNonce": 1505387817,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 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": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -40,7 +40,7 @@ exports[`select single element on the scene > arrow 1`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -78,7 +78,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -87,7 +87,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 449462985, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -112,14 +112,14 @@ exports[`select single element on the scene > diamond 1`] = `
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -144,14 +144,14 @@ exports[`select single element on the scene > ellipse 1`] = `
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "ellipse", "type": "ellipse",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -176,14 +176,14 @@ exports[`select single element on the scene > rectangle 1`] = `
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 337897, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 1278240551, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,

View File

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

View File

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

View File

@ -279,7 +279,7 @@ export class API {
}; };
static drop = async (blob: Blob) => { 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) => { const text = await new Promise<string>((resolve, reject) => {
try { try {
const reader = new FileReader(); 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) { restorePosition(x = 0, y = 0) {
this.clientX = x; this.clientX = x;
this.clientY = y; this.clientY = y;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
} }
private getEvent() { private getEvent() {
@ -129,18 +129,18 @@ export class Pointer {
if (dx !== 0 || dy !== 0) { if (dx !== 0 || dy !== 0) {
this.clientX += dx; this.clientX += dx;
this.clientY += dy; this.clientY += dy;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
} }
} }
down(dx = 0, dy = 0) { down(dx = 0, dy = 0) {
this.move(dx, dy); this.move(dx, dy);
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
} }
up(dx = 0, dy = 0) { up(dx = 0, dy = 0) {
this.move(dx, dy); this.move(dx, dy);
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
} }
click(dx = 0, dy = 0) { click(dx = 0, dy = 0) {
@ -150,7 +150,7 @@ export class Pointer {
doubleClick(dx = 0, dy = 0) { doubleClick(dx = 0, dy = 0) {
this.move(dx, dy); this.move(dx, dy);
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent()); fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
} }
// absolute coords // absolute coords
@ -159,19 +159,19 @@ export class Pointer {
moveTo(x: number = this.clientX, y: number = this.clientY) { moveTo(x: number = this.clientX, y: number = this.clientY) {
this.clientX = x; this.clientX = x;
this.clientY = y; this.clientY = y;
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
} }
downAt(x = this.clientX, y = this.clientY) { downAt(x = this.clientX, y = this.clientY) {
this.clientX = x; this.clientX = x;
this.clientY = y; this.clientY = y;
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
} }
upAt(x = this.clientX, y = this.clientY) { upAt(x = this.clientX, y = this.clientY) {
this.clientX = x; this.clientX = x;
this.clientY = y; this.clientY = y;
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent()); fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
} }
clickAt(x: number, y: number) { clickAt(x: number, y: number) {
@ -180,7 +180,7 @@ export class Pointer {
} }
rightClickAt(x: number, y: number) { rightClickAt(x: number, y: number) {
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
clientX: x, clientX: x,
clientY: y, clientY: y,
@ -189,7 +189,7 @@ export class Pointer {
doubleClickAt(x: number, y: number) { doubleClickAt(x: number, y: number) {
this.moveTo(x, y); 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 = () => { static queryContextMenu = () => {
return GlobalTestState.renderResult.container.querySelector( return GlobalTestState.renderResult.container.querySelector(
".context-menu", ".context-menu",

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ import { vi } from "vitest";
const { h } = window; const { h } = window;
const renderScene = vi.spyOn(Renderer, "renderScene"); const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1); const finger1 = new Pointer("touch", 1);
@ -33,7 +33,7 @@ const finger2 = new Pointer("touch", 2);
* to debug where a test failure came from. * to debug where a test failure came from.
*/ */
const checkpoint = (name: string) => { const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot( expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`, `[${name}] number of renders`,
); );
expect(h.state).toMatchSnapshot(`[${name}] appState`); expect(h.state).toMatchSnapshot(`[${name}] appState`);
@ -48,7 +48,7 @@ beforeEach(async () => {
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderStaticScene.mockClear();
reseed(7); reseed(7);
setDateTimeForTests("201933152653"); setDateTimeForTests("201933152653");
@ -1056,6 +1056,28 @@ describe("regression tests", () => {
expect(API.getSelectedElements()).toEqual(selectedElements_prev); 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", () => { it("Cmd/Ctrl-click exclusively select element under pointer", () => {
const rect1 = UI.createElement("rectangle", { x: 0 }); const rect1 = UI.createElement("rectangle", { x: 0 });
const rect2 = UI.createElement("rectangle", { x: 30 }); const rect2 = UI.createElement("rectangle", { x: 30 });

View File

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

View File

@ -18,10 +18,13 @@ import { vi } from "vitest";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("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(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7); reseed(7);
}); });
@ -201,7 +204,7 @@ describe("inner box-selection", () => {
}); });
h.elements = [rect1, rect2, rect3]; h.elements = [rect1, rect2, rect3];
Keyboard.withModifierKeys({ ctrl: true }, () => { 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); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]); assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true }); expect(h.state.selectedGroupIds).toEqual({ A: true });
@ -220,10 +223,11 @@ describe("selection element", () => {
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
expect(renderScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!; const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull(); expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection"); expect(selectionElement.type).toEqual("selection");
@ -240,11 +244,12 @@ describe("selection element", () => {
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!; const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull(); expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection"); expect(selectionElement.type).toEqual("selection");
@ -261,12 +266,13 @@ describe("selection element", () => {
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
}); });
}); });
@ -282,7 +288,7 @@ describe("select single element on the scene", () => {
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
const tool = getByToolName("rectangle"); const tool = getByToolName("rectangle");
@ -301,7 +307,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -311,7 +318,7 @@ describe("select single element on the scene", () => {
it("diamond", async () => { it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
const tool = getByToolName("diamond"); const tool = getByToolName("diamond");
@ -330,7 +337,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -340,7 +348,7 @@ describe("select single element on the scene", () => {
it("ellipse", async () => { it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
const tool = getByToolName("ellipse"); const tool = getByToolName("ellipse");
@ -359,7 +367,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -369,7 +378,7 @@ describe("select single element on the scene", () => {
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
@ -401,7 +410,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -410,7 +420,7 @@ describe("select single element on the scene", () => {
it("arrow escape", async () => { it("arrow escape", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
const tool = getByToolName("line"); const tool = getByToolName("line");
@ -442,7 +452,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); 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 // child App component isn't likely mounted yet (and thus canvas not
// present in DOM) // present in DOM)
get() { 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(() => { await waitFor(() => {
const canvas = renderResult.container.querySelector("canvas"); const canvas = renderResult.container.querySelector("canvas.static");
if (!canvas) { if (!canvas) {
throw new Error("not initialized yet"); throw new Error("not initialized yet");
} }
const interactiveCanvas =
renderResult.container.querySelector("canvas.interactive");
if (!interactiveCanvas) {
throw new Error("not initialized yet");
}
}); });
return renderResult; return renderResult;
@ -81,11 +96,17 @@ export class GlobalTestState {
*/ */
static renderResult: RenderResult<typeof customQueries> = null!; 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 { static get canvas(): HTMLCanvasElement {
return null!; return null!;
} }
/**
* retrieves interactive canvas for currently rendered app instance
*/
static get interactiveCanvas(): HTMLCanvasElement {
return null!;
}
} }
const initLocalStorage = (data: ImportedDataState) => { 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 () => { it("after switching to view mode cursor type should be pointer", async () => {
h.setState({ viewModeEnabled: true }); 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 () => { 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.move(100, 100);
pointer.click(); pointer.click();
Keyboard.keyPress(KEYS.SPACE); 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); pointer.moveTo(50, 50);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
if (pointerType["pointerType"] === "mouse") { if (pointerType["pointerType"] === "mouse") {
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.MOVE); expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.MOVE,
);
} else { } else {
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB); expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.GRAB,
);
} }
h.setState({ viewModeEnabled: true }); 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, ...appState,
selectedElementIds, selectedElementIds,
}); } as AppState);
return selectedElementIds; return selectedElementIds;
}; };

View File

@ -104,6 +104,52 @@ export type LastActiveTool =
export type SidebarName = string; export type SidebarName = string;
export type SidebarTabName = 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 = { export type AppState = {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@ -407,13 +453,13 @@ export type ExportOpts = {
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
files: BinaryFiles, files: BinaryFiles,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement,
) => void; ) => void;
renderCustomUI?: ( renderCustomUI?: (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
files: BinaryFiles, files: BinaryFiles,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement,
) => JSX.Element; ) => JSX.Element;
}; };
@ -458,7 +504,8 @@ export type AppProps = Merge<
* in the app, eg Manager. Factored out into a separate type to keep DRY. */ * in the app, eg Manager. Factored out into a separate type to keep DRY. */
export type AppClassProperties = { export type AppClassProperties = {
props: AppProps; props: AppProps;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
focusContainer(): void; focusContainer(): void;
library: Library; library: Library;
imageCache: Map< imageCache: Map<

View File

@ -20,6 +20,7 @@ import { unstable_batchedUpdates } from "react-dom";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { isEraserActive, isHandToolActive } from "./appState"; import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types"; import { ResolutionType } from "./utility-types";
import React from "react";
let mockDateTime: string | null = null; let mockDateTime: string | null = null;
@ -399,22 +400,25 @@ export const updateActiveTool = (
}; };
}; };
export const resetCursor = (canvas: HTMLCanvasElement | null) => { export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
if (canvas) { if (interactiveCanvas) {
canvas.style.cursor = ""; interactiveCanvas.style.cursor = "";
} }
}; };
export const setCursor = (canvas: HTMLCanvasElement | null, cursor: string) => { export const setCursor = (
if (canvas) { interactiveCanvas: HTMLCanvasElement | null,
canvas.style.cursor = cursor; cursor: string,
) => {
if (interactiveCanvas) {
interactiveCanvas.style.cursor = cursor;
} }
}; };
let eraserCanvasCache: any; let eraserCanvasCache: any;
let previewDataURL: string; let previewDataURL: string;
export const setEraserCursor = ( export const setEraserCursor = (
canvas: HTMLCanvasElement | null, interactiveCanvas: HTMLCanvasElement | null,
theme: AppState["theme"], theme: AppState["theme"],
) => { ) => {
const cursorImageSizePx = 20; const cursorImageSizePx = 20;
@ -446,7 +450,7 @@ export const setEraserCursor = (
} }
setCursor( setCursor(
canvas, interactiveCanvas,
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${ `url(${previewDataURL}) ${cursorImageSizePx / 2} ${
cursorImageSizePx / 2 cursorImageSizePx / 2
}, auto`, }, auto`,
@ -454,23 +458,23 @@ export const setEraserCursor = (
}; };
export const setCursorForShape = ( export const setCursorForShape = (
canvas: HTMLCanvasElement | null, interactiveCanvas: HTMLCanvasElement | null,
appState: Pick<AppState, "activeTool" | "theme">, appState: Pick<AppState, "activeTool" | "theme">,
) => { ) => {
if (!canvas) { if (!interactiveCanvas) {
return; return;
} }
if (appState.activeTool.type === "selection") { if (appState.activeTool.type === "selection") {
resetCursor(canvas); resetCursor(interactiveCanvas);
} else if (isHandToolActive(appState)) { } else if (isHandToolActive(appState)) {
canvas.style.cursor = CURSOR_TYPE.GRAB; interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
} else if (isEraserActive(appState)) { } else if (isEraserActive(appState)) {
setEraserCursor(canvas, appState.theme); setEraserCursor(interactiveCanvas, appState.theme);
// do nothing if image tool is selected which suggests there's // do nothing if image tool is selected which suggests there's
// a image-preview set as the cursor // a image-preview set as the cursor
// Ignore custom type as well and let host decide // Ignore custom type as well and let host decide
} else if (!["image", "custom"].includes(appState.activeTool.type)) { } 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); 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" test-exclude "^6.0.0"
v8-to-istanbul "^9.1.0" v8-to-istanbul "^9.1.0"
"@vitest/expect@0.32.2": "@vitest/expect@0.34.1":
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.32.2.tgz#8111f6ab1ff3b203efbe3a25e8bb2d160ce4b720" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.1.tgz#2ba6cb96695f4b4388c6d955423a81afc79b8da0"
integrity sha512-6q5yzweLnyEv5Zz1fqK5u5E83LU+gOMVBDuxBl2d2Jfx1BAp5M+rZgc5mlyqdnxquyoiOXpXmFNkcGcfFnFH3Q== integrity sha512-q2CD8+XIsQ+tHwypnoCk8Mnv5e6afLFvinVGCq3/BOT4kQdVQmY6rRfyKkwcg635lbliLPqbunXZr+L1ssUWiQ==
dependencies: dependencies:
"@vitest/spy" "0.32.2" "@vitest/spy" "0.34.1"
"@vitest/utils" "0.32.2" "@vitest/utils" "0.34.1"
chai "^4.3.7" chai "^4.3.7"
"@vitest/runner@0.32.2": "@vitest/runner@0.34.1":
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.32.2.tgz#18dd979ce4e8766bcc90948d11b4c8ae6ed90b89" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.1.tgz#23c21ba1db8bff610988c72744db590d0fb6c4ba"
integrity sha512-06vEL0C1pomOEktGoLjzZw+1Fb+7RBRhmw/06WkDrd1akkT9i12su0ku+R/0QM69dfkIL/rAIDTG+CSuQVDcKw== integrity sha512-YfQMpYzDsYB7yqgmlxZ06NI4LurHWfrH7Wy3Pvf/z/vwUSgq1zLAb1lWcItCzQG+NVox+VvzlKQrYEXb47645g==
dependencies: dependencies:
"@vitest/utils" "0.32.2" "@vitest/utils" "0.34.1"
concordance "^5.0.4"
p-limit "^4.0.0" p-limit "^4.0.0"
pathe "^1.1.0" pathe "^1.1.1"
"@vitest/snapshot@0.32.2": "@vitest/snapshot@0.34.1":
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.32.2.tgz#500b6453e88e4c50a0aded39839352c16b519b9e" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.1.tgz#814c65f8e714eaf255f47838541004b2a2ba28e6"
integrity sha512-JwhpeH/PPc7GJX38vEfCy9LtRzf9F4er7i4OsAJyV7sjPwjj+AIR8cUgpMTWK4S3TiamzopcTyLsZDMuldoi5A== integrity sha512-0O9LfLU0114OqdF8lENlrLsnn024Tb1CsS9UwG0YMWY2oGTQfPtkW+B/7ieyv0X9R2Oijhi3caB1xgGgEgclSQ==
dependencies: dependencies:
magic-string "^0.30.0" magic-string "^0.30.1"
pathe "^1.1.0" pathe "^1.1.1"
pretty-format "^27.5.1" pretty-format "^29.5.0"
"@vitest/spy@0.32.2": "@vitest/spy@0.34.1":
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.32.2.tgz#f3ef7afe0d34e863b90df7c959fa5af540a6aaf9" resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.1.tgz#2f77234a3d554c5dea664943f2caaab92d304f3c"
integrity sha512-Q/ZNILJ4ca/VzQbRM8ur3Si5Sardsh1HofatG9wsJY1RfEaw0XKP8IVax2lI1qnrk9YPuG9LA2LkZ0EI/3d4ug== integrity sha512-UT4WcI3EAPUNO8n6y9QoEqynGGEPmmRxC+cLzneFFXpmacivjHZsNbiKD88KUScv5DCHVDgdBsLD7O7s1enFcQ==
dependencies: dependencies:
tinyspy "^2.1.0" tinyspy "^2.1.1"
"@vitest/ui@0.32.2": "@vitest/ui@0.32.2":
version "0.32.2" version "0.32.2"
@ -2870,6 +2869,15 @@
loupe "^2.3.6" loupe "^2.3.6"
pretty-format "^27.5.1" 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: abab@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" 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" resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig== 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: brace-expansion@^1.1.7:
version "1.1.11" version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 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" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== 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: confusing-browser-globals@^1.0.11:
version "1.0.11" version "1.0.11"
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" 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-mimetype "^3.0.0"
whatwg-url "^12.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: 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" version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 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" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
esutils@^2.0.2, esutils@^2.0.3: esutils@^2.0.2:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== 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" resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== 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: fast-glob@^3.2.12, fast-glob@^3.2.9:
version "3.2.12" version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" 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" resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.13.1.tgz#20cc46454cbb39096b12fddfa635b873b3668236"
integrity sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw== 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: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -5561,13 +5533,6 @@ magic-string@^0.27.0:
dependencies: dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13" "@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: magic-string@^0.30.1:
version "0.30.2" version "0.30.2"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.2.tgz#dcf04aad3d0d1314bc743d076c50feb29b3c7aca" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.2.tgz#dcf04aad3d0d1314bc743d076c50feb29b3c7aca"
@ -5582,13 +5547,6 @@ make-dir@^4.0.0:
dependencies: dependencies:
semver "^7.5.3" 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: merge-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -5660,7 +5618,7 @@ mkdirp@^0.5.6:
dependencies: dependencies:
minimist "^1.2.6" minimist "^1.2.6"
mlly@^1.2.0: mlly@^1.2.0, mlly@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg== 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" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 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" version "7.4.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318" resolved "https://registry.yarnpkg.com/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318"
integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw== 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" resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== 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" version "3.3.3"
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe"
integrity sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg== 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" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== 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: tiny-invariant@^1.1.0:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" 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" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.0.tgz#4711c99bbf6f3e986f67eb722fed9cddb3a68ba5"
integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA== integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==
tinypool@^0.5.0: tinypool@^0.7.0:
version "0.5.0" version "0.7.0"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.5.0.tgz#3861c3069bf71e4f1f5aa2d2e6b3aaacc278961e" resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021"
integrity sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ== integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==
tinyspy@^2.1.0: tinyspy@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.1.1.tgz#9e6371b00c259e5c5b301917ca18c01d40ae558c" resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.1.1.tgz#9e6371b00c259e5c5b301917ca18c01d40ae558c"
integrity sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w== integrity sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==
@ -7231,15 +7184,15 @@ v8-to-istanbul@^9.1.0:
"@types/istanbul-lib-coverage" "^2.0.1" "@types/istanbul-lib-coverage" "^2.0.1"
convert-source-map "^1.6.0" convert-source-map "^1.6.0"
vite-node@0.32.2: vite-node@0.34.1:
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.2.tgz#bfccdfeb708b2309ea9e5fe424951c75bb9c0096" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.1.tgz#144900ca4bd54cc419c501d671350bcbc07eb1ee"
integrity sha512-dTQ1DCLwl2aEseov7cfQ+kDMNJpM1ebpyMMMwWzBvLbis8Nla/6c9WQcqpPssTwS6Rp/+U6KwlIj8Eapw4bLdA== integrity sha512-odAZAL9xFMuAg8aWd7nSPT+hU8u2r9gU3LRm9QKjxBEF2rRdWpMuqkrkjvyVQEdNFiBctqr2Gg4uJYizm5Le6w==
dependencies: dependencies:
cac "^6.7.14" cac "^6.7.14"
debug "^4.3.4" debug "^4.3.4"
mlly "^1.2.0" mlly "^1.4.0"
pathe "^1.1.0" pathe "^1.1.1"
picocolors "^1.0.0" picocolors "^1.0.0"
vite "^3.0.0 || ^4.0.0" vite "^3.0.0 || ^4.0.0"
@ -7321,35 +7274,34 @@ vitest-canvas-mock@0.3.2:
dependencies: dependencies:
jest-canvas-mock "~2.4.0" jest-canvas-mock "~2.4.0"
vitest@0.32.2: vitest@0.34.1:
version "0.32.2" version "0.34.1"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.32.2.tgz#758ce2220f609e240ac054eca7ad11a5140679ab" resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.1.tgz#3ad7f845e7a9fb0d72ab703cae832a54b8469e1e"
integrity sha512-hU8GNNuQfwuQmqTLfiKcqEhZY72Zxb7nnN07koCUNmntNxbKQnVbeIS6sqUgR3eXSlbOpit8+/gr1KpqoMgWCQ== integrity sha512-G1PzuBEq9A75XSU88yO5G4vPT20UovbC/2osB2KEuV/FisSIIsw7m5y2xMdB7RsAGHAfg2lPmp2qKr3KWliVlQ==
dependencies: dependencies:
"@types/chai" "^4.3.5" "@types/chai" "^4.3.5"
"@types/chai-subset" "^1.3.3" "@types/chai-subset" "^1.3.3"
"@types/node" "*" "@types/node" "*"
"@vitest/expect" "0.32.2" "@vitest/expect" "0.34.1"
"@vitest/runner" "0.32.2" "@vitest/runner" "0.34.1"
"@vitest/snapshot" "0.32.2" "@vitest/snapshot" "0.34.1"
"@vitest/spy" "0.32.2" "@vitest/spy" "0.34.1"
"@vitest/utils" "0.32.2" "@vitest/utils" "0.34.1"
acorn "^8.8.2" acorn "^8.9.0"
acorn-walk "^8.2.0" acorn-walk "^8.2.0"
cac "^6.7.14" cac "^6.7.14"
chai "^4.3.7" chai "^4.3.7"
concordance "^5.0.4"
debug "^4.3.4" debug "^4.3.4"
local-pkg "^0.4.3" local-pkg "^0.4.3"
magic-string "^0.30.0" magic-string "^0.30.1"
pathe "^1.1.0" pathe "^1.1.1"
picocolors "^1.0.0" picocolors "^1.0.0"
std-env "^3.3.2" std-env "^3.3.3"
strip-literal "^1.0.1" strip-literal "^1.0.1"
tinybench "^2.5.0" tinybench "^2.5.0"
tinypool "^0.5.0" tinypool "^0.7.0"
vite "^3.0.0 || ^4.0.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" why-is-node-running "^2.2.2"
vscode-jsonrpc@6.0.0: 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" resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g== 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: whatwg-encoding@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" 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" stackback "0.0.2"
word-wrap@^1.2.3: word-wrap@^1.2.3:
version "1.2.5" version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
workbox-background-sync@7.0.0: workbox-background-sync@7.0.0:
version "7.0.0" version "7.0.0"