Merge branch 'master' of github.com:excalidraw/excalidraw into arnost/scroll-in-read-only-links

This commit is contained in:
Arnošt Pleskot 2023-09-07 13:52:29 +02:00
commit 53a88d4c7a
No known key found for this signature in database
96 changed files with 9129 additions and 4501 deletions

View file

@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well. Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
:::note
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
:::
## Translating ## Translating

View file

@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.15.2", "@excalidraw/excalidraw": "0.15.3",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",

View file

@ -1631,10 +1631,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.15.2": "@excalidraw/excalidraw@0.15.3":
version "0.15.2" version "0.15.3"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.3.tgz#5dea570f76451adf68bc24d4bfdd67a375cfeab1"
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw== integrity sha512-/gpY7fgMO/AEaFLWnPqzbY8H7ly+/zocFf7D0Is5sWNMD2mhult5tana12lXKLSJ6EAz7ubo1A7LajXzvJXJDA==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"

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

@ -24,6 +24,7 @@ export interface ClipboardData {
files?: BinaryFiles; files?: BinaryFiles;
text?: string; text?: string;
errorMessage?: string; errorMessage?: string;
programmaticAPI?: boolean;
} }
let CLIPBOARD = ""; let CLIPBOARD = "";
@ -48,6 +49,7 @@ const clipboardContainsElements = (
[ [
EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard, EXPORT_DATA_TYPES.excalidrawClipboard,
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
].includes(contents?.type) && ].includes(contents?.type) &&
Array.isArray(contents.elements) Array.isArray(contents.elements)
) { ) {
@ -191,6 +193,8 @@ export const parseClipboard = async (
try { try {
const systemClipboardData = JSON.parse(systemClipboard); const systemClipboardData = JSON.parse(systemClipboard);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) { if (clipboardContainsElements(systemClipboardData)) {
return { return {
elements: systemClipboardData.elements, elements: systemClipboardData.elements,
@ -198,6 +202,7 @@ export const parseClipboard = async (
text: isPlainPaste text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2) ? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined, : undefined,
programmaticAPI,
}; };
} }
} catch (e) {} } catch (e) {}

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,226 @@
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 = {
containerRef: React.RefObject<HTMLDivElement>;
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 =
(props.containerRef?.current &&
getComputedStyle(props.containerRef.current).getPropertyValue(
"--color-selection",
)) ||
"#6965db";
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,
): InteractiveCanvasAppState => ({
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,125 @@
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");
}
const widthString = `${props.appState.width}px`;
const heightString = `${props.appState.height}px`;
if (canvas.style.width !== widthString) {
canvas.style.width = widthString;
}
if (canvas.style.height !== heightString) {
canvas.style.height = heightString;
}
const scaledWidth = props.appState.width * props.scale;
const scaledHeight = props.appState.height * props.scale;
// setting width/height resets the canvas even if dimensions not changed,
// which would cause flicker when we skip frame (due to throttling)
if (canvas.width !== scaledWidth) {
canvas.width = scaledWidth;
}
if (canvas.height !== scaledHeight) {
canvas.height = scaledHeight;
}
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,
): StaticCanvasAppState => ({
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,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
});
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

@ -117,6 +117,7 @@ export const FRAME_STYLE = {
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
@ -163,6 +164,7 @@ export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard", excalidrawClipboard: "excalidraw/clipboard",
excalidrawLibrary: "excalidrawlib", excalidrawLibrary: "excalidrawlib",
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const; } as const;
export const EXPORT_SOURCE = export const EXPORT_SOURCE =
@ -239,6 +241,8 @@ export const VERSIONS = {
} as const; } as const;
export const BOUND_TEXT_PADDING = 5; export const BOUND_TEXT_PADDING = 5;
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
export const VERTICAL_ALIGN = { export const VERTICAL_ALIGN = {
TOP: "top", TOP: "top",

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

File diff suppressed because it is too large Load diff

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

@ -29,6 +29,7 @@ import {
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
DEFAULT_ELEMENT_PROPS,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -41,7 +42,6 @@ import {
getDefaultLineHeight, getDefaultLineHeight,
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
type RestoredAppState = Omit< type RestoredAppState = Omit<
@ -122,16 +122,18 @@ const restoreElementWithProperties = <
versionNonce: element.versionNonce ?? 0, versionNonce: element.versionNonce ?? 0,
isDeleted: element.isDeleted ?? false, isDeleted: element.isDeleted ?? false,
id: element.id || randomId(), id: element.id || randomId(),
fillStyle: element.fillStyle || "hachure", fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
strokeWidth: element.strokeWidth || 1, strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
strokeStyle: element.strokeStyle ?? "solid", strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
roughness: element.roughness ?? 1, roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity: element.opacity == null ? 100 : element.opacity, opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
angle: element.angle || 0, angle: element.angle || 0,
x: extra.x ?? element.x ?? 0, x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0, y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || COLOR_PALETTE.black, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent, backgroundColor:
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
width: element.width || 0, width: element.width || 0,
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
@ -246,7 +248,6 @@ const restoreElement = (
startArrowhead = null, startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null, endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element; } = element;
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@ -286,7 +287,7 @@ const restoreElement = (
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "embeddable": case "embeddable":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
validated: undefined, validated: null,
}); });
case "frame": case "frame":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@ -410,7 +411,6 @@ export const restoreElements = (
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => { const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
@ -429,6 +429,7 @@ export const restoreElements = (
migratedElement = { ...migratedElement, id: randomId() }; migratedElement = { ...migratedElement, id: randomId() };
} }
existingIds.add(migratedElement.id); existingIds.add(migratedElement.id);
elements.push(migratedElement); elements.push(migratedElement);
} }
} }

706
src/data/transform.test.ts Normal file
View file

@ -0,0 +1,706 @@
import { vi } from "vitest";
import {
ExcalidrawElementSkeleton,
convertToExcalidrawElements,
} from "./transform";
import { ExcalidrawArrowElement } from "../element/types";
describe("Test Transform", () => {
it("should transform regular shapes", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
},
{
type: "ellipse",
x: 100,
y: 250,
},
{
type: "diamond",
x: 100,
y: 400,
},
{
type: "rectangle",
x: 300,
y: 100,
width: 200,
height: 100,
backgroundColor: "#c0eb75",
strokeWidth: 2,
},
{
type: "ellipse",
x: 300,
y: 250,
width: 200,
height: 100,
backgroundColor: "#ffc9c9",
strokeStyle: "dotted",
fillStyle: "solid",
strokeWidth: 2,
},
{
type: "diamond",
x: 300,
y: 400,
width: 200,
height: 100,
backgroundColor: "#a5d8ff",
strokeColor: "#1971c2",
strokeStyle: "dashed",
fillStyle: "cross-hatch",
strokeWidth: 2,
},
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform text element", () => {
const elements = [
{
type: "text",
x: 100,
y: 100,
text: "HELLO WORLD!",
},
{
type: "text",
x: 100,
y: 150,
text: "STYLED HELLO WORLD!",
fontSize: 20,
strokeColor: "#5f3dc4",
},
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform linear elements", () => {
const elements = [
{
type: "arrow",
x: 100,
y: 20,
},
{
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
},
{
type: "line",
x: 100,
y: 60,
},
{
type: "line",
x: 450,
y: 60,
strokeColor: "#2f9e44",
strokeWidth: 2,
strokeStyle: "dotted",
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform to text containers when label provided", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
label: {
text: "RECTANGLE TEXT CONTAINER",
},
},
{
type: "ellipse",
x: 500,
y: 100,
width: 200,
label: {
text: "ELLIPSE TEXT CONTAINER",
},
},
{
type: "diamond",
x: 100,
y: 150,
width: 280,
label: {
text: "DIAMOND\nTEXT CONTAINER",
},
},
{
type: "diamond",
x: 100,
y: 400,
width: 300,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "STYLED DIAMOND TEXT CONTAINER",
strokeColor: "#099268",
fontSize: 20,
},
},
{
type: "rectangle",
x: 500,
y: 300,
width: 200,
strokeColor: "#c2255c",
label: {
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
textAlign: "left",
verticalAlign: "top",
fontSize: 20,
},
},
{
type: "ellipse",
x: 500,
y: 500,
strokeColor: "#f08c00",
backgroundColor: "#ffec99",
width: 200,
label: {
text: "STYLED ELLIPSE TEXT CONTAINER",
strokeColor: "#c2255c",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(12);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform to labelled arrows when label provided for arrows", () => {
const elements = [
{
type: "arrow",
x: 100,
y: 100,
label: {
text: "LABELED ARROW",
},
},
{
type: "arrow",
x: 100,
y: 200,
label: {
text: "STYLED LABELED ARROW",
strokeColor: "#099268",
fontSize: 20,
},
},
{
type: "arrow",
x: 100,
y: 300,
strokeColor: "#1098ad",
strokeWidth: 2,
label: {
text: "ANOTHER STYLED LABELLED ARROW",
},
},
{
type: "arrow",
x: 100,
y: 400,
strokeColor: "#1098ad",
strokeWidth: 2,
label: {
text: "ANOTHER STYLED LABELLED ARROW",
strokeColor: "#099268",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(8);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
describe("Test arrow bindings", () => {
it("should bind arrows to shapes when start / end provided without ids", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
type: "rectangle",
},
end: {
type: "ellipse",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text, rectangle, ellipse] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [{ id: text.id, type: "text" }],
startBinding: {
elementId: rectangle.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: ellipse.id,
focus: 0,
},
});
expect(text).toMatchObject({
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
containerId: arrow.id,
});
expect(rectangle).toMatchObject({
x: 155,
y: 189,
type: "rectangle",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
expect(ellipse).toMatchObject({
x: 555,
y: 189,
type: "ellipse",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to text when start / end provided without ids", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
type: "text",
text: "HEYYYYY",
},
end: {
type: "text",
text: "WHATS UP ?",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [{ id: text1.id, type: "text" }],
startBinding: {
elementId: text2.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: text3.id,
focus: 0,
},
});
expect(text1).toMatchObject({
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
containerId: arrow.id,
});
expect(text2).toMatchObject({
x: 185,
y: 226.5,
type: "text",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
expect(text3).toMatchObject({
x: 555,
y: 226.5,
type: "text",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing shapes when start / end provided with ids", () => {
const elements = [
{
type: "ellipse",
id: "ellipse-1",
strokeColor: "#66a80f",
x: 630,
y: 316,
width: 300,
height: 300,
backgroundColor: "#d8f5a2",
},
{
type: "diamond",
id: "diamond-1",
strokeColor: "#9c36b5",
width: 140,
x: 96,
y: 400,
},
{
type: "arrow",
x: 247,
y: 420,
width: 395,
height: 35,
strokeColor: "#1864ab",
start: {
type: "rectangle",
width: 300,
height: 300,
},
end: {
id: "ellipse-1",
},
},
{
type: "arrow",
x: 227,
y: 450,
width: 400,
strokeColor: "#e67700",
start: {
id: "diamond-1",
},
end: {
id: "ellipse-1",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(5);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing text elements when start / end provided with ids", () => {
const elements = [
{
x: 100,
y: 239,
type: "text",
text: "HEYYYYY",
id: "text-1",
strokeColor: "#c2255c",
},
{
type: "text",
id: "text-2",
x: 560,
y: 239,
text: "Whats up ?",
},
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
id: "text-1",
},
end: {
id: "text-2",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing elements if ids are correct", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementationOnce(() => void 0);
const elements = [
{
x: 100,
y: 239,
type: "text",
text: "HEYYYYY",
id: "text-1",
strokeColor: "#c2255c",
},
{
type: "rectangle",
x: 560,
y: 139,
id: "rect-1",
width: 100,
height: 200,
backgroundColor: "#bac8ff",
},
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
id: "text-13",
},
end: {
id: "rect-11",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(4);
const [, , arrow] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [
{
id: "id46",
type: "text",
},
],
startBinding: null,
endBinding: null,
});
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
1,
"No element for start binding with id text-13 found",
);
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
2,
"No element for end binding with id rect-11 found",
);
});
it("should bind when ids referenced before the element data", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
end: {
id: "rect-1",
},
},
{
type: "rectangle",
x: 560,
y: 139,
id: "rect-1",
width: 100,
height: 200,
backgroundColor: "#bac8ff",
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
gap: 5,
});
expect(rect.boundElements).toStrictEqual([
{
id: "id47",
type: "arrow",
},
]);
});
});
it("should not allow duplicate ids", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementationOnce(() => void 0);
const elements = [
{
type: "rectangle",
x: 300,
y: 100,
id: "rect-1",
width: 100,
height: 200,
},
{
type: "rectangle",
x: 100,
y: 200,
id: "rect-1",
width: 100,
height: 200,
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(excaldrawElements.length).toBe(1);
expect(excaldrawElements[0]).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Duplicate id found for rect-1",
);
});
});

561
src/data/transform.ts Normal file
View file

@ -0,0 +1,561 @@
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
import {
newElement,
newLinearElement,
redrawTextBoundingBox,
} from "../element";
import { bindLinearElement } from "../element/binding";
import {
ElementConstructorOpts,
newImageElement,
newTextElement,
} from "../element/newElement";
import {
getDefaultLineHeight,
measureText,
normalizeText,
} from "../element/textElement";
import {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FileId,
FontFamilyValues,
TextAlign,
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils";
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
y: number;
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
end?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<ExcalidrawTextElement>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
start?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<ExcalidrawTextElement>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
} & Partial<ExcalidrawLinearElement>;
export type ValidContainer =
| {
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
id?: ExcalidrawGenericElement["id"];
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
} & ElementConstructorOpts;
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
| ExcalidrawEmbeddableElement
| ExcalidrawFreeDrawElement
| ExcalidrawFrameElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
x: number;
y: number;
} & Partial<ExcalidrawLinearElement>)
| ValidContainer
| ValidLinearElement
| ({
type: "text";
text: string;
x: number;
y: number;
id?: ExcalidrawTextElement["id"];
} & Partial<ExcalidrawTextElement>)
| ({
type: Extract<ExcalidrawImageElement["type"], "image">;
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 300,
height: 0,
};
const DEFAULT_DIMENSION = 100;
const bindTextToContainer = (
container: ExcalidrawElement,
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
) => {
const textElement: ExcalidrawTextElement = newTextElement({
x: 0,
y: 0,
textAlign: TEXT_ALIGN.CENTER,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
...textProps,
containerId: container.id,
strokeColor: textProps.strokeColor || container.strokeColor,
});
Object.assign(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
return [container, textElement] as const;
};
const bindLinearElementToElement = (
linearElement: ExcalidrawArrowElement,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
endBinding: linearElement.endBinding || null,
});
if (start) {
const width = start?.width ?? DEFAULT_DIMENSION;
const height = start?.height ?? DEFAULT_DIMENSION;
let existingElement;
if (start.id) {
existingElement = elementStore.getElement(start.id);
if (!existingElement) {
console.error(`No element for start binding with id ${start.id} found`);
}
}
const startX = start.x || linearElement.x - width;
const startY = start.y || linearElement.y - height / 2;
const startType = existingElement ? existingElement.type : start.type;
if (startType) {
if (startType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (start.type === "text") {
text = start.text;
}
if (!text) {
console.error(
`No text found for start binding text element for ${linearElement.id}`,
);
}
startBoundElement = newTextElement({
x: startX,
y: startY,
type: "text",
...existingElement,
...start,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(startBoundElement, {
x: start.x || linearElement.x - startBoundElement.width,
y: start.y || linearElement.y - startBoundElement.height / 2,
});
} else {
switch (startType) {
case "rectangle":
case "ellipse":
case "diamond": {
startBoundElement = newElement({
x: startX,
y: startY,
width,
height,
...existingElement,
...start,
type: startType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element start type "${start.type}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"start",
);
}
}
if (end) {
const height = end?.height ?? DEFAULT_DIMENSION;
const width = end?.width ?? DEFAULT_DIMENSION;
let existingElement;
if (end.id) {
existingElement = elementStore.getElement(end.id);
if (!existingElement) {
console.error(`No element for end binding with id ${end.id} found`);
}
}
const endX = end.x || linearElement.x + linearElement.width;
const endY = end.y || linearElement.y - height / 2;
const endType = existingElement ? existingElement.type : end.type;
if (endType) {
if (endType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (end.type === "text") {
text = end.text;
}
if (!text) {
console.error(
`No text found for end binding text element for ${linearElement.id}`,
);
}
endBoundElement = newTextElement({
x: endX,
y: endY,
type: "text",
...existingElement,
...end,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(endBoundElement, {
y: end.y || linearElement.y - endBoundElement.height / 2,
});
} else {
switch (endType) {
case "rectangle":
case "ellipse":
case "diamond": {
endBoundElement = newElement({
x: endX,
y: endY,
width,
height,
...existingElement,
...end,
type: endType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element end type "${endType}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"end",
);
}
}
return {
linearElement,
startBoundElement,
endBoundElement,
};
};
class ElementStore {
excalidrawElements = new Map<string, ExcalidrawElement>();
add = (ele?: ExcalidrawElement) => {
if (!ele) {
return;
}
this.excalidrawElements.set(ele.id, ele);
};
getElements = () => {
return Array.from(this.excalidrawElements.values());
};
getElement = (id: string) => {
return this.excalidrawElements.get(id);
};
}
export const convertToExcalidrawElements = (
elements: ExcalidrawElementSkeleton[] | null,
) => {
if (!elements) {
return [];
}
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
// Create individual elements
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond": {
const width =
element?.label?.text && element.width === undefined
? 0
: element?.width || DEFAULT_DIMENSION;
const height =
element?.label?.text && element.height === undefined
? 0
: element?.height || DEFAULT_DIMENSION;
excalidrawElement = newElement({
...element,
width,
height,
});
break;
}
case "line": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({
width,
height,
points: [
[0, 0],
[width, height],
],
...element,
});
break;
}
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({
width,
height,
endArrowhead: "arrow",
points: [
[0, 0],
[width, height],
],
...element,
});
break;
}
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight =
element?.lineHeight || getDefaultLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
normalizedText,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
excalidrawElement = newTextElement({
width: metrics.width,
height: metrics.height,
fontFamily,
fontSize,
...element,
});
break;
}
case "image": {
excalidrawElement = newImageElement({
width: element?.width || DEFAULT_DIMENSION,
height: element?.height || DEFAULT_DIMENSION,
...element,
});
break;
}
case "freedraw":
case "frame":
case "embeddable": {
excalidrawElement = element;
break;
}
default: {
excalidrawElement = element;
assertNever(
element,
`Unhandled element type "${(element as any).type}"`,
true,
);
}
}
const existingElement = elementStore.getElement(excalidrawElement.id);
if (existingElement) {
console.error(`Duplicate id found for ${excalidrawElement.id}`);
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
}
}
// Add labels and arrow bindings
for (const [id, element] of elementsWithIds) {
const excalidrawElement = elementStore.getElement(id)!;
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond":
case "arrow": {
if (element.label?.text) {
let [container, text] = bindTextToContainer(
excalidrawElement,
element?.label,
);
elementStore.add(container);
elementStore.add(text);
if (container.type === "arrow") {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as ExcalidrawArrowElement,
originalStart,
originalEnd,
elementStore,
);
container = linearElement;
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
}
} else {
switch (element.type) {
case "arrow": {
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
element.start,
element.end,
elementStore,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
break;
}
}
}
break;
}
}
}
return elementStore.getElements();
};

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

@ -190,7 +190,7 @@ export const maybeBindLinearElement = (
} }
}; };
const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
@ -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 "../scene/Shape";
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;
@ -264,11 +269,11 @@ export class LinearElementEditor {
}; };
}), }),
); );
}
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
handleBindTextResize(element, false); handleBindTextResize(element, false);
}
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
@ -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

@ -46,7 +46,7 @@ import {
} from "../constants"; } from "../constants";
import { MarkOptional, Merge, Mutable } from "../utility-types"; import { MarkOptional, Merge, Mutable } from "../utility-types";
type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
| "width" | "width"
| "height" | "height"
@ -134,7 +134,7 @@ export const newElement = (
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
type: "embeddable"; type: "embeddable";
validated: boolean | undefined; validated: ExcalidrawEmbeddableElement["validated"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawEmbeddableElement> => { ): NonDeleted<ExcalidrawEmbeddableElement> => {
return { return {
@ -187,7 +187,7 @@ export const newTextElement = (
fontFamily?: FontFamilyValues; fontFamily?: FontFamilyValues;
textAlign?: TextAlign; textAlign?: TextAlign;
verticalAlign?: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
@ -361,8 +361,8 @@ export const newFreeDrawElement = (
export const newLinearElement = ( export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
startArrowhead: Arrowhead | null; startArrowhead?: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
@ -372,8 +372,8 @@ export const newLinearElement = (
lastCommittedPoint: null, lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead, startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead, endArrowhead: opts.endArrowhead || null,
}; };
}; };
@ -477,7 +477,7 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
* utility wrapper to generate new id. In test env it reuses the old + postfix * utility wrapper to generate new id. In test env it reuses the old + postfix
* for test assertions. * for test assertions.
*/ */
const regenerateId = ( export const regenerateId = (
/** supply null if no previous id exists */ /** supply null if no previous id exists */
previousId: string | null, previousId: string | null,
) => { ) => {

View file

@ -1,4 +1,4 @@
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { import {
@ -204,8 +204,6 @@ const rescalePointsInElement = (
} }
: {}; : {};
const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
@ -589,24 +587,42 @@ export const resizeSingleElement = (
}); });
} }
if (
isArrowElement(element) &&
boundTextElement &&
shouldMaintainAspectRatio
) {
const fontSize =
(resizedElement.width / element.width) * boundTextElement.fontSize;
if (fontSize < MIN_FONT_SIZE) {
return;
}
boundTextFont.fontSize = fontSize;
}
if ( if (
resizedElement.width !== 0 && resizedElement.width !== 0 &&
resizedElement.height !== 0 && resizedElement.height !== 0 &&
Number.isFinite(resizedElement.x) && Number.isFinite(resizedElement.x) &&
Number.isFinite(resizedElement.y) Number.isFinite(resizedElement.y)
) { ) {
mutateElement(element, resizedElement);
updateBoundElements(element, { updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height }, newSize: { width: resizedElement.width, height: resizedElement.height },
}); });
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline, baseline: boundTextFont.baseline,
}); });
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(
element,
transformHandleDirection,
shouldMaintainAspectRatio,
);
} }
}; };
@ -722,12 +738,8 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
baseline?: ExcalidrawTextElement["baseline"]; baseline?: ExcalidrawTextElement["baseline"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
}; };
boundText: {
element: ExcalidrawTextElementWithContainer;
fontSize: ExcalidrawTextElement["fontSize"];
baseline: ExcalidrawTextElement["baseline"];
} | null;
}[] = []; }[] = [];
for (const { orig, latest } of targetElements) { for (const { orig, latest } of targetElements) {
@ -798,50 +810,39 @@ export const resizeMultipleElements = (
} }
} }
let boundText: typeof elementsAndUpdates[0]["boundText"] = null; if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(orig, width, height);
const boundTextElement = getBoundTextElement(latest);
if (boundTextElement || isTextElement(orig)) {
const updatedElement = {
...latest,
width,
height,
};
const metrics = measureFontSizeFromWidth(
boundTextElement ?? (orig as ExcalidrawTextElement),
boundTextElement
? getBoundTextMaxWidth(updatedElement)
: updatedElement.width,
boundTextElement
? getBoundTextMaxHeight(updatedElement, boundTextElement)
: updatedElement.height,
);
if (!metrics) { if (!metrics) {
return; return;
} }
update.fontSize = metrics.size;
if (isTextElement(orig)) { update.baseline = metrics.baseline;
update.fontSize = metrics.size;
update.baseline = metrics.baseline;
}
if (boundTextElement) {
boundText = {
element: boundTextElement,
fontSize: metrics.size,
baseline: metrics.baseline,
};
}
} }
elementsAndUpdates.push({ element: latest, update, boundText }); const boundTextElement = pointerDownState.originalElements.get(
getBoundTextElementId(orig) ?? "",
) as ExcalidrawTextElementWithContainer | undefined;
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
}
update.boundTextFontSize = newFontSize;
}
elementsAndUpdates.push({
element: latest,
update,
});
} }
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
for (const { element, update, boundText } of elementsAndUpdates) { for (const {
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
mutateElement(element, update, false); mutateElement(element, update, false);
@ -851,17 +852,17 @@ export const resizeMultipleElements = (
newSize: { width, height }, newSize: { width, height },
}); });
if (boundText) { const boundTextElement = getBoundTextElement(element);
const { element: boundTextElement, ...boundTextUpdates } = boundText; if (boundTextElement && boundTextFontSize) {
mutateElement( mutateElement(
boundTextElement, boundTextElement,
{ {
...boundTextUpdates, fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle, angle: isLinearElement(element) ? undefined : angle,
}, },
false, false,
); );
handleBindTextResize(element, transformHandleType); handleBindTextResize(element, transformHandleType, true);
} }
} }

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

@ -10,6 +10,8 @@ import {
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
@ -65,7 +67,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getBoundTextMaxWidth(container); maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
@ -83,21 +85,27 @@ export const redrawTextBoundingBox = (
boundTextUpdates.baseline = metrics.baseline; boundTextUpdates.baseline = metrics.baseline;
if (container) { if (container) {
const containerDims = getContainerDims(container);
const maxContainerHeight = getBoundTextMaxHeight( const maxContainerHeight = getBoundTextMaxHeight(
container, container,
textElement as ExcalidrawTextElementWithContainer, textElement as ExcalidrawTextElementWithContainer,
); );
const maxContainerWidth = getBoundTextMaxWidth(container);
let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) { if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerDimensionForBoundText( const nextHeight = computeContainerDimensionForBoundText(
metrics.height, metrics.height,
container.type, container.type,
); );
mutateElement(container, { height: nextHeight }); mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight); updateOriginalContainerCache(container.id, nextHeight);
} }
if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
);
mutateElement(container, { width: nextWidth });
}
const updatedTextElement = { const updatedTextElement = {
...textElement, ...textElement,
...boundTextUpdates, ...boundTextUpdates,
@ -155,6 +163,7 @@ export const bindTextToShapeAfterDuplication = (
export const handleBindTextResize = ( export const handleBindTextResize = (
container: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
) => { ) => {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) { if (!boundTextElementId) {
@ -175,15 +184,17 @@ export const handleBindTextResize = (
let text = textElement.text; let text = textElement.text;
let nextHeight = textElement.height; let nextHeight = textElement.height;
let nextWidth = textElement.width; let nextWidth = textElement.width;
const containerDims = getContainerDims(container);
const maxWidth = getBoundTextMaxWidth(container); const maxWidth = getBoundTextMaxWidth(container);
const maxHeight = getBoundTextMaxHeight( const maxHeight = getBoundTextMaxHeight(
container, container,
textElement as ExcalidrawTextElementWithContainer, textElement as ExcalidrawTextElementWithContainer,
); );
let containerHeight = containerDims.height; let containerHeight = container.height;
let nextBaseLine = textElement.baseline; let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") { if (
shouldMaintainAspectRatio ||
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) { if (text) {
text = wrapText( text = wrapText(
textElement.originalText, textElement.originalText,
@ -207,7 +218,7 @@ export const handleBindTextResize = (
container.type, container.type,
); );
const diff = containerHeight - containerDims.height; const diff = containerHeight - container.height;
// fix the y coord when resizing from ne/nw/n // fix the y coord when resizing from ne/nw/n
const updatedY = const updatedY =
!isArrowElement(container) && !isArrowElement(container) &&
@ -687,16 +698,6 @@ export const getContainerElement = (
return null; return null;
}; };
export const getContainerDims = (element: ExcalidrawElement) => {
const MIN_WIDTH = 300;
if (isArrowElement(element)) {
const width = Math.max(element.width, MIN_WIDTH);
const height = element.height;
return { width, height };
}
return { width: element.width, height: element.height };
};
export const getContainerCenter = ( export const getContainerCenter = (
container: ExcalidrawElement, container: ExcalidrawElement,
appState: AppState, appState: AppState,
@ -865,8 +866,9 @@ const VALID_CONTAINER_TYPES = new Set([
"arrow", "arrow",
]); ]);
export const isValidTextContainer = (element: ExcalidrawElement) => export const isValidTextContainer = (element: {
VALID_CONTAINER_TYPES.has(element.type); type: ExcalidrawElement["type"];
}) => VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
@ -887,12 +889,19 @@ export const computeContainerDimensionForBoundText = (
return dimension + padding; return dimension + padding;
}; };
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => { export const getBoundTextMaxWidth = (
const width = getContainerDims(container).width; container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
container,
),
) => {
const { width } = container;
if (isArrowElement(container)) { if (isArrowElement(container)) {
return width - BOUND_TEXT_PADDING * 8 * 2; const minWidth =
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
} }
if (container.type === "ellipse") { if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is // The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
@ -911,7 +920,7 @@ export const getBoundTextMaxHeight = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const height = getContainerDims(container).height; const { height } = container;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) { if (containerHeight <= 0) {

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,
@ -1509,4 +1509,30 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Excalidraw"); expect(text.text).toBe("Excalidraw");
}); });
}); });
it("should bump the version of labelled arrow when label updated", async () => {
await render(<ExcalidrawApp />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
const { version } = arrow;
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello\nworld!");
editor.blur();
expect(arrow.version).toEqual(version + 1);
});
}); });

View file

@ -20,10 +20,9 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
} from "./types"; } from "./types";
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { bumpVersion, mutateElement } from "./mutateElement";
import { import {
getBoundTextElementId, getBoundTextElementId,
getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
@ -117,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;
}) => { }) => {
@ -177,20 +176,19 @@ export const textWysiwyg = ({
updatedTextElement, updatedTextElement,
editable, editable,
); );
const containerDims = getContainerDims(container);
let originalContainerData; let originalContainerData;
if (propertiesUpdated) { if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache( originalContainerData = updateOriginalContainerCache(
container.id, container.id,
containerDims.height, container.height,
); );
} else { } else {
originalContainerData = originalContainerCache[container.id]; originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) { if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache( originalContainerData = updateOriginalContainerCache(
container.id, container.id,
containerDims.height, container.height,
); );
} }
} }
@ -214,7 +212,7 @@ export const textWysiwyg = ({
// autoshrink container height until original container height // autoshrink container height until original container height
// is reached when text is removed // is reached when text is removed
!isArrowElement(container) && !isArrowElement(container) &&
containerDims.height > originalContainerData.height && container.height > originalContainerData.height &&
textElementHeight < maxHeight textElementHeight < maxHeight
) { ) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight = computeContainerDimensionForBoundText(
@ -543,6 +541,9 @@ export const textWysiwyg = ({
id: element.id, id: element.id,
}), }),
}); });
} else if (isArrowElement(container)) {
// updating an arrow label may change bounds, prevent stale cache:
bumpVersion(container);
} }
} else { } else {
mutateElement(container, { mutateElement(container, {

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

@ -86,15 +86,15 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase & export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "embeddable";
/** /**
* indicates whether the embeddable src (url) has been validated for rendering. * indicates whether the embeddable src (url) has been validated for rendering.
* nullish value indicates that the validation is pending. We reset the * null value indicates that the validation is pending. We reset the
* value on each restore (or url change) so that we can guarantee * value on each restore (or url change) so that we can guarantee
* the validation came from a trusted source (the editor). Also because we * the validation came from a trusted source (the editor). Also because we
* may not have access to host-app supplied url validator during restore. * may not have access to host-app supplied url validator during restore.
*/ */
validated?: boolean; validated: boolean | null;
type: "embeddable";
}>; }>;
export type ExcalidrawImageElement = _ExcalidrawElementBase & export type ExcalidrawImageElement = _ExcalidrawElementBase &
@ -123,7 +123,6 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
export type ExcalidrawGenericElement = export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement | ExcalidrawSelectionElement
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawEmbeddableElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement; | ExcalidrawEllipseElement;
@ -138,7 +137,8 @@ export type ExcalidrawElement =
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement; | ExcalidrawFrameElement
| ExcalidrawEmbeddableElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean; isDeleted: boolean;

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

128
src/frame.test.tsx Normal file
View file

@ -0,0 +1,128 @@
import {
convertToExcalidrawElements,
Excalidraw,
} from "./packages/excalidraw/index";
import { API } from "./tests/helpers/api";
import { Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
describe("adding elements to frames", () => {
type ElementType = string;
const assertOrder = (
els: readonly { type: ElementType }[],
order: ElementType[],
) => {
expect(els.map((el) => el.type)).toEqual(order);
};
const reorderElements = <T extends { type: ElementType }>(
els: readonly T[],
order: ElementType[],
) => {
return order.reduce((acc: T[], el) => {
acc.push(els.find((e) => e.type === el)!);
return acc;
}, []);
};
describe("resizing frame over elements", () => {
const testElements = async (
containerType: "arrow" | "rectangle",
initialOrder: ElementType[],
expectedOrder: ElementType[],
) => {
await render(<Excalidraw />);
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
h.elements = reorderElements(
[
frame,
...convertToExcalidrawElements([
{
type: containerType,
x: 100,
y: 100,
height: 10,
label: { text: "xx" },
},
]),
],
initialOrder,
);
assertOrder(h.elements, initialOrder);
expect(h.elements[1].frameId).toBe(null);
expect(h.elements[2].frameId).toBe(null);
const container = h.elements[1];
mouse.clickAt(0, 0);
mouse.downAt(frame.x + frame.width, frame.y + frame.height);
mouse.moveTo(
container.x + container.width + 100,
container.y + container.height + 100,
);
mouse.up();
assertOrder(h.elements, expectedOrder);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].frameId).toBe(frame.id);
};
it("resizing over text containers / labelled arrows", async () => {
await testElements(
"rectangle",
["frame", "rectangle", "text"],
["rectangle", "text", "frame"],
);
await testElements(
"rectangle",
["frame", "text", "rectangle"],
["rectangle", "text", "frame"],
);
await testElements(
"rectangle",
["rectangle", "text", "frame"],
["rectangle", "text", "frame"],
);
await testElements(
"rectangle",
["text", "rectangle", "frame"],
["text", "rectangle", "frame"],
);
await testElements(
"arrow",
["frame", "arrow", "text"],
["arrow", "text", "frame"],
);
await testElements(
"arrow",
["text", "arrow", "frame"],
["text", "arrow", "frame"],
);
// FIXME failing in tests (it fails to add elements to frame for some
// reason) but works in browser. (╯°□°)╯︵ ┻━┻
//
// Looks like the `getElementsCompletelyInFrame()` doesn't work
// in these cases.
//
// await testElements(
// "arrow",
// ["arrow", "text", "frame"],
// ["arrow", "text", "frame"],
// );
// await testElements(
// "arrow",
// ["frame", "text", "arrow"],
// ["text", "arrow", "frame"],
// );
});
});
});

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";
@ -471,7 +471,6 @@ export const addElementsToFrame = (
let nextElements = allElements.slice(); let nextElements = allElements.slice();
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,
@ -648,7 +647,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 +659,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,41 @@ 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";
import { Mutable } from "./utility-types";
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 +47,190 @@ 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">,
prevAppState: InteractiveCanvasAppState,
): 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: makeNextSelectedElementIds(
{
...appState.selectedElementIds,
...selectedElementIdsInGroups,
},
prevAppState,
),
};
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,
): Mutable<
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, prevAppState);
};
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 +242,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

@ -1,7 +1,7 @@
[ [
{ {
"path": "dist/excalidraw.production.min.js", "path": "dist/excalidraw.production.min.js",
"limit": "290 kB" "limit": "291 kB"
}, },
{ {
"path": "dist/excalidraw-assets/locales", "path": "dist/excalidraw-assets/locales",

View file

@ -75,6 +75,7 @@ const {
WelcomeScreen, WelcomeScreen,
MainMenu, MainMenu,
LiveCollaborationTrigger, LiveCollaborationTrigger,
convertToExcalidrawElements,
} = window.ExcalidrawLib; } = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32; const COMMENT_ICON_DIMENSION = 32;
@ -140,7 +141,10 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
]; ];
//@ts-ignore //@ts-ignore
initialStatePromiseRef.current.promise.resolve(initialData); initialStatePromiseRef.current.promise.resolve({
...initialData,
elements: convertToExcalidrawElements(initialData.elements),
});
excalidrawAPI.addFiles(imagesArray); excalidrawAPI.addFiles(imagesArray);
}; };
}; };
@ -184,38 +188,40 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
const updateScene = () => { const updateScene = () => {
const sceneData = { const sceneData = {
elements: restoreElements( elements: restoreElements(
[ convertToExcalidrawElements([
{ {
type: "rectangle", type: "rectangle",
version: 141, id: "rect-1",
versionNonce: 361174001,
isDeleted: false,
id: "oDVXy8D6rom3H1-LLH2-f",
fillStyle: "hachure", fillStyle: "hachure",
strokeWidth: 1, strokeWidth: 1,
strokeStyle: "solid", strokeStyle: "solid",
roughness: 1, roughness: 1,
opacity: 100,
angle: 0, angle: 0,
x: 100.50390625, x: 100.50390625,
y: 93.67578125, y: 93.67578125,
strokeColor: "#c92a2a", strokeColor: "#c92a2a",
backgroundColor: "transparent",
width: 186.47265625, width: 186.47265625,
height: 141.9765625, height: 141.9765625,
seed: 1968410350, seed: 1968410350,
groupIds: [],
frameId: null,
boundElements: null,
locked: false,
link: null,
updated: 1,
roundness: { roundness: {
type: ROUNDNESS.ADAPTIVE_RADIUS, type: ROUNDNESS.ADAPTIVE_RADIUS,
value: 32, value: 32,
}, },
}, },
], {
type: "arrow",
x: 300,
y: 150,
start: { id: "rect-1" },
end: { type: "ellipse" },
},
{
type: "text",
x: 300,
y: 100,
text: "HELLO WORLD!",
},
]),
null, null,
), ),
appState: { appState: {

View file

@ -255,3 +255,4 @@ export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar"; export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url"; export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform";

View file

@ -52,7 +52,7 @@
"@babel/preset-env": "7.18.6", "@babel/preset-env": "7.18.6",
"@babel/preset-react": "7.18.6", "@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6", "@babel/preset-typescript": "7.18.6",
"@size-limit/preset-big-lib": "8.2.6", "@size-limit/preset-big-lib": "9.0.0",
"autoprefixer": "10.4.7", "autoprefixer": "10.4.7",
"babel-loader": "8.2.5", "babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1", "babel-plugin-transform-class-properties": "6.24.1",
@ -63,7 +63,7 @@
"mini-css-extract-plugin": "2.6.1", "mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1", "postcss-loader": "7.0.1",
"sass-loader": "13.0.2", "sass-loader": "13.0.2",
"size-limit": "8.2.4", "size-limit": "9.0.0",
"style-loader": "3.3.3", "style-loader": "3.3.3",
"terser-webpack-plugin": "5.3.3", "terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1", "ts-loader": "9.3.1",

View file

@ -2,8 +2,8 @@ const path = require("path");
const webpack = require("webpack"); const webpack = require("webpack");
const autoprefixer = require("autoprefixer"); const autoprefixer = require("autoprefixer");
const { parseEnvVariables } = require("./env"); const { parseEnvVariables } = require("./env");
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist"; const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
module.exports = { module.exports = {
mode: "development", mode: "development",
devtool: false, devtool: false,
@ -17,7 +17,6 @@ module.exports = {
filename: "[name].js", filename: "[name].js",
chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js", chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js",
assetModuleFilename: "excalidraw-assets-dev/[name][ext]", assetModuleFilename: "excalidraw-assets-dev/[name][ext]",
publicPath: "", publicPath: "",
}, },
resolve: { resolve: {
@ -45,7 +44,7 @@ module.exports = {
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude:
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/, /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
use: [ use: [
{ {
loader: "import-meta-loader", loader: "import-meta-loader",

View file

@ -1,10 +1,10 @@
const path = require("path"); const path = require("path");
const webpack = require("webpack");
const autoprefixer = require("autoprefixer");
const { parseEnvVariables } = require("./env");
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
const BundleAnalyzerPlugin = const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin; require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const autoprefixer = require("autoprefixer");
const webpack = require("webpack");
const { parseEnvVariables } = require("./env");
module.exports = { module.exports = {
mode: "production", mode: "production",
@ -47,8 +47,7 @@ module.exports = {
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude:
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/, /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
use: [ use: [
{ {
loader: "import-meta-loader", loader: "import-meta-loader",

View file

@ -1112,38 +1112,37 @@
dependencies: dependencies:
debug "^4.1.1" debug "^4.1.1"
"@size-limit/file@8.2.6": "@size-limit/file@9.0.0":
version "8.2.6" version "9.0.0"
resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-8.2.6.tgz#0e17045a0fa8009fc787c85e3c09f611316f908c" resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-9.0.0.tgz#eed5415f5bcc8407979e47ffa49ffaf12d2d2378"
integrity sha512-B7ayjxiJsbtXdIIWazJkB5gezi5WBMecdHTFPMDhI3NwEML1RVvUjAkrb1mPAAkIpt2LVHPnhdCUHjqDdjugwg== integrity sha512-oM2UaH2FRq4q22k+R+P6xCpzET10T94LFdSjb9svVu/vOD7NaB9LGcG6se8TW1BExXiyXO4GEhLsBt3uMKM3qA==
dependencies: dependencies:
semver "7.5.3" semver "7.5.4"
"@size-limit/preset-big-lib@8.2.6": "@size-limit/preset-big-lib@9.0.0":
version "8.2.6" version "9.0.0"
resolved "https://registry.yarnpkg.com/@size-limit/preset-big-lib/-/preset-big-lib-8.2.6.tgz#fbff51e7a03fc36b6b3d9103cbe5b3909e35a83e" resolved "https://registry.yarnpkg.com/@size-limit/preset-big-lib/-/preset-big-lib-9.0.0.tgz#ddcf30e7646b66ecc0f8a1a6498a5eda6d82876d"
integrity sha512-63a+yos0QNMVCfx1OWnxBrdQVTlBVGzW5fDXwpWq/hKfP3B89XXHYGeL2Z2f8IXSVeGkAHXnDcTZyIPRaXffVg== integrity sha512-wc+VNLXjn0z11s1IWevo8+utP7uZGPVDNNe5cNyMFYHv7/pwJtgsd8w2onEkbK1h8x1oJfWlcqFNKAnvD1Bylw==
dependencies: dependencies:
"@size-limit/file" "8.2.6" "@size-limit/file" "9.0.0"
"@size-limit/time" "8.2.6" "@size-limit/time" "9.0.0"
"@size-limit/webpack" "8.2.6" "@size-limit/webpack" "9.0.0"
size-limit "8.2.6" size-limit "9.0.0"
"@size-limit/time@8.2.6": "@size-limit/time@9.0.0":
version "8.2.6" version "9.0.0"
resolved "https://registry.yarnpkg.com/@size-limit/time/-/time-8.2.6.tgz#5d1912bcfc6437f6f59804737ad0538b25c207ed" resolved "https://registry.yarnpkg.com/@size-limit/time/-/time-9.0.0.tgz#44ba75b3cba30736b133dbb3fd740f894a642c87"
integrity sha512-fUEPvz7Uq6+oUQxSYbNlJt3tTgQBl1VY21USi/B7ebdnVKLnUx1JyPI9v7imN6XEkB2VpJtnYgjFeLgNrirzMA== integrity sha512-//Yba5fRkYqpBZ6MFtjDTSjCpQonDMqkwofpe0G1hMd/5l/3PZXVLDCAU2BW3nQFqTkpeyytFG6Y3jxUqSddiw==
dependencies: dependencies:
estimo "^2.3.6" estimo "^2.3.6"
react "^17.0.2"
"@size-limit/webpack@8.2.6": "@size-limit/webpack@9.0.0":
version "8.2.6" version "9.0.0"
resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-8.2.6.tgz#3a3c98293b80f7c5fb6e8499199ae6f94f05b463" resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-9.0.0.tgz#4514851d3607490e228bf22bc95286643f64a490"
integrity sha512-y2sB66m5sJxIjZ8SEAzpWbiw3/+bnQHDHfk9cSbV5ChKklq02AlYg8BS5KxGWmMpdyUo4TzpjSCP9oEudY+hxQ== integrity sha512-0YwdvmBj9rS4bXE/PY9vSdc5lCiQXmT0794EsG7yvlDMWyrWa/dsgcRok/w0MoZstfuLaS6lv03VI5UJRFU/lg==
dependencies: dependencies:
nanoid "^3.3.6" nanoid "^3.3.6"
webpack "^5.88.0" webpack "^5.88.2"
"@types/body-parser@*": "@types/body-parser@*":
version "1.19.2" version "1.19.2"
@ -1694,9 +1693,9 @@ ansi-styles@^4.1.0:
color-convert "^2.0.1" color-convert "^2.0.1"
anymatch@~3.1.2: anymatch@~3.1.2:
version "3.1.2" version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies: dependencies:
normalize-path "^3.0.0" normalize-path "^3.0.0"
picomatch "^2.0.4" picomatch "^2.0.4"
@ -2553,9 +2552,9 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.2.9: fast-glob@^3.2.9:
version "3.2.12" version "3.3.1"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
dependencies: dependencies:
"@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3" "@nodelib/fs.walk" "^1.2.3"
@ -2672,9 +2671,9 @@ fs.realpath@^1.0.0:
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
fsevents@~2.3.2: fsevents@~2.3.2:
version "2.3.2" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.1: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
@ -2993,7 +2992,7 @@ is-docker@^2.0.0, is-docker@^2.1.1:
is-extglob@^2.1.1: is-extglob@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-glob@^4.0.1, is-glob@~4.0.1: is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.3" version "4.0.3"
@ -3105,7 +3104,7 @@ klona@^2.0.4, klona@^2.0.5:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc"
integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==
lilconfig@^2.0.6, lilconfig@^2.1.0: lilconfig@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
@ -3146,7 +3145,7 @@ lodash@^4.17.20, lodash@^4.17.4:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
loose-envify@^1.0.0, loose-envify@^1.1.0: loose-envify@^1.0.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -3360,11 +3359,6 @@ npm-run-path@^4.0.1:
dependencies: dependencies:
path-key "^3.0.0" path-key "^3.0.0"
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-inspect@^1.9.0: object-inspect@^1.9.0:
version "1.12.0" version "1.12.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0"
@ -3519,16 +3513,16 @@ picocolors@^1.0.0:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
picomatch@^2.3.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
picomatch@^2.2.3:
version "2.3.0"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0: pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@ -3685,14 +3679,6 @@ raw-body@2.5.1:
iconv-lite "0.4.24" iconv-lite "0.4.24"
unpipe "1.0.0" unpipe "1.0.0"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1: readable-stream@^2.0.1:
version "2.3.7" version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
@ -3927,24 +3913,17 @@ semver@7.0.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@7.5.3: semver@7.5.4, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.5.3" version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ== integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
version "6.3.0" version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
dependencies:
lru-cache "^6.0.0"
send@0.18.0: send@0.18.0:
version "0.18.0" version "0.18.0"
@ -4054,22 +4033,10 @@ sirv@^1.0.7:
mime "^2.3.1" mime "^2.3.1"
totalist "^1.0.0" totalist "^1.0.0"
size-limit@8.2.4: size-limit@9.0.0:
version "8.2.4" version "9.0.0"
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-8.2.4.tgz#0ab0df7cbc89007d544a50b451f5fb4d110694ca" resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-9.0.0.tgz#203c47303462a8351976eb26175acea5f4e80447"
integrity sha512-Un16nSreD1v2CYwSorattiJcHuAWqXvg4TsGgzpjnoByqQwsSfCIEQHuaD14HNStzredR8cdsO9oGH91ibypTA== integrity sha512-DrA7o2DeRN3s+vwCA9nn7Ck9Y4pn9t0GNUwQRpKqBtBmNkl6LA2s/NlNCdtKHrEkRTeYA1ZQ65mnYveo9rUqgA==
dependencies:
bytes-iec "^3.1.1"
chokidar "^3.5.3"
globby "^11.1.0"
lilconfig "^2.0.6"
nanospinner "^1.1.0"
picocolors "^1.0.0"
size-limit@8.2.6:
version "8.2.6"
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-8.2.6.tgz#e41dbc74a4d7fc13be72551b6ef31ea50007d18d"
integrity sha512-zpznim/tX/NegjoQuRKgWTF4XiB0cn2qt90uJzxYNTFAqexk4b94DOAkBD3TwhC6c3kw2r0KcnA5upziVMZqDg==
dependencies: dependencies:
bytes-iec "^3.1.1" bytes-iec "^3.1.1"
chokidar "^3.5.3" chokidar "^3.5.3"
@ -4556,7 +4523,7 @@ webpack@5.76.0:
watchpack "^2.4.0" watchpack "^2.4.0"
webpack-sources "^3.2.3" webpack-sources "^3.2.3"
webpack@^5.88.0: webpack@^5.88.2:
version "5.88.2" version "5.88.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e"
integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ== integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==

View file

@ -2259,14 +2259,14 @@ semver@7.0.0:
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
version "6.3.0" version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.4, semver@^7.3.5: semver@^7.3.4, semver@^7.3.5:
version "7.3.5" version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"

View file

@ -1,8 +1,6 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
Arrowhead,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@ -16,27 +14,22 @@ import {
isArrowElement, isArrowElement,
hasBoundTextElement, hasBoundTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import { getElementAbsoluteCoords } from "../element/bounds";
getDiamondPoints, import type { RoughCanvas } from "roughjs/bin/canvas";
getElementAbsoluteCoords, import type { Drawable } from "roughjs/bin/core";
getArrowheadPoints, import type { RoughSVG } from "roughjs/bin/svg";
} from "../element/bounds";
import { RoughCanvas } from "roughjs/bin/canvas";
import { Drawable, Options } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { RenderConfig } from "../scene/types"; import { StaticCanvasRenderConfig } from "../scene/types";
import { import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
distance,
getFontString,
getFontFamilyString,
isRTL,
isTransparent,
} 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 +54,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,36 +66,33 @@ 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
); );
}; };
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
const getCanvasPadding = (element: ExcalidrawElement) => const getCanvasPadding = (element: ExcalidrawElement) =>
element.type === "freedraw" ? element.strokeWidth * 12 : 20; element.type === "freedraw" ? element.strokeWidth * 12 : 20;
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 +156,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 +197,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 +254,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 +271,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 +279,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 +290,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 +315,7 @@ const drawElementOnCanvas = (
element.height, element.height,
); );
} else { } else {
drawImagePlaceholder(element, context, renderConfig.zoom.value); drawImagePlaceholder(element, context, appState.zoom.value);
} }
break; break;
} }
@ -373,405 +367,29 @@ const drawElementOnCanvas = (
context.globalAlpha = 1; context.globalAlpha = 1;
}; };
const elementWithCanvasCache = new WeakMap< export const elementWithCanvasCache = new WeakMap<
ExcalidrawElement, ExcalidrawElement,
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 = (
element: ExcalidrawElement,
continuousPath = false,
): Options => {
const options: Options = {
seed: element.seed,
strokeLineDash:
element.strokeStyle === "dashed"
? getDashArrayDashed(element.strokeWidth)
: element.strokeStyle === "dotted"
? getDashArrayDotted(element.strokeWidth)
: undefined,
// for non-solid strokes, disable multiStroke because it tends to make
// dashes/dots overlay each other
disableMultiStroke: element.strokeStyle !== "solid",
// for non-solid strokes, increase the width a bit to make it visually
// similar to solid strokes, because we're also disabling multiStroke
strokeWidth:
element.strokeStyle !== "solid"
? element.strokeWidth + 0.5
: element.strokeWidth,
// when increasing strokeWidth, we must explicitly set fillWeight and
// hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: element.roughness,
stroke: element.strokeColor,
preserveVertices: continuousPath,
};
switch (element.type) {
case "rectangle":
case "embeddable":
case "diamond":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
}
return options;
}
case "line":
case "freedraw": {
if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle;
options.fill =
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor;
}
return options;
}
case "arrow":
return options;
default: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
};
const modifyEmbeddableForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
) => {
if (
element.type === "embeddable" &&
(isExporting || !element.validated) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
return {
...element,
roughness: 0,
backgroundColor: "#d3d3d3",
fillStyle: "solid",
} as const;
}
return element;
};
/**
* Generates the element's shape and puts it into the cache.
* @param element
* @param generator
*/
const generateElementShape = (
element: NonDeletedExcalidrawElement,
generator: RoughGenerator,
isExporting: boolean = false,
) => {
let shape = isExporting ? undefined : shapeCache.get(element);
// `null` indicates no rc shape applicable for this element type
// (= do not generate anything)
if (shape === undefined) {
elementWithCanvasCache.delete(element);
switch (element.type) {
case "rectangle":
case "embeddable": {
// this is for rendering the stroke/bg of the embeddable, especially
// when the src url is not set
if (element.roundness) {
const w = element.width;
const h = element.height;
const r = getCornerRadius(Math.min(w, h), element);
shape = generator.path(
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
h - r
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
true,
),
);
} else {
shape = generator.rectangle(
0,
0,
element.width,
element.height,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
false,
),
);
}
setShapeForElement(element, shape);
break;
}
case "diamond": {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
if (element.roundness) {
const verticalRadius = getCornerRadius(
Math.abs(topX - leftX),
element,
);
const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY),
element,
);
shape = generator.path(
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
rightX - verticalRadius
} ${rightY - horizontalRadius}
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
rightX - verticalRadius
} ${rightY + horizontalRadius}
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
bottomX - verticalRadius
} ${bottomY - horizontalRadius}
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
leftY - horizontalRadius
}
L ${topX - verticalRadius} ${topY + horizontalRadius}
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius
}`,
generateRoughOptions(element, true),
);
} else {
shape = generator.polygon(
[
[topX, topY],
[rightX, rightY],
[bottomX, bottomY],
[leftX, leftY],
],
generateRoughOptions(element),
);
}
setShapeForElement(element, shape);
break;
}
case "ellipse":
shape = generator.ellipse(
element.width / 2,
element.height / 2,
element.width,
element.height,
generateRoughOptions(element),
);
setShapeForElement(element, shape);
break;
case "line":
case "arrow": {
const options = generateRoughOptions(element);
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length ? element.points : [[0, 0]];
// curve is always the first element
// this simplifies finding the curve for an element
if (!element.roundness) {
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
} else {
shape = [
generator.linearPath(points as [number, number][], options),
];
}
} else {
shape = [generator.curve(points as [number, number][], options)];
}
// add lines only in arrow
if (element.type === "arrow") {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
// Other arrowheads here...
if (arrowhead === "dot") {
const [x, y, r] = arrowheadPoints;
return [
generator.circle(x, y, r, {
...options,
fill: element.strokeColor,
fillStyle: "solid",
stroke: "none",
}),
];
}
if (arrowhead === "triangle") {
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
// always use solid stroke for triangle arrowhead
delete options.strokeLineDash;
return [
generator.polygon(
[
[x, y],
[x2, y2],
[x3, y3],
[x, y],
],
{
...options,
fill: element.strokeColor,
fillStyle: "solid",
},
),
];
}
// Arrow arrowheads
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
];
};
if (startArrowhead !== null) {
const shapes = getArrowheadShapes(
element,
shape,
"start",
startArrowhead,
);
shape.push(...shapes);
}
if (endArrowhead !== null) {
if (endArrowhead === undefined) {
// Hey, we have an old arrow here!
}
const shapes = getArrowheadShapes(
element,
shape,
"end",
endArrowhead,
);
shape.push(...shapes);
}
}
setShapeForElement(element, shape);
break;
}
case "freedraw": {
generateFreeDrawShape(element);
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
shape = generator.polygon(element.points as [number, number][], {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
}
setShapeForElement(element, shape);
break;
}
case "text":
case "image": {
// just to ensure we don't regenerate element.canvas on rerenders
setShapeForElement(element, null);
break;
}
}
}
};
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 +397,7 @@ const generateElementWithCanvas = (
element, element,
zoom, zoom,
renderConfig, renderConfig,
appState,
); );
elementWithCanvasCache.set(element, elementWithCanvas); elementWithCanvasCache.set(element, elementWithCanvas);
@ -790,9 +409,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 +426,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 +525,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 +545,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 +557,37 @@ 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;
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 +596,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 +611,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();
@ -1008,26 +624,35 @@ export const renderElement = (
break; break;
} }
case "freedraw": { case "freedraw": {
generateElementShape(element, generator); // TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element);
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;
@ -1040,11 +665,14 @@ export const renderElement = (
case "image": case "image":
case "text": case "text":
case "embeddable": { case "embeddable": {
generateElementShape(element, generator, renderConfig.isExporting); // TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, 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 +690,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 +724,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 +767,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 +777,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 +785,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 +802,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;
@ -1245,7 +885,6 @@ export const renderElementToSvg = (
} }
} }
const degree = (180 * element.angle) / Math.PI; const degree = (180 * element.angle) / Math.PI;
const generator = rsvg.generator;
// element to append node to, most of the time svgRoot // element to append node to, most of the time svgRoot
let root = svgRoot; let root = svgRoot;
@ -1270,10 +909,10 @@ export const renderElementToSvg = (
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
generateElementShape(element, generator); const shape = ShapeCache.generateElementShape(element);
const node = roughSVGDrawWithPrecision( const node = roughSVGDrawWithPrecision(
rsvg, rsvg,
getShapeForElement(element)!, shape,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
); );
if (opacity !== 1) { if (opacity !== 1) {
@ -1300,10 +939,10 @@ export const renderElementToSvg = (
} }
case "embeddable": { case "embeddable": {
// render placeholder rectangle // render placeholder rectangle
generateElementShape(element, generator, true); const shape = ShapeCache.generateElementShape(element, true);
const node = roughSVGDrawWithPrecision( const node = roughSVGDrawWithPrecision(
rsvg, rsvg,
getShapeForElement(element)!, shape,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
); );
const opacity = element.opacity / 100; const opacity = element.opacity / 100;
@ -1337,7 +976,7 @@ export const renderElementToSvg = (
// render embeddable element + iframe // render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision( const embeddableNode = roughSVGDrawWithPrecision(
rsvg, rsvg,
getShapeForElement(element)!, shape,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
); );
embeddableNode.setAttribute("stroke-linecap", "round"); embeddableNode.setAttribute("stroke-linecap", "round");
@ -1443,14 +1082,14 @@ export const renderElementToSvg = (
maskRectInvisible.setAttribute("opacity", "1"); maskRectInvisible.setAttribute("opacity", "1");
maskPath.appendChild(maskRectInvisible); maskPath.appendChild(maskRectInvisible);
} }
generateElementShape(element, generator);
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (boundText) { if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`); group.setAttribute("mask", `url(#mask-${element.id})`);
} }
group.setAttribute("stroke-linecap", "round"); group.setAttribute("stroke-linecap", "round");
getShapeForElement(element)!.forEach((shape) => { const shapes = ShapeCache.generateElementShape(element);
shapes.forEach((shape) => {
const node = roughSVGDrawWithPrecision( const node = roughSVGDrawWithPrecision(
rsvg, rsvg,
shape, shape,
@ -1491,11 +1130,13 @@ export const renderElementToSvg = (
break; break;
} }
case "freedraw": { case "freedraw": {
generateElementShape(element, generator); const backgroundFillShape = ShapeCache.generateElementShape(element);
generateFreeDrawShape(element); const node = backgroundFillShape
const shape = getShapeForElement(element); ? roughSVGDrawWithPrecision(
const node = shape rsvg,
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT) backgroundFillShape,
MAX_DECIMALS_FOR_SVG_EXPORT,
)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) { if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("stroke-opacity", `${opacity}`);

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

362
src/scene/Shape.ts Normal file
View file

@ -0,0 +1,362 @@
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getDiamondPoints, getArrowheadPoints } from "../element";
import type { ElementShapes } from "./types";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { isPathALoop, getCornerRadius } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
export const generateRoughOptions = (
element: ExcalidrawElement,
continuousPath = false,
): Options => {
const options: Options = {
seed: element.seed,
strokeLineDash:
element.strokeStyle === "dashed"
? getDashArrayDashed(element.strokeWidth)
: element.strokeStyle === "dotted"
? getDashArrayDotted(element.strokeWidth)
: undefined,
// for non-solid strokes, disable multiStroke because it tends to make
// dashes/dots overlay each other
disableMultiStroke: element.strokeStyle !== "solid",
// for non-solid strokes, increase the width a bit to make it visually
// similar to solid strokes, because we're also disabling multiStroke
strokeWidth:
element.strokeStyle !== "solid"
? element.strokeWidth + 0.5
: element.strokeWidth,
// when increasing strokeWidth, we must explicitly set fillWeight and
// hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: element.roughness,
stroke: element.strokeColor,
preserveVertices: continuousPath,
};
switch (element.type) {
case "rectangle":
case "embeddable":
case "diamond":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
}
return options;
}
case "line":
case "freedraw": {
if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle;
options.fill =
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor;
}
return options;
}
case "arrow":
return options;
default: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
};
const modifyEmbeddableForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
) => {
if (
element.type === "embeddable" &&
(isExporting || !element.validated) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
return {
...element,
roughness: 0,
backgroundColor: "#d3d3d3",
fillStyle: "solid",
} as const;
}
return element;
};
/**
* Generates the roughjs shape for given element.
*
* Low-level. Use `ShapeCache.generateElementShape` instead.
*
* @private
*/
export const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator,
isExporting: boolean = false,
): Drawable | Drawable[] | null => {
switch (element.type) {
case "rectangle":
case "embeddable": {
let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially
// when the src url is not set
if (element.roundness) {
const w = element.width;
const h = element.height;
const r = getCornerRadius(Math.min(w, h), element);
shape = generator.path(
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
h - r
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
true,
),
);
} else {
shape = generator.rectangle(
0,
0,
element.width,
element.height,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
false,
),
);
}
return shape;
}
case "diamond": {
let shape: ElementShapes[typeof element.type];
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
if (element.roundness) {
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY),
element,
);
shape = generator.path(
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
rightX - verticalRadius
} ${rightY - horizontalRadius}
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
rightX - verticalRadius
} ${rightY + horizontalRadius}
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
bottomX - verticalRadius
} ${bottomY - horizontalRadius}
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
leftY - horizontalRadius
}
L ${topX - verticalRadius} ${topY + horizontalRadius}
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius
}`,
generateRoughOptions(element, true),
);
} else {
shape = generator.polygon(
[
[topX, topY],
[rightX, rightY],
[bottomX, bottomY],
[leftX, leftY],
],
generateRoughOptions(element),
);
}
return shape;
}
case "ellipse": {
const shape: ElementShapes[typeof element.type] = generator.ellipse(
element.width / 2,
element.height / 2,
element.width,
element.height,
generateRoughOptions(element),
);
return shape;
}
case "line":
case "arrow": {
let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element);
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length ? element.points : [[0, 0]];
// curve is always the first element
// this simplifies finding the curve for an element
if (!element.roundness) {
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
} else {
shape = [generator.linearPath(points as [number, number][], options)];
}
} else {
shape = [generator.curve(points as [number, number][], options)];
}
// add lines only in arrow
if (element.type === "arrow") {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
// Other arrowheads here...
if (arrowhead === "dot") {
const [x, y, r] = arrowheadPoints;
return [
generator.circle(x, y, r, {
...options,
fill: element.strokeColor,
fillStyle: "solid",
stroke: "none",
}),
];
}
if (arrowhead === "triangle") {
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
// always use solid stroke for triangle arrowhead
delete options.strokeLineDash;
return [
generator.polygon(
[
[x, y],
[x2, y2],
[x3, y3],
[x, y],
],
{
...options,
fill: element.strokeColor,
fillStyle: "solid",
},
),
];
}
// Arrow arrowheads
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
];
};
if (startArrowhead !== null) {
const shapes = getArrowheadShapes(
element,
shape,
"start",
startArrowhead,
);
shape.push(...shapes);
}
if (endArrowhead !== null) {
if (endArrowhead === undefined) {
// Hey, we have an old arrow here!
}
const shapes = getArrowheadShapes(
element,
shape,
"end",
endArrowhead,
);
shape.push(...shapes);
}
}
return shape;
}
case "freedraw": {
let shape: ElementShapes[typeof element.type];
generateFreeDrawShape(element);
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
shape = generator.polygon(element.points as [number, number][], {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
}
return shape;
}
case "frame":
case "text":
case "image": {
const shape: ElementShapes[typeof element.type] = null;
// we return (and cache) `null` to make sure we don't regenerate
// `element.canvas` on rerenders
return shape;
}
default: {
assertNever(
element,
`generateElementShape(): Unimplemented type ${(element as any)?.type}`,
);
return null;
}
}
};

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

@ -0,0 +1,74 @@
import { Drawable } from "roughjs/bin/core";
import { RoughGenerator } from "roughjs/bin/generator";
import {
ExcalidrawElement,
ExcalidrawSelectionElement,
} from "../element/types";
import { elementWithCanvasCache } from "../renderer/renderElement";
import { _generateElementShape } from "./Shape";
import { ElementShape, ElementShapes } from "./types";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | 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
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
isExporting = false,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = isExporting ? undefined : ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
isExporting,
) 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

@ -0,0 +1,497 @@
import { AppState, ScrollConstraints } from "../types";
import { easeToValuesRAF, isShallowEqual } from "../utils";
import { getNormalizedZoom } from "./zoom";
/**
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
*
* This method first calculates the necessary zoom level to fit the entire constrained scrollable area within the viewport.
* Then it calculates the constraints for the viewport given the new zoom level and the current scrollable area dimensions.
* The function returns an object containing the optimal scroll positions and zoom level.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param appState - An object containing the current horizontal and vertical scroll positions.
* @returns An object containing the calculated optimal horizontal and vertical scroll positions and zoom level.
*
* @example
*
* const { scrollX, scrollY, zoom } = this.calculateConstrainedScrollCenter(scrollConstraints, { scrollX, scrollY });
*/
export const calculateConstrainedScrollCenter = (
state: AppState,
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
): {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
zoom: AppState["zoom"];
} => {
const { width, height, zoom, scrollConstraints } = state;
if (!scrollConstraints) {
return { scrollX, scrollY, zoom };
}
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
scrollConstraints,
width,
height,
);
// The zoom level to contain the whole constrained area in view
const _zoom = {
value: getNormalizedZoom(
initialZoomLevel ?? Math.min(zoomLevelX, zoomLevelY),
),
};
const constraints = calculateConstraints({
scrollConstraints,
width,
height,
zoom: _zoom,
cursorButton: "up",
});
return {
scrollX: constraints.minScrollX,
scrollY: constraints.minScrollY,
zoom: constraints.constrainedZoom,
};
};
/**
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
*
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param width - The width of the viewport.
* @param height - The height of the viewport.
* @returns An object containing the calculated zoom levels for the X and Y axes, and the maximum zoom level if applicable.
*/
const calculateZoomLevel = (
scrollConstraints: ScrollConstraints,
width: AppState["width"],
height: AppState["height"],
) => {
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.7;
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1))
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
const scrollableWidth = scrollConstraints.width;
const scrollableHeight = scrollConstraints.height;
const zoomLevelX = width / scrollableWidth;
const zoomLevelY = height / scrollableHeight;
const initialZoomLevel = getNormalizedZoom(
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
);
return { zoomLevelX, zoomLevelY, initialZoomLevel };
};
const calculateConstraints = ({
scrollConstraints,
width,
height,
zoom,
cursorButton,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
cursorButton: AppState["cursorButton"];
}) => {
// Set the overscroll allowance percentage
const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2;
/**
* Calculates the center position of the constrained scroll area.
* @returns The X and Y coordinates of the center position.
*/
const calculateConstrainedScrollCenter = (zoom: number) => {
const constrainedScrollCenterX =
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
const constrainedScrollCenterY =
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
return { constrainedScrollCenterX, constrainedScrollCenterY };
};
/**
* Calculates the overscroll allowance values for the constrained area.
* @returns The overscroll allowance values for the X and Y axes.
*/
const calculateOverscrollAllowance = () => {
const overscrollAllowanceX =
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.width;
const overscrollAllowanceY =
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.height;
return Math.min(overscrollAllowanceX, overscrollAllowanceY);
};
/**
* Calculates the minimum and maximum scroll values based on the current state.
* @param shouldAdjustForCenteredViewX - Whether the view should be adjusted for centered view on X axis - when constrained area width fits the viewport.
* @param shouldAdjustForCenteredViewY - Whether the view should be adjusted for centered view on Y axis - when constrained area height fits the viewport.
* @param overscrollAllowanceX - The overscroll allowance value for the X axis.
* @param overscrollAllowanceY - The overscroll allowance value for the Y axis.
* @param constrainedScrollCenterX - The X coordinate of the constrained scroll area center.
* @param constrainedScrollCenterY - The Y coordinate of the constrained scroll area center.
* @returns The minimum and maximum scroll values for the X and Y axes.
*/
const calculateMinMaxScrollValues = (
shouldAdjustForCenteredViewX: boolean,
shouldAdjustForCenteredViewY: boolean,
overscrollAllowance: number,
constrainedScrollCenterX: number,
constrainedScrollCenterY: number,
zoom: number,
) => {
let maxScrollX;
let minScrollX;
let maxScrollY;
let minScrollY;
// Handling the X-axis
if (cursorButton === "down") {
if (shouldAdjustForCenteredViewX) {
maxScrollX = constrainedScrollCenterX + overscrollAllowance;
minScrollX = constrainedScrollCenterX - overscrollAllowance;
} else {
maxScrollX = scrollConstraints.x + overscrollAllowance;
minScrollX =
scrollConstraints.x -
scrollConstraints.width +
width / zoom -
overscrollAllowance;
}
} else if (shouldAdjustForCenteredViewX) {
maxScrollX = constrainedScrollCenterX;
minScrollX = constrainedScrollCenterX;
} else {
maxScrollX = scrollConstraints.x;
minScrollX = scrollConstraints.x - scrollConstraints.width + width / zoom;
}
// Handling the Y-axis
if (cursorButton === "down") {
if (shouldAdjustForCenteredViewY) {
maxScrollY = constrainedScrollCenterY + overscrollAllowance;
minScrollY = constrainedScrollCenterY - overscrollAllowance;
} else {
maxScrollY = scrollConstraints.y + overscrollAllowance;
minScrollY =
scrollConstraints.y -
scrollConstraints.height +
height / zoom -
overscrollAllowance;
}
} else if (shouldAdjustForCenteredViewY) {
maxScrollY = constrainedScrollCenterY;
minScrollY = constrainedScrollCenterY;
} else {
maxScrollY = scrollConstraints.y;
minScrollY =
scrollConstraints.y - scrollConstraints.height + height / zoom;
}
return { maxScrollX, minScrollX, maxScrollY, minScrollY };
};
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
scrollConstraints,
width,
height,
);
const constrainedZoom = getNormalizedZoom(
scrollConstraints.lockZoom
? Math.max(initialZoomLevel, zoom.value)
: zoom.value,
);
const { constrainedScrollCenterX, constrainedScrollCenterY } =
calculateConstrainedScrollCenter(constrainedZoom);
const overscrollAllowance = calculateOverscrollAllowance();
const shouldAdjustForCenteredViewX = constrainedZoom <= zoomLevelX;
const shouldAdjustForCenteredViewY = constrainedZoom <= zoomLevelY;
const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
calculateMinMaxScrollValues(
shouldAdjustForCenteredViewX,
shouldAdjustForCenteredViewY,
overscrollAllowance,
constrainedScrollCenterX,
constrainedScrollCenterY,
constrainedZoom,
);
return {
maxScrollX,
minScrollX,
maxScrollY,
minScrollY,
constrainedZoom: {
value: constrainedZoom,
},
initialZoomLevel,
};
};
/**
* Constrains the scroll values within the constrained area.
* @param maxScrollX - The maximum scroll value for the X axis.
* @param minScrollX - The minimum scroll value for the X axis.
* @param maxScrollY - The maximum scroll value for the Y axis.
* @param minScrollY - The minimum scroll value for the Y axis.
* @returns The constrained scroll values for the X and Y axes.
*/
const constrainScrollValues = ({
scrollX,
scrollY,
maxScrollX,
minScrollX,
maxScrollY,
minScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
maxScrollX: number;
minScrollX: number;
maxScrollY: number;
minScrollY: number;
constrainedZoom: AppState["zoom"];
}) => {
const constrainedScrollX = Math.min(
maxScrollX,
Math.max(scrollX, minScrollX),
);
const constrainedScrollY = Math.min(
maxScrollY,
Math.max(scrollY, minScrollY),
);
return { constrainedScrollX, constrainedScrollY, constrainedZoom };
};
/**
* Animate the scroll values to the constrained area
*/
const animateConstrainedScroll = ({
state,
constrainedScrollX,
constrainedScrollY,
opts,
}: {
state: AppState;
constrainedScrollX: number;
constrainedScrollY: number;
opts?: {
onStartCallback?: () => void;
onEndCallback?: () => void;
};
}) => {
const { scrollX, scrollY, scrollConstraints } = state;
const { onStartCallback, onEndCallback } = opts || {};
if (!scrollConstraints) {
return null;
}
easeToValuesRAF({
fromValues: { scrollX, scrollY },
toValues: {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
},
onStep: ({ scrollX, scrollY }) => {
// TODO: this.setState({ scrollX, scrollY });
},
onStart: () => {
// TODO: this.setState({
// scrollConstraints: { ...scrollConstraints, isAnimating: true },
// });
onStartCallback && onStartCallback();
},
onEnd: () => {
// TODO: this.setState({
// scrollConstraints: { ...scrollConstraints, isAnimating: false },
// });
onEndCallback && onEndCallback();
},
});
};
const isViewportOutsideOfConstrainedArea = ({
scrollX,
scrollY,
width,
height,
scrollConstraints,
}: {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
width: AppState["width"];
height: AppState["height"];
scrollConstraints: AppState["scrollConstraints"];
}) => {
if (!scrollConstraints) {
return false;
}
return (
scrollX < scrollConstraints.x ||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
scrollY < scrollConstraints.y ||
scrollY + height > scrollConstraints.y + scrollConstraints.height
);
};
/**
* Handles the state change based on the constrained scroll values.
* Also handles the animation to the constrained area when the viewport is outside of constrained area.
* @param constrainedScrollX - The constrained scroll value for the X axis.
* @param constrainedScrollY - The constrained scroll value for the Y axis.
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
*/
const handleConstrainedScrollStateChange = ({
state,
constrainedScrollX,
constrainedScrollY,
constrainedZoom,
shouldAnimate,
}: {
constrainedScrollX: number;
constrainedScrollY: number;
constrainedZoom: AppState["zoom"];
shouldAnimate?: boolean;
state: AppState;
}) => {
const { scrollX, scrollY } = state;
const isStateChanged =
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
if (isStateChanged) {
if (shouldAnimate) {
animateConstrainedScroll({
state,
constrainedScrollX,
constrainedScrollY,
});
return null;
}
const constrainedState = {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
// TODO: this.setState(constrainedState);
return constrainedState;
}
return null;
};
export const setScrollConstraints = (
scrollConstraints: ScrollConstraints,
state: AppState,
onAnimteEndCallback?: () => void,
) => {
const { scrollX, scrollY, width, height, zoom, cursorButton } = state;
const constrainedScrollValues = constrainScrollValues({
...calculateConstraints({
scrollConstraints,
zoom,
cursorButton,
width,
height,
}),
scrollX,
scrollY,
});
animateConstrainedScroll({
state,
...constrainedScrollValues,
opts: {
onEndCallback: () => {
onAnimteEndCallback && onAnimteEndCallback();
},
},
});
};
let memoizedScrollConstraints: ReturnType<typeof calculateConstraints> | null =
null;
export const constrainScrollState = (state: AppState, prevState: AppState) => {
if (!state.scrollConstraints || state.scrollConstraints.isAnimating) {
return state;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints,
zoom,
cursorButton,
} = state;
const canUseMemoizedConstraints =
isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) &&
isShallowEqual(
{ width, height, zoom: zoom.value, cursorButton },
{
width: prevState.width,
height: prevState.height,
zoom: prevState.zoom.value,
cursorButton: prevState.cursorButton,
} ?? {},
);
const calculatedConstraints =
canUseMemoizedConstraints && !!memoizedScrollConstraints
? memoizedScrollConstraints
: calculateConstraints({
scrollConstraints,
width,
height,
zoom,
cursorButton,
});
memoizedScrollConstraints = calculatedConstraints;
const constrainedScrollValues = constrainScrollValues({
...calculatedConstraints,
scrollX,
scrollY,
});
const viewportOutsideOfConstrainedArea = isViewportOutsideOfConstrainedArea({
scrollX,
scrollY,
width,
height,
scrollConstraints,
});
const shouldAnimate =
viewportOutsideOfConstrainedArea &&
state.cursorButton !== "down" &&
prevState.cursorButton === "down" &&
prevState.zoom.value === state.zoom.value &&
!state.isLoading; // Do not animate when app is initialized but scene is empty - it would cause flickering
return handleConstrainedScrollStateChange({
state,
...constrainedScrollValues,
shouldAnimate,
});
};

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,65 @@
import { ExcalidrawTextElement } from "../element/types"; import type { RoughCanvas } from "roughjs/bin/canvas";
import { AppClassProperties, AppState } from "../types"; import { Drawable } from "roughjs/bin/core";
import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
AppClassProperties,
AppState,
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 = {
@ -65,3 +97,18 @@ export type ConstrainedScrollValues = Pick<
AppState, AppState,
"scrollX" | "scrollY" | "zoom" "scrollX" | "scrollY" | "zoom"
> | null; > | null;
export type ElementShape = Drawable | Drawable[] | null;
export type ElementShapes = {
rectangle: Drawable;
ellipse: Drawable;
diamond: Drawable;
embeddable: Drawable;
freedraw: Drawable | null;
arrow: Drawable[];
line: Drawable[];
text: null;
image: null;
frame: null;
};

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

@ -140,9 +140,8 @@ describe("restoreElements", () => {
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) }); expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
}); });
it("when arrow element has defined endArrowHead", () => { it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
const arrowElement = API.createElement({ type: "arrow" }); const arrowElement = API.createElement({ type: "arrow" });
const restoredElements = restore.restoreElements([arrowElement], null); const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement; const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
@ -150,7 +149,7 @@ describe("restoreElements", () => {
expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead); expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
}); });
it("when arrow element has undefined endArrowHead", () => { it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is undefined', () => {
const arrowElement = API.createElement({ type: "arrow" }); const arrowElement = API.createElement({ type: "arrow" });
Object.defineProperty(arrowElement, "endArrowhead", { Object.defineProperty(arrowElement, "endArrowhead", {
get: vi.fn(() => undefined), get: vi.fn(() => undefined),

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(7);
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(7);
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(7);
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(7);
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(7);
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(6);
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(6);
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(6);
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(7);
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(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });

View file

@ -34,6 +34,7 @@ export const rectangleFixture: ExcalidrawElement = {
export const embeddableFixture: ExcalidrawElement = { export const embeddableFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
type: "embeddable", type: "embeddable",
validated: null,
}; };
export const ellipseFixture: ExcalidrawElement = { export const ellipseFixture: ExcalidrawElement = {
...elementBase, ...elementBase,

View file

@ -15,11 +15,17 @@ import fs from "fs";
import util from "util"; import util from "util";
import path from "path"; import path from "path";
import { getMimeType } from "../../data/blob"; import { getMimeType } from "../../data/blob";
import { newFreeDrawElement, newImageElement } from "../../element/newElement"; import {
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
newImageElement,
} from "../../element/newElement";
import { Point } from "../../types"; import { Point } from "../../types";
import { getSelectedElements } from "../../scene/selection"; import { getSelectedElements } from "../../scene/selection";
import { isLinearElementType } from "../../element/typeChecks"; import { isLinearElementType } from "../../element/typeChecks";
import { Mutable } from "../../utility-types"; import { Mutable } from "../../utility-types";
import { assertNever } from "../../utils";
const readFile = util.promisify(fs.readFile); const readFile = util.promisify(fs.readFile);
@ -178,14 +184,20 @@ export class API {
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": case "ellipse":
case "embeddable":
element = newElement({ element = newElement({
type: type as "rectangle" | "diamond" | "ellipse" | "embeddable", type: type as "rectangle" | "diamond" | "ellipse",
width, width,
height, height,
...base, ...base,
}); });
break; break;
case "embeddable":
element = newEmbeddableElement({
type: "embeddable",
...base,
validated: null,
});
break;
case "text": case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize; const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily; const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
@ -234,6 +246,15 @@ export class API {
scale: rest.scale || [1, 1], scale: rest.scale || [1, 1],
}); });
break; break;
case "frame":
element = newFrameElement({ ...base, width, height });
break;
default:
assertNever(
type,
`API.createElement: unimplemented element type ${type}}`,
);
break;
} }
if (element.type === "arrow") { if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null; element.startBinding = rest.startBinding ?? null;
@ -269,7 +290,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();
@ -296,6 +317,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(5);
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(7);
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(6);
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(8);
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(9);
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(9);
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(8);
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(8);
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(9);
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(9);
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(8);
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(`
[ [
@ -1128,7 +1143,7 @@ describe("Test Linear Elements", () => {
height: 500, height: 500,
}); });
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 210, x: -10,
y: 250, y: 250,
width: 400, width: 400,
height: 1, height: 1,
@ -1152,8 +1167,8 @@ describe("Test Linear Elements", () => {
expect( expect(
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
"Online whiteboard collaboration "Online whiteboard
made easy" collaboration made easy"
`); `);
const handleBindTextResizeSpy = vi.spyOn( const handleBindTextResizeSpy = vi.spyOn(
textElementUtils, textElementUtils,
@ -1165,7 +1180,7 @@ describe("Test Linear Elements", () => {
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBe(170); expect(arrow.width).toBe(200);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@ -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(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();
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(20);
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(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();
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(6);
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(6);
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(6);
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(11);
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(11);
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(8);
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(8);
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(8);
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(8);
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(8);
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();
@ -466,3 +477,46 @@ describe("tool locking & selection", () => {
} }
}); });
}); });
describe("selectedElementIds stability", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("box-selection should be stable when not changing selection", () => {
const rectangle = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 10,
height: 10,
});
h.elements = [rectangle];
const selectedElementIds_1 = h.state.selectedElementIds;
mouse.downAt(-100, -100);
mouse.moveTo(-50, -50);
mouse.up();
expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
mouse.downAt(-50, -50);
mouse.moveTo(50, 50);
const selectedElementIds_2 = h.state.selectedElementIds;
expect(selectedElementIds_2).toEqual({ [rectangle.id]: true });
mouse.moveTo(60, 60);
// box-selecting further without changing selection should keep
// selectedElementIds stable (the same object)
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
mouse.up();
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
});
});

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,55 @@ export type LastActiveTool =
export type SidebarName = string; export type SidebarName = string;
export type SidebarTabName = string; export type SidebarTabName = string;
type _CommonCanvasAppState = {
zoom: AppState["zoom"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
width: AppState["width"];
height: AppState["height"];
viewModeEnabled: AppState["viewModeEnabled"];
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 = Readonly<
_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 = Readonly<
_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;
@ -409,13 +458,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;
}; };
@ -460,7 +509,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;
} }
}; };
@ -914,3 +918,87 @@ export const isOnlyExportingSingleFrame = (
) )
); );
}; };
export const assertNever = (
value: never,
message: string,
softAssert?: boolean,
): never => {
if (softAssert) {
console.error(message);
return value;
}
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;
};
})();

191
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==
@ -6114,9 +6072,9 @@ prop-types@^15.8.1:
react-is "^16.13.1" react-is "^16.13.1"
protobufjs@^6.8.6: protobufjs@^6.8.6:
version "6.11.3" version "6.11.4"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.4.tgz#29a412c38bf70d89e537b6d02d904a6f448173aa"
integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== integrity sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==
dependencies: dependencies:
"@protobufjs/aspromise" "^1.1.2" "@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2" "@protobufjs/base64" "^1.1.2"
@ -6133,9 +6091,9 @@ protobufjs@^6.8.6:
long "^4.0.0" long "^4.0.0"
protobufjs@^7.0.0: protobufjs@^7.0.0:
version "7.2.3" version "7.2.4"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.3.tgz#01af019e40d9c6133c49acbb3ff9e30f4f0f70b2" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.4.tgz#3fc1ec0cdc89dd91aef9ba6037ba07408485c3ae"
integrity sha512-TtpvOqwB5Gdz/PQmOjgsrGH1nHjAQVCN7JG4A6r1sXRWESL5rNMAiRcBQlCAdKxZcAbstExQePYG8xof/JVRgg== integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==
dependencies: dependencies:
"@protobufjs/aspromise" "^1.1.2" "@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2" "@protobufjs/base64" "^1.1.2"
@ -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"