Compare commits
43 Commits
mtolmacs/a
...
master
Author | SHA1 | Date | |
---|---|---|---|
5639bb8e87 | |||
![]() |
712f267519 | ||
![]() |
41a7613dff | ||
![]() |
95d89a751a | ||
![]() |
6b5fb30d69 | ||
![]() |
d92a849038 | ||
![]() |
0a534f1bc6 | ||
![]() |
4ca5f53b1f | ||
![]() |
f7dcc893ea | ||
![]() |
4dfb8a3f8e | ||
![]() |
298812e1d0 | ||
![]() |
35bb449a4b | ||
![]() |
c4c064982f | ||
![]() |
51dbd4831b | ||
![]() |
7e41026812 | ||
![]() |
a8ebe514da | ||
![]() |
a30e1b25c6 | ||
![]() |
ff2ed5d26a | ||
![]() |
e058a08b33 | ||
![]() |
a306a909a0 | ||
![]() |
3dc54a724a | ||
![]() |
a7c61319dd | ||
![]() |
cec5232a7a | ||
![]() |
d4f70e9f31 | ||
![]() |
e19fd1332a | ||
![]() |
6e655cdb24 | ||
![]() |
192c4e7658 | ||
![]() |
195a743874 | ||
![]() |
4a60fe3d22 | ||
![]() |
2a0d15799c | ||
![]() |
a18b139a60 | ||
![]() |
1913599594 | ||
![]() |
debf2ad608 | ||
![]() |
8fb2f70414 | ||
![]() |
5fc13e4309 | ||
![]() |
b5d60973b7 | ||
![]() |
a5d6939826 | ||
![]() |
0cf36d6b30 | ||
![]() |
58f7d33d80 | ||
![]() |
6fe7de8020 | ||
![]() |
01304aac49 | ||
![]() |
dff69e9191 | ||
![]() |
6fc85022ae |
@ -1,5 +1,5 @@
|
|||||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||||
|
|
||||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
@ -32,6 +32,12 @@
|
|||||||
"name": "jotai",
|
"name": "jotai",
|
||||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-no-target-blank": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allowReferrer": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
|||||||
- 🏗️ Customizable.
|
- 🏗️ Customizable.
|
||||||
- 📷 Image support.
|
- 📷 Image support.
|
||||||
- 😀 Shape libraries support.
|
- 😀 Shape libraries support.
|
||||||
- 👅 Localization (i18n) support.
|
- 🌐 Localization (i18n) support.
|
||||||
- 🖼️ Export to PNG, SVG & clipboard.
|
- 🖼️ Export to PNG, SVG & clipboard.
|
||||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
|
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
|
||||||
|
|
||||||
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node.
|
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
|
||||||
|
|
||||||
**Usage**
|
**Usage**
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This will only for `Desktop` devices.
|
This will only work for `Desktop` devices.
|
||||||
|
|
||||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||||
|
|
||||||
@ -65,4 +65,4 @@ const App = () => (
|
|||||||
// Need to render when code is span across multiple components
|
// Need to render when code is span across multiple components
|
||||||
// in Live Code blocks editor
|
// in Live Code blocks editor
|
||||||
render(<App />);
|
render(<App />);
|
||||||
```
|
```
|
||||||
|
@ -31,6 +31,7 @@ All `props` are _optional_.
|
|||||||
| [`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[]` \| <code>((link: string) => boolean | undefined)</code> | \_ | use for custom src url validation |
|
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean | undefined)</code> | \_ | 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>` |
|
||||||
|
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
|
||||||
|
|
||||||
### Storing custom data on Excalidraw elements
|
### Storing custom data on Excalidraw elements
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ services:
|
|||||||
- ./:/opt/node_app/app:delegated
|
- ./:/opt/node_app/app:delegated
|
||||||
- ./package.json:/opt/node_app/package.json
|
- ./package.json:/opt/node_app/package.json
|
||||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||||
- notused:/opt/node_app/app/node_modules
|
# - notused:/opt/node_app/app/node_modules
|
||||||
|
|
||||||
volumes:
|
# volumes:
|
||||||
notused:
|
# notused:
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw .panelColumn {
|
.excalidraw .selected-shape-actions {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +104,7 @@ export default function ExampleApp({
|
|||||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||||
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
||||||
|
const [renderScrollbars, setRenderScrollbars] = useState(false);
|
||||||
const [blobUrl, setBlobUrl] = useState<string>("");
|
const [blobUrl, setBlobUrl] = useState<string>("");
|
||||||
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
||||||
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
||||||
@ -192,6 +193,7 @@ export default function ExampleApp({
|
|||||||
}) => setPointerData(payload),
|
}) => setPointerData(payload),
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
|
renderScrollbars,
|
||||||
gridModeEnabled,
|
gridModeEnabled,
|
||||||
theme,
|
theme,
|
||||||
name: "Custom name of drawing",
|
name: "Custom name of drawing",
|
||||||
@ -710,6 +712,14 @@ export default function ExampleApp({
|
|||||||
/>
|
/>
|
||||||
Grid mode
|
Grid mode
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={renderScrollbars}
|
||||||
|
onChange={() => setRenderScrollbars(!renderScrollbars)}
|
||||||
|
/>
|
||||||
|
Render scrollbars
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -47,10 +47,10 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import { isElementLink } from "@excalidraw/element/elementLink";
|
import { isElementLink } from "@excalidraw/element";
|
||||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
@ -926,16 +926,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
<ShareDialog
|
<ShareDialog
|
||||||
collabAPI={collabAPI}
|
collabAPI={collabAPI}
|
||||||
onExportToBackend={async () => {
|
onExportToBackend={async () => {
|
||||||
if (excalidrawAPI) {
|
if (!excalidrawAPI) {
|
||||||
try {
|
return;
|
||||||
await onExportToBackend(
|
}
|
||||||
excalidrawAPI.getSceneElements(),
|
try {
|
||||||
excalidrawAPI.getAppState(),
|
const { url, errorMessage } = await exportToBackend(
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getSceneElements(),
|
||||||
);
|
excalidrawAPI.getAppState(),
|
||||||
} catch (error: any) {
|
excalidrawAPI.getFiles(),
|
||||||
setErrorMessage(error.message);
|
);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
setLatestShareableLink(url);
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrorMessage(error.message);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -19,12 +19,9 @@ import {
|
|||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import {
|
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||||
isImageElement,
|
|
||||||
isInitializedImageElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
|
@ -73,7 +73,7 @@ export const AIComponents = ({
|
|||||||
</br>
|
</br>
|
||||||
<div>You can also try <a href="${
|
<div>You can also try <a href="${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
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>
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/common";
|
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
|||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
>
|
>
|
||||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||||
|
@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
|
|||||||
import.meta.env.VITE_APP_PLUS_APP
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener"
|
||||||
className="plus-button"
|
className="plus-button"
|
||||||
>
|
>
|
||||||
Go to Excalidraw+
|
Go to Excalidraw+
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -9,14 +9,14 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "@excalidraw/element";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { bytesToHexString } from "@excalidraw/common";
|
import { bytesToHexString } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
import type { SceneBounds } from "@excalidraw/element";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
@ -41,8 +41,8 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
"build:app:docker": "vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
"build:app": "vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
|
@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<a
|
<a
|
||||||
class="welcome-screen-menu-item "
|
class="welcome-screen-menu-item "
|
||||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||||
rel="noreferrer"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -3,11 +3,15 @@ import {
|
|||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||||
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncInvalidIndices } from "@excalidraw/element";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import { StoreIncrement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
|
|||||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
*/
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
|
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||||
|
const durableIncrements: DurableIncrement[] = [];
|
||||||
|
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||||
|
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||||
|
if (StoreIncrement.isDurable(increment)) {
|
||||||
|
durableIncrements.push(increment);
|
||||||
|
} else {
|
||||||
|
ephemeralIncrements.push(increment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||||
|
expect(durableIncrements.length).toBe(0);
|
||||||
|
expect(ephemeralIncrements.length).toBe(0);
|
||||||
|
|
||||||
|
const rectProps = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "A",
|
||||||
|
height: 200,
|
||||||
|
width: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect = API.createElement({ ...rectProps });
|
||||||
|
|
||||||
|
API.updateScene({
|
||||||
|
elements: [rect],
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(durableIncrements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate two batched remote updates
|
||||||
|
act(() => {
|
||||||
|
h.app.updateScene({
|
||||||
|
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
h.app.updateScene({
|
||||||
|
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// we scheduled two micro actions,
|
||||||
|
// which confirms they are going to be executed as part of one batched component update
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// altough the updates get batched,
|
||||||
|
// we expect two ephemeral increments for each update,
|
||||||
|
// and each such update should have the expected change
|
||||||
|
expect(ephemeralIncrements.length).toBe(2);
|
||||||
|
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||||
|
expect.objectContaining({ x: 100 }),
|
||||||
|
);
|
||||||
|
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||||
|
expect.objectContaining({ x: 200 }),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line dot-notation
|
||||||
|
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
const rect1Props = {
|
const rect1Props = {
|
||||||
@ -122,7 +199,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
@ -154,7 +231,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(redoAction));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.7.2",
|
||||||
@ -78,8 +79,8 @@
|
|||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"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:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
"types": "./dist/types/common/src/*.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|||||||
export const isWindows = /^Win/.test(navigator.platform);
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||||
export const isFirefox =
|
export const isFirefox =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
"netscape" in window &&
|
"netscape" in window &&
|
||||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||||
navigator.userAgent.indexOf("Gecko") > 1;
|
navigator.userAgent.indexOf("Gecko") > 1;
|
||||||
@ -112,12 +113,14 @@ export const YOUTUBE_STATES = {
|
|||||||
export const ENV = {
|
export const ENV = {
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
DEVELOPMENT: "development",
|
DEVELOPMENT: "development",
|
||||||
|
PRODUCTION: "production",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@ -141,6 +144,7 @@ export const FONT_FAMILY = {
|
|||||||
"Lilita One": 7,
|
"Lilita One": 7,
|
||||||
"Comic Shanns": 8,
|
"Comic Shanns": 8,
|
||||||
"Liberation Sans": 9,
|
"Liberation Sans": 9,
|
||||||
|
Assistant: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FONT_FAMILY_FALLBACKS = {
|
export const FONT_FAMILY_FALLBACKS = {
|
||||||
@ -252,7 +256,7 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_SOURCE =
|
export const getExportSource = () =>
|
||||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
@ -318,6 +322,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
|||||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
`;
|
||||||
|
|
||||||
export const ENCRYPTION_KEY_BITS = 128;
|
export const ENCRYPTION_KEY_BITS = 128;
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { UnsubscribeCallback } from "./types";
|
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||||
|
|
@ -22,8 +22,10 @@ export interface FontMetadata {
|
|||||||
};
|
};
|
||||||
/** flag to indicate a deprecated font */
|
/** flag to indicate a deprecated font */
|
||||||
deprecated?: true;
|
deprecated?: true;
|
||||||
/** flag to indicate a server-side only font */
|
/**
|
||||||
serverSide?: true;
|
* whether this is a font that users can use (= shown in font picker)
|
||||||
|
*/
|
||||||
|
private?: true;
|
||||||
/** flag to indiccate a local-only font */
|
/** flag to indiccate a local-only font */
|
||||||
local?: true;
|
local?: true;
|
||||||
/** flag to indicate a fallback font */
|
/** flag to indicate a fallback font */
|
||||||
@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 1011,
|
ascender: 1011,
|
||||||
descender: -353,
|
descender: -353,
|
||||||
lineHeight: 1.35,
|
lineHeight: 1.25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[FONT_FAMILY["Lilita One"]]: {
|
[FONT_FAMILY["Lilita One"]]: {
|
||||||
@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
descender: -434,
|
descender: -434,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
},
|
},
|
||||||
serverSide: true,
|
private: true,
|
||||||
|
},
|
||||||
|
[FONT_FAMILY.Assistant]: {
|
||||||
|
metrics: {
|
||||||
|
unitsPerEm: 2048,
|
||||||
|
ascender: 1021,
|
||||||
|
descender: -287,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
},
|
||||||
|
private: true,
|
||||||
},
|
},
|
||||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||||
metrics: {
|
metrics: {
|
||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 880,
|
ascender: 880,
|
||||||
descender: -144,
|
descender: -144,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.25,
|
||||||
},
|
},
|
||||||
fallback: true,
|
fallback: true,
|
||||||
},
|
},
|
||||||
|
@ -9,4 +9,4 @@ export * from "./promise-pool";
|
|||||||
export * from "./random";
|
export * from "./random";
|
||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./visualdebug";
|
export * from "./emitter";
|
||||||
|
@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||||||
|
|
||||||
// get union of all keys from the union of types
|
// get union of all keys from the union of types
|
||||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|
||||||
|
/** Strip all the methods or functions from a type */
|
||||||
|
export type DTO<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||||
|
? [K, V]
|
||||||
|
: never;
|
||||||
|
82
packages/common/src/utils.test.ts
Normal file
82
packages/common/src/utils.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
isTransparent,
|
||||||
|
mapFind,
|
||||||
|
reduceToCommonValue,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
describe("@excalidraw/common/utils", () => {
|
||||||
|
describe("isTransparent()", () => {
|
||||||
|
it("should return true when color is rgb transparent", () => {
|
||||||
|
expect(isTransparent("#ff00")).toEqual(true);
|
||||||
|
expect(isTransparent("#fff00000")).toEqual(true);
|
||||||
|
expect(isTransparent("transparent")).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when color is not transparent", () => {
|
||||||
|
expect(isTransparent("#ced4da")).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reduceToCommonValue()", () => {
|
||||||
|
it("should return the common value when all values are the same", () => {
|
||||||
|
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||||
|
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||||
|
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||||
|
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||||
|
expect(reduceToCommonValue([""])).toEqual("");
|
||||||
|
expect(reduceToCommonValue([0])).toEqual(0);
|
||||||
|
|
||||||
|
const o = {};
|
||||||
|
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||||
|
).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return `null` when values are different", () => {
|
||||||
|
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return `null` when some values are nullable", () => {
|
||||||
|
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([null])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||||
|
expect(reduceToCommonValue([])).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapFind()", () => {
|
||||||
|
it("should return the first mapped non-null element", () => {
|
||||||
|
{
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
const result = mapFind(["a", "b", "c"], (value) => {
|
||||||
|
counter++;
|
||||||
|
return value === "b" ? 42 : null;
|
||||||
|
});
|
||||||
|
expect(result).toEqual(42);
|
||||||
|
expect(counter).toBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||||
|
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||||
|
expect(mapFind([1, 2], () => "")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined if no mapped element is found", () => {
|
||||||
|
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||||
|
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,9 +1,10 @@
|
|||||||
import { average } from "@excalidraw/math";
|
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
|
ExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -543,6 +544,20 @@ export const findLastIndex = <T>(
|
|||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** returns the first non-null mapped value */
|
||||||
|
export const mapFind = <T, K>(
|
||||||
|
collection: readonly T[],
|
||||||
|
iteratee: (value: T, index: number) => K | undefined | null,
|
||||||
|
): K | undefined => {
|
||||||
|
for (let idx = 0; idx < collection.length; idx++) {
|
||||||
|
const result = iteratee(collection[idx], idx);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const isTransparent = (color: string) => {
|
export const isTransparent = (color: string) => {
|
||||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||||
@ -679,7 +694,7 @@ export const arrayToMap = <T extends { id: string } | string>(
|
|||||||
return items.reduce((acc: Map<string, T>, element) => {
|
return items.reduce((acc: Map<string, T>, element) => {
|
||||||
acc.set(typeof element === "string" ? element : element.id, element);
|
acc.set(typeof element === "string" ? element : element.id, element);
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map());
|
}, new Map() as Map<string, T>);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const arrayToMapWithIndex = <T extends { id: string }>(
|
export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||||
@ -734,10 +749,31 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, [] as Node<T>[]);
|
}, [] as Node<T>[]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a readonly array or map into an iterable.
|
||||||
|
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||||
|
*/
|
||||||
|
export const toIterable = <T>(
|
||||||
|
values: readonly T[] | ReadonlyMap<string, T>,
|
||||||
|
): Iterable<T> => {
|
||||||
|
return Array.isArray(values) ? values : values.values();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a readonly array or map into an array.
|
||||||
|
*/
|
||||||
|
export const toArray = <T>(
|
||||||
|
values: readonly T[] | ReadonlyMap<string, T>,
|
||||||
|
): T[] => {
|
||||||
|
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||||
|
};
|
||||||
|
|
||||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||||
|
|
||||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||||
|
|
||||||
|
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
|
||||||
|
|
||||||
export const isServerEnv = () =>
|
export const isServerEnv = () =>
|
||||||
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
||||||
|
|
||||||
@ -1201,3 +1237,60 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||||||
|
|
||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
const { x, y, width, height } = element;
|
||||||
|
|
||||||
|
const centerXPoint = x + width / 2 + xOffset;
|
||||||
|
|
||||||
|
const centerYPoint = y + height / 2 + yOffset;
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||||
|
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||||
|
return Array.isArray(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sizeOf = (
|
||||||
|
value:
|
||||||
|
| readonly unknown[]
|
||||||
|
| Readonly<Map<string, unknown>>
|
||||||
|
| Readonly<Record<string, unknown>>
|
||||||
|
| ReadonlySet<unknown>,
|
||||||
|
): number => {
|
||||||
|
return isReadonlyArray(value)
|
||||||
|
? value.length
|
||||||
|
: value instanceof Map || value instanceof Set
|
||||||
|
? value.size
|
||||||
|
: Object.keys(value).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduceToCommonValue = <T, R = T>(
|
||||||
|
collection: readonly T[] | ReadonlySet<T>,
|
||||||
|
getValue?: (item: T) => R,
|
||||||
|
): R | null => {
|
||||||
|
if (sizeOf(collection) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||||
|
|
||||||
|
let commonValue: R | null = null;
|
||||||
|
|
||||||
|
for (const item of collection) {
|
||||||
|
const value = valueExtractor(item);
|
||||||
|
if ((commonValue === null || commonValue === value) && value != null) {
|
||||||
|
commonValue = value;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commonValue;
|
||||||
|
};
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
"types": "./dist/types/element/src/*.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,20 +6,22 @@ import {
|
|||||||
toBrandedType,
|
toBrandedType,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
|
toArray,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isNonDeletedElement } from "@excalidraw/element";
|
import { isNonDeletedElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
import { getElementsInGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
} from "@excalidraw/element/fractionalIndex";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { getSelectedElements } from "@excalidraw/element/selection";
|
import { getSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
@ -32,12 +34,13 @@ import type {
|
|||||||
Ordered,
|
Ordered,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Assert, SameType } from "@excalidraw/common/utility-types";
|
import type {
|
||||||
|
Assert,
|
||||||
|
Mutable,
|
||||||
|
SameType,
|
||||||
|
} from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../../excalidraw/types";
|
||||||
|
|
||||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
|
||||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
|
||||||
|
|
||||||
type SceneStateCallback = () => void;
|
type SceneStateCallback = () => void;
|
||||||
type SceneStateCallbackRemover = () => void;
|
type SceneStateCallbackRemover = () => void;
|
||||||
@ -102,44 +105,7 @@ const hashSelectionOpts = (
|
|||||||
// in our codebase
|
// in our codebase
|
||||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||||
|
|
||||||
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
|
export class Scene {
|
||||||
if (typeof elementKey === "string") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Scene {
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// static methods/props
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
|
||||||
private static sceneMapById = new Map<string, Scene>();
|
|
||||||
|
|
||||||
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
|
|
||||||
if (isIdKey(elementKey)) {
|
|
||||||
// for cases where we don't have access to the element object
|
|
||||||
// (e.g. restore serialized appState with id references)
|
|
||||||
this.sceneMapById.set(elementKey, scene);
|
|
||||||
} else {
|
|
||||||
this.sceneMapByElement.set(elementKey, scene);
|
|
||||||
// if mapping element objects, also cache the id string when later
|
|
||||||
// looking up by id alone
|
|
||||||
this.sceneMapById.set(elementKey.id, scene);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated pass down `app.scene` and use it directly
|
|
||||||
*/
|
|
||||||
static getScene(elementKey: ElementKey): Scene | null {
|
|
||||||
if (isIdKey(elementKey)) {
|
|
||||||
return this.sceneMapById.get(elementKey) || null;
|
|
||||||
}
|
|
||||||
return this.sceneMapByElement.get(elementKey) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// instance methods/props
|
// instance methods/props
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -198,6 +164,12 @@ class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(elements: ElementsMapOrArray | null = null) {
|
||||||
|
if (elements) {
|
||||||
|
this.replaceAllElements(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getSelectedElements(opts: {
|
getSelectedElements(opts: {
|
||||||
// NOTE can be ommitted by making Scene constructor require App instance
|
// NOTE can be ommitted by making Scene constructor require App instance
|
||||||
selectedElementIds: AppState["selectedElementIds"];
|
selectedElementIds: AppState["selectedElementIds"];
|
||||||
@ -292,11 +264,8 @@ class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
const _nextElements =
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
const _nextElements = toArray(nextElements);
|
||||||
nextElements instanceof Array
|
|
||||||
? nextElements
|
|
||||||
: Array.from(nextElements.values());
|
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(_nextElements);
|
validateIndicesThrottled(_nextElements);
|
||||||
@ -308,7 +277,6 @@ class Scene {
|
|||||||
nextFrameLikes.push(element);
|
nextFrameLikes.push(element);
|
||||||
}
|
}
|
||||||
this.elementsMap.set(element.id, element);
|
this.elementsMap.set(element.id, element);
|
||||||
Scene.mapElementToScene(element, this);
|
|
||||||
});
|
});
|
||||||
const nonDeletedElements = getNonDeletedElements(this.elements);
|
const nonDeletedElements = getNonDeletedElements(this.elements);
|
||||||
this.nonDeletedElements = nonDeletedElements.elements;
|
this.nonDeletedElements = nonDeletedElements.elements;
|
||||||
@ -353,12 +321,6 @@ class Scene {
|
|||||||
this.selectedElementsCache.elements = null;
|
this.selectedElementsCache.elements = null;
|
||||||
this.selectedElementsCache.cache.clear();
|
this.selectedElementsCache.cache.clear();
|
||||||
|
|
||||||
Scene.sceneMapById.forEach((scene, elementKey) => {
|
|
||||||
if (scene === this) {
|
|
||||||
Scene.sceneMapById.delete(elementKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// done not for memory leaks, but to guard against possible late fires
|
// done not for memory leaks, but to guard against possible late fires
|
||||||
// (I guess?)
|
// (I guess?)
|
||||||
this.callbacks.clear();
|
this.callbacks.clear();
|
||||||
@ -455,6 +417,40 @@ class Scene {
|
|||||||
// then, check if the id is a group
|
// then, check if the id is a group
|
||||||
return getElementsInGroup(elementsMap, id);
|
return getElementsInGroup(elementsMap, id);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default Scene;
|
// Mutate an element with passed updates and trigger the component to update. Make sure you
|
||||||
|
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||||
|
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||||
|
element: TElement,
|
||||||
|
updates: ElementUpdate<TElement>,
|
||||||
|
options: {
|
||||||
|
informMutation: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
} = {
|
||||||
|
informMutation: true,
|
||||||
|
isDragging: false,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const elementsMap = this.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
const { version: prevVersion } = element;
|
||||||
|
const { version: nextVersion } = mutateElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
updates,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
// skip if the element is not in the scene (i.e. selection)
|
||||||
|
this.elementsMap.has(element.id) &&
|
||||||
|
// skip if the element's version hasn't changed, as mutateElement returned the same element
|
||||||
|
prevVersion !== nextVersion &&
|
||||||
|
options.informMutation
|
||||||
|
) {
|
||||||
|
this.triggerUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
@ -437,12 +437,26 @@ export const _generateElementShape = (
|
|||||||
: [pointFrom<LocalPoint>(0, 0)];
|
: [pointFrom<LocalPoint>(0, 0)];
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
shape = [
|
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
|
||||||
generator.path(
|
if (
|
||||||
generateElbowArrowShape(points, 16),
|
!points.every(
|
||||||
generateRoughOptions(element, true),
|
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
|
||||||
),
|
)
|
||||||
];
|
) {
|
||||||
|
console.error(
|
||||||
|
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
|
||||||
|
element.id,
|
||||||
|
JSON.stringify(points),
|
||||||
|
);
|
||||||
|
shape = [];
|
||||||
|
} else {
|
||||||
|
shape = [
|
||||||
|
generator.path(
|
||||||
|
generateElbowArrowShape(points, 16),
|
||||||
|
generateRoughOptions(element, true),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
} else if (!element.roundness) {
|
} else if (!element.roundness) {
|
||||||
// curve is always the first element
|
// curve is always the first element
|
||||||
// this simplifies finding the curve for an element
|
// this simplifies finding the curve for an element
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@ -15,10 +14,10 @@ export interface Alignment {
|
|||||||
|
|
||||||
export const alignElements = (
|
export const alignElements = (
|
||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -33,12 +32,13 @@ export const alignElements = (
|
|||||||
);
|
);
|
||||||
return group.map((element) => {
|
return group.map((element) => {
|
||||||
// update element
|
// update element
|
||||||
const updatedEle = mutateElement(element, {
|
const updatedEle = scene.mutateElement(element, {
|
||||||
x: element.x + translation.x,
|
x: element.x + translation.x,
|
||||||
y: element.y + translation.y,
|
y: element.y + translation.y,
|
||||||
});
|
});
|
||||||
|
|
||||||
// update bound elements
|
// update bound elements
|
||||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: group,
|
simultaneouslyUpdated: group,
|
||||||
});
|
});
|
||||||
return updatedEle;
|
return updatedEle;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,17 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
invariant,
|
||||||
|
rescalePoints,
|
||||||
|
sizeOf,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
pointDistance,
|
||||||
|
pointFrom,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
@ -28,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
import { generateRoughOptions } from "./Shape";
|
||||||
|
import { ShapeCache } from "./ShapeCache";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -47,19 +52,20 @@ import {
|
|||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
Arrowhead,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
NonDeleted,
|
|
||||||
ExcalidrawTextElementWithContainer,
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
ExcalidrawEllipseElement,
|
|
||||||
} from "./types";
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
import type {
|
||||||
|
Arrowhead,
|
||||||
|
ElementsMap,
|
||||||
|
ElementsMapOrArray,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawRectanguloidElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
|
NonDeleted,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
@ -938,10 +944,10 @@ export const getElementBounds = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: ElementsMapOrArray,
|
||||||
elementsMap?: ElementsMap,
|
elementsMap?: ElementsMap,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
if (!elements.length) {
|
if (!sizeOf(elements)) {
|
||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { isTransparent } from "@excalidraw/common";
|
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||||
import { getPolygonShape } from "@excalidraw/utils/shape";
|
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
@ -26,8 +26,6 @@ import type {
|
|||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||||
@ -191,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
@ -253,10 +248,7 @@ const intersectDiamondWithLineSegment = (
|
|||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@ -304,10 +296,7 @@ const intersectEllipseWithLineSegment = (
|
|||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
@ -14,6 +14,8 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
@ -61,7 +63,7 @@ export const cropElement = (
|
|||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
elementCenterPoint(element),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -5,41 +5,7 @@ import {
|
|||||||
isDevEnv,
|
isDevEnv,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
toBrandedType,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
|
||||||
BoundElement,
|
|
||||||
BindableElement,
|
|
||||||
bindingProperties,
|
|
||||||
updateBoundElements,
|
|
||||||
} from "@excalidraw/element/binding";
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
|
||||||
import {
|
|
||||||
mutateElement,
|
|
||||||
newElementWith,
|
|
||||||
} from "@excalidraw/element/mutateElement";
|
|
||||||
import {
|
|
||||||
getBoundTextElementId,
|
|
||||||
redrawTextBoundingBox,
|
|
||||||
} from "@excalidraw/element/textElement";
|
|
||||||
import {
|
|
||||||
hasBoundTextElement,
|
|
||||||
isBindableElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isImageElement,
|
|
||||||
isTextElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
|
|
||||||
|
|
||||||
import {
|
|
||||||
orderByFractionalIndex,
|
|
||||||
syncMovedIndices,
|
|
||||||
} from "@excalidraw/element/fractionalIndex";
|
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
|
|
||||||
|
|
||||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -52,16 +18,42 @@ import type {
|
|||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getObservedAppState } from "./store";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
ObservedAppState,
|
ObservedAppState,
|
||||||
ObservedElementsAppState,
|
ObservedElementsAppState,
|
||||||
ObservedStandaloneAppState,
|
ObservedStandaloneAppState,
|
||||||
} from "./types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
|
import { getObservedAppState } from "./store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoundElement,
|
||||||
|
BindableElement,
|
||||||
|
bindingProperties,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "./binding";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
|
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isBindableElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
isTextElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import { getNonDeletedGroupIds } from "./groups";
|
||||||
|
|
||||||
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the difference between two objects of the same type.
|
* Represents the difference between two objects of the same type.
|
||||||
@ -72,7 +64,7 @@ import type {
|
|||||||
*
|
*
|
||||||
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
||||||
*/
|
*/
|
||||||
class Delta<T> {
|
export class Delta<T> {
|
||||||
private constructor(
|
private constructor(
|
||||||
public readonly deleted: Partial<T>,
|
public readonly deleted: Partial<T>,
|
||||||
public readonly inserted: Partial<T>,
|
public readonly inserted: Partial<T>,
|
||||||
@ -195,10 +187,12 @@ class Delta<T> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isDeletedObject =
|
||||||
typeof deleted[property] === "object" ||
|
deleted[property] !== null && typeof deleted[property] === "object";
|
||||||
typeof inserted[property] === "object"
|
const isInsertedObject =
|
||||||
) {
|
inserted[property] !== null && typeof inserted[property] === "object";
|
||||||
|
|
||||||
|
if (isDeletedObject || isInsertedObject) {
|
||||||
type RecordLike = Record<string, V | undefined>;
|
type RecordLike = Record<string, V | undefined>;
|
||||||
|
|
||||||
const deletedObject: RecordLike = deleted[property] ?? {};
|
const deletedObject: RecordLike = deleted[property] ?? {};
|
||||||
@ -230,6 +224,9 @@ class Delta<T> {
|
|||||||
Reflect.deleteProperty(deleted, property);
|
Reflect.deleteProperty(deleted, property);
|
||||||
Reflect.deleteProperty(inserted, property);
|
Reflect.deleteProperty(inserted, property);
|
||||||
}
|
}
|
||||||
|
} else if (deleted[property] === inserted[property]) {
|
||||||
|
Reflect.deleteProperty(deleted, property);
|
||||||
|
Reflect.deleteProperty(inserted, property);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,7 +321,7 @@ class Delta<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the object1 keys that have distinct values.
|
* Returns sorted object1 keys that have distinct values.
|
||||||
*/
|
*/
|
||||||
public static getLeftDifferences<T extends {}>(
|
public static getLeftDifferences<T extends {}>(
|
||||||
object1: T,
|
object1: T,
|
||||||
@ -333,11 +330,11 @@ class Delta<T> {
|
|||||||
) {
|
) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
||||||
);
|
).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the object2 keys that have distinct values.
|
* Returns sorted object2 keys that have distinct values.
|
||||||
*/
|
*/
|
||||||
public static getRightDifferences<T extends {}>(
|
public static getRightDifferences<T extends {}>(
|
||||||
object1: T,
|
object1: T,
|
||||||
@ -346,7 +343,7 @@ class Delta<T> {
|
|||||||
) {
|
) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
||||||
);
|
).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -407,51 +404,57 @@ class Delta<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the modifications captured as `Delta`/s.
|
* Encapsulates a set of application-level `Delta`s.
|
||||||
*/
|
*/
|
||||||
interface Change<T> {
|
export interface DeltaContainer<T> {
|
||||||
/**
|
/**
|
||||||
* Inverses the `Delta`s inside while creating a new `Change`.
|
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
|
||||||
*/
|
*/
|
||||||
inverse(): Change<T>;
|
inverse(): DeltaContainer<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the `Change` to the previous object.
|
* Applies the `Delta`s to the previous object.
|
||||||
*
|
*
|
||||||
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
|
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
|
||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether there are actually `Delta`s.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
isEmpty(): boolean;
|
isEmpty(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateChange implements Change<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
nextAppState: T,
|
nextAppState: T,
|
||||||
): AppStateChange {
|
): AppStateDelta {
|
||||||
const delta = Delta.calculate(
|
const delta = Delta.calculate(
|
||||||
prevAppState,
|
prevAppState,
|
||||||
nextAppState,
|
nextAppState,
|
||||||
undefined,
|
// making the order of keys in deltas stable for hashing purposes
|
||||||
AppStateChange.postProcess,
|
AppStateDelta.orderAppStateKeys,
|
||||||
|
AppStateDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new AppStateChange(delta);
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||||
|
const { delta } = appStateDeltaDTO;
|
||||||
|
return new AppStateDelta(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return new AppStateChange(Delta.create({}, {}));
|
return new AppStateDelta(Delta.create({}, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): AppStateChange {
|
public inverse(): AppStateDelta {
|
||||||
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
return new AppStateChange(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
@ -490,6 +493,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
nextElements.get(
|
nextElements.get(
|
||||||
selectedLinearElementId,
|
selectedLinearElementId,
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
nextElements,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -499,6 +503,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
nextElements.get(
|
nextElements.get(
|
||||||
editingLinearElementId,
|
editingLinearElementId,
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
nextElements,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -540,40 +545,6 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
return Delta.isEmpty(this.delta);
|
return Delta.isEmpty(this.delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* It is necessary to post process the partials in case of reference values,
|
|
||||||
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
|
||||||
*/
|
|
||||||
private static postProcess<T extends ObservedAppState>(
|
|
||||||
deleted: Partial<T>,
|
|
||||||
inserted: Partial<T>,
|
|
||||||
): [Partial<T>, Partial<T>] {
|
|
||||||
try {
|
|
||||||
Delta.diffObjects(
|
|
||||||
deleted,
|
|
||||||
inserted,
|
|
||||||
"selectedElementIds",
|
|
||||||
// ts language server has a bit trouble resolving this, so we are giving it a little push
|
|
||||||
(_) => true as ValueOf<T["selectedElementIds"]>,
|
|
||||||
);
|
|
||||||
Delta.diffObjects(
|
|
||||||
deleted,
|
|
||||||
inserted,
|
|
||||||
"selectedGroupIds",
|
|
||||||
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
|
||||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
return [deleted, inserted];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutates `nextAppState` be filtering out state related to deleted elements.
|
* Mutates `nextAppState` be filtering out state related to deleted elements.
|
||||||
*
|
*
|
||||||
@ -590,13 +561,13 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
const nextObservedAppState = getObservedAppState(nextAppState);
|
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containsElementsDifference = Delta.isRightDifferent(
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
@ -611,8 +582,8 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
if (containsElementsDifference) {
|
if (containsElementsDifference) {
|
||||||
// filter invisible changes on each iteration
|
// filter invisible changes on each iteration
|
||||||
const changedElementsProps = Delta.getRightDifferences(
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
) as Array<keyof ObservedElementsAppState>;
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
let nonDeletedGroupIds = new Set<string>();
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
@ -629,7 +600,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
for (const key of changedElementsProps) {
|
for (const key of changedElementsProps) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "selectedElementIds":
|
case "selectedElementIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nextElements,
|
nextElements,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@ -637,7 +608,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedGroupIds":
|
case "selectedGroupIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nonDeletedGroupIds,
|
nonDeletedGroupIds,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@ -673,7 +644,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
break;
|
break;
|
||||||
case "selectedLinearElementId":
|
case "selectedLinearElementId":
|
||||||
case "editingLinearElementId":
|
case "editingLinearElementId":
|
||||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
const linearElement = nextAppState[appStateKey];
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!linearElement) {
|
if (!linearElement) {
|
||||||
@ -692,6 +663,24 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
case "lockedMultiSelections": {
|
||||||
|
const prevLockedUnits = prevAppState[key] || {};
|
||||||
|
const nextLockedUnits = nextAppState[key] || {};
|
||||||
|
|
||||||
|
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "activeLockedId": {
|
||||||
|
const prevHitLockedId = prevAppState[key] || null;
|
||||||
|
const nextHitLockedId = nextAppState[key] || null;
|
||||||
|
|
||||||
|
if (prevHitLockedId !== nextHitLockedId) {
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
assertNever(
|
assertNever(
|
||||||
key,
|
key,
|
||||||
@ -787,6 +776,8 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
editingLinearElementId,
|
editingLinearElementId,
|
||||||
selectedLinearElementId,
|
selectedLinearElementId,
|
||||||
croppingElementId,
|
croppingElementId,
|
||||||
|
lockedMultiSelections,
|
||||||
|
activeLockedId,
|
||||||
...standaloneProps
|
...standaloneProps
|
||||||
} = delta as ObservedAppState;
|
} = delta as ObservedAppState;
|
||||||
|
|
||||||
@ -808,6 +799,63 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
ObservedElementsAppState
|
ObservedElementsAppState
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It is necessary to post process the partials in case of reference values,
|
||||||
|
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
||||||
|
*/
|
||||||
|
private static postProcess<T extends ObservedAppState>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
): [Partial<T>, Partial<T>] {
|
||||||
|
try {
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"selectedElementIds",
|
||||||
|
// ts language server has a bit trouble resolving this, so we are giving it a little push
|
||||||
|
(_) => true as ValueOf<T["selectedElementIds"]>,
|
||||||
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"selectedGroupIds",
|
||||||
|
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
||||||
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"lockedMultiSelections",
|
||||||
|
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||||
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"activeLockedId",
|
||||||
|
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||||
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [deleted, inserted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
|
||||||
|
const orderedPartial: { [key: string]: unknown } = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(partial).sort()) {
|
||||||
|
// relying on insertion order
|
||||||
|
orderedPartial[key] = partial[key as keyof ObservedAppState];
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedPartial as Partial<ObservedAppState>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||||
@ -819,50 +867,63 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
|||||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||||
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
||||||
*/
|
*/
|
||||||
export class ElementsChange implements Change<SceneElementsMap> {
|
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly added: Map<string, Delta<ElementPartial>>,
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly removed: Map<string, Delta<ElementPartial>>,
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly updated: Map<string, Delta<ElementPartial>>,
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
added: Map<string, Delta<ElementPartial>>,
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
removed: Map<string, Delta<ElementPartial>>,
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
updated: Map<string, Delta<ElementPartial>>,
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
options = { shouldRedistribute: false },
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let change: ElementsChange;
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
if (options.shouldRedistribute) {
|
if (options.shouldRedistribute) {
|
||||||
const nextAdded = new Map<string, Delta<ElementPartial>>();
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextUpdated = new Map<string, Delta<ElementPartial>>();
|
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
const deltas = [...added, ...removed, ...updated];
|
const deltas = [
|
||||||
|
...Object.entries(added),
|
||||||
|
...Object.entries(removed),
|
||||||
|
...Object.entries(updated),
|
||||||
|
];
|
||||||
|
|
||||||
for (const [id, delta] of deltas) {
|
for (const [id, delta] of deltas) {
|
||||||
if (this.satisfiesAddition(delta)) {
|
if (this.satisfiesAddition(delta)) {
|
||||||
nextAdded.set(id, delta);
|
nextAdded[id] = delta;
|
||||||
} else if (this.satisfiesRemoval(delta)) {
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
nextRemoved.set(id, delta);
|
nextRemoved[id] = delta;
|
||||||
} else {
|
} else {
|
||||||
nextUpdated.set(id, delta);
|
nextUpdated[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
} else {
|
} else {
|
||||||
change = new ElementsChange(added, removed, updated);
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return change;
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static satisfiesAddition = ({
|
private static satisfiesAddition = ({
|
||||||
@ -884,17 +945,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
change: ElementsChange,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
satifies: (delta: Delta<ElementPartial>) => boolean,
|
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||||
) {
|
) {
|
||||||
for (const [id, delta] of change[type].entries()) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (!satifies(delta)) {
|
if (!satifies(delta)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
delta,
|
delta,
|
||||||
);
|
);
|
||||||
throw new Error(`ElementsChange invariant broken for element "${id}".`);
|
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -905,19 +966,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param prevElements - Map representing the previous state of elements.
|
* @param prevElements - Map representing the previous state of elements.
|
||||||
* @param nextElements - Map representing the next state of elements.
|
* @param nextElements - Map representing the next state of elements.
|
||||||
*
|
*
|
||||||
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
|
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
|
||||||
*/
|
*/
|
||||||
public static calculate<T extends OrderedExcalidrawElement>(
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
prevElements: Map<string, T>,
|
prevElements: Map<string, T>,
|
||||||
nextElements: Map<string, T>,
|
nextElements: Map<string, T>,
|
||||||
): ElementsChange {
|
): ElementsDelta {
|
||||||
if (prevElements === nextElements) {
|
if (prevElements === nextElements) {
|
||||||
return ElementsChange.empty();
|
return ElementsDelta.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = new Map<string, Delta<ElementPartial>>();
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
const removed = new Map<string, Delta<ElementPartial>>();
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
const updated = new Map<string, Delta<ElementPartial>>();
|
const updated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
||||||
for (const prevElement of prevElements.values()) {
|
for (const prevElement of prevElements.values()) {
|
||||||
@ -930,10 +991,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed.set(prevElement.id, delta);
|
removed[prevElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -950,10 +1011,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -962,8 +1023,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.calculate<ElementPartial>(
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
prevElement,
|
prevElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
ElementsChange.postProcess,
|
ElementsDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -974,9 +1035,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
// notice that other props could have been updated as well
|
// notice that other props could have been updated as well
|
||||||
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
} else {
|
} else {
|
||||||
removed.set(nextElement.id, delta);
|
removed[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -984,24 +1045,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// making sure there are at least some changes
|
// making sure there are at least some changes
|
||||||
if (!Delta.isEmpty(delta)) {
|
if (!Delta.isEmpty(delta)) {
|
||||||
updated.set(nextElement.id, delta);
|
updated[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated);
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return ElementsChange.create(new Map(), new Map(), new Map());
|
return ElementsDelta.create({}, {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): ElementsChange {
|
public inverse(): ElementsDelta {
|
||||||
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@ -1012,14 +1073,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const updated = inverseInternal(this.updated);
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
// notice we inverse removed with added not to break the invariants
|
// notice we inverse removed with added not to break the invariants
|
||||||
return ElementsChange.create(removed, added, updated);
|
return ElementsDelta.create(removed, added, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
public isEmpty(): boolean {
|
||||||
return (
|
return (
|
||||||
this.added.size === 0 &&
|
Object.keys(this.added).length === 0 &&
|
||||||
this.removed.size === 0 &&
|
Object.keys(this.removed).length === 0 &&
|
||||||
this.updated.size === 0
|
Object.keys(this.updated).length === 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1030,7 +1091,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
* @returns new instance with modified delta/s
|
* @returns new instance with modified delta/s
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
@ -1051,11 +1115,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyLatestChangesInternal = (
|
const applyLatestChangesInternal = (
|
||||||
deltas: Map<string, Delta<ElementPartial>>,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
|
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
const existingElement = elements.get(id);
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
if (existingElement) {
|
if (existingElement) {
|
||||||
@ -1063,12 +1127,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
delta.deleted,
|
delta.deleted,
|
||||||
delta.inserted,
|
delta.inserted,
|
||||||
modifier(existingElement),
|
modifier(existingElement),
|
||||||
"inserted",
|
modifierOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
modifiedDeltas.set(id, modifiedDelta);
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
} else {
|
} else {
|
||||||
modifiedDeltas.set(id, delta);
|
modifiedDeltas[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1079,16 +1143,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const removed = applyLatestChangesInternal(this.removed);
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
const updated = applyLatestChangesInternal(this.updated);
|
const updated = applyLatestChangesInternal(this.updated);
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated, {
|
return ElementsDelta.create(added, removed, updated, {
|
||||||
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@ -1098,15 +1162,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsChange.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas("added", this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
@ -1118,7 +1182,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
...affectedElements,
|
...affectedElements,
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Couldn't apply elements change`, e);
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@ -1132,19 +1196,22 @@ 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
|
|
||||||
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
|
||||||
|
|
||||||
// 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)
|
||||||
nextElements = ElementsChange.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||||
|
// we also don't have a scene on the server
|
||||||
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
|
const tempScene = new Scene(nextElements);
|
||||||
|
|
||||||
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
ElementsDelta.redrawBoundArrows(tempScene, 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`,
|
||||||
@ -1159,36 +1226,42 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createApplier = (
|
private static createApplier =
|
||||||
nextElements: SceneElementsMap,
|
(
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
nextElements: SceneElementsMap,
|
||||||
flags: {
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
containsVisibleDifference: boolean;
|
flags: {
|
||||||
containsZindexDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
},
|
containsZindexDifference: boolean;
|
||||||
) => {
|
},
|
||||||
const getElement = ElementsChange.createGetter(
|
) =>
|
||||||
nextElements,
|
(
|
||||||
snapshot,
|
type: "added" | "removed" | "updated",
|
||||||
flags,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
);
|
) => {
|
||||||
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
|
nextElements,
|
||||||
|
snapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
return (deltas: Map<string, Delta<ElementPartial>>) =>
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
|
|
||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(newElement.id, newElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map<string, OrderedExcalidrawElement>());
|
}, new Map<string, OrderedExcalidrawElement>());
|
||||||
};
|
};
|
||||||
|
|
||||||
private static createGetter =
|
private static createGetter =
|
||||||
(
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
@ -1214,6 +1287,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// not in elements, not in snapshot? element might have been added remotely!
|
||||||
|
element = newElementWith(
|
||||||
|
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||||
|
{
|
||||||
|
...partial,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1250,7 +1331,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImageElement(element)) {
|
// TODO: this looks wrong, shouldn't be here
|
||||||
|
if (element.type === "image") {
|
||||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
// we want to override `crop` only if modified so that we don't reset
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
// when undoing/redoing unrelated change
|
// when undoing/redoing unrelated change
|
||||||
@ -1263,10 +1345,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!flags.containsVisibleDifference) {
|
if (!flags.containsVisibleDifference) {
|
||||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||||
const { index, ...rest } = directlyApplicablePartial;
|
const { index, ...rest } = directlyApplicablePartial;
|
||||||
const containsVisibleDifference =
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
ElementsChange.checkForVisibleDifference(element, rest);
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
flags.containsVisibleDifference = containsVisibleDifference;
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
}
|
}
|
||||||
@ -1309,6 +1393,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* Resolves conflicts for all previously added, removed and updated elements.
|
* Resolves conflicts for all previously added, removed and updated elements.
|
||||||
* Updates the previous deltas with all the changes after conflict resolution.
|
* Updates the previous deltas with all the changes after conflict resolution.
|
||||||
*
|
*
|
||||||
|
* // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
|
||||||
|
*
|
||||||
* @returns all elements affected by the conflict resolution
|
* @returns all elements affected by the conflict resolution
|
||||||
*/
|
*/
|
||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
@ -1337,6 +1423,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
} else {
|
} else {
|
||||||
affectedElement = mutateElement(
|
affectedElement = mutateElement(
|
||||||
nextElement,
|
nextElement,
|
||||||
|
nextElements,
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1346,20 +1433,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
||||||
for (const [id] of this.removed) {
|
for (const id of Object.keys(this.removed)) {
|
||||||
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
||||||
for (const [id] of this.added) {
|
for (const id of Object.keys(this.added)) {
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
||||||
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
|
for (const [id] of Array.from(Object.entries(this.updated)).filter(
|
||||||
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
([_, delta]) =>
|
||||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||||
),
|
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||||
|
),
|
||||||
)) {
|
)) {
|
||||||
const updatedElement = nextElements.get(id);
|
const updatedElement = nextElements.get(id);
|
||||||
if (!updatedElement || updatedElement.isDeleted) {
|
if (!updatedElement || updatedElement.isDeleted) {
|
||||||
@ -1367,7 +1455,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter only previous elements, which were now affected
|
// filter only previous elements, which were now affected
|
||||||
@ -1377,21 +1465,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
// technically we could do better here if perf. would become an issue
|
||||||
const { added, removed, updated } = ElementsChange.calculate(
|
const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
prevAffectedElements,
|
prevAffectedElements,
|
||||||
nextAffectedElements,
|
nextAffectedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of added) {
|
for (const [id, delta] of Object.entries(added)) {
|
||||||
this.added.set(id, delta);
|
this.added[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of removed) {
|
for (const [id, delta] of Object.entries(removed)) {
|
||||||
this.removed.set(id, delta);
|
this.removed[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of updated) {
|
for (const [id, delta] of Object.entries(updated)) {
|
||||||
this.updated.set(id, delta);
|
this.updated[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
@ -1456,9 +1544,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static redrawTextBoundingBoxes(
|
private static redrawTextBoundingBoxes(
|
||||||
elements: SceneElementsMap,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
) {
|
) {
|
||||||
|
const elements = scene.getNonDeletedElementsMap();
|
||||||
const boxesToRedraw = new Map<
|
const boxesToRedraw = new Map<
|
||||||
string,
|
string,
|
||||||
{ container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
|
{ container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
|
||||||
@ -1498,17 +1587,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
redrawTextBoundingBox(boundText, container, elements, false);
|
redrawTextBoundingBox(boundText, container, scene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static redrawBoundArrows(
|
private static redrawBoundArrows(
|
||||||
elements: SceneElementsMap,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
) {
|
) {
|
||||||
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);
|
updateBoundElements(element, scene, {
|
||||||
|
changedElements: changed,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1561,7 +1652,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess elements change deltas.`);
|
console.error(`Couldn't postprocess elements delta.`);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@ -1574,8 +1665,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
): ElementPartial {
|
): ElementPartial {
|
||||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
partial;
|
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial;
|
||||||
}
|
}
|
@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
curvePointDistance,
|
curvePointDistance,
|
||||||
distanceToLineSegment,
|
distanceToLineSegment,
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -53,10 +54,7 @@ const distanceToRectanguloidElement = (
|
|||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
@ -84,10 +82,7 @@ const distanceToDiamondElement = (
|
|||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@ -115,10 +110,7 @@ const distanceToEllipseElement = (
|
|||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = pointFrom(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
|
@ -11,13 +11,10 @@ import type {
|
|||||||
PointerDownState,
|
PointerDownState,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBounds } from "./bounds";
|
import { getCommonBounds } from "./bounds";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { getMinTextElementWidth } from "./textMeasurements";
|
import { getMinTextElementWidth } from "./textMeasurements";
|
||||||
@ -29,6 +26,8 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
@ -104,7 +103,7 @@ export const dragSelectedElements = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||||
if (!isArrowElement(element)) {
|
if (!isArrowElement(element)) {
|
||||||
// skip arrow labels since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
const textElement = getBoundTextElement(
|
const textElement = getBoundTextElement(
|
||||||
@ -112,9 +111,14 @@ export const dragSelectedElements = (
|
|||||||
scene.getNonDeletedElementsMap(),
|
scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
if (textElement) {
|
if (textElement) {
|
||||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
updateElementCoords(
|
||||||
|
pointerDownState,
|
||||||
|
textElement,
|
||||||
|
scene,
|
||||||
|
adjustedOffset,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -155,6 +159,7 @@ const calculateOffset = (
|
|||||||
const updateElementCoords = (
|
const updateElementCoords = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
|
scene: Scene,
|
||||||
dragOffset: { x: number; y: number },
|
dragOffset: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
const originalElement =
|
const originalElement =
|
||||||
@ -163,7 +168,7 @@ const updateElementCoords = (
|
|||||||
const nextX = originalElement.x + dragOffset.x;
|
const nextX = originalElement.x + dragOffset.x;
|
||||||
const nextY = originalElement.y + dragOffset.y;
|
const nextY = originalElement.y + dragOffset.y;
|
||||||
|
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
x: nextX,
|
x: nextX,
|
||||||
y: nextY,
|
y: nextY,
|
||||||
});
|
});
|
||||||
@ -190,6 +195,7 @@ export const dragNewElement = ({
|
|||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
shouldResizeFromCenter,
|
shouldResizeFromCenter,
|
||||||
zoom,
|
zoom,
|
||||||
|
scene,
|
||||||
widthAspectRatio = null,
|
widthAspectRatio = null,
|
||||||
originOffset = null,
|
originOffset = null,
|
||||||
informMutation = true,
|
informMutation = true,
|
||||||
@ -205,6 +211,7 @@ export const dragNewElement = ({
|
|||||||
shouldMaintainAspectRatio: boolean;
|
shouldMaintainAspectRatio: boolean;
|
||||||
shouldResizeFromCenter: boolean;
|
shouldResizeFromCenter: boolean;
|
||||||
zoom: NormalizedZoomValue;
|
zoom: NormalizedZoomValue;
|
||||||
|
scene: Scene;
|
||||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||||
true */
|
true */
|
||||||
widthAspectRatio?: number | null;
|
widthAspectRatio?: number | null;
|
||||||
@ -285,7 +292,7 @@ export const dragNewElement = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(
|
scene.mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
x: newX + (originOffset?.x ?? 0),
|
x: newX + (originOffset?.x ?? 0),
|
||||||
@ -295,7 +302,7 @@ export const dragNewElement = ({
|
|||||||
...textAutoResize,
|
...textAutoResize,
|
||||||
...imageInitialDimension,
|
...imageInitialDimension,
|
||||||
},
|
},
|
||||||
informMutation,
|
{ informMutation, isDragging: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -36,10 +36,7 @@ import {
|
|||||||
|
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
|
|
||||||
import {
|
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
||||||
fixDuplicatedBindingsAfterDuplication,
|
|
||||||
fixReversedBindings,
|
|
||||||
} from "./binding";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
@ -60,16 +57,14 @@ import type {
|
|||||||
* multiple elements at once, share this map
|
* multiple elements at once, share this map
|
||||||
* amongst all of them
|
* amongst all of them
|
||||||
* @param element Element to duplicate
|
* @param element Element to duplicate
|
||||||
* @param overrides Any element properties to override
|
|
||||||
*/
|
*/
|
||||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||||
editingGroupId: AppState["editingGroupId"],
|
editingGroupId: AppState["editingGroupId"],
|
||||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||||
element: TElement,
|
element: TElement,
|
||||||
overrides?: Partial<TElement>,
|
|
||||||
randomizeSeed?: boolean,
|
randomizeSeed?: boolean,
|
||||||
): Readonly<TElement> => {
|
): Readonly<TElement> => {
|
||||||
let copy = deepCopyElement(element);
|
const copy = deepCopyElement(element);
|
||||||
|
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
__test__defineOrigId(copy, element.id);
|
__test__defineOrigId(copy, element.id);
|
||||||
@ -92,9 +87,6 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
return groupIdMapForOperation.get(groupId)!;
|
return groupIdMapForOperation.get(groupId)!;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (overrides) {
|
|
||||||
copy = Object.assign(copy, overrides);
|
|
||||||
}
|
|
||||||
return copy;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -102,9 +94,14 @@ export const duplicateElements = (
|
|||||||
opts: {
|
opts: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
randomizeSeed?: boolean;
|
randomizeSeed?: boolean;
|
||||||
overrides?: (
|
overrides?: (data: {
|
||||||
originalElement: ExcalidrawElement,
|
duplicateElement: ExcalidrawElement;
|
||||||
) => Partial<ExcalidrawElement>;
|
origElement: ExcalidrawElement;
|
||||||
|
origIdToDuplicateId: Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
ExcalidrawElement["id"]
|
||||||
|
>;
|
||||||
|
}) => Partial<ExcalidrawElement>;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
/**
|
/**
|
||||||
@ -132,14 +129,6 @@ export const duplicateElements = (
|
|||||||
editingGroupId: AppState["editingGroupId"];
|
editingGroupId: AppState["editingGroupId"];
|
||||||
selectedGroupIds: AppState["selectedGroupIds"];
|
selectedGroupIds: AppState["selectedGroupIds"];
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* If true, duplicated elements are inserted _before_ specified
|
|
||||||
* elements. Case: alt-dragging elements to duplicate them.
|
|
||||||
*
|
|
||||||
* TODO: remove this once (if) we stop replacing the original element
|
|
||||||
* with the duplicated one in the scene array.
|
|
||||||
*/
|
|
||||||
reverseOrder: boolean;
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) => {
|
) => {
|
||||||
@ -153,8 +142,6 @@ export const duplicateElements = (
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false;
|
|
||||||
|
|
||||||
// Ids of elements that have already been processed so we don't push them
|
// Ids of elements that have already been processed so we don't push them
|
||||||
// into the array twice if we end up backtracking when retrieving
|
// into the array twice if we end up backtracking when retrieving
|
||||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||||
@ -167,10 +154,17 @@ export const duplicateElements = (
|
|||||||
// loop over them.
|
// loop over them.
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
const groupIdMap = new Map();
|
const groupIdMap = new Map();
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const duplicatedElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const origElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const origIdToDuplicateId = new Map<
|
||||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
ExcalidrawElement["id"],
|
||||||
|
ExcalidrawElement["id"]
|
||||||
|
>();
|
||||||
|
const duplicateIdToOrigElement = new Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
ExcalidrawElement
|
||||||
|
>();
|
||||||
|
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||||
const _idsOfElementsToDuplicate =
|
const _idsOfElementsToDuplicate =
|
||||||
opts.type === "in-place"
|
opts.type === "in-place"
|
||||||
@ -188,7 +182,7 @@ export const duplicateElements = (
|
|||||||
|
|
||||||
elements = normalizeElementOrder(elements);
|
elements = normalizeElementOrder(elements);
|
||||||
|
|
||||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
|
||||||
|
|
||||||
// helper functions
|
// helper functions
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -214,17 +208,17 @@ export const duplicateElements = (
|
|||||||
appState.editingGroupId,
|
appState.editingGroupId,
|
||||||
groupIdMap,
|
groupIdMap,
|
||||||
element,
|
element,
|
||||||
opts.overrides?.(element),
|
|
||||||
opts.randomizeSeed,
|
opts.randomizeSeed,
|
||||||
);
|
);
|
||||||
|
|
||||||
processedIds.set(newElement.id, true);
|
processedIds.set(newElement.id, true);
|
||||||
|
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
duplicateElementsMap.set(newElement.id, newElement);
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
origIdToDuplicateId.set(element.id, newElement.id);
|
||||||
|
duplicateIdToOrigElement.set(newElement.id, element);
|
||||||
|
|
||||||
oldElements.push(element);
|
origElements.push(element);
|
||||||
newElements.push(newElement);
|
duplicatedElements.push(newElement);
|
||||||
|
|
||||||
acc.push(newElement);
|
acc.push(newElement);
|
||||||
return acc;
|
return acc;
|
||||||
@ -248,21 +242,12 @@ export const duplicateElements = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reverseOrder && index < 1) {
|
if (index > elementsWithDuplicates.length - 1) {
|
||||||
elementsWithClones.unshift(...castArray(elements));
|
elementsWithDuplicates.push(...castArray(elements));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reverseOrder && index > elementsWithClones.length - 1) {
|
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
||||||
elementsWithClones.push(...castArray(elements));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementsWithClones.splice(
|
|
||||||
index + (reverseOrder ? 0 : 1),
|
|
||||||
0,
|
|
||||||
...castArray(elements),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const frameIdsToDuplicate = new Set(
|
const frameIdsToDuplicate = new Set(
|
||||||
@ -294,13 +279,9 @@ export const duplicateElements = (
|
|||||||
: [element],
|
: [element],
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetIndex = reverseOrder
|
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||||
? elementsWithClones.findIndex((el) => {
|
return el.groupIds?.includes(groupId);
|
||||||
return el.groupIds?.includes(groupId);
|
});
|
||||||
})
|
|
||||||
: findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return el.groupIds?.includes(groupId);
|
|
||||||
});
|
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
||||||
continue;
|
continue;
|
||||||
@ -318,7 +299,7 @@ export const duplicateElements = (
|
|||||||
|
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
const frameChildren = getFrameChildren(elements, frameId);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||||
return el.frameId === frameId || el.id === frameId;
|
return el.frameId === frameId || el.id === frameId;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -335,7 +316,7 @@ export const duplicateElements = (
|
|||||||
if (hasBoundTextElement(element)) {
|
if (hasBoundTextElement(element)) {
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||||
return (
|
return (
|
||||||
el.id === element.id ||
|
el.id === element.id ||
|
||||||
("containerId" in el && el.containerId === element.id)
|
("containerId" in el && el.containerId === element.id)
|
||||||
@ -344,7 +325,7 @@ export const duplicateElements = (
|
|||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
insertBeforeOrAfterIndex(
|
insertBeforeOrAfterIndex(
|
||||||
targetIndex + (reverseOrder ? -1 : 0),
|
targetIndex,
|
||||||
copyElements([element, boundTextElement]),
|
copyElements([element, boundTextElement]),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -357,7 +338,7 @@ export const duplicateElements = (
|
|||||||
if (isBoundToContainer(element)) {
|
if (isBoundToContainer(element)) {
|
||||||
const container = getContainerElement(element, elementsMap);
|
const container = getContainerElement(element, elementsMap);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||||
return el.id === element.id || el.id === container?.id;
|
return el.id === element.id || el.id === container?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -377,7 +358,7 @@ export const duplicateElements = (
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(
|
insertBeforeOrAfterIndex(
|
||||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
|
||||||
copyElements(element),
|
copyElements(element),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -385,28 +366,38 @@ export const duplicateElements = (
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fixDuplicatedBindingsAfterDuplication(
|
fixDuplicatedBindingsAfterDuplication(
|
||||||
newElements,
|
duplicatedElements,
|
||||||
oldIdToDuplicatedId,
|
origIdToDuplicateId,
|
||||||
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
duplicateElementsMap as NonDeletedSceneElementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reverseOrder) {
|
|
||||||
fixReversedBindings(
|
|
||||||
_idsOfElementsToDuplicate,
|
|
||||||
elementsWithClones,
|
|
||||||
oldIdToDuplicatedId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bindElementsToFramesAfterDuplication(
|
bindElementsToFramesAfterDuplication(
|
||||||
elementsWithClones,
|
elementsWithDuplicates,
|
||||||
oldElements,
|
origElements,
|
||||||
oldIdToDuplicatedId,
|
origIdToDuplicateId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (opts.overrides) {
|
||||||
|
for (const duplicateElement of duplicatedElements) {
|
||||||
|
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
||||||
|
if (origElement) {
|
||||||
|
Object.assign(
|
||||||
|
duplicateElement,
|
||||||
|
opts.overrides({
|
||||||
|
duplicateElement,
|
||||||
|
origElement,
|
||||||
|
origIdToDuplicateId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newElements,
|
duplicatedElements,
|
||||||
elementsWithClones,
|
duplicateElementsMap,
|
||||||
|
elementsWithDuplicates,
|
||||||
|
origIdToDuplicateId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -20,7 +20,6 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@ -63,6 +62,7 @@ import type {
|
|||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||||
@ -876,6 +876,8 @@ const handleEndpointDrag = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_POS = 1e6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@ -884,7 +886,7 @@ export const updateElbowArrowPoints = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
updates: {
|
updates: {
|
||||||
points?: readonly LocalPoint[];
|
points?: readonly LocalPoint[];
|
||||||
fixedSegments?: FixedSegment[] | null;
|
fixedSegments?: readonly FixedSegment[] | null;
|
||||||
startBinding?: FixedPointBinding | null;
|
startBinding?: FixedPointBinding | null;
|
||||||
endBinding?: FixedPointBinding | null;
|
endBinding?: FixedPointBinding | null;
|
||||||
},
|
},
|
||||||
@ -896,7 +898,51 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevEnv() || isTestEnv()) {
|
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
||||||
|
// arrow size is valid. This check will be removed once the issue is identified
|
||||||
|
if (
|
||||||
|
arrow.x < -MAX_POS ||
|
||||||
|
arrow.x > MAX_POS ||
|
||||||
|
arrow.y < -MAX_POS ||
|
||||||
|
arrow.y > MAX_POS ||
|
||||||
|
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
||||||
|
MAX_POS ||
|
||||||
|
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
||||||
|
-MAX_POS ||
|
||||||
|
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
||||||
|
{
|
||||||
|
arrow,
|
||||||
|
updates,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// @ts-ignore See above note
|
||||||
|
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
||||||
|
// @ts-ignore See above note
|
||||||
|
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
||||||
|
if (updates.points) {
|
||||||
|
updates.points = updates.points.map(([x, y]) =>
|
||||||
|
pointFrom<LocalPoint>(
|
||||||
|
clamp(x, -MAX_POS, MAX_POS),
|
||||||
|
clamp(y, -MAX_POS, MAX_POS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
"Updated point array length must match the arrow point length, contain " +
|
"Updated point array length must match the arrow point length, contain " +
|
||||||
@ -1174,31 +1220,19 @@ const getElbowArrowData = (
|
|||||||
if (options?.isDragging) {
|
if (options?.isDragging) {
|
||||||
const elements = Array.from(elementsMap.values());
|
const elements = Array.from(elementsMap.values());
|
||||||
hoveredStartElement =
|
hoveredStartElement =
|
||||||
getHoveredElementForBinding(
|
getHoveredElement(
|
||||||
tupleToCoors(origStartGlobalPoint),
|
origStartGlobalPoint,
|
||||||
elements,
|
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
elements,
|
||||||
options?.zoom,
|
options?.zoom,
|
||||||
true,
|
|
||||||
true,
|
|
||||||
) || null;
|
) || null;
|
||||||
hoveredEndElement =
|
hoveredEndElement =
|
||||||
getHoveredElementForBinding(
|
getHoveredElement(
|
||||||
tupleToCoors(origEndGlobalPoint),
|
origEndGlobalPoint,
|
||||||
elements,
|
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
elements,
|
||||||
options?.zoom,
|
options?.zoom,
|
||||||
true,
|
|
||||||
true,
|
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
// Inside the same element there is no binding to the shape
|
|
||||||
if (hoveredStartElement === hoveredEndElement) {
|
|
||||||
hoveredStartElement = null;
|
|
||||||
hoveredEndElement = null;
|
|
||||||
arrow.startBinding = null;
|
|
||||||
arrow.endBinding = null;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
hoveredStartElement = arrow.startBinding
|
hoveredStartElement = arrow.startBinding
|
||||||
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
||||||
@ -1219,7 +1253,6 @@ const getElbowArrowData = (
|
|||||||
"start",
|
"start",
|
||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
elementsMap,
|
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
@ -1233,7 +1266,6 @@ const getElbowArrowData = (
|
|||||||
"end",
|
"end",
|
||||||
arrow.endBinding?.fixedPoint,
|
arrow.endBinding?.fixedPoint,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
elementsMap,
|
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
@ -2075,6 +2107,29 @@ const normalizeArrowElementUpdate = (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to see if the normalization
|
||||||
|
// creates an overly large arrow. This should be removed once we have an answer.
|
||||||
|
if (
|
||||||
|
offsetX < -MAX_POS ||
|
||||||
|
offsetX > MAX_POS ||
|
||||||
|
offsetY < -MAX_POS ||
|
||||||
|
offsetY > MAX_POS ||
|
||||||
|
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
||||||
|
offsetY + points[points.length - 1][0] > MAX_POS ||
|
||||||
|
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
||||||
|
offsetY + points[points.length - 1][1] > MAX_POS
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
|
||||||
|
{
|
||||||
|
x: offsetX,
|
||||||
|
y: offsetY,
|
||||||
|
points,
|
||||||
|
...getSizeFromPoints(points),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
points = points.map(([x, y]) =>
|
points = points.map(([x, y]) =>
|
||||||
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
|
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
|
||||||
);
|
);
|
||||||
@ -2154,7 +2209,6 @@ const getGlobalPoint = (
|
|||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
element?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
@ -2164,7 +2218,6 @@ const getGlobalPoint = (
|
|||||||
arrow,
|
arrow,
|
||||||
element,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(element, snapPoint);
|
return snapToMid(element, snapPoint);
|
||||||
@ -2184,7 +2237,7 @@ const getGlobalPoint = (
|
|||||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
) > 0.01
|
) > 0.01
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
|
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||||
: fixedGlobalPoint;
|
: fixedGlobalPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2214,6 +2267,22 @@ const getBindPointHeading = (
|
|||||||
origPoint,
|
origPoint,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getHoveredElement = (
|
||||||
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
zoom?: AppState["zoom"],
|
||||||
|
) => {
|
||||||
|
return getHoveredElementForBinding(
|
||||||
|
tupleToCoors(origPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||||
a[0] === b[0] && a[1] === b[1];
|
a[0] === b[0] && a[1] === b[1];
|
||||||
|
|
||||||
|
@ -33,6 +33,8 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
|||||||
const RE_GH_GIST_EMBED =
|
const RE_GH_GIST_EMBED =
|
||||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||||
|
|
||||||
|
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
||||||
|
|
||||||
// not anchored to start to allow <blockquote> twitter embeds
|
// not anchored to start to allow <blockquote> twitter embeds
|
||||||
const RE_TWITTER =
|
const RE_TWITTER =
|
||||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||||
@ -69,6 +71,7 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"val.town",
|
"val.town",
|
||||||
"giphy.com",
|
"giphy.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
|
"forms.microsoft.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOW_SAME_ORIGIN = new Set([
|
const ALLOW_SAME_ORIGIN = new Set([
|
||||||
@ -82,6 +85,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
|||||||
"*.simplepdf.eu",
|
"*.simplepdf.eu",
|
||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
|
"forms.microsoft.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const createSrcDoc = (body: string) => {
|
export const createSrcDoc = (body: string) => {
|
||||||
@ -206,6 +210,10 @@ export const getEmbedLink = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
|
||||||
|
link += link.includes("?") ? "&embed=true" : "?embed=true";
|
||||||
|
}
|
||||||
|
|
||||||
if (RE_TWITTER.test(link)) {
|
if (RE_TWITTER.test(link)) {
|
||||||
const postId = link.match(RE_TWITTER)![1];
|
const postId = link.match(RE_TWITTER)![1];
|
||||||
// the embed srcdoc still supports twitter.com domain only.
|
// the embed srcdoc still supports twitter.com domain only.
|
||||||
|
@ -39,6 +39,8 @@ import {
|
|||||||
type OrderedExcalidrawElement,
|
type OrderedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
const VERTICAL_OFFSET = 100;
|
const VERTICAL_OFFSET = 100;
|
||||||
@ -236,10 +238,11 @@ const getOffsets = (
|
|||||||
|
|
||||||
const addNewNode = (
|
const addNewNode = (
|
||||||
element: ExcalidrawFlowchartNodeElement,
|
element: ExcalidrawFlowchartNodeElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
direction: LinkDirection,
|
direction: LinkDirection,
|
||||||
|
scene: Scene,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const successors = getSuccessors(element, elementsMap, direction);
|
const successors = getSuccessors(element, elementsMap, direction);
|
||||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||||
|
|
||||||
@ -274,9 +277,9 @@ const addNewNode = (
|
|||||||
const bindingArrow = createBindingArrow(
|
const bindingArrow = createBindingArrow(
|
||||||
element,
|
element,
|
||||||
nextNode,
|
nextNode,
|
||||||
elementsMap,
|
|
||||||
direction,
|
direction,
|
||||||
appState,
|
appState,
|
||||||
|
scene,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -287,9 +290,9 @@ const addNewNode = (
|
|||||||
|
|
||||||
export const addNewNodes = (
|
export const addNewNodes = (
|
||||||
startNode: ExcalidrawFlowchartNodeElement,
|
startNode: ExcalidrawFlowchartNodeElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
direction: LinkDirection,
|
direction: LinkDirection,
|
||||||
|
scene: Scene,
|
||||||
numberOfNodes: number,
|
numberOfNodes: number,
|
||||||
) => {
|
) => {
|
||||||
// always start from 0 and distribute evenly
|
// always start from 0 and distribute evenly
|
||||||
@ -352,9 +355,9 @@ export const addNewNodes = (
|
|||||||
const bindingArrow = createBindingArrow(
|
const bindingArrow = createBindingArrow(
|
||||||
startNode,
|
startNode,
|
||||||
nextNode,
|
nextNode,
|
||||||
elementsMap,
|
|
||||||
direction,
|
direction,
|
||||||
appState,
|
appState,
|
||||||
|
scene,
|
||||||
);
|
);
|
||||||
|
|
||||||
newNodes.push(nextNode);
|
newNodes.push(nextNode);
|
||||||
@ -367,9 +370,9 @@ export const addNewNodes = (
|
|||||||
const createBindingArrow = (
|
const createBindingArrow = (
|
||||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
direction: LinkDirection,
|
direction: LinkDirection,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
scene: Scene,
|
||||||
) => {
|
) => {
|
||||||
let startX: number;
|
let startX: number;
|
||||||
let startY: number;
|
let startY: number;
|
||||||
@ -440,18 +443,10 @@ const createBindingArrow = (
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
bindLinearElement(
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
bindingArrow,
|
|
||||||
startBindingElement,
|
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||||
"start",
|
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||||
elementsMap as NonDeletedSceneElementsMap,
|
|
||||||
);
|
|
||||||
bindLinearElement(
|
|
||||||
bindingArrow,
|
|
||||||
endBindingElement,
|
|
||||||
"end",
|
|
||||||
elementsMap as NonDeletedSceneElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
changedElements.set(
|
changedElements.set(
|
||||||
@ -467,12 +462,18 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(bindingArrow, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
bindingArrow,
|
||||||
index: 1,
|
scene,
|
||||||
point: bindingArrow.points[1],
|
new Map([
|
||||||
},
|
[
|
||||||
]);
|
1,
|
||||||
|
{
|
||||||
|
point: bindingArrow.points[1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
const update = updateElbowArrowPoints(
|
||||||
bindingArrow,
|
bindingArrow,
|
||||||
@ -632,16 +633,17 @@ export class FlowChartCreator {
|
|||||||
|
|
||||||
createNodes(
|
createNodes(
|
||||||
startNode: ExcalidrawFlowchartNodeElement,
|
startNode: ExcalidrawFlowchartNodeElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
direction: LinkDirection,
|
direction: LinkDirection,
|
||||||
|
scene: Scene,
|
||||||
) {
|
) {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
if (direction !== this.direction) {
|
if (direction !== this.direction) {
|
||||||
const { nextNode, bindingArrow } = addNewNode(
|
const { nextNode, bindingArrow } = addNewNode(
|
||||||
startNode,
|
startNode,
|
||||||
elementsMap,
|
|
||||||
appState,
|
appState,
|
||||||
direction,
|
direction,
|
||||||
|
scene,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.numberOfNodes = 1;
|
this.numberOfNodes = 1;
|
||||||
@ -652,9 +654,9 @@ export class FlowChartCreator {
|
|||||||
this.numberOfNodes += 1;
|
this.numberOfNodes += 1;
|
||||||
const newNodes = addNewNodes(
|
const newNodes = addNewNodes(
|
||||||
startNode,
|
startNode,
|
||||||
elementsMap,
|
|
||||||
appState,
|
appState,
|
||||||
direction,
|
direction,
|
||||||
|
scene,
|
||||||
this.numberOfNodes,
|
this.numberOfNodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -682,13 +684,9 @@ export class FlowChartCreator {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.pendingNodes = this.pendingNodes.map((node) =>
|
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||||
mutateElement(
|
mutateElement(node, elementsMap, {
|
||||||
node,
|
frameId: startNode.frameId,
|
||||||
{
|
}),
|
||||||
frameId: startNode.frameId,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { getBoundTextElement } from "./textElement";
|
|||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
@ -152,9 +153,10 @@ export const orderByFractionalIndex = (
|
|||||||
*/
|
*/
|
||||||
export const syncMovedIndices = (
|
export const syncMovedIndices = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
movedElements: Map<string, ExcalidrawElement>,
|
movedElements: ElementsMap,
|
||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
try {
|
try {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
@ -176,7 +178,7 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, update] of elementsUpdates) {
|
||||||
mutateElement(element, update, false);
|
mutateElement(element, elementsMap, update);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@ -194,10 +196,12 @@ export const syncMovedIndices = (
|
|||||||
export const syncInvalidIndices = (
|
export const syncInvalidIndices = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, update] of elementsUpdates) {
|
||||||
mutateElement(element, update, false);
|
mutateElement(element, elementsMap, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
@ -210,7 +214,7 @@ export const syncInvalidIndices = (
|
|||||||
*/
|
*/
|
||||||
const getMovedIndicesGroups = (
|
const getMovedIndicesGroups = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
movedElements: Map<string, ExcalidrawElement>,
|
movedElements: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const indicesGroups: number[][] = [];
|
const indicesGroups: number[][] = [];
|
||||||
|
|
||||||
|
@ -3,8 +3,6 @@ import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
|||||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||||
|
|
||||||
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
@ -29,6 +27,8 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ElementsMapOrArray,
|
ElementsMapOrArray,
|
||||||
@ -41,30 +41,24 @@ import type {
|
|||||||
// --------------------------- Frame State ------------------------------------
|
// --------------------------- Frame State ------------------------------------
|
||||||
export const bindElementsToFramesAfterDuplication = (
|
export const bindElementsToFramesAfterDuplication = (
|
||||||
nextElements: readonly ExcalidrawElement[],
|
nextElements: readonly ExcalidrawElement[],
|
||||||
oldElements: readonly ExcalidrawElement[],
|
origElements: readonly ExcalidrawElement[],
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
) => {
|
) => {
|
||||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||||
ExcalidrawElement["id"],
|
ExcalidrawElement["id"],
|
||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
>;
|
>;
|
||||||
|
|
||||||
for (const element of oldElements) {
|
for (const element of origElements) {
|
||||||
if (element.frameId) {
|
if (element.frameId) {
|
||||||
// use its frameId to get the new frameId
|
// use its frameId to get the new frameId
|
||||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
const nextElementId = origIdToDuplicateId.get(element.id);
|
||||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
const nextFrameId = origIdToDuplicateId.get(element.frameId);
|
||||||
if (nextElementId) {
|
const nextElement = nextElementId && nextElementMap.get(nextElementId);
|
||||||
const nextElement = nextElementMap.get(nextElementId);
|
if (nextElement) {
|
||||||
if (nextElement) {
|
mutateElement(nextElement, nextElementMap, {
|
||||||
mutateElement(
|
frameId: nextFrameId ?? null,
|
||||||
nextElement,
|
});
|
||||||
{
|
|
||||||
frameId: nextFrameId ?? element.frameId,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -567,13 +561,9 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const element of finalElementsToAdd) {
|
for (const element of finalElementsToAdd) {
|
||||||
mutateElement(
|
mutateElement(element, elementsMap, {
|
||||||
element,
|
frameId: frame.id,
|
||||||
{
|
});
|
||||||
frameId: frame.id,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allElements;
|
return allElements;
|
||||||
@ -611,13 +601,9 @@ export const removeElementsFromFrame = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [, element] of _elementsToRemove) {
|
for (const [, element] of _elementsToRemove) {
|
||||||
mutateElement(
|
mutateElement(element, elementsMap, {
|
||||||
element,
|
frameId: null,
|
||||||
{
|
});
|
||||||
frameId: null,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -919,13 +905,16 @@ export const shouldApplyFrameClip = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
const DEFAULT_FRAME_NAME = "Frame";
|
||||||
|
const DEFAULT_AI_FRAME_NAME = "AI Frame";
|
||||||
|
|
||||||
|
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
|
||||||
// TODO name frames "AI" only if specific to AI frames
|
// TODO name frames "AI" only if specific to AI frames
|
||||||
return element.name === null
|
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
||||||
? isFrameElement(element)
|
};
|
||||||
? "Frame"
|
|
||||||
: "AI Frame"
|
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||||
: element.name;
|
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getElementsOverlappingFrame = (
|
export const getElementsOverlappingFrame = (
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { toIterable } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
import { isLinearElementType } from "./typeChecks";
|
||||||
|
|
||||||
@ -5,6 +7,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ElementsMapOrArray,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
/**
|
/**
|
||||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||||
*/
|
*/
|
||||||
export const hashElementsVersion = (
|
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): number => {
|
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (const element of toIterable(elements)) {
|
||||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
hash = (hash << 5) + hash + element.versionNonce;
|
||||||
}
|
}
|
||||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||||
};
|
};
|
||||||
@ -71,3 +72,47 @@ export const clearElementsForExport = (
|
|||||||
export const clearElementsForLocalStorage = (
|
export const clearElementsForLocalStorage = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => _clearElements(elements);
|
) => _clearElements(elements);
|
||||||
|
|
||||||
|
export * from "./align";
|
||||||
|
export * from "./binding";
|
||||||
|
export * from "./bounds";
|
||||||
|
export * from "./collision";
|
||||||
|
export * from "./comparisons";
|
||||||
|
export * from "./containerCache";
|
||||||
|
export * from "./cropElement";
|
||||||
|
export * from "./delta";
|
||||||
|
export * from "./distance";
|
||||||
|
export * from "./distribute";
|
||||||
|
export * from "./dragElements";
|
||||||
|
export * from "./duplicate";
|
||||||
|
export * from "./elbowArrow";
|
||||||
|
export * from "./elementLink";
|
||||||
|
export * from "./embeddable";
|
||||||
|
export * from "./flowchart";
|
||||||
|
export * from "./fractionalIndex";
|
||||||
|
export * from "./frame";
|
||||||
|
export * from "./groups";
|
||||||
|
export * from "./heading";
|
||||||
|
export * from "./image";
|
||||||
|
export * from "./linearElementEditor";
|
||||||
|
export * from "./mutateElement";
|
||||||
|
export * from "./newElement";
|
||||||
|
export * from "./renderElement";
|
||||||
|
export * from "./resizeElements";
|
||||||
|
export * from "./resizeTest";
|
||||||
|
export * from "./Scene";
|
||||||
|
export * from "./selection";
|
||||||
|
export * from "./Shape";
|
||||||
|
export * from "./ShapeCache";
|
||||||
|
export * from "./shapes";
|
||||||
|
export * from "./showSelectedShapeActions";
|
||||||
|
export * from "./sizeHelpers";
|
||||||
|
export * from "./sortElements";
|
||||||
|
export * from "./store";
|
||||||
|
export * from "./textElement";
|
||||||
|
export * from "./textMeasurements";
|
||||||
|
export * from "./textWrapping";
|
||||||
|
export * from "./transformHandles";
|
||||||
|
export * from "./typeChecks";
|
||||||
|
export * from "./utils";
|
||||||
|
export * from "./zindex";
|
||||||
|
@ -20,11 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
import type { Store } from "@excalidraw/element";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
|
||||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/excalidraw/store";
|
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -42,7 +38,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
getOutlineAvoidingPoint,
|
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
@ -51,13 +46,10 @@ import {
|
|||||||
getMinMaxXYFromCurvePathOps,
|
getMinMaxXYFromCurvePathOps,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
|
||||||
|
|
||||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
@ -75,6 +67,8 @@ import {
|
|||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
@ -86,16 +80,11 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
SceneElementsMap,
|
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
PointsPositionUpdates,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
|
||||||
version: number | null;
|
|
||||||
points: (GlobalPoint | null)[];
|
|
||||||
zoom: number | null;
|
|
||||||
} = { version: null, points: [], zoom: null };
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -129,15 +118,17 @@ export class LinearElementEditor {
|
|||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
public readonly elbowed: boolean;
|
||||||
|
|
||||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
constructor(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||||
console.error("Linear element is not normalized", Error().stack);
|
console.error("Linear element is not normalized", Error().stack);
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element, elementsMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedPointsIndices = null;
|
this.selectedPointsIndices = null;
|
||||||
this.lastUncommittedPoint = null;
|
this.lastUncommittedPoint = null;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
@ -254,28 +245,27 @@ export class LinearElementEditor {
|
|||||||
pointSceneCoords: { x: number; y: number }[],
|
pointSceneCoords: { x: number; y: number }[],
|
||||||
) => void,
|
) => void,
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
|
scene: Scene,
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
if (!linearElementEditor) {
|
if (!linearElementEditor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elbowed = isElbowArrow(element);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
elbowed &&
|
isElbowArrow(element) &&
|
||||||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPointsIndices = elbowed
|
const selectedPointsIndices = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||||
? 0
|
? 0
|
||||||
@ -285,7 +275,7 @@ export class LinearElementEditor {
|
|||||||
: undefined,
|
: undefined,
|
||||||
].filter((idx): idx is number => idx !== undefined)
|
].filter((idx): idx is number => idx !== undefined)
|
||||||
: linearElementEditor.selectedPointsIndices;
|
: linearElementEditor.selectedPointsIndices;
|
||||||
const lastClickedPoint = elbowed
|
const lastClickedPoint = isElbowArrow(element)
|
||||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||||
? element.points.length - 1
|
? element.points.length - 1
|
||||||
: 0
|
: 0
|
||||||
@ -312,16 +302,22 @@ export class LinearElementEditor {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: selectedIndex,
|
scene,
|
||||||
point: pointFrom(
|
new Map([
|
||||||
width + referencePoint[0],
|
[
|
||||||
height + referencePoint[1],
|
selectedIndex,
|
||||||
),
|
{
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
point: pointFrom(
|
||||||
},
|
width + referencePoint[0],
|
||||||
]);
|
height + referencePoint[1],
|
||||||
|
),
|
||||||
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@ -336,72 +332,39 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
scene,
|
||||||
let newPointPosition: LocalPoint =
|
new Map(
|
||||||
pointIndex === lastClickedPoint
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
? LinearElementEditor.createPointAt(
|
const newPointPosition: LocalPoint =
|
||||||
element,
|
pointIndex === lastClickedPoint
|
||||||
elementsMap,
|
? LinearElementEditor.createPointAt(
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
element,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
elementsMap,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
)
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
: pointFrom(
|
event[KEYS.CTRL_OR_CMD]
|
||||||
element.points[pointIndex][0] + deltaX,
|
? null
|
||||||
element.points[pointIndex][1] + deltaY,
|
: app.getEffectiveGridSize(),
|
||||||
);
|
)
|
||||||
|
: pointFrom(
|
||||||
if (pointIndex === 0 || pointIndex === element.points.length - 1) {
|
element.points[pointIndex][0] + deltaX,
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(
|
element.points[pointIndex][1] + deltaY,
|
||||||
element,
|
);
|
||||||
elementsMap,
|
return [
|
||||||
true,
|
|
||||||
);
|
|
||||||
const newGlobalPointPosition = pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + newPointPosition[0],
|
|
||||||
element.y + newPointPosition[1],
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(cx, cy),
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const avoidancePoint = getOutlineAvoidingPoint(
|
|
||||||
element,
|
|
||||||
newGlobalPointPosition,
|
|
||||||
pointIndex,
|
pointIndex,
|
||||||
app.scene,
|
{
|
||||||
app.state.zoom,
|
point: newPointPosition,
|
||||||
);
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
|
},
|
||||||
newPointPosition = LinearElementEditor.createPointAt(
|
];
|
||||||
element,
|
}),
|
||||||
elementsMap,
|
),
|
||||||
!isArrowElement(element) ||
|
|
||||||
avoidancePoint[0] === newGlobalPointPosition[0]
|
|
||||||
? newGlobalPointPosition[0] -
|
|
||||||
linearElementEditor.pointerOffset.x
|
|
||||||
: avoidancePoint[0],
|
|
||||||
!isArrowElement(element) ||
|
|
||||||
avoidancePoint[1] === newGlobalPointPosition[1]
|
|
||||||
? newGlobalPointPosition[1] -
|
|
||||||
linearElementEditor.pointerOffset.y
|
|
||||||
: avoidancePoint[1],
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
index: pointIndex,
|
|
||||||
point: newPointPosition,
|
|
||||||
isDragging: pointIndex === lastClickedPoint,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
handleBindTextResize(element, elementsMap, false);
|
handleBindTextResize(element, scene, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
@ -496,15 +459,21 @@ export class LinearElementEditor {
|
|||||||
selectedPoint === element.points.length - 1
|
selectedPoint === element.points.length - 1
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: selectedPoint,
|
scene,
|
||||||
point:
|
new Map([
|
||||||
selectedPoint === 0
|
[
|
||||||
? element.points[element.points.length - 1]
|
selectedPoint,
|
||||||
: element.points[0],
|
{
|
||||||
},
|
point:
|
||||||
]);
|
selectedPoint === 0
|
||||||
|
? element.points[element.points.length - 1]
|
||||||
|
: element.points[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
@ -562,7 +531,7 @@ export class LinearElementEditor {
|
|||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
): typeof editorMidPointsCache["points"] => {
|
): (GlobalPoint | null)[] => {
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
@ -574,25 +543,7 @@ export class LinearElementEditor {
|
|||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
editorMidPointsCache.version === element.version &&
|
|
||||||
editorMidPointsCache.zoom === appState.zoom.value
|
|
||||||
) {
|
|
||||||
return editorMidPointsCache.points;
|
|
||||||
}
|
|
||||||
LinearElementEditor.updateEditorMidPointsCache(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return editorMidPointsCache.points!;
|
|
||||||
};
|
|
||||||
|
|
||||||
static updateEditorMidPointsCache = (
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
appState: InteractiveCanvasAppState,
|
|
||||||
) => {
|
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -624,9 +575,8 @@ export class LinearElementEditor {
|
|||||||
midpoints.push(segmentMidPoint);
|
midpoints.push(segmentMidPoint);
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
editorMidPointsCache.points = midpoints;
|
|
||||||
editorMidPointsCache.version = element.version;
|
return midpoints;
|
||||||
editorMidPointsCache.zoom = appState.zoom.value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static getSegmentMidpointHitCoords = (
|
static getSegmentMidpointHitCoords = (
|
||||||
@ -680,8 +630,11 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const midPoints: typeof editorMidPointsCache["points"] =
|
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
element,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
while (index < midPoints.length) {
|
while (index < midPoints.length) {
|
||||||
if (midPoints[index] !== null) {
|
if (midPoints[index] !== null) {
|
||||||
@ -838,7 +791,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
} else if (event.altKey && appState.editingLinearElement) {
|
} else if (event.altKey && appState.editingLinearElement) {
|
||||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
...element.points,
|
...element.points,
|
||||||
LinearElementEditor.createPointAt(
|
LinearElementEditor.createPointAt(
|
||||||
@ -852,7 +805,7 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
store.shouldCaptureIncrement();
|
store.scheduleCapture();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
@ -904,7 +857,6 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
elementsMap,
|
|
||||||
scene,
|
scene,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -977,13 +929,13 @@ export class LinearElementEditor {
|
|||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
const appState = app.state;
|
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 = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return appState.editingLinearElement;
|
return appState.editingLinearElement;
|
||||||
@ -994,7 +946,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, app.scene, [
|
||||||
|
points.length - 1,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@ -1032,14 +986,20 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: element.points.length - 1,
|
app.scene,
|
||||||
point: newPoint,
|
new Map([
|
||||||
},
|
[
|
||||||
]);
|
element.points.length - 1,
|
||||||
|
{
|
||||||
|
point: newPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@ -1203,23 +1163,26 @@ export class LinearElementEditor {
|
|||||||
y: element.y + offsetY,
|
y: element.y + offsetY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// element-mutating methods
|
// element-mutating methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
static normalizePoints(
|
||||||
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
|
mutateElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
LinearElementEditor.getNormalizedPoints(element),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static duplicateSelectedPoints(
|
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||||
appState: AppState,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
): AppState {
|
|
||||||
invariant(
|
invariant(
|
||||||
appState.editingLinearElement,
|
appState.editingLinearElement,
|
||||||
"Not currently editing a linear element",
|
"Not currently editing a linear element",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
@ -1262,18 +1225,22 @@ export class LinearElementEditor {
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
mutateElement(element, { points: nextPoints });
|
scene.mutateElement(element, { points: nextPoints });
|
||||||
|
|
||||||
// temp hack to ensure the line doesn't move when adding point to the end,
|
// temp hack to ensure the line doesn't move when adding point to the end,
|
||||||
// potentially expanding the bounding box
|
// potentially expanding the bounding box
|
||||||
if (pointAddedToEnd) {
|
if (pointAddedToEnd) {
|
||||||
const lastPoint = element.points[element.points.length - 1];
|
const lastPoint = element.points[element.points.length - 1];
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: element.points.length - 1,
|
scene,
|
||||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
new Map([
|
||||||
},
|
[
|
||||||
]);
|
element.points.length - 1,
|
||||||
|
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1287,6 +1254,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static deletePoints(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
scene: Scene,
|
||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
) {
|
) {
|
||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
@ -1317,28 +1285,41 @@ export class LinearElementEditor {
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
LinearElementEditor._updatePoints(
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
nextPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
scene: Scene,
|
||||||
targetPoints: { point: LocalPoint }[],
|
targetPoints: { point: LocalPoint }[],
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
const offsetY = 0;
|
const offsetY = 0;
|
||||||
|
|
||||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
LinearElementEditor._updatePoints(
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
nextPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
scene: Scene,
|
||||||
|
pointUpdates: PointsPositionUpdates,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
},
|
},
|
||||||
sceneElementsMap?: NonDeletedSceneElementsMap,
|
|
||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
@ -1348,8 +1329,7 @@ export class LinearElementEditor {
|
|||||||
// offset it. We do the same with actual element.x/y position, so
|
// offset it. We do the same with actual element.x/y position, so
|
||||||
// this hacks are completely transparent to the user.
|
// this hacks are completely transparent to the user.
|
||||||
const [deltaX, deltaY] =
|
const [deltaX, deltaY] =
|
||||||
targetPoints.find(({ index }) => index === 0)?.point ??
|
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||||
pointFrom<LocalPoint>(0, 0);
|
|
||||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||||
deltaX - points[0][0],
|
deltaX - points[0][0],
|
||||||
deltaY - points[0][1],
|
deltaY - points[0][1],
|
||||||
@ -1357,12 +1337,12 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const nextPoints = isElbowArrow(element)
|
const nextPoints = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
pointUpdates.get(0)?.point ?? points[0],
|
||||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
pointUpdates.get(points.length - 1)?.point ??
|
||||||
points[points.length - 1],
|
points[points.length - 1],
|
||||||
]
|
]
|
||||||
: points.map((p, idx) => {
|
: points.map((p, idx) => {
|
||||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
const current = pointUpdates.get(idx)?.point ?? p;
|
||||||
|
|
||||||
return pointFrom<LocalPoint>(
|
return pointFrom<LocalPoint>(
|
||||||
current[0] - offsetX,
|
current[0] - offsetX,
|
||||||
@ -1372,17 +1352,13 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
element,
|
element,
|
||||||
|
scene,
|
||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: targetPoints.reduce(
|
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||||
(dragging, targetPoint): boolean =>
|
|
||||||
dragging || targetPoint.isDragging === true,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
sceneElementsMap,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1437,8 +1413,9 @@ export class LinearElementEditor {
|
|||||||
pointerCoords: PointerCoords,
|
pointerCoords: PointerCoords,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
snapToGrid: boolean,
|
snapToGrid: boolean,
|
||||||
elementsMap: ElementsMap,
|
scene: Scene,
|
||||||
) {
|
) {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(
|
const element = LinearElementEditor.getElement(
|
||||||
linearElementEditor.elementId,
|
linearElementEditor.elementId,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -1468,9 +1445,7 @@ export class LinearElementEditor {
|
|||||||
...element.points.slice(segmentMidpoint.index!),
|
...element.points.slice(segmentMidpoint.index!),
|
||||||
];
|
];
|
||||||
|
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, { points });
|
||||||
points,
|
|
||||||
});
|
|
||||||
|
|
||||||
ret.pointerDownState = {
|
ret.pointerDownState = {
|
||||||
...linearElementEditor.pointerDownState,
|
...linearElementEditor.pointerDownState,
|
||||||
@ -1486,6 +1461,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
private static _updatePoints(
|
private static _updatePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
scene: Scene,
|
||||||
nextPoints: readonly LocalPoint[],
|
nextPoints: readonly LocalPoint[],
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
@ -1522,28 +1498,10 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
updates.points = Array.from(nextPoints);
|
updates.points = Array.from(nextPoints);
|
||||||
|
|
||||||
if (!options?.sceneElementsMap || Scene.getScene(element)) {
|
scene.mutateElement(element, updates, {
|
||||||
mutateElement(element, updates, true, {
|
informMutation: true,
|
||||||
isDragging: options?.isDragging,
|
isDragging: options?.isDragging ?? false,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// The element is not in the scene, so we need to use the provided
|
|
||||||
// scene map.
|
|
||||||
Object.assign(element, {
|
|
||||||
...updates,
|
|
||||||
angle: 0 as Radians,
|
|
||||||
|
|
||||||
...updateElbowArrowPoints(
|
|
||||||
element,
|
|
||||||
options.sceneElementsMap,
|
|
||||||
updates,
|
|
||||||
{
|
|
||||||
isDragging: options?.isDragging,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bumpVersion(element);
|
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
@ -1558,7 +1516,7 @@ export class LinearElementEditor {
|
|||||||
pointFrom(dX, dY),
|
pointFrom(dX, dY),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
...otherUpdates,
|
...otherUpdates,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
x: element.x + rotated[0],
|
x: element.x + rotated[0],
|
||||||
@ -1617,7 +1575,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
if (points.length < 2) {
|
if (points.length < 2) {
|
||||||
mutateElement(boundTextElement, { isDeleted: true });
|
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
|
||||||
}
|
}
|
||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
@ -1632,23 +1590,14 @@ export class LinearElementEditor {
|
|||||||
y = midPoint[1] - boundTextElement.height / 2;
|
y = midPoint[1] - boundTextElement.height / 2;
|
||||||
} else {
|
} else {
|
||||||
const index = element.points.length / 2 - 1;
|
const index = element.points.length / 2 - 1;
|
||||||
|
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
|
element,
|
||||||
|
points[index],
|
||||||
|
points[index + 1],
|
||||||
|
index + 1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
|
||||||
if (element.points.length === 2) {
|
|
||||||
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!midSegmentMidpoint ||
|
|
||||||
editorMidPointsCache.version !== element.version
|
|
||||||
) {
|
|
||||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
|
||||||
element,
|
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||||
}
|
}
|
||||||
@ -1824,8 +1773,9 @@ export class LinearElementEditor {
|
|||||||
index: number,
|
index: number,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
elementsMap: ElementsMap,
|
scene: Scene,
|
||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(
|
const element = LinearElementEditor.getElement(
|
||||||
linearElement.elementId,
|
linearElement.elementId,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -1868,7 +1818,7 @@ export class LinearElementEditor {
|
|||||||
.map((segment) => segment.index)
|
.map((segment) => segment.index)
|
||||||
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
|
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
|
||||||
|
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fixedSegments: nextFixedSegments,
|
fixedSegments: nextFixedSegments,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1902,14 +1852,14 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static deleteFixedSegment(
|
static deleteFixedSegment(
|
||||||
element: ExcalidrawElbowArrowElement,
|
element: ExcalidrawElbowArrowElement,
|
||||||
|
scene: Scene,
|
||||||
index: number,
|
index: number,
|
||||||
): void {
|
): void {
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fixedSegments: element.fixedSegments?.filter(
|
fixedSegments: element.fixedSegments?.filter(
|
||||||
(segment) => segment.index !== index,
|
(segment) => segment.index !== index,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
mutateElement(element, {}, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,13 +2,8 @@ import {
|
|||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
getUpdatedTimestamp,
|
getUpdatedTimestamp,
|
||||||
toBrandedType,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
|
||||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
@ -16,35 +11,42 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./ShapeCache";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import { isElbowArrow } from "./typeChecks";
|
import { isElbowArrow } from "./typeChecks";
|
||||||
|
|
||||||
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce" | "updated"
|
"id" | "version" | "versionNonce" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// This function tracks updates of text elements for the purposes for collaboration.
|
/**
|
||||||
// The version is used to compare updates when more than one user is working in
|
* This function tracks updates of text elements for the purposes for collaboration.
|
||||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
* The version is used to compare updates when more than one user is working in
|
||||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
* the same drawing.
|
||||||
|
*
|
||||||
|
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
|
||||||
|
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
|
||||||
|
*/
|
||||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||||
element: TElement,
|
element: TElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
updates: ElementUpdate<TElement>,
|
updates: ElementUpdate<TElement>,
|
||||||
informMutation = true,
|
|
||||||
options?: {
|
options?: {
|
||||||
// Currently only for elbow arrows.
|
|
||||||
// If true, the elbow arrow tries to bind to the nearest element. If false
|
|
||||||
// it tries to keep the same bound element, if any.
|
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
},
|
},
|
||||||
): TElement => {
|
) => {
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points, fixedSegments, fileId, startBinding, endBinding } =
|
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||||
updates as any;
|
updates as any;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -55,10 +57,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
typeof startBinding !== "undefined" ||
|
typeof startBinding !== "undefined" ||
|
||||||
typeof endBinding !== "undefined") // manual binding to element
|
typeof endBinding !== "undefined") // manual binding to element
|
||||||
) {
|
) {
|
||||||
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
|
||||||
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
updates = {
|
updates = {
|
||||||
...updates,
|
...updates,
|
||||||
angle: 0 as Radians,
|
angle: 0 as Radians,
|
||||||
@ -68,16 +66,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
x: updates.x || element.x,
|
x: updates.x || element.x,
|
||||||
y: updates.y || element.y,
|
y: updates.y || element.y,
|
||||||
},
|
},
|
||||||
elementsMap,
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
{
|
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
|
||||||
fixedSegments,
|
options,
|
||||||
points,
|
|
||||||
startBinding,
|
|
||||||
endBinding,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDragging: options?.isDragging,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (typeof points !== "undefined") {
|
} else if (typeof points !== "undefined") {
|
||||||
@ -150,10 +141,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
element.versionNonce = randomInteger();
|
element.versionNonce = randomInteger();
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
if (informMutation) {
|
|
||||||
Scene.getScene(element)?.triggerUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -44,7 +44,6 @@ import type {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
FixedSegment,
|
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -98,6 +97,28 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
...rest
|
...rest
|
||||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||||
) => {
|
) => {
|
||||||
|
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||||
|
// element position or sizing
|
||||||
|
if (
|
||||||
|
x < -1e6 ||
|
||||||
|
x > 1e6 ||
|
||||||
|
y < -1e6 ||
|
||||||
|
y > 1e6 ||
|
||||||
|
width < -1e6 ||
|
||||||
|
width > 1e6 ||
|
||||||
|
height < -1e6 ||
|
||||||
|
height > 1e6
|
||||||
|
) {
|
||||||
|
console.error("New element size or position is too large", {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
// @ts-ignore
|
||||||
|
points: rest.points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// assign type to guard against excess properties
|
// assign type to guard against excess properties
|
||||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||||
id: rest.id || randomId(),
|
id: rest.id || randomId(),
|
||||||
@ -456,7 +477,7 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
endArrowhead?: Arrowhead | null;
|
endArrowhead?: Arrowhead | null;
|
||||||
points?: ExcalidrawArrowElement["points"];
|
points?: ExcalidrawArrowElement["points"];
|
||||||
elbowed?: T;
|
elbowed?: T;
|
||||||
fixedSegments?: FixedSegment[] | null;
|
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): T extends true
|
): T extends true
|
||||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||||
|
@ -351,12 +351,20 @@ const generateElementCanvas = (
|
|||||||
|
|
||||||
export const DEFAULT_LINK_SIZE = 14;
|
export const DEFAULT_LINK_SIZE = 14;
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
const IMAGE_PLACEHOLDER_IMG =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.createElement("img")
|
||||||
|
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||||
|
|
||||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.createElement("img")
|
||||||
|
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||||
|
|
||||||
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
@ -17,8 +17,6 @@ import {
|
|||||||
|
|
||||||
import type { GlobalPoint } from "@excalidraw/math";
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
@ -32,7 +30,6 @@ import {
|
|||||||
getElementBounds,
|
getElementBounds,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
@ -60,6 +57,8 @@ import {
|
|||||||
|
|
||||||
import { isInGroup } from "./groups";
|
import { isInGroup } from "./groups";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
MaybeTransformHandleType,
|
MaybeTransformHandleType,
|
||||||
@ -74,7 +73,6 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
SceneElementsMap,
|
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -83,7 +81,6 @@ export const transformElements = (
|
|||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: SceneElementsMap,
|
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
shouldRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
@ -93,31 +90,31 @@ export const transformElements = (
|
|||||||
centerX: number,
|
centerX: number,
|
||||||
centerY: number,
|
centerY: number,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
const [element] = selectedElements;
|
const [element] = selectedElements;
|
||||||
if (transformHandleType === "rotation") {
|
if (transformHandleType === "rotation") {
|
||||||
if (!isElbowArrow(element)) {
|
if (!isElbowArrow(element)) {
|
||||||
rotateSingleElement(
|
rotateSingleElement(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
|
||||||
scene,
|
scene,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
shouldRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
);
|
);
|
||||||
updateBoundElements(element, elementsMap);
|
updateBoundElements(element, scene);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
} else if (isTextElement(element) && transformHandleType) {
|
||||||
resizeSingleTextElement(
|
resizeSingleTextElement(
|
||||||
originalElements,
|
originalElements,
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
scene,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
shouldResizeFromCenter,
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
updateBoundElements(element, elementsMap);
|
updateBoundElements(element, scene);
|
||||||
return true;
|
return true;
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
const elementId = selectedElements[0].id;
|
const elementId = selectedElements[0].id;
|
||||||
@ -129,8 +126,6 @@ export const transformElements = (
|
|||||||
getNextSingleWidthAndHeightFromPointer(
|
getNextSingleWidthAndHeightFromPointer(
|
||||||
latestElement,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
|
||||||
originalElements,
|
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
@ -145,8 +140,8 @@ export const transformElements = (
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
latestElement,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
|
||||||
originalElements,
|
originalElements,
|
||||||
|
scene,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
{
|
{
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
@ -161,7 +156,6 @@ export const transformElements = (
|
|||||||
rotateMultipleElements(
|
rotateMultipleElements(
|
||||||
originalElements,
|
originalElements,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
|
||||||
scene,
|
scene,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
@ -210,13 +204,15 @@ export const transformElements = (
|
|||||||
|
|
||||||
const rotateSingleElement = (
|
const rotateSingleElement = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
shouldRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
let angle: Radians;
|
let angle: Radians;
|
||||||
@ -233,13 +229,13 @@ const rotateSingleElement = (
|
|||||||
}
|
}
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
mutateElement(element, { angle });
|
scene.mutateElement(element, { angle });
|
||||||
if (boundTextElementId) {
|
if (boundTextElementId) {
|
||||||
const textElement =
|
const textElement =
|
||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
mutateElement(textElement, { angle });
|
scene.mutateElement(textElement, { angle });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -289,12 +285,13 @@ export const measureFontSizeFromWidth = (
|
|||||||
const resizeSingleTextElement = (
|
const resizeSingleTextElement = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
elementsMap: ElementsMap,
|
scene: Scene,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -393,7 +390,7 @@ const resizeSingleTextElement = (
|
|||||||
);
|
);
|
||||||
const [nextX, nextY] = newTopLeft;
|
const [nextX, nextY] = newTopLeft;
|
||||||
|
|
||||||
mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
@ -508,14 +505,13 @@ const resizeSingleTextElement = (
|
|||||||
autoResize: false,
|
autoResize: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
mutateElement(element, resizedElement);
|
scene.mutateElement(element, resizedElement);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateMultipleElements = (
|
const rotateMultipleElements = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: SceneElementsMap,
|
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
@ -523,6 +519,7 @@ const rotateMultipleElements = (
|
|||||||
centerX: number,
|
centerX: number,
|
||||||
centerY: number,
|
centerY: number,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
let centerAngle =
|
let centerAngle =
|
||||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||||
if (shouldRotateWithDiscreteAngle) {
|
if (shouldRotateWithDiscreteAngle) {
|
||||||
@ -543,38 +540,30 @@ const rotateMultipleElements = (
|
|||||||
(centerAngle + origAngle - element.angle) as Radians,
|
(centerAngle + origAngle - element.angle) as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
const updates = isElbowArrow(element)
|
||||||
// Needed to re-route the arrow
|
? {
|
||||||
mutateElement(element, {
|
// Needed to re-route the arrow
|
||||||
points: getArrowLocalFixedPoints(element, elementsMap),
|
points: getArrowLocalFixedPoints(element, elementsMap),
|
||||||
});
|
}
|
||||||
} else {
|
: {
|
||||||
mutateElement(
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
x: element.x + (rotatedCX - cx),
|
x: element.x + (rotatedCX - cx),
|
||||||
y: element.y + (rotatedCY - cy),
|
y: element.y + (rotatedCY - cy),
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
},
|
};
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, {
|
scene.mutateElement(element, updates);
|
||||||
|
|
||||||
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elements,
|
simultaneouslyUpdated: elements,
|
||||||
});
|
});
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
mutateElement(
|
scene.mutateElement(boundText, {
|
||||||
boundText,
|
x: boundText.x + (rotatedCX - cx),
|
||||||
{
|
y: boundText.y + (rotatedCY - cy),
|
||||||
x: boundText.x + (rotatedCX - cx),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
y: boundText.y + (rotatedCY - cy),
|
});
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -819,8 +808,8 @@ export const resizeSingleElement = (
|
|||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
latestElement: ExcalidrawElement,
|
latestElement: ExcalidrawElement,
|
||||||
origElement: ExcalidrawElement,
|
origElement: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
originalElementsMap: ElementsMap,
|
originalElementsMap: ElementsMap,
|
||||||
|
scene: Scene,
|
||||||
handleDirection: TransformHandleDirection,
|
handleDirection: TransformHandleDirection,
|
||||||
{
|
{
|
||||||
shouldInformMutation = true,
|
shouldInformMutation = true,
|
||||||
@ -833,6 +822,7 @@ export const resizeSingleElement = (
|
|||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
@ -932,7 +922,7 @@ export const resizeSingleElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("scale" in latestElement && "scale" in origElement) {
|
if ("scale" in latestElement && "scale" in origElement) {
|
||||||
mutateElement(latestElement, {
|
scene.mutateElement(latestElement, {
|
||||||
scale: [
|
scale: [
|
||||||
// defaulting because scaleX/Y can be 0/-0
|
// defaulting because scaleX/Y can be 0/-0
|
||||||
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
|
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
|
||||||
@ -967,29 +957,33 @@ export const resizeSingleElement = (
|
|||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
mutateElement(latestElement, updates, shouldInformMutation);
|
scene.mutateElement(latestElement, updates, {
|
||||||
|
informMutation: shouldInformMutation,
|
||||||
updateBoundElements(latestElement, elementsMap as SceneElementsMap);
|
isDragging: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont != null) {
|
if (boundTextElement && boundTextFont != null) {
|
||||||
mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
fontSize: boundTextFont.fontSize,
|
fontSize: boundTextFont.fontSize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleBindTextResize(
|
handleBindTextResize(
|
||||||
latestElement,
|
latestElement,
|
||||||
elementsMap,
|
scene,
|
||||||
handleDirection,
|
handleDirection,
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
updateBoundElements(latestElement, scene, {
|
||||||
|
// TODO: confirm with MARK if this actually makes sense
|
||||||
|
newSize: { width: nextWidth, height: nextHeight },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextSingleWidthAndHeightFromPointer = (
|
const getNextSingleWidthAndHeightFromPointer = (
|
||||||
latestElement: ExcalidrawElement,
|
latestElement: ExcalidrawElement,
|
||||||
origElement: ExcalidrawElement,
|
origElement: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
originalElementsMap: ElementsMap,
|
|
||||||
handleDirection: TransformHandleDirection,
|
handleDirection: TransformHandleDirection,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
@ -1522,28 +1516,26 @@ export const resizeMultipleElements = (
|
|||||||
element,
|
element,
|
||||||
update: { boundTextFontSize, ...update },
|
update: { boundTextFontSize, ...update },
|
||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { angle } = update;
|
const { width, height, angle } = update;
|
||||||
|
|
||||||
mutateElement(element, update, false, {
|
scene.mutateElement(element, update, {
|
||||||
|
informMutation: true,
|
||||||
// needed for the fixed binding point udpate to take effect
|
// needed for the fixed binding point udpate to take effect
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap as SceneElementsMap, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
|
newSize: { width, height },
|
||||||
});
|
});
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement && boundTextFontSize) {
|
if (boundTextElement && boundTextFontSize) {
|
||||||
mutateElement(
|
scene.mutateElement(boundTextElement, {
|
||||||
boundTextElement,
|
fontSize: boundTextFontSize,
|
||||||
{
|
angle: isLinearElement(element) ? undefined : angle,
|
||||||
fontSize: boundTextFontSize,
|
});
|
||||||
angle: isLinearElement(element) ? undefined : angle,
|
handleBindTextResize(element, scene, handleDirection, true);
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
handleBindTextResize(element, elementsMap, handleDirection, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { isShallowEqual } from "@excalidraw/common";
|
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@ -7,13 +7,20 @@ import type {
|
|||||||
|
|
||||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
import { isElementInViewport } from "./sizeHelpers";
|
import { isElementInViewport } from "./sizeHelpers";
|
||||||
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
|
import {
|
||||||
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
|
isLinearElement,
|
||||||
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
elementOverlapsWithFrame,
|
elementOverlapsWithFrame,
|
||||||
getContainingFrame,
|
getContainingFrame,
|
||||||
getFrameChildren,
|
getFrameChildren,
|
||||||
} from "./frame";
|
} from "./frame";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { selectGroupsForSelectedElements } from "./groups";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ElementsMapOrArray,
|
ElementsMapOrArray,
|
||||||
@ -162,25 +169,6 @@ export const isSomeElementSelected = (function () {
|
|||||||
return ret;
|
return ret;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
|
||||||
* elements. If elements don't share the same value, returns `null`.
|
|
||||||
*/
|
|
||||||
export const getCommonAttributeOfSelectedElements = <T>(
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
appState: Pick<AppState, "selectedElementIds">,
|
|
||||||
getAttribute: (element: ExcalidrawElement) => T,
|
|
||||||
): T | null => {
|
|
||||||
const attributes = Array.from(
|
|
||||||
new Set(
|
|
||||||
getSelectedElements(elements, appState).map((element) =>
|
|
||||||
getAttribute(element),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return attributes.length === 1 ? attributes[0] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSelectedElements = (
|
export const getSelectedElements = (
|
||||||
elements: ElementsMapOrArray,
|
elements: ElementsMapOrArray,
|
||||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||||
@ -254,3 +242,49 @@ export const makeNextSelectedElementIds = (
|
|||||||
|
|
||||||
return nextSelectedElementIds;
|
return nextSelectedElementIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _getLinearElementEditor = (
|
||||||
|
targetElements: readonly ExcalidrawElement[],
|
||||||
|
allElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
const linears = targetElements.filter(isLinearElement);
|
||||||
|
if (linears.length === 1) {
|
||||||
|
const linear = linears[0];
|
||||||
|
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||||
|
const onlySingleLinearSelected = targetElements.every(
|
||||||
|
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onlySingleLinearSelected) {
|
||||||
|
return new LinearElementEditor(linear, arrayToMap(allElements));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelectionStateForElements = (
|
||||||
|
targetElements: readonly ExcalidrawElement[],
|
||||||
|
allElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
|
||||||
|
...selectGroupsForSelectedElements(
|
||||||
|
{
|
||||||
|
editingGroupId: appState.editingGroupId,
|
||||||
|
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||||
|
targetElements,
|
||||||
|
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
|
if (!isBoundToContainer(element)) {
|
||||||
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
allElements,
|
||||||
|
appState,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
invariant,
|
invariant,
|
||||||
|
elementCenterPoint,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
isPoint,
|
isPoint,
|
||||||
@ -297,7 +298,7 @@ export const aabbForElement = (
|
|||||||
midY: element.y + element.height / 2,
|
midY: element.y + element.height / 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const center = pointFrom(bbox.midX, bbox.midY);
|
const center = elementCenterPoint(element);
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
center,
|
center,
|
||||||
|
@ -6,7 +6,6 @@ import {
|
|||||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
@ -170,41 +169,6 @@ export const getLockedLinearCursorAlignSize = (
|
|||||||
return { width, height };
|
return { width, height };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizePerfectLineForNWHandler = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
) => {
|
|
||||||
const anchorX = element.x + element.width;
|
|
||||||
const anchorY = element.y + element.height;
|
|
||||||
const distanceToAnchorX = x - anchorX;
|
|
||||||
const distanceToAnchorY = y - anchorY;
|
|
||||||
if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
|
|
||||||
mutateElement(element, {
|
|
||||||
x: anchorX,
|
|
||||||
width: 0,
|
|
||||||
y,
|
|
||||||
height: -distanceToAnchorY,
|
|
||||||
});
|
|
||||||
} else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
|
|
||||||
mutateElement(element, {
|
|
||||||
y: anchorY,
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const nextHeight =
|
|
||||||
Math.sign(distanceToAnchorY) *
|
|
||||||
Math.sign(distanceToAnchorX) *
|
|
||||||
element.width;
|
|
||||||
mutateElement(element, {
|
|
||||||
x,
|
|
||||||
y: anchorY - nextHeight,
|
|
||||||
width: -distanceToAnchorX,
|
|
||||||
height: nextHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNormalizedDimensions = (
|
export const getNormalizedDimensions = (
|
||||||
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
|
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
|
||||||
): {
|
): {
|
||||||
|
972
packages/element/src/store.ts
Normal file
972
packages/element/src/store.ts
Normal file
@ -0,0 +1,972 @@
|
|||||||
|
import {
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
|
randomId,
|
||||||
|
Emitter,
|
||||||
|
toIterable,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type App from "@excalidraw/excalidraw/components/App";
|
||||||
|
|
||||||
|
import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
|
import { deepCopyElement } from "./duplicate";
|
||||||
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
|
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||||
|
|
||||||
|
import { hashElementsVersion, hashString } from "./index";
|
||||||
|
|
||||||
|
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
||||||
|
|
||||||
|
export const CaptureUpdateAction = {
|
||||||
|
/**
|
||||||
|
* Immediately undoable.
|
||||||
|
*
|
||||||
|
* Use for updates which should be captured.
|
||||||
|
* Should be used for most of the local updates, except ephemerals such as dragging or resizing.
|
||||||
|
*
|
||||||
|
* These updates will _immediately_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
|
IMMEDIATELY: "IMMEDIATELY",
|
||||||
|
/**
|
||||||
|
* Never undoable.
|
||||||
|
*
|
||||||
|
* Use for updates which should never be recorded, such as remote updates
|
||||||
|
* or scene initialization.
|
||||||
|
*
|
||||||
|
* These updates will _never_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
|
NEVER: "NEVER",
|
||||||
|
/**
|
||||||
|
* Eventually undoable.
|
||||||
|
*
|
||||||
|
* Use for updates which should not be captured immediately - likely
|
||||||
|
* exceptions which are part of some async multi-step process. Otherwise, all
|
||||||
|
* such updates would end up being captured with the next
|
||||||
|
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
|
||||||
|
* or internally by the editor.
|
||||||
|
*
|
||||||
|
* These updates will _eventually_ make it to the local undo / redo stacks.
|
||||||
|
*/
|
||||||
|
EVENTUALLY: "EVENTUALLY",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
|
||||||
|
|
||||||
|
type MicroActionsQueue = (() => void)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
|
*/
|
||||||
|
export class Store {
|
||||||
|
// internally used by history
|
||||||
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
|
[DurableIncrement | EphemeralIncrement]
|
||||||
|
>();
|
||||||
|
|
||||||
|
private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
|
||||||
|
private scheduledMicroActions: MicroActionsQueue = [];
|
||||||
|
|
||||||
|
private _snapshot = StoreSnapshot.empty();
|
||||||
|
|
||||||
|
public get snapshot() {
|
||||||
|
return this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set snapshot(snapshot: StoreSnapshot) {
|
||||||
|
this._snapshot = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly app: App) {}
|
||||||
|
|
||||||
|
public scheduleAction(action: CaptureUpdateActionType) {
|
||||||
|
this.scheduledMacroActions.add(action);
|
||||||
|
this.satisfiesScheduledActionsInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
|
||||||
|
*/
|
||||||
|
// TODO: Suspicious that this is called so many places. Seems error-prone.
|
||||||
|
public scheduleCapture() {
|
||||||
|
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
|
||||||
|
*/
|
||||||
|
public scheduleMicroAction(
|
||||||
|
params:
|
||||||
|
| {
|
||||||
|
action: CaptureUpdateActionType;
|
||||||
|
elements: SceneElementsMap | undefined;
|
||||||
|
appState: AppState | ObservedAppState | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
action: typeof CaptureUpdateAction.IMMEDIATELY;
|
||||||
|
change: StoreChange;
|
||||||
|
delta: StoreDelta;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
action:
|
||||||
|
| typeof CaptureUpdateAction.NEVER
|
||||||
|
| typeof CaptureUpdateAction.EVENTUALLY;
|
||||||
|
change: StoreChange;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { action } = params;
|
||||||
|
|
||||||
|
let change: StoreChange;
|
||||||
|
|
||||||
|
if ("change" in params) {
|
||||||
|
change = params.change;
|
||||||
|
} else {
|
||||||
|
// immediately create an immutable change of the scheduled updates,
|
||||||
|
// compared to the current state, so that they won't mutate later on during batching
|
||||||
|
const currentSnapshot = StoreSnapshot.create(
|
||||||
|
this.app.scene.getElementsMapIncludingDeleted(),
|
||||||
|
this.app.state,
|
||||||
|
);
|
||||||
|
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||||
|
action,
|
||||||
|
params.elements,
|
||||||
|
params.appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
change = StoreChange.create(currentSnapshot, scheduledSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = "delta" in params ? params.delta : undefined;
|
||||||
|
|
||||||
|
this.scheduledMicroActions.push(() =>
|
||||||
|
this.processAction({
|
||||||
|
action,
|
||||||
|
change,
|
||||||
|
delta,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
|
||||||
|
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
|
||||||
|
*
|
||||||
|
* @emits StoreIncrement
|
||||||
|
*/
|
||||||
|
public commit(
|
||||||
|
elements: SceneElementsMap | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
): void {
|
||||||
|
// execute all scheduled micro actions first
|
||||||
|
// similar to microTasks, there can be many
|
||||||
|
this.flushMicroActions();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// execute a single scheduled "macro" function
|
||||||
|
// similar to macro tasks, there can be only one within a single commit (loop)
|
||||||
|
const action = this.getScheduledMacroAction();
|
||||||
|
this.processAction({ action, elements, appState });
|
||||||
|
} finally {
|
||||||
|
this.satisfiesScheduledActionsInvariant();
|
||||||
|
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
|
||||||
|
this.scheduledMacroActions = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the store instance.
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.snapshot = StoreSnapshot.empty();
|
||||||
|
this.scheduledMacroActions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs delta & change calculation and emits a durable increment.
|
||||||
|
*
|
||||||
|
* @emits StoreIncrement.
|
||||||
|
*/
|
||||||
|
private emitDurableIncrement(
|
||||||
|
snapshot: StoreSnapshot,
|
||||||
|
change: StoreChange | undefined = undefined,
|
||||||
|
delta: StoreDelta | undefined = undefined,
|
||||||
|
) {
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
|
||||||
|
let storeChange: StoreChange;
|
||||||
|
let storeDelta: StoreDelta;
|
||||||
|
|
||||||
|
if (change) {
|
||||||
|
storeChange = change;
|
||||||
|
} else {
|
||||||
|
storeChange = StoreChange.create(prevSnapshot, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
// we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
|
||||||
|
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
||||||
|
storeDelta = delta;
|
||||||
|
} else {
|
||||||
|
// calculate the deltas based on the previous and next snapshot
|
||||||
|
const elementsDelta = snapshot.metadata.didElementsChange
|
||||||
|
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
|
||||||
|
: ElementsDelta.empty();
|
||||||
|
|
||||||
|
const appStateDelta = snapshot.metadata.didAppStateChange
|
||||||
|
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
|
||||||
|
: AppStateDelta.empty();
|
||||||
|
|
||||||
|
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeDelta.isEmpty()) {
|
||||||
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs change calculation and emits an ephemeral increment.
|
||||||
|
*
|
||||||
|
* @emits EphemeralStoreIncrement
|
||||||
|
*/
|
||||||
|
private emitEphemeralIncrement(
|
||||||
|
snapshot: StoreSnapshot,
|
||||||
|
change: StoreChange | undefined = undefined,
|
||||||
|
) {
|
||||||
|
let storeChange: StoreChange;
|
||||||
|
|
||||||
|
if (change) {
|
||||||
|
storeChange = change;
|
||||||
|
} else {
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
storeChange = StoreChange.create(prevSnapshot, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const increment = new EphemeralIncrement(storeChange);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyChangeToSnapshot(change: StoreChange) {
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.applyChange(change);
|
||||||
|
|
||||||
|
if (prevSnapshot === nextSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones the snapshot if there are changes detected.
|
||||||
|
*/
|
||||||
|
private maybeCloneSnapshot(
|
||||||
|
action: CaptureUpdateActionType,
|
||||||
|
elements: SceneElementsMap | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
) {
|
||||||
|
if (!elements && !appState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
|
||||||
|
|
||||||
|
if (prevSnapshot === nextSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushMicroActions() {
|
||||||
|
for (const microAction of this.scheduledMicroActions) {
|
||||||
|
try {
|
||||||
|
microAction();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to execute scheduled micro action`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduledMicroActions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private processAction(
|
||||||
|
params:
|
||||||
|
| {
|
||||||
|
action: CaptureUpdateActionType;
|
||||||
|
elements: SceneElementsMap | undefined;
|
||||||
|
appState: AppState | ObservedAppState | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
action: CaptureUpdateActionType;
|
||||||
|
change: StoreChange;
|
||||||
|
delta: StoreDelta | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { action } = params;
|
||||||
|
|
||||||
|
// perf. optimisation, since "EVENTUALLY" does not update the snapshot,
|
||||||
|
// so if nobody is listening for increments, we don't need to even clone the snapshot
|
||||||
|
// as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
|
||||||
|
if (
|
||||||
|
action === CaptureUpdateAction.EVENTUALLY &&
|
||||||
|
!this.onStoreIncrementEmitter.subscribers.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSnapshot: StoreSnapshot | null;
|
||||||
|
|
||||||
|
if ("change" in params) {
|
||||||
|
nextSnapshot = this.applyChangeToSnapshot(params.change);
|
||||||
|
} else {
|
||||||
|
nextSnapshot = this.maybeCloneSnapshot(
|
||||||
|
action,
|
||||||
|
params.elements,
|
||||||
|
params.appState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextSnapshot) {
|
||||||
|
// don't continue if there is not change detected
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = "change" in params ? params.change : undefined;
|
||||||
|
const delta = "delta" in params ? params.delta : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
// only immediately emits a durable increment
|
||||||
|
case CaptureUpdateAction.IMMEDIATELY:
|
||||||
|
this.emitDurableIncrement(nextSnapshot, change, delta);
|
||||||
|
break;
|
||||||
|
// both never and eventually emit an ephemeral increment
|
||||||
|
case CaptureUpdateAction.NEVER:
|
||||||
|
case CaptureUpdateAction.EVENTUALLY:
|
||||||
|
this.emitEphemeralIncrement(nextSnapshot, change);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assertNever(action, `Unknown store action`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// update the snapshot no-matter what, as it would mess up with the next action
|
||||||
|
switch (action) {
|
||||||
|
// both immediately and never update the snapshot, unlike eventually
|
||||||
|
case CaptureUpdateAction.IMMEDIATELY:
|
||||||
|
case CaptureUpdateAction.NEVER:
|
||||||
|
this.snapshot = nextSnapshot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the scheduled macro action.
|
||||||
|
*/
|
||||||
|
private getScheduledMacroAction() {
|
||||||
|
let scheduledAction: CaptureUpdateActionType;
|
||||||
|
|
||||||
|
if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
|
||||||
|
// Capture has a precedence over update, since it also performs snapshot update
|
||||||
|
scheduledAction = CaptureUpdateAction.IMMEDIATELY;
|
||||||
|
} else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
|
||||||
|
// Update has a precedence over none, since it also emits an (ephemeral) increment
|
||||||
|
scheduledAction = CaptureUpdateAction.NEVER;
|
||||||
|
} else {
|
||||||
|
// Default is to emit ephemeral increment and don't update the snapshot
|
||||||
|
scheduledAction = CaptureUpdateAction.EVENTUALLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduledAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the scheduled actions invariant is satisfied.
|
||||||
|
*/
|
||||||
|
private satisfiesScheduledActionsInvariant() {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
this.scheduledMacroActions.size >= 0 &&
|
||||||
|
this.scheduledMacroActions.size <=
|
||||||
|
Object.keys(CaptureUpdateAction).length
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
|
||||||
|
console.error(message, this.scheduledMacroActions.values());
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repsents a change to the store containing changed elements and appState.
|
||||||
|
*/
|
||||||
|
export class StoreChange {
|
||||||
|
// so figuring out what has changed should ideally be just quick reference checks
|
||||||
|
// TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
|
||||||
|
private constructor(
|
||||||
|
public readonly elements: Record<string, OrderedExcalidrawElement>,
|
||||||
|
public readonly appState: Partial<ObservedAppState>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
prevSnapshot: StoreSnapshot,
|
||||||
|
nextSnapshot: StoreSnapshot,
|
||||||
|
) {
|
||||||
|
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
|
||||||
|
const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
|
||||||
|
|
||||||
|
return new StoreChange(changedElements, changedAppState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encpasulates any change to the store (durable or ephemeral).
|
||||||
|
*/
|
||||||
|
export abstract class StoreIncrement {
|
||||||
|
protected constructor(
|
||||||
|
public readonly type: "durable" | "ephemeral",
|
||||||
|
public readonly change: StoreChange,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static isDurable(
|
||||||
|
increment: StoreIncrement,
|
||||||
|
): increment is DurableIncrement {
|
||||||
|
return increment.type === "durable";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isEphemeral(
|
||||||
|
increment: StoreIncrement,
|
||||||
|
): increment is EphemeralIncrement {
|
||||||
|
return increment.type === "ephemeral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a durable change to the store.
|
||||||
|
*/
|
||||||
|
export class DurableIncrement extends StoreIncrement {
|
||||||
|
constructor(
|
||||||
|
public readonly change: StoreChange,
|
||||||
|
public readonly delta: StoreDelta,
|
||||||
|
) {
|
||||||
|
super("durable", change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an ephemeral change to the store.
|
||||||
|
*/
|
||||||
|
export class EphemeralIncrement extends StoreIncrement {
|
||||||
|
constructor(public readonly change: StoreChange) {
|
||||||
|
super("ephemeral", change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a captured delta by the Store.
|
||||||
|
*/
|
||||||
|
export class StoreDelta {
|
||||||
|
protected constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly elements: ElementsDelta,
|
||||||
|
public readonly appState: AppStateDelta,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance of `StoreDelta`.
|
||||||
|
*/
|
||||||
|
public static create(
|
||||||
|
elements: ElementsDelta,
|
||||||
|
appState: AppStateDelta,
|
||||||
|
opts: {
|
||||||
|
id: string;
|
||||||
|
} = {
|
||||||
|
id: randomId(),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return new this(opts.id, elements, appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a store delta instance from a DTO.
|
||||||
|
*/
|
||||||
|
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||||
|
const { id, elements, appState } = storeDeltaDTO;
|
||||||
|
return new this(
|
||||||
|
id,
|
||||||
|
ElementsDelta.restore(elements),
|
||||||
|
AppStateDelta.restore(appState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and load the delta from the remote payload.
|
||||||
|
*/
|
||||||
|
public static load({
|
||||||
|
id,
|
||||||
|
elements: { added, removed, updated },
|
||||||
|
}: DTO<StoreDelta>) {
|
||||||
|
const elements = ElementsDelta.create(added, removed, updated, {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new this(id, elements, AppStateDelta.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
|
*/
|
||||||
|
public static inverse(delta: StoreDelta): StoreDelta {
|
||||||
|
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||||
|
*/
|
||||||
|
public static applyLatestChanges(
|
||||||
|
delta: StoreDelta,
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): StoreDelta {
|
||||||
|
return this.create(
|
||||||
|
delta.elements.applyLatestChanges(elements, modifierOptions),
|
||||||
|
delta.appState,
|
||||||
|
{
|
||||||
|
id: delta.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||||
|
*/
|
||||||
|
public static applyTo(
|
||||||
|
delta: StoreDelta,
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
||||||
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
|
elements,
|
||||||
|
prevSnapshot.elements,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
|
delta.appState.applyTo(appState, nextElements);
|
||||||
|
|
||||||
|
const appliedVisibleChanges =
|
||||||
|
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||||
|
|
||||||
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty() {
|
||||||
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a snapshot of the captured or updated changes in the store,
|
||||||
|
* used for producing deltas and emitting `DurableStoreIncrement`s.
|
||||||
|
*/
|
||||||
|
export class StoreSnapshot {
|
||||||
|
private _lastChangedElementsHash: number = 0;
|
||||||
|
private _lastChangedAppStateHash: number = 0;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
public readonly elements: SceneElementsMap,
|
||||||
|
public readonly appState: ObservedAppState,
|
||||||
|
public readonly metadata: {
|
||||||
|
didElementsChange: boolean;
|
||||||
|
didAppStateChange: boolean;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
} = {
|
||||||
|
didElementsChange: false,
|
||||||
|
didAppStateChange: false,
|
||||||
|
isEmpty: false,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
appState: AppState | ObservedAppState,
|
||||||
|
metadata: {
|
||||||
|
didElementsChange: boolean;
|
||||||
|
didAppStateChange: boolean;
|
||||||
|
} = {
|
||||||
|
didElementsChange: false,
|
||||||
|
didAppStateChange: false,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return new StoreSnapshot(
|
||||||
|
elements,
|
||||||
|
isObservedAppState(appState) ? appState : getObservedAppState(appState),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return new StoreSnapshot(
|
||||||
|
new Map() as SceneElementsMap,
|
||||||
|
getDefaultObservedAppState(),
|
||||||
|
{
|
||||||
|
didElementsChange: false,
|
||||||
|
didAppStateChange: false,
|
||||||
|
isEmpty: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChangedElements(prevSnapshot: StoreSnapshot) {
|
||||||
|
const changedElements: Record<string, OrderedExcalidrawElement> = {};
|
||||||
|
|
||||||
|
for (const prevElement of toIterable(prevSnapshot.elements)) {
|
||||||
|
const nextElement = this.elements.get(prevElement.id);
|
||||||
|
|
||||||
|
if (!nextElement) {
|
||||||
|
changedElements[prevElement.id] = newElementWith(prevElement, {
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextElement of toIterable(this.elements)) {
|
||||||
|
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
|
||||||
|
if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
|
||||||
|
changedElements[nextElement.id] = nextElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChangedAppState(
|
||||||
|
prevSnapshot: StoreSnapshot,
|
||||||
|
): Partial<ObservedAppState> {
|
||||||
|
return Delta.getRightDifferences(
|
||||||
|
prevSnapshot.appState,
|
||||||
|
this.appState,
|
||||||
|
).reduce(
|
||||||
|
(acc, key) =>
|
||||||
|
Object.assign(acc, {
|
||||||
|
[key]: this.appState[key as keyof ObservedAppState],
|
||||||
|
}),
|
||||||
|
{} as Partial<ObservedAppState>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty() {
|
||||||
|
return this.metadata.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the change and return a new snapshot instance.
|
||||||
|
*/
|
||||||
|
public applyChange(change: StoreChange): StoreSnapshot {
|
||||||
|
const nextElements = new Map(this.elements) as SceneElementsMap;
|
||||||
|
|
||||||
|
for (const [id, changedElement] of Object.entries(change.elements)) {
|
||||||
|
nextElements.set(id, changedElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAppState = Object.assign(
|
||||||
|
{},
|
||||||
|
this.appState,
|
||||||
|
change.appState,
|
||||||
|
) as ObservedAppState;
|
||||||
|
|
||||||
|
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||||
|
// by default we assume that change is different from what we have in the snapshot
|
||||||
|
// so that we trigger the delta calculation and if it isn't different, delta will be empty
|
||||||
|
didElementsChange: Object.keys(change.elements).length > 0,
|
||||||
|
didAppStateChange: Object.keys(change.appState).length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efficiently clone the existing snapshot, only if we detected changes.
|
||||||
|
*
|
||||||
|
* @returns same instance if there are no changes detected, new instance otherwise.
|
||||||
|
*/
|
||||||
|
public maybeClone(
|
||||||
|
action: CaptureUpdateActionType,
|
||||||
|
elements: SceneElementsMap | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
) {
|
||||||
|
const options = {
|
||||||
|
shouldCompareHashes: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === CaptureUpdateAction.EVENTUALLY) {
|
||||||
|
// actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
|
||||||
|
// as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
|
||||||
|
// instead of just the first time the elements or appState actually changed
|
||||||
|
options.shouldCompareHashes = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
|
||||||
|
elements,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
|
||||||
|
appState,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
let didElementsChange = false;
|
||||||
|
let didAppStateChange = false;
|
||||||
|
|
||||||
|
if (this.elements !== nextElementsSnapshot) {
|
||||||
|
didElementsChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.appState !== nextAppStateSnapshot) {
|
||||||
|
didAppStateChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!didElementsChange && !didAppStateChange) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = new StoreSnapshot(
|
||||||
|
nextElementsSnapshot,
|
||||||
|
nextAppStateSnapshot,
|
||||||
|
{
|
||||||
|
didElementsChange,
|
||||||
|
didAppStateChange,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeCreateAppStateSnapshot(
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
options: {
|
||||||
|
shouldCompareHashes: boolean;
|
||||||
|
} = {
|
||||||
|
shouldCompareHashes: false,
|
||||||
|
},
|
||||||
|
): ObservedAppState {
|
||||||
|
if (!appState) {
|
||||||
|
return this.appState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not watching over everything from the app state, just the relevant props
|
||||||
|
const nextAppStateSnapshot = !isObservedAppState(appState)
|
||||||
|
? getObservedAppState(appState)
|
||||||
|
: appState;
|
||||||
|
|
||||||
|
const didAppStateChange = this.detectChangedAppState(
|
||||||
|
nextAppStateSnapshot,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!didAppStateChange) {
|
||||||
|
return this.appState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextAppStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeCreateElementsSnapshot(
|
||||||
|
elements: SceneElementsMap | undefined,
|
||||||
|
options: {
|
||||||
|
shouldCompareHashes: boolean;
|
||||||
|
} = {
|
||||||
|
shouldCompareHashes: false,
|
||||||
|
},
|
||||||
|
): SceneElementsMap {
|
||||||
|
if (!elements) {
|
||||||
|
return this.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedElements = this.detectChangedElements(elements, options);
|
||||||
|
|
||||||
|
if (!changedElements?.size) {
|
||||||
|
return this.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementsSnapshot = this.createElementsSnapshot(changedElements);
|
||||||
|
return elementsSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectChangedAppState(
|
||||||
|
nextObservedAppState: ObservedAppState,
|
||||||
|
options: {
|
||||||
|
shouldCompareHashes: boolean;
|
||||||
|
} = {
|
||||||
|
shouldCompareHashes: false,
|
||||||
|
},
|
||||||
|
): boolean | undefined {
|
||||||
|
if (this.appState === nextObservedAppState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didAppStateChange = Delta.isRightDifferent(
|
||||||
|
this.appState,
|
||||||
|
nextObservedAppState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!didAppStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedAppStateHash = hashString(
|
||||||
|
JSON.stringify(nextObservedAppState),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.shouldCompareHashes &&
|
||||||
|
this._lastChangedAppStateHash === changedAppStateHash
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastChangedAppStateHash = changedAppStateHash;
|
||||||
|
|
||||||
|
return didAppStateChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if there any changed elements.
|
||||||
|
*/
|
||||||
|
private detectChangedElements(
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
options: {
|
||||||
|
shouldCompareHashes: boolean;
|
||||||
|
} = {
|
||||||
|
shouldCompareHashes: false,
|
||||||
|
},
|
||||||
|
): SceneElementsMap | undefined {
|
||||||
|
if (this.elements === nextElements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
|
||||||
|
|
||||||
|
for (const prevElement of toIterable(this.elements)) {
|
||||||
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
|
if (!nextElement) {
|
||||||
|
// element was deleted
|
||||||
|
changedElements.set(
|
||||||
|
prevElement.id,
|
||||||
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextElement of toIterable(nextElements)) {
|
||||||
|
const prevElement = this.elements.get(nextElement.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!prevElement || // element was added
|
||||||
|
prevElement.version < nextElement.version // element was updated
|
||||||
|
) {
|
||||||
|
changedElements.set(nextElement.id, nextElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changedElements.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedElementsHash = hashElementsVersion(changedElements);
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.shouldCompareHashes &&
|
||||||
|
this._lastChangedElementsHash === changedElementsHash
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastChangedElementsHash = changedElementsHash;
|
||||||
|
|
||||||
|
return changedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform structural clone, deep cloning only elements that changed.
|
||||||
|
*/
|
||||||
|
private createElementsSnapshot(changedElements: SceneElementsMap) {
|
||||||
|
const clonedElements = new Map() as SceneElementsMap;
|
||||||
|
|
||||||
|
for (const prevElement of toIterable(this.elements)) {
|
||||||
|
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
|
||||||
|
// i.e. during collab, persist or whenenever isDeleted elements get cleared
|
||||||
|
clonedElements.set(prevElement.id, prevElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const changedElement of toIterable(changedElements)) {
|
||||||
|
// TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
|
||||||
|
// TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
|
||||||
|
clonedElements.set(changedElement.id, deepCopyElement(changedElement));
|
||||||
|
}
|
||||||
|
|
||||||
|
return clonedElements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hidden non-enumerable property for runtime checks
|
||||||
|
const hiddenObservedAppStateProp = "__observedAppState";
|
||||||
|
|
||||||
|
const getDefaultObservedAppState = (): ObservedAppState => {
|
||||||
|
return {
|
||||||
|
name: null,
|
||||||
|
editingGroupId: null,
|
||||||
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
editingLinearElementId: null,
|
||||||
|
selectedLinearElementId: null,
|
||||||
|
croppingElementId: null,
|
||||||
|
activeLockedId: null,
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||||
|
const observedAppState = {
|
||||||
|
name: appState.name,
|
||||||
|
editingGroupId: appState.editingGroupId,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
selectedGroupIds: appState.selectedGroupIds,
|
||||||
|
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||||
|
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||||
|
croppingElementId: appState.croppingElementId,
|
||||||
|
activeLockedId: appState.activeLockedId,
|
||||||
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
value: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return observedAppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObservedAppState = (
|
||||||
|
appState: AppState | ObservedAppState,
|
||||||
|
): appState is ObservedAppState =>
|
||||||
|
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
@ -6,18 +6,22 @@ import {
|
|||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
isProdEnv,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
|
||||||
import { measureText } from "./textMeasurements";
|
import { measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
@ -26,6 +30,8 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
@ -40,17 +46,30 @@ import type {
|
|||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
elementsMap: ElementsMap,
|
scene: Scene,
|
||||||
informMutation = true,
|
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
let maxWidth = undefined;
|
let maxWidth = undefined;
|
||||||
|
|
||||||
|
if (!isProdEnv()) {
|
||||||
|
invariant(
|
||||||
|
!container || !isArrowElement(container) || textElement.angle === 0,
|
||||||
|
"text element angle must be 0 if bound to arrow container",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const boundTextUpdates = {
|
const boundTextUpdates = {
|
||||||
x: textElement.x,
|
x: textElement.x,
|
||||||
y: textElement.y,
|
y: textElement.y,
|
||||||
text: textElement.text,
|
text: textElement.text,
|
||||||
width: textElement.width,
|
width: textElement.width,
|
||||||
height: textElement.height,
|
height: textElement.height,
|
||||||
angle: container?.angle ?? textElement.angle,
|
angle: (container
|
||||||
|
? isArrowElement(container)
|
||||||
|
? 0
|
||||||
|
: container.angle
|
||||||
|
: textElement.angle) as Radians,
|
||||||
};
|
};
|
||||||
|
|
||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
@ -90,38 +109,43 @@ export const redrawTextBoundingBox = (
|
|||||||
metrics.height,
|
metrics.height,
|
||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: nextHeight }, informMutation);
|
scene.mutateElement(container, { height: nextHeight });
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metrics.width > maxContainerWidth) {
|
if (metrics.width > maxContainerWidth) {
|
||||||
const nextWidth = computeContainerDimensionForBoundText(
|
const nextWidth = computeContainerDimensionForBoundText(
|
||||||
metrics.width,
|
metrics.width,
|
||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { width: nextWidth }, informMutation);
|
scene.mutateElement(container, { width: nextWidth });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTextElement = {
|
const updatedTextElement = {
|
||||||
...textElement,
|
...textElement,
|
||||||
...boundTextUpdates,
|
...boundTextUpdates,
|
||||||
} as ExcalidrawTextElementWithContainer;
|
} as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
const { x, y } = computeBoundTextPosition(
|
const { x, y } = computeBoundTextPosition(
|
||||||
container,
|
container,
|
||||||
updatedTextElement,
|
updatedTextElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
boundTextUpdates.x = x;
|
boundTextUpdates.x = x;
|
||||||
boundTextUpdates.y = y;
|
boundTextUpdates.y = y;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(textElement, boundTextUpdates, informMutation);
|
scene.mutateElement(textElement, boundTextUpdates);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleBindTextResize = (
|
export const handleBindTextResize = (
|
||||||
container: NonDeletedExcalidrawElement,
|
container: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
scene: Scene,
|
||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
shouldMaintainAspectRatio = false,
|
shouldMaintainAspectRatio = false,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElementId = getBoundTextElementId(container);
|
const boundTextElementId = getBoundTextElementId(container);
|
||||||
if (!boundTextElementId) {
|
if (!boundTextElementId) {
|
||||||
return;
|
return;
|
||||||
@ -174,20 +198,20 @@ export const handleBindTextResize = (
|
|||||||
transformHandleType === "n")
|
transformHandleType === "n")
|
||||||
? container.y - diff
|
? container.y - diff
|
||||||
: container.y;
|
: container.y;
|
||||||
mutateElement(container, {
|
scene.mutateElement(container, {
|
||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
y: updatedY,
|
y: updatedY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(textElement, {
|
scene.mutateElement(textElement, {
|
||||||
text,
|
text,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isArrowElement(container)) {
|
if (!isArrowElement(container)) {
|
||||||
mutateElement(
|
scene.mutateElement(
|
||||||
textElement,
|
textElement,
|
||||||
computeBoundTextPosition(container, textElement, elementsMap),
|
computeBoundTextPosition(container, textElement, elementsMap),
|
||||||
);
|
);
|
||||||
@ -335,7 +359,10 @@ export const getTextElementAngle = (
|
|||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawTextContainer | null,
|
container: ExcalidrawTextContainer | null,
|
||||||
) => {
|
) => {
|
||||||
if (!container || isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!container) {
|
||||||
return textElement.angle;
|
return textElement.angle;
|
||||||
}
|
}
|
||||||
return container.angle;
|
return container.angle;
|
||||||
|
@ -28,6 +28,7 @@ import type {
|
|||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
|
ExcalidrawLinearElementSubType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
@ -119,6 +120,20 @@ export const isElbowArrow = (
|
|||||||
return isArrowElement(element) && element.elbowed;
|
return isArrowElement(element) && element.elbowed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isSharpArrow = (
|
||||||
|
element?: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawArrowElement => {
|
||||||
|
return isArrowElement(element) && !element.elbowed && !element.roundness;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCurvedArrow = (
|
||||||
|
element?: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawArrowElement => {
|
||||||
|
return (
|
||||||
|
isArrowElement(element) && !element.elbowed && element.roundness !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const isLinearElementType = (
|
export const isLinearElementType = (
|
||||||
elementType: ElementOrToolType,
|
elementType: ElementOrToolType,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@ -130,7 +145,7 @@ export const isLinearElementType = (
|
|||||||
export const isBindingElement = (
|
export const isBindingElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
includeLocked = true,
|
includeLocked = true,
|
||||||
): element is ExcalidrawArrowElement => {
|
): element is ExcalidrawLinearElement => {
|
||||||
return (
|
return (
|
||||||
element != null &&
|
element != null &&
|
||||||
(!element.locked || includeLocked === true) &&
|
(!element.locked || includeLocked === true) &&
|
||||||
@ -271,6 +286,10 @@ export const isBoundToContainer = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
|
||||||
|
return !!element.startBinding || !!element.endBinding;
|
||||||
|
};
|
||||||
|
|
||||||
export const isUsingAdaptiveRadius = (type: string) =>
|
export const isUsingAdaptiveRadius = (type: string) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
type === "embeddable" ||
|
type === "embeddable" ||
|
||||||
@ -338,3 +357,18 @@ export const isBounds = (box: unknown): box is Bounds =>
|
|||||||
typeof box[1] === "number" &&
|
typeof box[1] === "number" &&
|
||||||
typeof box[2] === "number" &&
|
typeof box[2] === "number" &&
|
||||||
typeof box[3] === "number";
|
typeof box[3] === "number";
|
||||||
|
|
||||||
|
export const getLinearElementSubType = (
|
||||||
|
element: ExcalidrawLinearElement,
|
||||||
|
): ExcalidrawLinearElementSubType => {
|
||||||
|
if (isSharpArrow(element)) {
|
||||||
|
return "sharpArrow";
|
||||||
|
}
|
||||||
|
if (isCurvedArrow(element)) {
|
||||||
|
return "curvedArrow";
|
||||||
|
}
|
||||||
|
if (isElbowArrow(element)) {
|
||||||
|
return "elbowArrow";
|
||||||
|
}
|
||||||
|
return "line";
|
||||||
|
};
|
||||||
|
@ -296,6 +296,11 @@ export type FixedPointBinding = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type PointsPositionUpdates = Map<
|
||||||
|
number,
|
||||||
|
{ point: LocalPoint; isDragging?: boolean }
|
||||||
|
>;
|
||||||
|
|
||||||
export type Arrowhead =
|
export type Arrowhead =
|
||||||
| "arrow"
|
| "arrow"
|
||||||
| "bar"
|
| "bar"
|
||||||
@ -412,3 +417,13 @@ export type NonDeletedSceneElementsMap = Map<
|
|||||||
export type ElementsMapOrArray =
|
export type ElementsMapOrArray =
|
||||||
| readonly ExcalidrawElement[]
|
| readonly ExcalidrawElement[]
|
||||||
| Readonly<ElementsMap>;
|
| Readonly<ElementsMap>;
|
||||||
|
|
||||||
|
export type ExcalidrawLinearElementSubType =
|
||||||
|
| "line"
|
||||||
|
| "sharpArrow"
|
||||||
|
| "curvedArrow"
|
||||||
|
| "elbowArrow";
|
||||||
|
|
||||||
|
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||||
|
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
||||||
|
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||||
|
@ -10,6 +10,8 @@ import {
|
|||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./shapes";
|
||||||
@ -68,10 +70,7 @@ export function deconstructRectanguloidElement(
|
|||||||
return [sides, []];
|
return [sides, []];
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const r = rectangle(
|
const r = rectangle(
|
||||||
pointFrom(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
@ -254,10 +253,7 @@ export function deconstructDiamondElement(
|
|||||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
pointFrom(element.x + topX, element.y + topY),
|
||||||
|
@ -2,8 +2,6 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
|||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
|
||||||
|
|
||||||
import { isFrameLikeElement } from "./typeChecks";
|
import { isFrameLikeElement } from "./typeChecks";
|
||||||
|
|
||||||
import { getElementsInGroup } from "./groups";
|
import { getElementsInGroup } from "./groups";
|
||||||
@ -12,6 +10,8 @@ import { syncMovedIndices } from "./fractionalIndex";
|
|||||||
|
|
||||||
import { getSelectedElements } from "./selection";
|
import { getSelectedElements } from "./selection";
|
||||||
|
|
||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||||
|
|
||||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||||
|
@ -10,8 +10,6 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|||||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import "@excalidraw/utils/test-utils";
|
|
||||||
|
|
||||||
import { getTransformHandles } from "../src/transformHandles";
|
import { getTransformHandles } from "../src/transformHandles";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -20,9 +18,7 @@ const mouse = new Pointer("mouse");
|
|||||||
|
|
||||||
describe("element binding", () => {
|
describe("element binding", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
mouse.reset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create valid binding if duplicate start/end points", async () => {
|
it("should create valid binding if duplicate start/end points", async () => {
|
||||||
@ -93,55 +89,46 @@ describe("element binding", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// UX RATIONALE: We are not aware of any use-case where the user would want to
|
//@TODO fix the test with rotation
|
||||||
// have the arrow rebind after rotation but not when the arrow shaft is
|
it.skip("rotation of arrow should rebind both ends", () => {
|
||||||
// dragged so either the start or the end point is in the binding range of a
|
const rectLeft = UI.createElement("rectangle", {
|
||||||
// bindable element. So to remain consistent, we only "rebind" if at the end
|
x: 0,
|
||||||
// of the rotation the original binding would remain the same (i.e. like we
|
width: 200,
|
||||||
// would've evaluated binding only at the end of the operation).
|
height: 500,
|
||||||
it(
|
});
|
||||||
"rotation of arrow should not rebind on both ends if rotated enough to" +
|
const rectRight = UI.createElement("rectangle", {
|
||||||
" not be in the binding range of the original elements",
|
x: 400,
|
||||||
() => {
|
width: 200,
|
||||||
const rectLeft = UI.createElement("rectangle", {
|
height: 500,
|
||||||
x: 0,
|
});
|
||||||
width: 200,
|
const arrow = UI.createElement("arrow", {
|
||||||
height: 500,
|
x: 210,
|
||||||
});
|
y: 250,
|
||||||
const rectRight = UI.createElement("rectangle", {
|
width: 180,
|
||||||
x: 400,
|
height: 1,
|
||||||
width: 200,
|
});
|
||||||
height: 500,
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||||
});
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||||
const arrow = UI.createElement("arrow", {
|
|
||||||
x: 210,
|
|
||||||
y: 250,
|
|
||||||
width: 180,
|
|
||||||
height: 1,
|
|
||||||
});
|
|
||||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
|
||||||
|
|
||||||
const rotation = getTransformHandles(
|
const rotation = getTransformHandles(
|
||||||
arrow,
|
arrow,
|
||||||
h.state.zoom,
|
h.state.zoom,
|
||||||
arrayToMap(h.elements),
|
arrayToMap(h.elements),
|
||||||
"mouse",
|
"mouse",
|
||||||
).rotation!;
|
).rotation!;
|
||||||
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
||||||
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
||||||
mouse.down(rotationHandleX, rotationHandleY);
|
mouse.down(rotationHandleX, rotationHandleY);
|
||||||
mouse.move(300, 400);
|
mouse.move(300, 400);
|
||||||
mouse.up();
|
mouse.up();
|
||||||
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
||||||
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
||||||
expect(arrow.startBinding).toBe(null);
|
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// TODO fix & reenable once we rewrite tests to work with concurrency
|
// TODO fix & reenable once we rewrite tests to work with concurrency
|
||||||
it(
|
it.skip(
|
||||||
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
||||||
"editing by clicking on element A should end up selecting A",
|
"editing by clicking on element A should end up selecting A",
|
||||||
async () => {
|
async () => {
|
||||||
@ -155,10 +142,7 @@ describe("element binding", () => {
|
|||||||
mouse.up(0, 80);
|
mouse.up(0, 80);
|
||||||
|
|
||||||
// Edit arrow with multi-point
|
// Edit arrow with multi-point
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
mouse.doubleClick();
|
||||||
mouse.doubleClick();
|
|
||||||
});
|
|
||||||
|
|
||||||
// move arrow head
|
// move arrow head
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(0, 10);
|
mouse.up(0, 10);
|
||||||
@ -168,12 +152,16 @@ describe("element binding", () => {
|
|||||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
expect(h.state.editingLinearElement).not.toBe(null);
|
expect(h.state.editingLinearElement).not.toBe(null);
|
||||||
mouse.click();
|
mouse.down(0, 0);
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
expect(h.state.editingLinearElement).toBe(null);
|
||||||
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
|
mouse.up();
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("should not move bound arrows when moving it with keyboard", () => {
|
it("should unbind arrow when moving it with keyboard", () => {
|
||||||
const rectangle = UI.createElement("rectangle", {
|
const rectangle = UI.createElement("rectangle", {
|
||||||
x: 75,
|
x: 75,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -199,19 +187,13 @@ describe("element binding", () => {
|
|||||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||||
expect(API.getSelectedElement().type).toBe("arrow");
|
|
||||||
|
|
||||||
// Sever connection
|
// Sever connection
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
expect(API.getSelectedElement().type).toBe("arrow");
|
||||||
// We have to move a significant distance to get out of the binding zone
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
Array.from({ length: 10 }).forEach(() => {
|
expect(arrow.endBinding).toBe(null);
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
});
|
expect(arrow.endBinding).toBe(null);
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
|
||||||
expect(arrow.x).toBe(0);
|
|
||||||
expect(arrow.y).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unbind on bound element deletion", () => {
|
it("should unbind on bound element deletion", () => {
|
||||||
@ -499,86 +481,4 @@ describe("element binding", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// UX RATIONALE: The arrow might be outside of the shape at high zoom and you
|
|
||||||
// won't see what's going on.
|
|
||||||
it(
|
|
||||||
"allow non-binding simple (complex) arrow creation while start and end" +
|
|
||||||
" points are in the same shape",
|
|
||||||
() => {
|
|
||||||
const rect = UI.createElement("rectangle", {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow = UI.createElement("arrow", {
|
|
||||||
x: 5,
|
|
||||||
y: 5,
|
|
||||||
height: 95,
|
|
||||||
width: 95,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow.startBinding).toBe(null);
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
|
||||||
expect(rect.boundElements).toEqual(null);
|
|
||||||
expect(arrow.points).toCloselyEqualPoints([
|
|
||||||
[0, 0],
|
|
||||||
[92.2855, 92.2855],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 300,
|
|
||||||
y: 300,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
backgroundColor: "red",
|
|
||||||
fillStyle: "solid",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect2]);
|
|
||||||
|
|
||||||
const arrow2 = UI.createElement("arrow", {
|
|
||||||
x: 305,
|
|
||||||
y: 305,
|
|
||||||
height: 95,
|
|
||||||
width: 95,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow2.startBinding).toBe(null);
|
|
||||||
expect(arrow2.endBinding).toBe(null);
|
|
||||||
expect(rect2.boundElements).toEqual(null);
|
|
||||||
expect(arrow2.points).toCloselyEqualPoints([
|
|
||||||
[0, 0],
|
|
||||||
[92.2855, 92.2855],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rect3 = UI.createElement("rectangle", {
|
|
||||||
x: 0,
|
|
||||||
y: 300,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow3 = UI.createElement("arrow", {
|
|
||||||
x: 10,
|
|
||||||
y: 310,
|
|
||||||
height: 85,
|
|
||||||
width: 84,
|
|
||||||
elbowed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow3.startBinding).toBe(null);
|
|
||||||
expect(arrow3.endBinding).toBe(null);
|
|
||||||
expect(rect3.boundElements).toEqual(null);
|
|
||||||
expect(arrow3.points).toCloselyEqualPoints([
|
|
||||||
[0, 0],
|
|
||||||
[0, 42.5],
|
|
||||||
[84, 42.5],
|
|
||||||
[84, 85],
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -3,21 +3,30 @@ import { vi } from "vitest";
|
|||||||
|
|
||||||
import { KEYS, cloneJSON } from "@excalidraw/common";
|
import { KEYS, cloneJSON } from "@excalidraw/common";
|
||||||
|
|
||||||
import { duplicateElement } from "@excalidraw/element/duplicate";
|
import {
|
||||||
|
Excalidraw,
|
||||||
|
exportToCanvas,
|
||||||
|
exportToSvg,
|
||||||
|
} from "@excalidraw/excalidraw";
|
||||||
|
import {
|
||||||
|
actionFlipHorizontal,
|
||||||
|
actionFlipVertical,
|
||||||
|
} from "@excalidraw/excalidraw/actions";
|
||||||
|
|
||||||
import type {
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
ExcalidrawImageElement,
|
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
ImageCrop,
|
import {
|
||||||
} from "@excalidraw/element/types";
|
act,
|
||||||
|
GlobalTestState,
|
||||||
|
render,
|
||||||
|
unmountComponent,
|
||||||
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import { Excalidraw, exportToCanvas, exportToSvg } from "..";
|
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
|
||||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { duplicateElement } from "../src/duplicate";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
|
||||||
import { act, GlobalTestState, render, unmountComponent } from "./test-utils";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue } from "../types";
|
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@ -218,7 +227,7 @@ describe("Cropping and other features", async () => {
|
|||||||
initialHeight / 2,
|
initialHeight / 2,
|
||||||
]);
|
]);
|
||||||
Keyboard.keyDown(KEYS.ESCAPE);
|
Keyboard.keyDown(KEYS.ESCAPE);
|
||||||
const duplicatedImage = duplicateElement(null, new Map(), image, {});
|
const duplicatedImage = duplicateElement(null, new Map(), image);
|
||||||
act(() => {
|
act(() => {
|
||||||
h.app.scene.insertElement(duplicatedImage);
|
h.app.scene.insertElement(duplicatedImage);
|
||||||
});
|
});
|
149
packages/element/tests/delta.test.tsx
Normal file
149
packages/element/tests/delta.test.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { AppStateDelta } from "../src/delta";
|
||||||
|
|
||||||
|
describe("AppStateDelta", () => {
|
||||||
|
describe("ensure stable delta properties order", () => {
|
||||||
|
it("should maintain stable order for root properties", () => {
|
||||||
|
const name = "untitled scene";
|
||||||
|
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||||
|
|
||||||
|
const commonAppState = {
|
||||||
|
viewBackgroundColor: "#ffffff",
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
editingGroupId: null,
|
||||||
|
croppingElementId: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
activeLockedId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
name: "",
|
||||||
|
selectedLinearElementId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
name,
|
||||||
|
selectedLinearElementId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState2: ObservedAppState = {
|
||||||
|
selectedLinearElementId: null,
|
||||||
|
name: "",
|
||||||
|
...commonAppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState2: ObservedAppState = {
|
||||||
|
selectedLinearElementId,
|
||||||
|
name,
|
||||||
|
...commonAppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||||
|
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||||
|
|
||||||
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain stable order for selectedElementIds", () => {
|
||||||
|
const commonAppState = {
|
||||||
|
name: "",
|
||||||
|
viewBackgroundColor: "#ffffff",
|
||||||
|
selectedGroupIds: {},
|
||||||
|
editingGroupId: null,
|
||||||
|
croppingElementId: null,
|
||||||
|
selectedLinearElementId: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
|
activeLockedId: null,
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedElementIds: { id5: true, id2: true, id4: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedElementIds: {
|
||||||
|
id1: true,
|
||||||
|
id2: true,
|
||||||
|
id3: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState2: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedElementIds: { id4: true, id2: true, id5: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState2: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedElementIds: {
|
||||||
|
id3: true,
|
||||||
|
id2: true,
|
||||||
|
id1: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||||
|
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||||
|
|
||||||
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain stable order for selectedGroupIds", () => {
|
||||||
|
const commonAppState = {
|
||||||
|
name: "",
|
||||||
|
viewBackgroundColor: "#ffffff",
|
||||||
|
selectedElementIds: {},
|
||||||
|
editingGroupId: null,
|
||||||
|
croppingElementId: null,
|
||||||
|
selectedLinearElementId: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
|
activeLockedId: null,
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState1: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedGroupIds: {
|
||||||
|
id0: true,
|
||||||
|
id1: true,
|
||||||
|
id2: false,
|
||||||
|
id3: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevAppState2: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAppState2: ObservedAppState = {
|
||||||
|
...commonAppState,
|
||||||
|
selectedGroupIds: {
|
||||||
|
id3: true,
|
||||||
|
id2: false,
|
||||||
|
id1: true,
|
||||||
|
id0: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||||
|
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||||
|
|
||||||
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -8,16 +7,13 @@ import {
|
|||||||
isPrimitive,
|
isPrimitive,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import {
|
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
||||||
actionDuplicateSelection,
|
|
||||||
actionSelectAll,
|
|
||||||
} from "@excalidraw/excalidraw/actions";
|
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
@ -28,13 +24,9 @@ import {
|
|||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { mutateElement } from "../src/mutateElement";
|
|
||||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||||
|
|
||||||
import type {
|
import type { ExcalidrawLinearElement } from "../src/types";
|
||||||
ExcalidrawArrowElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
} from "../src/types";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@ -69,11 +61,11 @@ describe("duplicating single elements", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
element.__proto__ = { hello: "world" };
|
element.__proto__ = { hello: "world" };
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, new Map(), {
|
||||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element, undefined, true);
|
const copy = duplicateElement(null, new Map(), element, true);
|
||||||
|
|
||||||
assertCloneObjects(element, copy);
|
assertCloneObjects(element, copy);
|
||||||
|
|
||||||
@ -179,7 +171,7 @@ describe("duplicating multiple elements", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||||
const { newElements: clonedElements } = duplicateElements({
|
const { duplicatedElements } = duplicateElements({
|
||||||
type: "everything",
|
type: "everything",
|
||||||
elements: origElements,
|
elements: origElements,
|
||||||
});
|
});
|
||||||
@ -187,10 +179,10 @@ describe("duplicating multiple elements", () => {
|
|||||||
// generic id in-equality checks
|
// generic id in-equality checks
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
expect(origElements.map((e) => e.type)).toEqual(
|
expect(origElements.map((e) => e.type)).toEqual(
|
||||||
clonedElements.map((e) => e.type),
|
duplicatedElements.map((e) => e.type),
|
||||||
);
|
);
|
||||||
origElements.forEach((origElement, idx) => {
|
origElements.forEach((origElement, idx) => {
|
||||||
const clonedElement = clonedElements[idx];
|
const clonedElement = duplicatedElements[idx];
|
||||||
expect(origElement).toEqual(
|
expect(origElement).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.not.stringMatching(clonedElement.id),
|
id: expect.not.stringMatching(clonedElement.id),
|
||||||
@ -223,12 +215,12 @@ describe("duplicating multiple elements", () => {
|
|||||||
});
|
});
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
const clonedArrows = clonedElements.filter(
|
const clonedArrows = duplicatedElements.filter(
|
||||||
(e) => e.type === "arrow",
|
(e) => e.type === "arrow",
|
||||||
) as ExcalidrawLinearElement[];
|
) as ExcalidrawLinearElement[];
|
||||||
|
|
||||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||||
clonedElements as any as typeof origElements;
|
duplicatedElements as any as typeof origElements;
|
||||||
|
|
||||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||||
expect(
|
expect(
|
||||||
@ -333,10 +325,10 @@ describe("duplicating multiple elements", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||||
const { newElements: clonedElements } = duplicateElements({
|
const duplicatedElements = duplicateElements({
|
||||||
type: "everything",
|
type: "everything",
|
||||||
elements: origElements,
|
elements: origElements,
|
||||||
}) as any as { newElements: typeof origElements };
|
}).duplicatedElements as any as typeof origElements;
|
||||||
|
|
||||||
const [
|
const [
|
||||||
clonedRectangle,
|
clonedRectangle,
|
||||||
@ -344,7 +336,7 @@ describe("duplicating multiple elements", () => {
|
|||||||
clonedArrow1,
|
clonedArrow1,
|
||||||
clonedArrow2,
|
clonedArrow2,
|
||||||
clonedArrow3,
|
clonedArrow3,
|
||||||
] = clonedElements;
|
] = duplicatedElements;
|
||||||
|
|
||||||
expect(clonedRectangle.boundElements).toEqual([
|
expect(clonedRectangle.boundElements).toEqual([
|
||||||
{ id: clonedArrow1.id, type: "arrow" },
|
{ id: clonedArrow1.id, type: "arrow" },
|
||||||
@ -380,12 +372,12 @@ describe("duplicating multiple elements", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||||
const { newElements: clonedElements } = duplicateElements({
|
const { duplicatedElements } = duplicateElements({
|
||||||
type: "everything",
|
type: "everything",
|
||||||
elements: origElements,
|
elements: origElements,
|
||||||
}) as any as { newElements: typeof origElements };
|
});
|
||||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||||
clonedElements;
|
duplicatedElements;
|
||||||
|
|
||||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||||
@ -405,7 +397,7 @@ describe("duplicating multiple elements", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
newElements: [clonedRectangle1],
|
duplicatedElements: [clonedRectangle1],
|
||||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||||
|
|
||||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||||
@ -414,119 +406,114 @@ describe("duplicating multiple elements", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("elbow arrow duplication", () => {
|
describe("group-related duplication", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
it("action-duplicating within group", async () => {
|
||||||
UI.createElement("rectangle", {
|
const rectangle1 = API.createElement({
|
||||||
x: -150,
|
type: "rectangle",
|
||||||
y: -150,
|
x: 0,
|
||||||
width: 100,
|
y: 0,
|
||||||
height: 100,
|
groupIds: ["group1"],
|
||||||
});
|
});
|
||||||
UI.createElement("rectangle", {
|
const rectangle2 = API.createElement({
|
||||||
x: 50,
|
type: "rectangle",
|
||||||
y: 50,
|
x: 10,
|
||||||
width: 100,
|
y: 10,
|
||||||
height: 100,
|
groupIds: ["group1"],
|
||||||
});
|
});
|
||||||
|
|
||||||
UI.clickTool("arrow");
|
API.setElements([rectangle1, rectangle2]);
|
||||||
UI.clickOnTestId("elbow-arrow");
|
API.setSelectedElements([rectangle2], "group1");
|
||||||
|
|
||||||
mouse.reset();
|
|
||||||
mouse.moveTo(-43, -99);
|
|
||||||
mouse.click();
|
|
||||||
mouse.moveTo(43, 99);
|
|
||||||
mouse.click();
|
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
|
||||||
h.state,
|
|
||||||
)[0] as ExcalidrawArrowElement;
|
|
||||||
const originalArrowId = arrow.id;
|
|
||||||
|
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionSelectAll);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(6);
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle1.id },
|
||||||
const duplicatedArrow = h.scene.getSelectedElements(
|
{ id: rectangle2.id },
|
||||||
h.state,
|
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||||
)[2] as ExcalidrawArrowElement;
|
|
||||||
|
|
||||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
|
||||||
expect(duplicatedArrow.type).toBe("arrow");
|
|
||||||
expect(duplicatedArrow.elbowed).toBe(true);
|
|
||||||
expect(duplicatedArrow.points).toEqual([
|
|
||||||
[0, 0],
|
|
||||||
[45, 0],
|
|
||||||
[45, 200],
|
|
||||||
[90, 200],
|
|
||||||
]);
|
]);
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(h.state.editingGroupId).toBe("group1");
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
it("alt-duplicating within group", async () => {
|
||||||
UI.createElement("rectangle", {
|
const rectangle1 = API.createElement({
|
||||||
x: -150,
|
type: "rectangle",
|
||||||
y: -150,
|
x: 0,
|
||||||
width: 100,
|
y: 0,
|
||||||
height: 100,
|
groupIds: ["group1"],
|
||||||
});
|
});
|
||||||
UI.createElement("rectangle", {
|
const rectangle2 = API.createElement({
|
||||||
x: 50,
|
type: "rectangle",
|
||||||
y: 50,
|
x: 10,
|
||||||
width: 100,
|
y: 10,
|
||||||
height: 100,
|
groupIds: ["group1"],
|
||||||
});
|
});
|
||||||
|
|
||||||
UI.clickTool("arrow");
|
API.setElements([rectangle1, rectangle2]);
|
||||||
UI.clickOnTestId("elbow-arrow");
|
API.setSelectedElements([rectangle2], "group1");
|
||||||
|
|
||||||
mouse.reset();
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.moveTo(-43, -99);
|
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||||
mouse.click();
|
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
|
||||||
mouse.moveTo(43, 99);
|
|
||||||
mouse.click();
|
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
|
||||||
h.state,
|
|
||||||
)[0] as ExcalidrawArrowElement;
|
|
||||||
const originalArrowId = arrow.id;
|
|
||||||
|
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(4);
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle1.id },
|
||||||
const duplicatedArrow = h.scene.getSelectedElements(
|
{ id: rectangle2.id },
|
||||||
h.state,
|
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||||
)[0] as ExcalidrawArrowElement;
|
|
||||||
|
|
||||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
|
||||||
expect(duplicatedArrow.type).toBe("arrow");
|
|
||||||
expect(duplicatedArrow.elbowed).toBe(true);
|
|
||||||
expect(duplicatedArrow.points).toEqual([
|
|
||||||
[0, 0],
|
|
||||||
[0, 100],
|
|
||||||
[90, 100],
|
|
||||||
[90, 200],
|
|
||||||
]);
|
]);
|
||||||
|
expect(h.state.editingGroupId).toBe("group1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alt-duplicating within group away outside frame", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["group1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["group1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle1, rectangle2]);
|
||||||
|
API.setSelectedElements([rectangle2], "group1");
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||||
|
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log(h.elements);
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle1.id, frameId: frame.id },
|
||||||
|
{ id: rectangle2.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
|
||||||
|
]);
|
||||||
|
expect(h.state.editingGroupId).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -625,8 +612,8 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: rectangle1.id },
|
{ id: rectangle1.id },
|
||||||
{ id: rectangle1.id, selected: true },
|
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||||
{ id: rectangle2.id },
|
{ id: rectangle2.id },
|
||||||
{ id: rectangle3.id },
|
{ id: rectangle3.id },
|
||||||
]);
|
]);
|
||||||
@ -660,8 +647,8 @@ describe("duplication z-order", () => {
|
|||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ id: rectangle1.id },
|
{ id: rectangle1.id },
|
||||||
{ id: rectangle2.id },
|
{ id: rectangle2.id },
|
||||||
{ [ORIG_ID]: rectangle3.id },
|
{ id: rectangle3.id },
|
||||||
{ id: rectangle3.id, selected: true },
|
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -691,8 +678,8 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: rectangle1.id },
|
{ id: rectangle1.id },
|
||||||
{ id: rectangle1.id, selected: true },
|
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||||
{ id: rectangle2.id },
|
{ id: rectangle2.id },
|
||||||
{ id: rectangle3.id },
|
{ id: rectangle3.id },
|
||||||
]);
|
]);
|
||||||
@ -727,19 +714,19 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: rectangle1.id },
|
{ id: rectangle1.id },
|
||||||
{ [ORIG_ID]: rectangle2.id },
|
{ id: rectangle2.id },
|
||||||
{ [ORIG_ID]: rectangle3.id },
|
{ id: rectangle3.id },
|
||||||
{ id: rectangle1.id, selected: true },
|
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||||
{ id: rectangle2.id, selected: true },
|
{ [ORIG_ID]: rectangle2.id, selected: true },
|
||||||
{ id: rectangle3.id, selected: true },
|
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverse-duplicating text container (in-order)", async () => {
|
it("alt-duplicating text container (in-order)", async () => {
|
||||||
const [rectangle, text] = API.createTextContainer();
|
const [rectangle, text] = API.createTextContainer();
|
||||||
API.setElements([rectangle, text]);
|
API.setElements([rectangle, text]);
|
||||||
API.setSelectedElements([rectangle, text]);
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||||
@ -747,20 +734,20 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: rectangle.id },
|
{ id: rectangle.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||||
{
|
{
|
||||||
[ORIG_ID]: text.id,
|
[ORIG_ID]: text.id,
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
},
|
},
|
||||||
{ id: rectangle.id, selected: true },
|
|
||||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverse-duplicating text container (out-of-order)", async () => {
|
it("alt-duplicating text container (out-of-order)", async () => {
|
||||||
const [rectangle, text] = API.createTextContainer();
|
const [rectangle, text] = API.createTextContainer();
|
||||||
API.setElements([text, rectangle]);
|
API.setElements([text, rectangle]);
|
||||||
API.setSelectedElements([rectangle, text]);
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||||
@ -768,21 +755,21 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: rectangle.id },
|
{ id: rectangle.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||||
{
|
{
|
||||||
[ORIG_ID]: text.id,
|
[ORIG_ID]: text.id,
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
},
|
},
|
||||||
{ id: rectangle.id, selected: true },
|
|
||||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverse-duplicating labeled arrows (in-order)", async () => {
|
it("alt-duplicating labeled arrows (in-order)", async () => {
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
API.setElements([arrow, text]);
|
||||||
API.setSelectedElements([arrow, text]);
|
API.setSelectedElements([arrow]);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||||
@ -790,21 +777,24 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: arrow.id },
|
{ id: arrow.id },
|
||||||
|
{ id: text.id, containerId: arrow.id },
|
||||||
|
{ [ORIG_ID]: arrow.id, selected: true },
|
||||||
{
|
{
|
||||||
[ORIG_ID]: text.id,
|
[ORIG_ID]: text.id,
|
||||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||||
},
|
},
|
||||||
{ id: arrow.id, selected: true },
|
|
||||||
{ id: text.id, containerId: arrow.id, selected: true },
|
|
||||||
]);
|
]);
|
||||||
|
expect(h.state.selectedLinearElement).toEqual(
|
||||||
|
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverse-duplicating labeled arrows (out-of-order)", async () => {
|
it("alt-duplicating labeled arrows (out-of-order)", async () => {
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
API.setElements([text, arrow]);
|
API.setElements([text, arrow]);
|
||||||
API.setSelectedElements([arrow, text]);
|
API.setSelectedElements([arrow]);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||||
@ -812,17 +802,17 @@ describe("duplication z-order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ [ORIG_ID]: arrow.id },
|
{ id: arrow.id },
|
||||||
|
{ id: text.id, containerId: arrow.id },
|
||||||
|
{ [ORIG_ID]: arrow.id, selected: true },
|
||||||
{
|
{
|
||||||
[ORIG_ID]: text.id,
|
[ORIG_ID]: text.id,
|
||||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||||
},
|
},
|
||||||
{ id: arrow.id, selected: true },
|
|
||||||
{ id: text.id, containerId: arrow.id, selected: true },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => {
|
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
|
||||||
const rect = UI.createElement("rectangle", {
|
const rect = UI.createElement("rectangle", {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -844,11 +834,18 @@ describe("duplication z-order", () => {
|
|||||||
mouse.up(15, 15);
|
mouse.up(15, 15);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.h.elements).toHaveLength(3);
|
assertElements(h.elements, [
|
||||||
|
{
|
||||||
const newRect = window.h.elements[0];
|
id: rect.id,
|
||||||
|
boundElements: expect.arrayContaining([
|
||||||
expect(arrow.endBinding?.elementId).toBe(newRect.id);
|
expect.objectContaining({ id: arrow.id }),
|
||||||
expect(newRect.boundElements?.[0]?.id).toBe(arrow.id);
|
]),
|
||||||
|
},
|
||||||
|
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
|
||||||
|
{
|
||||||
|
id: arrow.id,
|
||||||
|
endBinding: expect.objectContaining({ elementId: rect.id }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { ARROW_TYPE } from "@excalidraw/common";
|
import { ARROW_TYPE } from "@excalidraw/common";
|
||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||||
|
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
GlobalTestState,
|
GlobalTestState,
|
||||||
queryByTestId,
|
queryByTestId,
|
||||||
@ -20,6 +22,8 @@ import type { LocalPoint } from "@excalidraw/math";
|
|||||||
|
|
||||||
import { bindLinearElement } from "../src/binding";
|
import { bindLinearElement } from "../src/binding";
|
||||||
|
|
||||||
|
import { Scene } from "../src/Scene";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
@ -139,7 +143,7 @@ describe("elbow arrow routing", () => {
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElement(arrow, {
|
h.app.scene.mutateElement(arrow, {
|
||||||
points: [
|
points: [
|
||||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||||
@ -184,14 +188,14 @@ describe("elbow arrow routing", () => {
|
|||||||
scene.insertElement(rectangle1);
|
scene.insertElement(rectangle1);
|
||||||
scene.insertElement(rectangle2);
|
scene.insertElement(rectangle2);
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
|
||||||
bindLinearElement(arrow, rectangle1, "start", elementsMap);
|
bindLinearElement(arrow, rectangle1, "start", scene);
|
||||||
bindLinearElement(arrow, rectangle2, "end", elementsMap);
|
bindLinearElement(arrow, rectangle2, "end", scene);
|
||||||
|
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
mutateElement(arrow, {
|
h.app.scene.mutateElement(arrow, {
|
||||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -298,4 +302,114 @@ describe("elbow arrow ui", () => {
|
|||||||
[103, 165],
|
[103, 165],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: -150,
|
||||||
|
y: -150,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(-43, -99);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(43, 99);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
const originalArrowId = arrow.id;
|
||||||
|
|
||||||
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionSelectAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.elements.length).toEqual(6);
|
||||||
|
|
||||||
|
const duplicatedArrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[2] as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||||
|
expect(duplicatedArrow.type).toBe("arrow");
|
||||||
|
expect(duplicatedArrow.elbowed).toBe(true);
|
||||||
|
expect(duplicatedArrow.points).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[45, 0],
|
||||||
|
[45, 200],
|
||||||
|
[90, 200],
|
||||||
|
]);
|
||||||
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: -150,
|
||||||
|
y: -150,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(-43, -99);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(43, 99);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
const originalArrowId = arrow.id;
|
||||||
|
|
||||||
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.elements.length).toEqual(4);
|
||||||
|
|
||||||
|
const duplicatedArrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||||
|
expect(duplicatedArrow.type).toBe("arrow");
|
||||||
|
expect(duplicatedArrow.elbowed).toBe(true);
|
||||||
|
expect(duplicatedArrow.points).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[0, 100],
|
||||||
|
[90, 100],
|
||||||
|
[90, 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -7,13 +7,14 @@ import {
|
|||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
} from "@excalidraw/element/fractionalIndex";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
@ -749,7 +750,7 @@ function testInvalidIndicesSync(args: {
|
|||||||
function prepareArguments(
|
function prepareArguments(
|
||||||
elementsLike: { id: string; index?: string }[],
|
elementsLike: { id: string; index?: string }[],
|
||||||
movedElementsIds?: string[],
|
movedElementsIds?: string[],
|
||||||
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
|
): [ExcalidrawElement[], ElementsMap | undefined] {
|
||||||
const elements = elementsLike.map((x) =>
|
const elements = elementsLike.map((x) =>
|
||||||
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
|
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
|
||||||
);
|
);
|
||||||
@ -764,7 +765,7 @@ function prepareArguments(
|
|||||||
function test(
|
function test(
|
||||||
name: string,
|
name: string,
|
||||||
elements: ExcalidrawElement[],
|
elements: ExcalidrawElement[],
|
||||||
movedElements: Map<string, ExcalidrawElement> | undefined,
|
movedElements: ElementsMap | undefined,
|
||||||
expectUnchangedElements: Map<string, { id: string }>,
|
expectUnchangedElements: Map<string, { id: string }>,
|
||||||
expectValidInput?: boolean,
|
expectValidInput?: boolean,
|
||||||
) {
|
) {
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { newArrowElement } from "@excalidraw/element/newElement";
|
|
||||||
|
|
||||||
import { pointCenter, pointFrom } from "@excalidraw/math";
|
import { pointCenter, pointFrom } from "@excalidraw/math";
|
||||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@ -13,36 +11,34 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
import {
|
import * as InteractiveCanvas from "@excalidraw/excalidraw/renderer/interactiveScene";
|
||||||
getBoundTextElementPosition,
|
import * as StaticScene from "@excalidraw/excalidraw/renderer/staticScene";
|
||||||
getBoundTextMaxWidth,
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
} from "@excalidraw/element/textElement";
|
|
||||||
import * as textElementUtils from "@excalidraw/element/textElement";
|
|
||||||
import { wrapText } from "@excalidraw/element/textWrapping";
|
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawTextElementWithContainer,
|
|
||||||
FontString,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
|
||||||
import * as InteractiveCanvas from "../renderer/interactiveScene";
|
|
||||||
import * as StaticScene from "../renderer/staticScene";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
|
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
|
||||||
import {
|
import {
|
||||||
screen,
|
screen,
|
||||||
render,
|
render,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
GlobalTestState,
|
GlobalTestState,
|
||||||
unmountComponent,
|
unmountComponent,
|
||||||
} from "./test-utils";
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { wrapText } from "../src";
|
||||||
|
import * as textElementUtils from "../src/textElement";
|
||||||
|
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
||||||
|
import { LinearElementEditor } from "../src";
|
||||||
|
import { newArrowElement } from "../src";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
|
FontString,
|
||||||
|
} from "../src/types";
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(
|
const renderInteractiveScene = vi.spyOn(
|
||||||
InteractiveCanvas,
|
InteractiveCanvas,
|
||||||
@ -118,7 +114,7 @@ describe("Test Linear Elements", () => {
|
|||||||
],
|
],
|
||||||
roundness,
|
roundness,
|
||||||
});
|
});
|
||||||
mutateElement(line, { points: line.points });
|
h.app.scene.mutateElement(line, { points: line.points });
|
||||||
API.setElements([line]);
|
API.setElements([line]);
|
||||||
mouse.clickAt(p1[0], p1[1]);
|
mouse.clickAt(p1[0], p1[1]);
|
||||||
return line;
|
return line;
|
||||||
@ -177,7 +173,7 @@ describe("Test Linear Elements", () => {
|
|||||||
pointFrom<LocalPoint>(0.5, 0),
|
pointFrom<LocalPoint>(0.5, 0),
|
||||||
pointFrom<LocalPoint>(100, 100),
|
pointFrom<LocalPoint>(100, 100),
|
||||||
]);
|
]);
|
||||||
new LinearElementEditor(element);
|
new LinearElementEditor(element, arrayToMap(h.elements));
|
||||||
expect(element.points).toEqual([
|
expect(element.points).toEqual([
|
||||||
pointFrom<LocalPoint>(0, 0),
|
pointFrom<LocalPoint>(0, 0),
|
||||||
pointFrom<LocalPoint>(99.5, 100),
|
pointFrom<LocalPoint>(99.5, 100),
|
||||||
@ -1247,7 +1243,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||||
expect(arrow.width).toBeCloseTo(408, 0);
|
expect(arrow.width).toBe(400);
|
||||||
expect(rect.x).toBe(400);
|
expect(rect.x).toBe(400);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
@ -1266,12 +1262,12 @@ describe("Test Linear Elements", () => {
|
|||||||
mouse.downAt(rect.x, rect.y);
|
mouse.downAt(rect.x, rect.y);
|
||||||
mouse.moveTo(200, 0);
|
mouse.moveTo(200, 0);
|
||||||
mouse.upAt(200, 0);
|
mouse.upAt(200, 0);
|
||||||
expect(arrow.width).toBeCloseTo(207, 0);
|
expect(arrow.width).toBeCloseTo(204, 0);
|
||||||
expect(rect.x).toBe(200);
|
expect(rect.x).toBe(200);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
arrayToMap(h.elements),
|
h.app.scene,
|
||||||
"nw",
|
"nw",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@ -1384,19 +1380,30 @@ describe("Test Linear Elements", () => {
|
|||||||
const [origStartX, origStartY] = [line.x, line.y];
|
const [origStartX, origStartY] = [line.x, line.y];
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
LinearElementEditor.movePoints(line, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
line,
|
||||||
index: 0,
|
h.app.scene,
|
||||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
new Map([
|
||||||
},
|
[
|
||||||
{
|
0,
|
||||||
index: line.points.length - 1,
|
{
|
||||||
point: pointFrom(
|
point: pointFrom(
|
||||||
line.points[line.points.length - 1][0] - 10,
|
line.points[0][0] + 10,
|
||||||
line.points[line.points.length - 1][1] - 10,
|
line.points[0][1] + 10,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
[
|
||||||
|
line.points.length - 1,
|
||||||
|
{
|
||||||
|
point: pointFrom(
|
||||||
|
line.points[line.points.length - 1][0] - 10,
|
||||||
|
line.points[line.points.length - 1][1] - 10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(line.x).toBe(origStartX + 10);
|
expect(line.x).toBe(origStartX + 10);
|
||||||
expect(line.y).toBe(origStartY + 10);
|
expect(line.y).toBe(origStartY + 10);
|
@ -15,8 +15,6 @@ import {
|
|||||||
unmountComponent,
|
unmountComponent,
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { isLinearElement } from "../src/typeChecks";
|
import { isLinearElement } from "../src/typeChecks";
|
||||||
@ -197,7 +195,7 @@ describe("generic element", () => {
|
|||||||
UI.resize(rectangle, "w", [50, 0]);
|
UI.resize(rectangle, "w", [50, 0]);
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0);
|
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with a label", async () => {
|
it("resizes with a label", async () => {
|
||||||
@ -335,7 +333,7 @@ describe("line element", () => {
|
|||||||
element,
|
element,
|
||||||
element,
|
element,
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene,
|
||||||
"ne",
|
"ne",
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -371,7 +369,7 @@ describe("line element", () => {
|
|||||||
element,
|
element,
|
||||||
element,
|
element,
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene,
|
||||||
"se",
|
"se",
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -426,7 +424,7 @@ describe("line element", () => {
|
|||||||
element,
|
element,
|
||||||
element,
|
element,
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene.getNonDeletedElementsMap(),
|
||||||
h.app.scene.getNonDeletedElementsMap(),
|
h.app.scene,
|
||||||
"e",
|
"e",
|
||||||
{
|
{
|
||||||
shouldResizeFromCenter: true,
|
shouldResizeFromCenter: true,
|
||||||
@ -828,9 +826,8 @@ describe("image element", () => {
|
|||||||
UI.resize(image, "nw", [50, 20]);
|
UI.resize(image, "nw", [50, 20]);
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||||
|
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
|
30 + imageWidth * scale,
|
||||||
30 + imageWidth * scale + 1,
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -1006,14 +1003,14 @@ describe("multiple selection", () => {
|
|||||||
size: 100,
|
size: 100,
|
||||||
});
|
});
|
||||||
const leftBoundArrow = UI.createElement("arrow", {
|
const leftBoundArrow = UI.createElement("arrow", {
|
||||||
x: -100 - FIXED_BINDING_DISTANCE,
|
x: -110,
|
||||||
y: 50,
|
y: 50,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rightBoundArrow = UI.createElement("arrow", {
|
const rightBoundArrow = UI.createElement("arrow", {
|
||||||
x: 200 + FIXED_BINDING_DISTANCE,
|
x: 210,
|
||||||
y: 50,
|
y: 50,
|
||||||
width: -100,
|
width: -100,
|
||||||
height: 0,
|
height: 0,
|
||||||
@ -1034,29 +1031,27 @@ describe("multiple selection", () => {
|
|||||||
shift: true,
|
shift: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
|
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
expect(leftBoundArrow.width).toBeCloseTo(146 - FIXED_BINDING_DISTANCE, 0);
|
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||||
expect(leftBoundArrow.angle).toEqual(0);
|
expect(leftBoundArrow.angle).toEqual(0);
|
||||||
expect(leftBoundArrow.startBinding).toBeNull();
|
expect(leftBoundArrow.startBinding).toBeNull();
|
||||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
|
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||||
leftArrowBinding.elementId,
|
leftArrowBinding.elementId,
|
||||||
);
|
);
|
||||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||||
|
|
||||||
expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
|
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||||
expect(rightBoundArrow.y).toBeCloseTo(
|
expect(rightBoundArrow.y).toBeCloseTo(
|
||||||
(selectionHeight - 50) * (1 - scale) + 50,
|
(selectionHeight - 50) * (1 - scale) + 50,
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
//console.log(JSON.stringify(h.elements));
|
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale, 0);
|
|
||||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||||
expect(rightBoundArrow.angle).toEqual(0);
|
expect(rightBoundArrow.angle).toEqual(0);
|
||||||
expect(rightBoundArrow.startBinding).toBeNull();
|
expect(rightBoundArrow.startBinding).toBeNull();
|
||||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
|
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||||
rightArrowBinding.elementId,
|
rightArrowBinding.elementId,
|
||||||
);
|
);
|
||||||
@ -1343,8 +1338,8 @@ describe("multiple selection", () => {
|
|||||||
|
|
||||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
|
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
|
||||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
|
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
|
||||||
|
|
||||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { mutateElement } from "../src/mutateElement";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { normalizeElementOrder } from "../src/sortElements";
|
import { normalizeElementOrder } from "../src/sortElements";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "../src/types";
|
import type { ExcalidrawElement } from "../src/types";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
const assertOrder = (
|
const assertOrder = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
expectedOrder: string[],
|
expectedOrder: string[],
|
||||||
@ -35,7 +37,7 @@ describe("normalizeElementsOrder", () => {
|
|||||||
boundElements: [],
|
boundElements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(container, {
|
mutateElement(container, new Map(), {
|
||||||
boundElements: [{ type: "text", id: boundText.id }],
|
boundElements: [{ type: "text", id: boundText.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -352,7 +354,7 @@ describe("normalizeElementsOrder", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(container, {
|
h.app.scene.mutateElement(container, {
|
||||||
boundElements: [
|
boundElements: [
|
||||||
{ type: "text", id: boundText.id },
|
{ type: "text", id: boundText.id },
|
||||||
{ type: "text", id: "xxx" },
|
{ type: "text", id: "xxx" },
|
||||||
@ -387,7 +389,7 @@ describe("normalizeElementsOrder", () => {
|
|||||||
boundElements: [],
|
boundElements: [],
|
||||||
groupIds: ["C", "A"],
|
groupIds: ["C", "A"],
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
h.app.scene.mutateElement(container, {
|
||||||
boundElements: [{ type: "text", id: boundText.id }],
|
boundElements: [{ type: "text", id: boundText.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element/align";
|
import { alignElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element/align";
|
import type { Alignment } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import {
|
import {
|
||||||
@ -25,7 +27,6 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -50,14 +51,8 @@ const alignSelectedElements = (
|
|||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
const elementsMap = arrayToMap(elements);
|
|
||||||
|
|
||||||
const updatedElements = alignElements(
|
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||||
selectedElements,
|
|
||||||
elementsMap,
|
|
||||||
alignment,
|
|
||||||
app.scene,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
|
@ -10,28 +10,30 @@ import {
|
|||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "@excalidraw/element/containerCache";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element/textElement";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isArrowElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
import { measureText } from "@excalidraw/element";
|
||||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncMovedIndices } from "@excalidraw/element";
|
||||||
|
|
||||||
import { newElement } from "@excalidraw/element/newElement";
|
import { newElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -42,7 +44,7 @@ import type {
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -77,7 +79,7 @@ export const actionUnbindText = register({
|
|||||||
boundTextElement,
|
boundTextElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@ -85,7 +87,7 @@ export const actionUnbindText = register({
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
mutateElement(element, {
|
app.scene.mutateElement(element, {
|
||||||
boundElements: element.boundElements?.filter(
|
boundElements: element.boundElements?.filter(
|
||||||
(ele) => ele.id !== boundTextElement.id,
|
(ele) => ele.id !== boundTextElement.id,
|
||||||
),
|
),
|
||||||
@ -150,24 +152,21 @@ export const actionBindText = register({
|
|||||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||||
}
|
}
|
||||||
mutateElement(textElement, {
|
app.scene.mutateElement(textElement, {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
autoResize: true,
|
autoResize: true,
|
||||||
|
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||||
});
|
});
|
||||||
mutateElement(container, {
|
app.scene.mutateElement(container, {
|
||||||
boundElements: (container.boundElements || []).concat({
|
boundElements: (container.boundElements || []).concat({
|
||||||
type: "text",
|
type: "text",
|
||||||
id: textElement.id,
|
id: textElement.id,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const originalContainerHeight = container.height;
|
const originalContainerHeight = container.height;
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(textElement, container, app.scene);
|
||||||
textElement,
|
|
||||||
container,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
// overwritting the cache with original container height so
|
// overwritting the cache with original container height so
|
||||||
// it can be restored when unbind
|
// it can be restored when unbind
|
||||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||||
@ -297,27 +296,23 @@ export const actionWrapTextInContainer = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (startBinding || endBinding) {
|
if (startBinding || endBinding) {
|
||||||
mutateElement(ele, { startBinding, endBinding }, false);
|
app.scene.mutateElement(ele, {
|
||||||
|
startBinding,
|
||||||
|
endBinding,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(
|
app.scene.mutateElement(textElement, {
|
||||||
textElement,
|
containerId: container.id,
|
||||||
{
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
containerId: container.id,
|
boundElements: null,
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
boundElements: null,
|
autoResize: true,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
});
|
||||||
autoResize: true,
|
|
||||||
},
|
redrawTextBoundingBox(textElement, container, app.scene);
|
||||||
false,
|
|
||||||
);
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
textElement,
|
|
||||||
container,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedElements = pushContainerBelowText(
|
updatedElements = pushContainerBelowText(
|
||||||
[...updatedElements, container],
|
[...updatedElements, container],
|
||||||
|
@ -14,8 +14,10 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -44,7 +46,6 @@ import { t } from "../i18n";
|
|||||||
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 { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
import { isTextElement } from "@excalidraw/element";
|
||||||
import { getTextFromElements } from "@excalidraw/element/textElement";
|
import { getTextFromElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
|||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
import { isImageElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { cropIcon } from "../components/icons";
|
import { cropIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { act, assertElements, render } from "../tests/test-utils";
|
import { act, assertElements, render } from "../tests/test-utils";
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ describe("deleting selected elements when frame selected should keep children +
|
|||||||
frameId: f1.id,
|
frameId: f1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(r1, {
|
h.app.scene.mutateElement(r1, {
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ describe("deleting selected elements when frame selected should keep children +
|
|||||||
frameId: null,
|
frameId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(r1, {
|
h.app.scene.mutateElement(r1, {
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ describe("deleting selected elements when frame selected should keep children +
|
|||||||
frameId: null,
|
frameId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(r1, {
|
h.app.scene.mutateElement(r1, {
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -170,7 +170,7 @@ describe("deleting selected elements when frame selected should keep children +
|
|||||||
frameId: null,
|
frameId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(a1, {
|
h.app.scene.mutateElement(a1, {
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,30 +1,28 @@
|
|||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
import {
|
import { newElementWith } from "@excalidraw/element";
|
||||||
mutateElement,
|
import { getContainerElement } from "@excalidraw/element";
|
||||||
newElementWith,
|
|
||||||
} from "@excalidraw/element/mutateElement";
|
|
||||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
|
||||||
import {
|
import {
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element";
|
||||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
import { getFrameChildren } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
@ -94,7 +92,7 @@ const deleteSelectedElements = (
|
|||||||
el.boundElements.forEach((candidate) => {
|
el.boundElements.forEach((candidate) => {
|
||||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||||
if (bound && isElbowArrow(bound)) {
|
if (bound && isElbowArrow(bound)) {
|
||||||
mutateElement(bound, {
|
app.scene.mutateElement(bound, {
|
||||||
startBinding:
|
startBinding:
|
||||||
el.id === bound.startBinding?.elementId
|
el.id === bound.startBinding?.elementId
|
||||||
? null
|
? null
|
||||||
@ -102,7 +100,6 @@ const deleteSelectedElements = (
|
|||||||
endBinding:
|
endBinding:
|
||||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||||
});
|
});
|
||||||
mutateElement(bound, { points: bound.points });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -261,7 +258,11 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
app.scene,
|
||||||
|
selectedPointsIndices,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { distributeElements } from "@excalidraw/element/distribute";
|
import { distributeElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element/distribute";
|
import type { Distribution } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import {
|
import {
|
||||||
@ -21,7 +23,6 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,32 +7,24 @@ import {
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
isBoundToContainer,
|
|
||||||
isLinearElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
|
||||||
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
excludeElementsInFramesFromSelection,
|
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "@excalidraw/element/selection";
|
getSelectionStateForElements,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncMovedIndices } from "@excalidraw/element";
|
||||||
|
|
||||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
import { duplicateElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -52,7 +44,7 @@ export const actionDuplicateSelection = register({
|
|||||||
try {
|
try {
|
||||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
appState,
|
appState,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -65,52 +57,49 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
|
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
|
||||||
duplicateElements({
|
type: "in-place",
|
||||||
type: "in-place",
|
elements,
|
||||||
elements,
|
idsOfElementsToDuplicate: arrayToMap(
|
||||||
idsOfElementsToDuplicate: arrayToMap(
|
getSelectedElements(elements, appState, {
|
||||||
getSelectedElements(elements, appState, {
|
includeBoundTextElement: true,
|
||||||
includeBoundTextElement: true,
|
includeElementsInFrames: true,
|
||||||
includeElementsInFrames: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState,
|
|
||||||
randomizeSeed: true,
|
|
||||||
overrides: (element) => ({
|
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
|
||||||
}),
|
}),
|
||||||
reverseOrder: false,
|
),
|
||||||
});
|
appState,
|
||||||
|
randomizeSeed: true,
|
||||||
|
overrides: ({ origElement, origIdToDuplicateId }) => {
|
||||||
|
const duplicateFrameId =
|
||||||
|
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
|
||||||
|
return {
|
||||||
|
x: origElement.x + DEFAULT_GRID_SIZE / 2,
|
||||||
|
y: origElement.y + DEFAULT_GRID_SIZE / 2,
|
||||||
|
frameId: duplicateFrameId ?? origElement.frameId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (app.props.onDuplicate && nextElements) {
|
if (app.props.onDuplicate && elementsWithDuplicates) {
|
||||||
const mappedElements = app.props.onDuplicate(nextElements, elements);
|
const mappedElements = app.props.onDuplicate(
|
||||||
|
elementsWithDuplicates,
|
||||||
|
elements,
|
||||||
|
);
|
||||||
if (mappedElements) {
|
if (mappedElements) {
|
||||||
nextElements = mappedElements;
|
elementsWithDuplicates = mappedElements;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
|
elements: syncMovedIndices(
|
||||||
|
elementsWithDuplicates,
|
||||||
|
arrayToMap(duplicatedElements),
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...updateLinearElementEditors(duplicatedElements),
|
...getSelectionStateForElements(
|
||||||
...selectGroupsForSelectedElements(
|
duplicatedElements,
|
||||||
{
|
getNonDeletedElements(elementsWithDuplicates),
|
||||||
editingGroupId: appState.editingGroupId,
|
|
||||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
|
||||||
duplicatedElements,
|
|
||||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
|
||||||
if (!isBoundToContainer(element)) {
|
|
||||||
acc[element.id] = true;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
},
|
|
||||||
getNonDeletedElements(nextElements),
|
|
||||||
appState,
|
appState,
|
||||||
null,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@ -130,24 +119,3 @@ export const actionDuplicateSelection = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
|
|
||||||
const linears = clonedElements.filter(isLinearElement);
|
|
||||||
if (linears.length === 1) {
|
|
||||||
const linear = linears[0];
|
|
||||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
|
||||||
const onlySingleLinearSelected = clonedElements.every(
|
|
||||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onlySingleLinearSelected) {
|
|
||||||
return {
|
|
||||||
selectedLinearElement: new LinearElementEditor(linear),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedLinearElement: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -2,13 +2,14 @@ import {
|
|||||||
canCreateLinkFromElements,
|
canCreateLinkFromElements,
|
||||||
defaultGetElementLinkFromSelection,
|
defaultGetElementLinkFromSelection,
|
||||||
getLinkIdAndTypeFromSelection,
|
getLinkIdAndTypeFromSelection,
|
||||||
} from "@excalidraw/element/elementLink";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import {
|
||||||
|
elementsAreInSameGroup,
|
||||||
|
newElementWith,
|
||||||
|
selectGroupsFromGivenElements,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
import type { AppState } from "../types";
|
||||||
|
|
||||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||||
elements.every((el) => !el.locked);
|
elements.every((el) => !el.locked);
|
||||||
|
|
||||||
@ -23,15 +28,10 @@ export const actionToggleElementLock = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: false,
|
includeBoundTextElement: false,
|
||||||
});
|
});
|
||||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
|
||||||
return selected[0].locked
|
|
||||||
? "labels.elementLock.unlock"
|
|
||||||
: "labels.elementLock.lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldLock(selected)
|
return shouldLock(selected)
|
||||||
? "labels.elementLock.lockAll"
|
? "labels.elementLock.lock"
|
||||||
: "labels.elementLock.unlockAll";
|
: "labels.elementLock.unlock";
|
||||||
},
|
},
|
||||||
icon: (appState, elements) => {
|
icon: (appState, elements) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
@ -58,19 +58,84 @@ export const actionToggleElementLock = register({
|
|||||||
|
|
||||||
const nextLockState = shouldLock(selectedElements);
|
const nextLockState = shouldLock(selectedElements);
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
return {
|
|
||||||
elements: elements.map((element) => {
|
|
||||||
if (!selectedElementsMap.has(element.id)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElementWith(element, { locked: nextLockState });
|
const isAGroup =
|
||||||
}),
|
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
|
||||||
|
const isASingleUnit = selectedElements.length === 1 || isAGroup;
|
||||||
|
const newGroupId = isASingleUnit ? null : randomId();
|
||||||
|
|
||||||
|
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
|
||||||
|
|
||||||
|
if (nextLockState) {
|
||||||
|
nextLockedMultiSelections = {
|
||||||
|
...appState.lockedMultiSelections,
|
||||||
|
...(newGroupId ? { [newGroupId]: true } : {}),
|
||||||
|
};
|
||||||
|
} else if (isAGroup) {
|
||||||
|
const groupId = selectedElements[0].groupIds.at(-1)!;
|
||||||
|
delete nextLockedMultiSelections[groupId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextElements = elements.map((element) => {
|
||||||
|
if (!selectedElementsMap.has(element.id)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextGroupIds = element.groupIds;
|
||||||
|
|
||||||
|
// if locking together, add to group
|
||||||
|
// if unlocking, remove the temporary group
|
||||||
|
if (nextLockState) {
|
||||||
|
if (newGroupId) {
|
||||||
|
nextGroupIds = [...nextGroupIds, newGroupId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextGroupIds = nextGroupIds.filter(
|
||||||
|
(groupId) => !appState.lockedMultiSelections[groupId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
locked: nextLockState,
|
||||||
|
// do not recreate the array unncessarily
|
||||||
|
groupIds:
|
||||||
|
nextGroupIds.length !== element.groupIds.length
|
||||||
|
? nextGroupIds
|
||||||
|
: element.groupIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextElementsMap = arrayToMap(nextElements);
|
||||||
|
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
||||||
|
? {}
|
||||||
|
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
||||||
|
const unlockedSelectedElements = selectedElements.map(
|
||||||
|
(el) => nextElementsMap.get(el.id) || el,
|
||||||
|
);
|
||||||
|
const nextSelectedGroupIds = nextLockState
|
||||||
|
? {}
|
||||||
|
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
|
||||||
|
|
||||||
|
const activeLockedId = nextLockState
|
||||||
|
? newGroupId
|
||||||
|
? newGroupId
|
||||||
|
: isAGroup
|
||||||
|
? selectedElements[0].groupIds.at(-1)!
|
||||||
|
: selectedElements[0].id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
selectedElementIds: nextSelectedElementIds,
|
||||||
|
selectedGroupIds: nextSelectedGroupIds,
|
||||||
selectedLinearElement: nextLockState
|
selectedLinearElement: nextLockState
|
||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
|
lockedMultiSelections: nextLockedMultiSelections,
|
||||||
|
activeLockedId,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -103,18 +168,44 @@ export const actionUnlockAllElements = register({
|
|||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const lockedElements = elements.filter((el) => el.locked);
|
const lockedElements = elements.filter((el) => el.locked);
|
||||||
|
|
||||||
|
const nextElements = elements.map((element) => {
|
||||||
|
if (element.locked) {
|
||||||
|
// remove the temporary groupId if it exists
|
||||||
|
const nextGroupIds = element.groupIds.filter(
|
||||||
|
(gid) => !appState.lockedMultiSelections[gid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
locked: false,
|
||||||
|
groupIds:
|
||||||
|
// do not recreate the array unncessarily
|
||||||
|
element.groupIds.length !== nextGroupIds.length
|
||||||
|
? nextGroupIds
|
||||||
|
: element.groupIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextElementsMap = arrayToMap(nextElements);
|
||||||
|
|
||||||
|
const unlockedElements = lockedElements.map(
|
||||||
|
(el) => nextElementsMap.get(el.id) || el,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: nextElements,
|
||||||
if (element.locked) {
|
|
||||||
return newElementWith(element, { locked: false });
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: Object.fromEntries(
|
selectedElementIds: Object.fromEntries(
|
||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
|
selectedGroupIds: selectGroupsFromGivenElements(
|
||||||
|
unlockedElements,
|
||||||
|
appState,
|
||||||
|
),
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
activeLockedId: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { updateActiveTool } from "@excalidraw/common";
|
import { updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
|
||||||
|
@ -1,28 +1,24 @@
|
|||||||
import { type GlobalPoint, pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
} from "@excalidraw/element";
|
||||||
} from "@excalidraw/element/binding";
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
|
||||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
|
||||||
import {
|
|
||||||
isBindingElement,
|
|
||||||
isElbowArrow,
|
|
||||||
isLinearElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
||||||
import { isPathALoop } from "@excalidraw/element/shapes";
|
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||||
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -48,7 +44,6 @@ export const actionFinalize = register({
|
|||||||
element,
|
element,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
elementsMap,
|
|
||||||
scene,
|
scene,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -74,7 +69,11 @@ export const actionFinalize = register({
|
|||||||
scene.getElement(appState.pendingImageElementId);
|
scene.getElement(appState.pendingImageElementId);
|
||||||
|
|
||||||
if (pendingImageElement) {
|
if (pendingImageElement) {
|
||||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
scene.mutateElement(
|
||||||
|
pendingImageElement,
|
||||||
|
{ isDeleted: true },
|
||||||
|
{ informMutation: false, isDragging: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
@ -93,28 +92,12 @@ export const actionFinalize = register({
|
|||||||
multiPointElement.type !== "freedraw" &&
|
multiPointElement.type !== "freedraw" &&
|
||||||
appState.lastPointerDownWith !== "touch"
|
appState.lastPointerDownWith !== "touch"
|
||||||
) {
|
) {
|
||||||
const { x: rx, y: ry, points, lastCommittedPoint } = multiPointElement;
|
const { points, lastCommittedPoint } = multiPointElement;
|
||||||
const lastGlobalPoint = pointFrom<GlobalPoint>(
|
|
||||||
rx + points[points.length - 1][0],
|
|
||||||
ry + points[points.length - 1][1],
|
|
||||||
);
|
|
||||||
const hoveredElementForBinding = getHoveredElementForBinding(
|
|
||||||
{
|
|
||||||
x: lastGlobalPoint[0],
|
|
||||||
y: lastGlobalPoint[1],
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
app.state.zoom,
|
|
||||||
true,
|
|
||||||
isElbowArrow(multiPointElement),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
!hoveredElementForBinding &&
|
!lastCommittedPoint ||
|
||||||
(!lastCommittedPoint ||
|
points[points.length - 1] !== lastCommittedPoint
|
||||||
points[points.length - 1] !== lastCommittedPoint)
|
|
||||||
) {
|
) {
|
||||||
mutateElement(multiPointElement, {
|
scene.mutateElement(multiPointElement, {
|
||||||
points: multiPointElement.points.slice(0, -1),
|
points: multiPointElement.points.slice(0, -1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -138,7 +121,7 @@ export const actionFinalize = register({
|
|||||||
if (isLoop) {
|
if (isLoop) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
scene.mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((p, index) =>
|
points: linePoints.map((p, index) =>
|
||||||
index === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? pointFrom(firstPoint[0], firstPoint[1])
|
? pointFrom(firstPoint[0], firstPoint[1])
|
||||||
@ -153,12 +136,12 @@ export const actionFinalize = register({
|
|||||||
!isLoop &&
|
!isLoop &&
|
||||||
multiPointElement.points.length > 1
|
multiPointElement.points.length > 1
|
||||||
) {
|
) {
|
||||||
maybeBindLinearElement(
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
multiPointElement,
|
multiPointElement,
|
||||||
appState,
|
-1,
|
||||||
elementsMap,
|
arrayToMap(elements),
|
||||||
elements,
|
|
||||||
);
|
);
|
||||||
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,7 +197,10 @@ export const actionFinalize = register({
|
|||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
? new LinearElementEditor(multiPointElement)
|
? new LinearElementEditor(
|
||||||
|
multiPointElement,
|
||||||
|
arrayToMap(newElements),
|
||||||
|
)
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
|
@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
|
|||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||||
expect(Math.floor(rec1.x)).toBeCloseTo(100, 0);
|
expect(rec1.x).toBeCloseTo(100, 0);
|
||||||
expect(Math.floor(rec1.y)).toBeCloseTo(100, 0);
|
expect(rec1.y).toBeCloseTo(100, 0);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||||
expect(Math.floor(rec2.x)).toBeCloseTo(220, 0);
|
expect(rec2.x).toBeCloseTo(220, 0);
|
||||||
expect(Math.floor(rec2.y)).toBeCloseTo(250, 0);
|
expect(rec2.y).toBeCloseTo(250, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,16 +87,6 @@ describe("flipping arrowheads", () => {
|
|||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
});
|
});
|
||||||
|
|
||||||
// UX RATIONALE: If we flip bound arrows by the center axes then there could
|
|
||||||
// be a case where the bindable objects are offset and the arrow would lay
|
|
||||||
// outside both bindable objects binding range, yet remain bound to then,
|
|
||||||
// resulting in a jump on movement.
|
|
||||||
//
|
|
||||||
// We are aware that 2+ point simple arrows behave incorrectly when flipped
|
|
||||||
// this way but it was decided that there is no known use case for this so
|
|
||||||
// left as it is.
|
|
||||||
//
|
|
||||||
// Demo: https://excalidraw.com/#json=isE-S8LqNlD1u-LsS8Ezz,iZZ09PPasp6OWbGtJwOUGQ
|
|
||||||
it("flipping bound arrow should flip arrowheads only", () => {
|
it("flipping bound arrow should flip arrowheads only", () => {
|
||||||
const rect = API.createElement({
|
const rect = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
@ -133,7 +123,6 @@ describe("flipping arrowheads", () => {
|
|||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||||
});
|
});
|
||||||
|
|
||||||
// UX RATIONALE: See above for the reasoning.
|
|
||||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
it("flipping bound arrow should flip arrowheads only 2", () => {
|
||||||
const rect = API.createElement({
|
const rect = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
@ -175,9 +164,7 @@ describe("flipping arrowheads", () => {
|
|||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||||
});
|
});
|
||||||
|
|
||||||
// UX RATIONALE: Unbound arrows are not constrained by other elements and
|
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
||||||
// should behave like any other element when flipped for consisency.
|
|
||||||
it("flipping unbound arrow should mirror on horizontal or vertical axis", () => {
|
|
||||||
const arrow = API.createElement({
|
const arrow = API.createElement({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
id: "arrow1",
|
id: "arrow1",
|
||||||
|
@ -1,33 +1,31 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element";
|
||||||
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
|
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||||
import {
|
import { newElementWith } from "@excalidraw/element";
|
||||||
mutateElement,
|
import { deepCopyElement } from "@excalidraw/element";
|
||||||
newElementWith,
|
import { resizeMultipleElements } from "@excalidraw/element";
|
||||||
} from "@excalidraw/element/mutateElement";
|
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
|
||||||
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
|
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBindableElement,
|
isElbowArrow,
|
||||||
isBindingElement,
|
isLinearElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
|
||||||
@ -160,54 +158,50 @@ const flipElements = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedBindables = selectedElements.filter(
|
bindOrUnbindLinearElements(
|
||||||
(e): e is ExcalidrawBindableElement => isBindableElement(e),
|
selectedElements.filter(isLinearElement),
|
||||||
|
isBindingEnabled(appState),
|
||||||
|
[],
|
||||||
|
app.scene,
|
||||||
|
appState.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// flipping arrow elements (and potentially other) makes the selection group
|
||||||
|
// "move" across the canvas because of how arrows can bump against the "wall"
|
||||||
|
// of the selection, so we need to center the group back to the original
|
||||||
|
// position so that repeated flips don't accumulate the offset
|
||||||
|
|
||||||
|
const { elbowArrows, otherElements } = selectedElements.reduce(
|
||||||
|
(
|
||||||
|
acc: {
|
||||||
|
elbowArrows: ExcalidrawElbowArrowElement[];
|
||||||
|
otherElements: ExcalidrawElement[];
|
||||||
|
},
|
||||||
|
element,
|
||||||
|
) =>
|
||||||
|
isElbowArrow(element)
|
||||||
|
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
|
||||||
|
: { ...acc, otherElements: acc.otherElements.concat(element) },
|
||||||
|
{ elbowArrows: [], otherElements: [] },
|
||||||
|
);
|
||||||
|
|
||||||
const { midX: newMidX, midY: newMidY } =
|
const { midX: newMidX, midY: newMidY } =
|
||||||
getCommonBoundingBox(selectedElements);
|
getCommonBoundingBox(selectedElements);
|
||||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
||||||
|
otherElements.forEach((element) =>
|
||||||
selectedElements.forEach((element) => {
|
app.scene.mutateElement(element, {
|
||||||
fixBindings(element, selectedBindables, app, elementsMap);
|
|
||||||
|
|
||||||
mutateElement(element, {
|
|
||||||
x: element.x + diffX,
|
x: element.x + diffX,
|
||||||
y: element.y + diffY,
|
y: element.y + diffY,
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
elbowArrows.forEach((element) =>
|
||||||
|
app.scene.mutateElement(element, {
|
||||||
|
x: element.x + diffX,
|
||||||
|
y: element.y + diffY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
// BEHAVIOR: If you flip a binding element along with its bound elements,
|
|
||||||
// the binding should be preserved. If your selected elements doesn't contain
|
|
||||||
// the bound element(s), then remove the binding. Also do not "magically"
|
|
||||||
// re-bind a binable just because the arrow endpoint is flipped into the
|
|
||||||
// binding range. Rationale being the consistency with the fact that arrows
|
|
||||||
// don't bind when the arrow is moved into the binding range by its shaft.
|
|
||||||
const fixBindings = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
selectedBindables: ExcalidrawBindableElement[],
|
|
||||||
app: AppClassProperties,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
) => {
|
|
||||||
if (isBindingElement(element)) {
|
|
||||||
let start = null;
|
|
||||||
let end = null;
|
|
||||||
|
|
||||||
if (isBindingEnabled(app.state)) {
|
|
||||||
start = element.startBinding
|
|
||||||
? selectedBindables.find(
|
|
||||||
(e) => element.startBinding!.elementId === e.id,
|
|
||||||
) ?? null
|
|
||||||
: null;
|
|
||||||
end = element.endBinding
|
|
||||||
? selectedBindables.find(
|
|
||||||
(e) => element.endBinding!.elementId === e.id,
|
|
||||||
) ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bindOrUnbindLinearElement(element, start, end, elementsMap, app.scene);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -1,25 +1,26 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
import { newFrameElement } from "@excalidraw/element/newElement";
|
import { newFrameElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
addElementsToFrame,
|
addElementsToFrame,
|
||||||
removeAllElementsFromFrame,
|
removeAllElementsFromFrame,
|
||||||
} from "@excalidraw/element/frame";
|
} from "@excalidraw/element";
|
||||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
import { getFrameChildren } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
import { getElementsInGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getCommonBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -173,11 +174,9 @@ export const actionWrapSelectionInFrame = register({
|
|||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(
|
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
|
||||||
selectedElements,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
const PADDING = 16;
|
const PADDING = 16;
|
||||||
const frame = newFrameElement({
|
const frame = newFrameElement({
|
||||||
x: x1 - PADDING,
|
x: x1 - PADDING,
|
||||||
@ -196,13 +195,9 @@ export const actionWrapSelectionInFrame = register({
|
|||||||
for (const elementInGroup of elementsInGroup) {
|
for (const elementInGroup of elementsInGroup) {
|
||||||
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
|
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(elementInGroup, elementsMap, {
|
||||||
elementInGroup,
|
groupIds: elementInGroup.groupIds.slice(0, index),
|
||||||
{
|
});
|
||||||
groupIds: elementInGroup.groupIds.slice(0, index),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isBoundToContainer } from "@excalidraw/element/typeChecks";
|
import { isBoundToContainer } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
frameAndChildrenSelectedTogether,
|
frameAndChildrenSelectedTogether,
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
groupByFrameLikes,
|
groupByFrameLikes,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "@excalidraw/element/frame";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
@ -24,9 +24,11 @@ import {
|
|||||||
addToGroup,
|
addToGroup,
|
||||||
removeFromSelectedGroups,
|
removeFromSelectedGroups,
|
||||||
isElementInGroup,
|
isElementInGroup,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncMovedIndices } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { orderByFractionalIndex } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
|||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { Store } from "../store";
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import type { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
|
|
||||||
@ -35,7 +37,11 @@ const executeHistoryAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [nextElementsMap, nextAppState] = result;
|
const [nextElementsMap, nextAppState] = result;
|
||||||
const nextElements = Array.from(nextElementsMap.values());
|
|
||||||
|
// order by fractional indices in case the map was accidently modified in the meantime
|
||||||
|
const nextElements = orderByFractionalIndex(
|
||||||
|
Array.from(nextElementsMap.values()),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
@ -47,9 +53,9 @@ const executeHistoryAction = (
|
|||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History, store: Store) => Action;
|
type ActionCreator = (history: History) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
label: "buttons.undo",
|
label: "buttons.undo",
|
||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
label: "buttons.redo",
|
label: "buttons.redo",
|
||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, __, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
|
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -9,7 +13,6 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { lineEditorIcon } from "../components/icons";
|
import { lineEditorIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -50,7 +53,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
const editingLinearElement =
|
const editingLinearElement =
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
? null
|
? null
|
||||||
: new LinearElementEditor(selectedElement);
|
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
import { isEmbeddableElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
@ -8,7 +10,6 @@ import {
|
|||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,15 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
|
import { isLinearElement, isTextElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS } from "@excalidraw/common";
|
import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@ -53,7 +53,7 @@ export const actionSelectAll = register({
|
|||||||
// single linear element selected
|
// single linear element selected
|
||||||
Object.keys(selectedElementIds).length === 1 &&
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
isLinearElement(elements[0])
|
isLinearElement(elements[0])
|
||||||
? new LinearElementEditor(elements[0])
|
? new LinearElementEditor(elements[0], arrayToMap(elements))
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
getLineHeight,
|
getLineHeight,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
@ -17,12 +17,14 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isExcalidrawElement,
|
isExcalidrawElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element/textElement";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -30,7 +32,6 @@ import { paintIcon } from "../components/icons";
|
|||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -139,11 +140,8 @@ export const actionPasteStyles = register({
|
|||||||
element.id === newElement.containerId,
|
element.id === newElement.containerId,
|
||||||
) || null;
|
) || null;
|
||||||
}
|
}
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
redrawTextBoundingBox(newElement, container, app.scene);
|
||||||
container,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { getFontString } from "@excalidraw/common";
|
import { getFontString } from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
import { measureText } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
import { isTextElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user