Compare commits

..

29 Commits

Author SHA1 Message Date
Márk Tolmács
4eb1bd8036
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-05-05 09:56:19 +02:00
Márk Tolmács
8d7ffa21d1
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-05-02 09:47:18 +02:00
dwelle
82cef23c3d DEBUG 2025-04-30 10:39:54 +02:00
Mark Tolmacs
541725ff5a
Test fixes 2025-04-28 19:49:41 +02:00
Mark Tolmacs
28066034d7
Arrowhead padding 2025-04-28 19:37:50 +02:00
Mark Tolmacs
7d0d6aec7a
Another algo forpaddings 2025-04-28 19:31:30 +02:00
Mark Tolmacs
e6ade3b627
Fix test 2025-04-25 18:58:05 +02:00
Mark Tolmacs
9a2bd18904
Further adjustments for edge cases 2025-04-25 18:54:18 +02:00
Mark Tolmacs
c7c6a4c3f1
Fix test 2025-04-25 14:33:34 +02:00
Márk Tolmács
9c27f936de
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-25 14:27:15 +02:00
Mark Tolmacs
b8fdd7ef23
Remove unneeded imports 2025-04-25 14:26:01 +02:00
Mark Tolmacs
ece841326b
Refine corner avoidance 2025-04-25 14:25:40 +02:00
Mark Tolmacs
41711af210 Adjust padding so smaller objects have smaller padding 2025-04-21 17:24:13 +02:00
Mark Tolmacs
230e47fd52 Remove debug 2025-04-21 14:56:58 +02:00
Mark Tolmacs
52445aeb68 Fix a particular routing issue 2025-04-21 14:56:36 +02:00
Márk Tolmács
bc9f34e71e
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-21 12:30:49 +02:00
Márk Tolmács
22aade07b3
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-16 21:50:42 +02:00
Mark Tolmacs
c2de1304b7
Add snapshot update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-15 16:13:01 +02:00
Mark Tolmacs
25fb43f5b7
Snapshot update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-14 19:05:34 +02:00
Márk Tolmács
6dfa5de66c
Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-14 18:54:01 +02:00
Mark Tolmacs
7abbb2afa3
New heuristic based on minimal arrow extent
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-14 18:52:51 +02:00
Mark Tolmacs
aa91a3d610 Adaptive segment unification 2025-04-13 13:52:24 +02:00
Mark Tolmacs
25d6e517c9 Revert attempt to exclude some files from coverage 2025-04-13 13:29:21 +02:00
Mark Tolmacs
d5e33730ab Reduce scope of coverage reports 2025-04-13 13:19:45 +02:00
Mark Tolmacs
c06b78c1b2 Further fine-tune adaptive padding 2025-04-13 13:08:22 +02:00
Mark Tolmacs
eaa869620e Fine tuning 2025-04-11 21:53:23 +02:00
Mark Tolmacs
a8338cdb5a More adaptive elbow dongle offset 2025-04-11 21:08:55 +02:00
Mark Tolmacs
1ee3676784 Move visal debug to @excalidraw/util 2025-04-11 15:39:31 +02:00
Mark Tolmacs
f12f7e4b50 Fix sentry#6530117915 2025-04-11 10:24:02 +02:00
201 changed files with 21880 additions and 23354 deletions

View File

@ -1,5 +1,5 @@
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/ VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries

View File

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

View File

@ -19,7 +19,7 @@ services:
- ./:/opt/node_app/app:delegated - ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json - ./package.json:/opt/node_app/package.json
- ./yarn.lock:/opt/node_app/yarn.lock - ./yarn.lock:/opt/node_app/yarn.lock
# - notused:/opt/node_app/app/node_modules - notused:/opt/node_app/app/node_modules
# volumes: volumes:
# notused: notused:

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug"; import type { DebugElement } from "@excalidraw/utils/visualdebug";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";

View File

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

View File

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

View File

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

View File

@ -41,8 +41,8 @@
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",

View File

@ -3,15 +3,11 @@ import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory"; } from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element"; import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest"; import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element";
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../App";
const { h } = window; const { h } = window;
@ -69,79 +65,6 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = [];
await render(<ExcalidrawApp />);
h.store.onStoreIncrementEmitter.on((increment) => {
if (StoreIncrement.isDurable(increment)) {
durableIncrements.push(increment);
} else {
ephemeralIncrements.push(increment);
}
});
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
expect(durableIncrements.length).toBe(0);
expect(ephemeralIncrements.length).toBe(0);
const rectProps = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
x: 0,
y: 0,
} as const;
const rect = API.createElement({ ...rectProps });
API.updateScene({
elements: [rect],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
});
// simulate two batched remote updates
act(() => {
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 100 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 200 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
// we scheduled two micro actions,
// which confirms they are going to be executed as part of one batched component update
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(2);
});
await waitFor(() => {
// altough the updates get batched,
// we expect two ephemeral increments for each update,
// and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2);
expect(ephemeralIncrements[0].change.elements.A).toEqual(
expect.objectContaining({ x: 100 }),
);
expect(ephemeralIncrements[1].change.elements.A).toEqual(
expect.objectContaining({ x: 200 }),
);
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
});
});
it("should allow to undo / redo even on force-deleted elements", async () => { it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
const rect1Props = { const rect1Props = {
@ -199,7 +122,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const undoAction = createUndoAction(h.history); const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction)); act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot! // with explicit undo (as addition) we expect our item to be restored from the snapshot!
@ -231,7 +154,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const redoAction = createRedoAction(h.history); const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction)); act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot! // with explicit redo (as removal) we again restore the element from the snapshot!

View File

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

View File

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

View File

@ -10,7 +10,6 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox = export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window && "netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1; navigator.userAgent.indexOf("Gecko") > 1;
@ -144,7 +143,6 @@ export const FONT_FAMILY = {
"Lilita One": 7, "Lilita One": 7,
"Comic Shanns": 8, "Comic Shanns": 8,
"Liberation Sans": 9, "Liberation Sans": 9,
Assistant: 10,
}; };
export const FONT_FAMILY_FALLBACKS = { export const FONT_FAMILY_FALLBACKS = {
@ -256,7 +254,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawClipboardWithAPI: "excalidraw-api/clipboard", excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const; } as const;
export const getExportSource = () => export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin; window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds // time in milliseconds

View File

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

View File

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

View File

@ -68,12 +68,3 @@ export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types // get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never; export type AllPossibleKeys<T> = T extends any ? keyof T : never;
/** Strip all the methods or functions from a type */
export type DTO<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
? [K, V]
: never;

View File

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

View File

@ -544,20 +544,6 @@ export const findLastIndex = <T>(
return -1; return -1;
}; };
/** returns the first non-null mapped value */
export const mapFind = <T, K>(
collection: readonly T[],
iteratee: (value: T, index: number) => K | undefined | null,
): K | undefined => {
for (let idx = 0; idx < collection.length; idx++) {
const result = iteratee(collection[idx], idx);
if (result != null) {
return result;
}
}
return undefined;
};
export const isTransparent = (color: string) => { export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
@ -749,25 +735,6 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc; return acc;
}, [] as Node<T>[]); }, [] as Node<T>[]);
/**
* Converts a readonly array or map into an iterable.
* Useful for avoiding entry allocations when iterating object / map on each iteration.
*/
export const toIterable = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): Iterable<T> => {
return Array.isArray(values) ? values : values.values();
};
/**
* Converts a readonly array or map into an array.
*/
export const toArray = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): T[] => {
return Array.isArray(values) ? values : Array.from(toIterable(values));
};
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
@ -1258,39 +1225,11 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
}; };
export const sizeOf = ( export const sizeOf = (
value: value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => { ): number => {
return isReadonlyArray(value) return isReadonlyArray(value)
? value.length ? value.length
: value instanceof Map || value instanceof Set : value instanceof Map
? value.size ? value.size
: Object.keys(value).length; : 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" "default": "./dist/prod/index.js"
}, },
"./*": { "./*": {
"types": "./dist/types/element/src/*.d.ts" "types": "./../element/dist/types/element/src/*.d.ts"
} }
}, },
"files": [ "files": [
@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues", "bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw", "repository": "https://github.com/excalidraw/excalidraw",
"scripts": { "scripts": {
"gen:types": "rimraf types && tsc", "gen:types": "rm -rf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }

View File

@ -6,21 +6,25 @@ import {
toBrandedType, toBrandedType,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
toArray, isReadonlyArray,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { import {
orderByFractionalIndex,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
} from "@excalidraw/element"; } from "@excalidraw/element/fractionalIndex";
import { getSelectedElements } from "@excalidraw/element"; import { getSelectedElements } from "@excalidraw/element/selection";
import { mutateElement, type ElementUpdate } from "@excalidraw/element"; import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -105,7 +109,7 @@ const hashSelectionOpts = (
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
export class Scene { class Scene {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// instance methods/props // instance methods/props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -264,13 +268,19 @@ export class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices // ts doesn't like `Array.isArray` of `instanceof Map`
const _nextElements = toArray(nextElements); if (!isReadonlyArray(nextElements)) {
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements); validateIndicesThrottled(nextElements);
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
this.elements.forEach((element) => { this.elements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
@ -454,3 +464,5 @@ export class Scene {
return element; return element;
} }
} }
export default Scene;

View File

@ -2,7 +2,7 @@ import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";

View File

@ -33,7 +33,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
getCenterForBounds, getCenterForBounds,
@ -43,6 +43,12 @@ import {
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToBindableElement } from "./distance";
import { import {
compareHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
headingForPoint,
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
vectorToHeading, vectorToHeading,
@ -66,7 +72,7 @@ import {
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@ -84,7 +90,6 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPoint, FixedPoint,
FixedPointBinding, FixedPointBinding,
PointsPositionUpdates,
} from "./types"; } from "./types";
export type SuggestedBinding = export type SuggestedBinding =
@ -802,22 +807,28 @@ export const updateBoundElements = (
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
if (point) { if (point) {
return [ return {
bindingProp === "startBinding" ? 0 : element.points.length - 1, index:
{ point }, bindingProp === "startBinding" ? 0 : element.points.length - 1,
] as MapEntry<PointsPositionUpdates>; point,
};
} }
} }
return null; return null;
}, },
).filter( ).filter(
(update): update is MapEntry<PointsPositionUpdates> => update !== null, (
update,
): update is NonNullable<{
index: number;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
); );
LinearElementEditor.movePoints(element, scene, new Map(updates), { LinearElementEditor.movePoints(element, scene, updates, {
...(changedElement.id === element.startBinding?.elementId ...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding } ? { startBinding: bindings.startBinding }
: {}), : {}),
@ -1020,7 +1031,14 @@ export const avoidRectangularCorner = (
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left // Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x, element.y),
);
if (
compareHeading(heading, HEADING_DOWN) ||
compareHeading(heading, HEADING_LEFT)
) {
return pointRotateRads<GlobalPoint>( return pointRotateRads<GlobalPoint>(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center, center,
@ -1037,7 +1055,14 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > element.y + element.height
) { ) {
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x, element.y + element.height),
);
if (
compareHeading(heading, HEADING_DOWN) ||
compareHeading(heading, HEADING_RIGHT)
) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
element.x, element.x,
@ -1057,9 +1082,13 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > element.y + element.height
) { ) {
// Bottom right // Bottom right
const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x + element.width, element.y + element.height),
);
if ( if (
nonRotatedPoint[0] - element.x < compareHeading(heading, HEADING_DOWN) ||
element.width + FIXED_BINDING_DISTANCE compareHeading(heading, HEADING_LEFT)
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
@ -1083,9 +1112,13 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] < element.y nonRotatedPoint[1] < element.y
) { ) {
// Top right // Top right
const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x + element.width, element.y),
);
if ( if (
nonRotatedPoint[0] - element.x < compareHeading(heading, HEADING_UP) ||
element.width + FIXED_BINDING_DISTANCE compareHeading(heading, HEADING_LEFT)
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
@ -1103,6 +1136,17 @@ export const avoidRectangularCorner = (
); );
} }
// Break up explicit border bindings to have better elbow arrow routing
if (p[0] === element.x) {
return pointFrom(p[0] - FIXED_BINDING_DISTANCE, p[1]);
} else if (p[0] === element.x + element.width) {
return pointFrom(p[0] + FIXED_BINDING_DISTANCE, p[1]);
} else if (p[1] === element.y) {
return pointFrom(p[0], p[1] - FIXED_BINDING_DISTANCE);
} else if (p[1] === element.y + element.height) {
return pointFrom(p[0], p[1] + FIXED_BINDING_DISTANCE);
}
return p; return p;
}; };
@ -1166,48 +1210,6 @@ export const snapToMid = (
center, center,
angle, 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; return p;

View File

@ -26,7 +26,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";

View File

@ -52,7 +52,7 @@ import {
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes"; import { aabbForElement, aabbForPoints, pointInsideBounds } from "./shapes";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
@ -65,6 +65,8 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
type GridAddress = [number, number] & { _brand: "gridaddress" }; type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = { type Node = {
@ -106,8 +108,32 @@ type ElbowArrowData = {
hoveredEndElement: ExcalidrawBindableElement | null; hoveredEndElement: ExcalidrawBindableElement | null;
}; };
const DEDUP_TRESHOLD = 1; const calculateDedupTreshhold = <Point extends GlobalPoint | LocalPoint>(
export const BASE_PADDING = 40; a: Point,
b: Point,
) => 1 + pointDistance(a, b) / 300;
const calculatePadding = (
aabb: Bounds,
startBoundingBox: Bounds,
endBoundingBox: Bounds,
) => {
return Math.max(
Math.min(
Math.hypot(
startBoundingBox[2] - startBoundingBox[0],
startBoundingBox[3] - startBoundingBox[1],
) / 4,
Math.hypot(
endBoundingBox[2] - endBoundingBox[0],
endBoundingBox[3] - endBoundingBox[1],
) / 4,
Math.hypot(aabb[2] - aabb[0], aabb[3] - aabb[1]) / 4,
40,
),
30,
);
};
const handleSegmentRenormalization = ( const handleSegmentRenormalization = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
@ -183,7 +209,11 @@ const handleSegmentRenormalization = (
if ( if (
// Remove segments that are too short // Remove segments that are too short
pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD pointDistance(points[i - 2], points[i - 1]) <
calculateDedupTreshhold(
points[i - 3] ?? points[i - 3],
points[i] ?? points[i - 1],
)
) { ) {
const prevPrevSegmentIdx = const prevPrevSegmentIdx =
nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ?? nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
@ -359,6 +389,10 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints) {
return {};
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@ -463,6 +497,13 @@ const handleSegmentMove = (
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
const BASE_PADDING = calculatePadding(
aabbForElement(arrow),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
const activelyModifiedSegmentIdx = fixedSegments const activelyModifiedSegmentIdx = fixedSegments
.map((segment, i) => { .map((segment, i) => {
if ( if (
@ -707,6 +748,13 @@ const handleEndpointDrag = (
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
) => { ) => {
const BASE_PADDING = calculatePadding(
aabbForPoints([startGlobalPoint, endGlobalPoint]),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@ -741,6 +789,7 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
startIsSpecial = arrow.startIsSpecial && globalUpdatedPoints.length > 2;
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
@ -801,6 +850,7 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
endIsSpecial = arrow.endIsSpecial && globalUpdatedPoints.length > 2;
const secondToLastPoint = const secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint = const thirdToLastPoint =
@ -1293,29 +1343,28 @@ const getElbowArrowData = (
endGlobalPoint[0] + 2, endGlobalPoint[0] + 2,
endGlobalPoint[1] + 2, endGlobalPoint[1] + 2,
] as Bounds; ] as Bounds;
const BASE_PADDING = calculatePadding(
aabbForPoints([startGlobalPoint, endGlobalPoint]),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
const startOffsets = offsetFromHeading(
startHeading,
arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
1,
);
const endOffsets = offsetFromHeading(
endHeading,
arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
1,
);
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(hoveredStartElement, startOffsets)
hoveredStartElement,
offsetFromHeading(
startHeading,
arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: startPointBounds; : startPointBounds;
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(hoveredEndElement, endOffsets)
hoveredEndElement,
offsetFromHeading(
endHeading,
arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: endPointBounds; : endPointBounds;
const boundsOverlap = const boundsOverlap =
pointInsideBounds( pointInsideBounds(
@ -1358,7 +1407,7 @@ const getElbowArrowData = (
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6 ? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2), : FIXED_BINDING_DISTANCE),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@ -1374,13 +1423,29 @@ const getElbowArrowData = (
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6 ? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2), : FIXED_BINDING_DISTANCE),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement
hoveredEndElement && aabbForElement(hoveredEndElement), ? aabbForElement(hoveredStartElement)
: startPointBounds,
hoveredEndElement ? aabbForElement(hoveredEndElement) : endPointBounds,
); );
debugDrawBounds(startElementBounds, {
permanent: false,
color: "red",
});
debugDrawBounds(endElementBounds, {
permanent: false,
color: "green",
});
debugDrawBounds(dynamicAABBs, {
permanent: false,
color: "blue",
});
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
startHeading, startHeading,
@ -1651,11 +1716,11 @@ const generateDynamicAABBs = (
a: Bounds, a: Bounds,
b: Bounds, b: Bounds,
common: Bounds, common: Bounds,
startDifference?: [number, number, number, number], startDifference: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference: [number, number, number, number],
disableSideHack?: boolean, disableSideHack: boolean,
startElementBounds?: Bounds | null, startElementBounds: Bounds,
endElementBounds?: Bounds | null, endElementBounds: Bounds,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a; const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b; const endEl = endElementBounds ?? b;
@ -1735,15 +1800,24 @@ const generateDynamicAABBs = (
(second[0] + second[2]) / 2, (second[0] + second[2]) / 2,
(second[1] + second[3]) / 2, (second[1] + second[3]) / 2,
]; ];
if (b[0] > a[2] && a[1] > b[3]) { if (
endElementBounds[0] > startElementBounds[2] &&
startElementBounds[1] > endElementBounds[3]
) {
// BOTTOM LEFT // BOTTOM LEFT
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2; const cY = second[3] + (first[1] - second[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY), vector(
vector(a[0] - endCenterX, a[3] - endCenterY), startElementBounds[2] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[0] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@ -1756,15 +1830,24 @@ const generateDynamicAABBs = (
[first[0], cY, first[2], first[3]], [first[0], cY, first[2], first[3]],
[second[0], second[1], second[2], cY], [second[0], second[1], second[2], cY],
]; ];
} else if (a[2] < b[0] && a[3] < b[1]) { } else if (
startElementBounds[2] < endElementBounds[0] &&
startElementBounds[3] < endElementBounds[1]
) {
// TOP LEFT // TOP LEFT
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2; const cY = first[3] + (second[1] - first[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY), vector(
vector(a[2] - endCenterX, a[3] - endCenterY), startElementBounds[0] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[2] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@ -1777,15 +1860,24 @@ const generateDynamicAABBs = (
[first[0], first[1], cX, first[3]], [first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]], [cX, second[1], second[2], second[3]],
]; ];
} else if (a[0] > b[2] && a[3] < b[1]) { } else if (
startElementBounds[0] > endElementBounds[2] &&
startElementBounds[3] < endElementBounds[1]
) {
// TOP RIGHT // TOP RIGHT
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2; const cY = first[3] + (second[1] - first[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY), vector(
vector(a[0] - endCenterX, a[3] - endCenterY), startElementBounds[2] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[0] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@ -1798,15 +1890,24 @@ const generateDynamicAABBs = (
[first[0], first[1], first[2], cY], [first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]], [second[0], cY, second[2], second[3]],
]; ];
} else if (a[0] > b[2] && a[1] > b[3]) { } else if (
startElementBounds[0] > endElementBounds[2] &&
startElementBounds[1] > endElementBounds[3]
) {
// BOTTOM RIGHT // BOTTOM RIGHT
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2; const cY = second[3] + (first[1] - second[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY), vector(
vector(a[2] - endCenterX, a[3] - endCenterY), startElementBounds[0] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[2] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@ -2088,16 +2189,11 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
points: LocalPoint[]; if (global.length === 0) {
x: number; return {};
y: number; }
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
@ -2185,7 +2281,10 @@ const removeElbowArrowShortSegments = (
const prev = points[idx - 1]; const prev = points[idx - 1];
const prevDist = pointDistance(prev, p); const prevDist = pointDistance(prev, p);
return prevDist > DEDUP_TRESHOLD; return (
prevDist >
calculateDedupTreshhold(points[idx - 2] ?? prev, points[idx + 1] ?? p)
);
}); });
} }
@ -2288,13 +2387,16 @@ const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>( export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[], points: readonly P[],
tolerance: number = DEDUP_TRESHOLD, tolerance?: number,
) => ) =>
points points
.slice(1) .slice(1)
.map( .map((p, i) => {
(p, i) => const t =
Math.abs(p[0] - points[i][0]) < tolerance || tolerance ??
Math.abs(p[1] - points[i][1]) < tolerance, calculateDedupTreshhold(points[i - 1] ?? points[i], points[i + 2] ?? p);
) return (
Math.abs(p[0] - points[i][0]) < t || Math.abs(p[1] - points[i][1]) < t
);
})
.every(Boolean); .every(Boolean);

View File

@ -33,8 +33,6 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED = const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i; /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
// not anchored to start to allow <blockquote> twitter embeds // not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
@ -71,7 +69,6 @@ const ALLOWED_DOMAINS = new Set([
"val.town", "val.town",
"giphy.com", "giphy.com",
"reddit.com", "reddit.com",
"forms.microsoft.com",
]); ]);
const ALLOW_SAME_ORIGIN = new Set([ const ALLOW_SAME_ORIGIN = new Set([
@ -85,7 +82,6 @@ const ALLOW_SAME_ORIGIN = new Set([
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"reddit.com", "reddit.com",
"forms.microsoft.com",
]); ]);
export const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
@ -210,10 +206,6 @@ export const getEmbedLink = (
}; };
} }
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
link += link.includes("?") ? "&embed=true" : "?embed=true";
}
if (RE_TWITTER.test(link)) { if (RE_TWITTER.test(link)) {
const postId = link.match(RE_TWITTER)![1]; const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only. // the embed srcdoc still supports twitter.com domain only.

View File

@ -39,7 +39,7 @@ import {
type OrderedExcalidrawElement, type OrderedExcalidrawElement,
} from "./types"; } from "./types";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@ -462,18 +462,12 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(bindingArrow, scene, [
bindingArrow, {
scene, index: 1,
new Map([ point: bindingArrow.points[1],
[ },
1, ]);
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints( const update = updateElbowArrowPoints(
bindingArrow, bindingArrow,

View File

@ -905,16 +905,13 @@ export const shouldApplyFrameClip = (
return false; return false;
}; };
const DEFAULT_FRAME_NAME = "Frame";
const DEFAULT_AI_FRAME_NAME = "AI Frame";
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
// TODO name frames "AI" only if specific to AI frames
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => { export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
return element.name === null ? getDefaultFrameName(element) : element.name; // TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
? "Frame"
: "AI Frame"
: element.name;
}; };
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (

View File

@ -1,5 +1,3 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -7,7 +5,6 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ElementsMapOrArray,
} from "./types"; } from "./types";
/** /**
@ -19,10 +16,12 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/** /**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters. * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/ */
export const hashElementsVersion = (elements: ElementsMapOrArray): number => { export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
let hash = 5381; let hash = 5381;
for (const element of toIterable(elements)) { for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + element.versionNonce; hash = (hash << 5) + hash + elements[i].versionNonce;
} }
return hash >>> 0; // Ensure unsigned 32-bit integer return hash >>> 0; // Ensure unsigned 32-bit integer
}; };
@ -72,47 +71,3 @@ export const clearElementsForExport = (
export const clearElementsForLocalStorage = ( export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => _clearElements(elements); ) => _clearElements(elements);
export * from "./align";
export * from "./binding";
export * from "./bounds";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
export * from "./cropElement";
export * from "./delta";
export * from "./distance";
export * from "./distribute";
export * from "./dragElements";
export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
export * from "./heading";
export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./Shape";
export * from "./ShapeCache";
export * from "./shapes";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";

View File

@ -20,7 +20,7 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { Store } from "@excalidraw/element"; import type { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@ -67,7 +67,7 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { import type {
@ -82,9 +82,13 @@ import type {
FixedPointBinding, FixedPointBinding,
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointsPositionUpdates,
} from "./types"; } from "./types";
const editorMidPointsCache: {
version: number | null;
points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@ -302,22 +306,16 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, scene, [
element, {
scene, index: selectedIndex,
new Map([ point: pointFrom(
[ width + referencePoint[0],
selectedIndex, height + referencePoint[1],
{ ),
point: pointFrom( isDragging: selectedIndex === lastClickedPoint,
width + referencePoint[0], },
height + referencePoint[1], ]);
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@ -333,32 +331,26 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, scene,
new Map( selectedPointsIndices.map((pointIndex) => {
selectedPointsIndices.map((pointIndex) => { const newPointPosition: LocalPoint =
const newPointPosition: LocalPoint = pointIndex === lastClickedPoint
pointIndex === lastClickedPoint ? LinearElementEditor.createPointAt(
? LinearElementEditor.createPointAt( element,
element, elementsMap,
elementsMap, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y,
scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
event[KEYS.CTRL_OR_CMD] )
? null : pointFrom(
: app.getEffectiveGridSize(), element.points[pointIndex][0] + deltaX,
) element.points[pointIndex][1] + deltaY,
: pointFrom( );
element.points[pointIndex][0] + deltaX, return {
element.points[pointIndex][1] + deltaY, index: pointIndex,
); point: newPointPosition,
return [ isDragging: pointIndex === lastClickedPoint,
pointIndex, };
{ }),
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
),
); );
} }
@ -459,21 +451,15 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, scene, [
element, {
scene, index: selectedPoint,
new Map([ point:
[ selectedPoint === 0
selectedPoint, ? element.points[element.points.length - 1]
{ : element.points[0],
point: },
selectedPoint === 0 ]);
? element.points[element.points.length - 1]
: element.points[0],
},
],
]),
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@ -531,7 +517,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
): (GlobalPoint | null)[] => { ): typeof editorMidPointsCache["points"] => {
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
@ -543,7 +529,25 @@ export class LinearElementEditor {
) { ) {
return []; return [];
} }
if (
editorMidPointsCache.version === element.version &&
editorMidPointsCache.zoom === appState.zoom.value
) {
return editorMidPointsCache.points;
}
LinearElementEditor.updateEditorMidPointsCache(
element,
elementsMap,
appState,
);
return editorMidPointsCache.points!;
};
static updateEditorMidPointsCache = (
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates( const points = LinearElementEditor.getPointsGlobalCoordinates(
element, element,
elementsMap, elementsMap,
@ -575,8 +579,9 @@ export class LinearElementEditor {
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
} }
editorMidPointsCache.points = midpoints;
return midpoints; editorMidPointsCache.version = element.version;
editorMidPointsCache.zoom = appState.zoom.value;
}; };
static getSegmentMidpointHitCoords = ( static getSegmentMidpointHitCoords = (
@ -630,11 +635,8 @@ export class LinearElementEditor {
} }
} }
let index = 0; let index = 0;
const midPoints = LinearElementEditor.getEditorMidPoints( const midPoints: typeof editorMidPointsCache["points"] =
element, LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
elementsMap,
appState,
);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
@ -805,7 +807,7 @@ export class LinearElementEditor {
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
} }
store.scheduleCapture(); store.shouldCaptureIncrement();
ret.linearElementEditor = { ret.linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
pointerDownState: { pointerDownState: {
@ -986,18 +988,12 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, app.scene, [
element, {
app.scene, index: element.points.length - 1,
new Map([ point: newPoint,
[ },
element.points.length - 1, ]);
{
point: newPoint,
},
],
]),
);
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]); LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
} }
@ -1231,16 +1227,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, scene, [
element, {
scene, index: element.points.length - 1,
new Map([ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
[ },
element.points.length - 1, ]);
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
],
]),
);
} }
return { return {
@ -1315,7 +1307,7 @@ export class LinearElementEditor {
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, scene: Scene,
pointUpdates: PointsPositionUpdates, targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
@ -1329,7 +1321,8 @@ export class LinearElementEditor {
// offset it. We do the same with actual element.x/y position, so // offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user. // this hacks are completely transparent to the user.
const [deltaX, deltaY] = const [deltaX, deltaY] =
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0); targetPoints.find(({ index }) => index === 0)?.point ??
pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>( const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0], deltaX - points[0][0],
deltaY - points[0][1], deltaY - points[0][1],
@ -1337,12 +1330,12 @@ export class LinearElementEditor {
const nextPoints = isElbowArrow(element) const nextPoints = isElbowArrow(element)
? [ ? [
pointUpdates.get(0)?.point ?? points[0], targetPoints.find((t) => t.index === 0)?.point ?? points[0],
pointUpdates.get(points.length - 1)?.point ?? targetPoints.find((t) => t.index === points.length - 1)?.point ??
points[points.length - 1], points[points.length - 1],
] ]
: points.map((p, idx) => { : points.map((p, idx) => {
const current = pointUpdates.get(idx)?.point ?? p; const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
return pointFrom<LocalPoint>( return pointFrom<LocalPoint>(
current[0] - offsetX, current[0] - offsetX,
@ -1358,7 +1351,11 @@ export class LinearElementEditor {
offsetY, offsetY,
otherUpdates, otherUpdates,
{ {
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging), isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
}, },
); );
} }
@ -1590,14 +1587,23 @@ export class LinearElementEditor {
y = midPoint[1] - boundTextElement.height / 2; y = midPoint[1] - boundTextElement.height / 2;
} else { } else {
const index = element.points.length / 2 - 1; const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
editorMidPointsCache.version !== element.version
) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2; y = midSegmentMidpoint[1] - boundTextElement.height / 2;
} }

View File

@ -351,20 +351,12 @@ const generateElementCanvas = (
export const DEFAULT_LINK_SIZE = 14; export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`, `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`; )}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`, `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
)}`; )}`;

View File

@ -57,7 +57,7 @@ import {
import { isInGroup } from "./groups"; import { isInGroup } from "./groups";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { import type {
@ -962,6 +962,11 @@ export const resizeSingleElement = (
isDragging: false, isDragging: false,
}); });
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, { scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@ -973,11 +978,6 @@ export const resizeSingleElement = (
handleDirection, handleDirection,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
); );
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
} }
}; };

View File

@ -169,6 +169,25 @@ export const isSomeElementSelected = (function () {
return ret; return ret;
})(); })();
/**
* Returns common attribute (picked by `getAttribute` callback) of selected
* elements. If elements don't share the same value, returns `null`.
*/
export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
getAttribute: (element: ExcalidrawElement) => T,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
};
export const getSelectedElements = ( export const getSelectedElements = (
elements: ElementsMapOrArray, elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">, appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,

View File

@ -282,6 +282,15 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
); );
}; };
export const aabbForPoints = <Point extends GlobalPoint | LocalPoint>(
points: Point[],
): Bounds => [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
/** /**
* Get the axis-aligned bounding box for a given element * Get the axis-aligned bounding box for a given element
*/ */

View File

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

View File

@ -30,7 +30,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
import type { import type {

View File

@ -28,7 +28,6 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -357,18 +356,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
export const getLinearElementSubType = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};

View File

@ -296,11 +296,6 @@ export type FixedPointBinding = Merge<
} }
>; >;
export type PointsPositionUpdates = Map<
number,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
@ -418,12 +413,10 @@ export type ElementsMapOrArray =
| readonly ExcalidrawElement[] | readonly ExcalidrawElement[]
| Readonly<ElementsMap>; | Readonly<ElementsMap>;
export type ExcalidrawLinearElementSubType = export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes =
| "line" | "line"
| "sharpArrow" | "sharpArrow"
| "curvedArrow" | "curvedArrow"
| "elbowArrow"; | "elbowArrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes; export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

View File

@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import type { Scene } from "./Scene"; import type Scene from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";

View File

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

View File

@ -22,7 +22,7 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding"; import { bindLinearElement } from "../src/binding";
import { Scene } from "../src/Scene"; import Scene from "../src/Scene";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
@ -195,7 +195,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
h.app.scene.mutateElement(arrow, { scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });
@ -295,11 +295,11 @@ describe("elbow arrow ui", () => {
) as HTMLInputElement; ) as HTMLInputElement;
UI.updateInput(inputAngle, String("40")); UI.updateInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points).toCloselyEqualPoints([
[0, 0], [0, 0],
[35, 0], [34.7791, 0],
[35, 165], [34.7791, 164.67],
[103, 165], [102.931, 164.67],
]); ]);
}); });

View File

@ -7,9 +7,9 @@ import {
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
} from "@excalidraw/element"; } from "@excalidraw/element/fractionalIndex";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element/duplicate";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";

View File

@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });

View File

@ -1,6 +1,6 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { normalizeElementOrder } from "../src/sortElements"; import { normalizeElementOrder } from "../src/sortElements";

View File

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

View File

@ -1,18 +1,16 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element/align";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element"; import type { Alignment } from "@excalidraw/element/align";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import {
@ -27,6 +25,7 @@ import {
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

@ -10,14 +10,14 @@ import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element/containerCache";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -25,15 +25,13 @@ import {
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element/textMeasurements";
import { syncMovedIndices } from "@excalidraw/element"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { newElement } from "@excalidraw/element"; import { newElement } from "@excalidraw/element/newElement";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -46,6 +44,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";

View File

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

View File

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

View File

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

View File

@ -1,28 +1,27 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element"; import { getContainerElement } from "@excalidraw/element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { getFrameChildren } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element/frame";
import { import {
getElementsInGroup, getElementsInGroup,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element"; } from "@excalidraw/element/groups";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";

View File

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

View File

@ -7,24 +7,23 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { import {
getSelectedElements, getSelectedElements,
getSelectionStateForElements, getSelectionStateForElements,
} from "@excalidraw/element"; } from "@excalidraw/element/selection";
import { syncMovedIndices } from "@excalidraw/element"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element"; import { duplicateElements } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import { updateActiveTool } from "@excalidraw/common"; import { updateActiveTool } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

@ -3,22 +3,24 @@ import { pointFrom } from "@excalidraw/math";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isBindingElement, isLinearElement } from "@excalidraw/element"; import {
isBindingElement,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { CaptureUpdateAction } from "@excalidraw/element";
import { t } from "../i18n"; import { t } from "../i18n";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1")!; const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0); expect(rec1.x).toBeCloseTo(97.8678, 0);
expect(rec1.y).toBeCloseTo(100, 0); expect(rec1.y).toBeCloseTo(97.444, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!; const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0); expect(rec2.x).toBeCloseTo(218, 0);
expect(rec2.y).toBeCloseTo(250, 0); expect(rec2.y).toBeCloseTo(247, 0);
}); });
}); });

View File

@ -2,21 +2,19 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element"; import { getCommonBoundingBox } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element"; import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
@ -26,6 +24,7 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";

View File

@ -1,26 +1,25 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { newFrameElement } from "@excalidraw/element"; import { newFrameElement } from "@excalidraw/element/newElement";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { import {
addElementsToFrame, addElementsToFrame,
removeAllElementsFromFrame, removeAllElementsFromFrame,
} from "@excalidraw/element"; } from "@excalidraw/element/frame";
import { getFrameChildren } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element/frame";
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { getCommonBounds } from "@excalidraw/element"; import { getCommonBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { frameToolIcon } from "../components/icons"; import { frameToolIcon } from "../components/icons";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

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

View File

@ -1,11 +1,9 @@
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element"; import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@ -13,6 +11,7 @@ import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons"; import { lineEditorIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getClientColor } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { import {
@ -10,6 +8,7 @@ import {
microphoneMutedIcon, microphoneMutedIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isLinearElement, isTextElement } from "@excalidraw/element"; import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
import { arrayToMap, KEYS } from "@excalidraw/common"; import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element"; import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../store";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { register } from "./register"; import { register } from "./register";

View File

@ -7,7 +7,7 @@ import {
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -17,14 +17,12 @@ import {
isArrowElement, isArrowElement,
isExcalidrawElement, isExcalidrawElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { import {
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawTextElement } from "@excalidraw/element/types"; import type { ExcalidrawTextElement } from "@excalidraw/element/types";
@ -32,6 +30,7 @@ import { paintIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { import {
@ -7,6 +5,7 @@ import {
convertElementTypePopupAtom, convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup"; } from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai"; import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "@excalidraw/element"; import type { CaptureUpdateActionType } from "../store";
import type { import type {
AppClassProperties, AppClassProperties,
AppState, AppState,

View File

@ -121,9 +121,7 @@ export const getDefaultAppState = (): Omit<
followedBy: new Set(), followedBy: new Set(),
isCropping: false, isCropping: false,
croppingElementId: null, croppingElementId: null,
searchMatches: null, searchMatches: [],
lockedMultiSelections: {},
activeLockedId: null,
}; };
}; };
@ -248,8 +246,6 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false }, isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

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

View File

@ -15,7 +15,7 @@ import {
newTextElement, newTextElement,
newLinearElement, newLinearElement,
newElement, newElement,
} from "@excalidraw/element"; } from "@excalidraw/element/newElement";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";

View File

@ -7,14 +7,14 @@ import {
isPromiseLike, isPromiseLike,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element/duplicate";
import { import {
isFrameLikeElement, isFrameLikeElement,
isInitializedImageElement, isInitializedImageElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { getContainingFrame } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element/frame";
import type { import type {
ExcalidrawElement, ExcalidrawElement,

View File

@ -11,7 +11,7 @@ import {
import { import {
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -19,9 +19,9 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -154,7 +154,7 @@ export const SelectedShapeActions = ({
!isSingleElementBoundContainer && alignActionsPredicate(appState, app); !isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return ( return (
<div className="selected-shape-actions"> <div className="panelColumn">
<div> <div>
{canChangeStrokeColor(appState, targetElements) && {canChangeStrokeColor(appState, targetElements) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}

View File

@ -101,10 +101,12 @@ import {
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; import {
getCommonBounds,
getElementAbsoluteCoords,
} from "@excalidraw/element/bounds";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
@ -117,11 +119,11 @@ import {
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
updateBoundElements, updateBoundElements,
getSuggestedBindingsForArrows, getSuggestedBindingsForArrows,
} from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { import {
newFrameElement, newFrameElement,
@ -135,9 +137,12 @@ import {
newLinearElement, newLinearElement,
newTextElement, newTextElement,
refreshTextDimensions, refreshTextDimensions,
} from "@excalidraw/element"; } from "@excalidraw/element/newElement";
import { deepCopyElement, duplicateElements } from "@excalidraw/element"; import {
deepCopyElement,
duplicateElements,
} from "@excalidraw/element/duplicate";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -160,7 +165,7 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
isBindableElement, isBindableElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element/typeChecks";
import { import {
getLockedLinearCursorAlignSize, getLockedLinearCursorAlignSize,
@ -168,28 +173,28 @@ import {
isElementCompletelyInViewport, isElementCompletelyInViewport,
isElementInViewport, isElementInViewport,
isInvisiblySmallElement, isInvisiblySmallElement,
} from "@excalidraw/element"; } from "@excalidraw/element/sizeHelpers";
import { import {
getBoundTextShape, getBoundTextShape,
getCornerRadius, getCornerRadius,
getElementShape, getElementShape,
isPathALoop, isPathALoop,
} from "@excalidraw/element"; } from "@excalidraw/element/shapes";
import { import {
createSrcDoc, createSrcDoc,
embeddableURLValidator, embeddableURLValidator,
maybeParseEmbedSrc, maybeParseEmbedSrc,
getEmbedLink, getEmbedLink,
} from "@excalidraw/element"; } from "@excalidraw/element/embeddable";
import { import {
getInitializedImageElements, getInitializedImageElements,
loadHTMLImageElement, loadHTMLImageElement,
normalizeSVG, normalizeSVG,
updateImageCache as _updateImageCache, updateImageCache as _updateImageCache,
} from "@excalidraw/element"; } from "@excalidraw/element/image";
import { import {
getBoundTextElement, getBoundTextElement,
@ -197,9 +202,9 @@ import {
getContainerElement, getContainerElement,
isValidTextContainer, isValidTextContainer,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element/textElement";
import { shouldShowBoundingBox } from "@excalidraw/element"; import { shouldShowBoundingBox } from "@excalidraw/element/transformHandles";
import { import {
getFrameChildren, getFrameChildren,
@ -216,27 +221,30 @@ import {
getFrameLikeTitle, getFrameLikeTitle,
getElementsOverlappingFrame, getElementsOverlappingFrame,
filterElementsEligibleAsFrameChildren, filterElementsEligibleAsFrameChildren,
} from "@excalidraw/element"; } from "@excalidraw/element/frame";
import { import {
hitElementBoundText, hitElementBoundText,
hitElementBoundingBoxOnly, hitElementBoundingBoxOnly,
hitElementItself, hitElementItself,
} from "@excalidraw/element"; } from "@excalidraw/element/collision";
import { getVisibleSceneBounds } from "@excalidraw/element"; import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
getLinkDirectionFromKey, getLinkDirectionFromKey,
} from "@excalidraw/element"; } from "@excalidraw/element/flowchart";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element/cropElement";
import { wrapText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element/textWrapping";
import { isElementLink, parseElementLinkFromURL } from "@excalidraw/element"; import {
isElementLink,
parseElementLinkFromURL,
} from "@excalidraw/element/elementLink";
import { import {
isMeasureTextSupported, isMeasureTextSupported,
@ -246,11 +254,11 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
getMinTextElementWidth, getMinTextElementWidth,
} from "@excalidraw/element"; } from "@excalidraw/element/textMeasurements";
import { ShapeCache } from "@excalidraw/element"; import { ShapeCache } from "@excalidraw/element/ShapeCache";
import { getRenderOpacity } from "@excalidraw/element"; import { getRenderOpacity } from "@excalidraw/element/renderElement";
import { import {
editGroupForSelectedElement, editGroupForSelectedElement,
@ -260,41 +268,42 @@ import {
isElementInGroup, isElementInGroup,
isSelectedViaGroup, isSelectedViaGroup,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element"; } from "@excalidraw/element/groups";
import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element"; import {
syncInvalidIndices,
syncMovedIndices,
} from "@excalidraw/element/fractionalIndex";
import { import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectionStateForElements, getSelectionStateForElements,
makeNextSelectedElementIds, makeNextSelectedElementIds,
} from "@excalidraw/element"; } from "@excalidraw/element/selection";
import { import {
getResizeOffsetXY, getResizeOffsetXY,
getResizeArrowDirection, getResizeArrowDirection,
transformElements, transformElements,
} from "@excalidraw/element"; } from "@excalidraw/element/resizeElements";
import { import {
getCursorForResizingElement, getCursorForResizingElement,
getElementWithTransformHandleType, getElementWithTransformHandleType,
getTransformHandleTypeFromCoords, getTransformHandleTypeFromCoords,
} from "@excalidraw/element"; } from "@excalidraw/element/resizeTest";
import { import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
getDragOffsetXY, getDragOffsetXY,
} from "@excalidraw/element"; } from "@excalidraw/element/dragElements";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { Scene } from "@excalidraw/element"; import Scene from "@excalidraw/element/Scene";
import { Store, CaptureUpdateAction } from "@excalidraw/element"; import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { ElementUpdate } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -322,7 +331,6 @@ import type {
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@ -446,7 +454,9 @@ import {
resetCursor, resetCursor,
setCursorForShape, setCursorForShape,
} from "../cursor"; } from "../cursor";
import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { Store, CaptureUpdateAction } from "../store";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@ -485,8 +495,6 @@ import { Toast } from "./Toast";
import { findShapeByKey } from "./shapes"; import { findShapeByKey } from "./shapes";
import UnlockPopup from "./UnlockPopup";
import type { import type {
RenderInteractiveSceneCallback, RenderInteractiveSceneCallback,
ScrollBars, ScrollBars,
@ -753,8 +761,8 @@ class App extends React.Component<AppProps, AppState> {
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
this.visibleElements = []; this.visibleElements = [];
this.store = new Store(this); this.store = new Store();
this.history = new History(this.store); this.history = new History();
if (excalidrawAPI) { if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = { const api: ExcalidrawImperativeAPI = {
@ -784,7 +792,6 @@ class App extends React.Component<AppProps, AppState> {
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@ -803,11 +810,15 @@ class App extends React.Component<AppProps, AppState> {
}; };
this.fonts = new Fonts(this.scene); this.fonts = new Fonts(this.scene);
this.history = new History(this.store); this.history = new History();
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(
this.actionManager.registerAction(createRedoAction(this.history)); createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
} }
updateEditorAtom = <Value, Args extends unknown[], Result>( updateEditorAtom = <Value, Args extends unknown[], Result>(
@ -1407,19 +1418,8 @@ class App extends React.Component<AppProps, AppState> {
} }
const isDarkTheme = this.state.theme === THEME.DARK; const isDarkTheme = this.state.theme === THEME.DARK;
const nonDeletedFramesLikes = this.scene.getNonDeletedFramesLikes();
const focusedSearchMatch = return this.scene.getNonDeletedFramesLikes().map((f) => {
nonDeletedFramesLikes.length > 0
? this.state.searchMatches?.focusedId &&
isFrameLikeElement(
this.scene.getElement(this.state.searchMatches.focusedId),
)
? this.state.searchMatches.matches.find((sm) => sm.focus)
: null
: null;
return nonDeletedFramesLikes.map((f) => {
if ( if (
!isElementInViewport( !isElementInViewport(
f, f,
@ -1485,7 +1485,7 @@ class App extends React.Component<AppProps, AppState> {
borderRadius: 4, borderRadius: 4,
boxShadow: "inset 0 0 0 1px var(--color-primary)", boxShadow: "inset 0 0 0 1px var(--color-primary)",
fontFamily: "Assistant", fontFamily: "Assistant",
fontSize: `${FRAME_STYLE.nameFontSize}px`, fontSize: "14px",
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`, transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)", color: "var(--color-gray-80)",
overflow: "hidden", overflow: "hidden",
@ -1529,10 +1529,7 @@ class App extends React.Component<AppProps, AppState> {
: FRAME_STYLE.nameColorLightTheme, : FRAME_STYLE.nameColorLightTheme,
lineHeight: FRAME_STYLE.nameLineHeight, lineHeight: FRAME_STYLE.nameLineHeight,
width: "max-content", width: "max-content",
maxWidth: maxWidth: `${f.width}px`,
focusedSearchMatch?.id === f.id && focusedSearchMatch?.focus
? "none"
: `${f.width * this.state.zoom.value}px`,
overflow: f.id === this.state.editingFrame ? "visible" : "hidden", overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
whiteSpace: "nowrap", whiteSpace: "nowrap",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@ -1878,12 +1875,6 @@ class App extends React.Component<AppProps, AppState> {
/> />
)} )}
{this.renderFrameNames()} {this.renderFrameNames()}
{this.state.activeLockedId && (
<UnlockPopup
app={this}
activeLockedId={this.state.activeLockedId}
/>
)}
{showShapeSwitchPanel && ( {showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} /> <ConvertElementTypePopup app={this} />
)} )}
@ -1908,10 +1899,6 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getElementsIncludingDeleted(); return this.scene.getElementsIncludingDeleted();
}; };
public getSceneElementsMapIncludingDeleted = () => {
return this.scene.getElementsMapIncludingDeleted();
};
public getSceneElements = () => { public getSceneElements = () => {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
@ -2228,7 +2215,11 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
this.store.scheduleAction(actionResult.captureUpdate); if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.shouldCaptureIncrement();
}
let didUpdate = false; let didUpdate = false;
@ -2301,7 +2292,10 @@ class App extends React.Component<AppProps, AppState> {
didUpdate = true; didUpdate = true;
} }
if (!didUpdate) { if (
!didUpdate &&
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
this.scene.triggerUpdate(); this.scene.triggerUpdate();
} }
}); });
@ -2553,19 +2547,10 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
this.store.onDurableIncrementEmitter.on((increment) => { this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.delta); this.history.record(increment.elementsChange, increment.appStateChange);
}); });
const { onIncrement } = this.props;
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
if (onIncrement) {
this.store.onStoreIncrementEmitter.on((increment) => {
onIncrement(increment);
});
}
this.scene.onUpdate(this.triggerRender); this.scene.onUpdate(this.triggerRender);
this.addEventListeners(); this.addEventListeners();
@ -2625,7 +2610,6 @@ class App extends React.Component<AppProps, AppState> {
this.eraserTrail.stop(); this.eraserTrail.stop();
this.onChangeEmitter.clear(); this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear(); this.store.onStoreIncrementEmitter.clear();
this.store.onDurableIncrementEmitter.clear();
ShapeCache.destroy(); ShapeCache.destroy();
SnapCache.destroy(); SnapCache.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
@ -2919,7 +2903,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement && this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId] !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) { ) {
// defer so that the scheduleCapture flag isn't reset via current update // defer so that the shouldCaptureIncrement flag isn't reset via current update
setTimeout(() => { setTimeout(() => {
// execute only if the condition still holds when the deferred callback // execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how // executes (it can be scheduled multiple times depending on how
@ -3374,7 +3358,7 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files); this.addMissingFiles(opts.files);
} }
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
const nextElementsToSelect = const nextElementsToSelect =
excludeElementsInFramesFromSelection(duplicatedElements); excludeElementsInFramesFromSelection(duplicatedElements);
@ -3635,7 +3619,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true; PLAIN_PASTE_TOAST_SHOWN = true;
} }
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
setAppState: React.Component<any, AppState>["setState"] = ( setAppState: React.Component<any, AppState>["setState"] = (
@ -3991,37 +3975,51 @@ class App extends React.Component<AppProps, AppState> {
*/ */
captureUpdate?: SceneData["captureUpdate"]; captureUpdate?: SceneData["captureUpdate"];
}) => { }) => {
const { elements, appState, collaborators, captureUpdate } = sceneData; const nextElements = syncInvalidIndices(sceneData.elements ?? []);
const nextElements = elements ? syncInvalidIndices(elements) : undefined; if (
sceneData.captureUpdate &&
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;
if (captureUpdate) { const nextCommittedAppState = sceneData.appState
const nextElementsMap = elements ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
? (arrayToMap(nextElements ?? []) as SceneElementsMap) : prevCommittedAppState;
: undefined;
const nextAppState = appState const nextCommittedElements = sceneData.elements
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` ? this.store.filterUncomittedElements(
Object.assign({}, this.store.snapshot.appState, appState) this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
: undefined; arrayToMap(nextElements), // We expect all (already reconciled) elements
)
: prevCommittedElements;
this.store.scheduleMicroAction({ // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
action: captureUpdate, // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
elements: nextElementsMap, if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
appState: nextAppState, this.store.captureIncrement(
}); nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.updateSnapshot(
nextCommittedElements,
nextCommittedAppState,
);
}
} }
if (appState) { if (sceneData.appState) {
this.setState(appState); this.setState(sceneData.appState);
} }
if (nextElements) { if (sceneData.elements) {
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
} }
if (collaborators) { if (sceneData.collaborators) {
this.setState({ collaborators }); this.setState({ collaborators: sceneData.collaborators });
} }
}, },
); );
@ -4204,7 +4202,7 @@ class App extends React.Component<AppProps, AppState> {
direction: event.shiftKey ? "left" : "right", direction: event.shiftKey ? "left" : "right",
}) })
) { ) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
} }
if (conversionType) { if (conversionType) {
@ -4521,7 +4519,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !== this.state.editingLinearElement.elementId !==
selectedElements[0].id selectedElements[0].id
) { ) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
if (!isElbowArrow(selectedElement)) { if (!isElbowArrow(selectedElement)) {
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor( editingLinearElement: new LinearElementEditor(
@ -4847,7 +4845,7 @@ class App extends React.Component<AppProps, AppState> {
} as const; } as const;
if (nextActiveTool.type === "freedraw") { if (nextActiveTool.type === "freedraw") {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
if (nextActiveTool.type === "lasso") { if (nextActiveTool.type === "lasso") {
@ -5064,7 +5062,7 @@ class App extends React.Component<AppProps, AppState> {
]); ]);
} }
if (!isDeleted || isExistingElement) { if (!isDeleted || isExistingElement) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
flushSync(() => { flushSync(() => {
@ -5122,27 +5120,18 @@ class App extends React.Component<AppProps, AppState> {
private getElementAtPosition( private getElementAtPosition(
x: number, x: number,
y: number, y: number,
opts?: ( opts?: {
| {
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
}
| {
allHitElements: NonDeleted<ExcalidrawElement>[];
}
) & {
preferSelected?: boolean; preferSelected?: boolean;
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
}, },
): NonDeleted<ExcalidrawElement> | null { ): NonDeleted<ExcalidrawElement> | null {
let allHitElements: NonDeleted<ExcalidrawElement>[] = []; const allHitElements = this.getElementsAtPosition(
if (opts && "allHitElements" in opts) { x,
allHitElements = opts?.allHitElements || []; y,
} else { opts?.includeBoundTextElement,
allHitElements = this.getElementsAtPosition(x, y, { opts?.includeLockedElements,
includeBoundTextElement: opts?.includeBoundTextElement, );
includeLockedElements: opts?.includeLockedElements,
});
}
if (allHitElements.length > 1) { if (allHitElements.length > 1) {
if (opts?.preferSelected) { if (opts?.preferSelected) {
@ -5185,24 +5174,22 @@ class App extends React.Component<AppProps, AppState> {
private getElementsAtPosition( private getElementsAtPosition(
x: number, x: number,
y: number, y: number,
opts?: { includeBoundTextElement: boolean = false,
includeBoundTextElement?: boolean; includeLockedElements: boolean = false,
includeLockedElements?: boolean;
},
): NonDeleted<ExcalidrawElement>[] { ): NonDeleted<ExcalidrawElement>[] {
const iframeLikes: Ordered<ExcalidrawIframeElement>[] = []; const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const elements = ( const elements = (
opts?.includeBoundTextElement && opts?.includeLockedElements includeBoundTextElement && includeLockedElements
? this.scene.getNonDeletedElements() ? this.scene.getNonDeletedElements()
: this.scene : this.scene
.getNonDeletedElements() .getNonDeletedElements()
.filter( .filter(
(element) => (element) =>
(opts?.includeLockedElements || !element.locked) && (includeLockedElements || !element.locked) &&
(opts?.includeBoundTextElement || (includeBoundTextElement ||
!(isTextElement(element) && element.containerId)), !(isTextElement(element) && element.containerId)),
) )
) )
@ -5488,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private startImageCropping = (image: ExcalidrawImageElement) => { private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
this.setState({ this.setState({
croppingElementId: image.id, croppingElementId: image.id,
}); });
@ -5496,7 +5483,7 @@ class App extends React.Component<AppProps, AppState> {
private finishImageCropping = () => { private finishImageCropping = () => {
if (this.state.croppingElementId) { if (this.state.croppingElementId) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
this.setState({ this.setState({
croppingElementId: null, croppingElementId: null,
}); });
@ -5531,7 +5518,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id) && selectedElements[0].id) &&
!isElbowArrow(selectedElements[0]) !isElbowArrow(selectedElements[0])
) { ) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor( editingLinearElement: new LinearElementEditor(
selectedElements[0], selectedElements[0],
@ -5559,7 +5546,7 @@ class App extends React.Component<AppProps, AppState> {
: -1; : -1;
if (midPoint && midPoint > -1) { if (midPoint && midPoint > -1) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment( LinearElementEditor.deleteFixedSegment(
selectedElements[0], selectedElements[0],
this.scene, this.scene,
@ -5621,7 +5608,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) { if (selectedGroupId) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
this.setState((prevState) => ({ this.setState((prevState) => ({
...prevState, ...prevState,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@ -5688,21 +5675,14 @@ class App extends React.Component<AppProps, AppState> {
private getElementLinkAtPosition = ( private getElementLinkAtPosition = (
scenePointer: Readonly<{ x: number; y: number }>, scenePointer: Readonly<{ x: number; y: number }>,
hitElementMightBeLocked: NonDeletedExcalidrawElement | null, hitElement: NonDeletedExcalidrawElement | null,
): ExcalidrawElement | undefined => { ): ExcalidrawElement | undefined => {
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
return undefined;
}
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
let hitElementIndex = -1; let hitElementIndex = -1;
for (let index = elements.length - 1; index >= 0; index--) { for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index]; const element = elements[index];
if ( if (hitElement && element.id === hitElement.id) {
hitElementMightBeLocked &&
element.id === hitElementMightBeLocked.id
) {
hitElementIndex = index; hitElementIndex = index;
} }
if ( if (
@ -6184,25 +6164,14 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const hitElementMightBeLocked = this.getElementAtPosition( const hitElement = this.getElementAtPosition(
scenePointerX, scenePointer.x,
scenePointerY, scenePointer.y,
{
preferSelected: true,
includeLockedElements: true,
},
); );
let hitElement: ExcalidrawElement | null = null;
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
hitElement = null;
} else {
hitElement = hitElementMightBeLocked;
}
this.hitLinkElement = this.getElementLinkAtPosition( this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer, scenePointer,
hitElementMightBeLocked, hitElement,
); );
if (isEraserActive(this.state)) { if (isEraserActive(this.state)) {
return; return;
@ -6295,7 +6264,7 @@ class App extends React.Component<AppProps, AppState> {
selectGroupsForSelectedElements( selectGroupsForSelectedElements(
{ {
editingGroupId: prevState.editingGroupId, editingGroupId: prevState.editingGroupId,
selectedElementIds: { [hitElement!.id]: true }, selectedElementIds: { [hitElement.id]: true },
}, },
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
prevState, prevState,
@ -6444,17 +6413,12 @@ class App extends React.Component<AppProps, AppState> {
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) { if (this.state.searchMatches) {
this.setState((state) => { this.setState((state) => ({
return { searchMatches: state.searchMatches.map((searchMatch) => ({
searchMatches: state.searchMatches && { ...searchMatch,
focusedId: null, focus: false,
matches: state.searchMatches.matches.map((searchMatch) => ({ })),
...searchMatch, }));
focus: false,
})),
},
};
});
this.updateEditorAtom(searchItemInFocusAtom, null); this.updateEditorAtom(searchItemInFocusAtom, null);
} }
@ -6809,9 +6773,6 @@ class App extends React.Component<AppProps, AppState> {
const hitElement = this.getElementAtPosition( const hitElement = this.getElementAtPosition(
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
{
includeLockedElements: true,
},
); );
this.hitLinkElement = this.getElementLinkAtPosition( this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer, scenePointer,
@ -7247,57 +7208,17 @@ class App extends React.Component<AppProps, AppState> {
return true; return true;
} }
} }
// hitElement may already be set above, so check first
const allHitElements = this.getElementsAtPosition( pointerDownState.hit.element =
pointerDownState.origin.x, pointerDownState.hit.element ??
pointerDownState.origin.y, this.getElementAtPosition(
{ pointerDownState.origin.x,
includeLockedElements: true, pointerDownState.origin.y,
}, );
);
const unlockedHitElements = allHitElements.filter((e) => !e.locked);
// Cannot set preferSelected in getElementAtPosition as we do in pointer move; consider:
// A & B: both unlocked, A selected, B on top, A & B overlaps in some way
// we want to select B when clicking on the overlapping area
const hitElementMightBeLocked = this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
{
allHitElements,
},
);
if (
!hitElementMightBeLocked ||
hitElementMightBeLocked.id !== this.state.activeLockedId
) {
this.setState({
activeLockedId: null,
});
}
if (
hitElementMightBeLocked &&
hitElementMightBeLocked.locked &&
!unlockedHitElements.some(
(el) => this.state.selectedElementIds[el.id],
)
) {
pointerDownState.hit.element = null;
} else {
// hitElement may already be set above, so check first
pointerDownState.hit.element =
pointerDownState.hit.element ??
this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
}
this.hitLinkElement = this.getElementLinkAtPosition( this.hitLinkElement = this.getElementLinkAtPosition(
pointerDownState.origin, pointerDownState.origin,
hitElementMightBeLocked, pointerDownState.hit.element,
); );
if (this.hitLinkElement) { if (this.hitLinkElement) {
@ -7327,7 +7248,10 @@ class App extends React.Component<AppProps, AppState> {
// For overlapped elements one position may hit // For overlapped elements one position may hit
// multiple elements // multiple elements
pointerDownState.hit.allHitElements = unlockedHitElements; pointerDownState.hit.allHitElements = this.getElementsAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitElement = pointerDownState.hit.element; const hitElement = pointerDownState.hit.element;
const someHitElementIsSelected = const someHitElementIsSelected =
@ -7353,13 +7277,8 @@ class App extends React.Component<AppProps, AppState> {
}); });
// If we click on something // If we click on something
} else if (hitElement != null) { } else if (hitElement != null) {
// == deep selection ==
// on CMD/CTRL, drill down to hit element regardless of groups etc. // on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) { if (event[KEYS.CTRL_OR_CMD]) {
if (event.altKey) {
// ctrl + alt means we're lasso selecting
return false;
}
if (!this.state.selectedElementIds[hitElement.id]) { if (!this.state.selectedElementIds[hitElement.id]) {
pointerDownState.hit.wasAddedToSelection = true; pointerDownState.hit.wasAddedToSelection = true;
} }
@ -8143,12 +8062,6 @@ class App extends React.Component<AppProps, AppState> {
} }
const pointerCoords = viewportCoordsToSceneCoords(event, this.state); const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (this.state.activeLockedId) {
this.setState({
activeLockedId: null,
});
}
if ( if (
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed && this.state.selectedLinearElement.elbowed &&
@ -8724,19 +8637,17 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
if (event.altKey) { if (event.altKey) {
flushSync(() => { this.setActiveTool(
this.setActiveTool( { type: "lasso", fromSelection: true },
{ type: "lasso", fromSelection: true }, event.shiftKey,
event.shiftKey, );
); this.lassoTrail.startPath(
this.lassoTrail.startPath( pointerDownState.origin.x,
pointerDownState.origin.x, pointerDownState.origin.y,
pointerDownState.origin.y, event.shiftKey,
event.shiftKey, );
); this.setAppState({
this.setAppState({ selectionElement: null,
selectionElement: null,
});
}); });
} else { } else {
this.maybeDragNewGenericElement(pointerDownState, event); this.maybeDragNewGenericElement(pointerDownState, event);
@ -9030,49 +8941,6 @@ class App extends React.Component<AppProps, AppState> {
this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
// if current elements are still selected
// and the pointer is just over a locked element
// do not allow activeLockedId to be set
const hitElements = pointerDownState.hit.allHitElements;
if (
this.state.activeTool.type === "selection" &&
!pointerDownState.boxSelection.hasOccurred &&
!pointerDownState.resize.isResizing &&
!hitElements.some((el) => this.state.selectedElementIds[el.id])
) {
const sceneCoords = viewportCoordsToSceneCoords(
{ clientX: childEvent.clientX, clientY: childEvent.clientY },
this.state,
);
const hitLockedElement = this.getElementAtPosition(
sceneCoords.x,
sceneCoords.y,
{
includeLockedElements: true,
},
);
this.store.scheduleCapture();
if (hitLockedElement?.locked) {
this.setState({
activeLockedId:
hitLockedElement.groupIds.length > 0
? hitLockedElement.groupIds.at(-1) || ""
: hitLockedElement.id,
});
} else {
this.setState({
activeLockedId: null,
});
}
} else {
this.setState({
activeLockedId: null,
});
}
this.setState({ this.setState({
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
@ -9263,7 +9131,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) { if (newElement!.points.length > 1) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
const pointerCoords = viewportCoordsToSceneCoords( const pointerCoords = viewportCoordsToSceneCoords(
childEvent, childEvent,
@ -9536,7 +9404,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (resizingElement) { if (resizingElement) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
if (resizingElement && isInvisiblySmallElement(resizingElement)) { if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@ -9654,10 +9522,7 @@ class App extends React.Component<AppProps, AppState> {
// if we're editing a line, pointerup shouldn't switch selection if // if we're editing a line, pointerup shouldn't switch selection if
// box selected // box selected
(!this.state.editingLinearElement || (!this.state.editingLinearElement ||
!pointerDownState.boxSelection.hasOccurred) && !pointerDownState.boxSelection.hasOccurred)
// hitElement can be set when alt + ctrl to toggle lasso and we will
// just respect the selected elements from lasso instead
this.state.activeTool.type !== "lasso"
) { ) {
// when inside line editor, shift selects points instead // when inside line editor, shift selects points instead
if (childEvent.shiftKey && !this.state.editingLinearElement) { if (childEvent.shiftKey && !this.state.editingLinearElement) {
@ -9879,7 +9744,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds, this.state.selectedElementIds,
) )
) { ) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
} }
if ( if (
@ -9972,7 +9837,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set(); this.elementsPendingErasure = new Set();
if (didChange) { if (didChange) {
this.store.scheduleCapture(); this.store.shouldCaptureIncrement();
this.scene.replaceAllElements(elements); this.scene.replaceAllElements(elements);
} }
}; };
@ -10652,13 +10517,8 @@ class App extends React.Component<AppProps, AppState> {
// restore the fractional indices by mutating elements // restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements)); syncInvalidIndices(elements.concat(ret.data.elements));
// don't capture and only update the store snapshot for old elements, // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
// otherwise we would end up with duplicated fractional indices on undo this.store.updateSnapshot(arrayToMap(elements), this.state);
this.store.scheduleMicroAction({
action: CaptureUpdateAction.NEVER,
elements: arrayToMap(elements) as SceneElementsMap,
appState: undefined,
});
this.setState({ isLoading: true }); this.setState({ isLoading: true });
this.syncActionResult({ this.syncActionResult({

View File

@ -4,7 +4,8 @@ import { ButtonIcon } from "./ButtonIcon";
import type { JSX } from "react"; import type { JSX } from "react";
export const RadioSelection = <T extends Object>( // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
props: { props: {
options: { options: {
value: T; value: T;
@ -27,7 +28,7 @@ export const RadioSelection = <T extends Object>(
} }
), ),
) => ( ) => (
<> <div className="buttonList">
{props.options.map((option) => {props.options.map((option) =>
props.type === "button" ? ( props.type === "button" ? (
<ButtonIcon <ButtonIcon
@ -55,5 +56,5 @@ export const RadioSelection = <T extends Object>(
</label> </label>
), ),
)} )}
</> </div>
); );

View File

@ -0,0 +1,30 @@
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => (
<div className="buttonList">
{options.map((option) => (
<label
key={option.text}
className={clsx({ active: value === option.value })}
>
<input
type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
/>
{option.text}
</label>
))}
</div>
);

View File

@ -19,7 +19,6 @@ interface ColorInputProps {
onChange: (color: string) => void; onChange: (color: string) => void;
label: string; label: string;
colorPickerType: ColorPickerType; colorPickerType: ColorPickerType;
placeholder?: string;
} }
export const ColorInput = ({ export const ColorInput = ({
@ -27,7 +26,6 @@ export const ColorInput = ({
onChange, onChange,
label, label,
colorPickerType, colorPickerType,
placeholder,
}: ColorInputProps) => { }: ColorInputProps) => {
const device = useDevice(); const device = useDevice();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
@ -95,7 +93,6 @@ export const ColorInput = ({
} }
event.stopPropagation(); event.stopPropagation();
}} }}
placeholder={placeholder}
/> />
{/* TODO reenable on mobile with a better UX */} {/* TODO reenable on mobile with a better UX */}
{!device.editor.isMobile && ( {!device.editor.isMobile && (

View File

@ -69,17 +69,6 @@
} }
} }
.color-picker__button-outline {
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--color-gray-60);
width: 1.25rem;
height: 1.25rem;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;

View File

@ -18,7 +18,6 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon } from "../icons";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@ -55,11 +54,7 @@ export const getColor = (color: string): string | null => {
interface ColorPickerProps { interface ColorPickerProps {
type: ColorPickerType; type: ColorPickerType;
/** color: string;
* null indicates no color should be displayed as active
* (e.g. when multiple shapes selected with different colors)
*/
color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string; label: string;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@ -96,21 +91,22 @@ const ColorPickerPopupContent = ({
<div> <div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput <ColorInput
color={color || ""} color={color}
label={label} label={label}
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
}} }}
colorPickerType={type} colorPickerType={type}
placeholder={t("colorPicker.color")}
/> />
</div> </div>
); );
const colorPickerContentRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => { const focusPickerContent = () => {
colorPickerContentRef.current?.focus(); popoverRef.current
?.querySelector<HTMLDivElement>(".color-picker-content")
?.focus();
}; };
return ( return (
@ -137,7 +133,6 @@ const ColorPickerPopupContent = ({
> >
{palette ? ( {palette ? (
<Picker <Picker
ref={colorPickerContentRef}
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
@ -171,6 +166,7 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
label={label}
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
@ -189,7 +185,7 @@ const ColorPickerTrigger = ({
color, color,
type, type,
}: { }: {
color: string | null; color: string;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
}) => { }) => {
@ -197,9 +193,8 @@ const ColorPickerTrigger = ({
<Popover.Trigger <Popover.Trigger
type="button" type="button"
className={clsx("color-picker__button active-color properties-trigger", { className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": !color || color === "transparent", "is-transparent": color === "transparent" || !color,
"has-outline": "has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}
@ -209,7 +204,7 @@ const ColorPickerTrigger = ({
: t("labels.showBackground") : t("labels.showBackground")
} }
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline" />
</Popover.Trigger> </Popover.Trigger>
); );
}; };

View File

@ -8,7 +8,7 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils";
interface CustomColorListProps { interface CustomColorListProps {
colors: string[]; colors: string[];
color: string | null; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string; label: string;
} }

View File

@ -1,4 +1,4 @@
import React, { useEffect, useImperativeHandle, useState } from "react"; import React, { useEffect, useState } from "react";
import { EVENT } from "@excalidraw/common"; import { EVENT } from "@excalidraw/common";
@ -30,8 +30,9 @@ import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface PickerProps { interface PickerProps {
color: string | null; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string;
type: ColorPickerType; type: ColorPickerType;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
@ -41,150 +42,142 @@ interface PickerProps {
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
} }
export const Picker = React.forwardRef( export const Picker = ({
( color,
{ onChange,
color, label,
onChange, type,
type, elements,
elements, palette,
palette, updateData,
updateData, children,
children, onEyeDropperToggle,
onEyeDropperToggle, onEscape,
onEscape, }: PickerProps) => {
}: PickerProps, const [customColors] = React.useState(() => {
ref, if (type === "canvasBackground") {
) => { return [];
const [customColors] = React.useState(() => { }
if (type === "canvasBackground") { return getMostUsedCustomColors(elements, type, palette);
return []; });
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = isCustomColor({ color, palette });
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
? "hex"
: isCustom
? "custom"
: colorObj?.shade != null
? "shades"
: "baseColors",
);
}
}, [
activeColorPickerSection,
color,
palette,
setActiveColorPickerSection,
colorObj,
customColors,
]);
const [activeShade, setActiveShade] = useState(
colorObj?.shade ??
(type === "elementBackground"
? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX
: DEFAULT_ELEMENT_STROKE_COLOR_INDEX),
);
useEffect(() => {
if (colorObj?.shade != null) {
setActiveShade(colorObj.shade);
}
const keyup = (event: KeyboardEvent) => {
if (event.key === KEYS.ALT) {
onEyeDropperToggle(false);
} }
return getMostUsedCustomColors(elements, type, palette); };
}); document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
return () => {
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
};
}, [colorObj, onEyeDropperToggle]);
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( const pickerRef = React.useRef<HTMLDivElement>(null);
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromColor({ return (
color, <div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
palette, <div
}); ref={pickerRef}
onKeyDown={(event) => {
useEffect(() => { const handled = colorPickerKeyNavHandler({
if (!activeColorPickerSection) { event,
const isCustom = !!color && isCustomColor({ color, palette }); activeColorPickerSection,
const isCustomButNotInList = isCustom && !customColors.includes(color); palette,
color,
setActiveColorPickerSection( onChange,
isCustomButNotInList onEyeDropperToggle,
? null customColors,
: isCustom setActiveColorPickerSection,
? "custom" updateData,
: colorObj?.shade != null activeShade,
? "shades" onEscape,
: "baseColors", });
);
}
}, [
activeColorPickerSection,
color,
palette,
setActiveColorPickerSection,
colorObj,
customColors,
]);
const [activeShade, setActiveShade] = useState(
colorObj?.shade ??
(type === "elementBackground"
? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX
: DEFAULT_ELEMENT_STROKE_COLOR_INDEX),
);
useEffect(() => {
if (colorObj?.shade != null) {
setActiveShade(colorObj.shade);
}
const keyup = (event: KeyboardEvent) => {
if (event.key === KEYS.ALT) {
onEyeDropperToggle(false);
}
};
document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
return () => {
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
};
}, [colorObj, onEyeDropperToggle]);
const pickerRef = React.useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => pickerRef.current!);
useEffect(() => {
pickerRef?.current?.focus();
}, []);
return (
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
<div
ref={pickerRef}
onKeyDown={(event) => {
const handled = colorPickerKeyNavHandler({
event,
activeColorPickerSection,
palette,
color,
onChange,
onEyeDropperToggle,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEscape,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{!!customColors.length && (
<div>
<PickerHeading>
{t("colorPicker.mostUsedCustomColors")}
</PickerHeading>
<CustomColorList
colors={customColors}
color={color}
label={t("colorPicker.mostUsedCustomColors")}
onChange={onChange}
/>
</div>
)}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{!!customColors.length && (
<div> <div>
<PickerHeading>{t("colorPicker.colors")}</PickerHeading> <PickerHeading>
<PickerColorList {t("colorPicker.mostUsedCustomColors")}
</PickerHeading>
<CustomColorList
colors={customColors}
color={color} color={color}
palette={palette} label={t("colorPicker.mostUsedCustomColors")}
onChange={onChange} onChange={onChange}
activeShade={activeShade}
/> />
</div> </div>
)}
<div> <div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading> <PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<ShadeList color={color} onChange={onChange} palette={palette} /> <PickerColorList
</div> color={color}
{children} label={label}
palette={palette}
onChange={onChange}
activeShade={activeShade}
/>
</div> </div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList hex={color} onChange={onChange} palette={palette} />
</div>
{children}
</div> </div>
); </div>
}, );
); };

View File

@ -17,8 +17,9 @@ import type { TranslationKeys } from "../../i18n";
interface PickerColorListProps { interface PickerColorListProps {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
color: string | null; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string;
activeShade: number; activeShade: number;
} }
@ -26,10 +27,11 @@ const PickerColorList = ({
palette, palette,
color, color,
onChange, onChange,
label,
activeShade, activeShade,
}: PickerColorListProps) => { }: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color, color: color || "transparent",
palette, palette,
}); });
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(

View File

@ -13,14 +13,14 @@ import {
} from "./colorPickerUtils"; } from "./colorPickerUtils";
interface ShadeListProps { interface ShadeListProps {
color: string | null; hex: string;
onChange: (color: string) => void; onChange: (color: string) => void;
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
} }
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent", color: hex || "transparent",
palette, palette,
}); });

View File

@ -14,7 +14,7 @@ import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
onChange: (color: string) => void; onChange: (color: string) => void;
type: ColorPickerType; type: ColorPickerType;
activeColor: string | null; activeColor: string;
topPicks?: readonly string[]; topPicks?: readonly string[];
} }

View File

@ -11,14 +11,11 @@ export const getColorNameAndShadeFromColor = ({
color, color,
}: { }: {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
color: string | null; color: string;
}): { }): {
colorName: ColorPickerColor; colorName: ColorPickerColor;
shade: number | null; shade: number | null;
} | null => { } | null => {
if (!color) {
return null;
}
for (const [colorName, colorVal] of Object.entries(palette)) { for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) { if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(color); const shade = colorVal.indexOf(color);

View File

@ -109,7 +109,7 @@ interface ColorPickerKeyNavHandlerProps {
event: React.KeyboardEvent; event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType; activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
color: string | null; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
customColors: string[]; customColors: string[];
setActiveColorPickerSection: ( setActiveColorPickerSection: (
@ -270,7 +270,7 @@ export const colorPickerKeyNavHandler = ({
} }
if (activeColorPickerSection === "custom") { if (activeColorPickerSection === "custom") {
const indexOfColor = color != null ? customColors.indexOf(color) : 0; const indexOfColor = customColors.indexOf(color);
const newColorIndex = arrowHandler( const newColorIndex = arrowHandler(
event.key, event.key,

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