Merge branch 'master' into kb/auto-save-support

# Conflicts:
#	src/tests/__snapshots__/regressionTests.test.tsx.snap
pull/3047/head
dwelle 4 years ago
commit 6215256787

@ -21,7 +21,7 @@
"dependencies": {
"@sentry/browser": "6.2.2",
"@sentry/integrations": "6.2.1",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.22",
"@types/react": "17.0.3",

@ -39,5 +39,37 @@
}
]
}
}
},
"screenshots": [
{
"src": "/screenshots/virtual-whiteboard.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/wireframe.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/illustration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/shapes.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/collaboration.png",
"type": "image/png",
"sizes": "462x945"
},
{
"src": "/screenshots/export.png",
"type": "image/png",
"sizes": "462x945"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@ -275,7 +275,7 @@ export type ExcalidrawImperativeAPI = {
setScrollToContent: InstanceType<typeof App>["setScrollToContent"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
setCanvasOffsets: InstanceType<typeof App>["setCanvasOffsets"];
refresh: InstanceType<typeof App>["refresh"];
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
setToastMessage: InstanceType<typeof App>["setToastMessage"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
@ -336,7 +336,7 @@ class App extends React.Component<AppProps, AppState> {
setScrollToContent: this.setScrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
setCanvasOffsets: this.setCanvasOffsets,
refresh: this.refresh,
importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage,
} as const;
@ -410,7 +410,6 @@ class App extends React.Component<AppProps, AppState> {
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
@ -441,6 +440,7 @@ class App extends React.Component<AppProps, AppState> {
"excalidraw--view-mode": viewModeEnabled,
})}
ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop}
>
<LayerUI
canvas={this.canvas}
@ -475,6 +475,7 @@ class App extends React.Component<AppProps, AppState> {
UIOptions={this.props.UIOptions}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{this.state.showStats && (
<Stats
appState={this.state}
@ -3622,9 +3623,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
private handleCanvasOnDrop = async (
event: React.DragEvent<HTMLCanvasElement>,
) => {
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
try {
const file = event.dataTransfer.files[0];
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
@ -3714,12 +3713,20 @@ class App extends React.Component<AppProps, AppState> {
const type = element ? "element" : "canvas";
const container = this.excalidrawContainerRef.current!;
const {
top: offsetTop,
left: offsetLeft,
} = container.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
if (element && !this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } }, () => {
this._openContextMenu(event, type);
this._openContextMenu({ top, left }, type);
});
} else {
this._openContextMenu(event, type);
this._openContextMenu({ top, left }, type);
}
};
@ -3813,11 +3820,11 @@ class App extends React.Component<AppProps, AppState> {
/** @private use this.handleCanvasContextMenu */
private _openContextMenu = (
{
clientX,
clientY,
left,
top,
}: {
clientX: number;
clientY: number;
left: number;
top: number;
},
type: "canvas" | "element",
) => {
@ -3868,10 +3875,11 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({
options: viewModeOptions,
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
if (this.state.viewModeEnabled) {
@ -3911,10 +3919,11 @@ class App extends React.Component<AppProps, AppState> {
actionToggleViewMode,
actionToggleStats,
],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
@ -3922,10 +3931,11 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
@ -3967,10 +3977,11 @@ class App extends React.Component<AppProps, AppState> {
actionDuplicateSelection,
actionDeleteSelected,
],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
};
@ -4146,7 +4157,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
public setCanvasOffsets = () => {
public refresh = () => {
this.setState({ ...this.getCanvasOffsets() });
};

@ -32,67 +32,63 @@ const ContextMenu = ({
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
return (
<div
className={clsx("excalidraw", {
"theme--dark theme--dark-background-none": isDarkTheme,
})}
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
>
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
</div>
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
};
let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
return contextMenuNode;
}
const div = document.createElement("div");
document.body.appendChild(div);
return (contextMenuNode = div);
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
};
type ContextMenuParams = {
@ -101,10 +97,16 @@ type ContextMenuParams = {
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
};
const handleClose = () => {
unmountComponentAtNode(getContextMenuNode());
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
@ -121,11 +123,11 @@ export default {
top={params.top}
left={params.left}
options={options}
onCloseRequest={handleClose}
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(),
getContextMenuNode(params.container),
);
}
},

@ -28,14 +28,7 @@ export const ErrorDialog = ({
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div>
{message.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
</Dialog>
)}
</>

@ -1,6 +1,6 @@
.excalidraw {
.popover {
position: fixed;
position: absolute;
z-index: 10;
}
}

@ -95,7 +95,7 @@ export const loadFromBlob = async (
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: blob.handle ?? null,
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null)

@ -6,36 +6,68 @@ The change should be grouped under one of the below section and must contain PR
- Features: For new features.
- Fixes: For bug fixes.
- Chore: Changes for non src files example package.json.
- Improvements: For any improvements.
- Refactor: For any refactoring.
Please add the latest change on the top under the correct section.
-->
## Unreleased
## 0.6.0 (2021-04-04)
## Excalidraw API
### Features
- Add `UIOptions` prop to customise `canvas actions` which includes customising `background color picker`, `clear canvas`, `export`, `load`, `save`, `save as` & `theme toggle` [#3364](https://github.com/excalidraw/excalidraw/pull/3364).
- Add `UIOptions` prop to customise `canvas actions` which includes customising `background color picker`, `clear canvas`, `export`, `load`, `save`, `save as` & `theme toggle` [#3364](https://github.com/excalidraw/excalidraw/pull/3364). Check the [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#uioptions) for more details.
- Calculate `width/height` of canvas based on excalidraw component (".excalidraw" selector) & also resize and update offsets whenever the dimensions of excalidraw component gets updated [#3379](https://github.com/excalidraw/excalidraw/pull/3379). You also don't need to add a resize handler anymore for excalidraw as its handled now in excalidraw itself.
#### BREAKING CHANGE
- `width/height` props have been removed. Instead now it takes `100%` of `width` and `height` of the container so you need to make sure the container in which you are rendering Excalidraw has non zero dimensions (It should have non zero width and height so Excalidraw can match the dimensions of containing block)
- Calculate offsets when excalidraw container resizes using resize observer api [#3374](https://github.com/excalidraw/excalidraw/pull/3374).
- Export types for the package so now it can be used with typescript [#3337](https://github.com/excalidraw/excalidraw/pull/3337). The types are available at `@excalidraw/excalirdraw/types`.
- Export types for the package so now it can be used with typescript [#3337](https://github.com/excalidraw/excalidraw/pull/3337). The types are available at `@excalidraw/excalidraw/types`.
- Add `renderCustomStats` prop to render extra stats on host, and expose `setToastMessage` API via refs which can be used to show toast with custom message [#3360](https://github.com/excalidraw/excalidraw/pull/3360).
- Support passing a CSRF token when importing libraries to prevent prompting before installation. The token is passed from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/) using the `token` URL key [#3329](https://github.com/excalidraw/excalidraw/pull/3329).
- #### BREAKING CHANGE
Use `location.hash` when importing libraries to fix installation issues. This will require host apps to add a `hashchange` listener and call the newly exposed `excalidrawAPI.importLibrary(url)` API when applicable [#3320](https://github.com/excalidraw/excalidraw/pull/3320).
Use `location.hash` when importing libraries to fix installation issues. This will require host apps to add a `hashchange` listener and call the newly exposed `excalidrawAPI.importLibrary(url)` API when applicable [#3320](https://github.com/excalidraw/excalidraw/pull/3320). Check the [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#importlibrary) for more details.
- Append `location.pathname` to `libraryReturnUrl` default url [#3325](https://github.com/excalidraw/excalidraw/pull/3325).
### Build
- Expose separate builds for dev and prod and support source maps in dev build [#3330](https://github.com/excalidraw/excalidraw/pull/3330).
- Expose separate builds for dev and prod and support source maps in dev build [#3330](https://github.com/excalidraw/excalidraw/pull/3330). Check the [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#installation) for more details.
#### BREAKING CHANGE
- If you are using script tag to embed excalidraw then the name of the file will have to be updated to `excalidraw.production.min.js` instead of `excalidraw.min.js`. If you want to use dev build you can use `excalidraw.development.js`
### Refactor
#### BREAKING CHANGE
- Rename the API `setCanvasOffsets` exposed via [`ref`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx#L265) to `refresh` [#3398](https://github.com/excalidraw/excalidraw/pull/3398).
## Excalidraw Library
### Features
- Reopen library menu on import from file [#3383](https://github.com/excalidraw/excalidraw/pull/3383).
- Don't unnecessarily prompt when installing libraries [#3329](https://github.com/excalidraw/excalidraw/pull/3329).
- Add option to flip single element on the context menu [#2520](https://github.com/excalidraw/excalidraw/pull/2520).
- Replace fontSize and fontFamily text with icons [#2857](https://github.com/excalidraw/excalidraw/pull/2857).
### Fixes
- Export dialog canvas positioning [#3397](https://github.com/excalidraw/excalidraw/pull/3397).
- Don't share collab types with core [#3353](https://github.com/excalidraw/excalidraw/pull/3353).
- Support d&d of files without extension [#3168](https://github.com/excalidraw/excalidraw/pull/3168).
- Positions stats for linear elements [#3331](https://github.com/excalidraw/excalidraw/pull/3331).
- Debounce.flush invokes func even if never queued before [#3326](https://github.com/excalidraw/excalidraw/pull/3326).
- State selection state on opening contextMenu [#3333](https://github.com/excalidraw/excalidraw/pull/3333).
- Add unique key for library header to resolve dev warnings [#3316](https://github.com/excalidraw/excalidraw/pull/3316).
- disallow create text in viewMode on mobile [#3219](https://github.com/excalidraw/excalidraw/pull/3219).
- Make help toggle tabbable [#3310](https://github.com/excalidraw/excalidraw/pull/3310)
- Show Windows share icon for Windows users [#3306](https://github.com/excalidraw/excalidraw/pull/3306).
- Don't scroll to content on INIT websocket message [#3291](https://github.com/excalidraw/excalidraw/pull/3291).
### Refactor
- Use arrow function where possible [#3315](https://github.com/excalidraw/excalidraw/pull/3315).
---
## 0.5.0 (2021-03-21)

@ -16,13 +16,13 @@ or via yarn
yarn add react react-dom @excalidraw/excalidraw
```
After installation you will see a folder `excalidraw-assets` in `dist` directory which contains the assets needed for this app.
After installation you will see a folder `excalidraw-assets` and `excalidraw-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
Move the folder `excalidraw-assets` to the path where your assets are served.
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
By default it will try to load the files from `https://unpkg.com/@excalidraw/excalidraw/{currentVersion}/dist/`
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` to the url from where you want to load the assets.
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
### Demo
@ -45,33 +45,11 @@ import "./styles.scss";
export default function App() {
const excalidrawRef = useRef(null);
const excalidrawWrapperRef = useRef(null);
const [dimensions, setDimensions] = useState({
width: undefined,
height: undefined,
});
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
useEffect(() => {
setDimensions({
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
height: excalidrawWrapperRef.current.getBoundingClientRect().height,
});
const onResize = () => {
setDimensions({
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
height: excalidrawWrapperRef.current.getBoundingClientRect().height,
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [excalidrawWrapperRef]);
const updateScene = () => {
const sceneData = {
elements: [
@ -144,13 +122,11 @@ export default function App() {
Grid mode
</label>
</div>
<div className="excalidraw-wrapper" ref={excalidrawWrapperRef}>
<div className="excalidraw-wrapper">
<Excalidraw
ref={excalidrawRef}
width={dimensions.width}
height={dimensions.height}
initialData={InitialData}
onChange={(elements, state) =>
onChange={(elements, state) => {
console.log("Elements :", elements, "State : ", state)
}
onPointerUpdate={(payload) => console.log(payload)}
@ -171,6 +147,8 @@ To view the full example visit :point_down:
[![Edit excalidraw](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/excalidraw-ehlz3?fontsize=14&hidenavigation=1&theme=dark)
</details>
Since Excalidraw doesn't support server side rendering yet so you will have to make sure the component is rendered once host is mounted.
```js
@ -184,13 +162,31 @@ export default function IndexPage() {
}
```
</details>
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
#### In Browser
To use it in a browser directly:
You will need to make sure `react`, `react-dom` is available as shown below.
For development use :point_down:
```js
<script
type="text/javascript"
src="https://unpkg.com/@excalidraw/excalidraw@0.6.0/dist/excalidraw.development.js"
></script>
```
For production use :point_down:
```js
<script
type="text/javascript"
src="https://unpkg.com/@excalidraw/excalidraw@0.6.0/dist/excalidraw.production.min.js"
></script>
```
You will need to make sure `react`, `react-dom` is available as shown in the below example. For prod please use the production versions of `react`, `react-dom`.
<details><summary><strong>View Example</strong></summary>
@ -205,7 +201,7 @@ You will need to make sure `react`, `react-dom` is available as shown below.
<script
type="text/javascript"
src="https://unpkg.com/@excalidraw/excalidraw@0.5.0/dist/excalidraw.min.js"
src="https://unpkg.com/@excalidraw/excalidraw@0.6.0/dist/excalidraw.development.js"
></script>
</head>
@ -226,33 +222,11 @@ import InitialData from "./initialData";
const App = () => {
const excalidrawRef = React.useRef(null);
const excalidrawWrapperRef = React.useRef(null);
const [dimensions, setDimensions] = React.useState({
width: undefined,
height: undefined,
});
const [viewModeEnabled, setViewModeEnabled] = React.useState(false);
const [zenModeEnabled, setZenModeEnabled] = React.useState(false);
const [gridModeEnabled, setGridModeEnabled] = React.useState(false);
React.useEffect(() => {
setDimensions({
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
height: excalidrawWrapperRef.current.getBoundingClientRect().height,
});
const onResize = () => {
setDimensions({
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
height: excalidrawWrapperRef.current.getBoundingClientRect().height,
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [excalidrawWrapperRef]);
const updateScene = () => {
const sceneData = {
elements: [
@ -345,9 +319,6 @@ const App = () => {
ref: excalidrawWrapperRef,
},
React.createElement(Excalidraw.default, {
ref: excalidrawRef,
width: dimensions.width,
height: dimensions.height,
initialData: InitialData,
onChange: (elements, state) =>
console.log("Elements :", elements, "State : ", state),
@ -376,8 +347,6 @@ To view the full example visit :point_down:
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [`width`](#width) | Number | `window.innerWidth` | The width of Excalidraw component |
| [`height`](#height) | Number | `window.innerHeight` | The height of Excalidraw component |
| [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. |
| [`initialData`](#initialData) | <pre>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37">AppState<a> } </pre> | null | The initial data with which app loads. |
| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) or [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) or <pre>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</pre> | | Ref to be passed to Excalidraw |
@ -387,20 +356,18 @@ To view the full example visit :point_down:
| [`onExportToBackend`](#onExportToBackend) | Function | | Callback triggered when link button is clicked on export dialog |
| [`langCode`](#langCode) | string | `en` | Language code string |
| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer |
| [`renderCustomStats`](#renderCustomStats) | Function | | Function that can be used to render custom stats on the stats dialog. |
| [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. |
| [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled |
| [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled |
| [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `light` or `dark` | | The theme of the Excalidraw component |
| [`name`](#name) | string | | Name of the drawing |
| [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) |
#### `width`
### Dimensions of Excalidraw
This props defines the `width` of the Excalidraw component. Defaults to `window.innerWidth` if not passed.
#### `height`
This props defines the `height` of the Excalidraw component. Defaults to `window.innerHeight` if not passed.
Excalidraw takes `100%` of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
#### `onChange`
@ -472,7 +439,9 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| getAppState | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37">AppState</a></pre> | Returns current appState |
| history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history |
| setScrollToContent | <pre> (<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>) => void </pre> | Scroll to the nearest element to center |
| setCanvasOffsets | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You should call this API when your app changes the dimensions/position of the Excalidraw container, such as when toggling a sidebar. You don't have to call this when the position is changed on page scroll (we handled that ourselves). |
| refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. |
| [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL |
| setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. |
#### `readyPromise`
@ -531,6 +500,10 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
A function that renders (returns JSX) custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker).
#### `renderCustomStats`
A function that can be used to render custom stats (returns JSX) in the nerd stats dialog. For example you can use this prop to render the size of the elements in the storage.
#### `viewModeEnabled`
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over `intialData.appState.viewModeEnabled`, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
@ -545,7 +518,7 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
#### `libraryReturnUrl`
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to `window.location.origin`. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to `window.location.origin + window.location.pathname`. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
#### `theme`
@ -555,10 +528,52 @@ This prop controls Excalidraw's theme. When supplied, the value takes precedence
This prop sets the name of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over `intialData.appState.name`, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
### `UIOptions`
This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters
<pre>
{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }
</pre>
#### canvasActions
| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `changeViewBackgroundColor` | boolean | true | Implies whether to show `Background color picker` |
| `clearCanvas` | boolean | true | Implies whether to show `Clear canvas button` |
| `export` | boolean | true | Implies whether to show `Export button` |
| `loadScene` | boolean | true | Implies whether to show `Load button` |
| `saveAsScene` | boolean | true | Implies whether to show `Save as button` |
| `saveScene` | boolean | true | Implies whether to show `Save button` |
| `theme` | boolean | true | Implies whether to show `Theme toggle` |
### Does it support collaboration ?
No Excalidraw package doesn't come with collaboration, since this would have different implementations on the consumer so we expose the API's which you can use to communicate with Excalidraw as mentioned above. If you are interested in understanding how Excalidraw does it you can check it [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### importLibrary
Imports library from given URL. You should call this on `hashchange`, passing the `addLibrary` value if you detect it as shown below. Optionally pass a CSRF `token` to skip prompting during installation (retrievable via `token` key from the url coming from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/)).
```js
useEffect(() => {
const onHashChange = () => {
const hash = new URLSearchParams(window.location.hash.slice(1));
const libraryUrl = hash.get("addLibrary");
if (libraryUrl) {
excalidrawRef.current.importLibrary(libraryUrl, hash.get("token"));
}
};
window.addEventListener("hashchange", onHashChange, false);
return () => {
window.removeEventListener("hashchange", onHashChange);
};
}, []);
```
Try out the [Demo](#Demo) to see it in action.
### Extra API's
#### `getSceneVersion`

@ -16,9 +16,9 @@ or via yarn
yarn add react react-dom @excalidraw/excalidraw
```
After installation you will see a folder `excalidraw-assets` and `excalidraw-dev-assets` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
After installation you will see a folder `excalidraw-assets` and `excalidraw-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
Move the folder `excalidraw-assets` and `excalidraw-dev-assets` to the path where your assets are served.
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
By default it will try to load the files from `https://unpkg.com/@excalidraw/excalidraw/{currentVersion}/dist/`
@ -50,7 +50,6 @@ export default function App() {
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const updateScene = () => {
const sceneData = {
elements: [
@ -368,7 +367,7 @@ To view the full example visit :point_down:
### Dimensions of Excalidraw
Excalidraw takes `100%` of `width` and `height` of the containing block so you need to make sure the container in which you are rendering Excalidraw has non zero dimensions (It should have non zero width and height so Excalidraw can match the dimensions of the containing block). This is to make sure you don't have to worry about updating the offsets of dimensions when resizing Excalidraw.
Excalidraw takes `100%` of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
#### `onChange`
@ -440,8 +439,8 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| getAppState | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37">AppState</a></pre> | Returns current appState |
| history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history |
| setScrollToContent | <pre> (<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>) => void </pre> | Scroll to the nearest element to center |
| setCanvasOffsets | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. |
| importLibrary | `(url: string, token?: string) => void` | Imports library from given URL. You should call this on `hashchange`, passing the `addLibrary` value if you detect it. Optionally pass a CSRF `token` to skip prompting during installation (retrievable via `token` key from the url coming from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/)). |
| refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. |
| [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL |
| setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. |
#### `readyPromise`
@ -553,6 +552,28 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
No Excalidraw package doesn't come with collaboration, since this would have different implementations on the consumer so we expose the API's which you can use to communicate with Excalidraw as mentioned above. If you are interested in understanding how Excalidraw does it you can check it [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### importLibrary
Imports library from given URL. You should call this on `hashchange`, passing the `addLibrary` value if you detect it as shown below. Optionally pass a CSRF `token` to skip prompting during installation (retrievable via `token` key from the url coming from [https://libraries.excalidraw.com](https://libraries.excalidraw.com/)).
```js
useEffect(() => {
const onHashChange = () => {
const hash = new URLSearchParams(window.location.hash.slice(1));
const libraryUrl = hash.get("addLibrary");
if (libraryUrl) {
excalidrawRef.current.importLibrary(libraryUrl, hash.get("token"));
}
};
window.addEventListener("hashchange", onHashChange, false);
return () => {
window.removeEventListener("hashchange", onHashChange);
};
}, []);
```
Try out the [Demo](#Demo) to see it in action.
### Extra API's
#### `getSceneVersion`

@ -1,6 +1,6 @@
{
"name": "@excalidraw/excalidraw",
"version": "0.5.0",
"version": "0.6.0",
"main": "main.js",
"types": "types/packages/excalidraw/index.d.ts",
"files": [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,531 @@
import ReactDOM from "react-dom";
import {
render,
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
GlobalTestState,
screen,
queryByText,
queryAllByText,
waitFor,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { CODES } from "../keys";
import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
};
const mouse = new Pointer("mouse");
const queryContextMenu = () => {
return GlobalTestState.renderResult.container.querySelector(".context-menu");
};
const clickLabeledElement = (label: string) => {
const element = document.querySelector(`[aria-label='${label}']`);
if (!element) {
throw new Error(`No labeled element found: ${label}`);
}
fireEvent.click(element);
};
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
reseed(7);
});
const { h } = window;
describe("contextMenu element", () => {
beforeEach(async () => {
localStorage.clear();
renderScene.mockClear();
h.history.clear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<ExcalidrawApp />);
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
afterEach(() => {
checkpoint("end of test");
mouse.reset();
mouse.down(0, 0);
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"selectAll",
"gridMode",
"zenMode",
"viewMode",
"stats",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows context menu for element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"addToLibrary",
"flipHorizontal",
"flipVertical",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows 'Group selection' in context menu for multiple selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"group",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"ungroup",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles);
expect(element).toEqual(API.getSelectedElement());
});
it("selecting 'Paste styles' in context menu pastes styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
clickLabeledElement("Stroke");
clickLabeledElement("#c92a2a");
clickLabeledElement("Background");
clickLabeledElement("#e64980");
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
expect(API.getSelectedElements()).toHaveLength(0);
expect(h.elements[0].isDeleted).toBe(true);
});
it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
const addedElement = JSON.parse(library!)[0][0];
expect(addedElement).toEqual(h.elements[0]);
});
});
it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
expect(rect1).toEqual(rect2);
});
it("selecting 'Send backward' in context menu sends element backward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring forward' in context menu brings element forward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Send to back' in context menu sends element to back", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring to front' in context menu brings element to front", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
});
it("selecting 'Group selection' in context menu groups selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Group selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
});
it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
expect(contextMenu).not.toBeNull();
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(selectedGroupIds).toHaveLength(0);
expect(h.elements[0].groupIds).toHaveLength(0);
expect(h.elements[1].groupIds).toHaveLength(0);
});
});

@ -1,8 +1,4 @@
import { queryAllByText, queryByText } from "@testing-library/react";
import React from "react";
import ReactDOM from "react-dom";
import { copiedStyles } from "../actions/actionStyles";
import { ShortcutName } from "../actions/shortcuts";
import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app";
@ -11,13 +7,7 @@ import * as Renderer from "../renderer/renderScene";
import { setDateTimeForTests } from "../utils";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
fireEvent,
GlobalTestState,
render,
screen,
waitFor,
} from "./test-utils";
import { fireEvent, render, screen, waitFor } from "./test-utils";
import { defaultLang } from "../i18n";
const { h } = window;
@ -612,441 +602,6 @@ describe("regression tests", () => {
expect(h.state.currentItemFontFamily).toEqual(3); // Cascadia
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"selectAll",
"gridMode",
"zenMode",
"viewMode",
"stats",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows context menu for element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"addToLibrary",
"flipHorizontal",
"flipVertical",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows 'Group selection' in context menu for multiple selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"group",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"ungroup",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles);
expect(element).toEqual(API.getSelectedElement());
});
it("selecting 'Paste styles' in context menu pastes styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
clickLabeledElement("Stroke");
clickLabeledElement("#c92a2a");
clickLabeledElement("Background");
clickLabeledElement("#e64980");
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
expect(API.getSelectedElements()).toHaveLength(0);
expect(h.elements[0].isDeleted).toBe(true);
});
it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
const addedElement = JSON.parse(library!)[0][0];
expect(addedElement).toEqual(h.elements[0]);
});
});
it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
expect(rect1).toEqual(rect2);
});
it("selecting 'Send backward' in context menu sends element backward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring forward' in context menu brings element forward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Send to back' in context menu sends element to back", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring to front' in context menu brings element to front", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
});
it("selecting 'Group selection' in context menu groups selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Group selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
});
it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(selectedGroupIds).toHaveLength(0);
expect(h.elements[0].groupIds).toHaveLength(0);
expect(h.elements[1].groupIds).toHaveLength(0);
});
it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
UI.clickTool("ellipse");
mouse.down();

@ -1879,9 +1879,9 @@
lz-string "^1.4.4"
pretty-format "^26.6.2"
"@testing-library/jest-dom@5.11.9":
version "5.11.9"
resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz"
"@testing-library/jest-dom@5.11.10":
version "5.11.10"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.10.tgz#1cd90715023e1627f5ed26ab3b38e6f22d77046c"
dependencies:
"@babel/runtime" "^7.9.2"
"@types/testing-library__jest-dom" "^5.9.1"

Loading…
Cancel
Save