Compare commits

..

51 Commits

Author SHA1 Message Date
Mark Tolmacs
3f9c6299a0
Fix the grid and angle lock
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-09 12:15:04 +02:00
Mark Tolmacs
3068787ac4
Move linear element handling out of App.tsx
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-08 13:44:54 +02:00
Mark Tolmacs
c2b78346c1 Fix snapshot 2025-04-07 13:10:10 +02:00
Mark Tolmacs
44df764a88 [skip ci] Remove temporary size hacks 2025-04-07 13:09:06 +02:00
Mark Tolmacs
cc01e16e52 Attempt at moving the initial point when inside shape
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
6b9fa5bcc5 Do not snap linear elements
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
06b3750a2f Fix microjump on drag binding, no keyboard move if bound arrow
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
c3924a8f8c Fix tests for stats
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
eecabccf8d Stats unbonds arrows when used 2025-04-07 13:08:36 +02:00
Mark Tolmacs
ccda36a0e3 [skip ci] Remove debug
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
37653484a1 Rotated arrow drag fixes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
b2e19055bf [skip ci] Refactor visual debug
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
3d40221dc1 Restore input value and fix history
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
5cc5c626df Position revert 2025-04-07 13:08:36 +02:00
Mark Tolmacs
9a599cfc05 Fix drag rotation
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
88d4c4fe8d Stats: Angle setting now works properly and resets on unconnected bindings 2025-04-07 13:08:36 +02:00
Mark Tolmacs
4efa6f69e5 Inline binding logic fix 2025-04-07 13:08:36 +02:00
Mark Tolmacs
a79eb06939 [skip ci] Test updates 2025-04-07 13:08:36 +02:00
Mark Tolmacs
bcbd418154 [skip ci] Change flipping 2025-04-07 13:08:35 +02:00
Mark Tolmacs
db9e501d35 [skip ci] Stats binding behavior changes and test updates 2025-04-07 13:08:35 +02:00
Mark Tolmacs
ce10087edc [skip ci] Binding refactor 2025-04-07 13:08:35 +02:00
Mark Tolmacs
76a782bd52 [skip ci] No binding for properties panel movement and angle
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
d6d4d00f60 Inside detection for outline binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
4ea534a732 [skip ci] Tests for inner binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
e90350b7d1 Fix tests 2025-04-07 13:08:35 +02:00
Mark Tolmacs
ea5ad1412c [skip ci] No jumping at the beginning 2025-04-07 13:08:09 +02:00
Mark Tolmacs
c8ade51b53 [skip ci] Remove unneeded code segments 2025-04-07 13:08:09 +02:00
Mark Tolmacs
6e520fdbb9 Restore collision optimization 2025-04-07 13:08:09 +02:00
Mark Tolmacs
fdd7420e65 Type fixes 2025-04-07 13:08:09 +02:00
Mark Tolmacs
f4abdc751e [skip ci] Small updates to tests 2025-04-07 13:07:30 +02:00
Mark Tolmacs
946d3ddf87 Fine-tuning diamon intersections 2025-04-07 13:07:30 +02:00
Mark Tolmacs
fbde68c849 [skip ci] First iteration of bringing over previous changes 2025-04-07 13:07:30 +02:00
Mark Tolmacs
4ee99de2fb Get three solutions for curve-line intersections to avoid issue with high inclination intersectors 2025-04-07 13:07:30 +02:00
Mark Tolmacs
4d1e2c2bbb Revert to master 2025-04-07 13:07:30 +02:00
Mark Tolmacs
1a87aa8e55 Start grid point arrow align 2025-04-07 13:07:30 +02:00
Mark Tolmacs
528e6aa2df Fix tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:07:30 +02:00
Mark Tolmacs
8c9666b8ab Multipoint arrows now have single point commit in binding zones 2025-04-07 13:07:30 +02:00
Mark Tolmacs
8ac508af11 Fix missing parameter 2025-04-07 13:07:30 +02:00
Mark Tolmacs
cbe6705c98 10% inside shape still tracks outline 2025-04-07 13:07:30 +02:00
Mark Tolmacs
1819661828 Tune what's considered a duplicate intersection point 2025-04-07 13:07:30 +02:00
Mark Tolmacs
373b940e75 New simple arrows stick to outline as well 2025-04-07 13:07:30 +02:00
Mark Tolmacs
2f02d72741 Refactors 2025-04-07 13:07:30 +02:00
Mark Tolmacs
a54322a34f Fix unbind by move test 2025-04-07 13:07:30 +02:00
Mark Tolmacs
5c1fc2f4fb FIx tests 2025-04-07 13:07:30 +02:00
Mark Tolmacs
63d53fc242 Fix freshly created elbow arrow and bindable interaction 2025-04-07 13:07:29 +02:00
Mark Tolmacs
e1812c4c91 Need all intersection points for curved corners 2025-04-07 13:07:29 +02:00
Mark Tolmacs
e459ea0cc7 Apply outline tracking to simple arrows as well
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:07:29 +02:00
Mark Tolmacs
f354285d69 Linear element compatible snap binding 2025-04-07 13:07:29 +02:00
Mark Tolmacs
03b91deb4a Adjusted elbow in-shape binding strategy 2025-04-07 13:07:29 +02:00
Mark Tolmacs
dca9fbe306 Fixed gap binding 2025-04-07 13:07:29 +02:00
Mark Tolmacs
f363fcabd8 Common center point util 2025-04-07 13:07:29 +02:00
245 changed files with 24731 additions and 27869 deletions

View File

@ -1,5 +1,5 @@
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/ VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
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

View File

@ -32,12 +32,6 @@
"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
}
] ]
} }
} }

View File

@ -63,7 +63,7 @@ The Excalidraw editor (npm package) supports:
- 🏗️&nbsp;Customizable. - 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support. - 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support. - 😀&nbsp;Shape libraries support.
- 🌐&nbsp;Localization (i18n) support. - 👅&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard. - 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file. - 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser... - ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...

View File

@ -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 be 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 a valid React Node.
**Usage** **Usage**
@ -25,7 +25,7 @@ function App() {
} }
``` ```
This will only work for `Desktop` devices. This will only 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.

View File

@ -31,7 +31,6 @@ 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 &#124; undefined)</code> | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; 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

View File

@ -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:

View File

@ -52,7 +52,7 @@
transform: none; transform: none;
} }
.excalidraw .selected-shape-actions { .excalidraw .panelColumn {
text-align: left; text-align: left;
} }

View File

@ -104,7 +104,6 @@ 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);
@ -193,7 +192,6 @@ 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",
@ -712,14 +710,6 @@ 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"

View File

@ -47,10 +47,10 @@ import {
share, share,
youtubeIcon, youtubeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element"; import { isElementLink } from "@excalidraw/element/elementLink";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
parseLibraryTokensFromUrl, parseLibraryTokensFromUrl,
@ -926,21 +926,16 @@ const ExcalidrawWrapper = () => {
<ShareDialog <ShareDialog
collabAPI={collabAPI} collabAPI={collabAPI}
onExportToBackend={async () => { onExportToBackend={async () => {
if (!excalidrawAPI) { if (excalidrawAPI) {
return; try {
} await onExportToBackend(
try { excalidrawAPI.getSceneElements(),
const { url, errorMessage } = await exportToBackend( excalidrawAPI.getAppState(),
excalidrawAPI.getSceneElements(), excalidrawAPI.getFiles(),
excalidrawAPI.getAppState(), );
excalidrawAPI.getFiles(), } catch (error: any) {
); setErrorMessage(error.message);
if (errorMessage) {
throw new Error(errorMessage);
} }
setLatestShareableLink(url);
} catch (error: any) {
setErrorMessage(error.message);
} }
}} }}
/> />

View File

@ -19,9 +19,12 @@ 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"; import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { isImageElement, isInitializedImageElement } from "@excalidraw/element"; import {
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";

View File

@ -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"; import { newElementWith } from "@excalidraw/element/mutateElement";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common"; import type { UserIdleState } from "@excalidraw/common";

View File

@ -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="noopener">Excalidraw+</a> to get more requests.</div> }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
</div> </div>
</body> </body>
</html>`, </html>`,

View File

@ -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/excalidraw/visualdebug"; import type { DebugElement } from "@excalidraw/common";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";

View File

@ -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" rel="noopener noreferrer"
aria-label={t("encrypted.link")} aria-label={t("encrypted.link")}
> >
<Tooltip label={t("encrypted.tooltip")} long={true}> <Tooltip label={t("encrypted.tooltip")} long={true}>

View File

@ -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="noopener" rel="noreferrer"
className="plus-button" className="plus-button"
> >
Go to Excalidraw+ Go to Excalidraw+

View File

@ -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"; import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { useI18n } from "@excalidraw/excalidraw/i18n"; import { useI18n } from "@excalidraw/excalidraw/i18n";
import type { import type {

View File

@ -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"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import type { import type {

View File

@ -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"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
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"; import type { SceneBounds } from "@excalidraw/element/bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,

View File

@ -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": "vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true 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",

View File

@ -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="noopener" rel="noreferrer"
target="_blank" target="_blank"
> >
<div <div

View File

@ -3,15 +3,11 @@ import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory"; } from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element"; import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
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;
@ -69,79 +65,6 @@ 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 = {
@ -199,7 +122,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const undoAction = createUndoAction(h.history); const undoAction = createUndoAction(h.history, h.store);
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!
@ -231,7 +154,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const redoAction = createRedoAction(h.history); const redoAction = createRedoAction(h.history, h.store);
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!

View File

@ -33,7 +33,6 @@
"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",
@ -79,8 +78,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": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist", "rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules", "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install" "clean-install": "yarn rm:node_modules && yarn install"
}, },
"resolutions": { "resolutions": {

View File

@ -13,7 +13,7 @@
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
}, },
"./*": { "./*": {
"types": "./dist/types/common/src/*.d.ts" "types": "./../common/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": "rimraf types && tsc", "gen:types": "rm -rf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }

View File

@ -10,7 +10,6 @@ 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;
@ -113,14 +112,12 @@ 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";
@ -144,7 +141,6 @@ 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 = {
@ -256,7 +252,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawClipboardWithAPI: "excalidraw-api/clipboard", excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const; } as const;
export const getExportSource = () => export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin; window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds // time in milliseconds
@ -322,9 +318,6 @@ 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;

View File

@ -22,10 +22,8 @@ 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 */
* whether this is a font that users can use (= shown in font picker) serverSide?: true;
*/
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 */
@ -46,7 +44,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
unitsPerEm: 1000, unitsPerEm: 1000,
ascender: 1011, ascender: 1011,
descender: -353, descender: -353,
lineHeight: 1.25, lineHeight: 1.35,
}, },
}, },
[FONT_FAMILY["Lilita One"]]: { [FONT_FAMILY["Lilita One"]]: {
@ -100,23 +98,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -434, descender: -434,
lineHeight: 1.15, lineHeight: 1.15,
}, },
private: true, serverSide: 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.25, lineHeight: 1.15,
}, },
fallback: true, fallback: true,
}, },

View File

@ -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 "./emitter"; export * from "./visualdebug";

View File

@ -68,12 +68,3 @@ 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;

View File

@ -1,82 +0,0 @@
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);
});
});
});

View File

@ -1,10 +1,9 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average } 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 {
@ -544,20 +543,6 @@ 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";
@ -694,7 +679,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() as Map<string, T>); }, new Map());
}; };
export const arrayToMapWithIndex = <T extends { id: string }>( export const arrayToMapWithIndex = <T extends { id: string }>(
@ -749,31 +734,10 @@ 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;
@ -1237,60 +1201,3 @@ 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;
};

View File

@ -6,12 +6,12 @@ import {
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isBounds } from "@excalidraw/element"; import { isBounds } from "@excalidraw/element/typeChecks";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import type { Bounds } from "@excalidraw/element"; import type { Bounds } from "@excalidraw/element/bounds";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {

View File

@ -13,7 +13,7 @@
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
}, },
"./*": { "./*": {
"types": "./dist/types/element/src/*.d.ts" "types": "./../element/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": "rimraf types && tsc", "gen:types": "rm -rf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }

View File

@ -437,26 +437,12 @@ export const _generateElementShape = (
: [pointFrom<LocalPoint>(0, 0)]; : [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes shape = [
if ( generator.path(
!points.every( generateElbowArrowShape(points, 16),
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6, generateRoughOptions(element, true),
) ),
) { ];
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

View File

@ -1,11 +1,12 @@
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 { ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@ -14,10 +15,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,
@ -32,13 +33,12 @@ export const alignElements = (
); );
return group.map((element) => { return group.map((element) => {
// update element // update element
const updatedEle = scene.mutateElement(element, { const updatedEle = 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, { updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group, simultaneouslyUpdated: group,
}); });
return updatedEle; return updatedEle;

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,12 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common";
arrayToMap,
invariant,
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
pointDistance,
pointFrom, pointFrom,
pointDistance,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
@ -33,8 +28,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 { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
@ -52,20 +47,19 @@ 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;
@ -944,10 +938,10 @@ export const getElementBounds = (
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: ElementsMapOrArray, elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!sizeOf(elements)) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View File

@ -1,4 +1,4 @@
import { isTransparent, elementCenterPoint } from "@excalidraw/common"; import { isTransparent } 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 { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape"; import { getPolygonShape } from "@excalidraw/utils/shape";
import type { import type {
GlobalPoint, GlobalPoint,
@ -26,6 +26,8 @@ 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";
@ -189,7 +191,10 @@ const intersectRectanguloidWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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>(
@ -248,7 +253,10 @@ const intersectDiamondWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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.
@ -296,7 +304,10 @@ const intersectEllipseWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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);

View File

@ -14,8 +14,6 @@ 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,
@ -63,7 +61,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), pointFrom(element.x + element.width / 2, element.y + element.height / 2),
-element.angle as Radians, -element.angle as Radians,
); );

View File

@ -1,13 +1,12 @@
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 {
@ -54,7 +53,10 @@ const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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);
@ -82,7 +84,10 @@ const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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.
@ -110,7 +115,10 @@ const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = pointFrom(
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),

View File

@ -11,10 +11,13 @@ 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";
@ -26,8 +29,6 @@ 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";
@ -103,7 +104,7 @@ export const dragSelectedElements = (
); );
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); updateElementCoords(pointerDownState, element, 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(
@ -111,14 +112,9 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
); );
if (textElement) { if (textElement) {
updateElementCoords( updateElementCoords(pointerDownState, textElement, adjustedOffset);
pointerDownState,
textElement,
scene,
adjustedOffset,
);
} }
updateBoundElements(element, scene, { updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
} }
@ -159,7 +155,6 @@ 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 =
@ -168,7 +163,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;
scene.mutateElement(element, { mutateElement(element, {
x: nextX, x: nextX,
y: nextY, y: nextY,
}); });
@ -195,7 +190,6 @@ export const dragNewElement = ({
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
shouldResizeFromCenter, shouldResizeFromCenter,
zoom, zoom,
scene,
widthAspectRatio = null, widthAspectRatio = null,
originOffset = null, originOffset = null,
informMutation = true, informMutation = true,
@ -211,7 +205,6 @@ 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;
@ -292,7 +285,7 @@ export const dragNewElement = ({
}; };
} }
scene.mutateElement( mutateElement(
newElement, newElement,
{ {
x: newX + (originOffset?.x ?? 0), x: newX + (originOffset?.x ?? 0),
@ -302,7 +295,7 @@ export const dragNewElement = ({
...textAutoResize, ...textAutoResize,
...imageInitialDimension, ...imageInitialDimension,
}, },
{ informMutation, isDragging: false }, informMutation,
); );
} }
}; };

View File

@ -36,7 +36,10 @@ import {
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixDuplicatedBindingsAfterDuplication } from "./binding"; import {
fixDuplicatedBindingsAfterDuplication,
fixReversedBindings,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
@ -57,14 +60,16 @@ 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> => {
const copy = deepCopyElement(element); let copy = deepCopyElement(element);
if (isTestEnv()) { if (isTestEnv()) {
__test__defineOrigId(copy, element.id); __test__defineOrigId(copy, element.id);
@ -87,6 +92,9 @@ 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;
}; };
@ -94,14 +102,9 @@ export const duplicateElements = (
opts: { opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean; randomizeSeed?: boolean;
overrides?: (data: { overrides?: (
duplicateElement: ExcalidrawElement; originalElement: ExcalidrawElement,
origElement: ExcalidrawElement; ) => Partial<ExcalidrawElement>;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & ( } & (
| { | {
/** /**
@ -129,6 +132,14 @@ 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;
} }
), ),
) => { ) => {
@ -142,6 +153,8 @@ 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
@ -154,17 +167,10 @@ 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 duplicatedElements: ExcalidrawElement[] = []; const newElements: ExcalidrawElement[] = [];
const origElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = [];
const origIdToDuplicateId = new Map< const oldIdToDuplicatedId = new Map();
ExcalidrawElement["id"], const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
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"
@ -182,7 +188,7 @@ export const duplicateElements = (
elements = normalizeElementOrder(elements); elements = normalizeElementOrder(elements);
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice(); const elementsWithClones: ExcalidrawElement[] = elements.slice();
// helper functions // helper functions
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -208,17 +214,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);
duplicateElementsMap.set(newElement.id, newElement); duplicatedElementsMap.set(newElement.id, newElement);
origIdToDuplicateId.set(element.id, newElement.id); oldIdToDuplicatedId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
origElements.push(element); oldElements.push(element);
duplicatedElements.push(newElement); newElements.push(newElement);
acc.push(newElement); acc.push(newElement);
return acc; return acc;
@ -242,12 +248,21 @@ export const duplicateElements = (
return; return;
} }
if (index > elementsWithDuplicates.length - 1) { if (reverseOrder && index < 1) {
elementsWithDuplicates.push(...castArray(elements)); elementsWithClones.unshift(...castArray(elements));
return; return;
} }
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); if (!reverseOrder && index > elementsWithClones.length - 1) {
elementsWithClones.push(...castArray(elements));
return;
}
elementsWithClones.splice(
index + (reverseOrder ? 0 : 1),
0,
...castArray(elements),
);
}; };
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
@ -279,9 +294,13 @@ export const duplicateElements = (
: [element], : [element],
); );
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = reverseOrder
return el.groupIds?.includes(groupId); ? elementsWithClones.findIndex((el) => {
}); return el.groupIds?.includes(groupId);
})
: findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements)); insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue; continue;
@ -299,7 +318,7 @@ export const duplicateElements = (
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId; return el.frameId === frameId || el.id === frameId;
}); });
@ -316,7 +335,7 @@ export const duplicateElements = (
if (hasBoundTextElement(element)) { if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithClones, (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)
@ -325,7 +344,7 @@ export const duplicateElements = (
if (boundTextElement) { if (boundTextElement) {
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
targetIndex, targetIndex + (reverseOrder ? -1 : 0),
copyElements([element, boundTextElement]), copyElements([element, boundTextElement]),
); );
} else { } else {
@ -338,7 +357,7 @@ export const duplicateElements = (
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap); const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.id === element.id || el.id === container?.id; return el.id === element.id || el.id === container?.id;
}); });
@ -358,7 +377,7 @@ export const duplicateElements = (
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id), findLastIndex(elementsWithClones, (el) => el.id === element.id),
copyElements(element), copyElements(element),
); );
} }
@ -366,38 +385,28 @@ export const duplicateElements = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fixDuplicatedBindingsAfterDuplication( fixDuplicatedBindingsAfterDuplication(
duplicatedElements, newElements,
origIdToDuplicateId, oldIdToDuplicatedId,
duplicateElementsMap as NonDeletedSceneElementsMap, duplicatedElementsMap as NonDeletedSceneElementsMap,
); );
bindElementsToFramesAfterDuplication( if (reverseOrder) {
elementsWithDuplicates, fixReversedBindings(
origElements, _idsOfElementsToDuplicate,
origIdToDuplicateId, elementsWithClones,
); oldIdToDuplicatedId,
);
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
duplicateElement,
opts.overrides({
duplicateElement,
origElement,
origIdToDuplicateId,
}),
);
}
}
} }
bindElementsToFramesAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
return { return {
duplicatedElements, newElements,
duplicateElementsMap, elementsWithClones,
elementsWithDuplicates,
origIdToDuplicateId,
}; };
}; };

View File

@ -20,6 +20,7 @@ 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";
@ -62,7 +63,6 @@ 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,8 +876,6 @@ const handleEndpointDrag = (
); );
}; };
const MAX_POS = 1e6;
/** /**
* *
*/ */
@ -886,7 +884,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
updates: { updates: {
points?: readonly LocalPoint[]; points?: readonly LocalPoint[];
fixedSegments?: readonly FixedSegment[] | null; fixedSegments?: FixedSegment[] | null;
startBinding?: FixedPointBinding | null; startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
}, },
@ -898,51 +896,7 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow if (isDevEnv() || isTestEnv()) {
// 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 " +
@ -1220,19 +1174,31 @@ const getElbowArrowData = (
if (options?.isDragging) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement( getHoveredElementForBinding(
origStartGlobalPoint, tupleToCoors(origStartGlobalPoint),
elementsMap,
elements, elements,
elementsMap,
options?.zoom, options?.zoom,
true,
true,
) || null; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElementForBinding(
origEndGlobalPoint, tupleToCoors(origEndGlobalPoint),
elementsMap,
elements, elements,
elementsMap,
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) ||
@ -1253,6 +1219,7 @@ const getElbowArrowData = (
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
hoveredStartElement, hoveredStartElement,
options?.isDragging, options?.isDragging,
); );
@ -1266,6 +1233,7 @@ const getElbowArrowData = (
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
hoveredEndElement, hoveredEndElement,
options?.isDragging, options?.isDragging,
); );
@ -2107,29 +2075,6 @@ 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)),
); );
@ -2209,6 +2154,7 @@ 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 => {
@ -2218,6 +2164,7 @@ const getGlobalPoint = (
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint); return snapToMid(element, snapPoint);
@ -2237,7 +2184,7 @@ const getGlobalPoint = (
distanceToBindableElement(element, fixedGlobalPoint) - distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@ -2267,22 +2214,6 @@ 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];

View File

@ -33,8 +33,6 @@ 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+)/;
@ -71,7 +69,6 @@ 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([
@ -85,7 +82,6 @@ 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) => {
@ -210,10 +206,6 @@ 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.

View File

@ -39,8 +39,6 @@ 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;
@ -238,11 +236,10 @@ 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);
@ -277,9 +274,9 @@ const addNewNode = (
const bindingArrow = createBindingArrow( const bindingArrow = createBindingArrow(
element, element,
nextNode, nextNode,
elementsMap,
direction, direction,
appState, appState,
scene,
); );
return { return {
@ -290,9 +287,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
@ -355,9 +352,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);
@ -370,9 +367,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;
@ -443,10 +440,18 @@ const createBindingArrow = (
elbowed: true, elbowed: true,
}); });
const elementsMap = scene.getNonDeletedElementsMap(); bindLinearElement(
bindingArrow,
bindLinearElement(bindingArrow, startBindingElement, "start", scene); startBindingElement,
bindLinearElement(bindingArrow, endBindingElement, "end", scene); "start",
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(
@ -462,18 +467,12 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(bindingArrow, [
bindingArrow, {
scene, index: 1,
new Map([ point: bindingArrow.points[1],
[ },
1, ]);
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints( const update = updateElbowArrowPoints(
bindingArrow, bindingArrow,
@ -633,17 +632,16 @@ 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;
@ -654,9 +652,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,
); );
@ -684,9 +682,13 @@ export class FlowChartCreator {
) )
) { ) {
this.pendingNodes = this.pendingNodes.map((node) => this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement(node, elementsMap, { mutateElement(
frameId: startNode.frameId, node,
}), {
frameId: startNode.frameId,
},
false,
),
); );
} }
} }

View File

@ -7,7 +7,6 @@ import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
import type { import type {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
@ -153,10 +152,9 @@ export const orderByFractionalIndex = (
*/ */
export const syncMovedIndices = ( export const syncMovedIndices = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
movedElements: ElementsMap, movedElements: Map<string, ExcalidrawElement>,
): 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
@ -178,7 +176,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, elementsMap, update); mutateElement(element, update, false);
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@ -196,12 +194,10 @@ 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, elementsMap, update); mutateElement(element, update, false);
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
@ -214,7 +210,7 @@ export const syncInvalidIndices = (
*/ */
const getMovedIndicesGroups = ( const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
movedElements: ElementsMap, movedElements: Map<string, ExcalidrawElement>,
) => { ) => {
const indicesGroups: number[][] = []; const indicesGroups: number[][] = [];

View File

@ -3,6 +3,8 @@ 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,
@ -27,8 +29,6 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
@ -41,24 +41,30 @@ import type {
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
nextElements: readonly ExcalidrawElement[], nextElements: readonly ExcalidrawElement[],
origElements: readonly ExcalidrawElement[], oldElements: readonly ExcalidrawElement[],
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, oldIdToDuplicatedId: 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 origElements) { for (const element of oldElements) {
if (element.frameId) { if (element.frameId) {
// use its frameId to get the new frameId // use its frameId to get the new frameId
const nextElementId = origIdToDuplicateId.get(element.id); const nextElementId = oldIdToDuplicatedId.get(element.id);
const nextFrameId = origIdToDuplicateId.get(element.frameId); const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
const nextElement = nextElementId && nextElementMap.get(nextElementId); if (nextElementId) {
if (nextElement) { const nextElement = nextElementMap.get(nextElementId);
mutateElement(nextElement, nextElementMap, { if (nextElement) {
frameId: nextFrameId ?? null, mutateElement(
}); nextElement,
{
frameId: nextFrameId ?? element.frameId,
},
false,
);
}
} }
} }
} }
@ -561,9 +567,13 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, { mutateElement(
frameId: frame.id, element,
}); {
frameId: frame.id,
},
false,
);
} }
return allElements; return allElements;
@ -601,9 +611,13 @@ export const removeElementsFromFrame = (
} }
for (const [, element] of _elementsToRemove) { for (const [, element] of _elementsToRemove) {
mutateElement(element, elementsMap, { mutateElement(
frameId: null, element,
}); {
frameId: null,
},
false,
);
} }
}; };
@ -905,16 +919,13 @@ export const shouldApplyFrameClip = (
return false; return false;
}; };
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
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => { export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
return element.name === null ? getDefaultFrameName(element) : element.name; // TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
? "Frame"
: "AI Frame"
: element.name;
}; };
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (

View File

@ -1,5 +1,3 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -7,7 +5,6 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ElementsMapOrArray,
} from "./types"; } from "./types";
/** /**
@ -19,10 +16,12 @@ 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 = (elements: ElementsMapOrArray): number => { export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
let hash = 5381; let hash = 5381;
for (const element of toIterable(elements)) { for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + element.versionNonce; hash = (hash << 5) + hash + elements[i].versionNonce;
} }
return hash >>> 0; // Ensure unsigned 32-bit integer return hash >>> 0; // Ensure unsigned 32-bit integer
}; };
@ -72,47 +71,3 @@ 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";

View File

@ -20,7 +20,11 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { Store } from "@excalidraw/element"; // 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 { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@ -38,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { import {
@ -46,10 +51,13 @@ import {
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
} from "./bounds"; } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { bumpVersion, mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement,
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -67,8 +75,6 @@ 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,
@ -80,11 +86,16 @@ 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";
@ -118,17 +129,15 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
constructor( constructor(element: NonDeleted<ExcalidrawLinearElement>) {
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, elementsMap); LinearElementEditor.normalizePoints(element);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
this.isDragging = false; this.isDragging = false;
@ -245,27 +254,28 @@ 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 = scene.getNonDeletedElementsMap(); const elementsMap = app.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 (
isElbowArrow(element) && elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint && !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0 linearElementEditor.pointerDownState.lastClickedPoint !== 0
) { ) {
return null; return null;
} }
const selectedPointsIndices = isElbowArrow(element) const selectedPointsIndices = elbowed
? [ ? [
!!linearElementEditor.selectedPointsIndices?.includes(0) !!linearElementEditor.selectedPointsIndices?.includes(0)
? 0 ? 0
@ -275,7 +285,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 = isElbowArrow(element) const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0 ? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1 ? element.points.length - 1
: 0 : 0
@ -302,22 +312,16 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
scene, index: selectedIndex,
new Map([ point: pointFrom(
[ width + referencePoint[0],
selectedIndex, height + referencePoint[1],
{ ),
point: pointFrom( isDragging: selectedIndex === lastClickedPoint,
width + referencePoint[0], },
height + referencePoint[1], ]);
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@ -332,39 +336,72 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, selectedPointsIndices.map((pointIndex) => {
new Map( let newPointPosition: LocalPoint =
selectedPointsIndices.map((pointIndex) => { pointIndex === lastClickedPoint
const newPointPosition: LocalPoint = ? LinearElementEditor.createPointAt(
pointIndex === lastClickedPoint element,
? LinearElementEditor.createPointAt( elementsMap,
element, scenePointerX - linearElementEditor.pointerOffset.x,
elementsMap, scenePointerY - linearElementEditor.pointerOffset.y,
scenePointerX - linearElementEditor.pointerOffset.x, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
scenePointerY - linearElementEditor.pointerOffset.y, )
event[KEYS.CTRL_OR_CMD] : pointFrom(
? null element.points[pointIndex][0] + deltaX,
: app.getEffectiveGridSize(), element.points[pointIndex][1] + deltaY,
) );
: pointFrom(
element.points[pointIndex][0] + deltaX, if (pointIndex === 0 || pointIndex === element.points.length - 1) {
element.points[pointIndex][1] + deltaY, const [, , , , cx, cy] = getElementAbsoluteCoords(
); element,
return [ elementsMap,
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,
point: newPointPosition, app.state.zoom,
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, scene, false); handleBindTextResize(element, elementsMap, false);
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
@ -459,21 +496,15 @@ 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( LinearElementEditor.movePoints(element, [
element, {
scene, index: selectedPoint,
new Map([ point:
[ selectedPoint === 0
selectedPoint, ? element.points[element.points.length - 1]
{ : element.points[0],
point: },
selectedPoint === 0 ]);
? element.points[element.points.length - 1]
: element.points[0],
},
],
]),
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@ -531,7 +562,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
): (GlobalPoint | null)[] => { ): typeof editorMidPointsCache["points"] => {
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
@ -543,7 +574,25 @@ 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,
@ -575,8 +624,9 @@ export class LinearElementEditor {
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
} }
editorMidPointsCache.points = midpoints;
return midpoints; editorMidPointsCache.version = element.version;
editorMidPointsCache.zoom = appState.zoom.value;
}; };
static getSegmentMidpointHitCoords = ( static getSegmentMidpointHitCoords = (
@ -630,11 +680,8 @@ export class LinearElementEditor {
} }
} }
let index = 0; let index = 0;
const midPoints = LinearElementEditor.getEditorMidPoints( const midPoints: typeof editorMidPointsCache["points"] =
element, LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
elementsMap,
appState,
);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
@ -791,7 +838,7 @@ export class LinearElementEditor {
); );
} else if (event.altKey && appState.editingLinearElement) { } else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) { if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
LinearElementEditor.createPointAt( LinearElementEditor.createPointAt(
@ -805,7 +852,7 @@ export class LinearElementEditor {
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
} }
store.scheduleCapture(); store.shouldCaptureIncrement();
ret.linearElementEditor = { ret.linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
pointerDownState: { pointerDownState: {
@ -857,6 +904,7 @@ export class LinearElementEditor {
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
scene, scene,
); );
} }
@ -929,13 +977,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;
@ -946,9 +994,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app.scene, [ LinearElementEditor.deletePoints(element, [points.length - 1]);
points.length - 1,
]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -986,20 +1032,14 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
app.scene, index: element.points.length - 1,
new Map([ point: newPoint,
[ },
element.points.length - 1, ]);
{
point: newPoint,
},
],
]),
);
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]); LinearElementEditor.addPoints(element, [{ point: newPoint }]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -1163,26 +1203,23 @@ export class LinearElementEditor {
y: element.y + offsetY, y: element.y + offsetY,
}; };
} }
// element-mutating methods // element-mutating methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static normalizePoints(
element: NonDeleted<ExcalidrawLinearElement>, static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
elementsMap: ElementsMap, mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
} }
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(
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);
@ -1225,22 +1262,18 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
scene.mutateElement(element, { points: nextPoints }); 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( LinearElementEditor.movePoints(element, [
element, {
scene, index: element.points.length - 1,
new Map([ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
[ },
element.points.length - 1, ]);
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
],
]),
);
} }
return { return {
@ -1254,7 +1287,6 @@ 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;
@ -1285,41 +1317,28 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
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( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
element,
scene,
nextPoints,
offsetX,
offsetY,
);
} }
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
pointUpdates: PointsPositionUpdates,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
sceneElementsMap?: NonDeletedSceneElementsMap,
) { ) {
const { points } = element; const { points } = element;
@ -1329,7 +1348,8 @@ 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] =
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0); targetPoints.find(({ index }) => index === 0)?.point ??
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],
@ -1337,12 +1357,12 @@ export class LinearElementEditor {
const nextPoints = isElbowArrow(element) const nextPoints = isElbowArrow(element)
? [ ? [
pointUpdates.get(0)?.point ?? points[0], targetPoints.find((t) => t.index === 0)?.point ?? points[0],
pointUpdates.get(points.length - 1)?.point ?? targetPoints.find((t) => t.index === points.length - 1)?.point ??
points[points.length - 1], points[points.length - 1],
] ]
: points.map((p, idx) => { : points.map((p, idx) => {
const current = pointUpdates.get(idx)?.point ?? p; const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
return pointFrom<LocalPoint>( return pointFrom<LocalPoint>(
current[0] - offsetX, current[0] - offsetX,
@ -1352,13 +1372,17 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene,
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
otherUpdates, otherUpdates,
{ {
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging), isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
sceneElementsMap,
}, },
); );
} }
@ -1413,9 +1437,8 @@ export class LinearElementEditor {
pointerCoords: PointerCoords, pointerCoords: PointerCoords,
app: AppClassProperties, app: AppClassProperties,
snapToGrid: boolean, snapToGrid: boolean,
scene: Scene, elementsMap: ElementsMap,
) { ) {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
linearElementEditor.elementId, linearElementEditor.elementId,
elementsMap, elementsMap,
@ -1445,7 +1468,9 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!), ...element.points.slice(segmentMidpoint.index!),
]; ];
scene.mutateElement(element, { points }); mutateElement(element, {
points,
});
ret.pointerDownState = { ret.pointerDownState = {
...linearElementEditor.pointerDownState, ...linearElementEditor.pointerDownState,
@ -1461,7 +1486,6 @@ 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,
@ -1498,10 +1522,28 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints); updates.points = Array.from(nextPoints);
scene.mutateElement(element, updates, { if (!options?.sceneElementsMap || Scene.getScene(element)) {
informMutation: true, mutateElement(element, updates, true, {
isDragging: options?.isDragging ?? false, isDragging: options?.isDragging,
}); });
} 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);
@ -1516,7 +1558,7 @@ export class LinearElementEditor {
pointFrom(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
); );
scene.mutateElement(element, { mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotated[0],
@ -1575,7 +1617,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
if (points.length < 2) { if (points.length < 2) {
mutateElement(boundTextElement, elementsMap, { isDeleted: true }); mutateElement(boundTextElement, { isDeleted: true });
} }
let x = 0; let x = 0;
let y = 0; let y = 0;
@ -1590,14 +1632,23 @@ 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;
} }
@ -1773,9 +1824,8 @@ export class LinearElementEditor {
index: number, index: number,
x: number, x: number,
y: number, y: number,
scene: Scene, elementsMap: ElementsMap,
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
linearElement.elementId, linearElement.elementId,
elementsMap, elementsMap,
@ -1818,7 +1868,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);
scene.mutateElement(element, { mutateElement(element, {
fixedSegments: nextFixedSegments, fixedSegments: nextFixedSegments,
}); });
@ -1852,14 +1902,14 @@ export class LinearElementEditor {
static deleteFixedSegment( static deleteFixedSegment(
element: ExcalidrawElbowArrowElement, element: ExcalidrawElbowArrowElement,
scene: Scene,
index: number, index: number,
): void { ): void {
scene.mutateElement(element, { mutateElement(element, {
fixedSegments: element.fixedSegments?.filter( fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index, (segment) => segment.index !== index,
), ),
}); });
mutateElement(element, {}, true);
} }
} }

View File

@ -2,8 +2,13 @@ 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";
@ -11,42 +16,35 @@ 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 { import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
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.
* 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
* The version is used to compare updates when more than one user is working in // the same drawing. Note: this will trigger the component to update. Make sure you
* the same drawing. // are calling it either from a React event handler or within unstable_batchedUpdates().
*
* 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, startBinding, endBinding, fileId } = const { points, fixedSegments, fileId, startBinding, endBinding } =
updates as any; updates as any;
if ( if (
@ -57,6 +55,10 @@ 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,
@ -66,9 +68,16 @@ 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 as NonDeletedSceneElementsMap, elementsMap,
updates as ElementUpdate<ExcalidrawElbowArrowElement>, {
options, fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
), ),
}; };
} else if (typeof points !== "undefined") { } else if (typeof points !== "undefined") {
@ -141,6 +150,10 @@ 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;
}; };

View File

@ -44,6 +44,7 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
@ -97,28 +98,6 @@ 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(),
@ -477,7 +456,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"]; points?: ExcalidrawArrowElement["points"];
elbowed?: T; elbowed?: T;
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null; fixedSegments?: FixedSegment[] | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): T extends true ): T extends true
? NonDeleted<ExcalidrawElbowArrowElement> ? NonDeleted<ExcalidrawElbowArrowElement>

View File

@ -351,20 +351,12 @@ const generateElementCanvas = (
export const DEFAULT_LINK_SIZE = 14; export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = const IMAGE_PLACEHOLDER_IMG = document.createElement("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 = const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("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>`,
)}`; )}`;

View File

@ -17,6 +17,8 @@ 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";
@ -30,6 +32,7 @@ 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,
@ -57,8 +60,6 @@ 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,
@ -73,6 +74,7 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
SceneElementsMap,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
@ -81,6 +83,7 @@ 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,
@ -90,31 +93,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, scene); updateBoundElements(element, elementsMap);
} }
} else if (isTextElement(element) && transformHandleType) { } else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement( resizeSingleTextElement(
originalElements, originalElements,
element, element,
scene, elementsMap,
transformHandleType, transformHandleType,
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
); );
updateBoundElements(element, scene); updateBoundElements(element, elementsMap);
return true; return true;
} else if (transformHandleType) { } else if (transformHandleType) {
const elementId = selectedElements[0].id; const elementId = selectedElements[0].id;
@ -126,6 +129,8 @@ export const transformElements = (
getNextSingleWidthAndHeightFromPointer( getNextSingleWidthAndHeightFromPointer(
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElements,
transformHandleType, transformHandleType,
pointerX, pointerX,
pointerY, pointerY,
@ -140,8 +145,8 @@ export const transformElements = (
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElements, originalElements,
scene,
transformHandleType, transformHandleType,
{ {
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
@ -156,6 +161,7 @@ export const transformElements = (
rotateMultipleElements( rotateMultipleElements(
originalElements, originalElements,
selectedElements, selectedElements,
elementsMap,
scene, scene,
pointerX, pointerX,
pointerY, pointerY,
@ -204,15 +210,13 @@ 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( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
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;
@ -229,13 +233,13 @@ const rotateSingleElement = (
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
scene.mutateElement(element, { angle }); 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)) {
scene.mutateElement(textElement, { angle }); mutateElement(textElement, { angle });
} }
} }
}; };
@ -285,13 +289,12 @@ export const measureFontSizeFromWidth = (
const resizeSingleTextElement = ( const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"], originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
scene: Scene, elementsMap: ElementsMap,
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,
@ -390,7 +393,7 @@ const resizeSingleTextElement = (
); );
const [nextX, nextY] = newTopLeft; const [nextX, nextY] = newTopLeft;
scene.mutateElement(element, { mutateElement(element, {
fontSize: metrics.size, fontSize: metrics.size,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@ -505,13 +508,14 @@ const resizeSingleTextElement = (
autoResize: false, autoResize: false,
}; };
scene.mutateElement(element, resizedElement); 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,
@ -519,7 +523,6 @@ 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) {
@ -540,30 +543,38 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
const updates = isElbowArrow(element) if (isElbowArrow(element)) {
? { // Needed to re-route the arrow
// Needed to re-route the arrow mutateElement(element, {
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,
);
}
scene.mutateElement(element, updates); updateBoundElements(element, elementsMap, {
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)) {
scene.mutateElement(boundText, { mutateElement(
x: boundText.x + (rotatedCX - cx), boundText,
y: boundText.y + (rotatedCY - cy), {
angle: normalizeRadians((centerAngle + origAngle) as Radians), x: boundText.x + (rotatedCX - cx),
}); y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
} }
} }
} }
@ -808,8 +819,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,
@ -822,7 +833,6 @@ 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) {
@ -922,7 +932,7 @@ export const resizeSingleElement = (
} }
if ("scale" in latestElement && "scale" in origElement) { if ("scale" in latestElement && "scale" in origElement) {
scene.mutateElement(latestElement, { 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],
@ -957,33 +967,29 @@ export const resizeSingleElement = (
...rescaledPoints, ...rescaledPoints,
}; };
scene.mutateElement(latestElement, updates, { mutateElement(latestElement, updates, shouldInformMutation);
informMutation: shouldInformMutation,
isDragging: false, updateBoundElements(latestElement, elementsMap as SceneElementsMap);
});
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
}); });
} }
handleBindTextResize( handleBindTextResize(
latestElement, latestElement,
scene, elementsMap,
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,
@ -1516,26 +1522,28 @@ export const resizeMultipleElements = (
element, element,
update: { boundTextFontSize, ...update }, update: { boundTextFontSize, ...update },
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { angle } = update;
scene.mutateElement(element, update, { mutateElement(element, update, false, {
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, scene, { updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
}); });
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) { if (boundTextElement && boundTextFontSize) {
scene.mutateElement(boundTextElement, { mutateElement(
fontSize: boundTextFontSize, boundTextElement,
angle: isLinearElement(element) ? undefined : angle, {
}); fontSize: boundTextFontSize,
handleBindTextResize(element, scene, handleDirection, true); angle: isLinearElement(element) ? undefined : angle,
},
false,
);
handleBindTextResize(element, elementsMap, handleDirection, true);
} }
} }

View File

@ -1,4 +1,4 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common"; import { isShallowEqual } from "@excalidraw/common";
import type { import type {
AppState, AppState,
@ -7,20 +7,13 @@ import type {
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
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,
@ -169,6 +162,25 @@ 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">,
@ -242,49 +254,3 @@ 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,
),
};
};

View File

@ -4,7 +4,6 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
ROUNDNESS, ROUNDNESS,
invariant, invariant,
elementCenterPoint,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
isPoint, isPoint,
@ -298,7 +297,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = elementCenterPoint(element); const center = pointFrom(bbox.midX, bbox.midY);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,

View File

@ -6,6 +6,7 @@ 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";
@ -169,6 +170,41 @@ 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">,
): { ): {

View File

@ -1,972 +0,0 @@
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);

View File

@ -6,22 +6,18 @@ 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 {
@ -30,8 +26,6 @@ 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,
@ -46,30 +40,17 @@ import type {
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
scene: Scene, elementsMap: ElementsMap,
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: container?.angle ?? textElement.angle,
? isArrowElement(container)
? 0
: container.angle
: textElement.angle) as Radians,
}; };
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
@ -109,43 +90,38 @@ export const redrawTextBoundingBox = (
metrics.height, metrics.height,
container.type, container.type,
); );
scene.mutateElement(container, { height: nextHeight }); mutateElement(container, { height: nextHeight }, informMutation);
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,
); );
scene.mutateElement(container, { width: nextWidth }); mutateElement(container, { width: nextWidth }, informMutation);
} }
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;
} }
scene.mutateElement(textElement, boundTextUpdates); mutateElement(textElement, boundTextUpdates, informMutation);
}; };
export const handleBindTextResize = ( export const handleBindTextResize = (
container: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
scene: Scene, elementsMap: ElementsMap,
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;
@ -198,20 +174,20 @@ export const handleBindTextResize = (
transformHandleType === "n") transformHandleType === "n")
? container.y - diff ? container.y - diff
: container.y; : container.y;
scene.mutateElement(container, { mutateElement(container, {
height: containerHeight, height: containerHeight,
y: updatedY, y: updatedY,
}); });
} }
scene.mutateElement(textElement, { mutateElement(textElement, {
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
scene.mutateElement( mutateElement(
textElement, textElement,
computeBoundTextPosition(container, textElement, elementsMap), computeBoundTextPosition(container, textElement, elementsMap),
); );
@ -359,10 +335,7 @@ export const getTextElementAngle = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null, container: ExcalidrawTextContainer | null,
) => { ) => {
if (isArrowElement(container)) { if (!container || isArrowElement(container)) {
return 0;
}
if (!container) {
return textElement.angle; return textElement.angle;
} }
return container.angle; return container.angle;

View File

@ -28,7 +28,6 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -120,20 +119,6 @@ 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 => {
@ -145,7 +130,7 @@ export const isLinearElementType = (
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
): element is ExcalidrawLinearElement => { ): element is ExcalidrawArrowElement => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) && (!element.locked || includeLocked === true) &&
@ -286,10 +271,6 @@ 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" ||
@ -357,18 +338,3 @@ 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";
};

View File

@ -296,11 +296,6 @@ export type FixedPointBinding = Merge<
} }
>; >;
export type PointsPositionUpdates = Map<
number,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
@ -417,13 +412,3 @@ 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;

View File

@ -10,8 +10,6 @@ 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";
@ -70,7 +68,10 @@ export function deconstructRectanguloidElement(
return [sides, []]; return [sides, []];
} }
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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),
@ -253,7 +254,10 @@ export function deconstructDiamondElement(
return [[topRight, bottomRight, bottomLeft, topLeft], []]; return [[topRight, bottomRight, bottomLeft, topLeft], []];
} }
const center = elementCenterPoint(element); const center = pointFrom<GlobalPoint>(
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),

View File

@ -2,6 +2,8 @@ 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";
@ -10,8 +12,6 @@ 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) => {

View File

@ -10,6 +10,8 @@ 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;
@ -18,7 +20,9 @@ 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 () => {
@ -89,46 +93,55 @@ describe("element binding", () => {
}); });
}); });
//@TODO fix the test with rotation // UX RATIONALE: We are not aware of any use-case where the user would want to
it.skip("rotation of arrow should rebind both ends", () => { // have the arrow rebind after rotation but not when the arrow shaft is
const rectLeft = UI.createElement("rectangle", { // dragged so either the start or the end point is in the binding range of a
x: 0, // bindable element. So to remain consistent, we only "rebind" if at the end
width: 200, // of the rotation the original binding would remain the same (i.e. like we
height: 500, // would've evaluated binding only at the end of the operation).
}); it(
const rectRight = UI.createElement("rectangle", { "rotation of arrow should not rebind on both ends if rotated enough to" +
x: 400, " not be in the binding range of the original elements",
width: 200, () => {
height: 500, const rectLeft = UI.createElement("rectangle", {
}); x: 0,
const arrow = UI.createElement("arrow", { width: 200,
x: 210, height: 500,
y: 250, });
width: 180, const rectRight = UI.createElement("rectangle", {
height: 1, x: 400,
}); width: 200,
expect(arrow.startBinding?.elementId).toBe(rectLeft.id); height: 500,
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?.elementId).toBe(rectRight.id); expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding?.elementId).toBe(rectLeft.id); expect(arrow.endBinding).toBe(null);
}); },
);
// TODO fix & reenable once we rewrite tests to work with concurrency // TODO fix & reenable once we rewrite tests to work with concurrency
it.skip( it(
"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 () => {
@ -142,7 +155,10 @@ describe("element binding", () => {
mouse.up(0, 80); mouse.up(0, 80);
// Edit arrow with multi-point // Edit arrow with multi-point
mouse.doubleClick(); Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
// move arrow head // move arrow head
mouse.down(); mouse.down();
mouse.up(0, 10); mouse.up(0, 10);
@ -152,16 +168,12 @@ 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.down(0, 0); mouse.click();
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 unbind arrow when moving it with keyboard", () => { it("should not move bound arrows when moving it with keyboard", () => {
const rectangle = UI.createElement("rectangle", { const rectangle = UI.createElement("rectangle", {
x: 75, x: 75,
y: 0, y: 0,
@ -187,13 +199,19 @@ 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
expect(API.getSelectedElement().type).toBe("arrow"); Keyboard.withModifierKeys({ shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT); // We have to move a significant distance to get out of the binding zone
expect(arrow.endBinding).toBe(null); Array.from({ length: 10 }).forEach(() => {
Keyboard.keyPress(KEYS.ARROW_RIGHT); Keyboard.keyPress(KEYS.ARROW_LEFT);
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", () => {
@ -481,4 +499,86 @@ 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],
]);
},
);
}); });

View File

@ -1,149 +0,0 @@
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));
});
});
});

View File

@ -1,3 +1,4 @@
import React from "react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { import {
@ -7,13 +8,16 @@ import {
isPrimitive, isPrimitive,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions"; import {
actionDuplicateSelection,
actionSelectAll,
} from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { import {
act, act,
@ -24,9 +28,13 @@ 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 { ExcalidrawLinearElement } from "../src/types"; import type {
ExcalidrawArrowElement,
ExcalidrawLinearElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -61,11 +69,11 @@ describe("duplicating single elements", () => {
// @ts-ignore // @ts-ignore
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
mutateElement(element, new Map(), { mutateElement(element, {
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, true); const copy = duplicateElement(null, new Map(), element, undefined, true);
assertCloneObjects(element, copy); assertCloneObjects(element, copy);
@ -171,7 +179,7 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { duplicatedElements } = duplicateElements({ const { newElements: clonedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}); });
@ -179,10 +187,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(
duplicatedElements.map((e) => e.type), clonedElements.map((e) => e.type),
); );
origElements.forEach((origElement, idx) => { origElements.forEach((origElement, idx) => {
const clonedElement = duplicatedElements[idx]; const clonedElement = clonedElements[idx];
expect(origElement).toEqual( expect(origElement).toEqual(
expect.objectContaining({ expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id), id: expect.not.stringMatching(clonedElement.id),
@ -215,12 +223,12 @@ describe("duplicating multiple elements", () => {
}); });
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
const clonedArrows = duplicatedElements.filter( const clonedArrows = clonedElements.filter(
(e) => e.type === "arrow", (e) => e.type === "arrow",
) as ExcalidrawLinearElement[]; ) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] = const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
duplicatedElements as any as typeof origElements; clonedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id); expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect( expect(
@ -325,10 +333,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const duplicatedElements = duplicateElements({ const { newElements: clonedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}).duplicatedElements as any as typeof origElements; }) as any as { newElements: typeof origElements };
const [ const [
clonedRectangle, clonedRectangle,
@ -336,7 +344,7 @@ describe("duplicating multiple elements", () => {
clonedArrow1, clonedArrow1,
clonedArrow2, clonedArrow2,
clonedArrow3, clonedArrow3,
] = duplicatedElements; ] = clonedElements;
expect(clonedRectangle.boundElements).toEqual([ expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" }, { id: clonedArrow1.id, type: "arrow" },
@ -372,12 +380,12 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { duplicatedElements } = duplicateElements({ const { newElements: clonedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}); }) as any as { newElements: typeof origElements };
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
duplicatedElements; clonedElements;
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]);
@ -397,7 +405,7 @@ describe("duplicating multiple elements", () => {
}); });
const { const {
duplicatedElements: [clonedRectangle1], newElements: [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");
@ -406,114 +414,119 @@ describe("duplicating multiple elements", () => {
}); });
}); });
describe("group-related duplication", () => { describe("elbow arrow duplication", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
}); });
it("action-duplicating within group", async () => { it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
const rectangle1 = API.createElement({ UI.createElement("rectangle", {
type: "rectangle", x: -150,
x: 0, y: -150,
y: 0, width: 100,
groupIds: ["group1"], height: 100,
}); });
const rectangle2 = API.createElement({ UI.createElement("rectangle", {
type: "rectangle", x: 50,
x: 10, y: 50,
y: 10, width: 100,
groupIds: ["group1"], height: 100,
}); });
API.setElements([rectangle1, rectangle2]); UI.clickTool("arrow");
API.setSelectedElements([rectangle2], "group1"); 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(() => { act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection); h.app.actionManager.executeAction(actionDuplicateSelection);
}); });
assertElements(h.elements, [ expect(h.elements.length).toEqual(6);
{ id: rectangle1.id },
{ id: rectangle2.id }, const duplicatedArrow = h.scene.getSelectedElements(
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] }, 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(h.state.editingGroupId).toBe("group1"); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
}); });
it("alt-duplicating within group", async () => { it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
const rectangle1 = API.createElement({ UI.createElement("rectangle", {
type: "rectangle", x: -150,
x: 0, y: -150,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
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, width: 100,
height: 100, height: 100,
}); });
const rectangle1 = API.createElement({ UI.createElement("rectangle", {
type: "rectangle", x: 50,
x: 0, y: 50,
y: 0, width: 100,
width: 50, height: 100,
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]); UI.clickTool("arrow");
API.setSelectedElements([rectangle2], "group1"); UI.clickOnTestId("elbow-arrow");
Keyboard.withModifierKeys({ alt: true }, () => { mouse.reset();
mouse.down(rectangle2.x + 5, rectangle2.y + 5); mouse.moveTo(-43, -99);
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50); 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);
}); });
// console.log(h.elements); expect(h.elements.length).toEqual(4);
assertElements(h.elements, [ const duplicatedArrow = h.scene.getSelectedElements(
{ id: frame.id }, h.state,
{ id: rectangle1.id, frameId: frame.id }, )[0] as ExcalidrawArrowElement;
{ id: rectangle2.id, frameId: frame.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null }, 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(null);
}); });
}); });
@ -612,8 +625,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { [ORIG_ID]: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true }, { id: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -647,8 +660,8 @@ describe("duplication z-order", () => {
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { [ORIG_ID]: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true }, { id: rectangle3.id, selected: true },
]); ]);
}); });
@ -678,8 +691,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { [ORIG_ID]: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true }, { id: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -714,19 +727,19 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { [ORIG_ID]: rectangle1.id },
{ id: rectangle2.id }, { [ORIG_ID]: rectangle2.id },
{ id: rectangle3.id }, { [ORIG_ID]: rectangle3.id },
{ [ORIG_ID]: rectangle1.id, selected: true }, { id: rectangle1.id, selected: true },
{ [ORIG_ID]: rectangle2.id, selected: true }, { id: rectangle2.id, selected: true },
{ [ORIG_ID]: rectangle3.id, selected: true }, { id: rectangle3.id, selected: true },
]); ]);
}); });
it("alt-duplicating text container (in-order)", async () => { it("reverse-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]); API.setSelectedElements([rectangle, text]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -734,20 +747,20 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle.id }, { [ORIG_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("alt-duplicating text container (out-of-order)", async () => { it("reverse-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]); API.setSelectedElements([rectangle, text]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -755,21 +768,21 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle.id }, { [ORIG_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("alt-duplicating labeled arrows (in-order)", async () => { it("reverse-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]); API.setSelectedElements([arrow, text]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -777,24 +790,21 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: arrow.id }, { [ORIG_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("alt-duplicating labeled arrows (out-of-order)", async () => { it("reverse-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]); API.setSelectedElements([arrow, text]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -802,17 +812,17 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ id: arrow.id }, { [ORIG_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("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => { it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => {
const rect = UI.createElement("rectangle", { const rect = UI.createElement("rectangle", {
x: 0, x: 0,
y: 0, y: 0,
@ -834,18 +844,11 @@ describe("duplication z-order", () => {
mouse.up(15, 15); mouse.up(15, 15);
}); });
assertElements(h.elements, [ expect(window.h.elements).toHaveLength(3);
{
id: rect.id, const newRect = window.h.elements[0];
boundElements: expect.arrayContaining([
expect.objectContaining({ id: arrow.id }), expect(arrow.endBinding?.elementId).toBe(newRect.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 }),
},
]);
}); });
}); });

View File

@ -1,15 +1,13 @@
import { ARROW_TYPE } from "@excalidraw/common"; import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import Scene from "@excalidraw/excalidraw/scene/Scene";
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,
@ -22,8 +20,6 @@ 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,
@ -143,7 +139,7 @@ describe("elbow arrow routing", () => {
elbowed: true, elbowed: true,
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
h.app.scene.mutateElement(arrow, { 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),
@ -188,14 +184,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", scene); bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", scene); bindLinearElement(arrow, rectangle2, "end", elementsMap);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
h.app.scene.mutateElement(arrow, { mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });
@ -302,114 +298,4 @@ 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],
]);
});
}); });

View File

@ -7,14 +7,13 @@ import {
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
} from "@excalidraw/element"; } from "@excalidraw/element/fractionalIndex";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element/duplicate";
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";
@ -750,7 +749,7 @@ function testInvalidIndicesSync(args: {
function prepareArguments( function prepareArguments(
elementsLike: { id: string; index?: string }[], elementsLike: { id: string; index?: string }[],
movedElementsIds?: string[], movedElementsIds?: string[],
): [ExcalidrawElement[], ElementsMap | undefined] { ): [ExcalidrawElement[], Map<string, ExcalidrawElement> | 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 }),
); );
@ -765,7 +764,7 @@ function prepareArguments(
function test( function test(
name: string, name: string,
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
movedElements: ElementsMap | undefined, movedElements: Map<string, ExcalidrawElement> | undefined,
expectUnchangedElements: Map<string, { id: string }>, expectUnchangedElements: Map<string, { id: string }>,
expectValidInput?: boolean, expectValidInput?: boolean,
) { ) {

View File

@ -15,6 +15,8 @@ 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";
@ -195,7 +197,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(80, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0);
}); });
it("resizes with a label", async () => { it("resizes with a label", async () => {
@ -333,7 +335,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene, h.app.scene.getNonDeletedElementsMap(),
"ne", "ne",
); );
@ -369,7 +371,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene, h.app.scene.getNonDeletedElementsMap(),
"se", "se",
); );
@ -424,7 +426,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene, h.app.scene.getNonDeletedElementsMap(),
"e", "e",
{ {
shouldResizeFromCenter: true, shouldResizeFromCenter: true,
@ -826,8 +828,9 @@ 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(
30 + imageWidth * scale, expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
30 + imageWidth * scale + 1,
0, 0,
); );
}); });
@ -1003,14 +1006,14 @@ describe("multiple selection", () => {
size: 100, size: 100,
}); });
const leftBoundArrow = UI.createElement("arrow", { const leftBoundArrow = UI.createElement("arrow", {
x: -110, x: -100 - FIXED_BINDING_DISTANCE,
y: 50, y: 50,
width: 100, width: 100,
height: 0, height: 0,
}); });
const rightBoundArrow = UI.createElement("arrow", { const rightBoundArrow = UI.createElement("arrow", {
x: 210, x: 200 + FIXED_BINDING_DISTANCE,
y: 50, y: 50,
width: -100, width: -100,
height: 0, height: 0,
@ -1031,27 +1034,29 @@ describe("multiple selection", () => {
shift: true, shift: true,
}); });
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.width).toBeCloseTo(146 - FIXED_BINDING_DISTANCE, 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(10); expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
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); expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.y).toBeCloseTo( expect(rightBoundArrow.y).toBeCloseTo(
(selectionHeight - 50) * (1 - scale) + 50, (selectionHeight - 50) * (1 - scale) + 50,
0,
); );
expect(rightBoundArrow.width).toBeCloseTo(100 * scale); //console.log(JSON.stringify(h.elements));
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(8.0952); expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.endBinding?.elementId).toBe( expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId, rightArrowBinding.elementId,
); );
@ -1338,8 +1343,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); expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
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,

View File

@ -1,12 +1,10 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "../src/mutateElement";
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[],
@ -37,7 +35,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [], boundElements: [],
}); });
mutateElement(container, new Map(), { mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }], boundElements: [{ type: "text", id: boundText.id }],
}); });
@ -354,7 +352,7 @@ describe("normalizeElementsOrder", () => {
containerId: container.id, containerId: container.id,
}); });
h.app.scene.mutateElement(container, { mutateElement(container, {
boundElements: [ boundElements: [
{ type: "text", id: boundText.id }, { type: "text", id: boundText.id },
{ type: "text", id: "xxx" }, { type: "text", id: "xxx" },
@ -389,7 +387,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [], boundElements: [],
groupIds: ["C", "A"], groupIds: ["C", "A"],
}); });
h.app.scene.mutateElement(container, { mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }], boundElements: [{ type: "text", id: boundText.id }],
}); });

View File

@ -1,9 +1,8 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common"; import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element/duplicate";
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";

View File

@ -1,18 +1,16 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element/align";
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"; import type { Alignment } from "@excalidraw/element/align";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import {
@ -27,6 +25,7 @@ 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";
@ -51,8 +50,14 @@ 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(selectedElements, alignment, app.scene); const updatedElements = alignElements(
selectedElements,
elementsMap,
alignment,
app.scene,
);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -10,30 +10,28 @@ import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element/containerCache";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement,
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { measureText } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element/textMeasurements";
import { syncMovedIndices } from "@excalidraw/element"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { newElement } from "@excalidraw/element"; import { newElement } from "@excalidraw/element/newElement";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -44,7 +42,7 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math"; import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@ -79,7 +77,7 @@ export const actionUnbindText = register({
boundTextElement, boundTextElement,
elementsMap, elementsMap,
); );
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
height, height,
@ -87,7 +85,7 @@ export const actionUnbindText = register({
x, x,
y, y,
}); });
app.scene.mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id, (ele) => ele.id !== boundTextElement.id,
), ),
@ -152,21 +150,24 @@ export const actionBindText = register({
textElement = selectedElements[1] as ExcalidrawTextElement; textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer; container = selectedElements[0] as ExcalidrawTextContainer;
} }
app.scene.mutateElement(textElement, { 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,
}); });
app.scene.mutateElement(container, { 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(textElement, container, app.scene); redrawTextBoundingBox(
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);
@ -296,23 +297,27 @@ export const actionWrapTextInContainer = register({
} }
if (startBinding || endBinding) { if (startBinding || endBinding) {
app.scene.mutateElement(ele, { mutateElement(ele, { startBinding, endBinding }, false);
startBinding,
endBinding,
});
} }
}); });
} }
app.scene.mutateElement(textElement, { mutateElement(
containerId: container.id, textElement,
verticalAlign: VERTICAL_ALIGN.MIDDLE, {
boundElements: null, containerId: container.id,
textAlign: TEXT_ALIGN.CENTER, verticalAlign: VERTICAL_ALIGN.MIDDLE,
autoResize: true, boundElements: null,
}); 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],

View File

@ -14,10 +14,8 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element"; import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -46,6 +44,7 @@ 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";

View File

@ -1,10 +1,8 @@
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element/typeChecks";
import { getTextFromElements } from "@excalidraw/element"; import { getTextFromElements } from "@excalidraw/element/textElement";
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,
@ -17,6 +15,8 @@ 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";

View File

@ -1,12 +1,11 @@
import { isImageElement } from "@excalidraw/element"; import { isImageElement } from "@excalidraw/element/typeChecks";
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";

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw, mutateElement } 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,
}); });
h.app.scene.mutateElement(r1, { 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,
}); });
h.app.scene.mutateElement(r1, { 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,
}); });
h.app.scene.mutateElement(r1, { 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,
}); });
h.app.scene.mutateElement(a1, { mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }], boundElements: [{ type: "text", id: t1.id }],
}); });

View File

@ -1,28 +1,30 @@
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"; import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { newElementWith } from "@excalidraw/element"; import {
import { getContainerElement } from "@excalidraw/element"; mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element/frame";
import { import {
getElementsInGroup, getElementsInGroup,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element"; } from "@excalidraw/element/groups";
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";
@ -92,7 +94,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)) {
app.scene.mutateElement(bound, { mutateElement(bound, {
startBinding: startBinding:
el.id === bound.startBinding?.elementId el.id === bound.startBinding?.elementId
? null ? null
@ -100,6 +102,7 @@ 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 });
} }
}); });
} }
@ -258,11 +261,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, selectedPointsIndices);
element,
app.scene,
selectedPointsIndices,
);
return { return {
elements, elements,

View File

@ -1,18 +1,16 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { distributeElements } from "@excalidraw/element"; import { distributeElements } from "@excalidraw/element/distribute";
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"; import type { Distribution } from "@excalidraw/element/distribute";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import {
@ -23,6 +21,7 @@ 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";

View File

@ -7,24 +7,32 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element"; import {
isBoundToContainer,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { import {
excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
getSelectionStateForElements, } from "@excalidraw/element/selection";
} from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element"; import { duplicateElements } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types";
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";
@ -44,7 +52,7 @@ export const actionDuplicateSelection = register({
try { try {
const newAppState = LinearElementEditor.duplicateSelectedPoints( const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState, appState,
app.scene, app.scene.getNonDeletedElementsMap(),
); );
return { return {
@ -57,49 +65,52 @@ export const actionDuplicateSelection = register({
} }
} }
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({ let { newElements: duplicatedElements, elementsWithClones: nextElements } =
type: "in-place", duplicateElements({
elements, type: "in-place",
idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
),
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 && elementsWithDuplicates) {
const mappedElements = app.props.onDuplicate(
elementsWithDuplicates,
elements, elements,
); idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
),
appState,
randomizeSeed: true,
overrides: (element) => ({
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
}),
reverseOrder: false,
});
if (app.props.onDuplicate && nextElements) {
const mappedElements = app.props.onDuplicate(nextElements, elements);
if (mappedElements) { if (mappedElements) {
elementsWithDuplicates = mappedElements; nextElements = mappedElements;
} }
} }
return { return {
elements: syncMovedIndices( elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
elementsWithDuplicates,
arrayToMap(duplicatedElements),
),
appState: { appState: {
...appState, ...appState,
...getSelectionStateForElements( ...updateLinearElementEditors(duplicatedElements),
duplicatedElements, ...selectGroupsForSelectedElements(
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,
@ -119,3 +130,24 @@ 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,
};
};

View File

@ -2,14 +2,13 @@ import {
canCreateLinkFromElements, canCreateLinkFromElements,
defaultGetElementLinkFromSelection, defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection, getLinkIdAndTypeFromSelection,
} from "@excalidraw/element"; } from "@excalidraw/element/elementLink";
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";

View File

@ -1,23 +1,18 @@
import { KEYS, arrayToMap, randomId } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
elementsAreInSameGroup,
newElementWith,
selectGroupsFromGivenElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
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);
@ -28,10 +23,15 @@ 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.lock" ? "labels.elementLock.lockAll"
: "labels.elementLock.unlock"; : "labels.elementLock.unlockAll";
}, },
icon: (appState, elements) => { icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
@ -58,84 +58,19 @@ export const actionToggleElementLock = register({
const nextLockState = shouldLock(selectedElements); const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
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 { return {
elements: nextElements, elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: nextLockState });
}),
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,
}; };
@ -168,44 +103,18 @@ 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: nextElements, elements: elements.map((element) => {
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,
}; };

View File

@ -1,8 +1,7 @@
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";

View File

@ -7,8 +7,6 @@ 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";
@ -26,6 +24,7 @@ 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";

View File

@ -1,24 +1,28 @@
import { pointFrom } from "@excalidraw/math"; import { type GlobalPoint, pointFrom } from "@excalidraw/math";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "@excalidraw/element"; getHoveredElementForBinding,
import { LinearElementEditor } from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
isBindingElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { isBindingElement, isLinearElement } from "@excalidraw/element"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element/shapes";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
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";
@ -44,6 +48,7 @@ export const actionFinalize = register({
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
scene, scene,
); );
} }
@ -69,11 +74,7 @@ export const actionFinalize = register({
scene.getElement(appState.pendingImageElementId); scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) { if (pendingImageElement) {
scene.mutateElement( mutateElement(pendingImageElement, { isDeleted: true }, false);
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
} }
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
@ -92,12 +93,28 @@ export const actionFinalize = register({
multiPointElement.type !== "freedraw" && multiPointElement.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { x: rx, y: ry, 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 (
!lastCommittedPoint || !hoveredElementForBinding &&
points[points.length - 1] !== lastCommittedPoint (!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint)
) { ) {
scene.mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1), points: multiPointElement.points.slice(0, -1),
}); });
} }
@ -121,7 +138,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];
scene.mutateElement(multiPointElement, { 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])
@ -136,12 +153,12 @@ export const actionFinalize = register({
!isLoop && !isLoop &&
multiPointElement.points.length > 1 multiPointElement.points.length > 1
) { ) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( maybeBindLinearElement(
multiPointElement, multiPointElement,
-1, appState,
arrayToMap(elements), elementsMap,
elements,
); );
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
} }
} }
@ -197,10 +214,7 @@ 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( ? new LinearElementEditor(multiPointElement)
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },

View File

@ -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(rec1.x).toBeCloseTo(100, 0); expect(Math.floor(rec1.x)).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0); expect(Math.floor(rec1.y)).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!; const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0); expect(Math.floor(rec2.x)).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0); expect(Math.floor(rec2.y)).toBeCloseTo(250, 0);
}); });
}); });
@ -87,6 +87,16 @@ 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",
@ -123,6 +133,7 @@ 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",
@ -164,7 +175,9 @@ describe("flipping arrowheads", () => {
expect(API.getElement(arrow).endArrowhead).toBe("circle"); expect(API.getElement(arrow).endArrowhead).toBe("circle");
}); });
it("flipping unbound arrow shouldn't flip arrowheads", () => { // UX RATIONALE: Unbound arrows are not constrained by other elements and
// 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",

View File

@ -1,31 +1,33 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element"; import { getCommonBoundingBox } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element"; import {
import { deepCopyElement } from "@excalidraw/element"; mutateElement,
import { resizeMultipleElements } from "@excalidraw/element"; newElementWith,
} from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isBindableElement,
isLinearElement, isBindingElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
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,
ExcalidrawElbowArrowElement, ExcalidrawBindableElement,
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";
@ -158,50 +160,54 @@ const flipElements = (
}, },
); );
bindOrUnbindLinearElements( const selectedBindables = selectedElements.filter(
selectedElements.filter(isLinearElement), (e): e is ExcalidrawBindableElement => isBindableElement(e),
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) =>
app.scene.mutateElement(element, { selectedElements.forEach((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);
}
};

View File

@ -1,26 +1,25 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { newFrameElement } from "@excalidraw/element"; import { newFrameElement } from "@excalidraw/element/newElement";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { import {
addElementsToFrame, addElementsToFrame,
removeAllElementsFromFrame, removeAllElementsFromFrame,
} from "@excalidraw/element"; } from "@excalidraw/element/frame";
import { getFrameChildren } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element/frame";
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { getCommonBounds } from "@excalidraw/element"; import { getCommonBounds } from "@excalidraw/element/bounds";
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";
@ -174,9 +173,11 @@ 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(selectedElements, elementsMap); const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16; const PADDING = 16;
const frame = newFrameElement({ const frame = newFrameElement({
x: x1 - PADDING, x: x1 - PADDING,
@ -195,9 +196,13 @@ 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(elementInGroup, elementsMap, { mutateElement(
groupIds: elementInGroup.groupIds.slice(0, index), elementInGroup,
}); {
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
} }
} }

View File

@ -1,8 +1,8 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { isBoundToContainer } from "@excalidraw/element"; import { isBoundToContainer } from "@excalidraw/element/typeChecks";
import { import {
frameAndChildrenSelectedTogether, frameAndChildrenSelectedTogether,
@ -12,7 +12,7 @@ import {
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element/frame";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
@ -24,11 +24,9 @@ import {
addToGroup, addToGroup,
removeFromSelectedGroups, removeFromSelectedGroups,
isElementInGroup, isElementInGroup,
} from "@excalidraw/element"; } from "@excalidraw/element/groups";
import { syncMovedIndices } from "@excalidraw/element"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -42,6 +40,7 @@ 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";

View File

@ -1,9 +1,5 @@
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";
@ -11,8 +7,10 @@ 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";
@ -37,11 +35,7 @@ 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,
@ -53,9 +47,9 @@ const executeHistoryAction = (
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
}; };
type ActionCreator = (history: History) => Action; type ActionCreator = (history: History, store: Store) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history, store) => ({
name: "undo", name: "undo",
label: "buttons.undo", label: "buttons.undo",
icon: UndoIcon, icon: UndoIcon,
@ -63,7 +57,11 @@ export const createUndoAction: ActionCreator = (history) => ({
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () => executeHistoryAction(app, appState, () =>
history.undo(arrayToMap(elements) as SceneElementsMap, appState), history.undo(
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,
@ -90,15 +88,19 @@ export const createUndoAction: ActionCreator = (history) => ({
}, },
}); });
export const createRedoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history, store) => ({
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(arrayToMap(elements) as SceneElementsMap, appState), history.redo(
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)) ||

View File

@ -1,10 +1,6 @@
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element"; import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
@ -13,6 +9,7 @@ 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";
@ -53,7 +50,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement = const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id appState.editingLinearElement?.elementId === selectedElement.id
? null ? null
: new LinearElementEditor(selectedElement, arrayToMap(elements)); : new LinearElementEditor(selectedElement);
return { return {
appState: { appState: {
...appState, ...appState,

View File

@ -1,15 +1,14 @@
import { isEmbeddableElement } from "@excalidraw/element"; import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
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";

View File

@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element"; import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
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({

View File

@ -1,7 +1,5 @@
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 {
@ -10,6 +8,7 @@ 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

View File

@ -1,15 +1,15 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isLinearElement, isTextElement } from "@excalidraw/element"; import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
import { arrayToMap, KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element"; import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
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], arrayToMap(elements)) ? new LinearElementEditor(elements[0])
: null, : null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

@ -7,7 +7,7 @@ import {
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -17,14 +17,12 @@ import {
isArrowElement, isArrowElement,
isExcalidrawElement, isExcalidrawElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { import {
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawTextElement } from "@excalidraw/element/types"; import type { ExcalidrawTextElement } from "@excalidraw/element/types";
@ -32,6 +30,7 @@ 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";
@ -140,8 +139,11 @@ export const actionPasteStyles = register({
element.id === newElement.containerId, element.id === newElement.containerId,
) || null; ) || null;
} }
redrawTextBoundingBox(
redrawTextBoundingBox(newElement, container, app.scene); newElement,
container,
app.scene.getNonDeletedElementsMap(),
);
} }
if ( if (

View File

@ -1,13 +1,12 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element/textMeasurements";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element/typeChecks";
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";

View File

@ -1,8 +1,7 @@
import { CODES, KEYS } from "@excalidraw/common"; import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { gridIcon } from "../components/icons"; import { gridIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

@ -1,8 +1,7 @@
import { CODES, KEYS } from "@excalidraw/common"; import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { magnetIcon } from "../components/icons"; import { magnetIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

@ -5,9 +5,8 @@ import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { searchIcon } from "../components/icons"; import { searchIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@ -34,6 +33,13 @@ export const actionToggleSearchMenu = register({
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`, `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
); );
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
searchInput?.focus(); searchInput?.focus();
searchInput?.select(); searchInput?.select();
return false; return false;

View File

@ -1,35 +0,0 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
getConversionTypeFromElements,
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { register } from "./register";
export const actionToggleShapeSwitch = register({
name: "toggleShapeSwitch",
label: "labels.shapeSwitch",
icon: () => null,
viewMode: true,
trackEvent: {
category: "shape_switch",
action: "toggle",
},
keywords: ["change", "switch", "swap"],
perform(elements, appState, _, app) {
editorJotaiStore.set(convertElementTypePopupAtom, {
type: "panel",
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (elements, appState, props) =>
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
});

View File

@ -1,8 +1,7 @@
import { CODES, KEYS } from "@excalidraw/common"; import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { abacusIcon } from "../components/icons"; import { abacusIcon } from "../components/icons";
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