Compare commits
2 Commits
master
...
dwelle/bet
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a050e87c04 | ||
![]() |
32da1819f9 |
@ -1,5 +1,5 @@
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||
VITE_APP_BACKEND_V2_POST_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://json.excalidraw.com/api/v2/post/
|
||||
|
||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
@ -63,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
||||
- 🏗️ Customizable.
|
||||
- 📷 Image support.
|
||||
- 😀 Shape libraries support.
|
||||
- 🌐 Localization (i18n) support.
|
||||
- 👅 Localization (i18n) support.
|
||||
- 🖼️ Export to PNG, SVG & clipboard.
|
||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||
|
@ -19,7 +19,7 @@ services:
|
||||
- ./:/opt/node_app/app:delegated
|
||||
- ./package.json:/opt/node_app/package.json
|
||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||
# - notused:/opt/node_app/app/node_modules
|
||||
- notused:/opt/node_app/app/node_modules
|
||||
|
||||
# volumes:
|
||||
# notused:
|
||||
volumes:
|
||||
notused:
|
||||
|
@ -52,7 +52,7 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.excalidraw .selected-shape-actions {
|
||||
.excalidraw .panelColumn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
@ -47,10 +47,10 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} 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 { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@ -926,21 +926,16 @@ const ExcalidrawWrapper = () => {
|
||||
<ShareDialog
|
||||
collabAPI={collabAPI}
|
||||
onExportToBackend={async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
await onExportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
setLatestShareableLink(url);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -19,9 +19,12 @@ import {
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
generateEncryptionKey,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
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 type {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
@ -9,14 +9,14 @@ import {
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { bytesToHexString } from "@excalidraw/common";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { SceneBounds } from "@excalidraw/element";
|
||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
|
@ -41,8 +41,8 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "vite build",
|
||||
"build:app": "vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true 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": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
|
@ -3,15 +3,11 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} 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 { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { StoreIncrement } from "@excalidraw/element";
|
||||
|
||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
const { h } = window;
|
||||
@ -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.
|
||||
*/
|
||||
describe("collaboration", () => {
|
||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||
const durableIncrements: DurableIncrement[] = [];
|
||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
durableIncrements.push(increment);
|
||||
} else {
|
||||
ephemeralIncrements.push(increment);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
expect(durableIncrements.length).toBe(0);
|
||||
expect(ephemeralIncrements.length).toBe(0);
|
||||
|
||||
const rectProps = {
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
height: 200,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as const;
|
||||
|
||||
const rect = API.createElement({ ...rectProps });
|
||||
|
||||
API.updateScene({
|
||||
elements: [rect],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(durableIncrements.length).toBe(1);
|
||||
});
|
||||
|
||||
// simulate two batched remote updates
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// we scheduled two micro actions,
|
||||
// which confirms they are going to be executed as part of one batched component update
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// altough the updates get batched,
|
||||
// we expect two ephemeral increments for each update,
|
||||
// and each such update should have the expected change
|
||||
expect(ephemeralIncrements.length).toBe(2);
|
||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 100 }),
|
||||
);
|
||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 200 }),
|
||||
);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
const rect1Props = {
|
||||
@ -199,7 +122,7 @@ describe("collaboration", () => {
|
||||
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));
|
||||
|
||||
// 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)]);
|
||||
});
|
||||
|
||||
const redoAction = createRedoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||
|
@ -33,7 +33,6 @@
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
@ -79,8 +78,8 @@
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.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:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
|
@ -13,7 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts"
|
||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@ -50,7 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
@ -144,7 +143,6 @@ export const FONT_FAMILY = {
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
Assistant: 10,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_FALLBACKS = {
|
||||
@ -256,7 +254,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const getExportSource = () =>
|
||||
export const EXPORT_SOURCE =
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
|
@ -22,10 +22,8 @@ export interface FontMetadata {
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/**
|
||||
* whether this is a font that users can use (= shown in font picker)
|
||||
*/
|
||||
private?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
serverSide?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
@ -46,7 +44,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.35,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
@ -100,23 +98,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
descender: -434,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1021,
|
||||
descender: -287,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
private: true,
|
||||
serverSide: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
|
@ -9,4 +9,3 @@ export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
|
@ -69,11 +69,27 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
/** Strip all the methods or functions from a type */
|
||||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
// utlity types for filter helper and related data structures
|
||||
// -----------------------------------------------------------------------------
|
||||
export type ReadonlyArrayOrMap<
|
||||
T,
|
||||
K = T extends { id: string } ? T["id"] : string,
|
||||
> = readonly T[] | ReadonlyMap<K, T>;
|
||||
|
||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||
? [K, V]
|
||||
: never;
|
||||
export type GenericAccumulator<T = unknown> = Set<T> | Map<T, T> | Array<T>;
|
||||
export type ArrayAccumulator<T = unknown> = Array<T>;
|
||||
export type MapAccumulator<T = unknown, K = unknown> = Map<T, K>;
|
||||
export type SetAccumulator<T = unknown> = Set<T>;
|
||||
export type OutputAccumulator<
|
||||
Accumulator,
|
||||
OutputType,
|
||||
Attr extends keyof OutputType = never,
|
||||
> = Accumulator extends SetAccumulator
|
||||
? Set<[Attr] extends [never] ? OutputType : Attr>
|
||||
: Accumulator extends MapAccumulator
|
||||
? Map<
|
||||
OutputType extends { id: string } ? OutputType["id"] : string,
|
||||
[Attr] extends [never] ? OutputType : Attr
|
||||
>
|
||||
: Array<OutputType>;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -544,20 +544,6 @@ export const findLastIndex = <T>(
|
||||
return -1;
|
||||
};
|
||||
|
||||
/** returns the first non-null mapped value */
|
||||
export const mapFind = <T, K>(
|
||||
collection: readonly T[],
|
||||
iteratee: (value: T, index: number) => K | undefined | null,
|
||||
): K | undefined => {
|
||||
for (let idx = 0; idx < collection.length; idx++) {
|
||||
const result = iteratee(collection[idx], idx);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
@ -749,25 +735,6 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
||||
return acc;
|
||||
}, [] as Node<T>[]);
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an iterable.
|
||||
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||
*/
|
||||
export const toIterable = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): Iterable<T> => {
|
||||
return Array.isArray(values) ? values : values.values();
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an array.
|
||||
*/
|
||||
export const toArray = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): T[] => {
|
||||
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||
};
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||
|
||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
@ -1258,39 +1225,11 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
};
|
||||
|
||||
export const sizeOf = (
|
||||
value:
|
||||
| readonly unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map || value instanceof Set
|
||||
: value instanceof Map
|
||||
? value.size
|
||||
: Object.keys(value).length;
|
||||
};
|
||||
|
||||
export const reduceToCommonValue = <T, R = T>(
|
||||
collection: readonly T[] | ReadonlySet<T>,
|
||||
getValue?: (item: T) => R,
|
||||
): R | null => {
|
||||
if (sizeOf(collection) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||
|
||||
let commonValue: R | null = null;
|
||||
|
||||
for (const item of collection) {
|
||||
const value = valueExtractor(item);
|
||||
if ((commonValue === null || commonValue === value) && value != null) {
|
||||
commonValue = value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
@ -13,7 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/element/src/*.d.ts"
|
||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@ -50,7 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
@ -6,21 +6,25 @@ import {
|
||||
toBrandedType,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
toArray,
|
||||
isReadonlyArray,
|
||||
} from "@excalidraw/common";
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||
|
||||
import {
|
||||
orderByFractionalIndex,
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
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 {
|
||||
ExcalidrawElement,
|
||||
@ -105,7 +109,7 @@ const hashSelectionOpts = (
|
||||
// in our codebase
|
||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||
|
||||
export class Scene {
|
||||
class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// instance methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -264,13 +268,19 @@ export class Scene {
|
||||
}
|
||||
|
||||
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
|
||||
const _nextElements = toArray(nextElements);
|
||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
||||
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[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
validateIndicesThrottled(nextElements);
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elements = syncInvalidIndices(nextElements);
|
||||
this.elementsMap.clear();
|
||||
this.elements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
@ -454,3 +464,5 @@ export class Scene {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
export default Scene;
|
||||
|
@ -2,7 +2,7 @@ import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
@ -33,7 +33,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
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 {
|
||||
getCenterForBounds,
|
||||
@ -66,7 +66,7 @@ import {
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@ -84,7 +84,6 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPoint,
|
||||
FixedPointBinding,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
export type SuggestedBinding =
|
||||
@ -802,22 +801,28 @@ export const updateBoundElements = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (point) {
|
||||
return [
|
||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||
{ point },
|
||||
] as MapEntry<PointsPositionUpdates>;
|
||||
return {
|
||||
index:
|
||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||
point,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
).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
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
|
@ -26,7 +26,11 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import { filterElements } from "./utils";
|
||||
|
||||
import { getFrameChildren } from "./frame";
|
||||
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
@ -65,23 +69,13 @@ export const dragSelectedElements = (
|
||||
return true;
|
||||
});
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
// update frames and their children (use a set to make sure we avoid
|
||||
// duplicates in case the user already selected the frame's children)
|
||||
const elementsToUpdate = getFrameChildren(
|
||||
scene.getNonDeletedElements(),
|
||||
filterElements(selectedElements, isFrameLikeElement, new Set(), "id"),
|
||||
new Set(selectedElements),
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameLikeElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
for (const element of scene.getNonDeletedElements()) {
|
||||
if (element.frameId !== null && frames.includes(element.frameId)) {
|
||||
elementsToUpdate.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
|
||||
|
@ -33,8 +33,6 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||
|
||||
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER =
|
||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||
@ -71,7 +69,6 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
@ -85,7 +82,6 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
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)) {
|
||||
const postId = link.match(RE_TWITTER)![1];
|
||||
// the embed srcdoc still supports twitter.com domain only.
|
||||
|
@ -39,7 +39,7 @@ import {
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
@ -462,18 +462,12 @@ const createBindingArrow = (
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(bindingArrow, scene, [
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
]);
|
||||
|
||||
const update = updateElbowArrowPoints(
|
||||
bindingArrow,
|
||||
|
@ -9,7 +9,13 @@ import type {
|
||||
StaticCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ReadonlySetLike } from "@excalidraw/common/utility-types";
|
||||
import type {
|
||||
ArrayAccumulator,
|
||||
GenericAccumulator,
|
||||
ReadonlyArrayOrMap,
|
||||
ReadonlySetLike,
|
||||
OutputAccumulator,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./selection";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
@ -27,6 +33,8 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { filterElements } from "./utils";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
|
||||
|
||||
import type {
|
||||
@ -230,17 +238,30 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
|
||||
return frameElementsMap;
|
||||
};
|
||||
|
||||
export const getFrameChildren = (
|
||||
allElements: ElementsMapOrArray,
|
||||
frameId: string,
|
||||
) => {
|
||||
const frameChildren: ExcalidrawElement[] = [];
|
||||
for (const element of allElements.values()) {
|
||||
if (element.frameId === frameId) {
|
||||
frameChildren.push(element);
|
||||
}
|
||||
export const getFrameChildren = <
|
||||
K extends ExcalidrawElement,
|
||||
O extends GenericAccumulator = ArrayAccumulator,
|
||||
>(
|
||||
allElements: ReadonlyArrayOrMap<K>,
|
||||
frameId: K["id"] | Set<string>,
|
||||
output?: O,
|
||||
): OutputAccumulator<O, K> => {
|
||||
if (frameId instanceof Set && frameId.size === 0) {
|
||||
return (output || []) as any as OutputAccumulator<O, K>;
|
||||
}
|
||||
return frameChildren;
|
||||
|
||||
return filterElements(
|
||||
allElements,
|
||||
(element): element is K => {
|
||||
if (!element.frameId) {
|
||||
return false;
|
||||
}
|
||||
return typeof frameId === "string"
|
||||
? element.frameId === frameId
|
||||
: frameId.has(element.frameId);
|
||||
},
|
||||
output || [],
|
||||
) as any as OutputAccumulator<O, K>;
|
||||
};
|
||||
|
||||
export const getFrameLikeElements = (
|
||||
@ -905,16 +926,13 @@ export const shouldApplyFrameClip = (
|
||||
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) => {
|
||||
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 = (
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
@ -7,7 +5,6 @@ import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ElementsMapOrArray,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@ -19,10 +16,12 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
/**
|
||||
* 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;
|
||||
for (const element of toIterable(elements)) {
|
||||
hash = (hash << 5) + hash + element.versionNonce;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
@ -72,47 +71,3 @@ export const clearElementsForExport = (
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
export * from "./cropElement";
|
||||
export * from "./delta";
|
||||
export * from "./distance";
|
||||
export * from "./distribute";
|
||||
export * from "./dragElements";
|
||||
export * from "./duplicate";
|
||||
export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./groups";
|
||||
export * from "./heading";
|
||||
export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
tupleToCoors,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/element";
|
||||
import type { Store } from "@excalidraw/excalidraw/store";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@ -67,7 +67,7 @@ import {
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
@ -82,9 +82,13 @@ import type {
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
points: (GlobalPoint | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@ -302,22 +306,16 @@ export class LinearElementEditor {
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
{
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@ -333,32 +331,26 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
return [
|
||||
pointIndex,
|
||||
{
|
||||
point: newPointPosition,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -459,21 +451,15 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedPoint,
|
||||
{
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
@ -531,7 +517,7 @@ export class LinearElementEditor {
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): (GlobalPoint | null)[] => {
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
@ -543,7 +529,25 @@ export class LinearElementEditor {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
editorMidPointsCache.version === element.version &&
|
||||
editorMidPointsCache.zoom === appState.zoom.value
|
||||
) {
|
||||
return editorMidPointsCache.points;
|
||||
}
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
return editorMidPointsCache.points!;
|
||||
};
|
||||
|
||||
static updateEditorMidPointsCache = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
@ -575,8 +579,9 @@ export class LinearElementEditor {
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
}
|
||||
|
||||
return midpoints;
|
||||
editorMidPointsCache.points = midpoints;
|
||||
editorMidPointsCache.version = element.version;
|
||||
editorMidPointsCache.zoom = appState.zoom.value;
|
||||
};
|
||||
|
||||
static getSegmentMidpointHitCoords = (
|
||||
@ -630,11 +635,8 @@ export class LinearElementEditor {
|
||||
}
|
||||
}
|
||||
let index = 0;
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
const midPoints: typeof editorMidPointsCache["points"] =
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
@ -805,7 +807,7 @@ export class LinearElementEditor {
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
store.scheduleCapture();
|
||||
store.shouldCaptureIncrement();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
@ -986,18 +988,12 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(element, app.scene, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||
}
|
||||
@ -1231,16 +1227,12 @@ export class LinearElementEditor {
|
||||
// potentially expanding the bounding box
|
||||
if (pointAddedToEnd) {
|
||||
const lastPoint = element.points[element.points.length - 1];
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(element, scene, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -1315,7 +1307,7 @@ export class LinearElementEditor {
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
pointUpdates: PointsPositionUpdates,
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
otherUpdates?: {
|
||||
startBinding?: 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
|
||||
// this hacks are completely transparent to the user.
|
||||
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>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
@ -1337,12 +1330,12 @@ export class LinearElementEditor {
|
||||
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
pointUpdates.get(0)?.point ?? points[0],
|
||||
pointUpdates.get(points.length - 1)?.point ??
|
||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
||||
points[points.length - 1],
|
||||
]
|
||||
: points.map((p, idx) => {
|
||||
const current = pointUpdates.get(idx)?.point ?? p;
|
||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
||||
|
||||
return pointFrom<LocalPoint>(
|
||||
current[0] - offsetX,
|
||||
@ -1358,7 +1351,11 @@ export class LinearElementEditor {
|
||||
offsetY,
|
||||
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;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||
if (element.points.length === 2) {
|
||||
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
||||
}
|
||||
if (
|
||||
!midSegmentMidpoint ||
|
||||
editorMidPointsCache.version !== element.version
|
||||
) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
}
|
||||
|
@ -351,20 +351,12 @@ const generateElementCanvas = (
|
||||
|
||||
export const DEFAULT_LINK_SIZE = 14;
|
||||
|
||||
const IMAGE_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||
)}`;
|
||||
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
||||
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>`,
|
||||
)}`;
|
||||
|
@ -57,7 +57,7 @@ import {
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type {
|
||||
|
@ -169,6 +169,25 @@ export const isSomeElementSelected = (function () {
|
||||
return ret;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
||||
* elements. If elements don't share the same value, returns `null`.
|
||||
*/
|
||||
export const getCommonAttributeOfSelectedElements = <T>(
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
): T | null => {
|
||||
const attributes = Array.from(
|
||||
new Set(
|
||||
getSelectedElements(elements, appState).map((element) =>
|
||||
getAttribute(element),
|
||||
),
|
||||
),
|
||||
);
|
||||
return attributes.length === 1 ? attributes[0] : null;
|
||||
};
|
||||
|
||||
export const getSelectedElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||
|
@ -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);
|
@ -30,7 +30,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
|
@ -28,7 +28,6 @@ import type {
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@ -357,18 +356,3 @@ export const isBounds = (box: unknown): box is Bounds =>
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
if (isSharpArrow(element)) {
|
||||
return "sharpArrow";
|
||||
}
|
||||
if (isCurvedArrow(element)) {
|
||||
return "curvedArrow";
|
||||
}
|
||||
if (isElbowArrow(element)) {
|
||||
return "elbowArrow";
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
@ -296,11 +296,6 @@ export type FixedPointBinding = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
number,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
@ -418,12 +413,10 @@ export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type ExcalidrawLinearElementSubType =
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes =
|
||||
| "line"
|
||||
| "sharpArrow"
|
||||
| "curvedArrow"
|
||||
| "elbowArrow";
|
||||
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
import { elementCenterPoint, isReadonlyArray } from "@excalidraw/common";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
@ -18,8 +18,15 @@ import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
GenericAccumulator,
|
||||
OutputAccumulator,
|
||||
ReadonlyArrayOrMap,
|
||||
} from "../../common/src/utility-types";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
@ -353,3 +360,35 @@ export function deconstructDiamondElement(
|
||||
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
export const filterElements = <
|
||||
InputType extends ExcalidrawElement,
|
||||
PredicateOutputType extends InputType,
|
||||
AccumulatorType extends GenericAccumulator,
|
||||
Attr extends keyof PredicateOutputType = never,
|
||||
>(
|
||||
elements: ReadonlyArrayOrMap<InputType>,
|
||||
predicate: (elem: InputType) => elem is PredicateOutputType,
|
||||
accumulator: AccumulatorType,
|
||||
attr?: Attr,
|
||||
): OutputAccumulator<AccumulatorType, PredicateOutputType, Attr> => {
|
||||
for (const element of isReadonlyArray(elements)
|
||||
? elements
|
||||
: elements.values()) {
|
||||
if (predicate(element)) {
|
||||
if (accumulator instanceof Set) {
|
||||
accumulator.add(attr ? element[attr] : element);
|
||||
} else if (accumulator instanceof Map) {
|
||||
accumulator.set(element.id, attr ? element[attr] : element);
|
||||
} else {
|
||||
accumulator.push(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator as any as OutputAccumulator<
|
||||
AccumulatorType,
|
||||
PredicateOutputType,
|
||||
Attr
|
||||
>;
|
||||
};
|
||||
|
@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type Scene from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
@ -22,7 +22,7 @@ import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
import Scene from "../src/Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
|
@ -7,9 +7,9 @@ import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
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";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,18 +1,16 @@
|
||||
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 { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { alignElements } from "@excalidraw/element/align";
|
||||
|
||||
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 {
|
||||
@ -27,6 +25,7 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -10,14 +10,14 @@ import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/containerCache";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@ -25,15 +25,13 @@ import {
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
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 { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { newElement } from "@excalidraw/element/newElement";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -46,6 +44,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
@ -14,10 +14,8 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -46,6 +44,7 @@ import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
import { getTextFromElements } from "@excalidraw/element";
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
@ -17,6 +15,8 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,28 +1,27 @@
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getContainerElement } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
|
@ -1,18 +1,16 @@
|
||||
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 { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { distributeElements } from "@excalidraw/element/distribute";
|
||||
|
||||
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 {
|
||||
@ -23,6 +21,7 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -7,24 +7,23 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import {
|
||||
getSelectedElements,
|
||||
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 { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -2,14 +2,13 @@ import {
|
||||
canCreateLinkFromElements,
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/elementLink";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,23 +1,18 @@
|
||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
newElementWith,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
@ -28,10 +23,15 @@ export const actionToggleElementLock = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lock"
|
||||
: "labels.elementLock.unlock";
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
@ -58,84 +58,19 @@ export const actionToggleElementLock = register({
|
||||
|
||||
const nextLockState = shouldLock(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 {
|
||||
elements: nextElements,
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
selectedGroupIds: nextSelectedGroupIds,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
lockedMultiSelections: nextLockedMultiSelections,
|
||||
activeLockedId,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@ -168,44 +103,18 @@ export const actionUnlockAllElements = register({
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
if (element.locked) {
|
||||
// remove the temporary groupId if it exists
|
||||
const nextGroupIds = element.groupIds.filter(
|
||||
(gid) => !appState.lockedMultiSelections[gid],
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: false,
|
||||
groupIds:
|
||||
// do not recreate the array unncessarily
|
||||
element.groupIds.length !== nextGroupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
|
||||
const unlockedElements = lockedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
elements: elements.map((element) => {
|
||||
if (element.locked) {
|
||||
return newElementWith(element, { locked: false });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
selectedGroupIds: selectGroupsFromGivenElements(
|
||||
unlockedElements,
|
||||
appState,
|
||||
),
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -7,8 +7,6 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useDevice } from "../components/App";
|
||||
@ -26,6 +24,7 @@ import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
|
||||
|
@ -3,22 +3,24 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/binding";
|
||||
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 { isPathALoop } from "@excalidraw/element";
|
||||
import { isPathALoop } from "@excalidraw/element/shapes";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { done } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -2,21 +2,19 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element";
|
||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { resizeMultipleElements } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/binding";
|
||||
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
@ -26,6 +24,7 @@ import type {
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
|
||||
|
@ -1,26 +1,25 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { newFrameElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { newFrameElement } from "@excalidraw/element/newElement";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import {
|
||||
addElementsToFrame,
|
||||
removeAllElementsFromFrame,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/frame";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { getCommonBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
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 {
|
||||
frameAndChildrenSelectedTogether,
|
||||
@ -12,7 +12,7 @@ import {
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
@ -24,11 +24,9 @@ import {
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
isElementInGroup,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -42,6 +40,7 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,9 +1,5 @@
|
||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { orderByFractionalIndex } from "@excalidraw/element";
|
||||
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -11,8 +7,10 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { Store } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
|
||||
@ -37,11 +35,7 @@ const executeHistoryAction = (
|
||||
}
|
||||
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
|
||||
// order by fractional indices in case the map was accidently modified in the meantime
|
||||
const nextElements = orderByFractionalIndex(
|
||||
Array.from(nextElementsMap.values()),
|
||||
);
|
||||
const nextElements = Array.from(nextElementsMap.values());
|
||||
|
||||
return {
|
||||
appState: nextAppState,
|
||||
@ -53,9 +47,9 @@ const executeHistoryAction = (
|
||||
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",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
@ -63,7 +57,11 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
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) =>
|
||||
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",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, __, app) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
|
@ -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 { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
@ -13,6 +11,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,15 +1,14 @@
|
||||
import { isEmbeddableElement } from "@excalidraw/element";
|
||||
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
|
@ -1,7 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import {
|
||||
@ -10,6 +8,7 @@ import {
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,15 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { isLinearElement, isTextElement } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@ -17,14 +17,12 @@ import {
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -32,6 +30,7 @@ import { paintIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { magnetIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -5,9 +5,8 @@ import {
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { searchIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -34,6 +33,13 @@ export const actionToggleSearchMenu = register({
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
if (searchInput?.matches(":focus")) {
|
||||
return {
|
||||
appState: { ...appState, openSidebar: null },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
return false;
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
@ -7,6 +5,7 @@ import {
|
||||
convertElementTypePopupAtom,
|
||||
} from "../components/ConvertElementTypePopup";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { abacusIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { coffeeIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -5,9 +5,7 @@ import {
|
||||
moveOneRight,
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/zindex";
|
||||
|
||||
import {
|
||||
BringForwardIcon,
|
||||
@ -16,6 +14,7 @@ import {
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -3,8 +3,7 @@ import type {
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import type { CaptureUpdateActionType } from "../store";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
|
@ -121,9 +121,7 @@ export const getDefaultAppState = (): Omit<
|
||||
followedBy: new Set(),
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@ -248,8 +246,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { 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 = <
|
||||
|
@ -5,7 +5,43 @@ import {
|
||||
isDevEnv,
|
||||
isShallowEqual,
|
||||
isTestEnv,
|
||||
toBrandedType,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
BoundElement,
|
||||
BindableElement,
|
||||
bindingProperties,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import {
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
|
||||
|
||||
import {
|
||||
orderByFractionalIndex,
|
||||
syncMovedIndices,
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import Scene from "@excalidraw/element/Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
|
||||
|
||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -18,42 +54,16 @@ import type {
|
||||
SceneElementsMap,
|
||||
} 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 {
|
||||
AppState,
|
||||
ObservedAppState,
|
||||
ObservedElementsAppState,
|
||||
ObservedStandaloneAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getObservedAppState } from "./store";
|
||||
|
||||
import {
|
||||
BoundElement,
|
||||
BindableElement,
|
||||
bindingProperties,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class Delta<T> {
|
||||
class Delta<T> {
|
||||
private constructor(
|
||||
public readonly deleted: Partial<T>,
|
||||
public readonly inserted: Partial<T>,
|
||||
@ -187,12 +197,10 @@ export class Delta<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeletedObject =
|
||||
deleted[property] !== null && typeof deleted[property] === "object";
|
||||
const isInsertedObject =
|
||||
inserted[property] !== null && typeof inserted[property] === "object";
|
||||
|
||||
if (isDeletedObject || isInsertedObject) {
|
||||
if (
|
||||
typeof deleted[property] === "object" ||
|
||||
typeof inserted[property] === "object"
|
||||
) {
|
||||
type RecordLike = Record<string, V | undefined>;
|
||||
|
||||
const deletedObject: RecordLike = deleted[property] ?? {};
|
||||
@ -224,9 +232,6 @@ export class Delta<T> {
|
||||
Reflect.deleteProperty(deleted, property);
|
||||
Reflect.deleteProperty(inserted, property);
|
||||
}
|
||||
} else if (deleted[property] === inserted[property]) {
|
||||
Reflect.deleteProperty(deleted, property);
|
||||
Reflect.deleteProperty(inserted, property);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}>(
|
||||
object1: T,
|
||||
@ -330,11 +335,11 @@ export class Delta<T> {
|
||||
) {
|
||||
return Array.from(
|
||||
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 {}>(
|
||||
object1: T,
|
||||
@ -343,7 +348,7 @@ export class Delta<T> {
|
||||
) {
|
||||
return Array.from(
|
||||
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];
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
* Checks whether there are actually `Delta`s.
|
||||
*/
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
export class AppStateChange implements Change<AppState> {
|
||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
nextAppState: T,
|
||||
): AppStateDelta {
|
||||
): AppStateChange {
|
||||
const delta = Delta.calculate(
|
||||
prevAppState,
|
||||
nextAppState,
|
||||
// making the order of keys in deltas stable for hashing purposes
|
||||
AppStateDelta.orderAppStateKeys,
|
||||
AppStateDelta.postProcess,
|
||||
undefined,
|
||||
AppStateChange.postProcess,
|
||||
);
|
||||
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||
const { delta } = appStateDeltaDTO;
|
||||
return new AppStateDelta(delta);
|
||||
return new AppStateChange(delta);
|
||||
}
|
||||
|
||||
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);
|
||||
return new AppStateDelta(inversedDelta);
|
||||
return new AppStateChange(inversedDelta);
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
@ -545,6 +544,40 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return Delta.isEmpty(this.delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* It is necessary to post process the partials in case of reference values,
|
||||
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
||||
*/
|
||||
private static postProcess<T extends ObservedAppState>(
|
||||
deleted: Partial<T>,
|
||||
inserted: Partial<T>,
|
||||
): [Partial<T>, Partial<T>] {
|
||||
try {
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"selectedElementIds",
|
||||
// ts language server has a bit trouble resolving this, so we are giving it a little push
|
||||
(_) => true as ValueOf<T["selectedElementIds"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"selectedGroupIds",
|
||||
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return [deleted, inserted];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates `nextAppState` be filtering out state related to deleted elements.
|
||||
*
|
||||
@ -561,13 +594,13 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||
|
||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
||||
);
|
||||
|
||||
const containsElementsDifference = Delta.isRightDifferent(
|
||||
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
||||
);
|
||||
|
||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||
@ -582,8 +615,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
if (containsElementsDifference) {
|
||||
// filter invisible changes on each iteration
|
||||
const changedElementsProps = Delta.getRightDifferences(
|
||||
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
||||
) as Array<keyof ObservedElementsAppState>;
|
||||
|
||||
let nonDeletedGroupIds = new Set<string>();
|
||||
@ -600,7 +633,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
for (const key of changedElementsProps) {
|
||||
switch (key) {
|
||||
case "selectedElementIds":
|
||||
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
||||
nextAppState[key],
|
||||
nextElements,
|
||||
visibleDifferenceFlag,
|
||||
@ -608,7 +641,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
|
||||
break;
|
||||
case "selectedGroupIds":
|
||||
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
||||
nextAppState[key],
|
||||
nonDeletedGroupIds,
|
||||
visibleDifferenceFlag,
|
||||
@ -644,7 +677,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
break;
|
||||
case "selectedLinearElementId":
|
||||
case "editingLinearElementId":
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
|
||||
if (!linearElement) {
|
||||
@ -663,24 +696,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections": {
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
key,
|
||||
@ -776,8 +791,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
...standaloneProps
|
||||
} = delta as ObservedAppState;
|
||||
|
||||
@ -799,63 +812,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
ObservedElementsAppState
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* It is necessary to post process the partials in case of reference values,
|
||||
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
||||
*/
|
||||
private static postProcess<T extends ObservedAppState>(
|
||||
deleted: Partial<T>,
|
||||
inserted: Partial<T>,
|
||||
): [Partial<T>, Partial<T>] {
|
||||
try {
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"selectedElementIds",
|
||||
// ts language server has a bit trouble resolving this, so we are giving it a little push
|
||||
(_) => true as ValueOf<T["selectedElementIds"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"selectedGroupIds",
|
||||
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return [deleted, inserted];
|
||||
}
|
||||
}
|
||||
|
||||
private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
|
||||
const orderedPartial: { [key: string]: unknown } = {};
|
||||
|
||||
for (const key of Object.keys(partial).sort()) {
|
||||
// relying on insertion order
|
||||
orderedPartial[key] = partial[key as keyof ObservedAppState];
|
||||
}
|
||||
|
||||
return orderedPartial as Partial<ObservedAppState>;
|
||||
}
|
||||
}
|
||||
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||
@ -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.
|
||||
* 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(
|
||||
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||
private readonly added: Map<string, Delta<ElementPartial>>,
|
||||
private readonly removed: Map<string, Delta<ElementPartial>>,
|
||||
private readonly updated: Map<string, Delta<ElementPartial>>,
|
||||
) {}
|
||||
|
||||
public static create(
|
||||
added: Record<string, Delta<ElementPartial>>,
|
||||
removed: Record<string, Delta<ElementPartial>>,
|
||||
updated: Record<string, Delta<ElementPartial>>,
|
||||
options: {
|
||||
shouldRedistribute: boolean;
|
||||
} = {
|
||||
shouldRedistribute: false,
|
||||
},
|
||||
added: Map<string, Delta<ElementPartial>>,
|
||||
removed: Map<string, Delta<ElementPartial>>,
|
||||
updated: Map<string, Delta<ElementPartial>>,
|
||||
options = { shouldRedistribute: false },
|
||||
) {
|
||||
let delta: ElementsDelta;
|
||||
let change: ElementsChange;
|
||||
|
||||
if (options.shouldRedistribute) {
|
||||
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||
const nextAdded = new Map<string, Delta<ElementPartial>>();
|
||||
const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
||||
const nextUpdated = new Map<string, Delta<ElementPartial>>();
|
||||
|
||||
const deltas = [
|
||||
...Object.entries(added),
|
||||
...Object.entries(removed),
|
||||
...Object.entries(updated),
|
||||
];
|
||||
const deltas = [...added, ...removed, ...updated];
|
||||
|
||||
for (const [id, delta] of deltas) {
|
||||
if (this.satisfiesAddition(delta)) {
|
||||
nextAdded[id] = delta;
|
||||
nextAdded.set(id, delta);
|
||||
} else if (this.satisfiesRemoval(delta)) {
|
||||
nextRemoved[id] = delta;
|
||||
nextRemoved.set(id, delta);
|
||||
} else {
|
||||
nextUpdated[id] = delta;
|
||||
nextUpdated.set(id, delta);
|
||||
}
|
||||
}
|
||||
|
||||
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
||||
} else {
|
||||
delta = new ElementsDelta(added, removed, updated);
|
||||
change = new ElementsChange(added, removed, updated);
|
||||
}
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||
const { added, removed, updated } = elementsDeltaDTO;
|
||||
return ElementsDelta.create(added, removed, updated);
|
||||
return change;
|
||||
}
|
||||
|
||||
private static satisfiesAddition = ({
|
||||
@ -945,17 +888,17 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
change: ElementsChange,
|
||||
type: "added" | "removed" | "updated",
|
||||
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)) {
|
||||
console.error(
|
||||
`Broken invariant for "${type}" delta, element "${id}", 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 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>(
|
||||
prevElements: Map<string, T>,
|
||||
nextElements: Map<string, T>,
|
||||
): ElementsDelta {
|
||||
): ElementsChange {
|
||||
if (prevElements === nextElements) {
|
||||
return ElementsDelta.empty();
|
||||
return ElementsChange.empty();
|
||||
}
|
||||
|
||||
const added: Record<string, Delta<ElementPartial>> = {};
|
||||
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||
const updated: Record<string, Delta<ElementPartial>> = {};
|
||||
const added = new Map<string, Delta<ElementPartial>>();
|
||||
const removed = new Map<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
|
||||
for (const prevElement of prevElements.values()) {
|
||||
@ -991,10 +934,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const delta = Delta.create(
|
||||
deleted,
|
||||
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(
|
||||
deleted,
|
||||
inserted,
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
added.set(nextElement.id, delta);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1023,8 +966,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const delta = Delta.calculate<ElementPartial>(
|
||||
prevElement,
|
||||
nextElement,
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
ElementsDelta.postProcess,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
ElementsChange.postProcess,
|
||||
);
|
||||
|
||||
if (
|
||||
@ -1035,9 +978,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
// notice that other props could have been updated as well
|
||||
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
added.set(nextElement.id, delta);
|
||||
} else {
|
||||
removed[nextElement.id] = delta;
|
||||
removed.set(nextElement.id, delta);
|
||||
}
|
||||
|
||||
continue;
|
||||
@ -1045,24 +988,24 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
// making sure there are at least some changes
|
||||
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() {
|
||||
return ElementsDelta.create({}, {}, {});
|
||||
return ElementsChange.create(new Map(), new Map(), new Map());
|
||||
}
|
||||
|
||||
public inverse(): ElementsDelta {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
public inverse(): ElementsChange {
|
||||
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, delta] of deltas.entries()) {
|
||||
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@ -1073,14 +1016,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const updated = inverseInternal(this.updated);
|
||||
|
||||
// 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 {
|
||||
return (
|
||||
Object.keys(this.added).length === 0 &&
|
||||
Object.keys(this.removed).length === 0 &&
|
||||
Object.keys(this.updated).length === 0
|
||||
this.added.size === 0 &&
|
||||
this.removed.size === 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
|
||||
* @returns new instance with modified delta/s
|
||||
*/
|
||||
public applyLatestChanges(
|
||||
elements: SceneElementsMap,
|
||||
modifierOptions: "deleted" | "inserted",
|
||||
): ElementsDelta {
|
||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
||||
const modifier =
|
||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||
const latestPartial: { [key: string]: unknown } = {};
|
||||
@ -1115,11 +1055,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (existingElement) {
|
||||
@ -1127,12 +1067,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted,
|
||||
delta.inserted,
|
||||
modifier(existingElement),
|
||||
modifierOptions,
|
||||
"inserted",
|
||||
);
|
||||
|
||||
modifiedDeltas[id] = modifiedDelta;
|
||||
modifiedDeltas.set(id, modifiedDelta);
|
||||
} else {
|
||||
modifiedDeltas[id] = delta;
|
||||
modifiedDeltas.set(id, delta);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1143,16 +1083,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const removed = applyLatestChangesInternal(this.removed);
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
|
||||
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)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
const applyDeltas = ElementsChange.createApplier(
|
||||
nextElements,
|
||||
elementsSnapshot,
|
||||
snapshot,
|
||||
flags,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas("added", this.added);
|
||||
const removedElements = applyDeltas("removed", this.removed);
|
||||
const updatedElements = applyDeltas("updated", this.updated);
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
|
||||
@ -1182,7 +1122,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
...affectedElements,
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't apply elements delta`, e);
|
||||
console.error(`Couldn't apply elements change`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
@ -1198,7 +1138,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements = ElementsChange.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
@ -1209,9 +1149,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsChange.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@ -1226,42 +1166,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
private static createApplier =
|
||||
(
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
flags: {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
},
|
||||
) =>
|
||||
(
|
||||
type: "added" | "removed" | "updated",
|
||||
deltas: Record<string, Delta<ElementPartial>>,
|
||||
) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
type,
|
||||
nextElements,
|
||||
snapshot,
|
||||
flags,
|
||||
);
|
||||
private static createApplier = (
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
flags: {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
},
|
||||
) => {
|
||||
const getElement = ElementsChange.createGetter(
|
||||
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);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Map<string, OrderedExcalidrawElement>());
|
||||
};
|
||||
};
|
||||
|
||||
private static createGetter =
|
||||
(
|
||||
type: "added" | "removed" | "updated",
|
||||
elements: SceneElementsMap,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
flags: {
|
||||
@ -1287,14 +1221,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
} else {
|
||||
// not in elements, not in snapshot? element might have been added remotely!
|
||||
element = newElementWith(
|
||||
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||
{
|
||||
...partial,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1331,8 +1257,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: this looks wrong, shouldn't be here
|
||||
if (element.type === "image") {
|
||||
if (isImageElement(element)) {
|
||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||
// we want to override `crop` only if modified so that we don't reset
|
||||
// when undoing/redoing unrelated change
|
||||
@ -1345,12 +1270,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
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 containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||
element,
|
||||
rest,
|
||||
);
|
||||
const containsVisibleDifference =
|
||||
ElementsChange.checkForVisibleDifference(element, rest);
|
||||
|
||||
flags.containsVisibleDifference = containsVisibleDifference;
|
||||
}
|
||||
@ -1393,8 +1316,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
* Resolves conflicts for all previously added, removed and updated elements.
|
||||
* Updates the previous deltas with all the changes after conflict resolution.
|
||||
*
|
||||
* // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
|
||||
*
|
||||
* @returns all elements affected by the conflict resolution
|
||||
*/
|
||||
private resolveConflicts(
|
||||
@ -1425,7 +1346,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
) as OrderedExcalidrawElement;
|
||||
}
|
||||
|
||||
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
|
||||
for (const id of Object.keys(this.removed)) {
|
||||
ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
|
||||
for (const [id] of this.removed) {
|
||||
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
|
||||
for (const id of Object.keys(this.added)) {
|
||||
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||
for (const [id] of this.added) {
|
||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
||||
}
|
||||
|
||||
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
||||
for (const [id] of Array.from(Object.entries(this.updated)).filter(
|
||||
([_, delta]) =>
|
||||
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||
),
|
||||
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
|
||||
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||
),
|
||||
)) {
|
||||
const updatedElement = nextElements.get(id);
|
||||
if (!updatedElement || updatedElement.isDeleted) {
|
||||
@ -1455,7 +1375,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
nextAffectedElements,
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
for (const [id, delta] of added) {
|
||||
this.added.set(id, delta);
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
for (const [id, delta] of removed) {
|
||||
this.removed.set(id, delta);
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
for (const [id, delta] of updated) {
|
||||
this.updated.set(id, delta);
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
@ -1652,7 +1572,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||
} catch (e) {
|
||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess elements delta.`);
|
||||
console.error(`Couldn't postprocess elements change deltas.`);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
@ -1665,7 +1585,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static stripIrrelevantProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
||||
partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
@ -15,7 +15,7 @@ import {
|
||||
newTextElement,
|
||||
newLinearElement,
|
||||
newElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/newElement";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
|
@ -7,14 +7,14 @@ import {
|
||||
isPromiseLike,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { getContainingFrame } from "@excalidraw/element";
|
||||
import { getContainingFrame } from "@excalidraw/element/frame";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
shouldAllowVerticalAlign,
|
||||
suppportsHorizontalAlign,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@ -19,9 +19,9 @@ import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -154,7 +154,7 @@ export const SelectedShapeActions = ({
|
||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
||||
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
{canChangeStrokeColor(appState, targetElements) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
|
@ -101,10 +101,12 @@ import {
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
Emitter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
import {
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element/bounds";
|
||||
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
@ -117,11 +119,11 @@ import {
|
||||
shouldEnableBindingForPointerEvent,
|
||||
updateBoundElements,
|
||||
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 {
|
||||
newFrameElement,
|
||||
@ -135,9 +137,12 @@ import {
|
||||
newLinearElement,
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/newElement";
|
||||
|
||||
import { deepCopyElement, duplicateElements } from "@excalidraw/element";
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElements,
|
||||
} from "@excalidraw/element/duplicate";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@ -160,7 +165,7 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
isBindableElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
getLockedLinearCursorAlignSize,
|
||||
@ -168,28 +173,28 @@ import {
|
||||
isElementCompletelyInViewport,
|
||||
isElementInViewport,
|
||||
isInvisiblySmallElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/sizeHelpers";
|
||||
|
||||
import {
|
||||
getBoundTextShape,
|
||||
getCornerRadius,
|
||||
getElementShape,
|
||||
isPathALoop,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/shapes";
|
||||
|
||||
import {
|
||||
createSrcDoc,
|
||||
embeddableURLValidator,
|
||||
maybeParseEmbedSrc,
|
||||
getEmbedLink,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/embeddable";
|
||||
|
||||
import {
|
||||
getInitializedImageElements,
|
||||
loadHTMLImageElement,
|
||||
normalizeSVG,
|
||||
updateImageCache as _updateImageCache,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/image";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
@ -197,9 +202,9 @@ import {
|
||||
getContainerElement,
|
||||
isValidTextContainer,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import { shouldShowBoundingBox } from "@excalidraw/element";
|
||||
import { shouldShowBoundingBox } from "@excalidraw/element/transformHandles";
|
||||
|
||||
import {
|
||||
getFrameChildren,
|
||||
@ -216,27 +221,30 @@ import {
|
||||
getFrameLikeTitle,
|
||||
getElementsOverlappingFrame,
|
||||
filterElementsEligibleAsFrameChildren,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/frame";
|
||||
|
||||
import {
|
||||
hitElementBoundText,
|
||||
hitElementBoundingBoxOnly,
|
||||
hitElementItself,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/collision";
|
||||
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
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 {
|
||||
isMeasureTextSupported,
|
||||
@ -246,11 +254,11 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getApproxMinLineHeight,
|
||||
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 {
|
||||
editGroupForSelectedElement,
|
||||
@ -260,41 +268,42 @@ import {
|
||||
isElementInGroup,
|
||||
isSelectedViaGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element";
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectionStateForElements,
|
||||
makeNextSelectedElementIds,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/selection";
|
||||
|
||||
import {
|
||||
getResizeOffsetXY,
|
||||
getResizeArrowDirection,
|
||||
transformElements,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/resizeElements";
|
||||
|
||||
import {
|
||||
getCursorForResizingElement,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/resizeTest";
|
||||
|
||||
import {
|
||||
dragNewElement,
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/dragElements";
|
||||
|
||||
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";
|
||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
@ -322,7 +331,6 @@ import type {
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
@ -446,7 +454,9 @@ import {
|
||||
resetCursor,
|
||||
setCursorForShape,
|
||||
} from "../cursor";
|
||||
import { Emitter } from "../emitter";
|
||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||
import { Store, CaptureUpdateAction } from "../store";
|
||||
import { LaserTrails } from "../laser-trails";
|
||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||
@ -485,8 +495,6 @@ import { Toast } from "./Toast";
|
||||
|
||||
import { findShapeByKey } from "./shapes";
|
||||
|
||||
import UnlockPopup from "./UnlockPopup";
|
||||
|
||||
import type {
|
||||
RenderInteractiveSceneCallback,
|
||||
ScrollBars,
|
||||
@ -753,8 +761,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.renderer = new Renderer(this.scene);
|
||||
this.visibleElements = [];
|
||||
|
||||
this.store = new Store(this);
|
||||
this.history = new History(this.store);
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
@ -784,7 +792,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.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.history = new History(this.store);
|
||||
this.history = new History();
|
||||
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
this.actionManager.registerAction(
|
||||
createUndoAction(this.history, this.store),
|
||||
);
|
||||
this.actionManager.registerAction(
|
||||
createRedoAction(this.history, this.store),
|
||||
);
|
||||
}
|
||||
|
||||
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 nonDeletedFramesLikes = this.scene.getNonDeletedFramesLikes();
|
||||
|
||||
const focusedSearchMatch =
|
||||
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) => {
|
||||
return this.scene.getNonDeletedFramesLikes().map((f) => {
|
||||
if (
|
||||
!isElementInViewport(
|
||||
f,
|
||||
@ -1485,7 +1485,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
borderRadius: 4,
|
||||
boxShadow: "inset 0 0 0 1px var(--color-primary)",
|
||||
fontFamily: "Assistant",
|
||||
fontSize: `${FRAME_STYLE.nameFontSize}px`,
|
||||
fontSize: "14px",
|
||||
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
||||
color: "var(--color-gray-80)",
|
||||
overflow: "hidden",
|
||||
@ -1529,10 +1529,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
lineHeight: FRAME_STYLE.nameLineHeight,
|
||||
width: "max-content",
|
||||
maxWidth:
|
||||
focusedSearchMatch?.id === f.id && focusedSearchMatch?.focus
|
||||
? "none"
|
||||
: `${f.width * this.state.zoom.value}px`,
|
||||
maxWidth: `${f.width}px`,
|
||||
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@ -1878,12 +1875,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
@ -1908,10 +1899,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return this.scene.getElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
public getSceneElementsMapIncludingDeleted = () => {
|
||||
return this.scene.getElementsMapIncludingDeleted();
|
||||
};
|
||||
|
||||
public getSceneElements = () => {
|
||||
return this.scene.getNonDeletedElements();
|
||||
};
|
||||
@ -2228,7 +2215,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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;
|
||||
|
||||
@ -2301,7 +2292,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
if (!didUpdate) {
|
||||
if (
|
||||
!didUpdate &&
|
||||
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
||||
) {
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
});
|
||||
@ -2553,19 +2547,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
this.store.onDurableIncrementEmitter.on((increment) => {
|
||||
this.history.record(increment.delta);
|
||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||
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.addEventListeners();
|
||||
|
||||
@ -2625,7 +2610,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.eraserTrail.stop();
|
||||
this.onChangeEmitter.clear();
|
||||
this.store.onStoreIncrementEmitter.clear();
|
||||
this.store.onDurableIncrementEmitter.clear();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
@ -2919,7 +2903,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.editingLinearElement &&
|
||||
!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(() => {
|
||||
// execute only if the condition still holds when the deferred callback
|
||||
// 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.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(duplicatedElements);
|
||||
@ -3635,7 +3619,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||
}
|
||||
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
setAppState: React.Component<any, AppState>["setState"] = (
|
||||
@ -3991,37 +3975,51 @@ class App extends React.Component<AppProps, AppState> {
|
||||
*/
|
||||
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 nextElementsMap = elements
|
||||
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
|
||||
: undefined;
|
||||
const nextCommittedAppState = sceneData.appState
|
||||
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||
: prevCommittedAppState;
|
||||
|
||||
const nextAppState = appState
|
||||
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||
Object.assign({}, this.store.snapshot.appState, appState)
|
||||
: undefined;
|
||||
const nextCommittedElements = sceneData.elements
|
||||
? this.store.filterUncomittedElements(
|
||||
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
||||
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
||||
)
|
||||
: prevCommittedElements;
|
||||
|
||||
this.store.scheduleMicroAction({
|
||||
action: captureUpdate,
|
||||
elements: nextElementsMap,
|
||||
appState: nextAppState,
|
||||
});
|
||||
// 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
|
||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
||||
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
||||
this.store.captureIncrement(
|
||||
nextCommittedElements,
|
||||
nextCommittedAppState,
|
||||
);
|
||||
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
|
||||
this.store.updateSnapshot(
|
||||
nextCommittedElements,
|
||||
nextCommittedAppState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (appState) {
|
||||
this.setState(appState);
|
||||
if (sceneData.appState) {
|
||||
this.setState(sceneData.appState);
|
||||
}
|
||||
|
||||
if (nextElements) {
|
||||
if (sceneData.elements) {
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
if (collaborators) {
|
||||
this.setState({ collaborators });
|
||||
if (sceneData.collaborators) {
|
||||
this.setState({ collaborators: sceneData.collaborators });
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -4204,7 +4202,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
direction: event.shiftKey ? "left" : "right",
|
||||
})
|
||||
) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
}
|
||||
if (conversionType) {
|
||||
@ -4521,7 +4519,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
if (!isElbowArrow(selectedElement)) {
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
@ -4847,7 +4845,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} as const;
|
||||
|
||||
if (nextActiveTool.type === "freedraw") {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (nextActiveTool.type === "lasso") {
|
||||
@ -5064,7 +5062,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
@ -5122,27 +5120,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: (
|
||||
| {
|
||||
includeBoundTextElement?: boolean;
|
||||
includeLockedElements?: boolean;
|
||||
}
|
||||
| {
|
||||
allHitElements: NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
) & {
|
||||
opts?: {
|
||||
preferSelected?: boolean;
|
||||
includeBoundTextElement?: boolean;
|
||||
includeLockedElements?: boolean;
|
||||
},
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
let allHitElements: NonDeleted<ExcalidrawElement>[] = [];
|
||||
if (opts && "allHitElements" in opts) {
|
||||
allHitElements = opts?.allHitElements || [];
|
||||
} else {
|
||||
allHitElements = this.getElementsAtPosition(x, y, {
|
||||
includeBoundTextElement: opts?.includeBoundTextElement,
|
||||
includeLockedElements: opts?.includeLockedElements,
|
||||
});
|
||||
}
|
||||
const allHitElements = this.getElementsAtPosition(
|
||||
x,
|
||||
y,
|
||||
opts?.includeBoundTextElement,
|
||||
opts?.includeLockedElements,
|
||||
);
|
||||
|
||||
if (allHitElements.length > 1) {
|
||||
if (opts?.preferSelected) {
|
||||
@ -5185,24 +5174,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementsAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: {
|
||||
includeBoundTextElement?: boolean;
|
||||
includeLockedElements?: boolean;
|
||||
},
|
||||
includeBoundTextElement: boolean = false,
|
||||
includeLockedElements: boolean = false,
|
||||
): NonDeleted<ExcalidrawElement>[] {
|
||||
const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
|
||||
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
const elements = (
|
||||
opts?.includeBoundTextElement && opts?.includeLockedElements
|
||||
includeBoundTextElement && includeLockedElements
|
||||
? this.scene.getNonDeletedElements()
|
||||
: this.scene
|
||||
.getNonDeletedElements()
|
||||
.filter(
|
||||
(element) =>
|
||||
(opts?.includeLockedElements || !element.locked) &&
|
||||
(opts?.includeBoundTextElement ||
|
||||
(includeLockedElements || !element.locked) &&
|
||||
(includeBoundTextElement ||
|
||||
!(isTextElement(element) && element.containerId)),
|
||||
)
|
||||
)
|
||||
@ -5488,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
croppingElementId: image.id,
|
||||
});
|
||||
@ -5496,7 +5483,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private finishImageCropping = () => {
|
||||
if (this.state.croppingElementId) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
croppingElementId: null,
|
||||
});
|
||||
@ -5531,7 +5518,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElements[0].id) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
@ -5559,7 +5546,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: -1;
|
||||
|
||||
if (midPoint && midPoint > -1) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
LinearElementEditor.deleteFixedSegment(
|
||||
selectedElements[0],
|
||||
this.scene,
|
||||
@ -5621,7 +5608,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||
|
||||
if (selectedGroupId) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState((prevState) => ({
|
||||
...prevState,
|
||||
...selectGroupsForSelectedElements(
|
||||
@ -5688,21 +5675,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private getElementLinkAtPosition = (
|
||||
scenePointer: Readonly<{ x: number; y: number }>,
|
||||
hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
|
||||
hitElement: NonDeletedExcalidrawElement | null,
|
||||
): ExcalidrawElement | undefined => {
|
||||
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
let hitElementIndex = -1;
|
||||
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
if (
|
||||
hitElementMightBeLocked &&
|
||||
element.id === hitElementMightBeLocked.id
|
||||
) {
|
||||
if (hitElement && element.id === hitElement.id) {
|
||||
hitElementIndex = index;
|
||||
}
|
||||
if (
|
||||
@ -6184,25 +6164,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
const hitElementMightBeLocked = this.getElementAtPosition(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
{
|
||||
preferSelected: true,
|
||||
includeLockedElements: true,
|
||||
},
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
let hitElement: ExcalidrawElement | null = null;
|
||||
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
|
||||
hitElement = null;
|
||||
} else {
|
||||
hitElement = hitElementMightBeLocked;
|
||||
}
|
||||
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
hitElementMightBeLocked,
|
||||
hitElement,
|
||||
);
|
||||
if (isEraserActive(this.state)) {
|
||||
return;
|
||||
@ -6295,7 +6264,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: prevState.editingGroupId,
|
||||
selectedElementIds: { [hitElement!.id]: true },
|
||||
selectedElementIds: { [hitElement.id]: true },
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
prevState,
|
||||
@ -6444,17 +6413,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.maybeUnfollowRemoteUser();
|
||||
|
||||
if (this.state.searchMatches) {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
searchMatches: state.searchMatches && {
|
||||
focusedId: null,
|
||||
matches: state.searchMatches.matches.map((searchMatch) => ({
|
||||
...searchMatch,
|
||||
focus: false,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
this.setState((state) => ({
|
||||
searchMatches: state.searchMatches.map((searchMatch) => ({
|
||||
...searchMatch,
|
||||
focus: false,
|
||||
})),
|
||||
}));
|
||||
this.updateEditorAtom(searchItemInFocusAtom, null);
|
||||
}
|
||||
|
||||
@ -6809,9 +6773,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
);
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
@ -7247,57 +7208,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const allHitElements = this.getElementsAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
{
|
||||
includeLockedElements: true,
|
||||
},
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
// 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(
|
||||
pointerDownState.origin,
|
||||
hitElementMightBeLocked,
|
||||
pointerDownState.hit.element,
|
||||
);
|
||||
|
||||
if (this.hitLinkElement) {
|
||||
@ -7327,7 +7248,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// For overlapped elements one position may hit
|
||||
// multiple elements
|
||||
pointerDownState.hit.allHitElements = unlockedHitElements;
|
||||
pointerDownState.hit.allHitElements = this.getElementsAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
const someHitElementIsSelected =
|
||||
@ -7353,13 +7277,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
// If we click on something
|
||||
} else if (hitElement != null) {
|
||||
// == deep selection ==
|
||||
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
if (event.altKey) {
|
||||
// ctrl + alt means we're lasso selecting
|
||||
return false;
|
||||
}
|
||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||
pointerDownState.hit.wasAddedToSelection = true;
|
||||
}
|
||||
@ -8143,12 +8062,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
||||
|
||||
if (this.state.activeLockedId) {
|
||||
this.setState({
|
||||
activeLockedId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement &&
|
||||
this.state.selectedLinearElement.elbowed &&
|
||||
@ -8724,19 +8637,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
if (event.altKey) {
|
||||
flushSync(() => {
|
||||
this.setActiveTool(
|
||||
{ type: "lasso", fromSelection: true },
|
||||
event.shiftKey,
|
||||
);
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
this.setAppState({
|
||||
selectionElement: null,
|
||||
});
|
||||
this.setActiveTool(
|
||||
{ type: "lasso", fromSelection: true },
|
||||
event.shiftKey,
|
||||
);
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
this.setAppState({
|
||||
selectionElement: null,
|
||||
});
|
||||
} else {
|
||||
this.maybeDragNewGenericElement(pointerDownState, event);
|
||||
@ -9030,49 +8941,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
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({
|
||||
selectedElementsAreBeingDragged: false,
|
||||
});
|
||||
@ -9263,7 +9131,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (isLinearElement(newElement)) {
|
||||
if (newElement!.points.length > 1) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
@ -9536,7 +9404,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (resizingElement) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
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
|
||||
// box selected
|
||||
(!this.state.editingLinearElement ||
|
||||
!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"
|
||||
!pointerDownState.boxSelection.hasOccurred)
|
||||
) {
|
||||
// when inside line editor, shift selects points instead
|
||||
if (childEvent.shiftKey && !this.state.editingLinearElement) {
|
||||
@ -9879,7 +9744,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectedElementIds,
|
||||
)
|
||||
) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (
|
||||
@ -9972,7 +9837,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.elementsPendingErasure = new Set();
|
||||
|
||||
if (didChange) {
|
||||
this.store.scheduleCapture();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.scene.replaceAllElements(elements);
|
||||
}
|
||||
};
|
||||
@ -10652,13 +10517,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// restore the fractional indices by mutating elements
|
||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||
|
||||
// don't capture and only update the store snapshot for old elements,
|
||||
// otherwise we would end up with duplicated fractional indices on undo
|
||||
this.store.scheduleMicroAction({
|
||||
action: CaptureUpdateAction.NEVER,
|
||||
elements: arrayToMap(elements) as SceneElementsMap,
|
||||
appState: undefined,
|
||||
});
|
||||
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
||||
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
this.syncActionResult({
|
||||
|
@ -4,7 +4,8 @@ import { ButtonIcon } from "./ButtonIcon";
|
||||
|
||||
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: {
|
||||
options: {
|
||||
value: T;
|
||||
@ -27,7 +28,7 @@ export const RadioSelection = <T extends Object>(
|
||||
}
|
||||
),
|
||||
) => (
|
||||
<>
|
||||
<div className="buttonList">
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<ButtonIcon
|
||||
@ -55,5 +56,5 @@ export const RadioSelection = <T extends Object>(
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
30
packages/excalidraw/components/ButtonSelect.tsx
Normal file
30
packages/excalidraw/components/ButtonSelect.tsx
Normal 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>
|
||||
);
|
@ -19,7 +19,6 @@ interface ColorInputProps {
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
colorPickerType: ColorPickerType;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const ColorInput = ({
|
||||
@ -27,7 +26,6 @@ export const ColorInput = ({
|
||||
onChange,
|
||||
label,
|
||||
colorPickerType,
|
||||
placeholder,
|
||||
}: ColorInputProps) => {
|
||||
const device = useDevice();
|
||||
const [innerValue, setInnerValue] = useState(color);
|
||||
@ -95,7 +93,6 @@ export const ColorInput = ({
|
||||
}
|
||||
event.stopPropagation();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{!device.editor.isMobile && (
|
||||
|
@ -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 {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
|
@ -18,7 +18,6 @@ import { useExcalidrawContainer } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { slashIcon } from "../icons";
|
||||
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { Picker } from "./Picker";
|
||||
@ -55,11 +54,7 @@ export const getColor = (color: string): string | null => {
|
||||
|
||||
interface ColorPickerProps {
|
||||
type: ColorPickerType;
|
||||
/**
|
||||
* null indicates no color should be displayed as active
|
||||
* (e.g. when multiple shapes selected with different colors)
|
||||
*/
|
||||
color: string | null;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -96,21 +91,22 @@ const ColorPickerPopupContent = ({
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
<ColorInput
|
||||
color={color || ""}
|
||||
color={color}
|
||||
label={label}
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
colorPickerType={type}
|
||||
placeholder={t("colorPicker.color")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const colorPickerContentRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusPickerContent = () => {
|
||||
colorPickerContentRef.current?.focus();
|
||||
popoverRef.current
|
||||
?.querySelector<HTMLDivElement>(".color-picker-content")
|
||||
?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -137,7 +133,6 @@ const ColorPickerPopupContent = ({
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
ref={colorPickerContentRef}
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
@ -171,6 +166,7 @@ const ColorPickerPopupContent = ({
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
@ -189,7 +185,7 @@ const ColorPickerTrigger = ({
|
||||
color,
|
||||
type,
|
||||
}: {
|
||||
color: string | null;
|
||||
color: string;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
}) => {
|
||||
@ -197,9 +193,8 @@ const ColorPickerTrigger = ({
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
className={clsx("color-picker__button active-color properties-trigger", {
|
||||
"is-transparent": !color || color === "transparent",
|
||||
"has-outline":
|
||||
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
"has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||
})}
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
@ -209,7 +204,7 @@ const ColorPickerTrigger = ({
|
||||
: t("labels.showBackground")
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
<div className="color-picker__button-outline" />
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
|
@ -8,7 +8,7 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
|
||||
interface CustomColorListProps {
|
||||
colors: string[];
|
||||
color: string | null;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useImperativeHandle, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { EVENT } from "@excalidraw/common";
|
||||
|
||||
@ -30,8 +30,9 @@ import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
|
||||
interface PickerProps {
|
||||
color: string | null;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
palette: ColorPaletteCustom;
|
||||
@ -41,150 +42,142 @@ interface PickerProps {
|
||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Picker = React.forwardRef(
|
||||
(
|
||||
{
|
||||
color,
|
||||
onChange,
|
||||
type,
|
||||
elements,
|
||||
palette,
|
||||
updateData,
|
||||
children,
|
||||
onEyeDropperToggle,
|
||||
onEscape,
|
||||
}: PickerProps,
|
||||
ref,
|
||||
) => {
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
export const Picker = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
type,
|
||||
elements,
|
||||
palette,
|
||||
updateData,
|
||||
children,
|
||||
onEyeDropperToggle,
|
||||
onEscape,
|
||||
}: PickerProps) => {
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
}
|
||||
return getMostUsedCustomColors(elements, type, palette);
|
||||
});
|
||||
|
||||
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(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color,
|
||||
palette,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeColorPickerSection) {
|
||||
const isCustom = !!color && isCustomColor({ color, palette });
|
||||
const isCustomButNotInList = isCustom && !customColors.includes(color);
|
||||
|
||||
setActiveColorPickerSection(
|
||||
isCustomButNotInList
|
||||
? null
|
||||
: 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);
|
||||
}
|
||||
};
|
||||
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>
|
||||
)}
|
||||
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.colors")}</PickerHeading>
|
||||
<PickerColorList
|
||||
<PickerHeading>
|
||||
{t("colorPicker.mostUsedCustomColors")}
|
||||
</PickerHeading>
|
||||
<CustomColorList
|
||||
colors={customColors}
|
||||
color={color}
|
||||
palette={palette}
|
||||
label={t("colorPicker.mostUsedCustomColors")}
|
||||
onChange={onChange}
|
||||
activeShade={activeShade}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||
<ShadeList color={color} onChange={onChange} palette={palette} />
|
||||
</div>
|
||||
{children}
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
|
||||
<PickerColorList
|
||||
color={color}
|
||||
label={label}
|
||||
palette={palette}
|
||||
onChange={onChange}
|
||||
activeShade={activeShade}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||
<ShadeList hex={color} onChange={onChange} palette={palette} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -17,8 +17,9 @@ import type { TranslationKeys } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
color: string | null;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
activeShade: number;
|
||||
}
|
||||
|
||||
@ -26,10 +27,11 @@ const PickerColorList = ({
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
activeShade,
|
||||
}: PickerColorListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color,
|
||||
color: color || "transparent",
|
||||
palette,
|
||||
});
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
|
@ -13,14 +13,14 @@ import {
|
||||
} from "./colorPickerUtils";
|
||||
|
||||
interface ShadeListProps {
|
||||
color: string | null;
|
||||
hex: string;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
}
|
||||
|
||||
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
||||
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color: color || "transparent",
|
||||
color: hex || "transparent",
|
||||
palette,
|
||||
});
|
||||
|
||||
|
@ -14,7 +14,7 @@ import type { ColorPickerType } from "./colorPickerUtils";
|
||||
interface TopPicksProps {
|
||||
onChange: (color: string) => void;
|
||||
type: ColorPickerType;
|
||||
activeColor: string | null;
|
||||
activeColor: string;
|
||||
topPicks?: readonly string[];
|
||||
}
|
||||
|
||||
|
@ -11,14 +11,11 @@ export const getColorNameAndShadeFromColor = ({
|
||||
color,
|
||||
}: {
|
||||
palette: ColorPaletteCustom;
|
||||
color: string | null;
|
||||
color: string;
|
||||
}): {
|
||||
colorName: ColorPickerColor;
|
||||
shade: number | null;
|
||||
} | null => {
|
||||
if (!color) {
|
||||
return null;
|
||||
}
|
||||
for (const [colorName, colorVal] of Object.entries(palette)) {
|
||||
if (Array.isArray(colorVal)) {
|
||||
const shade = colorVal.indexOf(color);
|
||||
|
@ -109,7 +109,7 @@ interface ColorPickerKeyNavHandlerProps {
|
||||
event: React.KeyboardEvent;
|
||||
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
||||
palette: ColorPaletteCustom;
|
||||
color: string | null;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
customColors: string[];
|
||||
setActiveColorPickerSection: (
|
||||
@ -270,7 +270,7 @@ export const colorPickerKeyNavHandler = ({
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "custom") {
|
||||
const indexOfColor = color != null ? customColors.indexOf(color) : 0;
|
||||
const indexOfColor = customColors.indexOf(color);
|
||||
|
||||
const newColorIndex = arrowHandler(
|
||||
event.key,
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getLinearElementSubType,
|
||||
updateElbowArrowPoints,
|
||||
} from "@excalidraw/element";
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||
|
||||
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@ -11,54 +8,54 @@ import {
|
||||
hasBoundTextElement,
|
||||
isArrowBoundToElement,
|
||||
isArrowElement,
|
||||
isCurvedArrow,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isSharpArrow,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
getCommonBoundingBox,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/bounds";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import { wrapText } from "@excalidraw/element";
|
||||
import { wrapText } from "@excalidraw/element/textWrapping";
|
||||
|
||||
import {
|
||||
assertNever,
|
||||
CLASSES,
|
||||
getFontString,
|
||||
isProdEnv,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { measureText } from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/newElement";
|
||||
|
||||
import { ShapeCache } from "@excalidraw/element";
|
||||
|
||||
import { updateBindings } from "@excalidraw/element";
|
||||
import { ShapeCache } from "@excalidraw/element/ShapeCache";
|
||||
|
||||
import type {
|
||||
ConvertibleGenericTypes,
|
||||
ConvertibleLinearTypes,
|
||||
ConvertibleTypes,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawLinearElement,
|
||||
@ -69,7 +66,7 @@ import type {
|
||||
FixedSegment,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
import type Scene from "@excalidraw/element/Scene";
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
@ -78,7 +75,8 @@ import {
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "..";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { atom } from "../editor-jotai";
|
||||
import { atom, editorJotaiStore, useSetAtom } from "../editor-jotai";
|
||||
import { updateBindings } from "../../element/src/binding";
|
||||
|
||||
import "./ConvertElementTypePopup.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
@ -99,12 +97,6 @@ import type { AppClassProperties } from "../types";
|
||||
const GAP_HORIZONTAL = 8;
|
||||
const GAP_VERTICAL = 10;
|
||||
|
||||
type ExcalidrawConvertibleElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawLinearElement;
|
||||
|
||||
// indicates order of switching
|
||||
const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const;
|
||||
// indicates order of switching
|
||||
@ -138,21 +130,28 @@ export const convertElementTypePopupAtom = atom<{
|
||||
type: "panel";
|
||||
} | null>(null);
|
||||
|
||||
type CacheKey = string & { _brand: "CacheKey" };
|
||||
|
||||
const FONT_SIZE_CONVERSION_CACHE = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
{
|
||||
// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
|
||||
export const fontSize_conversionCacheAtom = atom<{
|
||||
[id: string]: {
|
||||
fontSize: number;
|
||||
}
|
||||
>();
|
||||
elementType: ConvertibleGenericTypes;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
const LINEAR_ELEMENT_CONVERSION_CACHE = new Map<
|
||||
CacheKey,
|
||||
ExcalidrawLinearElement
|
||||
>();
|
||||
// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
|
||||
export const linearElement_conversionCacheAtom = atom<{
|
||||
[id: string]: {
|
||||
properties:
|
||||
| Partial<ExcalidrawLinearElement>
|
||||
| Partial<ExcalidrawElbowArrowElement>;
|
||||
initialType: ConvertibleLinearTypes;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
const ConvertElementTypePopup = ({ app }: { app: App }) => {
|
||||
const setFontSizeCache = useSetAtom(fontSize_conversionCacheAtom);
|
||||
const setLinearElementCache = useSetAtom(linearElement_conversionCacheAtom);
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||
const elementsCategoryRef = useRef<ConversionType>(null);
|
||||
|
||||
@ -179,10 +178,10 @@ const ConvertElementTypePopup = ({ app }: { app: App }) => {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
FONT_SIZE_CONVERSION_CACHE.clear();
|
||||
LINEAR_ELEMENT_CONVERSION_CACHE.clear();
|
||||
setFontSizeCache(null);
|
||||
setLinearElementCache(null);
|
||||
};
|
||||
}, []);
|
||||
}, [setFontSizeCache, setLinearElementCache]);
|
||||
|
||||
return <Panel app={app} elements={selectedElements} />;
|
||||
};
|
||||
@ -215,8 +214,7 @@ const Panel = ({
|
||||
: conversionType === "linear"
|
||||
? linearElements.every(
|
||||
(element) =>
|
||||
getLinearElementSubType(element) ===
|
||||
getLinearElementSubType(linearElements[0]),
|
||||
getArrowType(element) === getArrowType(linearElements[0]),
|
||||
)
|
||||
: false;
|
||||
|
||||
@ -265,29 +263,51 @@ const Panel = ({
|
||||
}, [genericElements, linearElements, app.scene, app.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorJotaiStore.get(linearElement_conversionCacheAtom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const linearElement of linearElements) {
|
||||
const cacheKey = toCacheKey(
|
||||
linearElement.id,
|
||||
getConvertibleType(linearElement),
|
||||
);
|
||||
if (!LINEAR_ELEMENT_CONVERSION_CACHE.has(cacheKey)) {
|
||||
LINEAR_ELEMENT_CONVERSION_CACHE.set(cacheKey, linearElement);
|
||||
}
|
||||
const initialType = getArrowType(linearElement);
|
||||
const cachedProperties =
|
||||
initialType === "line"
|
||||
? getLineProperties(linearElement)
|
||||
: initialType === "sharpArrow"
|
||||
? getSharpArrowProperties(linearElement)
|
||||
: initialType === "curvedArrow"
|
||||
? getCurvedArrowProperties(linearElement)
|
||||
: initialType === "elbowArrow"
|
||||
? getElbowArrowProperties(linearElement)
|
||||
: {};
|
||||
|
||||
editorJotaiStore.set(linearElement_conversionCacheAtom, {
|
||||
...editorJotaiStore.get(linearElement_conversionCacheAtom),
|
||||
[linearElement.id]: {
|
||||
properties: cachedProperties,
|
||||
initialType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [linearElements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorJotaiStore.get(fontSize_conversionCacheAtom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of genericElements) {
|
||||
if (!FONT_SIZE_CONVERSION_CACHE.has(element.id)) {
|
||||
const boundText = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundText) {
|
||||
FONT_SIZE_CONVERSION_CACHE.set(element.id, {
|
||||
const boundText = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundText) {
|
||||
editorJotaiStore.set(fontSize_conversionCacheAtom, {
|
||||
...editorJotaiStore.get(fontSize_conversionCacheAtom),
|
||||
[element.id]: {
|
||||
fontSize: boundText.fontSize,
|
||||
});
|
||||
}
|
||||
elementType: element.type as ConvertibleGenericTypes,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [genericElements, app.scene]);
|
||||
@ -329,7 +349,7 @@ const Panel = ({
|
||||
sameType &&
|
||||
((conversionType === "generic" && genericElements[0].type === type) ||
|
||||
(conversionType === "linear" &&
|
||||
getLinearElementSubType(linearElements[0]) === type));
|
||||
getArrowType(linearElements[0]) === type));
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
@ -479,11 +499,14 @@ export const convertElementTypes = (
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundText) {
|
||||
if (FONT_SIZE_CONVERSION_CACHE.get(element.id)) {
|
||||
if (
|
||||
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
|
||||
?.elementType === nextType
|
||||
) {
|
||||
mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
|
||||
fontSize:
|
||||
FONT_SIZE_CONVERSION_CACHE.get(element.id)?.fontSize ??
|
||||
boundText.fontSize,
|
||||
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
|
||||
?.fontSize ?? boundText.fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
@ -511,101 +534,124 @@ export const convertElementTypes = (
|
||||
selectedElements,
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
if (!nextType) {
|
||||
const commonSubType = reduceToCommonValue(
|
||||
convertibleLinearElements,
|
||||
getLinearElementSubType,
|
||||
);
|
||||
const arrowType = getArrowType(convertibleLinearElements[0]);
|
||||
const sameType = convertibleLinearElements.every(
|
||||
(element) => getArrowType(element) === arrowType,
|
||||
);
|
||||
|
||||
const index = commonSubType ? LINEAR_TYPES.indexOf(commonSubType) : -1;
|
||||
nextType =
|
||||
LINEAR_TYPES[
|
||||
(index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
|
||||
];
|
||||
}
|
||||
|
||||
if (isConvertibleLinearType(nextType)) {
|
||||
const convertedElements: ExcalidrawElement[] = [];
|
||||
|
||||
const nextElementsMap: Map<ExcalidrawElement["id"], ExcalidrawElement> =
|
||||
app.scene.getElementsMapIncludingDeleted();
|
||||
const index = sameType ? LINEAR_TYPES.indexOf(arrowType) : -1;
|
||||
nextType =
|
||||
nextType ??
|
||||
LINEAR_TYPES[
|
||||
(index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
|
||||
];
|
||||
|
||||
if (nextType && isConvertibleLinearType(nextType)) {
|
||||
const convertedElements: Record<string, ExcalidrawElement> = {};
|
||||
for (const element of convertibleLinearElements) {
|
||||
const cachedElement = LINEAR_ELEMENT_CONVERSION_CACHE.get(
|
||||
toCacheKey(element.id, nextType),
|
||||
);
|
||||
const { properties, initialType } =
|
||||
editorJotaiStore.get(linearElement_conversionCacheAtom)?.[
|
||||
element.id
|
||||
] || {};
|
||||
|
||||
// if switching to the original subType or a subType we've already
|
||||
// converted to, reuse the cached element to get the original properties
|
||||
// (needed for simple->elbow->simple conversions or between line
|
||||
// and arrows)
|
||||
// If the initial type is not elbow, and when we switch to elbow,
|
||||
// the linear line might be "bent" and the points would likely be different.
|
||||
// When we then switch to other non elbow types from this converted elbow,
|
||||
// we still want to use the original points instead.
|
||||
if (
|
||||
cachedElement &&
|
||||
getLinearElementSubType(cachedElement) === nextType
|
||||
initialType &&
|
||||
properties &&
|
||||
isElbowArrow(element) &&
|
||||
initialType !== "elbowArrow" &&
|
||||
nextType !== "elbowArrow"
|
||||
) {
|
||||
nextElementsMap.set(cachedElement.id, cachedElement);
|
||||
convertedElements.push(cachedElement);
|
||||
// first convert back to the original type
|
||||
const originalType = convertElementType(
|
||||
element,
|
||||
initialType,
|
||||
app,
|
||||
) as ExcalidrawLinearElement;
|
||||
// then convert to the target type
|
||||
const converted = convertElementType(
|
||||
initialType === "line"
|
||||
? newLinearElement({
|
||||
...originalType,
|
||||
...properties,
|
||||
type: "line",
|
||||
})
|
||||
: newArrowElement({
|
||||
...originalType,
|
||||
...properties,
|
||||
type: "arrow",
|
||||
}),
|
||||
nextType,
|
||||
app,
|
||||
);
|
||||
convertedElements[converted.id] = converted;
|
||||
} else {
|
||||
const converted = convertElementType(element, nextType, app);
|
||||
nextElementsMap.set(converted.id, converted);
|
||||
convertedElements.push(converted);
|
||||
convertedElements[converted.id] = converted;
|
||||
}
|
||||
}
|
||||
|
||||
app.scene.replaceAllElements(nextElementsMap);
|
||||
const nextElements = [];
|
||||
|
||||
// post normalization
|
||||
for (const element of convertedElements) {
|
||||
if (isLinearElement(element)) {
|
||||
if (isElbowArrow(element)) {
|
||||
const nextPoints = convertLineToElbow(element);
|
||||
if (nextPoints.length < 2) {
|
||||
// skip if not enough points to form valid segments
|
||||
continue;
|
||||
}
|
||||
const fixedSegments: FixedSegment[] = [];
|
||||
for (let i = 0; i < nextPoints.length - 1; i++) {
|
||||
fixedSegments.push({
|
||||
start: nextPoints[i],
|
||||
end: nextPoints[i + 1],
|
||||
index: i + 1,
|
||||
});
|
||||
}
|
||||
const updates = updateElbowArrowPoints(
|
||||
for (const element of app.scene.getElementsIncludingDeleted()) {
|
||||
if (convertedElements[element.id]) {
|
||||
nextElements.push(convertedElements[element.id]);
|
||||
} else {
|
||||
nextElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
app.scene.replaceAllElements(nextElements);
|
||||
|
||||
for (const element of Object.values(convertedElements)) {
|
||||
const cachedLinear = editorJotaiStore.get(
|
||||
linearElement_conversionCacheAtom,
|
||||
)?.[element.id];
|
||||
|
||||
if (cachedLinear) {
|
||||
const { properties, initialType } = cachedLinear;
|
||||
|
||||
if (initialType === nextType) {
|
||||
mutateElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
points: nextPoints,
|
||||
fixedSegments,
|
||||
},
|
||||
properties,
|
||||
);
|
||||
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
||||
...updates,
|
||||
});
|
||||
} else {
|
||||
// if we're converting to non-elbow linear element, check if
|
||||
// we've already cached one of these linear elements so we can
|
||||
// reuse the points (case: curved->elbow->line and similar)
|
||||
|
||||
const similarCachedLinearElement = mapFind(
|
||||
["line", "sharpArrow", "curvedArrow"] as const,
|
||||
(type) =>
|
||||
LINEAR_ELEMENT_CONVERSION_CACHE.get(
|
||||
toCacheKey(element.id, type),
|
||||
),
|
||||
);
|
||||
|
||||
if (similarCachedLinearElement) {
|
||||
const points = similarCachedLinearElement.points;
|
||||
app.scene.mutateElement(element, {
|
||||
points,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const nextPoints = convertLineToElbow(element);
|
||||
if (nextPoints.length < 2) {
|
||||
// skip if not enough points to form valid segments
|
||||
continue;
|
||||
}
|
||||
const fixedSegments: FixedSegment[] = [];
|
||||
for (let i = 0; i < nextPoints.length - 1; i++) {
|
||||
fixedSegments.push({
|
||||
start: nextPoints[i],
|
||||
end: nextPoints[i + 1],
|
||||
index: i + 1,
|
||||
});
|
||||
}
|
||||
const updates = updateElbowArrowPoints(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
points: nextPoints,
|
||||
fixedSegments,
|
||||
},
|
||||
);
|
||||
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
||||
...updates,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convertedSelectedLinearElements = filterLinearConvertibleElements(
|
||||
app.scene.getSelectedElements(app.state),
|
||||
);
|
||||
@ -661,11 +707,83 @@ const isEligibleLinearElement = (element: ExcalidrawElement) => {
|
||||
);
|
||||
};
|
||||
|
||||
const toCacheKey = (
|
||||
elementId: ExcalidrawElement["id"],
|
||||
convertitleType: ConvertibleTypes,
|
||||
) => {
|
||||
return `${elementId}:${convertitleType}` as CacheKey;
|
||||
const getArrowType = (element: ExcalidrawLinearElement) => {
|
||||
if (isSharpArrow(element)) {
|
||||
return "sharpArrow";
|
||||
}
|
||||
if (isCurvedArrow(element)) {
|
||||
return "curvedArrow";
|
||||
}
|
||||
if (isElbowArrow(element)) {
|
||||
return "elbowArrow";
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
||||
const getLineProperties = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Partial<ExcalidrawLinearElement> => {
|
||||
if (element.type === "line") {
|
||||
return {
|
||||
points: element.points,
|
||||
roundness: element.roundness,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const getSharpArrowProperties = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Partial<ExcalidrawArrowElement> => {
|
||||
if (isSharpArrow(element)) {
|
||||
return {
|
||||
points: element.points,
|
||||
startArrowhead: element.startArrowhead,
|
||||
endArrowhead: element.endArrowhead,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
roundness: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getCurvedArrowProperties = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Partial<ExcalidrawArrowElement> => {
|
||||
if (isCurvedArrow(element)) {
|
||||
return {
|
||||
points: element.points,
|
||||
startArrowhead: element.startArrowhead,
|
||||
endArrowhead: element.endArrowhead,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
roundness: element.roundness,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getElbowArrowProperties = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Partial<ExcalidrawElbowArrowElement> => {
|
||||
if (isElbowArrow(element)) {
|
||||
return {
|
||||
points: element.points,
|
||||
startArrowhead: element.startArrowhead,
|
||||
endArrowhead: element.endArrowhead,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
roundness: null,
|
||||
fixedSegments: element.fixedSegments,
|
||||
startIsSpecial: element.startIsSpecial,
|
||||
endIsSpecial: element.endIsSpecial,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const filterGenericConvetibleElements = (elements: ExcalidrawElement[]) =>
|
||||
@ -926,13 +1044,4 @@ const isValidConversion = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const getConvertibleType = (
|
||||
element: ExcalidrawConvertibleElement,
|
||||
): ConvertibleTypes => {
|
||||
if (isLinearElement(element)) {
|
||||
return getLinearElementSubType(element);
|
||||
}
|
||||
return element.type;
|
||||
};
|
||||
|
||||
export default ConvertElementTypePopup;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { sceneCoordsToViewportCoords } from "@excalidraw/common";
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element/bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
|
@ -5,11 +5,11 @@ import { normalizeLink, KEYS } from "@excalidraw/common";
|
||||
import {
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/elementLink";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
import type Scene from "@excalidraw/element/Scene";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
@ -6,7 +6,7 @@ import { FONT_FAMILY } from "@excalidraw/common";
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { RadioSelection } from "../RadioSelection";
|
||||
import { ButtonIconSelect } from "../ButtonIconSelect";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
@ -82,14 +82,12 @@ export const FontPicker = React.memo(
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user