Compare commits

...

47 Commits

Author SHA1 Message Date
0ef2611633 refactor: rename 2025-05-29 12:21:23 +10:00
46c12a9f8c feat: add embeds to command palette 2025-05-29 12:00:01 +10:00
20dae101e9 feat: support slido in embeddable 2025-05-29 11:51:33 +10:00
022c407e24 feat: include google forms 2025-05-29 11:24:59 +10:00
7cad3645a0 perf: Simplify normalizeRadians function (#9572)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-28 15:58:42 +02:00
5921ebc416 fix: Regression in long press context menu closure (#9588) 2025-05-28 13:38:47 +02:00
864353be5f feat: Try to preserve line angle on SHIFT+drag (#9570) 2025-05-27 12:39:45 +02:00
db2911c6c4 fix: ghost point issue when moving a shape after dragging a point in the line editor (#9530)
fix: ghost point issue when moving a shape after
dragging a point in the line editor
2025-05-26 21:34:41 +02:00
fc3e062074 feat: do not break polygon on point delete inside line editor (#9580)
* feat: do not break polygon on point delete inside line editor

* fix: polygon point highlighting when selected point == 0
2025-05-26 16:51:47 +02:00
87c87a9fb1 feat: line polygons (#9477)
* Loop Lock/Unlock

* fixed condition. 4 line points are required for the action to be available

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

* do not split points on de-polygonizing & highlight overlapping points

* rewrite color picker to support no (mixed) colors & fix focus handling

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

* fix polygon behavior when adding/removing/moving points within line editor

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

* break polygon on restore/finalize if invalid & prevent creation

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-26 11:14:55 +02:00
4dc205537c feat: Call actionFinalize at the end of arrow creation and drag (#9453)
* First iter

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Restore binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* More actionFinalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Additional fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* New elbow arrow is removed if  too small

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Remove very small arrows

* Still allow loops

* Restore tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Update history snapshot

* More history snapshot updates

* keep invisible 2-point lines/freedraw elements

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-25 22:28:24 +02:00
cc571c4681 chore: init CLAUDE.md (#9563)
* chore: init CLAUDE.md

* Add Copilot instructions

* update gitignore

* simplify

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-25 21:23:40 +02:00
14d512f321 Fix import.meta.env.MODE being undefined in host apps 2025-05-22 15:25:48 +02:00
41c036e1a5 chore: Add DeepWiki badge (#9559) 2025-05-22 13:05:56 +02:00
91d36e9b81 fix: Linear to elbow conversion crash (#9556)
* Fix linear to elbow conversion

* Add invariant check

* Add dev invariant fix

* Add arrowhead
2025-05-22 12:34:15 +02:00
27522110df fix: fix keybindings for arrowheads (#9557) 2025-05-22 09:47:41 +02:00
712f267519 feat: better unlock (#9546)
* change lock label

* feat: add unlock logic for single units on pointer up

* feat: add unlock popup

* fix: linting errors

* style: padding tweaks

* style: remove redundant line

* feat: lock multiple units together

* style: tweak color & position

* feat: add highlight for locked elements

* feat: select groups correctly after unlocking

* test: update snapshots

* fix: lasso from selecting locked elements

* fix: should prevent selecting unlocked elements and setting locked id at the same time

* fix: reset hit locked id immediately when appropriate

* feat: capture locked units in delta

* test: update locking test

* feat: show lock highlight when locking (including undo/redo)

* feat: make locked highlighting consistent

* feat: show correct cursor type when moving over locked elements

* fix history

* remove `lockedUnits.singleUnits`

* tweak button

* do not render UnlockPopup if not locked element selected

* tweak actions

* refactor: simplify type

* refactor: rename type

* refactor: simplify hit element setting & checking

* fix: prefer locked over link

* rename to `activeLockedId`

* refactor: getElementAtPosition takes an optional hitelments array

* fix: avoid setting active locked id after resizing

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-21 21:57:12 +10:00
41a7613dff fix: Elbow arrow conversion labels mixed up (#9547) 2025-05-19 20:35:48 +02:00
95d89a751a refactor: decouple radio button selection from .buttonList wrapper (#9528)
* refactor: decouple radio button selection from `.buttonList`

* fix
2025-05-15 13:22:26 +02:00
6b5fb30d69 fix: unify line height across default fonts (#9513) 2025-05-14 16:02:01 +02:00
d92a849038 fix: issues when importing package outside of browser (#9525) 2025-05-14 16:01:43 +02:00
0a534f1bc6 fix: never show snap lines when lasso tool active (#9523) 2025-05-14 22:04:40 +10:00
4ca5f53b1f fix: alt + ctrl lasso selected elements not always kept (#9522)
* fix: alt + ctrl lasso selected elements not always kept

* Update packages/excalidraw/components/App.tsx

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-05-14 22:04:03 +10:00
f7dcc893ea feat: transparent link background, scale link icon when zooming to below 100% (#9520)
* Do not set link background color, dynamically scale down link icon size with zoom.

* removed unnecessary change

* use canvas bg color & reduce size and stroke width

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-14 13:38:18 +02:00
4dfb8a3f8e feat: allow forms.microsoft.com domain for embeddables (#9519)
* Update embeddable.ts

* no need for same origin

* The form does not load without allow same origin

* automatically add embed=true to link if not present

* fix link check
2025-05-13 19:48:26 +02:00
298812e1d0 fix: improve ctrl+alt lasso selecting (#9514) 2025-05-12 18:09:37 +02:00
35bb449a4b fix: update cached segments when visible area changes (#9512) 2025-05-12 15:55:36 +02:00
c4c064982f feat: show empty active color if no common color (#9506) 2025-05-11 15:07:57 +02:00
51dbd4831b refactor: make element type conversion more generic (#9504)
* feat: add `reduceToCommonValue()` & improve opacity slider

* feat: generalize and simplify type conversion cache

* refactor: change cache from atoms to Map

* feat: always attempt to reuse original fontSize when converting generic types
2025-05-10 20:06:16 +02:00
7e41026812 refactor: export everything from @excalidraw/element, don't import from subpaths (#9466)
* Don't import from subpaths

* Fix tests, move related tests to element
2025-05-09 23:01:33 +02:00
a8ebe514da Replace tongue emoji with globe emoji (#9489) 2025-05-09 16:59:06 +00:00
a30e1b25c6 feat: include frame names in canvas searches (#9484)
* fix frame name clipping on zooming

* include assistant font

* default frame name

* extend search to frame names

* add a simple test

* collpase search match items

* id check out of loop

* fix frame name check

* include focusedId for small perf improvement

* optionally show and hide collapse icon

* update section title

* fix tests

* rename `serverSide` -> `private`

* revert: do not reset zoom on zoom change

* feat: do not close menu on repeated ctrl+f

* remove collapsible

* tweak results CSS

* remove redundant check

* set `appState.searchMatches` to null if empty

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-09 18:32:16 +02:00
ff2ed5d26a refactor: change movePoints pointUpdates type (#9499) 2025-05-08 16:47:13 +02:00
e058a08b33 fix: use rimraf instead of rm -rf (#9460) 2025-05-07 14:13:27 +02:00
a306a909a0 fix: don't scroll page when TTDDialog is opened (#9455) 2025-05-07 13:33:18 +02:00
3dc54a724a feat: add onIncrement API (#9450) 2025-05-06 19:23:02 +02:00
a7c61319dd fix: do not translate bound elements twice (#9486) 2025-05-06 13:09:00 +02:00
cec5232a7a fix: when resizing element, update bound elements after final size of element is determined (#9475) 2025-05-05 12:15:42 +02:00
d4f70e9f31 feat: Quarter snap points for diamonds (#9387) 2025-05-05 11:34:40 +02:00
e19fd1332a feat: Precise highlights for bindings (#9472) 2025-05-05 09:51:20 +02:00
6e655cdb24 fix: When moving a frame through the stats inputs or drags move along its children (#9433)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-02 17:07:17 +02:00
192c4e7658 docs: added shape cycling shortcut in helper dialog (#9465)
* docs: added shape cycling shortcut in helper dialog

- Document Tab and Shift+Tab usage for shape cycling

* docs: added shape cycling shortcut in helper dialog

* Update packages/excalidraw/components/HelpDialog.tsx

* Update packages/excalidraw/locales/en.json

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-05-01 12:12:45 +02:00
195a743874 feat: switch between basic shapes (#9270)
* feat: switch between basic shapes

* add tab for testing

* style tweaks

* only show hint when a new node is created

* fix panel state

* refactor

* combine captures into one

* keep original font size

* switch multi

* switch different types altogether

* use tab only

* fix font size atom

* do not switch from active tool change

* prefer generic when mixed

* provide an optional direction when shape switching

* adjust panel bg & shadow

* redraw to correctly position text

* remove redundant code

* only tab to switch if focusing on app container

* limit which linear elements can be switched

* add shape switch to command palette

* remove hint

* cache initial panel position

* bend line to elbow if needed

* remove debug logic

* clean switch of arrows using app state

* safe conversion between line, sharp, curved, and elbow

* cache linear when panel shows up

* type safe element conversion

* rename type

* respect initial type when switching between linears

* fix elbow segment indexing

* use latest linear

* merge converted elbow points if too close

* focus on panel after click

* set roudness to null to fix drag points offset for elbows

* remove Mutable

* add arrowBoundToElement check

* make it dependent on one signle state

* unmount when not showing

* simpler types, tidy up code

* can change linear when it's linear + non-generic

* fix popup component lifecycle

* move constant to CLASSES

* DRY out type detection

* file & variable renaming

* refactor

* throw in not-prod instead

* simplify

* semi-fix bindings on `generic` type conversion

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-30 18:07:31 +02:00
4a60fe3d22 fix: remove noreferrer on internal links (#9452)
* fix: remove `noreferrer` on internal links

* fix snaps

* fix lint
2025-04-29 18:45:17 +02:00
2a0d15799c fix: when dragging arrow endpoint, update binding only on the dragged side (#9367) 2025-04-25 10:46:58 +02:00
a18b139a60 fix: laser pointer trail disappearing on pointerup (#9413) (#9427)
* Fix laser pointer trail disappearing on pointerup (#9413)

Previously, the laser pointer trail would disappear as soon as the pointerup event was triggered. This fix delays the trail removal to ensure it persists for a smoother visual experience.

Fixes #9413.

* Remove extra blank lines

Minor formatting cleanup. No functional changes.
2025-04-24 10:05:08 +10:00
1913599594 refactor: remove dependency on the (static) Scene (#9389) 2025-04-23 13:45:08 +02:00
233 changed files with 26557 additions and 22594 deletions

View File

@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/

View File

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

45
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,45 @@
# Project coding standards
## Generic Communication Guidelines
- Be succint and be aware that expansive generative AI answers are costly and slow
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
- Stop apologising if corrected, just provide the correct information or code
- Prefer code unless asked for explanation
- Stop summarizing what you've changed after modifications unless asked for
## TypeScript Guidelines
- Use TypeScript for all new code
- Where possible, prefer implementations without allocation
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
- Prefer immutable data (const, readonly)
- Use optional chaining (?.) and nullish coalescing (??) operators
## React Guidelines
- Use functional components with hooks
- Follow the React hooks rules (no conditional hooks)
- Keep components small and focused
- Use CSS modules for component styling
## Naming Conventions
- Use PascalCase for component names, interfaces, and type aliases
- Use camelCase for variables, functions, and methods
- Use ALL_CAPS for constants
## Error Handling
- Use try/catch blocks for async operations
- Implement proper error boundaries in React components
- Always log errors with contextual information
## Testing
- Always attempt to fix #problems
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
## Types
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}

3
.gitignore vendored
View File

@ -25,4 +25,5 @@ packages/excalidraw/types
coverage
dev-dist
html
meta*.json
meta*.json
.claude

34
CLAUDE.md Normal file
View File

@ -0,0 +1,34 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration

View File

@ -34,6 +34,9 @@
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
@ -63,7 +66,7 @@ The Excalidraw editor (npm package) supports:
- 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support.
- 👅&nbsp;Localization (i18n) support.
- 🌐&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...

View File

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

View File

@ -47,10 +47,10 @@ import {
share,
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element/elementLink";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,

View File

@ -19,12 +19,9 @@ import {
throttleRAF,
} from "@excalidraw/common";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/element/typeChecks";
import { getVisibleSceneBounds } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
import { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";

View File

@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";

View File

@ -73,7 +73,7 @@ export const AIComponents = ({
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,

View File

@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>

View File

@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+

View File

@ -12,7 +12,7 @@ import {
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import type {

View File

@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import type {

View File

@ -9,14 +9,14 @@ import {
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } from "@excalidraw/common";
import type { UserIdleState } from "@excalidraw/common";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { SceneBounds } from "@excalidraw/element/bounds";
import type { SceneBounds } from "@excalidraw/element";
import type {
ExcalidrawElement,
FileId,

View File

@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noreferrer"
rel="noopener"
target="_blank"
>
<div

View File

@ -3,11 +3,15 @@ import {
createRedoAction,
createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
import { syncInvalidIndices } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element";
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
import ExcalidrawApp from "../App";
const { h } = window;
@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
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 () => {
await render(<ExcalidrawApp />);
const rect1Props = {
@ -122,7 +199,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
@ -154,7 +231,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
const redoAction = createRedoAction(h.history);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!

View File

@ -33,6 +33,7 @@
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
@ -78,8 +79,8 @@
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {

View File

@ -13,7 +13,7 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../common/dist/types/common/src/*.d.ts"
"types": "./dist/types/common/src/*.d.ts"
}
},
"files": [
@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -10,6 +10,7 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
@ -119,6 +120,7 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -142,6 +144,7 @@ export const FONT_FAMILY = {
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
Assistant: 10,
};
export const FONT_FAMILY_FALLBACKS = {
@ -253,7 +256,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const;
export const EXPORT_SOURCE =
export const getExportSource = () =>
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds
@ -474,3 +477,10 @@ export enum UserIdleState {
AWAY = "away",
IDLE = "idle",
}
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;

View File

@ -1,4 +1,4 @@
import type { UnsubscribeCallback } from "./types";
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
type Subscriber<T extends any[]> = (...payload: T) => void;

View File

@ -22,8 +22,10 @@ export interface FontMetadata {
};
/** flag to indicate a deprecated font */
deprecated?: true;
/** flag to indicate a server-side only font */
serverSide?: true;
/**
* whether this is a font that users can use (= shown in font picker)
*/
private?: true;
/** flag to indiccate a local-only font */
local?: true;
/** flag to indicate a fallback font */
@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
unitsPerEm: 1000,
ascender: 1011,
descender: -353,
lineHeight: 1.35,
lineHeight: 1.25,
},
},
[FONT_FAMILY["Lilita One"]]: {
@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -434,
lineHeight: 1.15,
},
serverSide: true,
private: true,
},
[FONT_FAMILY.Assistant]: {
metrics: {
unitsPerEm: 2048,
ascender: 1021,
descender: -287,
lineHeight: 1.25,
},
private: true,
},
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
metrics: {
unitsPerEm: 1000,
ascender: 880,
descender: -144,
lineHeight: 1.15,
lineHeight: 1.25,
},
fallback: true,
},

View File

@ -9,3 +9,4 @@ export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";

View File

@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types
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

@ -0,0 +1,82 @@
import {
isTransparent,
mapFind,
reduceToCommonValue,
} from "@excalidraw/common";
describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
expect(isTransparent("#fff00000")).toEqual(true);
expect(isTransparent("transparent")).toEqual(true);
});
it("should return false when color is not transparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});
describe("reduceToCommonValue()", () => {
it("should return the common value when all values are the same", () => {
expect(reduceToCommonValue([1, 1])).toEqual(1);
expect(reduceToCommonValue([0, 0])).toEqual(0);
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
expect(reduceToCommonValue([""])).toEqual("");
expect(reduceToCommonValue([0])).toEqual(0);
const o = {};
expect(reduceToCommonValue([o, o])).toEqual(o);
expect(
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
).toEqual(1);
expect(
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
).toEqual(1);
});
it("should return `null` when values are different", () => {
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
null,
);
});
it("should return `null` when some values are nullable", () => {
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
expect(reduceToCommonValue([null, 1])).toEqual(null);
expect(reduceToCommonValue([1, undefined])).toEqual(null);
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
expect(reduceToCommonValue([null])).toEqual(null);
expect(reduceToCommonValue([undefined])).toEqual(null);
expect(reduceToCommonValue([])).toEqual(null);
});
});
describe("mapFind()", () => {
it("should return the first mapped non-null element", () => {
{
let counter = 0;
const result = mapFind(["a", "b", "c"], (value) => {
counter++;
return value === "b" ? 42 : null;
});
expect(result).toEqual(42);
expect(counter).toBe(2);
}
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
expect(mapFind([1, 2], () => false)).toBe(false);
expect(mapFind([1, 2], () => "")).toBe("");
});
it("should return undefined if no mapped element is found", () => {
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
expect(mapFind([1, 2], () => null)).toBe(undefined);
});
});
});

View File

@ -544,6 +544,20 @@ export const findLastIndex = <T>(
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) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
@ -680,7 +694,7 @@ export const arrayToMap = <T extends { id: string } | string>(
return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
}, new Map());
}, new Map() as Map<string, T>);
};
export const arrayToMapWithIndex = <T extends { id: string }>(
@ -735,6 +749,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc;
}, [] 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 isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
@ -1225,11 +1258,39 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
};
export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
value:
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => {
return isReadonlyArray(value)
? value.length
: value instanceof Map
: 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

@ -13,7 +13,7 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../element/dist/types/element/src/*.d.ts"
"types": "./dist/types/element/src/*.d.ts"
}
},
"files": [
@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -6,21 +6,22 @@ import {
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
} from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element/selection";
import { getSelectedElements } from "@excalidraw/element";
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@ -33,12 +34,13 @@ import type {
Ordered,
} from "@excalidraw/element/types";
import type { Assert, SameType } from "@excalidraw/common/utility-types";
import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
import type { AppState } from "../../excalidraw/types";
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
@ -103,44 +105,7 @@ const hashSelectionOpts = (
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
export class Scene {
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
@ -199,6 +164,12 @@ class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements);
}
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
@ -293,9 +264,8 @@ class Scene {
}
replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements = isReadonlyArray(nextElements)
? nextElements
: Array.from(nextElements.values());
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
@ -307,7 +277,6 @@ class Scene {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
@ -352,12 +321,6 @@ class Scene {
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();
@ -454,6 +417,40 @@ class Scene {
// then, check if the id is a group
return getElementsInGroup(elementsMap, id);
};
}
export default Scene;
// Mutate an element with passed updates and trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
const { version: prevVersion } = element;
const { version: nextVersion } = mutateElement(
element,
elementsMap,
updates,
options,
);
if (
// skip if the element is not in the scene (i.e. selection)
this.elementsMap.has(element.id) &&
// skip if the element's version hasn't changed, as mutateElement returned the same element
prevVersion !== nextVersion &&
options.informMutation
) {
this.triggerUpdate();
}
return element;
}
}

View File

@ -1,12 +1,11 @@
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getMaximumGroups } from "./groups";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ElementsMap, ExcalidrawElement } from "./types";
import type { ExcalidrawElement } from "./types";
export interface Alignment {
position: "start" | "center" | "end";
@ -15,10 +14,10 @@ export interface Alignment {
export const alignElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements,
elementsMap,
@ -33,12 +32,13 @@ export const alignElements = (
);
return group.map((element) => {
// update element
const updatedEle = mutateElement(element, {
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
// update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedEle;

View File

@ -31,11 +31,9 @@ import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import {
getCenterForBounds,
@ -68,6 +66,8 @@ import {
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
import type {
@ -81,11 +81,10 @@ import type {
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
FixedPointBinding,
PointsPositionUpdates,
} from "./types";
export type SuggestedBinding =
@ -130,7 +129,6 @@ export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -142,7 +140,7 @@ export const bindOrUnbindLinearElement = (
"start",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
bindOrUnbindLinearElementEdge(
linearElement,
@ -151,7 +149,7 @@ export const bindOrUnbindLinearElement = (
"end",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -159,7 +157,7 @@ export const bindOrUnbindLinearElement = (
);
getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
mutateElement(element, {
scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
@ -177,7 +175,7 @@ const bindOrUnbindLinearElementEdge = (
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
// Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") {
@ -186,7 +184,7 @@ const bindOrUnbindLinearElementEdge = (
// null means break the bind, so nothing to consider here
if (bindableElement === null) {
const unbound = unbindLinearElement(linearElement, startOrEnd);
const unbound = unbindLinearElement(linearElement, startOrEnd, scene);
if (unbound != null) {
unboundFromElementIds.add(unbound);
}
@ -209,16 +207,11 @@ const bindOrUnbindLinearElementEdge = (
: startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id)
) {
bindLinearElement(
linearElement,
bindableElement,
startOrEnd,
elementsMap,
);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
} else {
bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
};
@ -283,15 +276,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom,
)
: null // If binding is disabled and start is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
elements,
zoom,
)
: "keep";
const end = endDragged
? isBindingEnabled
@ -303,15 +287,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom,
)
: null // If binding is disabled and end is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
elements,
zoom,
)
: "keep";
return [start, end];
@ -362,11 +337,9 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
scene: Scene,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
@ -376,20 +349,20 @@ export const bindOrUnbindLinearElements = (
selectedElement,
isBindingEnabled,
draggingPoints ?? [],
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
selectedElement,
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
bindOrUnbindLinearElement(selectedElement, start, end, scene);
});
};
@ -429,15 +402,17 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
): void => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.startBoundElement != null) {
bindLinearElement(
linearElement,
appState.startBoundElement,
"start",
elementsMap,
scene,
);
}
@ -458,7 +433,7 @@ export const maybeBindLinearElement = (
"end",
)
) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
bindLinearElement(linearElement, hoveredElement, "end", scene);
}
}
};
@ -487,7 +462,7 @@ export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
if (!isArrowElement(linearElement)) {
return;
@ -500,7 +475,7 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
scene.getNonDeletedElementsMap(),
),
hoveredElement,
),
@ -513,18 +488,17 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
}
mutateElement(linearElement, {
scene.mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
mutateElement(hoveredElement, {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
@ -566,13 +540,14 @@ const isLinearElementSimple = (
const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
scene: Scene,
): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = linearElement[field];
if (binding == null) {
return null;
}
mutateElement(linearElement, { [field]: null });
scene.mutateElement(linearElement, { [field]: null });
return binding.elementId;
};
@ -735,25 +710,30 @@ const calculateFocusAndGap = (
// Supports translating, rotating and scaling `changedElement` with bound
// linear elements.
// Because scaling involves moving the focus points as well, it is
// done before the `changedElement` is updated, and the `newSize` is passed
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
changedElements?: Map<string, ExcalidrawElement>;
},
) => {
if (!isBindableElement(changedElement)) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
if (!isBindableElement(changedElement)) {
return;
let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
if (options?.changedElements) {
elementsMap = new Map(elementsMap) as typeof elementsMap;
options.changedElements.forEach((element) => {
elementsMap.set(element.id, element);
});
}
boundElementsVisitor(elementsMap, changedElement, (element) => {
@ -796,7 +776,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true);
scene.mutateElement(element, bindings);
return;
}
@ -822,48 +802,56 @@ export const updateBoundElements = (
bindableElement,
elementsMap,
);
if (point) {
return {
index:
bindingProp === "startBinding" ? 0 : element.points.length - 1,
point,
};
return [
bindingProp === "startBinding" ? 0 : element.points.length - 1,
{ point },
] as MapEntry<PointsPositionUpdates>;
}
}
return null;
},
).filter(
(
update,
): update is NonNullable<{
index: number;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
);
LinearElementEditor.movePoints(
element,
updates,
{
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
elementsMap as NonDeletedSceneElementsMap,
);
LinearElementEditor.movePoints(element, scene, new Map(updates), {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, elementsMap, false);
handleBindTextResize(element, scene, false);
}
});
};
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, {
...options,
changedElements: new Map([[latestElement.id, latestElement]]),
});
}
};
const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawLinearElement>,
changedElement: ExcalidrawBindableElement,
@ -885,7 +873,6 @@ export const getHeadingForElbowArrowSnap = (
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
@ -895,12 +882,7 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
if (!distance) {
return vectorToHeading(
@ -914,7 +896,6 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
@ -1185,6 +1166,48 @@ export const snapToMid = (
center,
angle,
);
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return p;
@ -1216,7 +1239,6 @@ const updateBoundPoint = (
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement);
const global = pointFrom<GlobalPoint>(
@ -1320,7 +1342,6 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@ -1486,8 +1507,12 @@ export const fixBindingsAfterDeletion = (
const elements = arrayToMap(sceneElements);
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, mutateElement);
BindableElement.unbindAffected(elements, element, mutateElement);
BoundElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
}
};

View File

@ -1,17 +1,17 @@
import rough from "roughjs/bin/rough";
import {
rescalePoints,
arrayToMap,
invariant,
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
pointFrom,
pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
@ -52,20 +52,20 @@ import {
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
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 = {
x: number;

View File

@ -5,41 +5,7 @@ import {
isDevEnv,
isShallowEqual,
isTestEnv,
toBrandedType,
} from "@excalidraw/common";
import {
BoundElement,
BindableElement,
bindingProperties,
updateBoundElements,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import {
getBoundTextElementId,
redrawTextBoundingBox,
} from "@excalidraw/element/textElement";
import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
import {
orderByFractionalIndex,
syncMovedIndices,
} from "@excalidraw/element/fractionalIndex";
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type {
ExcalidrawElement,
@ -52,16 +18,42 @@ import type {
SceneElementsMap,
} from "@excalidraw/element/types";
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
import { getObservedAppState } from "./store";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
import type {
AppState,
ObservedAppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
} from "./types";
} from "@excalidraw/excalidraw/types";
import { getObservedAppState } from "./store";
import {
BoundElement,
BindableElement,
bindingProperties,
updateBoundElements,
} from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement";
/**
* Represents the difference between two objects of the same type.
@ -72,7 +64,7 @@ import type {
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
*/
class Delta<T> {
export class Delta<T> {
private constructor(
public readonly deleted: Partial<T>,
public readonly inserted: Partial<T>,
@ -195,10 +187,12 @@ class Delta<T> {
return;
}
if (
typeof deleted[property] === "object" ||
typeof inserted[property] === "object"
) {
const isDeletedObject =
deleted[property] !== null && typeof deleted[property] === "object";
const isInsertedObject =
inserted[property] !== null && typeof inserted[property] === "object";
if (isDeletedObject || isInsertedObject) {
type RecordLike = Record<string, V | undefined>;
const deletedObject: RecordLike = deleted[property] ?? {};
@ -230,6 +224,9 @@ class Delta<T> {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
} else if (deleted[property] === inserted[property]) {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
}
@ -324,7 +321,7 @@ class Delta<T> {
}
/**
* Returns all the object1 keys that have distinct values.
* Returns sorted object1 keys that have distinct values.
*/
public static getLeftDifferences<T extends {}>(
object1: T,
@ -333,11 +330,11 @@ class Delta<T> {
) {
return Array.from(
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
);
).sort();
}
/**
* Returns all the object2 keys that have distinct values.
* Returns sorted object2 keys that have distinct values.
*/
public static getRightDifferences<T extends {}>(
object1: T,
@ -346,7 +343,7 @@ class Delta<T> {
) {
return Array.from(
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
);
).sort();
}
/**
@ -407,51 +404,57 @@ class Delta<T> {
}
/**
* Encapsulates the modifications captured as `Delta`/s.
* Encapsulates a set of application-level `Delta`s.
*/
interface Change<T> {
export interface DeltaContainer<T> {
/**
* Inverses the `Delta`s inside while creating a new `Change`.
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
*/
inverse(): Change<T>;
inverse(): DeltaContainer<T>;
/**
* Applies the `Change` to the previous object.
* Applies the `Delta`s to the previous object.
*
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
*/
applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Checks whether there are actually `Delta`s.
* Checks whether all `Delta`s are empty.
*/
isEmpty(): boolean;
}
export class AppStateChange implements Change<AppState> {
private constructor(private readonly delta: Delta<ObservedAppState>) {}
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
nextAppState: T,
): AppStateChange {
): AppStateDelta {
const delta = Delta.calculate(
prevAppState,
nextAppState,
undefined,
AppStateChange.postProcess,
// making the order of keys in deltas stable for hashing purposes
AppStateDelta.orderAppStateKeys,
AppStateDelta.postProcess,
);
return new AppStateChange(delta);
return new AppStateDelta(delta);
}
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
const { delta } = appStateDeltaDTO;
return new AppStateDelta(delta);
}
public static empty() {
return new AppStateChange(Delta.create({}, {}));
return new AppStateDelta(Delta.create({}, {}));
}
public inverse(): AppStateChange {
public inverse(): AppStateDelta {
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
return new AppStateChange(inversedDelta);
return new AppStateDelta(inversedDelta);
}
public applyTo(
@ -490,6 +493,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get(
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
@ -499,6 +503,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
@ -540,40 +545,6 @@ export class AppStateChange implements Change<AppState> {
return Delta.isEmpty(this.delta);
}
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
/**
* Mutates `nextAppState` be filtering out state related to deleted elements.
*
@ -590,13 +561,13 @@ export class AppStateChange implements Change<AppState> {
const nextObservedAppState = getObservedAppState(nextAppState);
const containsStandaloneDifference = Delta.isRightDifferent(
AppStateChange.stripElementsProps(prevObservedAppState),
AppStateChange.stripElementsProps(nextObservedAppState),
AppStateDelta.stripElementsProps(prevObservedAppState),
AppStateDelta.stripElementsProps(nextObservedAppState),
);
const containsElementsDifference = Delta.isRightDifferent(
AppStateChange.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState),
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
);
if (!containsStandaloneDifference && !containsElementsDifference) {
@ -611,8 +582,8 @@ export class AppStateChange implements Change<AppState> {
if (containsElementsDifference) {
// filter invisible changes on each iteration
const changedElementsProps = Delta.getRightDifferences(
AppStateChange.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState),
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
) as Array<keyof ObservedElementsAppState>;
let nonDeletedGroupIds = new Set<string>();
@ -629,7 +600,7 @@ export class AppStateChange implements Change<AppState> {
for (const key of changedElementsProps) {
switch (key) {
case "selectedElementIds":
nextAppState[key] = AppStateChange.filterSelectedElements(
nextAppState[key] = AppStateDelta.filterSelectedElements(
nextAppState[key],
nextElements,
visibleDifferenceFlag,
@ -637,7 +608,7 @@ export class AppStateChange implements Change<AppState> {
break;
case "selectedGroupIds":
nextAppState[key] = AppStateChange.filterSelectedGroups(
nextAppState[key] = AppStateDelta.filterSelectedGroups(
nextAppState[key],
nonDeletedGroupIds,
visibleDifferenceFlag,
@ -673,7 +644,7 @@ export class AppStateChange implements Change<AppState> {
break;
case "selectedLinearElementId":
case "editingLinearElementId":
const appStateKey = AppStateChange.convertToAppStateKey(key);
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) {
@ -692,6 +663,24 @@ export class AppStateChange implements Change<AppState> {
}
break;
case "lockedMultiSelections": {
const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {};
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true;
}
break;
}
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null;
if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true;
}
break;
}
default: {
assertNever(
key,
@ -787,6 +776,8 @@ export class AppStateChange implements Change<AppState> {
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
lockedMultiSelections,
activeLockedId,
...standaloneProps
} = delta as ObservedAppState;
@ -808,6 +799,63 @@ export class AppStateChange implements Change<AppState> {
ObservedElementsAppState
>;
}
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
);
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
const orderedPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial).sort()) {
// relying on insertion order
orderedPartial[key] = partial[key as keyof ObservedAppState];
}
return orderedPartial as Partial<ObservedAppState>;
}
}
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
@ -819,50 +867,63 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
* Elements change is a low level primitive to capture a change between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
*/
export class ElementsChange implements Change<SceneElementsMap> {
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private constructor(
private readonly added: Map<string, Delta<ElementPartial>>,
private readonly removed: Map<string, Delta<ElementPartial>>,
private readonly updated: Map<string, Delta<ElementPartial>>,
public readonly added: Record<string, Delta<ElementPartial>>,
public readonly removed: Record<string, Delta<ElementPartial>>,
public readonly updated: Record<string, Delta<ElementPartial>>,
) {}
public static create(
added: Map<string, Delta<ElementPartial>>,
removed: Map<string, Delta<ElementPartial>>,
updated: Map<string, Delta<ElementPartial>>,
options = { shouldRedistribute: false },
added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>,
options: {
shouldRedistribute: boolean;
} = {
shouldRedistribute: false,
},
) {
let change: ElementsChange;
let delta: ElementsDelta;
if (options.shouldRedistribute) {
const nextAdded = new Map<string, Delta<ElementPartial>>();
const nextRemoved = new Map<string, Delta<ElementPartial>>();
const nextUpdated = new Map<string, Delta<ElementPartial>>();
const nextAdded: Record<string, Delta<ElementPartial>> = {};
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
const deltas = [...added, ...removed, ...updated];
const deltas = [
...Object.entries(added),
...Object.entries(removed),
...Object.entries(updated),
];
for (const [id, delta] of deltas) {
if (this.satisfiesAddition(delta)) {
nextAdded.set(id, delta);
nextAdded[id] = delta;
} else if (this.satisfiesRemoval(delta)) {
nextRemoved.set(id, delta);
nextRemoved[id] = delta;
} else {
nextUpdated.set(id, delta);
nextUpdated[id] = delta;
}
}
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
} else {
change = new ElementsChange(added, removed, updated);
delta = new ElementsDelta(added, removed, updated);
}
if (isTestEnv() || isDevEnv()) {
ElementsChange.validate(change, "added", this.satisfiesAddition);
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
}
return change;
return delta;
}
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
const { added, removed, updated } = elementsDeltaDTO;
return ElementsDelta.create(added, removed, updated);
}
private static satisfiesAddition = ({
@ -884,17 +945,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static validate(
change: ElementsChange,
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean,
) {
for (const [id, delta] of change[type].entries()) {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) {
console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`,
delta,
);
throw new Error(`ElementsChange invariant broken for element "${id}".`);
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
}
}
}
@ -905,19 +966,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements.
*
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends OrderedExcalidrawElement>(
prevElements: Map<string, T>,
nextElements: Map<string, T>,
): ElementsChange {
): ElementsDelta {
if (prevElements === nextElements) {
return ElementsChange.empty();
return ElementsDelta.empty();
}
const added = new Map<string, Delta<ElementPartial>>();
const removed = new Map<string, Delta<ElementPartial>>();
const updated = new Map<string, Delta<ElementPartial>>();
const added: Record<string, Delta<ElementPartial>> = {};
const removed: Record<string, Delta<ElementPartial>> = {};
const updated: Record<string, Delta<ElementPartial>> = {};
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
for (const prevElement of prevElements.values()) {
@ -930,10 +991,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create(
deleted,
inserted,
ElementsChange.stripIrrelevantProps,
ElementsDelta.stripIrrelevantProps,
);
removed.set(prevElement.id, delta);
removed[prevElement.id] = delta;
}
}
@ -950,10 +1011,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create(
deleted,
inserted,
ElementsChange.stripIrrelevantProps,
ElementsDelta.stripIrrelevantProps,
);
added.set(nextElement.id, delta);
added[nextElement.id] = delta;
continue;
}
@ -962,8 +1023,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.calculate<ElementPartial>(
prevElement,
nextElement,
ElementsChange.stripIrrelevantProps,
ElementsChange.postProcess,
ElementsDelta.stripIrrelevantProps,
ElementsDelta.postProcess,
);
if (
@ -974,9 +1035,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
// notice that other props could have been updated as well
if (prevElement.isDeleted && !nextElement.isDeleted) {
added.set(nextElement.id, delta);
added[nextElement.id] = delta;
} else {
removed.set(nextElement.id, delta);
removed[nextElement.id] = delta;
}
continue;
@ -984,24 +1045,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated.set(nextElement.id, delta);
updated[nextElement.id] = delta;
}
}
}
return ElementsChange.create(added, removed, updated);
return ElementsDelta.create(added, removed, updated);
}
public static empty() {
return ElementsChange.create(new Map(), new Map(), new Map());
return ElementsDelta.create({}, {}, {});
}
public inverse(): ElementsChange {
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
public inverse(): ElementsDelta {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) {
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
}
return inversedDeltas;
@ -1012,14 +1073,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
const updated = inverseInternal(this.updated);
// notice we inverse removed with added not to break the invariants
return ElementsChange.create(removed, added, updated);
return ElementsDelta.create(removed, added, updated);
}
public isEmpty(): boolean {
return (
this.added.size === 0 &&
this.removed.size === 0 &&
this.updated.size === 0
Object.keys(this.added).length === 0 &&
Object.keys(this.removed).length === 0 &&
Object.keys(this.updated).length === 0
);
}
@ -1030,7 +1091,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
public applyLatestChanges(
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {};
@ -1051,11 +1115,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
};
const applyLatestChangesInternal = (
deltas: Map<string, Delta<ElementPartial>>,
deltas: Record<string, Delta<ElementPartial>>,
) => {
const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) {
for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id);
if (existingElement) {
@ -1063,12 +1127,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
delta.deleted,
delta.inserted,
modifier(existingElement),
"inserted",
modifierOptions,
);
modifiedDeltas.set(id, modifiedDelta);
modifiedDeltas[id] = modifiedDelta;
} else {
modifiedDeltas.set(id, delta);
modifiedDeltas[id] = delta;
}
}
@ -1079,16 +1143,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
const removed = applyLatestChangesInternal(this.removed);
const updated = applyLatestChangesInternal(this.updated);
return ElementsChange.create(added, removed, updated, {
return ElementsDelta.create(added, removed, updated, {
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
});
}
public applyTo(
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = {
@ -1098,15 +1162,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try {
const applyDeltas = ElementsChange.createApplier(
const applyDeltas = ElementsDelta.createApplier(
nextElements,
snapshot,
elementsSnapshot,
flags,
);
const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated);
const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas("updated", this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1118,7 +1182,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
...affectedElements,
]);
} catch (e) {
console.error(`Couldn't apply elements change`, e);
console.error(`Couldn't apply elements delta`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
@ -1132,19 +1196,22 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsChange.reorderElements(
nextElements = ElementsDelta.reorderElements(
nextElements,
changedElements,
flags,
);
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@ -1159,36 +1226,42 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
}
private static createApplier = (
nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) => {
const getElement = ElementsChange.createGetter(
nextElements,
snapshot,
flags,
);
private static createApplier =
(
nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter(
type,
nextElements,
snapshot,
flags,
);
return (deltas: Map<string, Delta<ElementPartial>>) =>
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
return Object.entries(deltas).reduce((acc, [id, delta]) => {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsChange.applyDelta(element, delta, flags);
const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
return acc;
}, new Map<string, OrderedExcalidrawElement>());
};
};
private static createGetter =
(
type: "added" | "removed" | "updated",
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
@ -1214,6 +1287,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
flags.containsVisibleDifference = true;
}
} else {
// not in elements, not in snapshot? element might have been added remotely!
element = newElementWith(
{ id, version: 1 } as OrderedExcalidrawElement,
{
...partial,
},
);
}
}
@ -1250,7 +1331,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
// TODO: this looks wrong, shouldn't be here
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
@ -1263,10 +1345,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference =
ElementsChange.checkForVisibleDifference(element, rest);
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference;
}
@ -1309,6 +1393,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
* Resolves conflicts for all previously added, removed and updated elements.
* Updates the previous deltas with all the changes after conflict resolution.
*
* // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
*
* @returns all elements affected by the conflict resolution
*/
private resolveConflicts(
@ -1337,6 +1423,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
} else {
affectedElement = mutateElement(
nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>,
);
}
@ -1346,20 +1433,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
};
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
for (const [id] of this.removed) {
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
for (const id of Object.keys(this.removed)) {
ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
}
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
for (const [id] of this.added) {
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
for (const id of Object.keys(this.added)) {
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
}
// updated delta is affecting the binding only in case it contains changed binding or bindable property
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
bindingProperties.has(prop as BindingProp | BindableProp),
),
for (const [id] of Array.from(Object.entries(this.updated)).filter(
([_, delta]) =>
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
bindingProperties.has(prop as BindingProp | BindableProp),
),
)) {
const updatedElement = nextElements.get(id);
if (!updatedElement || updatedElement.isDeleted) {
@ -1367,7 +1455,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue;
}
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
}
// filter only previous elements, which were now affected
@ -1377,21 +1465,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
// calculate complete deltas for affected elements, and assign them back to all the deltas
// technically we could do better here if perf. would become an issue
const { added, removed, updated } = ElementsChange.calculate(
const { added, removed, updated } = ElementsDelta.calculate(
prevAffectedElements,
nextAffectedElements,
);
for (const [id, delta] of added) {
this.added.set(id, delta);
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of removed) {
this.removed.set(id, delta);
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of updated) {
this.updated.set(id, delta);
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements;
@ -1456,9 +1544,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
private static redrawTextBoundingBoxes(
elements: SceneElementsMap,
scene: Scene,
changed: Map<string, OrderedExcalidrawElement>,
) {
const elements = scene.getNonDeletedElementsMap();
const boxesToRedraw = new Map<
string,
{ container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
@ -1498,17 +1587,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue;
}
redrawTextBoundingBox(boundText, container, elements, false);
redrawTextBoundingBox(boundText, container, scene);
}
}
private static redrawBoundArrows(
elements: SceneElementsMap,
scene: Scene,
changed: Map<string, OrderedExcalidrawElement>,
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, {
updateBoundElements(element, scene, {
changedElements: changed,
});
}
@ -1563,7 +1652,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
} catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements change deltas.`);
console.error(`Couldn't postprocess elements delta.`);
if (isTestEnv() || isDevEnv()) {
throw e;
@ -1576,8 +1665,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
partial;
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}

View File

@ -11,13 +11,10 @@ import type {
PointerDownState,
} from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements";
@ -29,6 +26,8 @@ import {
isTextElement,
} from "./typeChecks";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
@ -104,7 +103,7 @@ export const dragSelectedElements = (
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
@ -112,9 +111,14 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(),
);
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}
@ -155,6 +159,7 @@ const calculateOffset = (
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number },
) => {
const originalElement =
@ -163,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, {
scene.mutateElement(element, {
x: nextX,
y: nextY,
});
@ -190,6 +195,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio,
shouldResizeFromCenter,
zoom,
scene,
widthAspectRatio = null,
originOffset = null,
informMutation = true,
@ -205,6 +211,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null;
@ -285,7 +292,7 @@ export const dragNewElement = ({
};
}
mutateElement(
scene.mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
@ -295,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize,
...imageInitialDimension,
},
informMutation,
{ informMutation, isDragging: false },
);
}
};

View File

@ -50,7 +50,6 @@ import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
@ -887,7 +886,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap,
updates: {
points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null;
fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
@ -975,6 +974,25 @@ export const updateElbowArrowPoints = (
),
"Elbow arrow segments must be either horizontal or vertical",
);
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
}
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
@ -1273,14 +1291,12 @@ const getElbowArrowData = (
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
origEndGlobalPoint,
);
@ -2250,7 +2266,6 @@ const getGlobalPoint = (
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
): Heading =>
@ -2268,7 +2283,6 @@ const getBindPointHeading = (
number,
],
),
elementsMap,
origPoint,
);

View File

@ -33,6 +33,8 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED =
/^<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
const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
@ -42,6 +44,8 @@ const RE_TWITTER_EMBED =
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_SLIDO = /^https:\/\/app\.sli\.do\/event\/([a-zA-Z0-9]+)/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@ -54,7 +58,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const ALLOWED_DOMAINS = new Set([
const ALLOWED_EMBED_HOSTS = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
@ -69,6 +73,10 @@ const ALLOWED_DOMAINS = new Set([
"val.town",
"giphy.com",
"reddit.com",
"forms.microsoft.com",
"forms.gle",
"docs.google.com/forms",
"app.sli.do",
]);
const ALLOW_SAME_ORIGIN = new Set([
@ -82,6 +90,10 @@ const ALLOW_SAME_ORIGIN = new Set([
"*.simplepdf.eu",
"stackblitz.com",
"reddit.com",
"forms.microsoft.com",
"forms.gle",
"docs.google.com",
"app.sli.do",
]);
export const createSrcDoc = (body: string) => {
@ -206,6 +218,10 @@ export const getEmbedLink = (
};
}
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
link += link.includes("?") ? "&embed=true" : "?embed=true";
}
if (RE_TWITTER.test(link)) {
const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only.
@ -270,6 +286,23 @@ export const getEmbedLink = (
return ret;
}
if (RE_SLIDO.test(link)) {
const [, eventId] = link.match(RE_SLIDO)!;
link = `https://app.sli.do/event/${eventId}`;
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
embeddedLinkCache.set(link, {
link,
intrinsicSize: aspectRatio,
@ -327,20 +360,29 @@ const matchHostname = (
allowedHostnames: Set<string> | string,
): string | null => {
try {
const { hostname } = new URL(url);
const { hostname, pathname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, "");
if (allowedHostnames instanceof Set) {
if (ALLOWED_DOMAINS.has(bareDomain)) {
// Check for exact domain match
if (ALLOWED_EMBED_HOSTS.has(bareDomain)) {
return bareDomain;
}
// Check for path-based match (e.g., docs.google.com/forms)
const domainWithPath = `${bareDomain}${
pathname.split("/")[1] ? `/${pathname.split("/")[1]}` : ""
}`;
if (ALLOWED_EMBED_HOSTS.has(domainWithPath)) {
return domainWithPath;
}
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
if (ALLOWED_EMBED_HOSTS.has(bareDomainWithFirstSubdomainWildcarded)) {
return bareDomainWithFirstSubdomainWildcarded;
}
return null;
@ -391,6 +433,7 @@ export const embeddableURLValidator = (
if (!url) {
return false;
}
if (validateEmbeddable != null) {
if (typeof validateEmbeddable === "function") {
const ret = validateEmbeddable(url);
@ -416,5 +459,5 @@ export const embeddableURLValidator = (
}
}
return !!matchHostname(url, ALLOWED_DOMAINS);
return !!matchHostname(url, ALLOWED_EMBED_HOSTS);
};

View File

@ -39,6 +39,8 @@ import {
type OrderedExcalidrawElement,
} from "./types";
import type { Scene } from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
@ -236,10 +238,11 @@ const getOffsets = (
const addNewNode = (
element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const successors = getSuccessors(element, elementsMap, direction);
const predeccessors = getPredecessors(element, elementsMap, direction);
@ -274,9 +277,9 @@ const addNewNode = (
const bindingArrow = createBindingArrow(
element,
nextNode,
elementsMap,
direction,
appState,
scene,
);
return {
@ -287,9 +290,9 @@ const addNewNode = (
export const addNewNodes = (
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
numberOfNodes: number,
) => {
// always start from 0 and distribute evenly
@ -352,9 +355,9 @@ export const addNewNodes = (
const bindingArrow = createBindingArrow(
startNode,
nextNode,
elementsMap,
direction,
appState,
scene,
);
newNodes.push(nextNode);
@ -367,9 +370,9 @@ export const addNewNodes = (
const createBindingArrow = (
startBindingElement: ExcalidrawFlowchartNodeElement,
endBindingElement: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
direction: LinkDirection,
appState: AppState,
scene: Scene,
) => {
let startX: number;
let startY: number;
@ -440,18 +443,10 @@ const createBindingArrow = (
elbowed: true,
});
bindLinearElement(
bindingArrow,
startBindingElement,
"start",
elementsMap as NonDeletedSceneElementsMap,
);
bindLinearElement(
bindingArrow,
endBindingElement,
"end",
elementsMap as NonDeletedSceneElementsMap,
);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
@ -467,12 +462,18 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(bindingArrow, [
{
index: 1,
point: bindingArrow.points[1],
},
]);
LinearElementEditor.movePoints(
bindingArrow,
scene,
new Map([
[
1,
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints(
bindingArrow,
@ -632,16 +633,17 @@ export class FlowChartCreator {
createNodes(
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
if (direction !== this.direction) {
const { nextNode, bindingArrow } = addNewNode(
startNode,
elementsMap,
appState,
direction,
scene,
);
this.numberOfNodes = 1;
@ -652,9 +654,9 @@ export class FlowChartCreator {
this.numberOfNodes += 1;
const newNodes = addNewNodes(
startNode,
elementsMap,
appState,
direction,
scene,
this.numberOfNodes,
);
@ -682,13 +684,9 @@ export class FlowChartCreator {
)
) {
this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement(
node,
{
frameId: startNode.frameId,
},
false,
),
mutateElement(node, elementsMap, {
frameId: startNode.frameId,
}),
);
}
}

View File

@ -7,6 +7,7 @@ import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
OrderedExcalidrawElement,
@ -152,9 +153,10 @@ export const orderByFractionalIndex = (
*/
export const syncMovedIndices = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
movedElements: ElementsMap,
): OrderedExcalidrawElement[] => {
try {
const elementsMap = arrayToMap(elements);
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
// try generatating indices, throws on invalid movedElements
@ -176,7 +178,7 @@ export const syncMovedIndices = (
// split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
mutateElement(element, elementsMap, update);
}
} catch (e) {
// fallback to default sync
@ -194,10 +196,12 @@ export const syncMovedIndices = (
export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[],
): OrderedExcalidrawElement[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
mutateElement(element, elementsMap, update);
}
return elements as OrderedExcalidrawElement[];
@ -210,7 +214,7 @@ export const syncInvalidIndices = (
*/
const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
movedElements: ElementsMap,
) => {
const indicesGroups: number[][] = [];

View File

@ -3,8 +3,6 @@ import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
import type {
AppClassProperties,
AppState,
@ -29,6 +27,8 @@ import {
isTextElement,
} from "./typeChecks";
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
import type {
ElementsMap,
ElementsMapOrArray,
@ -56,13 +56,9 @@ export const bindElementsToFramesAfterDuplication = (
const nextFrameId = origIdToDuplicateId.get(element.frameId);
const nextElement = nextElementId && nextElementMap.get(nextElementId);
if (nextElement) {
mutateElement(
nextElement,
{
frameId: nextFrameId ?? null,
},
false,
);
mutateElement(nextElement, nextElementMap, {
frameId: nextFrameId ?? null,
});
}
}
}
@ -565,13 +561,9 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
return allElements;
@ -609,13 +601,9 @@ export const removeElementsFromFrame = (
}
for (const [, element] of _elementsToRemove) {
mutateElement(
element,
{
frameId: null,
},
false,
);
mutateElement(element, elementsMap, {
frameId: null,
});
}
};
@ -917,13 +905,16 @@ export const shouldApplyFrameClip = (
return false;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
const DEFAULT_FRAME_NAME = "Frame";
const DEFAULT_AI_FRAME_NAME = "AI Frame";
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
// TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
? "Frame"
: "AI Frame"
: element.name;
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
return element.name === null ? getDefaultFrameName(element) : element.name;
};
export const getElementsOverlappingFrame = (

View File

@ -1,3 +1,5 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
@ -5,6 +7,7 @@ import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ElementsMapOrArray,
} from "./types";
/**
@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/
export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
let hash = 5381;
for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + elements[i].versionNonce;
for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + element.versionNonce;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
@ -71,3 +72,47 @@ export const clearElementsForExport = (
export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[],
) => _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,11 +20,7 @@ import {
tupleToCoors,
} 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 { Store } from "@excalidraw/excalidraw/store";
import type { Store } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
@ -50,10 +46,8 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isBindingElement,
@ -69,10 +63,15 @@ import {
getControlPointsForBezierCurve,
mapIntervalToBezierT,
getBezierXY,
toggleLinePolygonState,
} from "./shapes";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import { isLineElement } from "./typeChecks";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type {
NonDeleted,
@ -84,16 +83,40 @@ import type {
ElementsMap,
NonDeletedSceneElementsMap,
FixedPointBinding,
SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
PointsPositionUpdates,
} from "./types";
const editorMidPointsCache: {
version: number | null;
points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
/**
* Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase.
*
* Also returns the offsets - [0,0] if no normalization needed.
*
* @private
*/
const getNormalizedPoints = ({
points,
}: {
points: ExcalidrawLinearElement["points"];
}): {
points: LocalPoint[];
offsetX: number;
offsetY: number;
} => {
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((p) => {
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
offsetX,
offsetY,
};
};
export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
@ -126,16 +149,23 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
constructor(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element);
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
);
}
this.selectedPointsIndices = null;
this.lastUncommittedPoint = null;
this.isDragging = false;
@ -157,6 +187,7 @@ export class LinearElementEditor {
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
}
// ---------------------------------------------------------------------------
@ -260,6 +291,7 @@ export class LinearElementEditor {
const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) {
return null;
}
@ -300,6 +332,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0];
const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
customLineAngle =
linearElementEditor.customLineAngle ??
Math.atan2(
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
@ -307,18 +345,25 @@ export class LinearElementEditor {
referencePoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
);
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
]);
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
selectedIndex,
{
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
@ -333,32 +378,39 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
return {
index: pointIndex,
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
};
}),
scene,
new Map(
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize(),
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
),
);
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
handleBindTextResize(element, elementsMap, false);
handleBindTextResize(element, scene, false);
}
// suggest bindings for first and last point if selected
@ -415,6 +467,7 @@ export class LinearElementEditor {
? lastClickedPoint
: -1,
isDragging: true,
customLineAngle,
};
}
@ -453,15 +506,33 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, [
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
]);
if (isLineElement(element)) {
scene.mutateElement(
element,
{
...toggleLinePolygonState(element, true),
},
{
informMutation: false,
isDragging: false,
},
);
}
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
selectedPoint,
{
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
],
]),
);
}
const bindingElement = isBindingEnabled(appState)
@ -491,6 +562,8 @@ export class LinearElementEditor {
return {
...editingLinearElement,
...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift,
// toggle the point.
@ -512,6 +585,7 @@ export class LinearElementEditor {
: selectedPointsIndices,
isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
};
}
@ -519,7 +593,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
): typeof editorMidPointsCache["points"] => {
): (GlobalPoint | null)[] => {
const boundText = getBoundTextElement(element, elementsMap);
// Since its not needed outside editor unless 2 pointer lines or bound text
@ -531,25 +605,7 @@ export class LinearElementEditor {
) {
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(
element,
elementsMap,
@ -581,9 +637,8 @@ export class LinearElementEditor {
midpoints.push(segmentMidPoint);
index++;
}
editorMidPointsCache.points = midpoints;
editorMidPointsCache.version = element.version;
editorMidPointsCache.zoom = appState.zoom.value;
return midpoints;
};
static getSegmentMidpointHitCoords = (
@ -637,8 +692,11 @@ export class LinearElementEditor {
}
}
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
const midPoints = LinearElementEditor.getEditorMidPoints(
element,
elementsMap,
appState,
);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
@ -795,7 +853,7 @@ export class LinearElementEditor {
);
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
scene.mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
@ -809,7 +867,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.shouldCaptureIncrement();
store.scheduleCapture();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
@ -861,7 +919,6 @@ export class LinearElementEditor {
element,
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
@ -934,13 +991,13 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.editingLinearElement;
@ -951,7 +1008,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]);
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
}
return {
...appState.editingLinearElement,
@ -989,14 +1046,20 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
LinearElementEditor.movePoints(
element,
app.scene,
new Map([
[
element.points.length - 1,
{
point: newPoint,
},
],
]),
);
} else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.editingLinearElement,
@ -1139,23 +1202,16 @@ export class LinearElementEditor {
/**
* Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
* expected in various parts of the codebase.
*
* Also returns normalized x and y coords to account for the normalization
* of the points.
*/
static getNormalizedPoints(element: ExcalidrawLinearElement): {
points: LocalPoint[];
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
const { points, offsetX, offsetY } = getNormalizedPoints(element);
return {
points: points.map((p) => {
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
points,
x: element.x + offsetX,
y: element.y + offsetY,
};
@ -1163,20 +1219,13 @@ export class LinearElementEditor {
// element-mutating methods
// ---------------------------------------------------------------------------
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
}
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): AppState {
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant(
appState.editingLinearElement,
"Not currently editing a linear element",
);
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
@ -1219,18 +1268,22 @@ export class LinearElementEditor {
return acc;
}, []);
mutateElement(element, { points: nextPoints });
scene.mutateElement(element, { points: nextPoints });
// temp hack to ensure the line doesn't move when adding point to the end,
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
]);
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
element.points.length - 1,
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
],
]),
);
}
return {
@ -1244,82 +1297,127 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties,
pointIndices: readonly number[],
) {
let offsetX = 0;
let offsetY = 0;
const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const isDeletingOriginPoint = pointIndices.includes(0);
const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
});
// if deleting first point, make the next to be [0,0] and recalculate
// positions of the rest with respect to it
if (isDeletingOriginPoint) {
const firstNonDeletedPoint = element.points.find((point, idx) => {
return !pointIndices.includes(idx);
});
if (firstNonDeletedPoint) {
offsetX = firstNonDeletedPoint[0];
offsetY = firstNonDeletedPoint[1];
}
const isPolygon = isLineElement(element) && element.polygon;
// keep polygon intact if deleting start/end point or uncommitted point
if (
isPolygon &&
(isUncommittedPoint ||
pointIndices.includes(0) ||
pointIndices.includes(element.points.length - 1))
) {
nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
}
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
}, []);
const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
LinearElementEditor._updatePoints(
element,
app.scene,
normalizedPoints,
offsetX,
offsetY,
);
}
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
scene: Scene,
addedPoints: LocalPoint[],
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...addedPoints];
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
if (isLineElement(element) && element.polygon) {
nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
}
const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
LinearElementEditor._updatePoints(
element,
scene,
normalizedPoints,
offsetX,
offsetY,
);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
scene: Scene,
pointUpdates: PointsPositionUpdates,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
sceneElementsMap?: NonDeletedSceneElementsMap,
) {
const { points } = element;
// if polygon, move start and end points together
if (isLineElement(element) && element.polygon) {
const firstPointUpdate = pointUpdates.get(0);
const lastPointUpdate = pointUpdates.get(points.length - 1);
if (firstPointUpdate) {
pointUpdates.set(points.length - 1, {
point: pointFrom(
firstPointUpdate.point[0],
firstPointUpdate.point[1],
),
isDragging: firstPointUpdate.isDragging,
});
} else if (lastPointUpdate) {
pointUpdates.set(0, {
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
isDragging: lastPointUpdate.isDragging,
});
}
}
// in case we're moving start point, instead of modifying its position
// which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user.
const [deltaX, deltaY] =
targetPoints.find(({ index }) => index === 0)?.point ??
pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0],
deltaY - points[0][1],
);
const updatedOriginPoint =
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = updatedOriginPoint;
const nextPoints = isElbowArrow(element)
? [
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
targetPoints.find((t) => t.index === points.length - 1)?.point ??
pointUpdates.get(0)?.point ?? points[0],
pointUpdates.get(points.length - 1)?.point ??
points[points.length - 1],
]
: points.map((p, idx) => {
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
const current = pointUpdates.get(idx)?.point ?? p;
return pointFrom<LocalPoint>(
current[0] - offsetX,
@ -1329,17 +1427,13 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
otherUpdates,
{
isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
sceneElementsMap,
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
},
);
}
@ -1394,8 +1488,9 @@ export class LinearElementEditor {
pointerCoords: PointerCoords,
app: AppClassProperties,
snapToGrid: boolean,
elementsMap: ElementsMap,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
@ -1425,9 +1520,7 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
scene.mutateElement(element, { points });
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
@ -1443,6 +1536,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
@ -1479,29 +1573,12 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
if (!options?.sceneElementsMap || Scene.getScene(element)) {
mutateElement(element, updates, true, {
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);
scene.mutateElement(element, updates, {
informMutation: true,
isDragging: options?.isDragging ?? false,
});
} else {
// TODO do we need to get precise coords here just to calc centers?
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@ -1510,16 +1587,16 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads(
const rotatedOffset = pointRotateRads(
pointFrom(offsetX, offsetY),
pointFrom(dX, dY),
element.angle,
);
mutateElement(element, {
scene.mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
y: element.y + rotated[1],
x: element.x + rotatedOffset[0],
y: element.y + rotatedOffset[1],
});
}
}
@ -1530,6 +1607,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
gridSize: NullableGridSize,
customLineAngle?: number,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
@ -1555,6 +1633,7 @@ export class LinearElementEditor {
referencePointCoords[1],
gridX,
gridY,
customLineAngle,
);
return pointRotateRads(
@ -1574,7 +1653,7 @@ export class LinearElementEditor {
elementsMap,
);
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
}
let x = 0;
let y = 0;
@ -1589,23 +1668,14 @@ export class LinearElementEditor {
y = midPoint[1] - boundTextElement.height / 2;
} else {
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;
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
}
@ -1781,8 +1851,9 @@ export class LinearElementEditor {
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
scene: Scene,
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
@ -1825,7 +1896,7 @@ export class LinearElementEditor {
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
scene.mutateElement(element, {
fixedSegments: nextFixedSegments,
});
@ -1859,14 +1930,14 @@ export class LinearElementEditor {
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
scene: Scene,
index: number,
): void {
mutateElement(element, {
scene.mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
}

View File

@ -2,13 +2,8 @@ import {
getSizeFromPoints,
randomInteger,
getUpdatedTimestamp,
toBrandedType,
} 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 { Mutable } from "@excalidraw/common/utility-types";
@ -16,35 +11,42 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce" | "updated"
>;
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
/**
* 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 same drawing.
*
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
*/
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
elementsMap: ElementsMap,
updates: ElementUpdate<TElement>,
informMutation = true,
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;
},
): TElement => {
) => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId, startBinding, endBinding } =
const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
if (
@ -55,10 +57,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
@ -68,16 +66,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
elementsMap as NonDeletedSceneElementsMap,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
options,
),
};
} else if (typeof points !== "undefined") {
@ -150,10 +141,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.triggerUpdate();
}
return element;
};

View File

@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
@ -44,8 +46,8 @@ import type {
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@ -458,9 +460,10 @@ export const newLinearElement = (
opts: {
type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
return {
const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
@ -469,6 +472,17 @@ export const newLinearElement = (
startArrowhead: null,
endArrowhead: null,
};
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
};
export const newArrowElement = <T extends boolean>(
@ -478,7 +492,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: T;
fixedSegments?: FixedSegment[] | null;
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts,
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>

View File

@ -351,12 +351,20 @@ const generateElementCanvas = (
export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
const IMAGE_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
const IMAGE_ERROR_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<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,8 +17,6 @@ import {
import type { GlobalPoint } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
@ -32,7 +30,6 @@ import {
getElementBounds,
} from "./bounds";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import {
getBoundTextElement,
getBoundTextElementId,
@ -60,6 +57,8 @@ import {
import { isInGroup } from "./groups";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type {
MaybeTransformHandleType,
@ -74,7 +73,6 @@ import type {
ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
ElementsMap,
SceneElementsMap,
ExcalidrawElbowArrowElement,
} from "./types";
@ -83,7 +81,6 @@ export const transformElements = (
originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
@ -93,31 +90,31 @@ export const transformElements = (
centerX: number,
centerY: number,
): boolean => {
const elementsMap = scene.getNonDeletedElementsMap();
if (selectedElements.length === 1) {
const [element] = selectedElements;
if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) {
rotateSingleElement(
element,
elementsMap,
scene,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
elementsMap,
scene,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
@ -129,8 +126,6 @@ export const transformElements = (
getNextSingleWidthAndHeightFromPointer(
latestElement,
origElement,
elementsMap,
originalElements,
transformHandleType,
pointerX,
pointerY,
@ -145,8 +140,8 @@ export const transformElements = (
nextHeight,
latestElement,
origElement,
elementsMap,
originalElements,
scene,
transformHandleType,
{
shouldMaintainAspectRatio,
@ -161,7 +156,6 @@ export const transformElements = (
rotateMultipleElements(
originalElements,
selectedElements,
elementsMap,
scene,
pointerX,
pointerY,
@ -210,13 +204,15 @@ export const transformElements = (
const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
element,
scene.getNonDeletedElementsMap(),
);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: Radians;
@ -233,13 +229,13 @@ const rotateSingleElement = (
}
const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
scene.mutateElement(element, { angle });
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle });
scene.mutateElement(textElement, { angle });
}
}
};
@ -289,12 +285,13 @@ export const measureFontSizeFromWidth = (
const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
scene: Scene,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
@ -393,7 +390,7 @@ const resizeSingleTextElement = (
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
scene.mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
@ -508,14 +505,13 @@ const resizeSingleTextElement = (
autoResize: false,
};
mutateElement(element, resizedElement);
scene.mutateElement(element, resizedElement);
}
};
const rotateMultipleElements = (
originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
@ -523,6 +519,7 @@ const rotateMultipleElements = (
centerX: number,
centerY: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (shouldRotateWithDiscreteAngle) {
@ -543,38 +540,30 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians,
);
if (isElbowArrow(element)) {
// Needed to re-route the arrow
mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
} else {
mutateElement(
element,
{
const updates = isElbowArrow(element)
? {
// Needed to re-route the arrow
points: getArrowLocalFixedPoints(element, elementsMap),
}
: {
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
}
};
updateBoundElements(element, elementsMap, {
scene.mutateElement(element, updates);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elements,
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}
}
}
@ -819,8 +808,8 @@ export const resizeSingleElement = (
nextHeight: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
handleDirection: TransformHandleDirection,
{
shouldInformMutation = true,
@ -833,6 +822,7 @@ export const resizeSingleElement = (
} = {},
) => {
let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
@ -932,7 +922,7 @@ export const resizeSingleElement = (
}
if ("scale" in latestElement && "scale" in origElement) {
mutateElement(latestElement, {
scene.mutateElement(latestElement, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
@ -967,32 +957,33 @@ export const resizeSingleElement = (
...rescaledPoints,
};
mutateElement(latestElement, updates, shouldInformMutation);
updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
});
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(
latestElement,
elementsMap,
scene,
handleDirection,
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
}
};
const getNextSingleWidthAndHeightFromPointer = (
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
pointerX: number,
pointerY: number,
@ -1527,27 +1518,24 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
mutateElement(element, update, false, {
scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
updateBoundElements(element, elementsMap as SceneElementsMap, {
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
mutateElement(
boundTextElement,
{
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
},
false,
);
handleBindTextResize(element, elementsMap, handleDirection, true);
scene.mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
});
handleBindTextResize(element, scene, handleDirection, true);
}
}

View File

@ -1,4 +1,4 @@
import { isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type {
AppState,
@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
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 = (
elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
@ -264,6 +245,7 @@ export const makeNextSelectedElementIds = (
const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
) => {
const linears = targetElements.filter(isLinearElement);
if (linears.length === 1) {
@ -274,7 +256,7 @@ const _getLinearElementEditor = (
);
if (onlySingleLinearSelected) {
return new LinearElementEditor(linear);
return new LinearElementEditor(linear, arrayToMap(allElements));
}
}
@ -287,7 +269,7 @@ export const getSelectionStateForElements = (
appState: AppState,
) => {
return {
selectedLinearElement: _getLinearElementEditor(targetElements),
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,

View File

@ -5,6 +5,7 @@ import {
ROUNDNESS,
invariant,
elementCenterPoint,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common";
import {
isPoint,
@ -35,10 +36,13 @@ import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import { canBecomePolygon } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawLineElement,
NonDeleted,
} from "./types";
@ -396,3 +400,47 @@ export const isPathALoop = (
}
return false;
};
export const toggleLinePolygonState = (
element: ExcalidrawLineElement,
nextPolygonState: boolean,
): {
polygon: ExcalidrawLineElement["polygon"];
points: ExcalidrawLineElement["points"];
} | null => {
const updatedPoints = [...element.points];
if (nextPolygonState) {
if (!canBecomePolygon(element.points)) {
return null;
}
const firstPoint = updatedPoints[0];
const lastPoint = updatedPoints[updatedPoints.length - 1];
const distance = Math.hypot(
firstPoint[0] - lastPoint[0],
firstPoint[1] - lastPoint[1],
);
if (
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
updatedPoints.length < 4
) {
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
} else {
updatedPoints[updatedPoints.length - 1] = pointFrom(
firstPoint[0],
firstPoint[1],
);
}
}
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
const ret = {
polygon: nextPolygonState,
points: updatedPoints,
};
return ret;
};

View File

@ -2,15 +2,28 @@ import {
SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types";
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements`
@ -18,8 +31,18 @@ export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2;
return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
}
return element.width === 0 && element.height === 0;
};
@ -135,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number,
x: number,
y: number,
customAngle?: number,
) => {
let width = x - originX;
let height = y - originY;
const lockedAngle =
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
const angle = Math.atan2(height, width) as Radians;
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) {
height = 0;
@ -170,41 +222,6 @@ export const getLockedLinearCursorAlignSize = (
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 = (
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
): {

View File

@ -0,0 +1,972 @@
import {
assertNever,
COLOR_PALETTE,
isDevEnv,
isTestEnv,
randomId,
Emitter,
toIterable,
} from "@excalidraw/common";
import type App from "@excalidraw/excalidraw/components/App";
import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
import { deepCopyElement } from "./duplicate";
import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
export const CaptureUpdateAction = {
/**
* Immediately undoable.
*
* Use for updates which should be captured.
* Should be used for most of the local updates, except ephemerals such as dragging or resizing.
*
* These updates will _immediately_ make it to the local undo / redo stacks.
*/
IMMEDIATELY: "IMMEDIATELY",
/**
* Never undoable.
*
* Use for updates which should never be recorded, such as remote updates
* or scene initialization.
*
* These updates will _never_ make it to the local undo / redo stacks.
*/
NEVER: "NEVER",
/**
* Eventually undoable.
*
* Use for updates which should not be captured immediately - likely
* exceptions which are part of some async multi-step process. Otherwise, all
* such updates would end up being captured with the next
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
* or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
*/
EVENTUALLY: "EVENTUALLY",
} as const;
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
type MicroActionsQueue = (() => void)[];
/**
* Store which captures the observed changes and emits them as `StoreIncrement` events.
*/
export class Store {
// internally used by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement]
>();
private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
private scheduledMicroActions: MicroActionsQueue = [];
private _snapshot = StoreSnapshot.empty();
public get snapshot() {
return this._snapshot;
}
public set snapshot(snapshot: StoreSnapshot) {
this._snapshot = snapshot;
}
constructor(private readonly app: App) {}
public scheduleAction(action: CaptureUpdateActionType) {
this.scheduledMacroActions.add(action);
this.satisfiesScheduledActionsInvariant();
}
/**
* Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
*/
// TODO: Suspicious that this is called so many places. Seems error-prone.
public scheduleCapture() {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
}
/**
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
*/
public scheduleMicroAction(
params:
| {
action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
action: typeof CaptureUpdateAction.IMMEDIATELY;
change: StoreChange;
delta: StoreDelta;
}
| {
action:
| typeof CaptureUpdateAction.NEVER
| typeof CaptureUpdateAction.EVENTUALLY;
change: StoreChange;
},
) {
const { action } = params;
let change: StoreChange;
if ("change" in params) {
change = params.change;
} else {
// immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching
const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(),
this.app.state,
);
const scheduledSnapshot = currentSnapshot.maybeClone(
action,
params.elements,
params.appState,
);
change = StoreChange.create(currentSnapshot, scheduledSnapshot);
}
const delta = "delta" in params ? params.delta : undefined;
this.scheduledMicroActions.push(() =>
this.processAction({
action,
change,
delta,
}),
);
}
/**
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
*
* @emits StoreIncrement
*/
public commit(
elements: SceneElementsMap | undefined,
appState: AppState | ObservedAppState | undefined,
): void {
// execute all scheduled micro actions first
// similar to microTasks, there can be many
this.flushMicroActions();
try {
// execute a single scheduled "macro" function
// similar to macro tasks, there can be only one within a single commit (loop)
const action = this.getScheduledMacroAction();
this.processAction({ action, elements, appState });
} finally {
this.satisfiesScheduledActionsInvariant();
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
this.scheduledMacroActions = new Set();
}
}
/**
* Clears the store instance.
*/
public clear(): void {
this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set();
}
/**
* Performs delta & change calculation and emits a durable increment.
*
* @emits StoreIncrement.
*/
private emitDurableIncrement(
snapshot: StoreSnapshot,
change: StoreChange | undefined = undefined,
delta: StoreDelta | undefined = undefined,
) {
const prevSnapshot = this.snapshot;
let storeChange: StoreChange;
let storeDelta: StoreDelta;
if (change) {
storeChange = change;
} else {
storeChange = StoreChange.create(prevSnapshot, snapshot);
}
if (delta) {
// we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta;
} else {
// calculate the deltas based on the previous and next snapshot
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
}
if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment);
}
}
/**
* Performs change calculation and emits an ephemeral increment.
*
* @emits EphemeralStoreIncrement
*/
private emitEphemeralIncrement(
snapshot: StoreSnapshot,
change: StoreChange | undefined = undefined,
) {
let storeChange: StoreChange;
if (change) {
storeChange = change;
} else {
const prevSnapshot = this.snapshot;
storeChange = StoreChange.create(prevSnapshot, snapshot);
}
const increment = new EphemeralIncrement(storeChange);
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(increment);
}
private applyChangeToSnapshot(change: StoreChange) {
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.applyChange(change);
if (prevSnapshot === nextSnapshot) {
return null;
}
return nextSnapshot;
}
/**
* Clones the snapshot if there are changes detected.
*/
private maybeCloneSnapshot(
action: CaptureUpdateActionType,
elements: SceneElementsMap | undefined,
appState: AppState | ObservedAppState | undefined,
) {
if (!elements && !appState) {
return null;
}
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
if (prevSnapshot === nextSnapshot) {
return null;
}
return nextSnapshot;
}
private flushMicroActions() {
for (const microAction of this.scheduledMicroActions) {
try {
microAction();
} catch (error) {
console.error(`Failed to execute scheduled micro action`, error);
}
}
this.scheduledMicroActions = [];
}
private processAction(
params:
| {
action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
action: CaptureUpdateActionType;
change: StoreChange;
delta: StoreDelta | undefined;
},
) {
const { action } = params;
// perf. optimisation, since "EVENTUALLY" does not update the snapshot,
// so if nobody is listening for increments, we don't need to even clone the snapshot
// as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
if (
action === CaptureUpdateAction.EVENTUALLY &&
!this.onStoreIncrementEmitter.subscribers.length
) {
return;
}
let nextSnapshot: StoreSnapshot | null;
if ("change" in params) {
nextSnapshot = this.applyChangeToSnapshot(params.change);
} else {
nextSnapshot = this.maybeCloneSnapshot(
action,
params.elements,
params.appState,
);
}
if (!nextSnapshot) {
// don't continue if there is not change detected
return;
}
const change = "change" in params ? params.change : undefined;
const delta = "delta" in params ? params.delta : undefined;
try {
switch (action) {
// only immediately emits a durable increment
case CaptureUpdateAction.IMMEDIATELY:
this.emitDurableIncrement(nextSnapshot, change, delta);
break;
// both never and eventually emit an ephemeral increment
case CaptureUpdateAction.NEVER:
case CaptureUpdateAction.EVENTUALLY:
this.emitEphemeralIncrement(nextSnapshot, change);
break;
default:
assertNever(action, `Unknown store action`);
}
} finally {
// update the snapshot no-matter what, as it would mess up with the next action
switch (action) {
// both immediately and never update the snapshot, unlike eventually
case CaptureUpdateAction.IMMEDIATELY:
case CaptureUpdateAction.NEVER:
this.snapshot = nextSnapshot;
break;
}
}
}
/**
* Returns the scheduled macro action.
*/
private getScheduledMacroAction() {
let scheduledAction: CaptureUpdateActionType;
if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
// Capture has a precedence over update, since it also performs snapshot update
scheduledAction = CaptureUpdateAction.IMMEDIATELY;
} else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
// Update has a precedence over none, since it also emits an (ephemeral) increment
scheduledAction = CaptureUpdateAction.NEVER;
} else {
// Default is to emit ephemeral increment and don't update the snapshot
scheduledAction = CaptureUpdateAction.EVENTUALLY;
}
return scheduledAction;
}
/**
* Ensures that the scheduled actions invariant is satisfied.
*/
private satisfiesScheduledActionsInvariant() {
if (
!(
this.scheduledMacroActions.size >= 0 &&
this.scheduledMacroActions.size <=
Object.keys(CaptureUpdateAction).length
)
) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
console.error(message, this.scheduledMacroActions.values());
if (isTestEnv() || isDevEnv()) {
throw new Error(message);
}
}
}
}
/**
* Repsents a change to the store containing changed elements and appState.
*/
export class StoreChange {
// so figuring out what has changed should ideally be just quick reference checks
// TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
private constructor(
public readonly elements: Record<string, OrderedExcalidrawElement>,
public readonly appState: Partial<ObservedAppState>,
) {}
public static create(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
return new StoreChange(changedElements, changedAppState);
}
}
/**
* Encpasulates any change to the store (durable or ephemeral).
*/
export abstract class StoreIncrement {
protected constructor(
public readonly type: "durable" | "ephemeral",
public readonly change: StoreChange,
) {}
public static isDurable(
increment: StoreIncrement,
): increment is DurableIncrement {
return increment.type === "durable";
}
public static isEphemeral(
increment: StoreIncrement,
): increment is EphemeralIncrement {
return increment.type === "ephemeral";
}
}
/**
* Represents a durable change to the store.
*/
export class DurableIncrement extends StoreIncrement {
constructor(
public readonly change: StoreChange,
public readonly delta: StoreDelta,
) {
super("durable", change);
}
}
/**
* Represents an ephemeral change to the store.
*/
export class EphemeralIncrement extends StoreIncrement {
constructor(public readonly change: StoreChange) {
super("ephemeral", change);
}
}
/**
* Represents a captured delta by the Store.
*/
export class StoreDelta {
protected constructor(
public readonly id: string,
public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta,
) {}
/**
* Create a new instance of `StoreDelta`.
*/
public static create(
elements: ElementsDelta,
appState: AppStateDelta,
opts: {
id: string;
} = {
id: randomId(),
},
) {
return new this(opts.id, elements, appState);
}
/**
* Restore a store delta instance from a DTO.
*/
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState } = storeDeltaDTO;
return new this(
id,
ElementsDelta.restore(elements),
AppStateDelta.restore(appState),
);
}
/**
* Parse and load the delta from the remote payload.
*/
public static load({
id,
elements: { added, removed, updated },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated, {
shouldRedistribute: false,
});
return new this(id, elements, AppStateDelta.empty());
}
/**
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta): StoreDelta {
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
public static applyTo(
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
}
/**
* Represents a snapshot of the captured or updated changes in the store,
* used for producing deltas and emitting `DurableStoreIncrement`s.
*/
export class StoreSnapshot {
private _lastChangedElementsHash: number = 0;
private _lastChangedAppStateHash: number = 0;
private constructor(
public readonly elements: SceneElementsMap,
public readonly appState: ObservedAppState,
public readonly metadata: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static create(
elements: SceneElementsMap,
appState: AppState | ObservedAppState,
metadata: {
didElementsChange: boolean;
didAppStateChange: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
},
) {
return new StoreSnapshot(
elements,
isObservedAppState(appState) ? appState : getObservedAppState(appState),
metadata,
);
}
public static empty() {
return new StoreSnapshot(
new Map() as SceneElementsMap,
getDefaultObservedAppState(),
{
didElementsChange: false,
didAppStateChange: false,
isEmpty: true,
},
);
}
public getChangedElements(prevSnapshot: StoreSnapshot) {
const changedElements: Record<string, OrderedExcalidrawElement> = {};
for (const prevElement of toIterable(prevSnapshot.elements)) {
const nextElement = this.elements.get(prevElement.id);
if (!nextElement) {
changedElements[prevElement.id] = newElementWith(prevElement, {
isDeleted: true,
});
}
}
for (const nextElement of toIterable(this.elements)) {
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
changedElements[nextElement.id] = nextElement;
}
}
return changedElements;
}
public getChangedAppState(
prevSnapshot: StoreSnapshot,
): Partial<ObservedAppState> {
return Delta.getRightDifferences(
prevSnapshot.appState,
this.appState,
).reduce(
(acc, key) =>
Object.assign(acc, {
[key]: this.appState[key as keyof ObservedAppState],
}),
{} as Partial<ObservedAppState>,
);
}
public isEmpty() {
return this.metadata.isEmpty;
}
/**
* Apply the change and return a new snapshot instance.
*/
public applyChange(change: StoreChange): StoreSnapshot {
const nextElements = new Map(this.elements) as SceneElementsMap;
for (const [id, changedElement] of Object.entries(change.elements)) {
nextElements.set(id, changedElement);
}
const nextAppState = Object.assign(
{},
this.appState,
change.appState,
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot
// so that we trigger the delta calculation and if it isn't different, delta will be empty
didElementsChange: Object.keys(change.elements).length > 0,
didAppStateChange: Object.keys(change.appState).length > 0,
});
}
/**
* Efficiently clone the existing snapshot, only if we detected changes.
*
* @returns same instance if there are no changes detected, new instance otherwise.
*/
public maybeClone(
action: CaptureUpdateActionType,
elements: SceneElementsMap | undefined,
appState: AppState | ObservedAppState | undefined,
) {
const options = {
shouldCompareHashes: false,
};
if (action === CaptureUpdateAction.EVENTUALLY) {
// actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
// as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
// instead of just the first time the elements or appState actually changed
options.shouldCompareHashes = true;
}
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
elements,
options,
);
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
appState,
options,
);
let didElementsChange = false;
let didAppStateChange = false;
if (this.elements !== nextElementsSnapshot) {
didElementsChange = true;
}
if (this.appState !== nextAppStateSnapshot) {
didAppStateChange = true;
}
if (!didElementsChange && !didAppStateChange) {
return this;
}
const snapshot = new StoreSnapshot(
nextElementsSnapshot,
nextAppStateSnapshot,
{
didElementsChange,
didAppStateChange,
},
);
return snapshot;
}
private maybeCreateAppStateSnapshot(
appState: AppState | ObservedAppState | undefined,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): ObservedAppState {
if (!appState) {
return this.appState;
}
// Not watching over everything from the app state, just the relevant props
const nextAppStateSnapshot = !isObservedAppState(appState)
? getObservedAppState(appState)
: appState;
const didAppStateChange = this.detectChangedAppState(
nextAppStateSnapshot,
options,
);
if (!didAppStateChange) {
return this.appState;
}
return nextAppStateSnapshot;
}
private maybeCreateElementsSnapshot(
elements: SceneElementsMap | undefined,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): SceneElementsMap {
if (!elements) {
return this.elements;
}
const changedElements = this.detectChangedElements(elements, options);
if (!changedElements?.size) {
return this.elements;
}
const elementsSnapshot = this.createElementsSnapshot(changedElements);
return elementsSnapshot;
}
private detectChangedAppState(
nextObservedAppState: ObservedAppState,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): boolean | undefined {
if (this.appState === nextObservedAppState) {
return;
}
const didAppStateChange = Delta.isRightDifferent(
this.appState,
nextObservedAppState,
);
if (!didAppStateChange) {
return;
}
const changedAppStateHash = hashString(
JSON.stringify(nextObservedAppState),
);
if (
options.shouldCompareHashes &&
this._lastChangedAppStateHash === changedAppStateHash
) {
return;
}
this._lastChangedAppStateHash = changedAppStateHash;
return didAppStateChange;
}
/**
* Detect if there any changed elements.
*/
private detectChangedElements(
nextElements: SceneElementsMap,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): SceneElementsMap | undefined {
if (this.elements === nextElements) {
return;
}
const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
for (const prevElement of toIterable(this.elements)) {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
// element was deleted
changedElements.set(
prevElement.id,
newElementWith(prevElement, { isDeleted: true }),
);
}
}
for (const nextElement of toIterable(nextElements)) {
const prevElement = this.elements.get(nextElement.id);
if (
!prevElement || // element was added
prevElement.version < nextElement.version // element was updated
) {
changedElements.set(nextElement.id, nextElement);
}
}
if (!changedElements.size) {
return;
}
const changedElementsHash = hashElementsVersion(changedElements);
if (
options.shouldCompareHashes &&
this._lastChangedElementsHash === changedElementsHash
) {
return;
}
this._lastChangedElementsHash = changedElementsHash;
return changedElements;
}
/**
* Perform structural clone, deep cloning only elements that changed.
*/
private createElementsSnapshot(changedElements: SceneElementsMap) {
const clonedElements = new Map() as SceneElementsMap;
for (const prevElement of toIterable(this.elements)) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
clonedElements.set(prevElement.id, prevElement);
}
for (const changedElement of toIterable(changedElements)) {
// TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
// TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
clonedElements.set(changedElement.id, deepCopyElement(changedElement));
}
return clonedElements;
}
}
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
const getDefaultObservedAppState = (): ObservedAppState => {
return {
name: null,
editingGroupId: null,
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
editingLinearElementId: null,
selectedLinearElementId: null,
croppingElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
};
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
const isObservedAppState = (
appState: AppState | ObservedAppState,
): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp);

View File

@ -14,12 +14,14 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import {
@ -28,7 +30,7 @@ import {
isTextElement,
} from "./typeChecks";
import type { Radians } from "../../math/src";
import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
import type {
@ -44,9 +46,10 @@ import type {
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
elementsMap: ElementsMap,
informMutation = true,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
let maxWidth = undefined;
if (!isProdEnv()) {
@ -106,38 +109,43 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight }, informMutation);
scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
);
mutateElement(container, { width: nextWidth }, informMutation);
scene.mutateElement(container, { width: nextWidth });
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement,
elementsMap,
);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
mutateElement(textElement, boundTextUpdates, informMutation);
scene.mutateElement(textElement, boundTextUpdates);
};
export const handleBindTextResize = (
container: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
return;
@ -190,20 +198,20 @@ export const handleBindTextResize = (
transformHandleType === "n")
? container.y - diff
: container.y;
mutateElement(container, {
scene.mutateElement(container, {
height: containerHeight,
y: updatedY,
});
}
mutateElement(textElement, {
scene.mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
});
if (!isArrowElement(container)) {
mutateElement(
scene.mutateElement(
textElement,
computeBoundTextPosition(container, textElement, elementsMap),
);

View File

@ -1,5 +1,7 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@ -25,9 +27,11 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types";
export const isInitializedImageElement = (
@ -107,6 +111,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => {
@ -119,6 +129,20 @@ export const isElbowArrow = (
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 = (
elementType: ElementOrToolType,
): boolean => {
@ -271,6 +295,10 @@ export const isBoundToContainer = (
);
};
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
@ -338,3 +366,41 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "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";
};
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};

View File

@ -296,6 +296,13 @@ export type FixedPointBinding = Merge<
}
>;
type Index = number;
export type PointsPositionUpdates = Map<
Index,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead =
| "arrow"
| "bar"
@ -321,10 +328,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
index: Index;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
@ -412,3 +425,13 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| 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

@ -2,8 +2,6 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { isFrameLikeElement } from "./typeChecks";
import { getElementsInGroup } from "./groups";
@ -12,6 +10,8 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import type { Scene } from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {

View File

@ -3,21 +3,30 @@ import { vi } from "vitest";
import { KEYS, cloneJSON } from "@excalidraw/common";
import { duplicateElement } from "@excalidraw/element/duplicate";
import {
Excalidraw,
exportToCanvas,
exportToSvg,
} from "@excalidraw/excalidraw";
import {
actionFlipHorizontal,
actionFlipVertical,
} from "@excalidraw/excalidraw/actions";
import type {
ExcalidrawImageElement,
ImageCrop,
} from "@excalidraw/element/types";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
GlobalTestState,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import { Excalidraw, exportToCanvas, exportToSvg } from "..";
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { act, GlobalTestState, render, unmountComponent } from "./test-utils";
import { duplicateElement } from "../src/duplicate";
import type { NormalizedZoomValue } from "../types";
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");

View File

@ -0,0 +1,149 @@
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import { AppStateDelta } from "../src/delta";
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
const commonAppState = {
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
lockedMultiSelections: {},
activeLockedId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElementId: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElementId,
};
const prevAppState2: ObservedAppState = {
selectedLinearElementId: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElementId,
name,
...commonAppState,
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedElementIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: { id5: true, id2: true, id4: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id1: true,
id2: true,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: { id4: true, id2: true, id5: true },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id3: true,
id2: true,
id1: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedGroupIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id0: true,
id1: true,
id2: false,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id3: true,
id2: false,
id1: true,
id0: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
});

View File

@ -7,7 +7,7 @@ import {
isPrimitive,
} from "@excalidraw/common";
import { Excalidraw } from "@excalidraw/excalidraw";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
@ -24,7 +24,6 @@ import {
import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
@ -62,7 +61,7 @@ describe("duplicating single elements", () => {
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, {
mutateElement(element, new Map(), {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});

View File

@ -1,8 +1,7 @@
import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import Scene from "@excalidraw/excalidraw/scene/Scene";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
@ -23,6 +22,8 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import { Scene } from "../src/Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
@ -142,7 +143,7 @@ describe("elbow arrow routing", () => {
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElement(arrow, {
h.app.scene.mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
@ -187,14 +188,14 @@ describe("elbow arrow routing", () => {
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", elementsMap);
bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElement(arrow, {
h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});

View File

@ -7,13 +7,14 @@ import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
} from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
@ -749,7 +750,7 @@ function testInvalidIndicesSync(args: {
function prepareArguments(
elementsLike: { id: string; index?: string }[],
movedElementsIds?: string[],
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
): [ExcalidrawElement[], ElementsMap | undefined] {
const elements = elementsLike.map((x) =>
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
);
@ -764,7 +765,7 @@ function prepareArguments(
function test(
name: string,
elements: ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement> | undefined,
movedElements: ElementsMap | undefined,
expectUnchangedElements: Map<string, { id: string }>,
expectValidInput?: boolean,
) {

View File

@ -1,5 +1,3 @@
import { newArrowElement } from "@excalidraw/element/newElement";
import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
@ -13,36 +11,34 @@ import {
arrayToMap,
} from "@excalidraw/common";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
getBoundTextElementPosition,
getBoundTextMaxWidth,
} from "@excalidraw/element/textElement";
import * as textElementUtils from "@excalidraw/element/textElement";
import { wrapText } from "@excalidraw/element/textWrapping";
import { Excalidraw } from "@excalidraw/excalidraw";
import * as InteractiveCanvas from "@excalidraw/excalidraw/renderer/interactiveScene";
import * as StaticScene from "@excalidraw/excalidraw/renderer/staticScene";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "@excalidraw/element/types";
import { Excalidraw, mutateElement } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
render,
fireEvent,
GlobalTestState,
unmountComponent,
} from "./test-utils";
} from "@excalidraw/excalidraw/tests/test-utils";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import { wrapText } from "../src";
import * as textElementUtils from "../src/textElement";
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "../src/types";
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
@ -118,7 +114,7 @@ describe("Test Linear Elements", () => {
],
roundness,
});
mutateElement(line, { points: line.points });
h.app.scene.mutateElement(line, { points: line.points });
API.setElements([line]);
mouse.clickAt(p1[0], p1[1]);
return line;
@ -177,7 +173,7 @@ describe("Test Linear Elements", () => {
pointFrom<LocalPoint>(0.5, 0),
pointFrom<LocalPoint>(100, 100),
]);
new LinearElementEditor(element);
new LinearElementEditor(element, arrayToMap(h.elements));
expect(element.points).toEqual([
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(99.5, 100),
@ -1271,7 +1267,7 @@ describe("Test Linear Elements", () => {
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[0],
arrayToMap(h.elements),
h.app.scene,
"nw",
false,
);
@ -1384,19 +1380,30 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y];
act(() => {
LinearElementEditor.movePoints(line, [
{
index: 0,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
},
{
index: line.points.length - 1,
point: pointFrom(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
),
},
]);
LinearElementEditor.movePoints(
line,
h.app.scene,
new Map([
[
0,
{
point: pointFrom(
line.points[0][0] + 10,
line.points[0][1] + 10,
),
},
],
[
line.points.length - 1,
{
point: pointFrom(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
),
},
],
]),
);
});
expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10);
@ -1404,5 +1411,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20);
});
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(line);
const elementsMap = arrayToMap(h.elements);
const points = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate original angle between first and last point
const originalAngle = Math.atan2(
points[1][1] - points[0][1],
points[1][0] - points[0][0],
);
// Drag the second point (endpoint) with SHIFT key pressed
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
const endPoint = pointFrom<GlobalPoint>(
startPoint[0] + 4,
startPoint[1] + 4,
);
// Perform drag with SHIFT key modifier
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(startPoint[0], startPoint[1]);
mouse.moveTo(endPoint[0], endPoint[1]);
mouse.upAt(endPoint[0], endPoint[1]);
});
// Get updated points after drag
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate new angle
const newAngle = Math.atan2(
updatedPoints[1][1] - updatedPoints[0][1],
updatedPoints[1][0] - updatedPoints[0][0],
);
// The angle should be preserved (within a small tolerance for floating point precision)
const angleDifference = Math.abs(newAngle - originalAngle);
const tolerance = 0.01; // Small tolerance for floating point precision
expect(angleDifference).toBeLessThan(tolerance);
});
});
});

View File

@ -333,7 +333,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"ne",
);
@ -369,7 +369,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"se",
);
@ -424,7 +424,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"e",
{
shouldResizeFromCenter: true,

View File

@ -1,10 +1,12 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "../src/mutateElement";
import { mutateElement } from "@excalidraw/element";
import { normalizeElementOrder } from "../src/sortElements";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const assertOrder = (
elements: readonly ExcalidrawElement[],
expectedOrder: string[],
@ -35,7 +37,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
});
mutateElement(container, {
mutateElement(container, new Map(), {
boundElements: [{ type: "text", id: boundText.id }],
});
@ -352,7 +354,7 @@ describe("normalizeElementsOrder", () => {
containerId: container.id,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
@ -387,7 +389,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
groupIds: ["C", "A"],
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});

View File

@ -1,8 +1,9 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { deepCopyElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -1,16 +1,18 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align";
import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align";
import type { Alignment } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton";
import {
@ -25,7 +27,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@ -50,14 +51,8 @@ const alignSelectedElements = (
alignment: Alignment,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const elementsMap = arrayToMap(elements);
const updatedElements = alignElements(
selectedElements,
elementsMap,
alignment,
app.scene,
);
const updatedElements = alignElements(selectedElements, alignment, app.scene);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -10,14 +10,14 @@ import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "@excalidraw/element/containerCache";
} from "@excalidraw/element";
import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
redrawTextBoundingBox,
} from "@excalidraw/element/textElement";
} from "@excalidraw/element";
import {
hasBoundTextElement,
@ -25,14 +25,15 @@ import {
isTextBindableContainer,
isTextElement,
isUsingAdaptiveRadius,
} from "@excalidraw/element/typeChecks";
} from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element/textMeasurements";
import { measureText } from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { syncMovedIndices } from "@excalidraw/element";
import { newElement } from "@excalidraw/element/newElement";
import { newElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawElement,
@ -43,12 +44,10 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types";
import { CaptureUpdateAction } from "../store";
import type { Radians } from "@excalidraw/math";
import { register } from "./register";
import type { Radians } from "../../math/src";
import type { AppState } from "../types";
export const actionUnbindText = register({
@ -80,7 +79,7 @@ export const actionUnbindText = register({
boundTextElement,
elementsMap,
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
@ -88,7 +87,7 @@ export const actionUnbindText = register({
x,
y,
});
mutateElement(element, {
app.scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
@ -153,25 +152,21 @@ export const actionBindText = register({
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
});
mutateElement(container, {
app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
const originalContainerHeight = container.height;
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(textElement, container, app.scene);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
@ -301,27 +296,23 @@ export const actionWrapTextInContainer = register({
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
}
});
}
mutateElement(
textElement,
{
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
},
false,
);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
});
redrawTextBoundingBox(textElement, container, app.scene);
updatedElements = pushContainerBelowText(
[...updatedElements, container],

View File

@ -14,8 +14,10 @@ import {
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -44,7 +46,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -1,8 +1,10 @@
import { isTextElement } from "@excalidraw/element/typeChecks";
import { getTextFromElements } from "@excalidraw/element/textElement";
import { isTextElement } from "@excalidraw/element";
import { getTextFromElements } from "@excalidraw/element";
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
copyTextToSystemClipboard,
copyToClipboard,
@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";

View File

@ -1,11 +1,12 @@
import { isImageElement } from "@excalidraw/element/typeChecks";
import { isImageElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
import { cropIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -1,6 +1,6 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
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,
});
mutateElement(r1, {
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
@ -94,7 +94,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null,
});
mutateElement(r1, {
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
@ -132,7 +132,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null,
});
mutateElement(r1, {
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
@ -170,7 +170,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null,
});
mutateElement(a1, {
h.app.scene.mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});

View File

@ -1,30 +1,28 @@
import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
import { fixBindingsAfterDeletion } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getContainerElement } from "@excalidraw/element";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element/frame";
} from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element";
import {
getElementsInGroup,
selectGroupsForSelectedElements,
} from "@excalidraw/element/groups";
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
@ -94,7 +92,7 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
app.scene.mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
@ -102,7 +100,6 @@ const deleteSelectedElements = (
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
}
});
}
@ -261,7 +258,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(element, selectedPointsIndices);
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
return {
elements,

View File

@ -1,16 +1,18 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { distributeElements } from "@excalidraw/element/distribute";
import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute";
import type { Distribution } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton";
import {
@ -21,7 +23,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -7,23 +7,24 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { LinearElementEditor } from "@excalidraw/element";
import {
getSelectedElements,
getSelectionStateForElements,
} from "@excalidraw/element/selection";
} from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { syncMovedIndices } from "@excalidraw/element";
import { duplicateElements } from "@excalidraw/element/duplicate";
import { duplicateElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@ -43,7 +44,7 @@ export const actionDuplicateSelection = register({
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
app.scene,
);
return {

View File

@ -2,13 +2,14 @@ import {
canCreateLinkFromElements,
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink";
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -1,18 +1,23 @@
import { KEYS, arrayToMap } from "@excalidraw/common";
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
elementsAreInSameGroup,
newElementWith,
selectGroupsFromGivenElements,
} from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { AppState } from "../types";
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked);
@ -23,15 +28,10 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
? "labels.elementLock.lock"
: "labels.elementLock.unlock";
},
icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState);
@ -58,19 +58,84 @@ export const actionToggleElementLock = register({
const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: nextLockState });
}),
const isAGroup =
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
const isASingleUnit = selectedElements.length === 1 || isAGroup;
const newGroupId = isASingleUnit ? null : randomId();
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
if (nextLockState) {
nextLockedMultiSelections = {
...appState.lockedMultiSelections,
...(newGroupId ? { [newGroupId]: true } : {}),
};
} else if (isAGroup) {
const groupId = selectedElements[0].groupIds.at(-1)!;
delete nextLockedMultiSelections[groupId];
}
const nextElements = elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
let nextGroupIds = element.groupIds;
// if locking together, add to group
// if unlocking, remove the temporary group
if (nextLockState) {
if (newGroupId) {
nextGroupIds = [...nextGroupIds, newGroupId];
}
} else {
nextGroupIds = nextGroupIds.filter(
(groupId) => !appState.lockedMultiSelections[groupId],
);
}
return newElementWith(element, {
locked: nextLockState,
// do not recreate the array unncessarily
groupIds:
nextGroupIds.length !== element.groupIds.length
? nextGroupIds
: element.groupIds,
});
});
const nextElementsMap = arrayToMap(nextElements);
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
? {}
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
const unlockedSelectedElements = selectedElements.map(
(el) => nextElementsMap.get(el.id) || el,
);
const nextSelectedGroupIds = nextLockState
? {}
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
const activeLockedId = nextLockState
? newGroupId
? newGroupId
: isAGroup
? selectedElements[0].groupIds.at(-1)!
: selectedElements[0].id
: null;
return {
elements: nextElements,
appState: {
...appState,
selectedElementIds: nextSelectedElementIds,
selectedGroupIds: nextSelectedGroupIds,
selectedLinearElement: nextLockState
? null
: appState.selectedLinearElement,
lockedMultiSelections: nextLockedMultiSelections,
activeLockedId,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -103,18 +168,44 @@ export const actionUnlockAllElements = register({
perform: (elements, appState) => {
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 {
elements: elements.map((element) => {
if (element.locked) {
return newElementWith(element, { locked: false });
}
return element;
}),
elements: nextElements,
appState: {
...appState,
selectedElementIds: Object.fromEntries(
lockedElements.map((el) => [el.id, true]),
),
selectedGroupIds: selectGroupsFromGivenElements(
unlockedElements,
appState,
),
lockedMultiSelections: {},
activeLockedId: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};

View File

@ -1,7 +1,8 @@
import { updateActiveTool } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { setCursorForShape } from "../cursor";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@ -10,6 +11,7 @@ export const actionSetEmbeddableAsActiveTool = register({
trackEvent: { category: "toolbar" },
target: "Tool",
label: "toolBar.embeddable",
keywords: ["embeddable", "embed", "iframe"],
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",

View File

@ -7,6 +7,8 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types";
import { useDevice } from "../components/App";
@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getExportSize } from "../scene/export";
import { CaptureUpdateAction } from "../store";
import "../components/ToolIcon.scss";

View File

@ -3,24 +3,35 @@ import { pointFrom } from "@excalidraw/math";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import {
isBindingElement,
isFreeDrawElement,
isLinearElement,
} from "@excalidraw/element/typeChecks";
isLineElement,
} from "@excalidraw/element";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element/shapes";
import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { t } from "../i18n";
import { resetCursor } from "../cursor";
import { done } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@ -30,11 +41,50 @@ export const actionFinalize = register({
name: "finalize",
label: "",
trackEvent: false,
perform: (elements, appState, _, app) => {
perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap();
if (data?.event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp(
data.event,
appState.selectedLinearElement,
appState,
app.scene,
);
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
app.scene,
);
}
if (linearElementEditor !== appState.selectedLinearElement) {
let newElements = elements;
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
}
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@ -46,10 +96,15 @@ export const actionFinalize = register({
element,
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
@ -72,88 +127,109 @@ export const actionFinalize = register({
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
scene.mutateElement(
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
}
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.newElement?.type === "freedraw"
? appState.newElement
: null;
let element: NonDeleted<ExcalidrawElement> | null = null;
if (appState.multiElement) {
element = appState.multiElement;
} else if (
appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement)
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
const candidate = elementsMap.get(
Object.keys(appState.selectedElementIds)[0],
) as NonDeleted<ExcalidrawLinearElement> | undefined;
if (candidate) {
element = candidate;
}
}
if (multiPointElement) {
if (element) {
// pen and mouse have hover
if (
multiPointElement.type !== "freedraw" &&
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = multiPointElement;
const { points, lastCommittedPoint } = element;
if (
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
) {
mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1),
scene.mutateElement(element, {
points: element.points.slice(0, -1),
});
}
}
if (isInvisiblySmallElement(multiPointElement)) {
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
newElements = newElements.filter((el) => el.id !== element!.id);
}
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw"
) {
if (isLoop) {
const linePoints = multiPointElement.points;
if (isLinearElement(element) || isFreeDrawElement(element)) {
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(element.points, appState.zoom.value);
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
const linePoints = element.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])
: p,
),
const points: LocalPoint[] = linePoints.map((p, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])
: p,
);
if (isLineElement(element)) {
scene.mutateElement(element, {
points,
polygon: true,
});
} else {
scene.mutateElement(element, {
points,
});
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
}
if (
isBindingElement(multiPointElement) &&
!isLoop &&
multiPointElement.points.length > 1
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
if (
isBindingElement(element) &&
!isLoop &&
element.points.length > 1 &&
isBindingEnabled(appState)
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(element, appState, { x, y }, scene);
}
}
}
if (
(!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") ||
!multiPointElement
!element
) {
resetCursor(interactiveCanvas);
}
@ -180,7 +256,7 @@ export const actionFinalize = register({
activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement
element
? appState.activeTool
: activeTool,
activeEmbeddable: null,
@ -191,18 +267,18 @@ export const actionFinalize = register({
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
multiPointElement &&
element &&
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
[element.id]: true,
}
: appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement)
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement,
pendingImageElementId: null,
},

View File

@ -2,22 +2,21 @@ import { getNonDeletedElements } from "@excalidraw/element";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
} from "@excalidraw/element";
import { getCommonBoundingBox } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element";
import { resizeMultipleElements } from "@excalidraw/element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
} from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
@ -27,7 +26,6 @@ import type {
} from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { flipHorizontal, flipVertical } from "../components/icons";
@ -162,11 +160,9 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
app.scene,
appState.zoom,
);
@ -194,13 +190,13 @@ const flipElements = (
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElement(element, {
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),

View File

@ -1,25 +1,26 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { newFrameElement } from "@excalidraw/element/newElement";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { mutateElement } from "@excalidraw/element";
import { newFrameElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import {
addElementsToFrame,
removeAllElementsFromFrame,
} from "@excalidraw/element/frame";
import { getFrameChildren } from "@excalidraw/element/frame";
} from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element";
import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getElementsInGroup } from "@excalidraw/element/groups";
import { getElementsInGroup } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element/bounds";
import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { setCursorForShape } from "../cursor";
import { frameToolIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
@ -173,11 +174,9 @@ export const actionWrapSelectionInFrame = register({
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const elementsMap = app.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
@ -196,13 +195,9 @@ export const actionWrapSelectionInFrame = register({
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
mutateElement(elementInGroup, elementsMap, {
groupIds: elementInGroup.groupIds.slice(0, index),
});
}
}

View File

@ -1,8 +1,8 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element/typeChecks";
import { isBoundToContainer } from "@excalidraw/element";
import {
frameAndChildrenSelectedTogether,
@ -12,7 +12,7 @@ import {
groupByFrameLikes,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "@excalidraw/element/frame";
} from "@excalidraw/element";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
@ -24,9 +24,11 @@ import {
addToGroup,
removeFromSelectedGroups,
isElementInGroup,
} from "@excalidraw/element/groups";
} from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { syncMovedIndices } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawElement,
@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -1,5 +1,9 @@
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 { ToolButton } from "../components/ToolButton";
@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import type { History } from "../history";
import type { Store } from "../store";
import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types";
@ -35,7 +37,11 @@ const executeHistoryAction = (
}
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 {
appState: nextAppState,
@ -47,9 +53,9 @@ const executeHistoryAction = (
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
};
type ActionCreator = (history: History, store: Store) => Action;
type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
viewMode: false,
perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
},
});
export const createRedoAction: ActionCreator = (history, store) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
perform: (elements, appState, __, app) =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||

View File

@ -1,15 +1,28 @@
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { LinearElementEditor } from "@excalidraw/element";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons";
import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register";
@ -50,7 +63,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement);
: new LinearElementEditor(selectedElement, arrayToMap(elements));
return {
appState: {
...appState,
@ -80,3 +93,110 @@ export const actionToggleLinearEditor = register({
);
},
});
export const actionTogglePolygon = register({
name: "togglePolygon",
category: DEFAULT_CATEGORIES.elements,
icon: polygonIcon,
keywords: ["loop"],
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
const allPolygons = !selectedElements.some(
(element) => !isLineElement(element) || !element.polygon,
);
return allPolygons
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 4,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.some((element) => !isLineElement(element))) {
return false;
}
const targetElements = selectedElements as ExcalidrawLineElement[];
// if one element not a polygon, convert all to polygon
const nextPolygonState = targetElements.some((element) => !element.polygon);
const targetElementsMap = arrayToMap(targetElements);
return {
elements: elements.map((element) => {
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
return element;
}
return newElementWith(element, {
backgroundColor: nextPolygonState
? element.backgroundColor
: "transparent",
...toggleLinePolygonState(element, nextPolygonState),
});
}),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
selectedElements.some(
(element) =>
!isLineElement(element) ||
// only show polygon button if every selected element is already
// a polygon, effectively showing this button only to allow for
// disabling the polygon state
!element.polygon ||
element.points.length < 3,
)
) {
return null;
}
const allPolygon = selectedElements.every(
(element) => isLineElement(element) && element.polygon,
);
const label = t(
allPolygon
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon",
);
return (
<ButtonIcon
icon={polygonIcon}
title={label}
aria-label={label}
active={allPolygon}
onClick={() => updateData(null)}
style={{ marginLeft: "auto" }}
/>
);
},
});

View File

@ -1,14 +1,15 @@
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS, getShortcutKey } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
import { showSelectedShapeActions } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
export const actionToggleCanvasMenu = register({

View File

@ -1,5 +1,7 @@
import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
@ -8,7 +10,6 @@ import {
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
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 { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
import { LinearElementEditor } from "@excalidraw/element";
import { isLinearElement, isTextElement } from "@excalidraw/element";
import { KEYS } from "@excalidraw/common";
import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../store";
import { selectAllIcon } from "../components/icons";
import { register } from "./register";
@ -53,7 +53,7 @@ export const actionSelectAll = register({
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0])
? new LinearElementEditor(elements[0], arrayToMap(elements))
: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

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

View File

@ -1,12 +1,13 @@
import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element/textMeasurements";
import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element/typeChecks";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,9 @@ import {
moveOneRight,
moveAllLeft,
moveAllRight,
} from "@excalidraw/element/zindex";
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
BringForwardIcon,
@ -14,7 +16,6 @@ import {
SendToBackIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

@ -179,6 +179,7 @@ export class ActionManager {
appProps={this.app.props}
app={this.app}
data={data}
renderAction={this.renderAction}
/>
);
}

View File

@ -3,7 +3,8 @@ import type {
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "../store";
import type { CaptureUpdateActionType } from "@excalidraw/element";
import type {
AppClassProperties,
AppState,
@ -140,7 +141,9 @@ export type ActionName =
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleLassoTool";
| "toggleLassoTool"
| "toggleShapeSwitch"
| "togglePolygon";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -149,6 +152,10 @@ export type PanelComponentProps = {
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
renderAction: (
name: ActionName,
data?: PanelComponentProps["data"],
) => React.JSX.Element | null;
};
export interface Action {
@ -195,7 +202,8 @@ export interface Action {
| "menu"
| "collab"
| "hyperlink"
| "search_menu";
| "search_menu"
| "shape_switch";
action?: string;
predicate?: (
appState: Readonly<AppState>,

Some files were not shown because too many files have changed in this diff Show More