Compare commits

...

8 Commits

Author SHA1 Message Date
dwelle 0e3a5b2042 feat: cycle through selected elements on cmd/ctrl-click 2021-05-29 22:34:41 +02:00
David Luzar c819b653bf fix: on contextMenu, use selected element regardless of z-index (#3668) 2021-05-29 22:33:53 +02:00
David Luzar 60cea7a0c2 fix: selectedGroupIds not being stored in history (#3630)
thanks!
2021-05-29 21:35:03 +02:00
Aakansha Doshi d63b6a3469 feat: support custom UI rendering inside export dialog (#3666)
* feat: support custom UI rendering inside export dialog

* docs

* add

* remove assertion

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-05-30 00:37:38 +05:30
Jed Fox 0912fe1c93 fix: overscroll on touch devices (#3663) 2021-05-29 11:54:36 -04:00
Aakansha Doshi 360310de31 feat: Add prop UIOptions.canvasActions.saveAsImage to show/hide save image button (#3662)
* feat: Add prop UIOptions.canvasActions.saveAsImage which implies whether the save as image dialog should be shown

* Add docs

* fix specs
2021-05-29 19:41:50 +05:30
dependabot[bot] 716c78e930 chore(deps): bump dns-packet from 1.3.1 to 1.3.4 (#3652)
Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-29 16:06:20 +02:00
Aakansha Doshi ba48974351 feat: customise export dialog with UIOptions.canvasActions.export prop (#3658)
* refactor: update UIOptions.canvasActions.export to be a an object

* fix

* fix

* dnt show export icon when false

* fix

* inline

* memoize UIOptions

* update docs

* fix

* tweak readme

Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-05-29 02:56:25 +05:30
25 changed files with 926 additions and 193 deletions
+1
View File
@@ -117,6 +117,7 @@
width: 100%;
height: 100%;
overflow: hidden;
}
.visually-hidden {
+2 -2
View File
@@ -113,8 +113,8 @@ export const actionSaveToActiveFile = register({
),
});
export const actionSaveAsScene = register({
name: "saveAsScene",
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
+1 -1
View File
@@ -35,7 +35,7 @@ export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveAsScene,
actionSaveFileToDisk,
actionLoadScene,
} from "./actionExport";
+1 -1
View File
@@ -67,7 +67,7 @@ export type ActionName =
| "changeExportBackground"
| "changeExportEmbedScene"
| "saveToActiveFile"
| "saveAsScene"
| "saveFileToDisk"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
+25 -4
View File
@@ -452,7 +452,6 @@ class App extends React.Component<AppProps, AppState> {
const {
onCollabButtonClick,
onExportToBackend,
renderTopRightUI,
renderFooter,
renderCustomStats,
@@ -493,7 +492,6 @@ class App extends React.Component<AppProps, AppState> {
toggleZenMode={this.toggleZenMode}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderTopRightUI={renderTopRightUI}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
@@ -1860,9 +1858,29 @@ class App extends React.Component<AppProps, AppState> {
private getElementAtPosition(
x: number,
y: number,
opts?: {
/** if true, returns the first selected element (with highest z-index)
of all hit elements */
preferSelected?: boolean;
cycleElementsUnderCursor?: boolean;
},
): NonDeleted<ExcalidrawElement> | null {
const allHitElements = this.getElementsAtPosition(x, y);
if (allHitElements.length > 1) {
if (opts?.preferSelected) {
for (let index = allHitElements.length - 1; index > -1; index--) {
if (this.state.selectedElementIds[allHitElements[index].id]) {
return allHitElements[index];
}
}
} else if (opts?.cycleElementsUnderCursor) {
const selectedIdx = allHitElements.findIndex(
(element) => this.state.selectedElementIds[element.id],
);
return selectedIdx > 0
? allHitElements[selectedIdx - 1]
: allHitElements[allHitElements.length - 1];
}
const elementWithHighestZIndex =
allHitElements[allHitElements.length - 1];
// If we're hitting element with highest z-index only on its bounding box
@@ -2736,10 +2754,13 @@ class App extends React.Component<AppProps, AppState> {
// hitElement may already be set above, so check first
pointerDownState.hit.element =
pointerDownState.hit.element ??
(!event[KEYS.CTRL_OR_CMD] ? pointerDownState.hit.element : null) ??
this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
{
cycleElementsUnderCursor: event[KEYS.CTRL_OR_CMD],
},
);
// For overlapped elements one position may hit
@@ -3937,7 +3958,7 @@ class App extends React.Component<AppProps, AppState> {
event.preventDefault();
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
const element = this.getElementAtPosition(x, y);
const element = this.getElementAtPosition(x, y, { preferSelected: true });
const type = element ? "element" : "canvas";
+36 -26
View File
@@ -3,11 +3,11 @@ import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState } from "../types";
import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveAsScene } from "../actions/actionExport";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
@@ -22,35 +22,40 @@ const JSONExportModal = ({
elements,
appState,
actionManager,
onExportToBackend,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveAsScene);
}}
/>
</Card>
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
)}
{onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{link}</div>
@@ -62,10 +67,12 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => onExportToBackend(elements)}
onClick={() => onExportToBackend(elements, appState, canvas)}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, canvas)}
</div>
</div>
);
@@ -75,12 +82,14 @@ export const JSONExportDialog = ({
elements,
appState,
actionManager,
onExportToBackend,
exportOpts,
canvas,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onExportToBackend?: ExportCB;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
@@ -107,8 +116,9 @@ export const JSONExportDialog = ({
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
+3 -15
View File
@@ -63,11 +63,6 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
@@ -371,7 +366,6 @@ const LayerUI = ({
showThemeBtn,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderTopRightUI,
renderCustomFooter,
viewModeEnabled,
@@ -393,20 +387,14 @@ const LayerUI = ({
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
/>
);
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.export) {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
+2 -2
View File
@@ -131,11 +131,11 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: true,
export: { saveFileToDisk: true },
loadScene: true,
saveAsScene: true,
saveToActiveFile: true,
theme: true,
saveAsImage: true,
},
};
+13 -1
View File
@@ -307,7 +307,19 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>,
): TElement => {
let copy: TElement = deepCopyElement(element);
copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId();
if (process.env.NODE_ENV === "test") {
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === copy.id)
) {
copy.id += "_copy";
}
} else {
copy.id = randomId();
}
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
+7 -1
View File
@@ -424,7 +424,13 @@ const ExcalidrawWrapper = () => {
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend}
UIOptions={{
canvasActions: {
export: {
onExportToBackend,
},
},
}}
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
langCode={langCode}
+2 -1
View File
@@ -21,6 +21,7 @@ interface DehydratedHistoryEntry {
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
@@ -169,7 +170,7 @@ class History {
continue;
}
}
if (key === "selectedElementIds") {
if (key === "selectedElementIds" || key === "selectedGroupIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
+18
View File
@@ -13,8 +13,24 @@ Please add the latest change on the top under the correct section.
## Unreleased
**This changes are not yet released but you can still try it out in [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**
## Excalidraw API
### Features
- Added prop `UIOptions.canvasActions.export.renderCustomUI` to support Custom UI rendering inside export dialog [#3666](https://github.com/excalidraw/excalidraw/pull/3666).
- Added prop `UIOptions.canvasActions.saveAsImage` to show/hide the **Save as image** button in the canvas actions. Defauls to `true` hence the **Save as Image** button is rendered [#3662](https://github.com/excalidraw/excalidraw/pull/3662).
- Export dialog can be customised with [`UiOptions.canvasActions.export`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportOpts) [#3658](https://github.com/excalidraw/excalidraw/pull/3658).
Also, [`UIOptions`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#UIOptions) is now memoized to avoid unnecessary rerenders.
#### BREAKING CHANGE
- `UIOptions.canvasActions.saveAsScene` is now renamed to `UiOptions.canvasActions.export.saveFileToDisk`. Defaults to `true` hence the **save file to disk** button is rendered inside the export dialog.
- `exportToBackend` is now renamed to `UIOptions.canvasActions.export.exportToBackend`. If this prop is not passed, the **shareable-link** button will not be rendered, same as before.
### Refactor
- #### BREAKING CHANGE
@@ -22,6 +38,8 @@ Please add the latest change on the top under the correct section.
- Removed `shouldAddWatermark: boolean` attribute from options for [export](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#export-utilities) APIs [#3639](https://github.com/excalidraw/excalidraw/pull/3639).
- Removed `appState.shouldAddWatermark` so in case you were passing `shouldAddWatermark` in [initialData.AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) it will not work anymore.
---
## 0.8.0 (2021-05-15)
## Excalidraw API
+12 -7
View File
@@ -363,7 +363,6 @@ To view the full example visit :point_down:
| [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked |
| [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode |
| [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. |
| [`onExportToBackend`](#onExportToBackend) | Function | | Callback triggered when link button is clicked on export dialog |
| [`langCode`](#langCode) | string | `en` | Language code string |
| [`renderTopRightUI`](#renderTopRightUI) | Function | | Function that renders custom UI in top right corner |
| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer |
@@ -488,10 +487,6 @@ This callback is triggered when mouse pointer is updated.
3.`pointersMap`: [`pointers map`](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L131) of the scene
#### `onExportToBackend`
This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed.
```js
(exportedElements, appState, canvas) => void
```
@@ -571,11 +566,21 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
| --- | --- | --- | --- |
| `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` |
| `export` | false &#124; [exportOpts](#exportOpts) | <pre>{ saveFileToDisk: true }</pre> | This prop allows to customize the UI inside the export dialog. By default it shows the "saveFileToDisk". If this prop is `false` the export button will not be rendered. For more details visit [`exportOpts`](#exportOpts). |
| `loadScene` | boolean | true | Implies whether to show `Load button` |
| `saveAsScene` | boolean | true | Implies whether to show `Save as button` |
| `saveToActiveFile` | boolean | true | Implies whether to show `Save button` to save to current file |
| `theme` | boolean | true | Implies whether to show `Theme toggle` |
| `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
#### `exportOpts`
The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `saveFileToDisk` | boolean | true | Implies if save file to disk button should be shown |
| `exportToBackend` | <pre> (exportedElements: readonly NonDeletedExcalidrawElement[],appState: AppState,canvas: HTMLCanvasElement &#124; null) => void </pre> | | This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed. |
| `renderCustomUI` | <pre> (exportedElements: readonly NonDeletedExcalidrawElement[],appState: AppState,canvas: HTMLCanvasElement &#124; null) => void </pre> | | This callback should be supplied if you want to render custom UI in the export dialog. |
#### `onPaste`
+56 -6
View File
@@ -7,7 +7,7 @@ import App from "../../components/App";
import "../../css/app.scss";
import "../../css/styles.scss";
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants";
@@ -19,7 +19,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
onCollabButtonClick,
isCollaborating,
onPointerUpdate,
onExportToBackend,
renderTopRightUI,
renderFooter,
langCode = defaultLang.code,
@@ -38,13 +37,19 @@ const Excalidraw = (props: ExcalidrawProps) => {
const canvasActions = props.UIOptions?.canvasActions;
const UIOptions = {
const UIOptions: AppProps["UIOptions"] = {
canvasActions: {
...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions,
},
};
if (canvasActions?.export) {
UIOptions.canvasActions.export.saveFileToDisk =
canvasActions.export?.saveFileToDisk ||
DEFAULT_UI_OPTIONS.canvasActions.export.saveFileToDisk;
}
useEffect(() => {
// Block pinch-zooming on iOS outside of the content area
const handleTouchMove = (event: TouchEvent) => {
@@ -72,7 +77,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
onCollabButtonClick={onCollabButtonClick}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
onExportToBackend={onExportToBackend}
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
langCode={langCode}
@@ -99,12 +103,58 @@ const areEqual = (
prevProps: PublicExcalidrawProps,
nextProps: PublicExcalidrawProps,
) => {
const { initialData: prevInitialData, ...prev } = prevProps;
const { initialData: nextInitialData, ...next } = nextProps;
const {
initialData: prevInitialData,
UIOptions: prevUIOptions = {},
...prev
} = prevProps;
const {
initialData: nextInitialData,
UIOptions: nextUIOptions = {},
...next
} = nextProps;
// comparing UIOptions
const prevUIOptionsKeys = Object.keys(prevUIOptions) as (keyof Partial<
typeof DEFAULT_UI_OPTIONS
>)[];
const nextUIOptionsKeys = Object.keys(nextUIOptions) as (keyof Partial<
typeof DEFAULT_UI_OPTIONS
>)[];
if (prevUIOptionsKeys.length !== nextUIOptionsKeys.length) {
return false;
}
const isUIOptionsSame = prevUIOptionsKeys.every((key) => {
if (key === "canvasActions") {
const canvasOptionKeys = Object.keys(
prevUIOptions.canvasActions!,
) as (keyof Partial<typeof DEFAULT_UI_OPTIONS.canvasActions>)[];
canvasOptionKeys.every((key) => {
if (
key === "export" &&
prevUIOptions?.canvasActions?.export &&
nextUIOptions?.canvasActions?.export
) {
return (
prevUIOptions.canvasActions.export.saveFileToDisk ===
nextUIOptions.canvasActions.export.saveFileToDisk
);
}
return (
prevUIOptions?.canvasActions?.[key] ===
nextUIOptions?.canvasActions?.[key]
);
});
}
return true;
});
const prevKeys = Object.keys(prevProps) as (keyof typeof prev)[];
const nextKeys = Object.keys(nextProps) as (keyof typeof next)[];
return (
isUIOptionsSame &&
prevKeys.length === nextKeys.length &&
prevKeys.every((key) => prev[key] === next[key])
);
@@ -115,6 +115,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -127,6 +128,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -304,6 +306,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -316,6 +319,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -352,6 +356,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -411,6 +416,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -611,6 +617,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -623,6 +630,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -659,6 +667,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -718,6 +727,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -892,6 +902,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -904,6 +915,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1053,6 +1065,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -1065,6 +1078,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1099,6 +1113,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1276,6 +1291,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -1288,6 +1304,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1324,6 +1341,7 @@ Object {
"selectedElementIds": Object {
"id0_copy": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1534,6 +1552,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -1546,6 +1565,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1582,6 +1602,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1643,6 +1664,9 @@ Object {
"id1": true,
"id2": true,
},
"selectedGroupIds": Object {
"id3": true,
},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1847,6 +1871,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -1859,6 +1884,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1895,6 +1921,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -1954,6 +1981,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2013,6 +2041,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2072,6 +2101,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2131,6 +2161,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2190,6 +2221,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2249,6 +2281,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2308,6 +2341,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2367,6 +2401,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2567,6 +2602,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -2579,6 +2615,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2615,6 +2652,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2674,6 +2712,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2874,6 +2913,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -2886,6 +2926,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2922,6 +2963,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -2981,6 +3023,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3185,6 +3228,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -3197,6 +3241,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3233,6 +3278,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3294,6 +3340,9 @@ Object {
"id1": true,
"id2": true,
},
"selectedGroupIds": Object {
"id3": true,
},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3359,6 +3408,7 @@ Object {
"id1": true,
"id2": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3565,6 +3615,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -3577,6 +3628,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3613,6 +3665,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3825,6 +3878,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -3837,6 +3891,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3873,6 +3928,7 @@ Object {
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -3935,6 +3991,9 @@ Object {
"id2": true,
"id3": true,
},
"selectedGroupIds": Object {
"id4": true,
},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -4085,6 +4144,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -4175,6 +4235,84 @@ Object {
}
`;
exports[`contextMenu element shows context menu for element: [end of test] appState 2`] = `
Object {
"collaborators": Map {},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "hachure",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemLinearStrokeSharpness": "round",
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#000000",
"currentItemStrokeSharpness": "sharp",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 1,
"currentItemTextAlign": "left",
"cursorButton": "up",
"draggingElement": null,
"editingElement": null,
"editingGroupId": null,
"editingLinearElement": null,
"elementLocked": false,
"elementType": "selection",
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportWithDarkMode": false,
"fileHandle": null,
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"offsetLeft": 20,
"offsetTop": 10,
"openMenu": null,
"pasteDialog": Object {
"data": null,
"shown": false,
},
"previousSelectedElementIds": Object {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"selectedElementIds": Object {
"id1": true,
},
"selectedGroupIds": Object {},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHelpDialog": false,
"showStats": false,
"startBoundElement": null,
"suggestedBindings": Array [],
"theme": "light",
"toastMessage": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"width": 200,
"zenModeEnabled": false,
"zoom": Object {
"translation": Object {
"x": 0,
"y": 0,
},
"value": 1,
},
}
`;
exports[`contextMenu element shows context menu for element: [end of test] element 0 1`] = `
Object {
"angle": 0,
@@ -4201,6 +4339,58 @@ Object {
}
`;
exports[`contextMenu element shows context menu for element: [end of test] element 0 2`] = `
Object {
"angle": 0,
"backgroundColor": "red",
"boundElementIds": null,
"fillStyle": "hachure",
"groupIds": Array [],
"height": 200,
"id": "id0",
"isDeleted": false,
"opacity": 100,
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"version": 1,
"versionNonce": 0,
"width": 200,
"x": 0,
"y": 0,
}
`;
exports[`contextMenu element shows context menu for element: [end of test] element 1 1`] = `
Object {
"angle": 0,
"backgroundColor": "red",
"boundElementIds": null,
"fillStyle": "hachure",
"groupIds": Array [],
"height": 200,
"id": "id1",
"isDeleted": false,
"opacity": 100,
"roughness": 1,
"seed": 1278240551,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
"version": 1,
"versionNonce": 0,
"width": 200,
"x": 0,
"y": 0,
}
`;
exports[`contextMenu element shows context menu for element: [end of test] history 1`] = `
Object {
"recording": false,
@@ -4212,6 +4402,7 @@ Object {
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
@@ -4224,6 +4415,7 @@ Object {
"selectedElementIds": Object {
"id0": true,
},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [
@@ -4256,6 +4448,30 @@ Object {
}
`;
exports[`contextMenu element shows context menu for element: [end of test] history 2`] = `
Object {
"recording": false,
"redoStack": Array [],
"stateHistory": Array [
Object {
"appState": Object {
"editingGroupId": null,
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": Object {},
"selectedGroupIds": Object {},
"viewBackgroundColor": "#ffffff",
},
"elements": Array [],
},
],
}
`;
exports[`contextMenu element shows context menu for element: [end of test] number of elements 1`] = `1`;
exports[`contextMenu element shows context menu for element: [end of test] number of elements 2`] = `2`;
exports[`contextMenu element shows context menu for element: [end of test] number of renders 1`] = `9`;
exports[`contextMenu element shows context menu for element: [end of test] number of renders 2`] = `6`;
File diff suppressed because it is too large Load Diff
+40
View File
@@ -147,6 +147,46 @@ describe("contextMenu element", () => {
});
});
it("shows context menu for element", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
height: 200,
width: 200,
backgroundColor: "red",
});
const rect2 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
height: 200,
width: 200,
backgroundColor: "red",
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1]);
// lower z-index
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 100,
clientY: 100,
});
expect(queryContextMenu()).not.toBeNull();
expect(API.getSelectedElement().id).toBe(rect1.id);
// higher z-index
API.setSelectedElements([rect2]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 100,
clientY: 100,
});
expect(queryContextMenu()).not.toBeNull();
expect(API.getSelectedElement().id).toBe(rect2.id);
});
it("shows 'Group selection' in context menu for multiple selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
+11 -2
View File
@@ -167,6 +167,13 @@ describe("<Excalidraw/>", () => {
);
expect(queryByTestId(container, "json-export-button")).toBeNull();
});
it("should hide 'Save as image' button when 'saveAsImage' is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
);
expect(queryByTestId(container, "image-export-button")).toBeNull();
});
@@ -178,9 +185,11 @@ describe("<Excalidraw/>", () => {
expect(queryByTestId(container, "load-button")).toBeNull();
});
it("should hide save as button when saveAsScene is false", async () => {
it("should hide save as button when saveFileToDisk is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { saveAsScene: false } }} />,
<Excalidraw
UIOptions={{ canvasActions: { export: { saveFileToDisk: false } } }}
/>,
);
expect(queryByTestId(container, "save-as-button")).toBeNull();
+9
View File
@@ -20,6 +20,15 @@ const readFile = util.promisify(fs.readFile);
const { h } = window;
export class API {
static setSelectedElements = (elements: ExcalidrawElement[]) => {
h.setState({
selectedElementIds: elements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
});
};
static getSelectedElements = (): ExcalidrawElement[] => {
return h.elements.filter(
(element) => h.state.selectedElementIds[element.id],
+78 -2
View File
@@ -1,7 +1,7 @@
import React from "react";
import { render } from "./test-utils";
import { assertSelectedElements, render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { UI } from "./helpers/ui";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState";
import { waitFor } from "@testing-library/react";
@@ -10,6 +10,8 @@ import { EXPORT_DATA_TYPES } from "../constants";
const { h } = window;
const mouse = new Pointer("mouse");
describe("history", () => {
it("initializing scene should end up with single history entry", async () => {
await render(<ExcalidrawApp />, {
@@ -110,4 +112,78 @@ describe("history", () => {
expect.objectContaining({ id: "A", isDeleted: true }),
]);
});
it("undo/redo works properly with groups", async () => {
await render(<ExcalidrawApp />);
const rect1 = API.createElement({ type: "rectangle", groupIds: ["A"] });
const rect2 = API.createElement({ type: "rectangle", groupIds: ["A"] });
h.elements = [rect1, rect2];
mouse.select(rect1);
assertSelectedElements([rect1, rect2]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("d");
});
expect(h.elements.length).toBe(4);
assertSelectedElements([h.elements[2], h.elements[3]]);
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("z");
});
expect(h.elements.length).toBe(4);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress("z");
});
expect(h.elements.length).toBe(4);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
]);
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
// undo again, and duplicate once more
// -------------------------------------------------------------------------
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("z");
Keyboard.keyPress("d");
});
expect(h.elements.length).toBe(6);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
expect.objectContaining({
id: `${rect1.id}_copy_copy`,
isDeleted: false,
}),
expect.objectContaining({
id: `${rect2.id}_copy_copy`,
isDeleted: false,
}),
]),
);
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
});
});
+7 -9
View File
@@ -7,21 +7,19 @@ import * as Renderer from "../renderer/renderScene";
import { setDateTimeForTests } from "../utils";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { fireEvent, render, screen, waitFor } from "./test-utils";
import {
assertSelectedElements,
fireEvent,
render,
screen,
waitFor,
} from "./test-utils";
import { defaultLang } from "../i18n";
const { h } = window;
const renderScene = jest.spyOn(Renderer, "renderScene");
const assertSelectedElements = (...elements: ExcalidrawElement[]) => {
expect(
API.getSelectedElements().map((element) => {
return element.id;
}),
).toEqual(expect.arrayContaining(elements.map((element) => element.id)));
};
const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1);
const finger2 = new Pointer("touch", 2);
+51 -11
View File
@@ -5,6 +5,7 @@ import {
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
assertSelectedElements,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
@@ -12,7 +13,6 @@ import { KEYS } from "../keys";
import { reseed } from "../random";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import { getSelectedElements } from "../scene";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -28,12 +28,6 @@ const { h } = window;
const mouse = new Pointer("mouse");
const assertSelectedIds = (ids: string[]) => {
expect(
getSelectedElements(h.app.getSceneElements(), h.state).map((el) => el.id),
).toEqual(ids);
};
describe("inner box-selection", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
@@ -68,7 +62,7 @@ describe("inner box-selection", () => {
mouse.moveTo(290, 290);
mouse.up();
assertSelectedIds([rect2.id, rect3.id]);
assertSelectedElements([rect2.id, rect3.id]);
});
});
@@ -105,7 +99,7 @@ describe("inner box-selection", () => {
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up();
assertSelectedIds([rect2.id, rect3.id]);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
});
@@ -140,16 +134,62 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.x - 20);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedIds([rect2.id, rect3.id]);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
mouse.moveTo(rect2.x - 10, rect2.y - 10);
assertSelectedIds([rect1.id]);
assertSelectedElements([rect1.id]);
expect(h.state.selectedGroupIds).toEqual({});
mouse.up();
});
});
});
describe("selecting with cmd/ctrl modifier", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("cycling through elements under cursor", async () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 200,
height: 200,
backgroundColor: "red",
fillStyle: "solid",
});
const rect2 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 200,
height: 200,
backgroundColor: "red",
fillStyle: "solid",
});
const rect3 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 200,
height: 200,
backgroundColor: "red",
fillStyle: "solid",
});
h.elements = [rect1, rect2, rect3];
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.clickAt(100, 100);
assertSelectedElements(rect3);
mouse.clickAt(100, 100);
assertSelectedElements(rect2);
mouse.clickAt(100, 100);
assertSelectedElements(rect1);
mouse.clickAt(100, 100);
assertSelectedElements(rect3);
});
});
});
describe("selection element", () => {
it("create selection element on pointer down", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
+21
View File
@@ -13,6 +13,8 @@ import { ImportedDataState } from "../data/types";
import { STORAGE_KEYS } from "../excalidraw-app/data/localStorage";
import { SceneData } from "../types";
import { getSelectedElements } from "../scene/selection";
import { ExcalidrawElement } from "../element/types";
const customQueries = {
...queries,
@@ -124,3 +126,22 @@ export const mockBoundingClientRect = () => {
export const restoreOriginalGetBoundingClientRect = () => {
global.window.HTMLDivElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
};
export const assertSelectedElements = (
...elements: (
| (ExcalidrawElement["id"] | ExcalidrawElement)[]
| ExcalidrawElement["id"]
| ExcalidrawElement
)[]
) => {
const { h } = window;
const selectedElementIds = getSelectedElements(
h.app.getSceneElements(),
h.state,
).map((el) => el.id);
const ids = elements
.flat()
.map((item) => (typeof item === "string" ? item : item.id));
expect(selectedElementIds.length).toBe(ids.length);
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
};
+17 -8
View File
@@ -178,11 +178,6 @@ export interface ExcalidrawProps {
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => void;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
onPaste?: (
data: ClipboardData,
event: ClipboardEvent | null,
@@ -219,14 +214,28 @@ export enum UserIdleState {
IDLE = "idle",
}
export type ExportOpts = {
saveFileToDisk?: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomUI?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => JSX.Element;
};
type CanvasActions = {
changeViewBackgroundColor?: boolean;
clearCanvas?: boolean;
export?: boolean;
export?: false | ExportOpts;
loadScene?: boolean;
saveAsScene?: boolean;
saveToActiveFile?: boolean;
theme?: boolean;
saveAsImage?: boolean;
};
export type UIOptions = {
@@ -235,7 +244,7 @@ export type UIOptions = {
export type AppProps = ExcalidrawProps & {
UIOptions: {
canvasActions: Required<CanvasActions>;
canvasActions: Required<CanvasActions> & { export: ExportOpts };
};
detectScroll: boolean;
handleKeyboardGlobally: boolean;
+7 -5
View File
@@ -4595,8 +4595,9 @@ dns-equal@^1.0.0:
resolved "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz"
dns-packet@^1.3.1:
version "1.3.1"
resolved "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.1.tgz"
version "1.3.4"
resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.4.tgz#e3455065824a2507ba886c55a89963bb107dec6f"
integrity sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==
dependencies:
ip "^1.1.0"
safe-buffer "^5.0.1"
@@ -10899,13 +10900,14 @@ rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.6:
dependencies:
tslib "^1.9.0"
safe-buffer@5.1.2, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
safe-buffer@^5.2.0, safe-buffer@~5.2.0:
safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-regex@^1.1.0:
version "1.1.0"