Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d2296578 | ||
|
|
afb68a6467 | ||
|
|
b459e5cfd2 | ||
|
|
5facc0d6da | ||
|
|
824ad603e1 | ||
|
|
5e1ff7cafe | ||
|
|
b5d7f5b4ba | ||
|
|
fb4bb29aa5 | ||
|
|
3cfcc7b489 | ||
|
|
4320a3cf41 | ||
|
|
8420e1aa13 | ||
|
|
5daf1a1b4e | ||
|
|
97981804d7 | ||
|
|
f7b3befd0a | ||
|
|
7b2bee9746 | ||
|
|
88014ace4a | ||
|
|
87a9430809 | ||
|
|
99b91c46f7 | ||
|
|
1ea5b26f25 | ||
|
|
d5f4ee7b3f | ||
|
|
261304c1a4 | ||
|
|
84398a7e5c | ||
|
|
54491d13d4 | ||
|
|
dd1370381d | ||
|
|
72d6ee48fc | ||
|
|
232242d2e9 | ||
|
|
f19ce30dfe | ||
|
|
3cf14c73a3 | ||
|
|
8d530cf102 | ||
|
|
b87925d253 | ||
|
|
a6684b09f2 | ||
|
|
b427617f66 | ||
|
|
2907fbe34b | ||
|
|
c67815f7b0 |
@@ -8,6 +8,7 @@
|
|||||||
!package.json
|
!package.json
|
||||||
!public/
|
!public/
|
||||||
!packages/
|
!packages/
|
||||||
|
!scripts/
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!yarn.lock
|
!yarn.lock
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ firebase/
|
|||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
packages/excalidraw/types
|
packages/excalidraw/types
|
||||||
|
examples/**/public
|
||||||
|
dev-dist
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
branches: master
|
branches: master
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ ARG NODE_ENV=production
|
|||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.24-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ All `props` are _optional_.
|
|||||||
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
|
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
|
||||||
| [`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`. |
|
| [`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`. |
|
||||||
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
||||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
|
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
|
||||||
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
||||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
|
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
|
||||||
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
||||||
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
|
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
|
||||||
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
|
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
|
||||||
@@ -26,7 +26,7 @@ All `props` are _optional_.
|
|||||||
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
|
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
|
||||||
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
|
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
|
||||||
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
|
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
|
||||||
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
||||||
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
|
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
|
||||||
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
|
|||||||
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
||||||
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
||||||
try {
|
try {
|
||||||
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
|
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
});
|
});
|
||||||
const excalidrawElements = convertToExcalidrawElements(elements);
|
const excalidrawElements = convertToExcalidrawElements(elements);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
|||||||
|
|
||||||
// editor state (canvas config, preferences, ...)
|
// editor state (canvas config, preferences, ...)
|
||||||
"appState": {
|
"appState": {
|
||||||
"gridSize": null,
|
"gridSize": 20,
|
||||||
"viewBackgroundColor": "#ffffff"
|
"viewBackgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@docusaurus/core": "2.2.0",
|
"@docusaurus/core": "2.2.0",
|
||||||
"@docusaurus/preset-classic": "2.2.0",
|
"@docusaurus/preset-classic": "2.2.0",
|
||||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||||
"@excalidraw/excalidraw": "0.17.0",
|
"@excalidraw/excalidraw": "0.17.6",
|
||||||
"@mdx-js/react": "^1.6.22",
|
"@mdx-js/react": "^1.6.22",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"docusaurus-plugin-sass": "0.2.3",
|
"docusaurus-plugin-sass": "0.2.3",
|
||||||
|
|||||||
+4
-4
@@ -1718,10 +1718,10 @@
|
|||||||
url-loader "^4.1.1"
|
url-loader "^4.1.1"
|
||||||
webpack "^5.73.0"
|
webpack "^5.73.0"
|
||||||
|
|
||||||
"@excalidraw/excalidraw@0.17.0":
|
"@excalidraw/excalidraw@0.17.6":
|
||||||
version "0.17.0"
|
version "0.17.6"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
|
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
|
||||||
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
|
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
|
||||||
|
|
||||||
"@hapi/hoek@^9.0.0":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
|
|||||||
+2
-57
@@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
|
|||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialog,
|
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
StoreAction,
|
StoreAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
@@ -121,6 +120,7 @@ import {
|
|||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
|
import { AIComponents } from "./components/AI";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -846,63 +846,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
<TTDDialog
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||||
onTextSubmit={async (input) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${
|
|
||||||
import.meta.env.VITE_APP_AI_BACKEND
|
|
||||||
}/v1/ai/text-to-diagram/generate`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ prompt: input }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
|
||||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rateLimitRemaining = response.headers.has(
|
|
||||||
"X-Ratelimit-Remaining",
|
|
||||||
)
|
|
||||||
? parseInt(
|
|
||||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
|
||||||
10,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 429) {
|
|
||||||
return {
|
|
||||||
rateLimit,
|
|
||||||
rateLimitRemaining,
|
|
||||||
error: new Error(
|
|
||||||
"Too many requests today, please try again tomorrow!",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(json.message || "Generation failed...");
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedResponse = json.generatedResponse;
|
|
||||||
if (!generatedResponse) {
|
|
||||||
throw new Error("Generation failed...");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new Error("Request failed");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="collab-offline-warning">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
|
|||||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||||
import type { UIAppState } from "../packages/excalidraw/types";
|
import type { UIAppState } from "../packages/excalidraw/types";
|
||||||
|
import { Stats } from "../packages/excalidraw";
|
||||||
|
|
||||||
type StorageSizes = { scene: number; total: number };
|
type StorageSizes = { scene: number; total: number };
|
||||||
|
|
||||||
@@ -51,24 +52,9 @@ const CustomStats = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stats.StatsRows order={-1}>
|
||||||
<tr>
|
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
|
||||||
<th colSpan={2}>{t("stats.storage")}</th>
|
<Stats.StatsRow
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.scene")}</td>
|
|
||||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{t("stats.total")}</td>
|
|
||||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2}>{t("stats.version")}</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={2}
|
|
||||||
style={{ textAlign: "center", cursor: "pointer" }}
|
style={{ textAlign: "center", cursor: "pointer" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,9 +67,18 @@ const CustomStats = (props: Props) => {
|
|||||||
{timestamp}
|
{timestamp}
|
||||||
<br />
|
<br />
|
||||||
{hash}
|
{hash}
|
||||||
</td>
|
</Stats.StatsRow>
|
||||||
</tr>
|
|
||||||
</>
|
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
|
||||||
|
<Stats.StatsRow columns={2}>
|
||||||
|
<div>{t("stats.scene")}</div>
|
||||||
|
<div>{nFormatter(storageSizes.scene, 1)}</div>
|
||||||
|
</Stats.StatsRow>
|
||||||
|
<Stats.StatsRow columns={2}>
|
||||||
|
<div>{t("stats.total")}</div>
|
||||||
|
<div>{nFormatter(storageSizes.total, 1)}</div>
|
||||||
|
</Stats.StatsRow>
|
||||||
|
</Stats.StatsRows>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -116,20 +116,26 @@ class Portal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.collab.excalidrawAPI.updateScene({
|
let isChanged = false;
|
||||||
elements: this.collab.excalidrawAPI
|
const newElements = this.collab.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||||
|
isChanged = true;
|
||||||
// this will signal collaborators to pull image data from server
|
// this will signal collaborators to pull image data from server
|
||||||
// (using mutation instead of newElementWith otherwise it'd break
|
// (using mutation instead of newElementWith otherwise it'd break
|
||||||
// in-progress dragging)
|
// in-progress dragging)
|
||||||
return newElementWith(element, { status: "saved" });
|
return newElementWith(element, { status: "saved" });
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
if (isChanged) {
|
||||||
|
this.collab.excalidrawAPI.updateScene({
|
||||||
|
elements: newElements,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { useRef, useState } from "react";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
|
||||||
import { KEYS } from "../../packages/excalidraw/keys";
|
|
||||||
|
|
||||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
|
||||||
import {
|
|
||||||
copyIcon,
|
|
||||||
playerPlayIcon,
|
|
||||||
playerStopFilledIcon,
|
|
||||||
share,
|
|
||||||
shareIOS,
|
|
||||||
shareWindows,
|
|
||||||
tablerCheckIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
|
||||||
|
|
||||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
|
||||||
import "./RoomDialog.scss";
|
|
||||||
|
|
||||||
const getShareIcon = () => {
|
|
||||||
const navigator = window.navigator as any;
|
|
||||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
|
||||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
|
||||||
|
|
||||||
if (isAppleBrowser) {
|
|
||||||
return shareIOS;
|
|
||||||
} else if (isWindowsBrowser) {
|
|
||||||
return shareWindows;
|
|
||||||
}
|
|
||||||
|
|
||||||
return share;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoomModalProps = {
|
|
||||||
handleClose: () => void;
|
|
||||||
activeRoomLink: string;
|
|
||||||
username: string;
|
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomCreate: () => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
setErrorMessage: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RoomModal = ({
|
|
||||||
activeRoomLink,
|
|
||||||
onRoomCreate,
|
|
||||||
onRoomDestroy,
|
|
||||||
setErrorMessage,
|
|
||||||
username,
|
|
||||||
onUsernameChange,
|
|
||||||
handleClose,
|
|
||||||
}: RoomModalProps) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
|
||||||
const timerRef = useRef<number>(0);
|
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
|
||||||
const isShareSupported = "share" in navigator;
|
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
|
||||||
try {
|
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
|
||||||
} catch (e) {
|
|
||||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
|
||||||
}
|
|
||||||
setJustCopied(true);
|
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
ref.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareRoomLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.share({
|
|
||||||
title: t("roomDialog.shareTitle"),
|
|
||||||
text: t("roomDialog.shareTitle"),
|
|
||||||
url: activeRoomLink,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// Just ignore.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeRoomLink) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 className="RoomDialog__active__header">
|
|
||||||
{t("labels.liveCollaboration")}
|
|
||||||
</h3>
|
|
||||||
<TextField
|
|
||||||
value={username}
|
|
||||||
placeholder="Your name"
|
|
||||||
label="Your name"
|
|
||||||
onChange={onUsernameChange}
|
|
||||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
|
||||||
/>
|
|
||||||
<div className="RoomDialog__active__linkRow">
|
|
||||||
<TextField
|
|
||||||
ref={ref}
|
|
||||||
label="Link"
|
|
||||||
readonly
|
|
||||||
fullWidth
|
|
||||||
value={activeRoomLink}
|
|
||||||
/>
|
|
||||||
{isShareSupported && (
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
variant="icon"
|
|
||||||
label="Share"
|
|
||||||
icon={getShareIcon()}
|
|
||||||
className="RoomDialog__active__share"
|
|
||||||
onClick={shareRoomLink}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Popover.Root open={justCopied}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
label="Copy link"
|
|
||||||
icon={copyIcon}
|
|
||||||
onClick={copyRoomLink}
|
|
||||||
/>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
className="RoomDialog__popover"
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={5.5}
|
|
||||||
>
|
|
||||||
{tablerCheckIcon} copied
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
|
||||||
<div className="RoomDialog__active__description">
|
|
||||||
<p>
|
|
||||||
<span
|
|
||||||
role="img"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="RoomDialog__active__description__emoji"
|
|
||||||
>
|
|
||||||
🔒{" "}
|
|
||||||
</span>
|
|
||||||
{t("roomDialog.desc_privacy")}
|
|
||||||
</p>
|
|
||||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__active__actions">
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
variant="outlined"
|
|
||||||
color="danger"
|
|
||||||
label={t("roomDialog.button_stopSession")}
|
|
||||||
icon={playerStopFilledIcon}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("share", "room closed");
|
|
||||||
onRoomDestroy();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="RoomDialog__inactive__illustration">
|
|
||||||
<CollabImage />
|
|
||||||
</div>
|
|
||||||
<div className="RoomDialog__inactive__header">
|
|
||||||
{t("labels.liveCollaboration")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__inactive__description">
|
|
||||||
<strong>{t("roomDialog.desc_intro")}</strong>
|
|
||||||
{t("roomDialog.desc_privacy")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="RoomDialog__inactive__start_session">
|
|
||||||
<FilledButton
|
|
||||||
size="large"
|
|
||||||
label={t("roomDialog.button_startSession")}
|
|
||||||
icon={playerPlayIcon}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
onRoomCreate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RoomDialog = (props: RoomModalProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
|
||||||
<div className="RoomDialog">
|
|
||||||
<RoomModal {...props} />
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomDialog;
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||||
|
import {
|
||||||
|
DiagramToCodePlugin,
|
||||||
|
exportToBlob,
|
||||||
|
getTextFromElements,
|
||||||
|
MIME_TYPES,
|
||||||
|
TTDDialog,
|
||||||
|
} from "../../packages/excalidraw";
|
||||||
|
import { getDataURL } from "../../packages/excalidraw/data/blob";
|
||||||
|
import { safelyParseJSON } from "../../packages/excalidraw/utils";
|
||||||
|
|
||||||
|
export const AIComponents = ({
|
||||||
|
excalidrawAPI,
|
||||||
|
}: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DiagramToCodePlugin
|
||||||
|
generate={async ({ frame, children }) => {
|
||||||
|
const appState = excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
const blob = await exportToBlob({
|
||||||
|
elements: children,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
},
|
||||||
|
exportingFrame: frame,
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
mimeType: MIME_TYPES.jpg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataURL = await getDataURL(blob);
|
||||||
|
|
||||||
|
const textFromFrameChildren = getTextFromElements(children);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/diagram-to-code/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
texts: textFromFrameChildren,
|
||||||
|
image: dataURL,
|
||||||
|
theme: appState.theme,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
const errorJSON = safelyParseJSON(text);
|
||||||
|
|
||||||
|
if (!errorJSON) {
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorJSON.statusCode === 429) {
|
||||||
|
return {
|
||||||
|
html: `<html>
|
||||||
|
<body style="margin: 0; text-align: center">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
|
||||||
|
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
|
||||||
|
</br>
|
||||||
|
</br>
|
||||||
|
<div>You can also try <a href="${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorJSON.message || text);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { html } = await response.json();
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
throw new Error("Generation failed (invalid response)");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error("Generation failed (invalid response)");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TTDDialog
|
||||||
|
onTextSubmit={async (input) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/text-to-diagram/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ prompt: input }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||||
|
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rateLimitRemaining = response.headers.has(
|
||||||
|
"X-Ratelimit-Remaining",
|
||||||
|
)
|
||||||
|
? parseInt(
|
||||||
|
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitRemaining,
|
||||||
|
error: new Error(
|
||||||
|
"Too many requests today, please try again tomorrow!",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(json.message || "Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedResponse = json.generatedResponse;
|
||||||
|
if (!generatedResponse) {
|
||||||
|
throw new Error("Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error("Request failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 110%;
|
line-height: 110%;
|
||||||
|
|
||||||
background: var(--color-success-lighter);
|
background: var(--color-success);
|
||||||
color: var(--color-success);
|
color: var(--color-success-text);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
share,
|
share,
|
||||||
shareIOS,
|
shareIOS,
|
||||||
shareWindows,
|
shareWindows,
|
||||||
tablerCheckIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
|
|||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
|
|||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [, setJustCopied] = useState(false);
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const isShareSupported = "share" in navigator;
|
const isShareSupported = "share" in navigator;
|
||||||
|
const { onCopy, copyStatus } = useCopyStatus();
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
|
|||||||
onClick={shareRoomLink}
|
onClick={shareRoomLink}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Popover.Root open={justCopied}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label="Copy link"
|
label={t("buttons.copyLink")}
|
||||||
icon={copyIcon}
|
icon={copyIcon}
|
||||||
onClick={copyRoomLink}
|
status={copyStatus}
|
||||||
|
onClick={() => {
|
||||||
|
copyRoomLink();
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
className="ShareDialog__popover"
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={5.5}
|
|
||||||
>
|
|
||||||
{tablerCheckIcon} copied
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ShareDialog__active__description">
|
<div className="ShareDialog__active__description">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
render,
|
render,
|
||||||
updateSceneData,
|
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "../../packages/excalidraw/tests/test-utils";
|
} from "../../packages/excalidraw/tests/test-utils";
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
@@ -88,12 +87,12 @@ describe("collaboration", () => {
|
|||||||
const rect1 = API.createElement({ ...rect1Props });
|
const rect1 = API.createElement({ ...rect1Props });
|
||||||
const rect2 = API.createElement({ ...rect2Props });
|
const rect2 = API.createElement({ ...rect2Props });
|
||||||
|
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1, rect2]),
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
rect1,
|
rect1,
|
||||||
newElementWith(h.elements[1], { isDeleted: true }),
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
@@ -143,7 +142,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
@@ -178,7 +177,7 @@ describe("collaboration", () => {
|
|||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// simulate local update
|
// simulate local update
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
@@ -216,7 +215,7 @@ describe("collaboration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export default defineConfig({
|
|||||||
assetFileNames(chunkInfo) {
|
assetFileNames(chunkInfo) {
|
||||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
// put on root so we are flexible about the CDN path
|
// put on root so we are flexible about the CDN path
|
||||||
return '[name]-[hash][extname]';
|
return "[name]-[hash][extname]";
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'assets/[name]-[hash][extname]';
|
return "assets/[name]-[hash][extname]";
|
||||||
},
|
},
|
||||||
// Creating separate chunk for locales except for en and percentages.json so they
|
// Creating separate chunk for locales except for en and percentages.json so they
|
||||||
// can be cached at runtime and not merged with
|
// can be cached at runtime and not merged with
|
||||||
@@ -44,7 +44,7 @@ export default defineConfig({
|
|||||||
// Taking the substring after "locales/"
|
// Taking the substring after "locales/"
|
||||||
return `locales/${id.substring(index + 8)}`;
|
return `locales/${id.substring(index + 8)}`;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
+4
-1
@@ -76,7 +76,10 @@
|
|||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"build:preview": "yarn build && vite preview --port 5000",
|
"build:preview": "yarn build && vite preview --port 5000",
|
||||||
"release:excalidraw": "node scripts/release.js"
|
"release:excalidraw": "node scripts/release.js",
|
||||||
|
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
|
||||||
|
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||||
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.2.0"
|
"@types/react": "18.2.0"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
|
||||||
|
|
||||||
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||||
|
|
||||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import type { AppState, NormalizedZoomValue } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
|||||||
import type { SceneBounds } from "../element/bounds";
|
import type { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { clamp } from "../math";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -104,6 +105,8 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
|
gridStep: appState.gridStep,
|
||||||
|
gridModeEnabled: appState.gridModeEnabled,
|
||||||
stats: appState.stats,
|
stats: appState.stats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
|
|||||||
const zoomValueToFitBoundsOnViewport = (
|
const zoomValueToFitBoundsOnViewport = (
|
||||||
bounds: SceneBounds,
|
bounds: SceneBounds,
|
||||||
viewportDimensions: { width: number; height: number },
|
viewportDimensions: { width: number; height: number },
|
||||||
|
viewportZoomFactor: number = 1, // default to 1 if not provided
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
const commonBoundsWidth = x2 - x1;
|
const commonBoundsWidth = x2 - x1;
|
||||||
@@ -251,20 +255,21 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
const commonBoundsHeight = y2 - y1;
|
const commonBoundsHeight = y2 - y1;
|
||||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||||
|
|
||||||
|
const adjustedZoomValue =
|
||||||
|
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||||
|
|
||||||
const zoomAdjustedToSteps =
|
const zoomAdjustedToSteps =
|
||||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||||
const clampedZoomValueToFitElements = Math.min(
|
|
||||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
|
||||||
1,
|
|
||||||
);
|
|
||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFitBounds = ({
|
export const zoomToFitBounds = ({
|
||||||
bounds,
|
bounds,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport = false,
|
fitToViewport = false,
|
||||||
viewportZoomFactor = 0.7,
|
viewportZoomFactor = 1,
|
||||||
}: {
|
}: {
|
||||||
bounds: SceneBounds;
|
bounds: SceneBounds;
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
@@ -289,13 +294,9 @@ export const zoomToFitBounds = ({
|
|||||||
Math.min(
|
Math.min(
|
||||||
appState.width / commonBoundsWidth,
|
appState.width / commonBoundsWidth,
|
||||||
appState.height / commonBoundsHeight,
|
appState.height / commonBoundsHeight,
|
||||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
) * clamp(viewportZoomFactor, 0.1, 1);
|
||||||
|
|
||||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
newZoomValue = getNormalizedZoom(newZoomValue);
|
||||||
newZoomValue = Math.min(
|
|
||||||
Math.max(newZoomValue, MIN_ZOOM),
|
|
||||||
MAX_ZOOM,
|
|
||||||
) as NormalizedZoomValue;
|
|
||||||
|
|
||||||
let appStateWidth = appState.width;
|
let appStateWidth = appState.width;
|
||||||
|
|
||||||
@@ -314,10 +315,14 @@ export const zoomToFitBounds = ({
|
|||||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||||
} else {
|
} else {
|
||||||
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
newZoomValue = zoomValueToFitBoundsOnViewport(
|
||||||
|
bounds,
|
||||||
|
{
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
});
|
},
|
||||||
|
viewportZoomFactor,
|
||||||
|
);
|
||||||
|
|
||||||
const centerScroll = centerScrollOn({
|
const centerScroll = centerScrollOn({
|
||||||
scenePoint: { x: centerX, y: centerY },
|
scenePoint: { x: centerX, y: centerY },
|
||||||
@@ -408,6 +413,7 @@ export const actionZoomToFitSelection = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: true,
|
fitToViewport: true,
|
||||||
|
viewportZoomFactor: 0.7,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE this action should use shift-2 per figma, alas
|
// NOTE this action should use shift-2 per figma, alas
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { isTextElement } from "../element";
|
import { getTextFromElements, isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
@@ -239,16 +239,8 @@ export const copyText = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = selectedElements
|
|
||||||
.reduce((acc: string[], element) => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
acc.push(element.text);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [])
|
|
||||||
.join("\n\n");
|
|
||||||
try {
|
try {
|
||||||
copyTextToSystemClipboard(text);
|
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const deleteSelectedElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const framesToBeDeleted = new Set(
|
const framesToBeDeleted = new Set(
|
||||||
getSelectedElements(
|
getSelectedElements(
|
||||||
elements.filter((el) => isFrameLikeElement(el)),
|
elements.filter((el) => isFrameLikeElement(el)),
|
||||||
@@ -51,7 +52,7 @@ const deleteSelectedElements = (
|
|||||||
? null
|
? null
|
||||||
: bound.endBinding,
|
: bound.endBinding,
|
||||||
});
|
});
|
||||||
mutateElbowArrow(bound, app.scene, bound.points);
|
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,7 @@ export const actionDeleteSelected = register({
|
|||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(
|
||||||
element,
|
element,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
app.scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import type { ActionResult } from "./types";
|
import type { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
@@ -44,7 +44,7 @@ export const actionDuplicateSelection = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
appState,
|
appState,
|
||||||
app.scene,
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
@@ -99,8 +99,8 @@ const duplicateElements = (
|
|||||||
groupIdMap,
|
groupIdMap,
|
||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
x: element.x + GRID_SIZE / 2,
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
y: element.y + GRID_SIZE / 2,
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { queryByTestId, fireEvent } from "@testing-library/react";
|
import { queryByTestId, fireEvent } from "@testing-library/react";
|
||||||
import { render } from "../tests/test-utils";
|
import { render } from "../tests/test-utils";
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
selectedLinearElement: null,
|
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
@@ -73,8 +72,8 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
const multiPointElement = appState.multiElement
|
||||||
? appState.multiElement
|
? appState.multiElement
|
||||||
: appState.editingElement?.type === "freedraw"
|
: appState.newElement?.type === "freedraw"
|
||||||
? appState.editingElement
|
? appState.newElement
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (multiPointElement) {
|
||||||
@@ -176,9 +175,10 @@ export const actionFinalize = register({
|
|||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: activeTool,
|
: activeTool,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
draggingElement: null,
|
newElement: null,
|
||||||
|
selectionElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingTextElement: null,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
@@ -204,7 +204,7 @@ export const actionFinalize = register({
|
|||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.editingLinearElement !== null ||
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.newElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData, data }) => (
|
PanelComponent: ({ appState, updateData, data }) => (
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ const flipElements = (
|
|||||||
true,
|
true,
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
flipDirection === "horizontal" ? maxX : minX,
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
app.scene,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import type { AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
@@ -13,15 +13,19 @@ import type { Store } from "../store";
|
|||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const executeHistoryAction = (
|
||||||
|
app: AppClassProperties,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
updater: () => [SceneElementsMap, AppState] | void,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
if (
|
if (
|
||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.draggingElement
|
!appState.newElement &&
|
||||||
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
|
!appState.selectionElement &&
|
||||||
|
!app.flowChartCreator.isCreatingChart
|
||||||
) {
|
) {
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
@@ -51,12 +55,11 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
store.snapshot,
|
store.snapshot,
|
||||||
app.scene,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -93,12 +96,11 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, _, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
store.snapshot,
|
store.snapshot,
|
||||||
app.scene,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { queryByTestId } from "@testing-library/react";
|
import { queryByTestId } from "@testing-library/react";
|
||||||
import { render } from "../tests/test-utils";
|
import { render } from "../tests/test-utils";
|
||||||
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
|
|||||||
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
||||||
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("element locking", () => {
|
describe("element locking", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
@@ -22,7 +21,7 @@ describe("element locking", () => {
|
|||||||
// just in case we change it in the future
|
// just in case we change it in the future
|
||||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
});
|
});
|
||||||
const activeColor = queryByTestId(
|
const activeColor = queryByTestId(
|
||||||
@@ -40,14 +39,14 @@ describe("element locking", () => {
|
|||||||
// just in case we change it in the future
|
// just in case we change it in the future
|
||||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: color,
|
currentItemBackgroundColor: color,
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
});
|
});
|
||||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||||
|
|
||||||
expect(hachureFillButton).toHaveClass("active");
|
expect(hachureFillButton).toHaveClass("active");
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemFillStyle: "solid",
|
currentItemFillStyle: "solid",
|
||||||
});
|
});
|
||||||
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
||||||
@@ -57,7 +56,7 @@ describe("element locking", () => {
|
|||||||
it("should not show fill style when background transparent", () => {
|
it("should not show fill style when background transparent", () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
});
|
});
|
||||||
@@ -69,7 +68,7 @@ describe("element locking", () => {
|
|||||||
it("should show horizontal text align for text tool", () => {
|
it("should show horizontal text align for text tool", () => {
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
currentItemTextAlign: "right",
|
currentItemTextAlign: "right",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: "red",
|
backgroundColor: "red",
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
h.elements = [rect];
|
API.setElements([rect]);
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -98,7 +97,7 @@ describe("element locking", () => {
|
|||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
fillStyle: "cross-hatch",
|
fillStyle: "cross-hatch",
|
||||||
});
|
});
|
||||||
h.elements = [rect];
|
API.setElements([rect]);
|
||||||
API.setSelectedElements([rect]);
|
API.setSelectedElements([rect]);
|
||||||
|
|
||||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||||
@@ -114,7 +113,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.thin,
|
strokeWidth: STROKE_WIDTH.thin,
|
||||||
});
|
});
|
||||||
h.elements = [rect1, rect2];
|
API.setElements([rect1, rect2]);
|
||||||
API.setSelectedElements([rect1, rect2]);
|
API.setSelectedElements([rect1, rect2]);
|
||||||
|
|
||||||
const thinStrokeWidthButton = queryByTestId(
|
const thinStrokeWidthButton = queryByTestId(
|
||||||
@@ -133,7 +132,7 @@ describe("element locking", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.bold,
|
strokeWidth: STROKE_WIDTH.bold,
|
||||||
});
|
});
|
||||||
h.elements = [rect1, rect2];
|
API.setElements([rect1, rect2]);
|
||||||
API.setSelectedElements([rect1, rect2]);
|
API.setSelectedElements([rect1, rect2]);
|
||||||
|
|
||||||
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
||||||
@@ -157,7 +156,7 @@ describe("element locking", () => {
|
|||||||
type: "text",
|
type: "text",
|
||||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||||
});
|
});
|
||||||
h.elements = [rect, text];
|
API.setElements([rect, text]);
|
||||||
API.setSelectedElements([rect, text]);
|
API.setSelectedElements([rect, text]);
|
||||||
|
|
||||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const changeProperty = (
|
|||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
selectedElementIds.get(element.id) ||
|
selectedElementIds.get(element.id) ||
|
||||||
element.id === appState.editingElement?.id
|
element.id === appState.editingTextElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
}
|
}
|
||||||
@@ -148,13 +148,13 @@ export const getFormValue = function <T extends Primitive>(
|
|||||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||||
): T {
|
): T {
|
||||||
const editingElement = appState.editingElement;
|
const editingTextElement = appState.editingTextElement;
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
|
||||||
let ret: T | null = null;
|
let ret: T | null = null;
|
||||||
|
|
||||||
if (editingElement) {
|
if (editingTextElement) {
|
||||||
ret = getAttribute(editingElement);
|
ret = getAttribute(editingTextElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
@@ -1076,19 +1076,20 @@ export const actionChangeFontFamily = register({
|
|||||||
// open, populate the cache from scratch
|
// open, populate the cache from scratch
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
const { editingElement } = appState;
|
const { editingTextElement } = appState;
|
||||||
|
|
||||||
if (editingElement?.type === "text") {
|
// still check type to be safe
|
||||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
if (editingTextElement?.type === "text") {
|
||||||
const latestEditingElement = app.scene.getElement(
|
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated
|
||||||
editingElement.id,
|
const latesteditingTextElement = app.scene.getElement(
|
||||||
|
editingTextElement.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// inside the wysiwyg editor
|
// inside the wysiwyg editor
|
||||||
cachedElementsRef.current.set(
|
cachedElementsRef.current.set(
|
||||||
editingElement.id,
|
editingTextElement.id,
|
||||||
newElementWith(
|
newElementWith(
|
||||||
latestEditingElement || editingElement,
|
latesteditingTextElement || editingTextElement,
|
||||||
{},
|
{},
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
@@ -1648,7 +1649,7 @@ export const actionChangeArrowType = register({
|
|||||||
|
|
||||||
mutateElbowArrow(
|
mutateElbowArrow(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene,
|
elementsMap,
|
||||||
[finalStartPoint, finalEndPoint].map(
|
[finalStartPoint, finalEndPoint].map(
|
||||||
(point) =>
|
(point) =>
|
||||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => !appState.gridSize,
|
predicate: (appState) => appState.gridModeEnabled,
|
||||||
},
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
gridModeEnabled: !this.checked!(appState),
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridSize !== null,
|
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return typeof props.gridModeEnabled === "undefined";
|
return props.gridModeEnabled === undefined;
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridSize: null,
|
gridModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
DEFAULT_GRID_SIZE,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
STATS_PANELS,
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
|
DEFAULT_GRID_STEP,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
|
|
||||||
@@ -41,8 +43,8 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
draggingElement: null,
|
newElement: null,
|
||||||
editingElement: null,
|
editingTextElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
@@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
|
|||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: DEFAULT_GRID_SIZE,
|
||||||
|
gridStep: DEFAULT_GRID_STEP,
|
||||||
|
gridModeEnabled: false,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
defaultSidebarDockedPreference: false,
|
defaultSidebarDockedPreference: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -160,8 +164,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
@@ -174,6 +178,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false, server: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true, server: true },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
|
gridStep: { browser: true, export: true, server: true },
|
||||||
|
gridModeEnabled: { browser: true, export: true, server: true },
|
||||||
height: { browser: false, export: false, server: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
defaultSidebarDockedPreference: {
|
defaultSidebarDockedPreference: {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import type {
|
|||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
import { getNonDeletedGroupIds } from "./groups";
|
import { getNonDeletedGroupIds } from "./groups";
|
||||||
import type Scene from "./scene/Scene";
|
|
||||||
import { getObservedAppState } from "./store";
|
import { getObservedAppState } from "./store";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -1054,7 +1053,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
scene: Scene,
|
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@@ -1102,7 +1100,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
try {
|
try {
|
||||||
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||||
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||||
ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
|
|
||||||
|
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
@@ -1111,6 +1108,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
|
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1459,11 +1459,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
private static redrawBoundArrows(
|
private static redrawBoundArrows(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
scene: Scene,
|
|
||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
if (!element.isDeleted && isBindableElement(element)) {
|
||||||
updateBoundElements(element, elements, scene, {
|
updateBoundElements(element, elements, {
|
||||||
changedElements: changed,
|
changedElements: changed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
frameToolIcon,
|
frameToolIcon,
|
||||||
mermaidLogoIcon,
|
mermaidLogoIcon,
|
||||||
laserPointerToolIcon,
|
laserPointerToolIcon,
|
||||||
OpenAIIcon,
|
|
||||||
MagicIcon,
|
MagicIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -104,7 +103,9 @@ export const SelectedShapeActions = ({
|
|||||||
) {
|
) {
|
||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditingTextOrNewElement = Boolean(
|
||||||
|
appState.editingTextElement || appState.newElement,
|
||||||
|
);
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
@@ -234,7 +235,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isEditing && targetElements.length > 0 && (
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -400,7 +401,7 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.mermaidToExcalidraw")}
|
{t("toolBar.mermaidToExcalidraw")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{app.props.aiEnabled !== false && (
|
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.onMagicframeToolSelect()}
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
@@ -410,20 +411,6 @@ export const ShapesSwitcher = ({
|
|||||||
{t("toolBar.magicframe")}
|
{t("toolBar.magicframe")}
|
||||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => {
|
|
||||||
trackEvent("ai", "open-settings", "d2c");
|
|
||||||
app.setOpenDialog({
|
|
||||||
name: "settings",
|
|
||||||
source: "settings",
|
|
||||||
tab: "diagram-to-code",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
icon={OpenAIIcon}
|
|
||||||
data-testid="toolbar-magicSettings"
|
|
||||||
>
|
|
||||||
{t("toolBar.magicSettings")}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertExcalidrawWithSidebar,
|
assertExcalidrawWithSidebar,
|
||||||
assertSidebarDockButton,
|
assertSidebarDockButton,
|
||||||
} from "./Sidebar/Sidebar.test";
|
} from "./Sidebar/siderbar.test.helpers";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { useApp } from "../App";
|
||||||
|
import type { GenerateDiagramToCode } from "../../types";
|
||||||
|
|
||||||
|
export const DiagramToCodePlugin = (props: {
|
||||||
|
generate: GenerateDiagramToCode;
|
||||||
|
}) => {
|
||||||
|
const app = useApp();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
app.setPlugins({
|
||||||
|
diagramToCode: { generate: props.generate },
|
||||||
|
});
|
||||||
|
}, [app, props.generate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -16,11 +16,19 @@
|
|||||||
|
|
||||||
.Spinner {
|
.Spinner {
|
||||||
--spinner-color: var(--color-surface-lowest);
|
--spinner-color: var(--color-surface-lowest);
|
||||||
position: absolute;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[disabled] {
|
.ExcButton__statusIcon {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ExcButton--status-loading,
|
||||||
|
&.ExcButton--status-success {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.ExcButton__contents {
|
.ExcButton__contents {
|
||||||
@@ -28,6 +36,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
&,
|
&,
|
||||||
&__contents {
|
&__contents {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -119,6 +131,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--color-success {
|
||||||
|
&.ExcButton--variant-filled {
|
||||||
|
--text-color: var(--color-success-text);
|
||||||
|
--back-color: var(--color-success);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--back-color: var(--color-success-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--back-color: var(--color-success-darkest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ExcButton--variant-outlined,
|
||||||
|
&.ExcButton--variant-icon {
|
||||||
|
--text-color: var(--color-success-contrast);
|
||||||
|
--border-color: var(--color-success-contrast);
|
||||||
|
--back-color: transparent;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--color-success-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--text-color: var(--color-success-contrast-hover);
|
||||||
|
--border-color: var(--color-success-contrast-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--text-color: var(--color-success-contrast-active);
|
||||||
|
--border-color: var(--color-success-contrast-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--color-muted {
|
&--color-muted {
|
||||||
&.ExcButton--variant-filled {
|
&.ExcButton--variant-filled {
|
||||||
--text-color: var(--island-bg-color);
|
--text-color: var(--island-bg-color);
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import "./FilledButton.scss";
|
|||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { isPromiseLike } from "../utils";
|
import { isPromiseLike } from "../utils";
|
||||||
|
import { tablerCheckIcon } from "./icons";
|
||||||
|
|
||||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
export type ButtonColor =
|
||||||
|
| "primary"
|
||||||
|
| "danger"
|
||||||
|
| "warning"
|
||||||
|
| "muted"
|
||||||
|
| "success";
|
||||||
export type ButtonSize = "medium" | "large";
|
export type ButtonSize = "medium" | "large";
|
||||||
|
|
||||||
export type FilledButtonProps = {
|
export type FilledButtonProps = {
|
||||||
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
|
|||||||
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: (event: React.MouseEvent) => void;
|
onClick?: (event: React.MouseEvent) => void;
|
||||||
|
status?: null | "loading" | "success";
|
||||||
|
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
size = "medium",
|
size = "medium",
|
||||||
fullWidth,
|
fullWidth,
|
||||||
className,
|
className,
|
||||||
|
status,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
const ret = onClick?.(event);
|
const ret = onClick?.(event);
|
||||||
|
|
||||||
if (isPromiseLike(ret)) {
|
if (isPromiseLike(ret)) {
|
||||||
try {
|
// delay loading state to prevent flicker in case of quick response
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
}, 50);
|
||||||
|
try {
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _status = isLoading ? "loading" : status;
|
||||||
|
color = _status === "success" ? "success" : color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
`ExcButton--color-${color}`,
|
`ExcButton--color-${color}`,
|
||||||
`ExcButton--variant-${variant}`,
|
`ExcButton--variant-${variant}`,
|
||||||
`ExcButton--size-${size}`,
|
`ExcButton--size-${size}`,
|
||||||
|
`ExcButton--status-${_status}`,
|
||||||
{ "ExcButton--fullWidth": fullWidth },
|
{ "ExcButton--fullWidth": fullWidth },
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={isLoading}
|
disabled={_status === "loading" || _status === "success"}
|
||||||
>
|
>
|
||||||
<div className="ExcButton__contents">
|
<div className="ExcButton__contents">
|
||||||
{isLoading && <Spinner />}
|
{_status === "loading" ? (
|
||||||
|
<Spinner className="ExcButton__statusIcon" />
|
||||||
|
) : (
|
||||||
|
_status === "success" && (
|
||||||
|
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className="ExcButton__icon" aria-hidden>
|
<div className="ExcButton__icon" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
className="HelpDialog__island--editor"
|
className="HelpDialog__island--editor"
|
||||||
caption={t("helpDialog.editor")}
|
caption={t("helpDialog.editor")}
|
||||||
>
|
>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.createFlowchart")}
|
||||||
|
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
||||||
|
isOr={true}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.navigateFlowchart")}
|
||||||
|
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
||||||
|
isOr={true}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||||
import {
|
import {
|
||||||
|
isFlowchartNodeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
@@ -10,6 +11,8 @@ import { getShortcutKey } from "../utils";
|
|||||||
import { isEraserActive } from "../appState";
|
import { isEraserActive } from "../appState";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
|
import { isNodeInFlowchart } from "../element/flowchart";
|
||||||
|
import { isGridModeEnabled } from "../snapping";
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface HintViewerProps {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
@@ -18,7 +21,12 @@ interface HintViewerProps {
|
|||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
const getHints = ({
|
||||||
|
appState,
|
||||||
|
isMobile,
|
||||||
|
device,
|
||||||
|
app,
|
||||||
|
}: HintViewerProps): null | string | string[] => {
|
||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
@@ -79,21 +87,21 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
if (appState.editingTextElement) {
|
||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "selection") {
|
if (activeTool.type === "selection") {
|
||||||
if (
|
if (
|
||||||
appState.draggingElement?.type === "selection" &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.editingLinearElement
|
!appState.editingLinearElement
|
||||||
) {
|
) {
|
||||||
return t("hints.deepBoxSelect");
|
return t("hints.deepBoxSelect");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.gridSize && appState.draggingElement) {
|
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||||
return t("hints.disableSnapping");
|
return t("hints.disableSnapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +119,23 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
return t("hints.lineEditor_info");
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!appState.draggingElement &&
|
!appState.newElement &&
|
||||||
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
isTextBindableContainer(selectedElements[0])
|
isTextBindableContainer(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
|
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||||
|
if (
|
||||||
|
isNodeInFlowchart(
|
||||||
|
selectedElements[0],
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||||
|
}
|
||||||
|
|
||||||
return t("hints.bindTextToElement");
|
return t("hints.bindTextToElement");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,17 +150,24 @@ export const HintViewer = ({
|
|||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
}: HintViewerProps) => {
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
const hints = getHints({
|
||||||
appState,
|
appState,
|
||||||
isMobile,
|
isMobile,
|
||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
|
||||||
|
if (!hints) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
hint = getShortcutKey(hint);
|
const hint = Array.isArray(hints)
|
||||||
|
? hints
|
||||||
|
.map((hint) => {
|
||||||
|
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||||
|
})
|
||||||
|
.join(". ")
|
||||||
|
: getShortcutKey(hints);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
|
|||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
import { cloneJSON } from "../utils";
|
import { cloneJSON } from "../utils";
|
||||||
import { prepareElementsForExport } from "../data";
|
import { prepareElementsForExport } from "../data";
|
||||||
|
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -89,6 +90,8 @@ const ImageExportModal = ({
|
|||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { onCopy, copyStatus } = useCopyStatus();
|
||||||
|
|
||||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
elementsSnapshot,
|
elementsSnapshot,
|
||||||
appStateSnapshot,
|
appStateSnapshot,
|
||||||
@@ -294,11 +297,17 @@ const ImageExportModal = ({
|
|||||||
<FilledButton
|
<FilledButton
|
||||||
className="ImageExportModal__settings__buttons__button"
|
className="ImageExportModal__settings__buttons__button"
|
||||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||||
onClick={() =>
|
status={copyStatus}
|
||||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
onClick={async () => {
|
||||||
|
await onExportImage(
|
||||||
|
EXPORT_IMAGE_TYPES.clipboard,
|
||||||
|
exportedElements,
|
||||||
|
{
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
})
|
},
|
||||||
}
|
);
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
icon={copyIcon}
|
icon={copyIcon}
|
||||||
>
|
>
|
||||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||||
|
|||||||
@@ -27,99 +27,6 @@
|
|||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .Stats {
|
|
||||||
width: 204px;
|
|
||||||
position: absolute;
|
|
||||||
top: 60px;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: var(--zIndex-layerUI);
|
|
||||||
pointer-events: var(--ui-pointerEvents);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elementType {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elementsCount {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statsItem {
|
|
||||||
margin-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid var(--input-border-color);
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
td:nth-child(2) {
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--default-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 12px;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ import { mutateElement } from "../element/mutateElement";
|
|||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { LaserPointerButton } from "./LaserPointerButton";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
import { MagicSettings } from "./MagicSettings";
|
|
||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions";
|
import { actionToggleStats } from "../actions";
|
||||||
@@ -85,14 +84,6 @@ interface LayerUIProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
openAIKey: string | null;
|
|
||||||
isOpenAIKeyPersisted: boolean;
|
|
||||||
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
|
||||||
onMagicSettingsConfirm: (
|
|
||||||
apiKey: string,
|
|
||||||
shouldPersist: boolean,
|
|
||||||
source: "tool" | "generation" | "settings",
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMainMenu: React.FC<{
|
const DefaultMainMenu: React.FC<{
|
||||||
@@ -149,10 +140,6 @@ const LayerUI = ({
|
|||||||
children,
|
children,
|
||||||
app,
|
app,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
openAIKey,
|
|
||||||
isOpenAIKeyPersisted,
|
|
||||||
onOpenAIAPIKeyChange,
|
|
||||||
onMagicSettingsConfirm,
|
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
@@ -360,7 +347,7 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{shouldShowStats && (
|
{shouldShowStats && (
|
||||||
<Stats
|
<Stats
|
||||||
scene={app.scene}
|
app={app}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
actionManager.executeAction(actionToggleStats);
|
actionManager.executeAction(actionToggleStats);
|
||||||
}}
|
}}
|
||||||
@@ -482,25 +469,6 @@ const LayerUI = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.openDialog?.name === "settings" && (
|
|
||||||
<MagicSettings
|
|
||||||
openAIKey={openAIKey}
|
|
||||||
isPersisted={isOpenAIKeyPersisted}
|
|
||||||
onChange={onOpenAIAPIKeyChange}
|
|
||||||
onConfirm={(apiKey, shouldPersist) => {
|
|
||||||
const source =
|
|
||||||
appState.openDialog?.name === "settings"
|
|
||||||
? appState.openDialog?.source
|
|
||||||
: "settings";
|
|
||||||
setAppState({ openDialog: null }, () => {
|
|
||||||
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onClose={() => {
|
|
||||||
setAppState({ openDialog: null });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ActiveConfirmDialog />
|
<ActiveConfirmDialog />
|
||||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||||
{renderImageExportDialog()}
|
{renderImageExportDialog()}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
.excalidraw {
|
|
||||||
.MagicSettings {
|
|
||||||
.Island {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.MagicSettings-confirm {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MagicSettings__confirm {
|
|
||||||
margin-top: 2rem;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { TextField } from "./TextField";
|
|
||||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
|
||||||
import { FilledButton } from "./FilledButton";
|
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
|
||||||
import { InlineIcon } from "./InlineIcon";
|
|
||||||
import { Paragraph } from "./Paragraph";
|
|
||||||
|
|
||||||
import "./MagicSettings.scss";
|
|
||||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
|
||||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
|
||||||
|
|
||||||
export const MagicSettings = (props: {
|
|
||||||
openAIKey: string | null;
|
|
||||||
isPersisted: boolean;
|
|
||||||
onChange: (key: string, shouldPersist: boolean) => void;
|
|
||||||
onConfirm: (key: string, shouldPersist: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) => {
|
|
||||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
|
||||||
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
|
||||||
props.isPersisted,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appState = useUIAppState();
|
|
||||||
|
|
||||||
const onConfirm = () => {
|
|
||||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (appState.openDialog?.name !== "settings") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={() => {
|
|
||||||
props.onClose();
|
|
||||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
<div style={{ display: "flex" }}>
|
|
||||||
Wireframe to Code (AI){" "}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "0.1rem 0.5rem",
|
|
||||||
marginLeft: "1rem",
|
|
||||||
fontSize: 14,
|
|
||||||
borderRadius: "12px",
|
|
||||||
background: "var(--color-promo)",
|
|
||||||
color: "var(--color-surface-lowest)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Experimental
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className="MagicSettings"
|
|
||||||
autofocus={false}
|
|
||||||
>
|
|
||||||
{/* <h2
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
paddingLeft: "2.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
AI Settings
|
|
||||||
</h2> */}
|
|
||||||
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
|
|
||||||
{/* <TTDDialogTabTriggers>
|
|
||||||
<TTDDialogTabTrigger tab="text-to-diagram">
|
|
||||||
<InlineIcon icon={brainIcon} /> Text to diagram
|
|
||||||
</TTDDialogTabTrigger>
|
|
||||||
<TTDDialogTabTrigger tab="diagram-to-code">
|
|
||||||
<InlineIcon icon={MagicIcon} /> Wireframe to code
|
|
||||||
</TTDDialogTabTrigger>
|
|
||||||
</TTDDialogTabTriggers> */}
|
|
||||||
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
|
||||||
TODO
|
|
||||||
</TTDDialogTab> */}
|
|
||||||
<TTDDialogTab
|
|
||||||
// className="ttd-dialog-content"
|
|
||||||
tab="diagram-to-code"
|
|
||||||
>
|
|
||||||
<Paragraph>
|
|
||||||
For the diagram-to-code feature we use{" "}
|
|
||||||
<InlineIcon icon={OpenAIIcon} />
|
|
||||||
OpenAI.
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph>
|
|
||||||
While the OpenAI API is in beta, its use is strictly limited — as
|
|
||||||
such we require you use your own API key. You can create an{" "}
|
|
||||||
<a
|
|
||||||
href="https://platform.openai.com/login?launch"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
OpenAI account
|
|
||||||
</a>
|
|
||||||
, add a small credit (5 USD minimum), and{" "}
|
|
||||||
<a
|
|
||||||
href="https://platform.openai.com/api-keys"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
generate your own API key
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph>
|
|
||||||
Your OpenAI key does not leave the browser, and you can also set
|
|
||||||
your own limit in your OpenAI account dashboard if needed.
|
|
||||||
</Paragraph>
|
|
||||||
<TextField
|
|
||||||
isRedacted
|
|
||||||
value={keyInputValue}
|
|
||||||
placeholder="Paste your API key here"
|
|
||||||
label="OpenAI API key"
|
|
||||||
onChange={(value) => {
|
|
||||||
setKeyInputValue(value);
|
|
||||||
props.onChange(value.trim(), shouldPersist);
|
|
||||||
}}
|
|
||||||
selectOnRender
|
|
||||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
|
||||||
/>
|
|
||||||
<Paragraph>
|
|
||||||
By default, your API token is not persisted anywhere so you'll need
|
|
||||||
to insert it again after reload. But, you can persist locally in
|
|
||||||
your browser below.
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
|
||||||
Persist API key in browser storage
|
|
||||||
</CheckboxItem>
|
|
||||||
|
|
||||||
<Paragraph>
|
|
||||||
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
|
|
||||||
tool to wrap your elements in a frame that will then allow you to
|
|
||||||
turn it into code. This dialog can be accessed using the{" "}
|
|
||||||
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<FilledButton
|
|
||||||
className="MagicSettings__confirm"
|
|
||||||
size="large"
|
|
||||||
label="Confirm"
|
|
||||||
onClick={onConfirm}
|
|
||||||
/>
|
|
||||||
</TTDDialogTab>
|
|
||||||
</TTDDialogTabs>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 110%;
|
line-height: 110%;
|
||||||
|
|
||||||
background: var(--color-success-lighter);
|
background: var(--color-success);
|
||||||
color: var(--color-success);
|
color: var(--color-success-text);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
|
|||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { TextField } from "./TextField";
|
import { TextField } from "./TextField";
|
||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
import { copyIcon, tablerCheckIcon } from "./icons";
|
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||||
|
import { copyIcon } from "./icons";
|
||||||
|
|
||||||
import "./ShareableLinkDialog.scss";
|
import "./ShareableLinkDialog.scss";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
|
|||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
}: ShareableLinkDialogProps) => {
|
}: ShareableLinkDialogProps) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [, setJustCopied] = useState(false);
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
|
|||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
const { onCopy, copyStatus } = useCopyStatus();
|
||||||
return (
|
return (
|
||||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||||
<div className="ShareableLinkDialog">
|
<div className="ShareableLinkDialog">
|
||||||
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
|
|||||||
value={link}
|
value={link}
|
||||||
selectOnRender
|
selectOnRender
|
||||||
/>
|
/>
|
||||||
<Popover.Root open={justCopied}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label="Copy link"
|
label={t("buttons.copyLink")}
|
||||||
icon={copyIcon}
|
icon={copyIcon}
|
||||||
onClick={copyRoomLink}
|
status={copyStatus}
|
||||||
|
onClick={() => {
|
||||||
|
onCopy();
|
||||||
|
copyRoomLink();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
className="ShareableLinkDialog__popover"
|
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={5.5}
|
|
||||||
>
|
|
||||||
{tablerCheckIcon} copied
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ShareableLinkDialog__description">
|
<div className="ShareableLinkDialog__description">
|
||||||
🔒 {t("alerts.uploadedSecurly")}
|
🔒 {t("alerts.uploadedSecurly")}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React from "react";
|
|||||||
import { DEFAULT_SIDEBAR } from "../../constants";
|
import { DEFAULT_SIDEBAR } from "../../constants";
|
||||||
import { Excalidraw, Sidebar } from "../../index";
|
import { Excalidraw, Sidebar } from "../../index";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
GlobalTestState,
|
|
||||||
queryAllByTestId,
|
queryAllByTestId,
|
||||||
queryByTestId,
|
queryByTestId,
|
||||||
render,
|
render,
|
||||||
@@ -11,39 +11,17 @@ import {
|
|||||||
withExcalidrawDimensions,
|
withExcalidrawDimensions,
|
||||||
} from "../../tests/test-utils";
|
} from "../../tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
import {
|
||||||
|
assertExcalidrawWithSidebar,
|
||||||
|
assertSidebarDockButton,
|
||||||
|
} from "./siderbar.test.helpers";
|
||||||
|
|
||||||
export const assertSidebarDockButton = async <T extends boolean>(
|
const toggleSidebar = (
|
||||||
hasDockButton: T,
|
...args: Parameters<typeof window.h.app.toggleSidebar>
|
||||||
): Promise<
|
): Promise<boolean> => {
|
||||||
T extends false
|
return act(() => {
|
||||||
? { dockButton: null; sidebar: HTMLElement }
|
return window.h.app.toggleSidebar(...args);
|
||||||
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
});
|
||||||
> => {
|
|
||||||
const sidebar =
|
|
||||||
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
|
||||||
".sidebar",
|
|
||||||
);
|
|
||||||
expect(sidebar).not.toBe(null);
|
|
||||||
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
|
||||||
if (hasDockButton) {
|
|
||||||
expect(dockButton).not.toBe(null);
|
|
||||||
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
|
||||||
}
|
|
||||||
expect(dockButton).toBe(null);
|
|
||||||
return { dockButton: null, sidebar: sidebar! } as any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assertExcalidrawWithSidebar = async (
|
|
||||||
sidebar: React.ReactNode,
|
|
||||||
name: string,
|
|
||||||
test: () => void,
|
|
||||||
) => {
|
|
||||||
await render(
|
|
||||||
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
|
||||||
{sidebar}
|
|
||||||
</Excalidraw>,
|
|
||||||
);
|
|
||||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
@@ -103,7 +81,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar on
|
// toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -112,7 +90,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle sidebar off
|
// toggle sidebar off
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
|
expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -121,9 +99,9 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar off (=> still hidden)
|
// force-toggle sidebar off (=> still hidden)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(
|
expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
|
false,
|
||||||
).toBe(false);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -132,12 +110,12 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// force-toggle sidebar on
|
// force-toggle sidebar on
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(
|
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
expect(
|
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -146,9 +124,7 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// toggle library (= hide custom sidebar)
|
// toggle library (= hide custom sidebar)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
|
expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
@@ -161,13 +137,13 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
// closing sidebar using `{ name: null }`
|
// closing sidebar using `{ name: null }`
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).not.toBe(null);
|
expect(node).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
|
expect(await toggleSidebar({ name: null })).toBe(false);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const node = container.querySelector("#test-sidebar-content");
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
expect(node).toBe(null);
|
expect(node).toBe(null);
|
||||||
@@ -321,6 +297,9 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
||||||
|
// we expect warnings in this test and don't want to pollute stdout
|
||||||
|
const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||||
@@ -341,6 +320,8 @@ describe("Sidebar", () => {
|
|||||||
await assertSidebarDockButton(false);
|
await assertSidebarDockButton(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
mock.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,9 +348,9 @@ describe("Sidebar", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
// open library sidebar
|
// open library sidebar
|
||||||
expect(
|
expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=library]",
|
"[role=tabpanel][data-testid=library]",
|
||||||
@@ -377,9 +358,9 @@ describe("Sidebar", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// switch to comments tab
|
// switch to comments tab
|
||||||
expect(
|
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
@@ -387,9 +368,9 @@ describe("Sidebar", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// toggle sidebar closed
|
// toggle sidebar closed
|
||||||
expect(
|
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
false,
|
||||||
).toBe(false);
|
);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
@@ -397,9 +378,9 @@ describe("Sidebar", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
// toggle sidebar open
|
// toggle sidebar open
|
||||||
expect(
|
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
|
||||||
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
true,
|
||||||
).toBe(true);
|
);
|
||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(
|
container.querySelector<HTMLElement>(
|
||||||
"[role=tabpanel][data-testid=comments]",
|
"[role=tabpanel][data-testid=comments]",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Excalidraw } from "../..";
|
||||||
|
import {
|
||||||
|
GlobalTestState,
|
||||||
|
queryByTestId,
|
||||||
|
render,
|
||||||
|
withExcalidrawDimensions,
|
||||||
|
} from "../../tests/test-utils";
|
||||||
|
|
||||||
|
export const assertSidebarDockButton = async <T extends boolean>(
|
||||||
|
hasDockButton: T,
|
||||||
|
): Promise<
|
||||||
|
T extends false
|
||||||
|
? { dockButton: null; sidebar: HTMLElement }
|
||||||
|
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
||||||
|
> => {
|
||||||
|
const sidebar =
|
||||||
|
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
||||||
|
".sidebar",
|
||||||
|
);
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
if (hasDockButton) {
|
||||||
|
expect(dockButton).not.toBe(null);
|
||||||
|
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
||||||
|
}
|
||||||
|
expect(dockButton).toBe(null);
|
||||||
|
return { dockButton: null, sidebar: sidebar! } as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertExcalidrawWithSidebar = async (
|
||||||
|
sidebar: React.ReactNode,
|
||||||
|
name: string,
|
||||||
|
test: () => void,
|
||||||
|
) => {
|
||||||
|
await render(
|
||||||
|
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
||||||
|
{sidebar}
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
||||||
|
};
|
||||||
@@ -6,16 +6,18 @@ const Spinner = ({
|
|||||||
size = "1em",
|
size = "1em",
|
||||||
circleWidth = 8,
|
circleWidth = 8,
|
||||||
synchronized = false,
|
synchronized = false,
|
||||||
|
className = "",
|
||||||
}: {
|
}: {
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
circleWidth?: number;
|
circleWidth?: number;
|
||||||
synchronized?: boolean;
|
synchronized?: boolean;
|
||||||
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const mountTime = React.useRef(Date.now());
|
const mountTime = React.useRef(Date.now());
|
||||||
const mountDelay = -(mountTime.current % 1600);
|
const mountDelay = -(mountTime.current % 1600);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Spinner">
|
<div className={`Spinner ${className}`}>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import StatsDragInput from "./DragInput";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import { getStepSizedValue } from "./utils";
|
||||||
|
import { getNormalizedGridStep } from "../../scene";
|
||||||
|
|
||||||
|
interface PositionProps {
|
||||||
|
property: "gridStep";
|
||||||
|
scene: Scene;
|
||||||
|
appState: AppState;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 5;
|
||||||
|
|
||||||
|
const CanvasGrid = ({
|
||||||
|
property,
|
||||||
|
scene,
|
||||||
|
appState,
|
||||||
|
setAppState,
|
||||||
|
}: PositionProps) => {
|
||||||
|
return (
|
||||||
|
<StatsDragInput
|
||||||
|
label="Grid step"
|
||||||
|
sensitivity={8}
|
||||||
|
elements={[]}
|
||||||
|
dragInputCallback={({
|
||||||
|
nextValue,
|
||||||
|
instantChange,
|
||||||
|
shouldChangeByStepSize,
|
||||||
|
setInputValue,
|
||||||
|
}) => {
|
||||||
|
setAppState((state) => {
|
||||||
|
let nextGridStep;
|
||||||
|
|
||||||
|
if (nextValue) {
|
||||||
|
nextGridStep = nextValue;
|
||||||
|
} else if (instantChange) {
|
||||||
|
nextGridStep = shouldChangeByStepSize
|
||||||
|
? getStepSizedValue(
|
||||||
|
state.gridStep + STEP_SIZE * Math.sign(instantChange),
|
||||||
|
STEP_SIZE,
|
||||||
|
)
|
||||||
|
: state.gridStep + instantChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextGridStep) {
|
||||||
|
setInputValue(state.gridStep);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextGridStep = getNormalizedGridStep(nextGridStep);
|
||||||
|
setInputValue(nextGridStep);
|
||||||
|
return {
|
||||||
|
gridStep: nextGridStep,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
scene={scene}
|
||||||
|
value={appState.gridStep}
|
||||||
|
property={property}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CanvasGrid;
|
||||||
@@ -31,7 +31,11 @@ const Collapsible = ({
|
|||||||
{label}
|
{label}
|
||||||
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
||||||
</div>
|
</div>
|
||||||
{open && <>{children}</>}
|
{open && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
> = ({
|
> = ({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
originalElements,
|
originalElements,
|
||||||
originalElementsMap,
|
|
||||||
shouldKeepAspectRatio,
|
shouldKeepAspectRatio,
|
||||||
shouldChangeByStepSize,
|
shouldChangeByStepSize,
|
||||||
nextValue,
|
nextValue,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-md);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,17 +18,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--default-border-color);
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
width: 2rem;
|
padding: 0 0.5rem 0 0.75rem;
|
||||||
|
min-width: 1rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: var(--popup-text-color);
|
color: var(--popup-text-color);
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
|
||||||
border-right: 1px solid var(--default-border-color);
|
border-right: 1px solid var(--default-border-color);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
@@ -55,11 +56,11 @@
|
|||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
|
||||||
border-left: 1px solid var(--default-border-color);
|
border-left: 1px solid var(--default-border-color);
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type DragInputCallbackType<
|
|||||||
nextValue?: number;
|
nextValue?: number;
|
||||||
property: P;
|
property: P;
|
||||||
originalAppState: AppState;
|
originalAppState: AppState;
|
||||||
|
setInputValue: (value: number) => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
interface StatsDragInputProps<
|
interface StatsDragInputProps<
|
||||||
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
|
|||||||
property: T;
|
property: T;
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
/** how many px you need to drag to get 1 unit change */
|
||||||
|
sensitivity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsDragInput = <
|
const StatsDragInput = <
|
||||||
@@ -61,6 +64,7 @@ const StatsDragInput = <
|
|||||||
property,
|
property,
|
||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
|
sensitivity = 1,
|
||||||
}: StatsDragInputProps<T, E>) => {
|
}: StatsDragInputProps<T, E>) => {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -126,27 +130,49 @@ const StatsDragInput = <
|
|||||||
nextValue: rounded,
|
nextValue: rounded,
|
||||||
property,
|
property,
|
||||||
originalAppState: appState,
|
originalAppState: appState,
|
||||||
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
});
|
});
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputValueRef = useRef(handleInputValue);
|
const callbacksRef = useRef<
|
||||||
handleInputValueRef.current = handleInputValue;
|
Partial<{
|
||||||
|
handleInputValue: typeof handleInputValue;
|
||||||
|
onPointerUp: (event: PointerEvent) => void;
|
||||||
|
onPointerMove: (event: PointerEvent) => void;
|
||||||
|
}>
|
||||||
|
>({});
|
||||||
|
callbacksRef.current.handleInputValue = handleInputValue;
|
||||||
|
|
||||||
// make sure that clicking on canvas (which umounts the component)
|
// make sure that clicking on canvas (which umounts the component)
|
||||||
// updates current input value (blur isn't triggered)
|
// updates current input value (blur isn't triggered)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
|
const callbacks = callbacksRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
const nextValue = input?.value;
|
const nextValue = input?.value;
|
||||||
if (nextValue) {
|
if (nextValue) {
|
||||||
handleInputValueRef.current(
|
callbacks.handleInputValue?.(
|
||||||
nextValue,
|
nextValue,
|
||||||
stateRef.current.originalElements,
|
stateRef.current.originalElements,
|
||||||
stateRef.current.originalAppState,
|
stateRef.current.originalAppState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generally not needed, but in case `pointerup` doesn't fire and
|
||||||
|
// we don't remove the listeners that way, we should at least remove
|
||||||
|
// on unmount
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.POINTER_MOVE,
|
||||||
|
callbacks.onPointerMove!,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.POINTER_UP,
|
||||||
|
callbacks.onPointerUp!,
|
||||||
|
false,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
// we need to track change of `editable` state as mount/unmount
|
// we need to track change of `editable` state as mount/unmount
|
||||||
@@ -172,6 +198,8 @@ const StatsDragInput = <
|
|||||||
ref={labelRef}
|
ref={labelRef}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (inputRef.current && editable) {
|
if (inputRef.current && editable) {
|
||||||
|
document.body.classList.add("excalidraw-cursor-resize");
|
||||||
|
|
||||||
let startValue = Number(inputRef.current.value);
|
let startValue = Number(inputRef.current.value);
|
||||||
if (isNaN(startValue)) {
|
if (isNaN(startValue)) {
|
||||||
startValue = 0;
|
startValue = 0;
|
||||||
@@ -196,27 +224,30 @@ const StatsDragInput = <
|
|||||||
|
|
||||||
const originalAppState: AppState = cloneJSON(appState);
|
const originalAppState: AppState = cloneJSON(appState);
|
||||||
|
|
||||||
let accumulatedChange: number | null = null;
|
let accumulatedChange = 0;
|
||||||
|
let stepChange = 0;
|
||||||
document.body.classList.add("excalidraw-cursor-resize");
|
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
if (!accumulatedChange) {
|
|
||||||
accumulatedChange = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lastPointer &&
|
lastPointer &&
|
||||||
originalElementsMap !== null &&
|
originalElementsMap !== null &&
|
||||||
originalElements !== null &&
|
originalElements !== null
|
||||||
accumulatedChange !== null
|
|
||||||
) {
|
) {
|
||||||
const instantChange = event.clientX - lastPointer.x;
|
const instantChange = event.clientX - lastPointer.x;
|
||||||
accumulatedChange += instantChange;
|
|
||||||
|
if (instantChange !== 0) {
|
||||||
|
stepChange += instantChange;
|
||||||
|
|
||||||
|
if (Math.abs(stepChange) >= sensitivity) {
|
||||||
|
stepChange =
|
||||||
|
Math.sign(stepChange) *
|
||||||
|
Math.floor(Math.abs(stepChange) / sensitivity);
|
||||||
|
|
||||||
|
accumulatedChange += stepChange;
|
||||||
|
|
||||||
dragInputCallback({
|
dragInputCallback({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
instantChange,
|
instantChange: stepChange,
|
||||||
originalElements,
|
originalElements,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
@@ -224,7 +255,12 @@ const StatsDragInput = <
|
|||||||
property,
|
property,
|
||||||
scene,
|
scene,
|
||||||
originalAppState,
|
originalAppState,
|
||||||
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stepChange = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPointer = {
|
lastPointer = {
|
||||||
@@ -233,10 +269,7 @@ const StatsDragInput = <
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
const onPointerUp = () => {
|
||||||
window.addEventListener(
|
|
||||||
EVENT.POINTER_UP,
|
|
||||||
() => {
|
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
EVENT.POINTER_MOVE,
|
EVENT.POINTER_MOVE,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
@@ -246,14 +279,21 @@ const StatsDragInput = <
|
|||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
|
|
||||||
lastPointer = null;
|
lastPointer = null;
|
||||||
accumulatedChange = null;
|
accumulatedChange = 0;
|
||||||
|
stepChange = 0;
|
||||||
originalElements = null;
|
originalElements = null;
|
||||||
originalElementsMap = null;
|
originalElementsMap = null;
|
||||||
|
|
||||||
document.body.classList.remove("excalidraw-cursor-resize");
|
document.body.classList.remove("excalidraw-cursor-resize");
|
||||||
},
|
|
||||||
false,
|
window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
callbacksRef.current.onPointerMove = onPointerMove;
|
||||||
|
callbacksRef.current.onPointerUp = onPointerUp;
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
||||||
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerEnter={() => {
|
onPointerEnter={() => {
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const resizeElementInGroup = (
|
|||||||
origElement: ExcalidrawElement,
|
origElement: ExcalidrawElement,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||||
const { width: oldWidth, height: oldHeight } = latestElement;
|
const { width: oldWidth, height: oldHeight } = latestElement;
|
||||||
@@ -78,7 +77,7 @@ const resizeElementInGroup = (
|
|||||||
);
|
);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const newFontSize = boundTextElement.fontSize * scale;
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
updateBoundElements(latestElement, elementsMap, scene, {
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
oldSize: { width: oldWidth, height: oldHeight },
|
||||||
});
|
});
|
||||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
@@ -111,7 +110,6 @@ const resizeGroup = (
|
|||||||
originalElements: ExcalidrawElement[],
|
originalElements: ExcalidrawElement[],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
// keep aspect ratio for groups
|
// keep aspect ratio for groups
|
||||||
if (property === "width") {
|
if (property === "width") {
|
||||||
@@ -135,7 +133,6 @@ const resizeGroup = (
|
|||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -190,7 +187,6 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
originalElements,
|
originalElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
@@ -296,7 +292,6 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
originalElements,
|
originalElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.exc-stats {
|
||||||
|
width: 204px;
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-block-start: 0.83em;
|
||||||
|
margin-block-end: 0.83em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 1.17em;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
div + div {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row--heading {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState, memo } from "react";
|
|||||||
import { getCommonBounds } from "../../element/bounds";
|
import { getCommonBounds } from "../../element/bounds";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import type { AppState, ExcalidrawProps } from "../../types";
|
import type {
|
||||||
|
AppClassProperties,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
} from "../../types";
|
||||||
import { CloseIcon } from "../icons";
|
import { CloseIcon } from "../icons";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
import Dimension from "./Dimension";
|
import Dimension from "./Dimension";
|
||||||
import Angle from "./Angle";
|
import Angle from "./Angle";
|
||||||
|
|
||||||
import FontSize from "./FontSize";
|
import FontSize from "./FontSize";
|
||||||
import MultiDimension from "./MultiDimension";
|
import MultiDimension from "./MultiDimension";
|
||||||
import { elementsAreInSameGroup } from "../../groups";
|
import { elementsAreInSameGroup } from "../../groups";
|
||||||
@@ -17,14 +20,18 @@ import MultiFontSize from "./MultiFontSize";
|
|||||||
import Position from "./Position";
|
import Position from "./Position";
|
||||||
import MultiPosition from "./MultiPosition";
|
import MultiPosition from "./MultiPosition";
|
||||||
import Collapsible from "./Collapsible";
|
import Collapsible from "./Collapsible";
|
||||||
import type Scene from "../../scene/Scene";
|
|
||||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||||
import { getAtomicUnits } from "./utils";
|
import { getAtomicUnits } from "./utils";
|
||||||
import { STATS_PANELS } from "../../constants";
|
import { STATS_PANELS } from "../../constants";
|
||||||
import { isElbowArrow } from "../../element/typeChecks";
|
import { isElbowArrow } from "../../element/typeChecks";
|
||||||
|
import CanvasGrid from "./CanvasGrid";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import "./Stats.scss";
|
||||||
|
import { isGridModeEnabled } from "../../snapping";
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
scene: Scene;
|
app: AppClassProperties;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}
|
}
|
||||||
@@ -33,11 +40,12 @@ const STATS_TIMEOUT = 50;
|
|||||||
|
|
||||||
export const Stats = (props: StatsProps) => {
|
export const Stats = (props: StatsProps) => {
|
||||||
const appState = useExcalidrawAppState();
|
const appState = useExcalidrawAppState();
|
||||||
const sceneNonce = props.scene.getSceneNonce() || 1;
|
const sceneNonce = props.app.scene.getSceneNonce() || 1;
|
||||||
const selectedElements = props.scene.getSelectedElements({
|
const selectedElements = props.app.scene.getSelectedElements({
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: false,
|
includeBoundTextElement: false,
|
||||||
});
|
});
|
||||||
|
const gridModeEnabled = isGridModeEnabled(props.app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatsInner
|
<StatsInner
|
||||||
@@ -45,23 +53,71 @@ export const Stats = (props: StatsProps) => {
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
sceneNonce={sceneNonce}
|
sceneNonce={sceneNonce}
|
||||||
selectedElements={selectedElements}
|
selectedElements={selectedElements}
|
||||||
|
gridModeEnabled={gridModeEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StatsRow = ({
|
||||||
|
children,
|
||||||
|
columns = 1,
|
||||||
|
heading,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
columns?: number;
|
||||||
|
heading?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
StatsRow.displayName = "StatsRow";
|
||||||
|
|
||||||
|
const StatsRows = ({
|
||||||
|
children,
|
||||||
|
order,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
order?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
StatsRows.displayName = "StatsRows";
|
||||||
|
|
||||||
|
Stats.StatsRow = StatsRow;
|
||||||
|
Stats.StatsRows = StatsRows;
|
||||||
|
|
||||||
export const StatsInner = memo(
|
export const StatsInner = memo(
|
||||||
({
|
({
|
||||||
scene,
|
app,
|
||||||
onClose,
|
onClose,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
appState,
|
appState,
|
||||||
sceneNonce,
|
sceneNonce,
|
||||||
|
gridModeEnabled,
|
||||||
}: StatsProps & {
|
}: StatsProps & {
|
||||||
sceneNonce: number;
|
sceneNonce: number;
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
gridModeEnabled: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const scene = app.scene;
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
@@ -106,7 +162,7 @@ export const StatsInner = memo(
|
|||||||
}, [selectedElements, appState]);
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Stats">
|
<div className="exc-stats">
|
||||||
<Island padding={3}>
|
<Island padding={3}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
<h2>{t("stats.title")}</h2>
|
<h2>{t("stats.title")}</h2>
|
||||||
@@ -121,7 +177,6 @@ export const StatsInner = memo(
|
|||||||
openTrigger={() =>
|
openTrigger={() =>
|
||||||
setAppState((state) => {
|
setAppState((state) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
stats: {
|
stats: {
|
||||||
open: true,
|
open: true,
|
||||||
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
||||||
@@ -130,26 +185,36 @@ export const StatsInner = memo(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<table>
|
<StatsRows>
|
||||||
<tbody>
|
<StatsRow heading>{t("stats.scene")}</StatsRow>
|
||||||
<tr>
|
<StatsRow columns={2}>
|
||||||
<th colSpan={2}>{t("stats.scene")}</th>
|
<div>{t("stats.shapes")}</div>
|
||||||
</tr>
|
<div>{elements.length}</div>
|
||||||
<tr>
|
</StatsRow>
|
||||||
<td>{t("stats.elements")}</td>
|
<StatsRow columns={2}>
|
||||||
<td>{elements.length}</td>
|
<div>{t("stats.width")}</div>
|
||||||
</tr>
|
<div>{sceneDimension.width}</div>
|
||||||
<tr>
|
</StatsRow>
|
||||||
<td>{t("stats.width")}</td>
|
<StatsRow columns={2}>
|
||||||
<td>{sceneDimension.width}</td>
|
<div>{t("stats.height")}</div>
|
||||||
</tr>
|
<div>{sceneDimension.height}</div>
|
||||||
<tr>
|
</StatsRow>
|
||||||
<td>{t("stats.height")}</td>
|
{gridModeEnabled && (
|
||||||
<td>{sceneDimension.height}</td>
|
<>
|
||||||
</tr>
|
<StatsRow heading>Canvas</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
|
<CanvasGrid
|
||||||
|
property="gridStep"
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</StatsRows>
|
||||||
|
|
||||||
{renderCustomStats?.(elements, appState)}
|
{renderCustomStats?.(elements, appState)}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{selectedElements.length > 0 && (
|
{selectedElements.length > 0 && (
|
||||||
@@ -167,7 +232,6 @@ export const StatsInner = memo(
|
|||||||
openTrigger={() =>
|
openTrigger={() =>
|
||||||
setAppState((state) => {
|
setAppState((state) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
stats: {
|
stats: {
|
||||||
open: true,
|
open: true,
|
||||||
panels:
|
panels:
|
||||||
@@ -177,13 +241,14 @@ export const StatsInner = memo(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<StatsRows>
|
||||||
{singleElement && (
|
{singleElement && (
|
||||||
<div className="sectionContent">
|
<>
|
||||||
<div className="elementType">
|
<StatsRow heading data-testid="stats-element-type">
|
||||||
{t(`element.${singleElement.type}`)}
|
{t(`element.${singleElement.type}`)}
|
||||||
</div>
|
</StatsRow>
|
||||||
|
|
||||||
<div className="statsItem">
|
<StatsRow>
|
||||||
<Position
|
<Position
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
property="x"
|
property="x"
|
||||||
@@ -191,6 +256,8 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<Position
|
<Position
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
property="y"
|
property="y"
|
||||||
@@ -198,48 +265,56 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<Dimension
|
<Dimension
|
||||||
property="width"
|
property="width"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<Dimension
|
<Dimension
|
||||||
property="height"
|
property="height"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
{!isElbowArrow(singleElement) && (
|
{!isElbowArrow(singleElement) && (
|
||||||
|
<StatsRow>
|
||||||
<Angle
|
<Angle
|
||||||
property="angle"
|
property="angle"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
)}
|
)}
|
||||||
|
<StatsRow>
|
||||||
<FontSize
|
<FontSize
|
||||||
property="fontSize"
|
property="fontSize"
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StatsRow>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{multipleElements && (
|
{multipleElements && (
|
||||||
<div className="sectionContent">
|
<>
|
||||||
{elementsAreInSameGroup(multipleElements) && (
|
{elementsAreInSameGroup(multipleElements) && (
|
||||||
<div className="elementType">{t("element.group")}</div>
|
<StatsRow heading>{t("element.group")}</StatsRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="elementsCount">
|
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
|
||||||
<div>{t("stats.elements")}</div>
|
<div>{t("stats.shapes")}</div>
|
||||||
<div>{selectedElements.length}</div>
|
<div>{selectedElements.length}</div>
|
||||||
</div>
|
</StatsRow>
|
||||||
|
|
||||||
<div className="statsItem">
|
<StatsRow>
|
||||||
<MultiPosition
|
<MultiPosition
|
||||||
property="x"
|
property="x"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
@@ -248,6 +323,8 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<MultiPosition
|
<MultiPosition
|
||||||
property="y"
|
property="y"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
@@ -256,6 +333,8 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<MultiDimension
|
<MultiDimension
|
||||||
property="width"
|
property="width"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
@@ -264,6 +343,8 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<MultiDimension
|
<MultiDimension
|
||||||
property="height"
|
property="height"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
@@ -272,12 +353,16 @@ export const StatsInner = memo(
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<MultiAngle
|
<MultiAngle
|
||||||
property="angle"
|
property="angle"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
|
</StatsRow>
|
||||||
|
<StatsRow>
|
||||||
<MultiFontSize
|
<MultiFontSize
|
||||||
property="fontSize"
|
property="fontSize"
|
||||||
elements={multipleElements}
|
elements={multipleElements}
|
||||||
@@ -285,9 +370,10 @@ export const StatsInner = memo(
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StatsRow>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</StatsRows>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -299,7 +385,9 @@ export const StatsInner = memo(
|
|||||||
return (
|
return (
|
||||||
prev.sceneNonce === next.sceneNonce &&
|
prev.sceneNonce === next.sceneNonce &&
|
||||||
prev.selectedElements === next.selectedElements &&
|
prev.selectedElements === next.selectedElements &&
|
||||||
prev.appState.stats.panels === next.appState.stats.panels
|
prev.appState.stats.panels === next.appState.stats.panels &&
|
||||||
|
prev.gridModeEnabled === next.gridModeEnabled &&
|
||||||
|
prev.appState.gridStep === next.appState.gridStep
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, queryByTestId } from "@testing-library/react";
|
import React from "react";
|
||||||
|
import { act, fireEvent, queryByTestId } from "@testing-library/react";
|
||||||
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
|
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
|
||||||
import { getStepSizedValue } from "./utils";
|
import { getStepSizedValue } from "./utils";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +25,6 @@ import { getCommonBounds, isTextElement } from "../../element";
|
|||||||
import { API } from "../../tests/helpers/api";
|
import { API } from "../../tests/helpers/api";
|
||||||
import { actionGroup } from "../../actions";
|
import { actionGroup } from "../../actions";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -32,27 +32,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
|||||||
let stats: HTMLElement | null = null;
|
let stats: HTMLElement | null = null;
|
||||||
let elementStats: HTMLElement | null | undefined = null;
|
let elementStats: HTMLElement | null | undefined = null;
|
||||||
|
|
||||||
const editInput = (input: HTMLInputElement, value: string) => {
|
|
||||||
input.focus();
|
|
||||||
fireEvent.change(input, { target: { value } });
|
|
||||||
input.blur();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatsProperty = (label: string) => {
|
|
||||||
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
|
||||||
|
|
||||||
if (elementStats) {
|
|
||||||
const properties = elementStats?.querySelector(".statsItem");
|
|
||||||
return (
|
|
||||||
properties?.querySelector?.(
|
|
||||||
`.drag-input-container[data-testid="${label}"]`,
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const testInputProperty = (
|
const testInputProperty = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
||||||
@@ -60,12 +39,12 @@ const testInputProperty = (
|
|||||||
initialValue: number,
|
initialValue: number,
|
||||||
nextValue: number,
|
nextValue: number,
|
||||||
) => {
|
) => {
|
||||||
const input = getStatsProperty(label)?.querySelector(
|
const input = UI.queryStatsProperty(label)?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(initialValue.toString());
|
expect(input.value).toBe(initialValue.toString());
|
||||||
editInput(input, String(nextValue));
|
UI.updateInput(input, String(nextValue));
|
||||||
if (property === "angle") {
|
if (property === "angle") {
|
||||||
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||||
} else if (property === "fontSize" && isTextElement(element)) {
|
} else if (property === "fontSize" && isTextElement(element)) {
|
||||||
@@ -110,7 +89,7 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@@ -142,47 +121,47 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
it("should remain bound to linear element on small position change", async () => {
|
it("should remain bound to linear element on small position change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputX = getStatsProperty("X")?.querySelector(
|
const inputX = UI.queryStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
expect(inputX).not.toBeNull();
|
expect(inputX).not.toBeNull();
|
||||||
editInput(inputX, String("204"));
|
UI.updateInput(inputX, String("204"));
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remain bound to linear element on small angle change", async () => {
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputAngle = getStatsProperty("A")?.querySelector(
|
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
editInput(inputAngle, String("1"));
|
UI.updateInput(inputAngle, String("1"));
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unbind linear element on large position change", async () => {
|
it("should unbind linear element on large position change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputX = getStatsProperty("X")?.querySelector(
|
const inputX = UI.queryStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
expect(inputX).not.toBeNull();
|
expect(inputX).not.toBeNull();
|
||||||
editInput(inputX, String("254"));
|
UI.updateInput(inputX, String("254"));
|
||||||
expect(linear.startBinding).toBe(null);
|
expect(linear.startBinding).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remain bound to linear element on small angle change", async () => {
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputAngle = getStatsProperty("A")?.querySelector(
|
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(linear.startBinding).not.toBe(null);
|
expect(linear.startBinding).not.toBe(null);
|
||||||
editInput(inputAngle, String("45"));
|
UI.updateInput(inputAngle, String("45"));
|
||||||
expect(linear.startBinding).toBe(null);
|
expect(linear.startBinding).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -197,7 +176,7 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@@ -231,18 +210,14 @@ describe("stats for a generic element", () => {
|
|||||||
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
||||||
|
|
||||||
// element type
|
// element type
|
||||||
const elementType = elementStats?.querySelector(".elementType");
|
const elementType = queryByTestId(elementStats!, "stats-element-type");
|
||||||
expect(elementType).toBeDefined();
|
expect(elementType).toBeDefined();
|
||||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||||
|
|
||||||
// properties
|
// properties
|
||||||
const properties = elementStats?.querySelector(".statsItem");
|
|
||||||
expect(properties?.childNodes).toBeDefined();
|
|
||||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||||
expect(
|
expect(
|
||||||
properties?.querySelector?.(
|
stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
|
||||||
`.drag-input-container[data-testid="${label}"]`,
|
|
||||||
),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -263,18 +238,18 @@ describe("stats for a generic element", () => {
|
|||||||
const rectangle = h.elements[0];
|
const rectangle = h.elements[0];
|
||||||
const rectangleId = rectangle.id;
|
const rectangleId = rectangle.id;
|
||||||
|
|
||||||
const input = getStatsProperty("W")?.querySelector(
|
const input = UI.queryStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(rectangle.width.toString());
|
expect(input.value).toBe(rectangle.width.toString());
|
||||||
editInput(input, "123.123");
|
UI.updateInput(input, "123.123");
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(rectangle.id).toBe(rectangleId);
|
expect(rectangle.id).toBe(rectangleId);
|
||||||
expect(input.value).toBe("123.12");
|
expect(input.value).toBe("123.12");
|
||||||
expect(rectangle.width).toBe(123.12);
|
expect(rectangle.width).toBe(123.12);
|
||||||
|
|
||||||
editInput(input, "88.98766");
|
UI.updateInput(input, "88.98766");
|
||||||
expect(input.value).toBe("88.99");
|
expect(input.value).toBe("88.99");
|
||||||
expect(rectangle.width).toBe(88.99);
|
expect(rectangle.width).toBe(88.99);
|
||||||
});
|
});
|
||||||
@@ -293,11 +268,11 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
const xInput = getStatsProperty("X")?.querySelector(
|
const xInput = UI.queryStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const yInput = getStatsProperty("Y")?.querySelector(
|
const yInput = UI.queryStatsProperty("Y")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -387,7 +362,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@@ -412,9 +387,10 @@ describe("stats for a non-generic element", () => {
|
|||||||
mouse.clickAt(20, 30);
|
mouse.clickAt(20, 30);
|
||||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello!");
|
updateTextEditor(editor, "Hello!");
|
||||||
|
act(() => {
|
||||||
editor.blur();
|
editor.blur();
|
||||||
|
});
|
||||||
|
|
||||||
const text = h.elements[0] as ExcalidrawTextElement;
|
const text = h.elements[0] as ExcalidrawTextElement;
|
||||||
mouse.clickOn(text);
|
mouse.clickOn(text);
|
||||||
@@ -422,22 +398,22 @@ describe("stats for a non-generic element", () => {
|
|||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
// can change font size
|
// can change font size
|
||||||
const input = getStatsProperty("F")?.querySelector(
|
const input = UI.queryStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(text.fontSize.toString());
|
expect(input.value).toBe(text.fontSize.toString());
|
||||||
editInput(input, "36");
|
UI.updateInput(input, "36");
|
||||||
expect(text.fontSize).toBe(36);
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
// cannot change width or height
|
// cannot change width or height
|
||||||
const width = getStatsProperty("W")?.querySelector(".drag-input");
|
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
||||||
expect(width).toBeUndefined();
|
expect(width).toBeUndefined();
|
||||||
const height = getStatsProperty("H")?.querySelector(".drag-input");
|
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
||||||
expect(height).toBeUndefined();
|
expect(height).toBeUndefined();
|
||||||
|
|
||||||
// min font size is 4
|
// min font size is 4
|
||||||
editInput(input, "0");
|
UI.updateInput(input, "0");
|
||||||
expect(text.fontSize).not.toBe(0);
|
expect(text.fontSize).not.toBe(0);
|
||||||
expect(text.fontSize).toBe(4);
|
expect(text.fontSize).toBe(4);
|
||||||
});
|
});
|
||||||
@@ -449,8 +425,8 @@ describe("stats for a non-generic element", () => {
|
|||||||
x: 150,
|
x: 150,
|
||||||
width: 150,
|
width: 150,
|
||||||
});
|
});
|
||||||
h.elements = [frame];
|
API.setElements([frame]);
|
||||||
h.setState({
|
API.setAppState({
|
||||||
selectedElementIds: {
|
selectedElementIds: {
|
||||||
[frame.id]: true,
|
[frame.id]: true,
|
||||||
},
|
},
|
||||||
@@ -461,7 +437,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
expect(elementStats).toBeDefined();
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
// cannot change angle
|
// cannot change angle
|
||||||
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
|
||||||
expect(angle).toBeUndefined();
|
expect(angle).toBeUndefined();
|
||||||
|
|
||||||
// can change width or height
|
// can change width or height
|
||||||
@@ -471,9 +447,9 @@ describe("stats for a non-generic element", () => {
|
|||||||
|
|
||||||
it("image element", () => {
|
it("image element", () => {
|
||||||
const image = API.createElement({ type: "image", width: 200, height: 100 });
|
const image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||||
h.elements = [image];
|
API.setElements([image]);
|
||||||
mouse.clickOn(image);
|
mouse.clickOn(image);
|
||||||
h.setState({
|
API.setAppState({
|
||||||
selectedElementIds: {
|
selectedElementIds: {
|
||||||
[image.id]: true,
|
[image.id]: true,
|
||||||
},
|
},
|
||||||
@@ -508,15 +484,15 @@ describe("stats for a non-generic element", () => {
|
|||||||
mutateElement(container, {
|
mutateElement(container, {
|
||||||
boundElements: [{ type: "text", id: text.id }],
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
});
|
});
|
||||||
h.elements = [container, text];
|
API.setElements([container, text]);
|
||||||
|
|
||||||
API.setSelectedElements([container]);
|
API.setSelectedElements([container]);
|
||||||
const fontSize = getStatsProperty("F")?.querySelector(
|
const fontSize = UI.queryStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
editInput(fontSize, "40");
|
UI.updateInput(fontSize, "40");
|
||||||
|
|
||||||
expect(text.fontSize).toBe(40);
|
expect(text.fontSize).toBe(40);
|
||||||
});
|
});
|
||||||
@@ -533,7 +509,7 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@@ -566,7 +542,7 @@ describe("stats for multiple elements", () => {
|
|||||||
mouse.down(-100, -100);
|
mouse.down(-100, -100);
|
||||||
mouse.up(125, 145);
|
mouse.up(125, 145);
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
selectedElementIds: h.elements.reduce((acc, el) => {
|
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||||
acc[el.id] = true;
|
acc[el.id] = true;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -575,25 +551,25 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const width = getStatsProperty("W")?.querySelector(
|
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width?.value).toBe("Mixed");
|
expect(width?.value).toBe("Mixed");
|
||||||
const height = getStatsProperty("H")?.querySelector(
|
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height?.value).toBe("Mixed");
|
expect(height?.value).toBe("Mixed");
|
||||||
const angle = getStatsProperty("A")?.querySelector(
|
const angle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
editInput(width, "250");
|
UI.updateInput(width, "250");
|
||||||
h.elements.forEach((el) => {
|
h.elements.forEach((el) => {
|
||||||
expect(el.width).toBe(250);
|
expect(el.width).toBe(250);
|
||||||
});
|
});
|
||||||
|
|
||||||
editInput(height, "450");
|
UI.updateInput(height, "450");
|
||||||
h.elements.forEach((el) => {
|
h.elements.forEach((el) => {
|
||||||
expect(el.height).toBe(450);
|
expect(el.height).toBe(450);
|
||||||
});
|
});
|
||||||
@@ -605,9 +581,10 @@ describe("stats for multiple elements", () => {
|
|||||||
mouse.clickAt(20, 30);
|
mouse.clickAt(20, 30);
|
||||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello!");
|
updateTextEditor(editor, "Hello!");
|
||||||
|
act(() => {
|
||||||
editor.blur();
|
editor.blur();
|
||||||
|
});
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
@@ -619,12 +596,12 @@ describe("stats for multiple elements", () => {
|
|||||||
width: 150,
|
width: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [...h.elements, frame];
|
API.setElements([...h.elements, frame]);
|
||||||
|
|
||||||
const text = h.elements.find((el) => el.type === "text");
|
const text = h.elements.find((el) => el.type === "text");
|
||||||
const rectangle = h.elements.find((el) => el.type === "rectangle");
|
const rectangle = h.elements.find((el) => el.type === "rectangle");
|
||||||
|
|
||||||
h.setState({
|
API.setAppState({
|
||||||
selectedElementIds: h.elements.reduce((acc, el) => {
|
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||||
acc[el.id] = true;
|
acc[el.id] = true;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -633,37 +610,37 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const width = getStatsProperty("W")?.querySelector(
|
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).toBeDefined();
|
expect(width).toBeDefined();
|
||||||
expect(width.value).toBe("Mixed");
|
expect(width.value).toBe("Mixed");
|
||||||
|
|
||||||
const height = getStatsProperty("H")?.querySelector(
|
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).toBeDefined();
|
expect(height).toBeDefined();
|
||||||
expect(height.value).toBe("Mixed");
|
expect(height.value).toBe("Mixed");
|
||||||
|
|
||||||
const angle = getStatsProperty("A")?.querySelector(
|
const angle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle).toBeDefined();
|
expect(angle).toBeDefined();
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
const fontSize = getStatsProperty("F")?.querySelector(
|
const fontSize = UI.queryStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
// changing width does not affect text
|
// changing width does not affect text
|
||||||
editInput(width, "200");
|
UI.updateInput(width, "200");
|
||||||
|
|
||||||
expect(rectangle?.width).toBe(200);
|
expect(rectangle?.width).toBe(200);
|
||||||
expect(frame.width).toBe(200);
|
expect(frame.width).toBe(200);
|
||||||
expect(text?.width).not.toBe(200);
|
expect(text?.width).not.toBe(200);
|
||||||
|
|
||||||
editInput(angle, "40");
|
UI.updateInput(angle, "40");
|
||||||
|
|
||||||
const angleInRadian = degreeToRadian(40);
|
const angleInRadian = degreeToRadian(40);
|
||||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
@@ -686,7 +663,7 @@ describe("stats for multiple elements", () => {
|
|||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
h.app.actionManager.executeAction(actionGroup);
|
API.executeAction(actionGroup);
|
||||||
};
|
};
|
||||||
|
|
||||||
createAndSelectGroup();
|
createAndSelectGroup();
|
||||||
@@ -696,58 +673,58 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const x = getStatsProperty("X")?.querySelector(
|
const x = UI.queryStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(x).toBeDefined();
|
expect(x).toBeDefined();
|
||||||
expect(Number(x.value)).toBe(x1);
|
expect(Number(x.value)).toBe(x1);
|
||||||
|
|
||||||
editInput(x, "300");
|
UI.updateInput(x, "300");
|
||||||
|
|
||||||
expect(h.elements[0].x).toBe(300);
|
expect(h.elements[0].x).toBe(300);
|
||||||
expect(h.elements[1].x).toBe(400);
|
expect(h.elements[1].x).toBe(400);
|
||||||
expect(x.value).toBe("300");
|
expect(x.value).toBe("300");
|
||||||
|
|
||||||
const y = getStatsProperty("Y")?.querySelector(
|
const y = UI.queryStatsProperty("Y")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
expect(y).toBeDefined();
|
expect(y).toBeDefined();
|
||||||
expect(Number(y.value)).toBe(y1);
|
expect(Number(y.value)).toBe(y1);
|
||||||
|
|
||||||
editInput(y, "200");
|
UI.updateInput(y, "200");
|
||||||
|
|
||||||
expect(h.elements[0].y).toBe(200);
|
expect(h.elements[0].y).toBe(200);
|
||||||
expect(h.elements[1].y).toBe(300);
|
expect(h.elements[1].y).toBe(300);
|
||||||
expect(y.value).toBe("200");
|
expect(y.value).toBe("200");
|
||||||
|
|
||||||
const width = getStatsProperty("W")?.querySelector(
|
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).toBeDefined();
|
expect(width).toBeDefined();
|
||||||
expect(Number(width.value)).toBe(200);
|
expect(Number(width.value)).toBe(200);
|
||||||
|
|
||||||
const height = getStatsProperty("H")?.querySelector(
|
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).toBeDefined();
|
expect(height).toBeDefined();
|
||||||
expect(Number(height.value)).toBe(200);
|
expect(Number(height.value)).toBe(200);
|
||||||
|
|
||||||
editInput(width, "400");
|
UI.updateInput(width, "400");
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
let newGroupWidth = x2 - x1;
|
let newGroupWidth = x2 - x1;
|
||||||
|
|
||||||
expect(newGroupWidth).toBeCloseTo(400, 4);
|
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||||
|
|
||||||
editInput(width, "300");
|
UI.updateInput(width, "300");
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
newGroupWidth = x2 - x1;
|
newGroupWidth = x2 - x1;
|
||||||
expect(newGroupWidth).toBeCloseTo(300, 4);
|
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||||
|
|
||||||
editInput(height, "500");
|
UI.updateInput(height, "500");
|
||||||
|
|
||||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||||
const newGroupHeight = y2 - y1;
|
const newGroupHeight = y2 - y1;
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export type StatsInputProperty =
|
|||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "angle"
|
| "angle"
|
||||||
| "fontSize";
|
| "fontSize"
|
||||||
|
| "gridStep";
|
||||||
|
|
||||||
export const SMALLEST_DELTA = 0.01;
|
export const SMALLEST_DELTA = 0.01;
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ export const resizeElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBoundElements(latestElement, elementsMap, scene, {
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
oldSize: { width: oldWidth, height: oldHeight },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,6 +317,6 @@ export const updateBindings = (
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updateBoundElements(latestElement, elementsMap, scene, options);
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
|
|||||||
const TTDDialogTabs = (
|
const TTDDialogTabs = (
|
||||||
props: {
|
props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
} & (
|
} & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
|
||||||
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
|
|
||||||
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
|
|
||||||
),
|
|
||||||
) => {
|
) => {
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
props.dialog === "settings" &&
|
|
||||||
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
|
|
||||||
) {
|
|
||||||
setAppState({
|
|
||||||
openDialog: { name: props.dialog, tab, source: "settings" },
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
props.dialog === "ttd" &&
|
props.dialog === "ttd" &&
|
||||||
isMemberOf(["text-to-diagram", "mermaid"], tab)
|
isMemberOf(["text-to-diagram", "mermaid"], tab)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const getRelevantAppStateProps = (
|
|||||||
activeEmbeddable: appState.activeEmbeddable,
|
activeEmbeddable: appState.activeEmbeddable,
|
||||||
snapLines: appState.snapLines,
|
snapLines: appState.snapLines,
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
zenModeEnabled: appState.zenModeEnabled,
|
||||||
editingElement: appState.editingElement,
|
editingTextElement: appState.editingTextElement,
|
||||||
});
|
});
|
||||||
|
|
||||||
const areEqual = (
|
const areEqual = (
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { NonDeletedSceneElementsMap } from "../../element/types";
|
||||||
|
import type { AppState } from "../../types";
|
||||||
|
import type {
|
||||||
|
RenderableElementsMap,
|
||||||
|
StaticCanvasRenderConfig,
|
||||||
|
} from "../../scene/types";
|
||||||
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
|
import { renderNewElementScene } from "../../renderer/renderNewElementScene";
|
||||||
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
|
|
||||||
|
interface NewElementCanvasProps {
|
||||||
|
appState: AppState;
|
||||||
|
elementsMap: RenderableElementsMap;
|
||||||
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
|
scale: number;
|
||||||
|
rc: RoughCanvas;
|
||||||
|
renderConfig: StaticCanvasRenderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewElementCanvas = (props: NewElementCanvasProps) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderNewElementScene(
|
||||||
|
{
|
||||||
|
canvas: canvasRef.current,
|
||||||
|
scale: props.scale,
|
||||||
|
newElement: props.appState.newElement,
|
||||||
|
elementsMap: props.elementsMap,
|
||||||
|
allElementsMap: props.allElementsMap,
|
||||||
|
rc: props.rc,
|
||||||
|
renderConfig: props.renderConfig,
|
||||||
|
appState: props.appState,
|
||||||
|
},
|
||||||
|
isRenderThrottlingEnabled(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
className="excalidraw__canvas"
|
||||||
|
style={{
|
||||||
|
width: props.appState.width,
|
||||||
|
height: props.appState.height,
|
||||||
|
}}
|
||||||
|
width={props.appState.width * props.scale}
|
||||||
|
height={props.appState.height * props.scale}
|
||||||
|
ref={canvasRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewElementCanvas;
|
||||||
@@ -101,6 +101,7 @@ const getRelevantAppStateProps = (
|
|||||||
exportScale: appState.exportScale,
|
exportScale: appState.exportScale,
|
||||||
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
|
gridStep: appState.gridStep,
|
||||||
frameRendering: appState.frameRendering,
|
frameRendering: appState.frameRendering,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
frameToHighlight: appState.frameToHighlight,
|
frameToHighlight: appState.frameToHighlight,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
import { Excalidraw } from "../../index";
|
import { Excalidraw } from "../../index";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { Keyboard } from "../../tests/helpers/ui";
|
import { Keyboard } from "../../tests/helpers/ui";
|
||||||
import { render, waitFor, getByTestId } from "../../tests/test-utils";
|
import {
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
getByTestId,
|
||||||
|
fireEvent,
|
||||||
|
} from "../../tests/test-utils";
|
||||||
|
|
||||||
describe("Test <DropdownMenu/>", () => {
|
describe("Test <DropdownMenu/>", () => {
|
||||||
it("should", async () => {
|
it("should", async () => {
|
||||||
@@ -9,7 +15,7 @@ describe("Test <DropdownMenu/>", () => {
|
|||||||
|
|
||||||
expect(window.h.state.openMenu).toBe(null);
|
expect(window.h.state.openMenu).toBe(null);
|
||||||
|
|
||||||
getByTestId(container, "main-menu-trigger").click();
|
fireEvent.click(getByTestId(container, "main-menu-trigger"));
|
||||||
expect(window.h.state.openMenu).toBe("canvas");
|
expect(window.h.state.openMenu).toBe("canvas");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DropdownMenuItemLink = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
className = "",
|
className = "",
|
||||||
selected,
|
selected,
|
||||||
|
rel = "noreferrer",
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -22,6 +23,7 @@ const DropdownMenuItemLink = ({
|
|||||||
className?: string;
|
className?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
|
rel?: string;
|
||||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import { render, queryAllByTestId } from "../../tests/test-utils";
|
import { render, queryAllByTestId } from "../../tests/test-utils";
|
||||||
import { Excalidraw, MainMenu } from "../../index";
|
import { Excalidraw, MainMenu } from "../../index";
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export const Hyperlink = ({
|
|||||||
const { x, y } = getCoordsForPopover(element, appState, elementsMap);
|
const { x, y } = getCoordsForPopover(element, appState, elementsMap);
|
||||||
if (
|
if (
|
||||||
appState.contextMenu ||
|
appState.contextMenu ||
|
||||||
appState.draggingElement ||
|
appState.selectedElementsAreBeingDragged ||
|
||||||
appState.resizingElement ||
|
appState.resizingElement ||
|
||||||
appState.isRotating ||
|
appState.isRotating ||
|
||||||
appState.openMenu ||
|
appState.openMenu ||
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
|
|||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const DEFAULT_GRID_SIZE = 20;
|
||||||
|
export const DEFAULT_GRID_STEP = 5;
|
||||||
|
|
||||||
export const IMAGE_MIME_TYPES = {
|
export const IMAGE_MIME_TYPES = {
|
||||||
svg: "image/svg+xml",
|
svg: "image/svg+xml",
|
||||||
@@ -234,7 +235,7 @@ export const VERSION_TIMEOUT = 30000;
|
|||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
export const MIN_ZOOM = 0.1;
|
export const MIN_ZOOM = 0.1;
|
||||||
export const MAX_ZOOM = 30.0;
|
export const MAX_ZOOM = 30;
|
||||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||||
|
|
||||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
|
|||||||
@@ -129,8 +129,14 @@
|
|||||||
--color-muted-background-darker: var(--color-gray-100);
|
--color-muted-background-darker: var(--color-gray-100);
|
||||||
|
|
||||||
--color-promo: var(--color-primary);
|
--color-promo: var(--color-primary);
|
||||||
--color-success: #268029;
|
|
||||||
--color-success-lighter: #cafccc;
|
--color-success: #cafccc;
|
||||||
|
--color-success-darker: #bafabc;
|
||||||
|
--color-success-darkest: #a5eba8;
|
||||||
|
--color-success-text: #268029;
|
||||||
|
--color-success-contrast: #65bb6a;
|
||||||
|
--color-success-contrast-hover: #6bcf70;
|
||||||
|
--color-success-contrast-active: #6edf74;
|
||||||
|
|
||||||
--color-logo-icon: var(--color-primary);
|
--color-logo-icon: var(--color-primary);
|
||||||
--color-logo-text: #190064;
|
--color-logo-text: #190064;
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { THEME } from "../constants";
|
|
||||||
import type { Theme } from "../element/types";
|
|
||||||
import type { DataURL } from "../types";
|
|
||||||
import type { OpenAIInput, OpenAIOutput } from "./ai/types";
|
|
||||||
|
|
||||||
export type MagicCacheData =
|
|
||||||
| {
|
|
||||||
status: "pending";
|
|
||||||
}
|
|
||||||
| { status: "done"; html: string }
|
|
||||||
| {
|
|
||||||
status: "error";
|
|
||||||
message?: string;
|
|
||||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
|
||||||
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
|
||||||
|
|
||||||
YOU MUST FOLLOW FOLLOWING RULES:
|
|
||||||
|
|
||||||
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
|
||||||
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
|
||||||
- Inline JavaScript when needed
|
|
||||||
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
|
||||||
- Source images from Unsplash or create applicable placeholders
|
|
||||||
- Interpret annotations as intended vs literal UI
|
|
||||||
- Fill gaps using your expertise in UX and business logic
|
|
||||||
- generate primarily for desktop UI, but make it responsive.
|
|
||||||
- Use grid and flexbox wherever applicable.
|
|
||||||
- Convert the wireframe in its entirety, don't omit elements if possible.
|
|
||||||
|
|
||||||
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
|
||||||
|
|
||||||
Your goal is a production-ready prototype that brings the wireframes to life.
|
|
||||||
|
|
||||||
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
|
||||||
|
|
||||||
export async function diagramToHTML({
|
|
||||||
image,
|
|
||||||
apiKey,
|
|
||||||
text,
|
|
||||||
theme = THEME.LIGHT,
|
|
||||||
}: {
|
|
||||||
image: DataURL;
|
|
||||||
apiKey: string;
|
|
||||||
text: string;
|
|
||||||
theme?: Theme;
|
|
||||||
}) {
|
|
||||||
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
|
||||||
model: "gpt-4-vision-preview",
|
|
||||||
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
|
||||||
max_tokens: 4096,
|
|
||||||
temperature: 0.1,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: SYSTEM_PROMPT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url: image,
|
|
||||||
detail: "high",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let result:
|
|
||||||
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
|
||||||
| ({ ok: false } & OpenAIOutput.APIError);
|
|
||||||
|
|
||||||
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.ok) {
|
|
||||||
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
|
||||||
result = { ...json, ok: true };
|
|
||||||
} else {
|
|
||||||
const json: OpenAIOutput.APIError = await resp.json();
|
|
||||||
result = { ...json, ok: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -24,9 +24,9 @@ const shouldDiscardRemoteElement = (
|
|||||||
if (
|
if (
|
||||||
local &&
|
local &&
|
||||||
// local element is being edited
|
// local element is being edited
|
||||||
(local.id === localAppState.editingElement?.id ||
|
(local.id === localAppState.editingTextElement?.id ||
|
||||||
local.id === localAppState.resizingElement?.id ||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array
|
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||||
// local element is newer
|
// local element is newer
|
||||||
local.version > remote.version ||
|
local.version > remote.version ||
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
// resolve conflicting edits deterministically by taking the one with
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import type {
|
|||||||
PointBinding,
|
PointBinding,
|
||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import type {
|
import type { AppState, BinaryFiles, LibraryItem } from "../types";
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
LibraryItem,
|
|
||||||
NormalizedZoomValue,
|
|
||||||
} from "../types";
|
|
||||||
import type { ImportedDataState, LegacyAppState } from "./types";
|
import type { ImportedDataState, LegacyAppState } from "./types";
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
@@ -39,11 +34,17 @@ import {
|
|||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
|
DEFAULT_GRID_SIZE,
|
||||||
|
DEFAULT_GRID_STEP,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import {
|
||||||
|
getUpdatedTimestamp,
|
||||||
|
isFiniteNumber,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { MarkOptional, Mutable } from "../utility-types";
|
import type { MarkOptional, Mutable } from "../utility-types";
|
||||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||||
@@ -51,6 +52,12 @@ import { normalizeLink } from "./url";
|
|||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
|
import { normalizeFixedPoint } from "../element/binding";
|
||||||
|
import {
|
||||||
|
getNormalizedGridSize,
|
||||||
|
getNormalizedGridStep,
|
||||||
|
getNormalizedZoom,
|
||||||
|
} from "../scene";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -106,7 +113,7 @@ const repairBinding = (
|
|||||||
...binding,
|
...binding,
|
||||||
focus: binding.focus || 0,
|
focus: binding.focus || 0,
|
||||||
fixedPoint: isElbowArrow(element)
|
fixedPoint: isElbowArrow(element)
|
||||||
? binding.fixedPoint ?? ([0, 0] as [number, number])
|
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -613,19 +620,24 @@ export const restoreAppState = (
|
|||||||
locked: nextAppState.activeTool.locked ?? false,
|
locked: nextAppState.activeTool.locked ?? false,
|
||||||
},
|
},
|
||||||
// Migrates from previous version where appState.zoom was a number
|
// Migrates from previous version where appState.zoom was a number
|
||||||
zoom:
|
zoom: {
|
||||||
typeof appState.zoom === "number"
|
value: getNormalizedZoom(
|
||||||
? {
|
isFiniteNumber(appState.zoom)
|
||||||
value: appState.zoom as NormalizedZoomValue,
|
|
||||||
}
|
|
||||||
: appState.zoom?.value
|
|
||||||
? appState.zoom
|
? appState.zoom
|
||||||
: defaultAppState.zoom,
|
: appState.zoom?.value ?? defaultAppState.zoom.value,
|
||||||
|
),
|
||||||
|
},
|
||||||
openSidebar:
|
openSidebar:
|
||||||
// string (legacy)
|
// string (legacy)
|
||||||
typeof (appState.openSidebar as any as string) === "string"
|
typeof (appState.openSidebar as any as string) === "string"
|
||||||
? { name: DEFAULT_SIDEBAR.name }
|
? { name: DEFAULT_SIDEBAR.name }
|
||||||
: nextAppState.openSidebar,
|
: nextAppState.openSidebar,
|
||||||
|
gridSize: getNormalizedGridSize(
|
||||||
|
isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
|
||||||
|
),
|
||||||
|
gridStep: getNormalizedGridStep(
|
||||||
|
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const ElementCanvasButtons = ({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
appState.contextMenu ||
|
appState.contextMenu ||
|
||||||
appState.draggingElement ||
|
appState.newElement ||
|
||||||
appState.resizingElement ||
|
appState.resizingElement ||
|
||||||
appState.isRotating ||
|
appState.isRotating ||
|
||||||
appState.openMenu ||
|
appState.openMenu ||
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPoint,
|
FixedPoint,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isRectangularElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@@ -70,6 +72,7 @@ import {
|
|||||||
vectorToHeading,
|
vectorToHeading,
|
||||||
type Heading,
|
type Heading,
|
||||||
} from "./heading";
|
} from "./heading";
|
||||||
|
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
| NonDeleted<ExcalidrawBindableElement>
|
| NonDeleted<ExcalidrawBindableElement>
|
||||||
@@ -124,7 +127,6 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
linearElement,
|
linearElement,
|
||||||
@@ -134,7 +136,6 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
@@ -161,7 +162,6 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
// Is mutated
|
// Is mutated
|
||||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
scene: Scene,
|
|
||||||
): void => {
|
): void => {
|
||||||
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
||||||
if (bindableElement === "keep") {
|
if (bindableElement === "keep") {
|
||||||
@@ -571,8 +571,7 @@ const calculateFocusAndGap = (
|
|||||||
// in explicitly.
|
// in explicitly.
|
||||||
export const updateBoundElements = (
|
export const updateBoundElements = (
|
||||||
changedElement: NonDeletedExcalidrawElement,
|
changedElement: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
scene: Scene,
|
|
||||||
options?: {
|
options?: {
|
||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
oldSize?: { width: number; height: number };
|
oldSize?: { width: number; height: number };
|
||||||
@@ -658,7 +657,7 @@ export const updateBoundElements = (
|
|||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
updates,
|
updates,
|
||||||
scene,
|
elementsMap,
|
||||||
{
|
{
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
? { startBinding: bindings.startBinding }
|
? { startBinding: bindings.startBinding }
|
||||||
@@ -754,7 +753,8 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
const aabb = bindableElement && aabbForElement(bindableElement);
|
const aabb = bindableElement && aabbForElement(bindableElement);
|
||||||
|
|
||||||
if (bindableElement && aabb) {
|
if (bindableElement && aabb) {
|
||||||
// TODO: Dirty hack until tangents are properly calculated
|
// TODO: Dirty hacks until tangents are properly calculated
|
||||||
|
const heading = headingForPointFromElement(bindableElement, aabb, point);
|
||||||
const intersections = [
|
const intersections = [
|
||||||
...intersectElementWithLine(
|
...intersectElementWithLine(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
@@ -770,18 +770,14 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
].map((i) =>
|
];
|
||||||
distanceToBindableElement(bindableElement, i, elementsMap) >
|
|
||||||
Math.min(bindableElement.width, bindableElement.height) / 2
|
|
||||||
? ([-1 * i[0], -1 * i[1]] as Point)
|
|
||||||
: i,
|
|
||||||
);
|
|
||||||
|
|
||||||
const heading = headingForPointFromElement(bindableElement, aabb, point);
|
|
||||||
const isVertical =
|
const isVertical =
|
||||||
compareHeading(heading, HEADING_LEFT) ||
|
compareHeading(heading, HEADING_LEFT) ||
|
||||||
compareHeading(heading, HEADING_RIGHT);
|
compareHeading(heading, HEADING_RIGHT);
|
||||||
const dist = distanceToBindableElement(bindableElement, point, elementsMap);
|
const dist = Math.abs(
|
||||||
|
distanceToBindableElement(bindableElement, point, elementsMap),
|
||||||
|
);
|
||||||
const isInner = isVertical
|
const isInner = isVertical
|
||||||
? dist < bindableElement.width * -0.1
|
? dist < bindableElement.width * -0.1
|
||||||
: dist < bindableElement.height * -0.1;
|
: dist < bindableElement.height * -0.1;
|
||||||
@@ -1003,7 +999,7 @@ const updateBoundPoint = (
|
|||||||
|
|
||||||
if (isElbowArrow(linearElement)) {
|
if (isElbowArrow(linearElement)) {
|
||||||
const fixedPoint =
|
const fixedPoint =
|
||||||
binding.fixedPoint ??
|
normalizeFixedPoint(binding.fixedPoint) ??
|
||||||
calculateFixedPointForElbowArrowBinding(
|
calculateFixedPointForElbowArrowBinding(
|
||||||
linearElement,
|
linearElement,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
@@ -1116,12 +1112,12 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
) as Point;
|
) as Point;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fixedPoint: [
|
fixedPoint: normalizeFixedPoint([
|
||||||
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
|
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
|
||||||
hoveredElement.width,
|
hoveredElement.width,
|
||||||
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
|
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
|
||||||
hoveredElement.height,
|
hoveredElement.height,
|
||||||
] as [number, number],
|
]),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1246,11 +1242,11 @@ export const fixBindingsAfterDuplication = (
|
|||||||
.filter(({ id }) => allBindableElementIds.has(id))
|
.filter(({ id }) => allBindableElementIds.has(id))
|
||||||
.forEach((bindableElement) => {
|
.forEach((bindableElement) => {
|
||||||
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
||||||
const { boundElements } = sceneElements.find(
|
const boundElements = sceneElements.find(
|
||||||
({ id }) => id === oldElementId,
|
({ id }) => id === oldElementId,
|
||||||
)!;
|
)?.boundElements;
|
||||||
|
|
||||||
if (boundElements != null && boundElements.length > 0) {
|
if (boundElements && boundElements.length > 0) {
|
||||||
mutateElement(bindableElement, {
|
mutateElement(bindableElement, {
|
||||||
boundElements: boundElements.map((boundElement) =>
|
boundElements: boundElements.map((boundElement) =>
|
||||||
oldIdToDuplicatedId.has(boundElement.id)
|
oldIdToDuplicatedId.has(boundElement.id)
|
||||||
@@ -1608,6 +1604,10 @@ const intersectElementWithLine = (
|
|||||||
gap: number = 0,
|
gap: number = 0,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point[] => {
|
): Point[] => {
|
||||||
|
if (isRectangularElement(element)) {
|
||||||
|
return segmentIntersectRectangleElement(element, [a, b], gap);
|
||||||
|
}
|
||||||
|
|
||||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||||
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||||
@@ -2174,7 +2174,8 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
fixedPointRatio: [number, number],
|
fixedPointRatio: [number, number],
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
) => {
|
) => {
|
||||||
const [fixedX, fixedY] = fixedPointRatio;
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
return rotatePoint(
|
return rotatePoint(
|
||||||
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
||||||
getCenterForElement(element),
|
getCenterForElement(element),
|
||||||
@@ -2228,3 +2229,16 @@ export const getArrowLocalFixedPoints = (
|
|||||||
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
|
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeFixedPoint = <T extends FixedPoint | null>(
|
||||||
|
fixedPoint: T,
|
||||||
|
): T extends null ? null : FixedPoint => {
|
||||||
|
// Do not allow a precise 0.5 for fixed point ratio
|
||||||
|
// to avoid jumping arrow heading due to floating point imprecision
|
||||||
|
if (fixedPoint && (fixedPoint[0] === 0.5 || fixedPoint[1] === 0.5)) {
|
||||||
|
return fixedPoint.map((ratio) =>
|
||||||
|
ratio === 0.5 ? 0.5001 : ratio,
|
||||||
|
) as T extends null ? null : FixedPoint;
|
||||||
|
}
|
||||||
|
return fixedPoint as any as T extends null ? null : FixedPoint;
|
||||||
|
};
|
||||||
|
|||||||
@@ -738,6 +738,7 @@ export const getElementBounds = (
|
|||||||
|
|
||||||
export const getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
elementsMap?: ElementsMap,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
if (!elements.length) {
|
if (!elements.length) {
|
||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
@@ -748,10 +749,10 @@ export const getCommonBounds = (
|
|||||||
let minY = Infinity;
|
let minY = Infinity;
|
||||||
let maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
|
|
||||||
const elementsMap = arrayToMap(elements);
|
const _elementsMap = elementsMap || arrayToMap(elements);
|
||||||
|
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
|
||||||
minX = Math.min(minX, x1);
|
minX = Math.min(minX, x1);
|
||||||
minY = Math.min(minY, y1);
|
minY = Math.min(minY, y1);
|
||||||
maxX = Math.max(maxX, x2);
|
maxX = Math.max(maxX, x2);
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { getCommonBounds } from "./bounds";
|
|||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import type { NonDeletedExcalidrawElement } from "./types";
|
import type { NonDeletedExcalidrawElement } from "./types";
|
||||||
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
|
import type {
|
||||||
|
AppState,
|
||||||
|
NormalizedZoomValue,
|
||||||
|
NullableGridSize,
|
||||||
|
PointerDownState,
|
||||||
|
} from "../types";
|
||||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||||
import { getGridPoint } from "../math";
|
import { getGridPoint } from "../math";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
@@ -26,7 +31,7 @@ export const dragSelectedElements = (
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
},
|
},
|
||||||
gridSize: AppState["gridSize"],
|
gridSize: NullableGridSize,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
_selectedElements.length === 1 &&
|
_selectedElements.length === 1 &&
|
||||||
@@ -91,14 +96,9 @@ export const dragSelectedElements = (
|
|||||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateBoundElements(
|
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||||
element,
|
|
||||||
scene.getElementsMapIncludingDeleted(),
|
|
||||||
scene,
|
|
||||||
{
|
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ const calculateOffset = (
|
|||||||
commonBounds: Bounds,
|
commonBounds: Bounds,
|
||||||
dragOffset: { x: number; y: number },
|
dragOffset: { x: number; y: number },
|
||||||
snapOffset: { x: number; y: number },
|
snapOffset: { x: number; y: number },
|
||||||
gridSize: AppState["gridSize"],
|
gridSize: NullableGridSize,
|
||||||
): { x: number; y: number } => {
|
): { x: number; y: number } => {
|
||||||
const [x, y] = commonBounds;
|
const [x, y] = commonBounds;
|
||||||
let nextX = x + dragOffset.x + snapOffset.x;
|
let nextX = x + dragOffset.x + snapOffset.x;
|
||||||
@@ -159,27 +159,43 @@ export const getDragOffsetXY = (
|
|||||||
return [x - x1, y - y1];
|
return [x - x1, y - y1];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dragNewElement = (
|
export const dragNewElement = ({
|
||||||
draggingElement: NonDeletedExcalidrawElement,
|
newElement,
|
||||||
elementType: AppState["activeTool"]["type"],
|
elementType,
|
||||||
originX: number,
|
originX,
|
||||||
originY: number,
|
originY,
|
||||||
x: number,
|
x,
|
||||||
y: number,
|
y,
|
||||||
width: number,
|
width,
|
||||||
height: number,
|
height,
|
||||||
shouldMaintainAspectRatio: boolean,
|
shouldMaintainAspectRatio,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter,
|
||||||
zoom: NormalizedZoomValue,
|
zoom,
|
||||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
widthAspectRatio = null,
|
||||||
true */
|
originOffset = null,
|
||||||
widthAspectRatio?: number | null,
|
informMutation = true,
|
||||||
originOffset: {
|
}: {
|
||||||
|
newElement: NonDeletedExcalidrawElement;
|
||||||
|
elementType: AppState["activeTool"]["type"];
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
} | null = null,
|
width: number;
|
||||||
) => {
|
height: number;
|
||||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
shouldMaintainAspectRatio: boolean;
|
||||||
|
shouldResizeFromCenter: boolean;
|
||||||
|
zoom: NormalizedZoomValue;
|
||||||
|
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||||
|
true */
|
||||||
|
widthAspectRatio?: number | null;
|
||||||
|
originOffset?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null;
|
||||||
|
informMutation?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
|
||||||
if (widthAspectRatio) {
|
if (widthAspectRatio) {
|
||||||
height = width / widthAspectRatio;
|
height = width / widthAspectRatio;
|
||||||
} else {
|
} else {
|
||||||
@@ -218,17 +234,14 @@ export const dragNewElement = (
|
|||||||
|
|
||||||
let textAutoResize = null;
|
let textAutoResize = null;
|
||||||
|
|
||||||
// NOTE this should apply only to creating text elements, not existing
|
if (isTextElement(newElement)) {
|
||||||
// (once we rewrite appState.draggingElement to actually mean dragging
|
height = newElement.height;
|
||||||
// elements)
|
|
||||||
if (isTextElement(draggingElement)) {
|
|
||||||
height = draggingElement.height;
|
|
||||||
const minWidth = getMinTextElementWidth(
|
const minWidth = getMinTextElementWidth(
|
||||||
getFontString({
|
getFontString({
|
||||||
fontSize: draggingElement.fontSize,
|
fontSize: newElement.fontSize,
|
||||||
fontFamily: draggingElement.fontFamily,
|
fontFamily: newElement.fontFamily,
|
||||||
}),
|
}),
|
||||||
draggingElement.lineHeight,
|
newElement.lineHeight,
|
||||||
);
|
);
|
||||||
width = Math.max(width, minWidth);
|
width = Math.max(width, minWidth);
|
||||||
|
|
||||||
@@ -245,12 +258,16 @@ export const dragNewElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (width !== 0 && height !== 0) {
|
if (width !== 0 && height !== 0) {
|
||||||
mutateElement(draggingElement, {
|
mutateElement(
|
||||||
|
newElement,
|
||||||
|
{
|
||||||
x: newX + (originOffset?.x ?? 0),
|
x: newX + (originOffset?.x ?? 0),
|
||||||
y: newY + (originOffset?.y ?? 0),
|
y: newY + (originOffset?.y ?? 0),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
...textAutoResize,
|
...textAutoResize,
|
||||||
});
|
},
|
||||||
|
informMutation,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { render } from "../tests/test-utils";
|
||||||
|
import { reseed } from "../random";
|
||||||
|
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
reseed(7);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
h.state.width = 1000;
|
||||||
|
h.state.height = 1000;
|
||||||
|
|
||||||
|
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||||
|
// removing this style for testing
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickByTitle("Architect");
|
||||||
|
UI.clickTool("selection");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flow chart creation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// multiple at once
|
||||||
|
it("create multiple successor nodes at once", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when directions are changed, only the last same directions will apply", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(7);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when escaped, no nodes will be created", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ESCAPE);
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create nodes one at a time", () => {
|
||||||
|
const initialNode = h.elements[0];
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||||
|
|
||||||
|
const firstChildNode = h.elements.filter(
|
||||||
|
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||||
|
)[0];
|
||||||
|
expect(firstChildNode).not.toBe(null);
|
||||||
|
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
API.setSelectedElements([initialNode]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||||
|
|
||||||
|
const secondChildNode = h.elements.filter(
|
||||||
|
(el) =>
|
||||||
|
el.type === "rectangle" &&
|
||||||
|
el.id !== initialNode.id &&
|
||||||
|
el.id !== firstChildNode.id,
|
||||||
|
)[0];
|
||||||
|
expect(secondChildNode).not.toBe(null);
|
||||||
|
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
API.setSelectedElements([initialNode]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(7);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||||
|
|
||||||
|
const thirdChildNode = h.elements.filter(
|
||||||
|
(el) =>
|
||||||
|
el.type === "rectangle" &&
|
||||||
|
el.id !== initialNode.id &&
|
||||||
|
el.id !== firstChildNode.id &&
|
||||||
|
el.id !== secondChildNode.id,
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
expect(thirdChildNode).not.toBe(null);
|
||||||
|
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||||
|
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flow chart navigation", () => {
|
||||||
|
it("single node at each level", () => {
|
||||||
|
/**
|
||||||
|
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||||
|
|
||||||
|
// all the way to the left, gets us to the first node
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
|
||||||
|
// all the way to the right, gets us to the last node
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
expect(rightMostNode);
|
||||||
|
expect(rightMostNode.type).toBe("rectangle");
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple nodes at each level", () => {
|
||||||
|
/**
|
||||||
|
* from the perspective of the first node, there're four layers, and
|
||||||
|
* there are four nodes at the second layer
|
||||||
|
*
|
||||||
|
* -> ▨
|
||||||
|
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||||
|
* -> ▨
|
||||||
|
* -> ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
const secondNode = h.elements[1];
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
// because of same level cycling,
|
||||||
|
// going right five times should take us back to the second node again
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||||
|
|
||||||
|
// from the second node, going right three times should take us to the rightmost node
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("take the most obvious link when possible", () => {
|
||||||
|
/**
|
||||||
|
* ▨ → ▨ ▨ → ▨
|
||||||
|
* ↓ ↑
|
||||||
|
* ▨ → ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
// last node should be the one that's selected
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
expect(rightMostNode.type).toBe("rectangle");
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
|
||||||
|
// going any direction takes us to the predecessor as well
|
||||||
|
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||||
|
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||||
|
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
import {
|
||||||
|
HEADING_DOWN,
|
||||||
|
HEADING_LEFT,
|
||||||
|
HEADING_RIGHT,
|
||||||
|
HEADING_UP,
|
||||||
|
compareHeading,
|
||||||
|
headingForPointFromElement,
|
||||||
|
type Heading,
|
||||||
|
} from "./heading";
|
||||||
|
import { bindLinearElement } from "./binding";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
|
import { aabbForElement } from "../math";
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFlowchartNodeElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "./types";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import type { AppState, PendingExcalidrawElements, Point } from "../types";
|
||||||
|
import { mutateElement } from "./mutateElement";
|
||||||
|
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||||
|
import {
|
||||||
|
isBindableElement,
|
||||||
|
isElbowArrow,
|
||||||
|
isFrameElement,
|
||||||
|
isFlowchartNodeElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
import { invariant } from "../utils";
|
||||||
|
|
||||||
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
|
const VERTICAL_OFFSET = 100;
|
||||||
|
const HORIZONTAL_OFFSET = 100;
|
||||||
|
|
||||||
|
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
|
||||||
|
switch (key) {
|
||||||
|
case KEYS.ARROW_UP:
|
||||||
|
return "up";
|
||||||
|
case KEYS.ARROW_DOWN:
|
||||||
|
return "down";
|
||||||
|
case KEYS.ARROW_RIGHT:
|
||||||
|
return "right";
|
||||||
|
case KEYS.ARROW_LEFT:
|
||||||
|
return "left";
|
||||||
|
default:
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeRelatives = (
|
||||||
|
type: "predecessors" | "successors",
|
||||||
|
node: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
direction: LinkDirection,
|
||||||
|
) => {
|
||||||
|
const items = [...elementsMap.values()].reduce(
|
||||||
|
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
|
||||||
|
let oppositeBinding;
|
||||||
|
if (
|
||||||
|
isElbowArrow(el) &&
|
||||||
|
// we want check existence of the opposite binding, in the direction
|
||||||
|
// we're interested in
|
||||||
|
(oppositeBinding =
|
||||||
|
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||||
|
// similarly, we need to filter only arrows bound to target node
|
||||||
|
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||||
|
?.elementId === node.id
|
||||||
|
) {
|
||||||
|
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||||
|
|
||||||
|
if (!relative) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
isBindableElement(relative),
|
||||||
|
"not an ExcalidrawBindableElement",
|
||||||
|
);
|
||||||
|
|
||||||
|
const edgePoint: Point =
|
||||||
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
|
||||||
|
|
||||||
|
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||||
|
edgePoint[0] + el.x,
|
||||||
|
edgePoint[1] + el.y,
|
||||||
|
]);
|
||||||
|
|
||||||
|
acc.push({
|
||||||
|
relative,
|
||||||
|
heading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case "up":
|
||||||
|
return items
|
||||||
|
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||||
|
.map((item) => item.relative);
|
||||||
|
case "down":
|
||||||
|
return items
|
||||||
|
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||||
|
.map((item) => item.relative);
|
||||||
|
case "right":
|
||||||
|
return items
|
||||||
|
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||||
|
.map((item) => item.relative);
|
||||||
|
case "left":
|
||||||
|
return items
|
||||||
|
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||||
|
.map((item) => item.relative);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuccessors = (
|
||||||
|
node: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
direction: LinkDirection,
|
||||||
|
) => {
|
||||||
|
return getNodeRelatives("successors", node, elementsMap, direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPredecessors = (
|
||||||
|
node: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
direction: LinkDirection,
|
||||||
|
) => {
|
||||||
|
return getNodeRelatives("predecessors", node, elementsMap, direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOffsets = (
|
||||||
|
element: ExcalidrawFlowchartNodeElement,
|
||||||
|
linkedNodes: ExcalidrawElement[],
|
||||||
|
direction: LinkDirection,
|
||||||
|
) => {
|
||||||
|
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
|
||||||
|
|
||||||
|
// check if vertical space or horizontal space is available first
|
||||||
|
if (direction === "up" || direction === "down") {
|
||||||
|
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||||
|
// check vertical space
|
||||||
|
const minX = element.x;
|
||||||
|
const maxX = element.x + element.width;
|
||||||
|
|
||||||
|
// vertical space is available
|
||||||
|
if (
|
||||||
|
linkedNodes.every(
|
||||||
|
(linkedNode) =>
|
||||||
|
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (direction === "right" || direction === "left") {
|
||||||
|
const minY = element.y;
|
||||||
|
const maxY = element.y + element.height;
|
||||||
|
|
||||||
|
if (
|
||||||
|
linkedNodes.every(
|
||||||
|
(linkedNode) =>
|
||||||
|
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
x:
|
||||||
|
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "up" || direction === "down") {
|
||||||
|
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||||
|
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
|
||||||
|
const x =
|
||||||
|
linkedNodes.length === 0
|
||||||
|
? 0
|
||||||
|
: (linkedNodes.length + 1) % 2 === 0
|
||||||
|
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
|
||||||
|
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
|
||||||
|
|
||||||
|
if (direction === "up") {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y: y * -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||||
|
const x =
|
||||||
|
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
|
||||||
|
element.width;
|
||||||
|
const y =
|
||||||
|
linkedNodes.length === 0
|
||||||
|
? 0
|
||||||
|
: (linkedNodes.length + 1) % 2 === 0
|
||||||
|
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
|
||||||
|
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
|
||||||
|
|
||||||
|
if (direction === "left") {
|
||||||
|
return {
|
||||||
|
x: x * -1,
|
||||||
|
y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewNode = (
|
||||||
|
element: ExcalidrawFlowchartNodeElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
direction: LinkDirection,
|
||||||
|
) => {
|
||||||
|
const successors = getSuccessors(element, elementsMap, direction);
|
||||||
|
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||||
|
|
||||||
|
const offsets = getOffsets(
|
||||||
|
element,
|
||||||
|
[...successors, ...predeccessors],
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextNode = newElement({
|
||||||
|
type: element.type,
|
||||||
|
x: element.x + offsets.x,
|
||||||
|
y: element.y + offsets.y,
|
||||||
|
// TODO: extract this to a util
|
||||||
|
width: element.width,
|
||||||
|
height: element.height,
|
||||||
|
roundness: element.roundness,
|
||||||
|
roughness: element.roughness,
|
||||||
|
backgroundColor: element.backgroundColor,
|
||||||
|
strokeColor: element.strokeColor,
|
||||||
|
strokeWidth: element.strokeWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
isFlowchartNodeElement(nextNode),
|
||||||
|
"not an ExcalidrawFlowchartNodeElement",
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindingArrow = createBindingArrow(
|
||||||
|
element,
|
||||||
|
nextNode,
|
||||||
|
elementsMap,
|
||||||
|
direction,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextNode,
|
||||||
|
bindingArrow,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addNewNodes = (
|
||||||
|
startNode: ExcalidrawFlowchartNodeElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
direction: LinkDirection,
|
||||||
|
numberOfNodes: number,
|
||||||
|
) => {
|
||||||
|
// always start from 0 and distribute evenly
|
||||||
|
const newNodes: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfNodes; i++) {
|
||||||
|
let nextX: number;
|
||||||
|
let nextY: number;
|
||||||
|
if (direction === "left" || direction === "right") {
|
||||||
|
const totalHeight =
|
||||||
|
VERTICAL_OFFSET * (numberOfNodes - 1) +
|
||||||
|
numberOfNodes * startNode.height;
|
||||||
|
|
||||||
|
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
|
||||||
|
|
||||||
|
let offsetX = HORIZONTAL_OFFSET + startNode.width;
|
||||||
|
if (direction === "left") {
|
||||||
|
offsetX *= -1;
|
||||||
|
}
|
||||||
|
nextX = startNode.x + offsetX;
|
||||||
|
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
|
||||||
|
nextY = startY + offsetY;
|
||||||
|
} else {
|
||||||
|
const totalWidth =
|
||||||
|
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
|
||||||
|
numberOfNodes * startNode.width;
|
||||||
|
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
|
||||||
|
let offsetY = VERTICAL_OFFSET + startNode.height;
|
||||||
|
|
||||||
|
if (direction === "up") {
|
||||||
|
offsetY *= -1;
|
||||||
|
}
|
||||||
|
nextY = startNode.y + offsetY;
|
||||||
|
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
|
||||||
|
nextX = startX + offsetX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNode = newElement({
|
||||||
|
type: startNode.type,
|
||||||
|
x: nextX,
|
||||||
|
y: nextY,
|
||||||
|
// TODO: extract this to a util
|
||||||
|
width: startNode.width,
|
||||||
|
height: startNode.height,
|
||||||
|
roundness: startNode.roundness,
|
||||||
|
roughness: startNode.roughness,
|
||||||
|
backgroundColor: startNode.backgroundColor,
|
||||||
|
strokeColor: startNode.strokeColor,
|
||||||
|
strokeWidth: startNode.strokeWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
isFlowchartNodeElement(nextNode),
|
||||||
|
"not an ExcalidrawFlowchartNodeElement",
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindingArrow = createBindingArrow(
|
||||||
|
startNode,
|
||||||
|
nextNode,
|
||||||
|
elementsMap,
|
||||||
|
direction,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
newNodes.push(nextNode);
|
||||||
|
newNodes.push(bindingArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBindingArrow = (
|
||||||
|
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||||
|
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
direction: LinkDirection,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
let startX: number;
|
||||||
|
let startY: number;
|
||||||
|
|
||||||
|
const PADDING = 6;
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case "up": {
|
||||||
|
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||||
|
startY = startBindingElement.y - PADDING;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "down": {
|
||||||
|
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||||
|
startY = startBindingElement.y + startBindingElement.height + PADDING;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "right": {
|
||||||
|
startX = startBindingElement.x + startBindingElement.width + PADDING;
|
||||||
|
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "left": {
|
||||||
|
startX = startBindingElement.x - PADDING;
|
||||||
|
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let endX: number;
|
||||||
|
let endY: number;
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case "up": {
|
||||||
|
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||||
|
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "down": {
|
||||||
|
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||||
|
endY = endBindingElement.y - startY - PADDING;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "right": {
|
||||||
|
endX = endBindingElement.x - startX - PADDING;
|
||||||
|
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "left": {
|
||||||
|
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
|
||||||
|
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingArrow = newArrowElement({
|
||||||
|
type: "arrow",
|
||||||
|
x: startX,
|
||||||
|
y: startY,
|
||||||
|
startArrowhead: appState.currentItemStartArrowhead,
|
||||||
|
endArrowhead: appState.currentItemEndArrowhead,
|
||||||
|
strokeColor: appState.currentItemStrokeColor,
|
||||||
|
strokeStyle: appState.currentItemStrokeStyle,
|
||||||
|
strokeWidth: appState.currentItemStrokeWidth,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[endX, endY],
|
||||||
|
],
|
||||||
|
elbowed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
bindLinearElement(
|
||||||
|
bindingArrow,
|
||||||
|
startBindingElement,
|
||||||
|
"start",
|
||||||
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
|
);
|
||||||
|
bindLinearElement(
|
||||||
|
bindingArrow,
|
||||||
|
endBindingElement,
|
||||||
|
"end",
|
||||||
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
|
changedElements.set(
|
||||||
|
startBindingElement.id,
|
||||||
|
startBindingElement as OrderedExcalidrawElement,
|
||||||
|
);
|
||||||
|
changedElements.set(
|
||||||
|
endBindingElement.id,
|
||||||
|
endBindingElement as OrderedExcalidrawElement,
|
||||||
|
);
|
||||||
|
changedElements.set(
|
||||||
|
bindingArrow.id,
|
||||||
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
LinearElementEditor.movePoints(
|
||||||
|
bindingArrow,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
point: bindingArrow.points[1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
changedElements,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return bindingArrow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FlowChartNavigator {
|
||||||
|
isExploring: boolean = false;
|
||||||
|
// nodes that are ONE link away (successor and predecessor both included)
|
||||||
|
private sameLevelNodes: ExcalidrawElement[] = [];
|
||||||
|
private sameLevelIndex: number = 0;
|
||||||
|
// set it to the opposite of the defalut creation direction
|
||||||
|
private direction: LinkDirection | null = null;
|
||||||
|
// for speedier navigation
|
||||||
|
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.isExploring = false;
|
||||||
|
this.sameLevelNodes = [];
|
||||||
|
this.sameLevelIndex = 0;
|
||||||
|
this.direction = null;
|
||||||
|
this.visitedNodes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
exploreByDirection(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
direction: LinkDirection,
|
||||||
|
): ExcalidrawElement["id"] | null {
|
||||||
|
if (!isBindableElement(element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear if going at a different direction
|
||||||
|
if (direction !== this.direction) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the current node to the visited
|
||||||
|
if (!this.visitedNodes.has(element.id)) {
|
||||||
|
this.visitedNodes.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CASE:
|
||||||
|
* - already started exploring, AND
|
||||||
|
* - there are multiple nodes at the same level, AND
|
||||||
|
* - still going at the same direction, AND
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - loop through nodes at the same level
|
||||||
|
*
|
||||||
|
* WHY:
|
||||||
|
* - provides user the capability to loop through nodes at the same level
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
this.isExploring &&
|
||||||
|
direction === this.direction &&
|
||||||
|
this.sameLevelNodes.length > 1
|
||||||
|
) {
|
||||||
|
this.sameLevelIndex =
|
||||||
|
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
||||||
|
|
||||||
|
return this.sameLevelNodes[this.sameLevelIndex].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
...getSuccessors(element, elementsMap, direction),
|
||||||
|
...getPredecessors(element, elementsMap, direction),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CASE:
|
||||||
|
* - just started exploring at the given direction
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - go to the first node in the given direction
|
||||||
|
*/
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
this.sameLevelIndex = 0;
|
||||||
|
this.isExploring = true;
|
||||||
|
this.sameLevelNodes = nodes;
|
||||||
|
this.direction = direction;
|
||||||
|
this.visitedNodes.add(nodes[0].id);
|
||||||
|
|
||||||
|
return nodes[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CASE:
|
||||||
|
* - (just started exploring or still going at the same direction) OR
|
||||||
|
* - there're no nodes at the given direction
|
||||||
|
*
|
||||||
|
* RESULT:
|
||||||
|
* - go to some other unvisited linked node
|
||||||
|
*
|
||||||
|
* WHY:
|
||||||
|
* - provide a speedier navigation from a given node to some predecessor
|
||||||
|
* without the user having to change arrow key
|
||||||
|
*/
|
||||||
|
if (direction === this.direction || !this.isExploring) {
|
||||||
|
if (!this.isExploring) {
|
||||||
|
// just started and no other nodes at the given direction
|
||||||
|
// so the current node is technically the first visited node
|
||||||
|
// (this is needed so that we don't get stuck between looping through )
|
||||||
|
this.visitedNodes.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherDirections: LinkDirection[] = [
|
||||||
|
"up",
|
||||||
|
"right",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
].filter((dir): dir is LinkDirection => dir !== direction);
|
||||||
|
|
||||||
|
const otherLinkedNodes = otherDirections
|
||||||
|
.map((dir) => [
|
||||||
|
...getSuccessors(element, elementsMap, dir),
|
||||||
|
...getPredecessors(element, elementsMap, dir),
|
||||||
|
])
|
||||||
|
.flat()
|
||||||
|
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||||
|
|
||||||
|
for (const linkedNode of otherLinkedNodes) {
|
||||||
|
if (!this.visitedNodes.has(linkedNode.id)) {
|
||||||
|
this.visitedNodes.add(linkedNode.id);
|
||||||
|
this.isExploring = true;
|
||||||
|
this.direction = direction;
|
||||||
|
return linkedNode.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FlowChartCreator {
|
||||||
|
isCreatingChart: boolean = false;
|
||||||
|
private numberOfNodes: number = 0;
|
||||||
|
private direction: LinkDirection | null = "right";
|
||||||
|
pendingNodes: PendingExcalidrawElements | null = null;
|
||||||
|
|
||||||
|
createNodes(
|
||||||
|
startNode: ExcalidrawFlowchartNodeElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
direction: LinkDirection,
|
||||||
|
) {
|
||||||
|
if (direction !== this.direction) {
|
||||||
|
const { nextNode, bindingArrow } = addNewNode(
|
||||||
|
startNode,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.numberOfNodes = 1;
|
||||||
|
this.isCreatingChart = true;
|
||||||
|
this.direction = direction;
|
||||||
|
this.pendingNodes = [nextNode, bindingArrow];
|
||||||
|
} else {
|
||||||
|
this.numberOfNodes += 1;
|
||||||
|
const newNodes = addNewNodes(
|
||||||
|
startNode,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
this.numberOfNodes,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isCreatingChart = true;
|
||||||
|
this.direction = direction;
|
||||||
|
this.pendingNodes = newNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add pending nodes to the same frame as the start node
|
||||||
|
// if every pending node is at least intersecting with the frame
|
||||||
|
if (startNode.frameId) {
|
||||||
|
const frame = elementsMap.get(startNode.frameId);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
frame && isFrameElement(frame),
|
||||||
|
"not an ExcalidrawFrameElement",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
frame &&
|
||||||
|
this.pendingNodes.every(
|
||||||
|
(node) =>
|
||||||
|
elementsAreInFrameBounds([node], frame, elementsMap) ||
|
||||||
|
elementOverlapsWithFrame(node, frame, elementsMap),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||||
|
mutateElement(
|
||||||
|
node,
|
||||||
|
{
|
||||||
|
frameId: startNode.frameId,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.isCreatingChart = false;
|
||||||
|
this.pendingNodes = null;
|
||||||
|
this.direction = null;
|
||||||
|
this.numberOfNodes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isNodeInFlowchart = (
|
||||||
|
element: ExcalidrawFlowchartNodeElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
for (const [, el] of elementsMap) {
|
||||||
|
if (
|
||||||
|
el.type === "arrow" &&
|
||||||
|
(el.startBinding?.elementId === element.id ||
|
||||||
|
el.endBinding?.elementId === element.id)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@@ -46,7 +46,7 @@ export {
|
|||||||
dragNewElement,
|
dragNewElement,
|
||||||
} from "./dragElements";
|
} from "./dragElements";
|
||||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||||
export { redrawTextBoundingBox } from "./textElement";
|
export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
|
||||||
export {
|
export {
|
||||||
getPerfectElementSize,
|
getPerfectElementSize,
|
||||||
getLockedLinearCursorAlignSize,
|
getLockedLinearCursorAlignSize,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
distance2d,
|
distance2d,
|
||||||
@@ -35,6 +36,8 @@ import type {
|
|||||||
AppState,
|
AppState,
|
||||||
PointerCoords,
|
PointerCoords,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
|
AppClassProperties,
|
||||||
|
NullableGridSize,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ import {
|
|||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { tupleToCoors } from "../utils";
|
import { toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
@@ -151,10 +154,7 @@ export class LinearElementEditor {
|
|||||||
setState: React.Component<any, AppState>["setState"],
|
setState: React.Component<any, AppState>["setState"],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
) {
|
) {
|
||||||
if (
|
if (!appState.editingLinearElement || !appState.selectionElement) {
|
||||||
!appState.editingLinearElement ||
|
|
||||||
appState.draggingElement?.type !== "selection"
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { editingLinearElement } = appState;
|
||||||
@@ -166,7 +166,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||||
getElementAbsoluteCoords(appState.draggingElement, elementsMap);
|
getElementAbsoluteCoords(appState.selectionElement, elementsMap);
|
||||||
|
|
||||||
const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates(
|
const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
@@ -211,7 +211,7 @@ export class LinearElementEditor {
|
|||||||
/** @returns whether point was dragged */
|
/** @returns whether point was dragged */
|
||||||
static handlePointDragging(
|
static handlePointDragging(
|
||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
appState: AppState,
|
app: AppClassProperties,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
maybeSuggestBinding: (
|
||||||
@@ -281,7 +281,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
referencePoint,
|
referencePoint,
|
||||||
[scenePointerX, scenePointerY],
|
[scenePointerX, scenePointerY],
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
@@ -293,7 +293,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: selectedIndex === lastClickedPoint,
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
@@ -301,7 +301,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||||
@@ -317,7 +317,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
)
|
)
|
||||||
: ([
|
: ([
|
||||||
element.points[pointIndex][0] + deltaX,
|
element.points[pointIndex][0] + deltaX,
|
||||||
@@ -329,7 +329,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: pointIndex === lastClickedPoint,
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ export class LinearElementEditor {
|
|||||||
: element.points[0],
|
: element.points[0],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,7 +697,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
appState: AppState,
|
app: AppClassProperties,
|
||||||
store: Store,
|
store: Store,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
@@ -707,6 +707,7 @@ export class LinearElementEditor {
|
|||||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||||
linearElementEditor: LinearElementEditor | null;
|
linearElementEditor: LinearElementEditor | null;
|
||||||
} {
|
} {
|
||||||
|
const appState = app.state;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
|
|
||||||
@@ -743,7 +744,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
if (event.altKey && appState.editingLinearElement) {
|
if (event.altKey && appState.editingLinearElement) {
|
||||||
if (
|
if (
|
||||||
linearElementEditor.lastUncommittedPoint == null ||
|
linearElementEditor.lastUncommittedPoint == null &&
|
||||||
!isElbowArrow(element)
|
!isElbowArrow(element)
|
||||||
) {
|
) {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
@@ -754,7 +755,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -878,14 +879,14 @@ export class LinearElementEditor {
|
|||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
appState: AppState,
|
app: AppClassProperties,
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
|
const appState = app.state;
|
||||||
if (!appState.editingLinearElement) {
|
if (!appState.editingLinearElement) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return appState.editingLinearElement;
|
return appState.editingLinearElement;
|
||||||
@@ -896,7 +897,11 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1], scene);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
[points.length - 1],
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@@ -914,7 +919,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
lastCommittedPoint,
|
lastCommittedPoint,
|
||||||
[scenePointerX, scenePointerY],
|
[scenePointerX, scenePointerY],
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
newPoint = [
|
newPoint = [
|
||||||
@@ -929,7 +934,7 @@ export class LinearElementEditor {
|
|||||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||||
? null
|
? null
|
||||||
: appState.gridSize,
|
: app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,14 +947,13 @@ export class LinearElementEditor {
|
|||||||
point: newPoint,
|
point: newPoint,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(
|
LinearElementEditor.addPoints(
|
||||||
element,
|
element,
|
||||||
appState,
|
|
||||||
[{ point: newPoint }],
|
[{ point: newPoint }],
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1065,7 +1069,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
gridSize: number | null,
|
gridSize: NullableGridSize,
|
||||||
): Point {
|
): Point {
|
||||||
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
|
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
@@ -1094,7 +1098,7 @@ export class LinearElementEditor {
|
|||||||
const offsetY = points[0][1];
|
const offsetY = points[0][1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points: points.map((point, _idx) => {
|
points: points.map((point) => {
|
||||||
return [point[0] - offsetX, point[1] - offsetY] as const;
|
return [point[0] - offsetX, point[1] - offsetY] as const;
|
||||||
}),
|
}),
|
||||||
x: element.x + offsetX,
|
x: element.x + offsetX,
|
||||||
@@ -1109,13 +1113,15 @@ export class LinearElementEditor {
|
|||||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
static duplicateSelectedPoints(appState: AppState, scene: Scene) {
|
static duplicateSelectedPoints(
|
||||||
|
appState: AppState,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
|
) {
|
||||||
if (!appState.editingLinearElement) {
|
if (!appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (!element || selectedPointsIndices === null) {
|
if (!element || selectedPointsIndices === null) {
|
||||||
@@ -1166,7 +1172,7 @@ export class LinearElementEditor {
|
|||||||
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,7 +1190,7 @@ export class LinearElementEditor {
|
|||||||
static deletePoints(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
@@ -1217,15 +1223,14 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
|
||||||
targetPoints: { point: Point }[],
|
targetPoints: { point: Point }[],
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
const offsetY = 0;
|
const offsetY = 0;
|
||||||
@@ -1236,14 +1241,14 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
scene,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
@@ -1299,7 +1304,7 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
scene,
|
elementsMap,
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: targetPoints.reduce(
|
isDragging: targetPoints.reduce(
|
||||||
@@ -1362,7 +1367,7 @@ export class LinearElementEditor {
|
|||||||
static addMidpoint(
|
static addMidpoint(
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
pointerCoords: PointerCoords,
|
pointerCoords: PointerCoords,
|
||||||
appState: AppState,
|
app: AppClassProperties,
|
||||||
snapToGrid: boolean,
|
snapToGrid: boolean,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) {
|
) {
|
||||||
@@ -1387,7 +1392,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
pointerCoords.x,
|
pointerCoords.x,
|
||||||
pointerCoords.y,
|
pointerCoords.y,
|
||||||
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
|
snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
|
||||||
);
|
);
|
||||||
const points = [
|
const points = [
|
||||||
...element.points.slice(0, segmentMidpoint.index!),
|
...element.points.slice(0, segmentMidpoint.index!),
|
||||||
@@ -1416,7 +1421,7 @@ export class LinearElementEditor {
|
|||||||
nextPoints: readonly Point[],
|
nextPoints: readonly Point[],
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
@@ -1446,9 +1451,15 @@ export class LinearElementEditor {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedElementsMap = options?.changedElements
|
||||||
|
? toBrandedType<SceneElementsMap>(
|
||||||
|
new Map([...elementsMap, ...options.changedElements]),
|
||||||
|
)
|
||||||
|
: elementsMap;
|
||||||
|
|
||||||
mutateElbowArrow(
|
mutateElbowArrow(
|
||||||
element,
|
element,
|
||||||
scene,
|
mergedElementsMap,
|
||||||
nextPoints,
|
nextPoints,
|
||||||
[offsetX, offsetY],
|
[offsetX, offsetY],
|
||||||
bindings,
|
bindings,
|
||||||
@@ -1478,7 +1489,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
referencePoint: Point,
|
referencePoint: Point,
|
||||||
scenePointer: Point,
|
scenePointer: Point,
|
||||||
gridSize: number | null,
|
gridSize: NullableGridSize,
|
||||||
) {
|
) {
|
||||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
|
|||||||
@@ -375,12 +375,13 @@ export const newFreeDrawElement = (
|
|||||||
type: "freedraw";
|
type: "freedraw";
|
||||||
points?: ExcalidrawFreeDrawElement["points"];
|
points?: ExcalidrawFreeDrawElement["points"];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
|
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
pressures: [],
|
pressures: opts.pressures || [],
|
||||||
simulatePressure: opts.simulatePressure,
|
simulatePressure: opts.simulatePressure,
|
||||||
lastCommittedPoint: null,
|
lastCommittedPoint: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +71,7 @@ export const transformElements = (
|
|||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: SceneElementsMap,
|
||||||
shouldRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
shouldMaintainAspectRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
@@ -77,7 +79,6 @@ export const transformElements = (
|
|||||||
pointerY: number,
|
pointerY: number,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
centerY: number,
|
centerY: number,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
const [element] = selectedElements;
|
const [element] = selectedElements;
|
||||||
@@ -90,7 +91,7 @@ export const transformElements = (
|
|||||||
pointerY,
|
pointerY,
|
||||||
shouldRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
);
|
);
|
||||||
updateBoundElements(element, elementsMap, scene);
|
updateBoundElements(element, elementsMap);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
} else if (isTextElement(element) && transformHandleType) {
|
||||||
resizeSingleTextElement(
|
resizeSingleTextElement(
|
||||||
@@ -102,7 +103,7 @@ export const transformElements = (
|
|||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
updateBoundElements(element, elementsMap, scene);
|
updateBoundElements(element, elementsMap);
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
resizeSingleElement(
|
resizeSingleElement(
|
||||||
originalElements,
|
originalElements,
|
||||||
@@ -113,7 +114,6 @@ export const transformElements = (
|
|||||||
shouldResizeFromCenter,
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,6 @@ export const transformElements = (
|
|||||||
shouldRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
@@ -142,7 +141,6 @@ export const transformElements = (
|
|||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -434,22 +432,12 @@ export const resizeSingleElement = (
|
|||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
shouldMaintainAspectRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: SceneElementsMap,
|
||||||
transformHandleDirection: TransformHandleDirection,
|
transformHandleDirection: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
// Elbow arrows cannot be resized when bound on either end
|
|
||||||
if (
|
|
||||||
isArrowElement(element) &&
|
|
||||||
isElbowArrow(element) &&
|
|
||||||
(element.startBinding || element.endBinding)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||||
// Gets bounds corners
|
// Gets bounds corners
|
||||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||||
@@ -719,7 +707,7 @@ export const resizeSingleElement = (
|
|||||||
) {
|
) {
|
||||||
mutateElement(element, resizedElement);
|
mutateElement(element, resizedElement);
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, scene, {
|
updateBoundElements(element, elementsMap, {
|
||||||
oldSize: {
|
oldSize: {
|
||||||
width: stateAtResizeStart.width,
|
width: stateAtResizeStart.width,
|
||||||
height: stateAtResizeStart.height,
|
height: stateAtResizeStart.height,
|
||||||
@@ -743,13 +731,12 @@ export const resizeSingleElement = (
|
|||||||
export const resizeMultipleElements = (
|
export const resizeMultipleElements = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
shouldMaintainAspectRatio: boolean,
|
shouldMaintainAspectRatio: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
// map selected elements to the original elements. While it never should
|
// map selected elements to the original elements. While it never should
|
||||||
// happen that pointerDownState.originalElements won't contain the selected
|
// happen that pointerDownState.originalElements won't contain the selected
|
||||||
@@ -983,12 +970,19 @@ export const resizeMultipleElements = (
|
|||||||
mutateElement(element, update, false);
|
mutateElement(element, update, false);
|
||||||
|
|
||||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||||
mutateElbowArrow(element, scene, element.points, undefined, undefined, {
|
mutateElbowArrow(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
element.points,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
informMutation: false,
|
informMutation: false,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, scene, {
|
updateBoundElements(element, elementsMap, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
oldSize: { width: oldWidth, height: oldHeight },
|
||||||
});
|
});
|
||||||
@@ -1013,13 +1007,12 @@ export const resizeMultipleElements = (
|
|||||||
const rotateMultipleElements = (
|
const rotateMultipleElements = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: SceneElementsMap,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
shouldRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
centerY: number,
|
centerY: number,
|
||||||
scene: Scene,
|
|
||||||
) => {
|
) => {
|
||||||
let centerAngle =
|
let centerAngle =
|
||||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||||
@@ -1046,7 +1039,7 @@ const rotateMultipleElements = (
|
|||||||
|
|
||||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||||
mutateElbowArrow(element, scene, points);
|
mutateElbowArrow(element, elementsMap, points);
|
||||||
} else {
|
} else {
|
||||||
mutateElement(
|
mutateElement(
|
||||||
element,
|
element,
|
||||||
@@ -1059,7 +1052,7 @@ const rotateMultipleElements = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, scene, {
|
updateBoundElements(element, elementsMap, {
|
||||||
simultaneouslyUpdated: elements,
|
simultaneouslyUpdated: elements,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,27 +22,6 @@ const { h } = window;
|
|||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
const editInput = (input: HTMLInputElement, value: string) => {
|
|
||||||
input.focus();
|
|
||||||
fireEvent.change(input, { target: { value } });
|
|
||||||
input.blur();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatsProperty = (label: string) => {
|
|
||||||
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
|
||||||
|
|
||||||
if (elementStats) {
|
|
||||||
const properties = elementStats?.querySelector(".statsItem");
|
|
||||||
return (
|
|
||||||
properties?.querySelector?.(
|
|
||||||
`.drag-input-container[data-testid="${label}"]`,
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("elbow arrow routing", () => {
|
describe("elbow arrow routing", () => {
|
||||||
it("can properly generate orthogonal arrow points", () => {
|
it("can properly generate orthogonal arrow points", () => {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
@@ -51,7 +30,7 @@ describe("elbow arrow routing", () => {
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElbowArrow(arrow, scene, [
|
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||||
[-45 - arrow.x, -100.1 - arrow.y],
|
[-45 - arrow.x, -100.1 - arrow.y],
|
||||||
[45 - arrow.x, 99.9 - arrow.y],
|
[45 - arrow.x, 99.9 - arrow.y],
|
||||||
]);
|
]);
|
||||||
@@ -104,7 +83,7 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
mutateElbowArrow(arrow, scene, [
|
mutateElbowArrow(arrow, elementsMap, [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[90, 200],
|
[90, 200],
|
||||||
]);
|
]);
|
||||||
@@ -199,10 +178,10 @@ describe("elbow arrow ui", () => {
|
|||||||
|
|
||||||
mouse.click(51, 51);
|
mouse.click(51, 51);
|
||||||
|
|
||||||
const inputAngle = getStatsProperty("A")?.querySelector(
|
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
editInput(inputAngle, String("40"));
|
UI.updateInput(inputAngle, String("40"));
|
||||||
|
|
||||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
translatePoint,
|
translatePoint,
|
||||||
} from "../math";
|
} from "../math";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import type Scene from "../scene/Scene";
|
|
||||||
import type { Point } from "../types";
|
import type { Point } from "../types";
|
||||||
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
@@ -37,14 +36,10 @@ import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
|||||||
import type {
|
import type {
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type {
|
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawBindableElement,
|
|
||||||
OrderedExcalidrawElement,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
type Node = {
|
type Node = {
|
||||||
f: number;
|
f: number;
|
||||||
@@ -67,7 +62,7 @@ const BASE_PADDING = 40;
|
|||||||
|
|
||||||
export const mutateElbowArrow = (
|
export const mutateElbowArrow = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
scene: Scene,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
nextPoints: readonly Point[],
|
nextPoints: readonly Point[],
|
||||||
offset?: Point,
|
offset?: Point,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
@@ -75,15 +70,11 @@ export const mutateElbowArrow = (
|
|||||||
endBinding?: FixedPointBinding | null;
|
endBinding?: FixedPointBinding | null;
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
disableBinding?: boolean;
|
disableBinding?: boolean;
|
||||||
informMutation?: boolean;
|
informMutation?: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const elements = getAllElements(scene, options?.changedElements);
|
|
||||||
const elementsMap = getAllElementsMap(scene, options?.changedElements);
|
|
||||||
|
|
||||||
const origStartGlobalPoint = translatePoint(nextPoints[0], [
|
const origStartGlobalPoint = translatePoint(nextPoints[0], [
|
||||||
arrow.x + (offset ? offset[0] : 0),
|
arrow.x + (offset ? offset[0] : 0),
|
||||||
arrow.y + (offset ? offset[1] : 0),
|
arrow.y + (offset ? offset[1] : 0),
|
||||||
@@ -99,22 +90,9 @@ export const mutateElbowArrow = (
|
|||||||
const endElement =
|
const endElement =
|
||||||
arrow.endBinding &&
|
arrow.endBinding &&
|
||||||
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
|
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
|
||||||
const hoveredStartElement = options?.isDragging
|
const [hoveredStartElement, hoveredEndElement] = options?.isDragging
|
||||||
? getHoveredElementForBinding(
|
? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
|
||||||
tupleToCoors(origStartGlobalPoint),
|
: [startElement, endElement];
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
: startElement;
|
|
||||||
const hoveredEndElement = options?.isDragging
|
|
||||||
? getHoveredElementForBinding(
|
|
||||||
tupleToCoors(origEndGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
: endElement;
|
|
||||||
const startGlobalPoint = getGlobalPoint(
|
const startGlobalPoint = getGlobalPoint(
|
||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
@@ -895,7 +873,7 @@ const normalizedArrowElementUpdate = (
|
|||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
|
|
||||||
const points = global.map(
|
const points = global.map(
|
||||||
(point, _idx) => [point[0] - offsetX, point[1] - offsetY] as const,
|
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -935,32 +913,11 @@ const neighborIndexToHeading = (idx: number): Heading => {
|
|||||||
return HEADING_LEFT;
|
return HEADING_LEFT;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllElementsMap = (
|
|
||||||
scene: Scene,
|
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>,
|
|
||||||
): NonDeletedSceneElementsMap =>
|
|
||||||
changedElements
|
|
||||||
? toBrandedType<NonDeletedSceneElementsMap>(
|
|
||||||
new Map([...scene.getNonDeletedElementsMap(), ...changedElements]),
|
|
||||||
)
|
|
||||||
: scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
const getAllElements = (
|
|
||||||
scene: Scene,
|
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>,
|
|
||||||
): readonly NonDeletedExcalidrawElement[] =>
|
|
||||||
changedElements
|
|
||||||
? ([
|
|
||||||
...scene.getNonDeletedElements(),
|
|
||||||
...[...changedElements].map(([_, value]) => value),
|
|
||||||
] as NonDeletedExcalidrawElement[])
|
|
||||||
: scene.getNonDeletedElements();
|
|
||||||
|
|
||||||
const getGlobalPoint = (
|
const getGlobalPoint = (
|
||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: Point,
|
initialPoint: Point,
|
||||||
otherPoint: Point,
|
otherPoint: Point,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
boundElement?: ExcalidrawBindableElement | null,
|
boundElement?: ExcalidrawBindableElement | null,
|
||||||
hoveredElement?: ExcalidrawBindableElement | null,
|
hoveredElement?: ExcalidrawBindableElement | null,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
@@ -1016,7 +973,7 @@ const getSnapPoint = (
|
|||||||
const getBindPointHeading = (
|
const getBindPointHeading = (
|
||||||
point: Point,
|
point: Point,
|
||||||
otherPoint: Point,
|
otherPoint: Point,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: Point,
|
origPoint: Point,
|
||||||
) =>
|
) =>
|
||||||
@@ -1034,3 +991,30 @@ const getBindPointHeading = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
origPoint,
|
origPoint,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getHoveredElements = (
|
||||||
|
origStartGlobalPoint: Point,
|
||||||
|
origEndGlobalPoint: Point,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
|
) => {
|
||||||
|
// TODO: Might be a performance bottleneck and the Map type
|
||||||
|
// remembers the insertion order anyway...
|
||||||
|
const nonDeletedSceneElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
|
new Map([...elementsMap].filter((el) => !el[1].isDeleted)),
|
||||||
|
);
|
||||||
|
const elements = Array.from(elementsMap.values());
|
||||||
|
return [
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(origStartGlobalPoint),
|
||||||
|
elements,
|
||||||
|
nonDeletedSceneElementsMap,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(origEndGlobalPoint),
|
||||||
|
elements,
|
||||||
|
nonDeletedSceneElementsMap,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const showSelectedShapeActions = (
|
|||||||
Boolean(
|
Boolean(
|
||||||
!appState.viewModeEnabled &&
|
!appState.viewModeEnabled &&
|
||||||
((appState.activeTool.type !== "custom" &&
|
((appState.activeTool.type !== "custom" &&
|
||||||
(appState.editingElement ||
|
(appState.editingTextElement ||
|
||||||
(appState.activeTool.type !== "selection" &&
|
(appState.activeTool.type !== "selection" &&
|
||||||
appState.activeTool.type !== "eraser" &&
|
appState.activeTool.type !== "eraser" &&
|
||||||
appState.activeTool.type !== "hand" &&
|
appState.activeTool.type !== "hand" &&
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||||
import type { AppState, Zoom } from "../types";
|
import type { AppState, Zoom } from "../types";
|
||||||
import { getElementBounds } from "./bounds";
|
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||||
import { viewportCoordsToSceneCoords } from "../utils";
|
import { viewportCoordsToSceneCoords } from "../utils";
|
||||||
|
|
||||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||||
@@ -55,6 +55,49 @@ export const isElementInViewport = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isElementCompletelyInViewport = (
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
viewTransformations: {
|
||||||
|
zoom: Zoom;
|
||||||
|
offsetLeft: number;
|
||||||
|
offsetTop: number;
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
},
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
padding?: Partial<{
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||||
|
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||||
|
{
|
||||||
|
clientX: viewTransformations.offsetLeft + (padding?.left || 0),
|
||||||
|
clientY: viewTransformations.offsetTop + (padding?.top || 0),
|
||||||
|
},
|
||||||
|
viewTransformations,
|
||||||
|
);
|
||||||
|
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||||
|
{
|
||||||
|
clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
|
||||||
|
clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
|
||||||
|
},
|
||||||
|
viewTransformations,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
x1 >= topLeftSceneCoords.x &&
|
||||||
|
y1 >= topLeftSceneCoords.y &&
|
||||||
|
x2 <= bottomRightSceneCoords.x &&
|
||||||
|
y2 <= bottomRightSceneCoords.y
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -886,3 +886,19 @@ export const getMinTextElementWidth = (
|
|||||||
) => {
|
) => {
|
||||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
|
export const getTextFromElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
separator = "\n\n",
|
||||||
|
) => {
|
||||||
|
const text = elements
|
||||||
|
.reduce((acc: string[], element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
acc.push(element.text);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.join(separator);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { GlobalTestState, render, screen } from "../tests/test-utils";
|
import { GlobalTestState, render, screen } from "../tests/test-utils";
|
||||||
@@ -16,7 +17,6 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
||||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ describe("textWysiwyg", () => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer editing selected text element (non-bindable container present)", async () => {
|
it("should prefer editing selected text element (non-bindable container present)", async () => {
|
||||||
@@ -55,15 +55,15 @@ describe("textWysiwyg", () => {
|
|||||||
width: textSize,
|
width: textSize,
|
||||||
height: textSize,
|
height: textSize,
|
||||||
});
|
});
|
||||||
h.elements = [text, line];
|
API.setElements([text, line]);
|
||||||
|
|
||||||
API.setSelectedElements([text]);
|
API.setSelectedElements([text]);
|
||||||
|
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
expect(h.state.editingElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
expect(
|
expect(
|
||||||
(h.state.editingElement as ExcalidrawTextElement).containerId,
|
(h.state.editingTextElement as ExcalidrawTextElement).containerId,
|
||||||
).toBe(null);
|
).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ describe("textWysiwyg", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [container, boundText, boundText2];
|
API.setElements([container, boundText, boundText2]);
|
||||||
|
|
||||||
mutateElement(container, {
|
API.updateElement(container, {
|
||||||
boundElements: [{ type: "text", id: boundText.id }],
|
boundElements: [{ type: "text", id: boundText.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
expect(h.state.editingElement?.id).toBe(boundText2.id);
|
expect(h.state.editingTextElement?.id).toBe(boundText2.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not create bound text on ENTER if text exists at container center", () => {
|
it("should not create bound text on ENTER if text exists at container center", () => {
|
||||||
@@ -123,17 +123,17 @@ describe("textWysiwyg", () => {
|
|||||||
height: textSize,
|
height: textSize,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
API.updateElement(container, {
|
||||||
boundElements: [{ type: "text", id: text.id }],
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [container, text];
|
API.setElements([container, text]);
|
||||||
|
|
||||||
API.setSelectedElements([container]);
|
API.setSelectedElements([container]);
|
||||||
|
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
expect(h.state.editingElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
|
it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
|
||||||
@@ -164,9 +164,9 @@ describe("textWysiwyg", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [container, boundText, boundText2];
|
API.setElements([container, boundText, boundText2]);
|
||||||
|
|
||||||
mutateElement(container, {
|
API.updateElement(container, {
|
||||||
boundElements: [{ type: "text", id: boundText.id }],
|
boundElements: [{ type: "text", id: boundText.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
expect(h.state.editingElement?.id).toBe(boundText.id);
|
expect(h.state.editingTextElement?.id).toBe(boundText.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should edit text under cursor when clicked with text tool", async () => {
|
it("should edit text under cursor when clicked with text tool", async () => {
|
||||||
@@ -187,7 +187,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 100,
|
height: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [text];
|
API.setElements([text]);
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
@@ -195,7 +195,7 @@ describe("textWysiwyg", () => {
|
|||||||
const editor = await getTextEditor(textEditorSelector, false);
|
const editor = await getTextEditor(textEditorSelector, false);
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 100,
|
height: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [text];
|
API.setElements([text]);
|
||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
|
|
||||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||||
@@ -217,7 +217,7 @@ describe("textWysiwyg", () => {
|
|||||||
const editor = await getTextEditor(textEditorSelector, false);
|
const editor = await getTextEditor(textEditorSelector, false);
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ describe("textWysiwyg", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
h.app.refreshEditorBreakpoints();
|
h.app.refreshEditorBreakpoints();
|
||||||
|
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -264,7 +264,7 @@ describe("textWysiwyg", () => {
|
|||||||
text: "Excalidraw\nEditor",
|
text: "Excalidraw\nEditor",
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [text];
|
API.setElements([text]);
|
||||||
|
|
||||||
const prevWidth = text.width;
|
const prevWidth = text.width;
|
||||||
const prevHeight = text.height;
|
const prevHeight = text.height;
|
||||||
@@ -286,23 +286,20 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
const editor = await getTextEditor(textEditorSelector);
|
const editor = await getTextEditor(textEditorSelector);
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
|
|
||||||
const nextText = `${wrappedText} is great!`;
|
const nextText = `${wrappedText} is great!`;
|
||||||
updateTextEditor(editor, nextText);
|
updateTextEditor(editor, nextText);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
expect(h.elements[0].width).toEqual(wrappedWidth);
|
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||||
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
|
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
|
||||||
|
|
||||||
// remove all texts and then add it back editing
|
// remove all texts and then add it back editing
|
||||||
updateTextEditor(editor, "");
|
updateTextEditor(editor, "");
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
|
||||||
updateTextEditor(editor, nextText);
|
updateTextEditor(editor, nextText);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
expect(h.elements[0].width).toEqual(wrappedWidth);
|
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||||
});
|
});
|
||||||
@@ -313,7 +310,7 @@ describe("textWysiwyg", () => {
|
|||||||
type: "text",
|
type: "text",
|
||||||
text: originalText,
|
text: originalText,
|
||||||
});
|
});
|
||||||
h.elements = [text];
|
API.setElements([text]);
|
||||||
|
|
||||||
// wrap
|
// wrap
|
||||||
UI.resize(text, "e", [-40, 0]);
|
UI.resize(text, "e", [-40, 0]);
|
||||||
@@ -321,7 +318,7 @@ describe("textWysiwyg", () => {
|
|||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
const editor = await getTextEditor(textEditorSelector);
|
const editor = await getTextEditor(textEditorSelector);
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
// restore after unwrapping
|
// restore after unwrapping
|
||||||
UI.resize(text, "e", [40, 0]);
|
UI.resize(text, "e", [40, 0]);
|
||||||
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
||||||
@@ -332,14 +329,12 @@ describe("textWysiwyg", () => {
|
|||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
updateTextEditor(editor, `${wrappedText}\nA new line!`);
|
updateTextEditor(editor, `${wrappedText}\nA new line!`);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
// remove the newly added line
|
// remove the newly added line
|
||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
updateTextEditor(editor, wrappedText);
|
updateTextEditor(editor, wrappedText);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
// unwrap
|
// unwrap
|
||||||
UI.resize(text, "e", [30, 0]);
|
UI.resize(text, "e", [30, 0]);
|
||||||
// expect the text to be restored the same
|
// expect the text to be restored the same
|
||||||
@@ -376,12 +371,11 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add a tab at the start of the first line", () => {
|
it("should add a tab at the start of the first line", () => {
|
||||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
|
||||||
textarea.value = "Line#1\nLine#2";
|
textarea.value = "Line#1\nLine#2";
|
||||||
// cursor: "|Line#1\nLine#2"
|
// cursor: "|Line#1\nLine#2"
|
||||||
textarea.selectionStart = 0;
|
textarea.selectionStart = 0;
|
||||||
textarea.selectionEnd = 0;
|
textarea.selectionEnd = 0;
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, { key: KEYS.TAB });
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
|
expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
|
||||||
// cursor: " |Line#1\nLine#2"
|
// cursor: " |Line#1\nLine#2"
|
||||||
@@ -390,13 +384,12 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add a tab at the start of the second line", () => {
|
it("should add a tab at the start of the second line", () => {
|
||||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
|
||||||
textarea.value = "Line#1\nLine#2";
|
textarea.value = "Line#1\nLine#2";
|
||||||
// cursor: "Line#1\nLin|e#2"
|
// cursor: "Line#1\nLin|e#2"
|
||||||
textarea.selectionStart = 10;
|
textarea.selectionStart = 10;
|
||||||
textarea.selectionEnd = 10;
|
textarea.selectionEnd = 10;
|
||||||
|
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, { key: KEYS.TAB });
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
|
expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
|
||||||
|
|
||||||
@@ -406,13 +399,12 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add a tab at the start of the first and second line", () => {
|
it("should add a tab at the start of the first and second line", () => {
|
||||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
|
||||||
textarea.value = "Line#1\nLine#2\nLine#3";
|
textarea.value = "Line#1\nLine#2\nLine#3";
|
||||||
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||||
textarea.selectionStart = 2;
|
textarea.selectionStart = 2;
|
||||||
textarea.selectionEnd = 9;
|
textarea.selectionEnd = 9;
|
||||||
|
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, { key: KEYS.TAB });
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
|
expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
|
||||||
|
|
||||||
@@ -422,16 +414,15 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should remove a tab at the start of the first line", () => {
|
it("should remove a tab at the start of the first line", () => {
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
textarea.value = `${tab}Line#1\nLine#2`;
|
textarea.value = `${tab}Line#1\nLine#2`;
|
||||||
// cursor: "| Line#1\nLine#2"
|
// cursor: "| Line#1\nLine#2"
|
||||||
textarea.selectionStart = 0;
|
textarea.selectionStart = 0;
|
||||||
textarea.selectionEnd = 0;
|
textarea.selectionEnd = 0;
|
||||||
|
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
|
|
||||||
@@ -441,16 +432,15 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should remove a tab at the start of the second line", () => {
|
it("should remove a tab at the start of the second line", () => {
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
// cursor: "Line#1\n Lin|e#2"
|
// cursor: "Line#1\n Lin|e#2"
|
||||||
textarea.value = `Line#1\n${tab}Line#2`;
|
textarea.value = `Line#1\n${tab}Line#2`;
|
||||||
textarea.selectionStart = 15;
|
textarea.selectionStart = 15;
|
||||||
textarea.selectionEnd = 15;
|
textarea.selectionEnd = 15;
|
||||||
|
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
// cursor: "Line#1\nLin|e#2"
|
// cursor: "Line#1\nLin|e#2"
|
||||||
@@ -459,16 +449,15 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should remove a tab at the start of the first and second line", () => {
|
it("should remove a tab at the start of the first and second line", () => {
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
||||||
textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
|
textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
|
||||||
textarea.selectionStart = 6;
|
textarea.selectionStart = 6;
|
||||||
textarea.selectionEnd = 17;
|
textarea.selectionEnd = 17;
|
||||||
|
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
|
expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
|
||||||
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||||
@@ -477,45 +466,41 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should remove a tab at the start of the second line and cursor stay on this line", () => {
|
it("should remove a tab at the start of the second line and cursor stay on this line", () => {
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
// cursor: "Line#1\n | Line#2"
|
// cursor: "Line#1\n | Line#2"
|
||||||
textarea.value = `Line#1\n${tab}Line#2`;
|
textarea.value = `Line#1\n${tab}Line#2`;
|
||||||
textarea.selectionStart = 9;
|
textarea.selectionStart = 9;
|
||||||
textarea.selectionEnd = 9;
|
textarea.selectionEnd = 9;
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
|
||||||
// cursor: "Line#1\n|Line#2"
|
|
||||||
expect(textarea.selectionStart).toEqual(7);
|
|
||||||
// expect(textarea.selectionEnd).toEqual(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove partial tabs", () => {
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
key: KEYS.TAB,
|
||||||
shiftKey: true,
|
shiftKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cursor: "Line#1\n|Line#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove partial tabs", () => {
|
||||||
// cursor: "Line#1\n Line#|2"
|
// cursor: "Line#1\n Line#|2"
|
||||||
textarea.value = `Line#1\n Line#2`;
|
textarea.value = `Line#1\n Line#2`;
|
||||||
textarea.selectionStart = 15;
|
textarea.selectionStart = 15;
|
||||||
textarea.selectionEnd = 15;
|
textarea.selectionEnd = 15;
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove nothing", () => {
|
it("should remove nothing", () => {
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.TAB,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
// cursor: "Line#1\n Li|ne#2"
|
// cursor: "Line#1\n Li|ne#2"
|
||||||
textarea.value = `Line#1\nLine#2`;
|
textarea.value = `Line#1\nLine#2`;
|
||||||
textarea.selectionStart = 9;
|
textarea.selectionStart = 9;
|
||||||
textarea.selectionEnd = 9;
|
textarea.selectionEnd = 9;
|
||||||
textarea.dispatchEvent(event);
|
fireEvent.keyDown(textarea, {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
});
|
});
|
||||||
@@ -523,54 +508,42 @@ describe("textWysiwyg", () => {
|
|||||||
it("should resize text via shortcuts while in wysiwyg", () => {
|
it("should resize text via shortcuts while in wysiwyg", () => {
|
||||||
textarea.value = "abc def";
|
textarea.value = "abc def";
|
||||||
const origFontSize = textElement.fontSize;
|
const origFontSize = textElement.fontSize;
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.CHEVRON_RIGHT,
|
key: KEYS.CHEVRON_RIGHT,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
shiftKey: true,
|
shiftKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(textElement.fontSize).toBe(origFontSize * 1.1);
|
expect(textElement.fontSize).toBe(origFontSize * 1.1);
|
||||||
|
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
key: KEYS.CHEVRON_LEFT,
|
key: KEYS.CHEVRON_LEFT,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
shiftKey: true,
|
shiftKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(textElement.fontSize).toBe(origFontSize);
|
expect(textElement.fontSize).toBe(origFontSize);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zooming via keyboard should zoom canvas", () => {
|
it("zooming via keyboard should zoom canvas", () => {
|
||||||
expect(h.state.zoom.value).toBe(1);
|
expect(h.state.zoom.value).toBe(1);
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
code: CODES.MINUS,
|
code: CODES.MINUS,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(h.state.zoom.value).toBe(0.9);
|
expect(h.state.zoom.value).toBe(0.9);
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
code: CODES.NUM_SUBTRACT,
|
code: CODES.NUM_SUBTRACT,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(h.state.zoom.value).toBe(0.8);
|
expect(h.state.zoom.value).toBe(0.8);
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
code: CODES.NUM_ADD,
|
code: CODES.NUM_ADD,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(h.state.zoom.value).toBe(0.9);
|
expect(h.state.zoom.value).toBe(0.9);
|
||||||
textarea.dispatchEvent(
|
fireEvent.keyDown(textarea, {
|
||||||
new KeyboardEvent("keydown", {
|
|
||||||
code: CODES.EQUAL,
|
code: CODES.EQUAL,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
expect(h.state.zoom.value).toBe(1);
|
expect(h.state.zoom.value).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -583,8 +556,8 @@ describe("textWysiwyg", () => {
|
|||||||
textarea,
|
textarea,
|
||||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||||
);
|
);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(textarea);
|
||||||
textarea.blur();
|
|
||||||
expect(textarea.style.width).toBe("792px");
|
expect(textarea.style.width).toBe("792px");
|
||||||
expect(h.elements[0].width).toBe(1000);
|
expect(h.elements[0].width).toBe(1000);
|
||||||
});
|
});
|
||||||
@@ -596,7 +569,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
|
|
||||||
rectangle = UI.createElement("rectangle", {
|
rectangle = UI.createElement("rectangle", {
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -615,7 +588,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 75,
|
height: 75,
|
||||||
backgroundColor: "red",
|
backgroundColor: "red",
|
||||||
});
|
});
|
||||||
h.elements = [rectangle];
|
API.setElements([rectangle]);
|
||||||
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements[0].id).toBe(rectangle.id);
|
expect(h.elements[0].id).toBe(rectangle.id);
|
||||||
@@ -634,8 +607,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -648,7 +620,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 75,
|
height: 75,
|
||||||
angle: 45,
|
angle: 45,
|
||||||
});
|
});
|
||||||
h.elements = [rectangle];
|
API.setElements([rectangle]);
|
||||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
@@ -662,8 +634,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -677,7 +648,7 @@ describe("textWysiwyg", () => {
|
|||||||
width: 90,
|
width: 90,
|
||||||
height: 75,
|
height: 75,
|
||||||
});
|
});
|
||||||
h.elements = [diamond];
|
API.setElements([diamond]);
|
||||||
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements[0].id).toBe(diamond.id);
|
expect(h.elements[0].id).toBe(diamond.id);
|
||||||
@@ -687,7 +658,6 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
const value = new Array(1000).fill("1").join("\n");
|
const value = new Array(1000).fill("1").join("\n");
|
||||||
|
|
||||||
// Pasting large text to simulate height increase
|
// Pasting large text to simulate height increase
|
||||||
@@ -712,7 +682,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 75,
|
height: 75,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
});
|
});
|
||||||
h.elements = [rectangle];
|
API.setElements([rectangle]);
|
||||||
|
|
||||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
@@ -721,8 +691,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.containerId).toBe(null);
|
expect(text.containerId).toBe(null);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
mouse.doubleClickAt(
|
mouse.doubleClickAt(
|
||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
@@ -738,8 +707,7 @@ describe("textWysiwyg", () => {
|
|||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
@@ -759,10 +727,8 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -777,7 +743,7 @@ describe("textWysiwyg", () => {
|
|||||||
height: 75,
|
height: 75,
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
});
|
});
|
||||||
h.elements = [rectangle];
|
API.setElements([rectangle]);
|
||||||
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements[0].id).toBe(rectangle.id);
|
expect(h.elements[0].id).toBe(rectangle.id);
|
||||||
@@ -795,8 +761,7 @@ describe("textWysiwyg", () => {
|
|||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -808,7 +773,7 @@ describe("textWysiwyg", () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
h.elements = [freedraw];
|
API.setElements([freedraw]);
|
||||||
|
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
@@ -819,7 +784,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
expect(freedraw.boundElements).toBe(null);
|
expect(freedraw.boundElements).toBe(null);
|
||||||
expect(h.elements[1].type).toBe("text");
|
expect(h.elements[1].type).toBe("text");
|
||||||
@@ -828,7 +793,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
["freedraw", "line"].forEach((type: any) => {
|
["freedraw", "line"].forEach((type: any) => {
|
||||||
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
|
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
|
||||||
h.elements = [];
|
API.setElements([]);
|
||||||
const element = UI.createElement(type, {
|
const element = UI.createElement(type, {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -855,8 +820,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle.boundElements).toBe(null);
|
expect(rectangle.boundElements).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -872,7 +836,6 @@ describe("textWysiwyg", () => {
|
|||||||
editor,
|
editor,
|
||||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||||
);
|
);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
expect(h.elements[1].type).toBe("text");
|
expect(h.elements[1].type).toBe("text");
|
||||||
|
|
||||||
@@ -908,14 +871,18 @@ describe("textWysiwyg", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
);
|
);
|
||||||
mouse.down();
|
|
||||||
|
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
|
||||||
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
|
expect(await getTextEditor(textEditorSelector, false)).toBe(null);
|
||||||
|
|
||||||
|
expect(h.state.editingTextElement).toBe(null);
|
||||||
|
|
||||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
|
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle(/code/i));
|
fireEvent.click(screen.getByTitle(/code/i));
|
||||||
@@ -950,8 +917,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.text).toBe("Hello \nWorld!");
|
expect(text.text).toBe("Hello \nWorld!");
|
||||||
expect(text.originalText).toBe("Hello World!");
|
expect(text.originalText).toBe("Hello World!");
|
||||||
@@ -970,9 +936,7 @@ describe("textWysiwyg", () => {
|
|||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
editor.blur();
|
|
||||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
expect(text.text).toBe("Hello");
|
expect(text.text).toBe("Hello");
|
||||||
@@ -998,10 +962,8 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -1034,9 +996,8 @@ describe("textWysiwyg", () => {
|
|||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -1055,9 +1016,8 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
// should center align horizontally and vertically by default
|
// should center align horizontally and vertically by default
|
||||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
@@ -1076,12 +1036,8 @@ describe("textWysiwyg", () => {
|
|||||||
editor.select();
|
editor.select();
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle("Left"));
|
fireEvent.click(screen.getByTitle("Left"));
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
// should left align horizontally and bottom vertically after resize
|
// should left align horizontally and bottom vertically after resize
|
||||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
@@ -1101,9 +1057,7 @@ describe("textWysiwyg", () => {
|
|||||||
fireEvent.click(screen.getByTitle("Right"));
|
fireEvent.click(screen.getByTitle("Right"));
|
||||||
fireEvent.click(screen.getByTitle("Align top"));
|
fireEvent.click(screen.getByTitle("Align top"));
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
editor.blur();
|
|
||||||
|
|
||||||
// should right align horizontally and top vertically after resize
|
// should right align horizontally and top vertically after resize
|
||||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
@@ -1136,8 +1090,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle2.boundElements).toBeNull();
|
expect(rectangle2.boundElements).toBeNull();
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
@@ -1148,9 +1101,8 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||||
expect(rectangle.width).toBe(90);
|
expect(rectangle.width).toBe(90);
|
||||||
expect(rectangle.height).toBe(75);
|
expect(rectangle.height).toBe(75);
|
||||||
@@ -1168,9 +1120,8 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
|
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
@@ -1200,9 +1151,8 @@ describe("textWysiwyg", () => {
|
|||||||
it("undo should work", async () => {
|
it("undo should work", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: h.elements[1].id, type: "text" },
|
{ id: h.elements[1].id, type: "text" },
|
||||||
]);
|
]);
|
||||||
@@ -1237,10 +1187,9 @@ describe("textWysiwyg", () => {
|
|||||||
it("should not allow bound text with only whitespaces", async () => {
|
it("should not allow bound text with only whitespaces", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
updateTextEditor(editor, " ");
|
updateTextEditor(editor, " ");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([]);
|
expect(rectangle.boundElements).toStrictEqual([]);
|
||||||
expect(h.elements[1].isDeleted).toBe(true);
|
expect(h.elements[1].isDeleted).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1259,7 +1208,7 @@ describe("textWysiwyg", () => {
|
|||||||
text: "Online whiteboard collaboration made easy",
|
text: "Online whiteboard collaboration made easy",
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [container, text];
|
API.setElements([container, text]);
|
||||||
API.setSelectedElements([container, text]);
|
API.setSelectedElements([container, text]);
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@@ -1292,9 +1241,8 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||||
@@ -1305,8 +1253,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
Keyboard.exitTextEditor(editor);
|
||||||
editor.blur();
|
|
||||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||||
// cache updated again
|
// cache updated again
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
|
||||||
@@ -1321,7 +1268,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
@@ -1346,7 +1293,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||||
).toEqual(1.25);
|
).toEqual(1.25);
|
||||||
@@ -1378,7 +1325,7 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
@@ -1498,13 +1445,11 @@ describe("textWysiwyg", () => {
|
|||||||
editor,
|
editor,
|
||||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||||
);
|
);
|
||||||
await new Promise((cb) => setTimeout(cb, 0));
|
|
||||||
|
|
||||||
editor.select();
|
editor.select();
|
||||||
fireEvent.click(screen.getByTitle("Left"));
|
fireEvent.click(screen.getByTitle("Left"));
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||||
expect(textElement.width).toBe(600);
|
expect(textElement.width).toBe(600);
|
||||||
@@ -1581,16 +1526,14 @@ describe("textWysiwyg", () => {
|
|||||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Hello!");
|
updateTextEditor(editor, "Hello!");
|
||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||||
).toBe(VERTICAL_ALIGN.MIDDLE);
|
).toBe(VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
@@ -1606,9 +1549,8 @@ describe("textWysiwyg", () => {
|
|||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
);
|
);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor(textEditorSelector, true);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
updateTextEditor(editor, "Excalidraw");
|
updateTextEditor(editor, "Excalidraw");
|
||||||
editor.blur();
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
expect(h.elements.length).toBe(3);
|
expect(h.elements.length).toBe(3);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
|
|||||||
@@ -357,7 +357,16 @@ export const textWysiwyg = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
editable.oninput = () => {
|
editable.oninput = () => {
|
||||||
onChange(normalizeText(editable.value));
|
const normalized = normalizeText(editable.value);
|
||||||
|
if (editable.value !== normalized) {
|
||||||
|
const selectionStart = editable.selectionStart;
|
||||||
|
editable.value = normalized;
|
||||||
|
// put the cursor at some position close to where it was before
|
||||||
|
// normalization (otherwise it'll end up at the end of the text)
|
||||||
|
editable.selectionStart = selectionStart;
|
||||||
|
editable.selectionEnd = selectionStart;
|
||||||
|
}
|
||||||
|
onChange(editable.value);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export const shouldShowBoundingBox = (
|
|||||||
}
|
}
|
||||||
const element = elements[0];
|
const element = elements[0];
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
|
// Elbow arrows cannot be resized as single selected elements
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!isLinearElement(element)) {
|
if (!isLinearElement(element)) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
|
ExcalidrawFlowchartNodeElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
@@ -175,6 +176,23 @@ export const isRectanguloidElement = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Remove this when proper distance calculation is introduced
|
||||||
|
// @see binding.ts:distanceToBindableElement()
|
||||||
|
export const isRectangularElement = (
|
||||||
|
element?: ExcalidrawElement | null,
|
||||||
|
): element is ExcalidrawBindableElement => {
|
||||||
|
return (
|
||||||
|
element != null &&
|
||||||
|
(element.type === "rectangle" ||
|
||||||
|
element.type === "image" ||
|
||||||
|
element.type === "text" ||
|
||||||
|
element.type === "iframe" ||
|
||||||
|
element.type === "embeddable" ||
|
||||||
|
element.type === "frame" ||
|
||||||
|
element.type === "magicframe")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const isTextBindableContainer = (
|
export const isTextBindableContainer = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
includeLocked = true,
|
includeLocked = true,
|
||||||
@@ -219,6 +237,16 @@ export const isExcalidrawElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFlowchartNodeElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawFlowchartNodeElement => {
|
||||||
|
return (
|
||||||
|
element.type === "rectangle" ||
|
||||||
|
element.type === "ellipse" ||
|
||||||
|
element.type === "diamond"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const hasBoundTextElement = (
|
export const hasBoundTextElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
Merge,
|
Merge,
|
||||||
ValueOf,
|
ValueOf,
|
||||||
} from "../utility-types";
|
} from "../utility-types";
|
||||||
import type { MagicCacheData } from "../data/magic";
|
|
||||||
|
|
||||||
export type ChartType = "bar" | "line";
|
export type ChartType = "bar" | "line";
|
||||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||||
@@ -101,11 +100,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
|||||||
type: "embeddable";
|
type: "embeddable";
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type MagicGenerationData =
|
||||||
|
| {
|
||||||
|
status: "pending";
|
||||||
|
}
|
||||||
|
| { status: "done"; html: string }
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
message?: string;
|
||||||
|
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "iframe";
|
type: "iframe";
|
||||||
// TODO move later to AI-specific frame
|
// TODO move later to AI-specific frame
|
||||||
customData?: { generationData?: MagicCacheData };
|
customData?: { generationData?: MagicGenerationData };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawIframeLikeElement =
|
export type ExcalidrawIframeLikeElement =
|
||||||
@@ -160,6 +170,11 @@ export type ExcalidrawGenericElement =
|
|||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement;
|
| ExcalidrawEllipseElement;
|
||||||
|
|
||||||
|
export type ExcalidrawFlowchartNodeElement =
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawEllipseElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||||
@@ -176,6 +191,11 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawIframeElement
|
| ExcalidrawIframeElement
|
||||||
| ExcalidrawEmbeddableElement;
|
| ExcalidrawEmbeddableElement;
|
||||||
|
|
||||||
|
export type ExcalidrawNonSelectionElement = Exclude<
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawSelectionElement
|
||||||
|
>;
|
||||||
|
|
||||||
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||||
index: FractionalIndex;
|
index: FractionalIndex;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import type { ExcalidrawElement } from "./element/types";
|
import type { ExcalidrawElement } from "./element/types";
|
||||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@@ -122,7 +123,7 @@ describe("adding elements to frames", () => {
|
|||||||
) => {
|
) => {
|
||||||
describe.skip("when frame is in a layer below", async () => {
|
describe.skip("when frame is in a layer below", async () => {
|
||||||
it("should add an element", async () => {
|
it("should add an element", async () => {
|
||||||
h.elements = [frame, rect2];
|
API.setElements([frame, rect2]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements", async () => {
|
it("should add elements", async () => {
|
||||||
h.elements = [frame, rect2, rect3];
|
API.setElements([frame, rect2, rect3]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -142,7 +143,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements when there are other other elements in between", async () => {
|
it("should add elements when there are other other elements in between", async () => {
|
||||||
h.elements = [frame, rect1, rect2, rect4, rect3];
|
API.setElements([frame, rect1, rect2, rect4, rect3]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -153,7 +154,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||||
h.elements = [frame, rect3, rect4, rect2, rect1];
|
API.setElements([frame, rect3, rect4, rect2, rect1]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -166,7 +167,7 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
describe.skip("when frame is in a layer above", async () => {
|
describe.skip("when frame is in a layer above", async () => {
|
||||||
it("should add an element", async () => {
|
it("should add an element", async () => {
|
||||||
h.elements = [rect2, frame];
|
API.setElements([rect2, frame]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements", async () => {
|
it("should add elements", async () => {
|
||||||
h.elements = [rect2, rect3, frame];
|
API.setElements([rect2, rect3, frame]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -186,7 +187,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements when there are other other elements in between", async () => {
|
it("should add elements when there are other other elements in between", async () => {
|
||||||
h.elements = [rect1, rect2, rect4, rect3, frame];
|
API.setElements([rect1, rect2, rect4, rect3, frame]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -197,7 +198,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||||
h.elements = [rect3, rect4, rect2, rect1, frame];
|
API.setElements([rect3, rect4, rect2, rect1, frame]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -210,7 +211,7 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
describe("when frame is in an inner layer", async () => {
|
describe("when frame is in an inner layer", async () => {
|
||||||
it.skip("should add elements", async () => {
|
it.skip("should add elements", async () => {
|
||||||
h.elements = [rect2, frame, rect3];
|
API.setElements([rect2, frame, rect3]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -221,7 +222,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add elements when there are other other elements in between", async () => {
|
it.skip("should add elements when there are other other elements in between", async () => {
|
||||||
h.elements = [rect2, rect1, frame, rect4, rect3];
|
API.setElements([rect2, rect1, frame, rect4, rect3]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -232,7 +233,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
|
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||||
h.elements = [rect3, rect4, frame, rect2, rect1];
|
API.setElements([rect3, rect4, frame, rect2, rect1]);
|
||||||
|
|
||||||
func(frame, rect2);
|
func(frame, rect2);
|
||||||
func(frame, rect3);
|
func(frame, rect3);
|
||||||
@@ -253,7 +254,8 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
|
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
|
||||||
|
|
||||||
h.elements = reorderElements(
|
API.setElements(
|
||||||
|
reorderElements(
|
||||||
[
|
[
|
||||||
frame,
|
frame,
|
||||||
...convertToExcalidrawElements([
|
...convertToExcalidrawElements([
|
||||||
@@ -267,6 +269,7 @@ describe("adding elements to frames", () => {
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
initialOrder,
|
initialOrder,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertOrder(h.elements, initialOrder);
|
assertOrder(h.elements, initialOrder);
|
||||||
@@ -337,7 +340,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add arrow bound with text when frame is in a layer below", async () => {
|
it.skip("should add arrow bound with text when frame is in a layer below", async () => {
|
||||||
h.elements = [frame, arrow, text];
|
API.setElements([frame, arrow, text]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, arrow);
|
resizeFrameOverElement(frame, arrow);
|
||||||
|
|
||||||
@@ -347,7 +350,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should add arrow bound with text when frame is in a layer above", async () => {
|
it("should add arrow bound with text when frame is in a layer above", async () => {
|
||||||
h.elements = [arrow, text, frame];
|
API.setElements([arrow, text, frame]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, arrow);
|
resizeFrameOverElement(frame, arrow);
|
||||||
|
|
||||||
@@ -357,7 +360,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
|
it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
|
||||||
h.elements = [arrow, frame, text];
|
API.setElements([arrow, frame, text]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, arrow);
|
resizeFrameOverElement(frame, arrow);
|
||||||
|
|
||||||
@@ -369,7 +372,7 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
describe("resizing frame over elements but downwards", async () => {
|
describe("resizing frame over elements but downwards", async () => {
|
||||||
it.skip("should add elements when frame is in a layer below", async () => {
|
it.skip("should add elements when frame is in a layer below", async () => {
|
||||||
h.elements = [frame, rect1, rect2, rect3, rect4];
|
API.setElements([frame, rect1, rect2, rect3, rect4]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, rect4);
|
resizeFrameOverElement(frame, rect4);
|
||||||
resizeFrameOverElement(frame, rect3);
|
resizeFrameOverElement(frame, rect3);
|
||||||
@@ -380,7 +383,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add elements when frame is in a layer above", async () => {
|
it.skip("should add elements when frame is in a layer above", async () => {
|
||||||
h.elements = [rect1, rect2, rect3, rect4, frame];
|
API.setElements([rect1, rect2, rect3, rect4, frame]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, rect4);
|
resizeFrameOverElement(frame, rect4);
|
||||||
resizeFrameOverElement(frame, rect3);
|
resizeFrameOverElement(frame, rect3);
|
||||||
@@ -391,7 +394,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should add elements when frame is in an inner layer", async () => {
|
it.skip("should add elements when frame is in an inner layer", async () => {
|
||||||
h.elements = [rect1, rect2, frame, rect3, rect4];
|
API.setElements([rect1, rect2, frame, rect3, rect4]);
|
||||||
|
|
||||||
resizeFrameOverElement(frame, rect4);
|
resizeFrameOverElement(frame, rect4);
|
||||||
resizeFrameOverElement(frame, rect3);
|
resizeFrameOverElement(frame, rect3);
|
||||||
@@ -406,7 +409,7 @@ describe("adding elements to frames", () => {
|
|||||||
await commonTestCases(dragElementIntoFrame);
|
await commonTestCases(dragElementIntoFrame);
|
||||||
|
|
||||||
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
|
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
|
||||||
h.elements = [frame, rect2];
|
API.setElements([frame, rect2]);
|
||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
@@ -420,7 +423,7 @@ describe("adding elements to frames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skip("should drag element inside, duplicate it and remove it from frame", () => {
|
it.skip("should drag element inside, duplicate it and remove it from frame", () => {
|
||||||
h.elements = [frame, rect2];
|
API.setElements([frame, rect2]);
|
||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
@@ -490,7 +493,7 @@ describe("adding elements to frames", () => {
|
|||||||
frameId: frame3.id,
|
frameId: frame3.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [
|
API.setElements([
|
||||||
frame1,
|
frame1,
|
||||||
rectangle4,
|
rectangle4,
|
||||||
rectangle1,
|
rectangle1,
|
||||||
@@ -498,7 +501,7 @@ describe("adding elements to frames", () => {
|
|||||||
frame3,
|
frame3,
|
||||||
rectangle2,
|
rectangle2,
|
||||||
frame2,
|
frame2,
|
||||||
];
|
]);
|
||||||
|
|
||||||
API.setSelectedElements([rectangle2]);
|
API.setSelectedElements([rectangle2]);
|
||||||
|
|
||||||
@@ -541,7 +544,7 @@ describe("adding elements to frames", () => {
|
|||||||
frameId: frame2.id,
|
frameId: frame2.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
h.elements = [rectangle1, rectangle2, frame1, frame2];
|
API.setElements([rectangle1, rectangle2, frame1, frame2]);
|
||||||
|
|
||||||
API.setSelectedElements([rectangle2]);
|
API.setSelectedElements([rectangle2]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { AppStateChange, ElementsChange } from "./change";
|
import type { AppStateChange, ElementsChange } from "./change";
|
||||||
import type { SceneElementsMap } from "./element/types";
|
import type { SceneElementsMap } from "./element/types";
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
import type Scene from "./scene/Scene";
|
|
||||||
import type { Snapshot } from "./store";
|
import type { Snapshot } from "./store";
|
||||||
import type { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ export class History {
|
|||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
snapshot: Readonly<Snapshot>,
|
||||||
scene: Scene,
|
|
||||||
) {
|
) {
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
@@ -73,7 +71,6 @@ export class History {
|
|||||||
snapshot,
|
snapshot,
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +78,6 @@ export class History {
|
|||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
snapshot: Readonly<Snapshot>,
|
||||||
scene: Scene,
|
|
||||||
) {
|
) {
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
@@ -89,7 +85,6 @@ export class History {
|
|||||||
snapshot,
|
snapshot,
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
scene,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +94,6 @@ export class History {
|
|||||||
snapshot: Readonly<Snapshot>,
|
snapshot: Readonly<Snapshot>,
|
||||||
pop: () => HistoryEntry | null,
|
pop: () => HistoryEntry | null,
|
||||||
push: (entry: HistoryEntry) => void,
|
push: (entry: HistoryEntry) => void,
|
||||||
scene: Scene,
|
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
try {
|
try {
|
||||||
let historyEntry = pop();
|
let historyEntry = pop();
|
||||||
@@ -116,7 +110,7 @@ export class History {
|
|||||||
while (historyEntry) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
historyEntry.applyTo(nextElements, nextAppState, snapshot, scene);
|
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
||||||
} finally {
|
} finally {
|
||||||
// make sure to always push / pop, even if the increment is corrupted
|
// make sure to always push / pop, even if the increment is corrupted
|
||||||
push(historyEntry);
|
push(historyEntry);
|
||||||
@@ -187,10 +181,9 @@ export class HistoryEntry {
|
|||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
snapshot: Readonly<Snapshot>,
|
||||||
scene: Scene,
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
const [nextElements, elementsContainVisibleChange] =
|
||||||
this.elementsChange.applyTo(elements, snapshot.elements, scene);
|
this.elementsChange.applyTo(elements, snapshot.elements);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
this.appStateChange.applyTo(appState, nextElements);
|
this.appStateChange.applyTo(appState, nextElements);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
const TIMEOUT = 2000;
|
||||||
|
|
||||||
|
export const useCopyStatus = () => {
|
||||||
|
const [copyStatus, setCopyStatus] = useState<"success" | null>(null);
|
||||||
|
const timeoutRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
setCopyStatus("success");
|
||||||
|
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopyStatus(null);
|
||||||
|
}, TIMEOUT);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyStatus,
|
||||||
|
onCopy,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -213,6 +213,7 @@ export {
|
|||||||
hashString,
|
hashString,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
|
getTextFromElements,
|
||||||
} from "./element";
|
} from "./element";
|
||||||
export { defaultLang, useI18n, languages } from "./i18n";
|
export { defaultLang, useI18n, languages } from "./i18n";
|
||||||
export {
|
export {
|
||||||
@@ -271,6 +272,7 @@ export { MainMenu };
|
|||||||
export { useDevice } from "./components/App";
|
export { useDevice } from "./components/App";
|
||||||
export { WelcomeScreen };
|
export { WelcomeScreen };
|
||||||
export { LiveCollaborationTrigger };
|
export { LiveCollaborationTrigger };
|
||||||
|
export { Stats } from "./components/Stats";
|
||||||
|
|
||||||
export { DefaultSidebar } from "./components/DefaultSidebar";
|
export { DefaultSidebar } from "./components/DefaultSidebar";
|
||||||
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
|
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
|
||||||
@@ -286,3 +288,6 @@ export {
|
|||||||
isElementInsideBBox,
|
isElementInsideBBox,
|
||||||
elementPartiallyOverlapsWithOrContainsBBox,
|
elementPartiallyOverlapsWithOrContainsBBox,
|
||||||
} from "../utils/withinBounds";
|
} from "../utils/withinBounds";
|
||||||
|
|
||||||
|
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||||
|
export { getDataURL } from "./data/blob";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user