Compare commits
25 Commits
master
...
mrazator/d
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d2038b7c5a | ||
![]() |
de81ba25fd | ||
![]() |
858c65b314 | ||
![]() |
f00069be68 | ||
![]() |
7b72406824 | ||
![]() |
49925038fd | ||
![]() |
05ba0339fe | ||
![]() |
cdd7f6158b | ||
![]() |
7e0f5b6369 | ||
![]() |
310a9ae4e0 | ||
![]() |
c57249481e | ||
![]() |
e72d83541a | ||
![]() |
9f8c87ae8c | ||
![]() |
f6061f5ec6 | ||
![]() |
12be5d716b | ||
![]() |
1abb901ec2 | ||
![]() |
6a17541713 | ||
![]() |
040a57f56a | ||
![]() |
15d2942aaa | ||
![]() |
59a0653fd4 | ||
![]() |
725c25c966 | ||
![]() |
d2fed34a30 | ||
![]() |
f12ed8e0b2 | ||
![]() |
508cfbc843 | ||
![]() |
245d681b7d |
@ -1,5 +1,5 @@
|
|||||||
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
@ -32,12 +32,6 @@
|
|||||||
"name": "jotai",
|
"name": "jotai",
|
||||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"react/jsx-no-target-blank": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allowReferrer": true
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
|||||||
- 🏗️ Customizable.
|
- 🏗️ Customizable.
|
||||||
- 📷 Image support.
|
- 📷 Image support.
|
||||||
- 😀 Shape libraries support.
|
- 😀 Shape libraries support.
|
||||||
- 🌐 Localization (i18n) support.
|
- 👅 Localization (i18n) support.
|
||||||
- 🖼️ Export to PNG, SVG & clipboard.
|
- 🖼️ Export to PNG, SVG & clipboard.
|
||||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||||
|
@ -19,7 +19,7 @@ services:
|
|||||||
- ./:/opt/node_app/app:delegated
|
- ./:/opt/node_app/app:delegated
|
||||||
- ./package.json:/opt/node_app/package.json
|
- ./package.json:/opt/node_app/package.json
|
||||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||||
# - notused:/opt/node_app/app/node_modules
|
- notused:/opt/node_app/app/node_modules
|
||||||
|
|
||||||
# volumes:
|
volumes:
|
||||||
# notused:
|
notused:
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw .selected-shape-actions {
|
.excalidraw .panelColumn {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
import Slider from "rc-slider";
|
||||||
|
|
||||||
|
import "rc-slider/assets/index.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
@ -30,6 +34,7 @@ import {
|
|||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
isRunningInIframe,
|
isRunningInIframe,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
assertNever,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@ -47,15 +52,16 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import { isElementLink } from "@excalidraw/element";
|
import { isElementLink } from "@excalidraw/element/elementLink";
|
||||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
useHandleLibrary,
|
useHandleLibrary,
|
||||||
} from "@excalidraw/excalidraw/data/library";
|
} from "@excalidraw/excalidraw/data/library";
|
||||||
|
import { StoreDelta, DurableStoreIncrement, EphemeralStoreIncrement, StoreIncrement } from "@excalidraw/excalidraw/store";
|
||||||
|
|
||||||
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
||||||
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
|
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
|
||||||
@ -63,6 +69,7 @@ import type {
|
|||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@ -92,6 +99,7 @@ import Collab, {
|
|||||||
collabAPIAtom,
|
collabAPIAtom,
|
||||||
isCollaboratingAtom,
|
isCollaboratingAtom,
|
||||||
isOfflineAtom,
|
isOfflineAtom,
|
||||||
|
syncApiAtom,
|
||||||
} from "./collab/Collab";
|
} from "./collab/Collab";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
@ -368,11 +376,40 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
const [collabAPI] = useAtom(collabAPIAtom);
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
|
const [syncAPI] = useAtom(syncApiAtom);
|
||||||
|
const [sliderVersion, setSliderVersion] = useState(0);
|
||||||
|
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
|
||||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
return isCollaborationLink(window.location.href);
|
return isCollaborationLink(window.location.href);
|
||||||
});
|
});
|
||||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
acknowledgedDeltasRef.current = acknowledgedDeltas;
|
||||||
|
}, [acknowledgedDeltas]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const deltas = syncAPI?.acknowledgedDeltas ?? [];
|
||||||
|
|
||||||
|
// CFDO: buffer local deltas as well, not only acknowledged ones
|
||||||
|
if (deltas.length > acknowledgedDeltasRef.current.length) {
|
||||||
|
setAcknowledgedDeltas([...deltas]);
|
||||||
|
setSliderVersion(deltas.length);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
syncAPI?.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
syncAPI?.disconnect();
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [syncAPI]);
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
adapter: LibraryIndexedDBAdapter,
|
adapter: LibraryIndexedDBAdapter,
|
||||||
@ -675,6 +712,34 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onIncrement = (
|
||||||
|
increment: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!syncAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StoreIncrement.isDurable(increment)) {
|
||||||
|
// push only if there are element changes
|
||||||
|
if (!increment.delta.elements.isEmpty()) {
|
||||||
|
syncAPI.push(increment.delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StoreIncrement.isEphemeral(increment)) {
|
||||||
|
syncAPI.relay(increment.change);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNever(increment, `Unknown increment type`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error during onIncrement handler", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -797,6 +862,57 @@ const ExcalidrawWrapper = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedTimeTravel = debounce(
|
||||||
|
(value: number, direction: "forward" | "backward") => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextAppState = excalidrawAPI.getAppState();
|
||||||
|
// CFDO: retrieve the scene map already
|
||||||
|
let nextElements = new Map(
|
||||||
|
excalidrawAPI.getSceneElements().map((x) => [x.id, x]),
|
||||||
|
) as SceneElementsMap;
|
||||||
|
|
||||||
|
let deltas: StoreDelta[] = [];
|
||||||
|
|
||||||
|
// CFDO I: going both in collaborative setting means the (acknowledge) deltas need to have applied latest changes
|
||||||
|
switch (direction) {
|
||||||
|
case "forward": {
|
||||||
|
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "backward": {
|
||||||
|
deltas = acknowledgedDeltas
|
||||||
|
.slice(value)
|
||||||
|
.reverse()
|
||||||
|
.map((x) => StoreDelta.inverse(x));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(direction, `Unknown direction: ${direction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
[nextElements, nextAppState] = excalidrawAPI.store.applyDeltaTo(
|
||||||
|
delta,
|
||||||
|
nextElements,
|
||||||
|
nextAppState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
excalidrawAPI?.updateScene({
|
||||||
|
appState: {
|
||||||
|
...nextAppState,
|
||||||
|
viewModeEnabled: value !== acknowledgedDeltas.length,
|
||||||
|
},
|
||||||
|
elements: Array.from(nextElements.values()),
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
@ -804,9 +920,45 @@ const ExcalidrawWrapper = () => {
|
|||||||
"is-collaborating": isCollaborating,
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<Slider
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "25px",
|
||||||
|
zIndex: 999,
|
||||||
|
width: "60%",
|
||||||
|
left: "25%",
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
min={0}
|
||||||
|
max={acknowledgedDeltas.length}
|
||||||
|
value={sliderVersion}
|
||||||
|
onChange={(value) => {
|
||||||
|
const nextSliderVersion = value as number;
|
||||||
|
// CFDO: in safari the whole canvas gets selected when dragging
|
||||||
|
if (nextSliderVersion !== acknowledgedDeltas.length) {
|
||||||
|
// don't listen to updates in the detached mode
|
||||||
|
syncAPI?.disconnect();
|
||||||
|
} else {
|
||||||
|
// reconnect once we're back to the latest version
|
||||||
|
syncAPI?.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSliderVersion === sliderVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedTimeTravel(
|
||||||
|
nextSliderVersion,
|
||||||
|
nextSliderVersion < sliderVersion ? "backward" : "forward",
|
||||||
|
);
|
||||||
|
|
||||||
|
setSliderVersion(nextSliderVersion);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={excalidrawRefCallback}
|
excalidrawAPI={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onIncrement={onIncrement}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
@ -885,7 +1037,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
/>
|
/>
|
||||||
<OverwriteConfirmDialog>
|
<OverwriteConfirmDialog>
|
||||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
|
||||||
{excalidrawAPI && (
|
{excalidrawAPI && (
|
||||||
<OverwriteConfirmDialog.Action
|
<OverwriteConfirmDialog.Action
|
||||||
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||||
@ -926,22 +1077,17 @@ const ExcalidrawWrapper = () => {
|
|||||||
<ShareDialog
|
<ShareDialog
|
||||||
collabAPI={collabAPI}
|
collabAPI={collabAPI}
|
||||||
onExportToBackend={async () => {
|
onExportToBackend={async () => {
|
||||||
if (!excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { url, errorMessage } = await exportToBackend(
|
await onExportToBackend(
|
||||||
excalidrawAPI.getSceneElements(),
|
excalidrawAPI.getSceneElements(),
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getFiles(),
|
||||||
);
|
);
|
||||||
if (errorMessage) {
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
setLatestShareableLink(url);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setErrorMessage(error.message);
|
setErrorMessage(error.message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ export const STORAGE_KEYS = {
|
|||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
IDB_LIBRARY: "excalidraw-library",
|
IDB_LIBRARY: "excalidraw-library",
|
||||||
|
IDB_SYNC: "excalidraw-sync",
|
||||||
|
|
||||||
// do not use apart from migrations
|
// do not use apart from migrations
|
||||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||||
|
@ -19,9 +19,12 @@ import {
|
|||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
import {
|
||||||
|
isImageElement,
|
||||||
|
isInitializedImageElement,
|
||||||
|
} from "@excalidraw/element/typeChecks";
|
||||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||||
@ -70,7 +73,7 @@ import {
|
|||||||
FileManager,
|
FileManager,
|
||||||
updateStaleImageStatuses,
|
updateStaleImageStatuses,
|
||||||
} from "../data/FileManager";
|
} from "../data/FileManager";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData, SyncIndexedDBAdapter } from "../data/LocalData";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
loadFilesFromFirebase,
|
loadFilesFromFirebase,
|
||||||
@ -92,6 +95,7 @@ import type {
|
|||||||
SyncableExcalidrawElement,
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
|
export const syncApiAtom = atom<SyncClient | null>(null);
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
export const isOfflineAtom = atom(false);
|
export const isOfflineAtom = atom(false);
|
||||||
@ -238,6 +242,12 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
|
|
||||||
|
SyncClient.create(this.excalidrawAPI, SyncIndexedDBAdapter).then(
|
||||||
|
(syncAPI) => {
|
||||||
|
appJotaiStore.set(syncApiAtom, syncAPI);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
Object.defineProperties(window, {
|
Object.defineProperties(window, {
|
||||||
@ -271,6 +281,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
window.clearTimeout(this.idleTimeoutId);
|
window.clearTimeout(this.idleTimeoutId);
|
||||||
this.idleTimeoutId = null;
|
this.idleTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appJotaiStore.get(syncApiAtom)?.disconnect();
|
||||||
this.onUmmount?.();
|
this.onUmmount?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
|
@ -73,7 +73,7 @@ export const AIComponents = ({
|
|||||||
</br>
|
</br>
|
||||||
<div>You can also try <a href="${
|
<div>You can also try <a href="${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
|
@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
|||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
>
|
>
|
||||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||||
|
@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
|
|||||||
import.meta.env.VITE_APP_PLUS_APP
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
className="plus-button"
|
className="plus-button"
|
||||||
>
|
>
|
||||||
Go to Excalidraw+
|
Go to Excalidraw+
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -27,6 +27,8 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
|
||||||
|
import { StoreDelta } from "@excalidraw/excalidraw/store";
|
||||||
|
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||||
@ -35,7 +37,7 @@ import type {
|
|||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
import type { DTO, MaybePromise } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
@ -104,13 +106,12 @@ export class LocalData {
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
onFilesSaved: () => void,
|
onFilesSaved: () => void,
|
||||||
) => {
|
) => {
|
||||||
saveDataStateToLocalStorage(elements, appState);
|
// saveDataStateToLocalStorage(elements, appState);
|
||||||
|
// await this.fileStorage.saveFiles({
|
||||||
await this.fileStorage.saveFiles({
|
// elements,
|
||||||
elements,
|
// files,
|
||||||
files,
|
// });
|
||||||
});
|
// onFilesSaved();
|
||||||
onFilesSaved();
|
|
||||||
},
|
},
|
||||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||||
);
|
);
|
||||||
@ -256,3 +257,60 @@ export class LibraryLocalStorageMigrationAdapter {
|
|||||||
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncDeltaPersistedData = DTO<StoreDelta>[];
|
||||||
|
|
||||||
|
type SyncMetaPersistedData = {
|
||||||
|
lastAcknowledgedVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SyncIndexedDBAdapter {
|
||||||
|
/** IndexedDB database and store name */
|
||||||
|
private static idb_name = STORAGE_KEYS.IDB_SYNC;
|
||||||
|
/** library data store keys */
|
||||||
|
private static deltasKey = "deltas";
|
||||||
|
private static metadataKey = "metadata";
|
||||||
|
|
||||||
|
private static store = createStore(
|
||||||
|
`${SyncIndexedDBAdapter.idb_name}-db`,
|
||||||
|
`${SyncIndexedDBAdapter.idb_name}-store`,
|
||||||
|
);
|
||||||
|
|
||||||
|
static async loadDeltas() {
|
||||||
|
const deltas = await get<SyncDeltaPersistedData>(
|
||||||
|
SyncIndexedDBAdapter.deltasKey,
|
||||||
|
SyncIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deltas?.length) {
|
||||||
|
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
|
||||||
|
return set(
|
||||||
|
SyncIndexedDBAdapter.deltasKey,
|
||||||
|
data,
|
||||||
|
SyncIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async loadMetadata() {
|
||||||
|
const metadata = await get<SyncMetaPersistedData>(
|
||||||
|
SyncIndexedDBAdapter.metadataKey,
|
||||||
|
SyncIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
|
||||||
|
return metadata || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveMetadata(data: SyncMetaPersistedData): Promise<void> {
|
||||||
|
return set(
|
||||||
|
SyncIndexedDBAdapter.metadataKey,
|
||||||
|
data,
|
||||||
|
SyncIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -9,14 +9,14 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import { bytesToHexString } from "@excalidraw/common";
|
import { bytesToHexString } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { SceneBounds } from "@excalidraw/element";
|
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "2.11.0",
|
"jotai": "2.11.0",
|
||||||
|
"rc-slider": "11.1.7",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"socket.io-client": "4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
@ -41,8 +42,8 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "vite build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||||
"build:app": "vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
|
@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<a
|
<a
|
||||||
class="welcome-screen-menu-item "
|
class="welcome-screen-menu-item "
|
||||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -3,15 +3,11 @@ import {
|
|||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||||
import { syncInvalidIndices } from "@excalidraw/element";
|
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import { StoreIncrement } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -69,79 +65,6 @@ vi.mock("socket.io-client", () => {
|
|||||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
*/
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
|
||||||
const durableIncrements: DurableIncrement[] = [];
|
|
||||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
|
||||||
|
|
||||||
await render(<ExcalidrawApp />);
|
|
||||||
|
|
||||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
|
||||||
if (StoreIncrement.isDurable(increment)) {
|
|
||||||
durableIncrements.push(increment);
|
|
||||||
} else {
|
|
||||||
ephemeralIncrements.push(increment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
|
||||||
expect(durableIncrements.length).toBe(0);
|
|
||||||
expect(ephemeralIncrements.length).toBe(0);
|
|
||||||
|
|
||||||
const rectProps = {
|
|
||||||
type: "rectangle",
|
|
||||||
id: "A",
|
|
||||||
height: 200,
|
|
||||||
width: 100,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const rect = API.createElement({ ...rectProps });
|
|
||||||
|
|
||||||
API.updateScene({
|
|
||||||
elements: [rect],
|
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(durableIncrements.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// simulate two batched remote updates
|
|
||||||
act(() => {
|
|
||||||
h.app.updateScene({
|
|
||||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
});
|
|
||||||
h.app.updateScene({
|
|
||||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
});
|
|
||||||
|
|
||||||
// we scheduled two micro actions,
|
|
||||||
// which confirms they are going to be executed as part of one batched component update
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// altough the updates get batched,
|
|
||||||
// we expect two ephemeral increments for each update,
|
|
||||||
// and each such update should have the expected change
|
|
||||||
expect(ephemeralIncrements.length).toBe(2);
|
|
||||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
|
||||||
expect.objectContaining({ x: 100 }),
|
|
||||||
);
|
|
||||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
|
||||||
expect.objectContaining({ x: 200 }),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
const rect1Props = {
|
const rect1Props = {
|
||||||
|
@ -33,7 +33,6 @@
|
|||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"rimraf": "^5.0.0",
|
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.7.2",
|
||||||
@ -79,8 +78,8 @@
|
|||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"release:excalidraw": "node scripts/release.js",
|
"release:excalidraw": "node scripts/release.js",
|
||||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/common/src/*.d.ts"
|
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rimraf types && tsc",
|
"gen:types": "rm -rf types && tsc",
|
||||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,6 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|||||||
export const isWindows = /^Win/.test(navigator.platform);
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||||
export const isFirefox =
|
export const isFirefox =
|
||||||
typeof window !== "undefined" &&
|
|
||||||
"netscape" in window &&
|
"netscape" in window &&
|
||||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||||
navigator.userAgent.indexOf("Gecko") > 1;
|
navigator.userAgent.indexOf("Gecko") > 1;
|
||||||
@ -120,7 +119,6 @@ export const CLASSES = {
|
|||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@ -144,7 +142,6 @@ export const FONT_FAMILY = {
|
|||||||
"Lilita One": 7,
|
"Lilita One": 7,
|
||||||
"Comic Shanns": 8,
|
"Comic Shanns": 8,
|
||||||
"Liberation Sans": 9,
|
"Liberation Sans": 9,
|
||||||
Assistant: 10,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FONT_FAMILY_FALLBACKS = {
|
export const FONT_FAMILY_FALLBACKS = {
|
||||||
@ -256,7 +253,7 @@ export const EXPORT_DATA_TYPES = {
|
|||||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const getExportSource = () =>
|
export const EXPORT_SOURCE =
|
||||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||||
|
|
||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
|
@ -22,10 +22,8 @@ export interface FontMetadata {
|
|||||||
};
|
};
|
||||||
/** flag to indicate a deprecated font */
|
/** flag to indicate a deprecated font */
|
||||||
deprecated?: true;
|
deprecated?: true;
|
||||||
/**
|
/** flag to indicate a server-side only font */
|
||||||
* whether this is a font that users can use (= shown in font picker)
|
serverSide?: true;
|
||||||
*/
|
|
||||||
private?: true;
|
|
||||||
/** flag to indiccate a local-only font */
|
/** flag to indiccate a local-only font */
|
||||||
local?: true;
|
local?: true;
|
||||||
/** flag to indicate a fallback font */
|
/** flag to indicate a fallback font */
|
||||||
@ -46,7 +44,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 1011,
|
ascender: 1011,
|
||||||
descender: -353,
|
descender: -353,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.35,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[FONT_FAMILY["Lilita One"]]: {
|
[FONT_FAMILY["Lilita One"]]: {
|
||||||
@ -100,23 +98,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||||||
descender: -434,
|
descender: -434,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
},
|
},
|
||||||
private: true,
|
serverSide: true,
|
||||||
},
|
|
||||||
[FONT_FAMILY.Assistant]: {
|
|
||||||
metrics: {
|
|
||||||
unitsPerEm: 2048,
|
|
||||||
ascender: 1021,
|
|
||||||
descender: -287,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
},
|
|
||||||
private: true,
|
|
||||||
},
|
},
|
||||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||||
metrics: {
|
metrics: {
|
||||||
unitsPerEm: 1000,
|
unitsPerEm: 1000,
|
||||||
ascender: 880,
|
ascender: 880,
|
||||||
descender: -144,
|
descender: -144,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.15,
|
||||||
},
|
},
|
||||||
fallback: true,
|
fallback: true,
|
||||||
},
|
},
|
||||||
|
@ -9,4 +9,3 @@ export * from "./promise-pool";
|
|||||||
export * from "./random";
|
export * from "./random";
|
||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./emitter";
|
|
||||||
|
@ -66,14 +66,10 @@ export type MakeBrand<T extends string> = {
|
|||||||
/** Maybe just promise or already fulfilled one! */
|
/** Maybe just promise or already fulfilled one! */
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
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 */
|
/** Strip all the methods or functions from a type */
|
||||||
export type DTO<T> = {
|
export type DTO<T> = {
|
||||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
// get union of all keys from the union of types
|
||||||
? [K, V]
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
: never;
|
|
||||||
|
@ -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;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** returns the first non-null mapped value */
|
|
||||||
export const mapFind = <T, K>(
|
|
||||||
collection: readonly T[],
|
|
||||||
iteratee: (value: T, index: number) => K | undefined | null,
|
|
||||||
): K | undefined => {
|
|
||||||
for (let idx = 0; idx < collection.length; idx++) {
|
|
||||||
const result = iteratee(collection[idx], idx);
|
|
||||||
if (result != null) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isTransparent = (color: string) => {
|
export const isTransparent = (color: string) => {
|
||||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||||
@ -749,25 +735,6 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, [] as Node<T>[]);
|
}, [] as Node<T>[]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a readonly array or map into an iterable.
|
|
||||||
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
|
||||||
*/
|
|
||||||
export const toIterable = <T>(
|
|
||||||
values: readonly T[] | ReadonlyMap<string, T>,
|
|
||||||
): Iterable<T> => {
|
|
||||||
return Array.isArray(values) ? values : values.values();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a readonly array or map into an array.
|
|
||||||
*/
|
|
||||||
export const toArray = <T>(
|
|
||||||
values: readonly T[] | ReadonlyMap<string, T>,
|
|
||||||
): T[] => {
|
|
||||||
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||||
|
|
||||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||||
@ -1258,39 +1225,11 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sizeOf = (
|
export const sizeOf = (
|
||||||
value:
|
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
||||||
| readonly unknown[]
|
|
||||||
| Readonly<Map<string, unknown>>
|
|
||||||
| Readonly<Record<string, unknown>>
|
|
||||||
| ReadonlySet<unknown>,
|
|
||||||
): number => {
|
): number => {
|
||||||
return isReadonlyArray(value)
|
return isReadonlyArray(value)
|
||||||
? value.length
|
? value.length
|
||||||
: value instanceof Map || value instanceof Set
|
: value instanceof Map
|
||||||
? value.size
|
? value.size
|
||||||
: Object.keys(value).length;
|
: Object.keys(value).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reduceToCommonValue = <T, R = T>(
|
|
||||||
collection: readonly T[] | ReadonlySet<T>,
|
|
||||||
getValue?: (item: T) => R,
|
|
||||||
): R | null => {
|
|
||||||
if (sizeOf(collection) === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
|
||||||
|
|
||||||
let commonValue: R | null = null;
|
|
||||||
|
|
||||||
for (const item of collection) {
|
|
||||||
const value = valueExtractor(item);
|
|
||||||
if ((commonValue === null || commonValue === value) && value != null) {
|
|
||||||
commonValue = value;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return commonValue;
|
|
||||||
};
|
|
||||||
|
38
packages/deltas/package.json
Normal file
38
packages/deltas/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@excalidraw/deltas",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./dist/prod/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"module": "./dist/prod/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/*"
|
||||||
|
],
|
||||||
|
"description": "Excalidraw utilities for handling deltas",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"excalidraw",
|
||||||
|
"excalidraw-deltas"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "5.0.9",
|
||||||
|
"roughjs": "4.6.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
|
"scripts": {
|
||||||
|
"gen:types": "rm -rf types && tsc",
|
||||||
|
"build:esm": "rm -rf dist && node ../../scripts/buildShared.js && yarn gen:types",
|
||||||
|
"pack": "yarn build:umd && yarn pack"
|
||||||
|
}
|
||||||
|
}
|
357
packages/deltas/src/common/delta.ts
Normal file
357
packages/deltas/src/common/delta.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { arrayToObject, assertNever } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the difference between two objects of the same type.
|
||||||
|
*
|
||||||
|
* Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where:
|
||||||
|
* - `deleted` is a set of all the deleted values
|
||||||
|
* - `inserted` is a set of all the inserted (added, updated) values
|
||||||
|
*
|
||||||
|
* 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> {
|
||||||
|
private constructor(
|
||||||
|
public readonly deleted: Partial<T>,
|
||||||
|
public readonly inserted: Partial<T>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create<T>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
modifier?: (delta: Partial<T>) => Partial<T>,
|
||||||
|
modifierOptions?: "deleted" | "inserted",
|
||||||
|
) {
|
||||||
|
const modifiedDeleted =
|
||||||
|
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
|
||||||
|
const modifiedInserted =
|
||||||
|
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
|
||||||
|
|
||||||
|
return new Delta(modifiedDeleted, modifiedInserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the delta between two objects.
|
||||||
|
*
|
||||||
|
* @param prevObject - The previous state of the object.
|
||||||
|
* @param nextObject - The next state of the object.
|
||||||
|
*
|
||||||
|
* @returns new delta instance.
|
||||||
|
*/
|
||||||
|
public static calculate<T extends { [key: string]: any }>(
|
||||||
|
prevObject: T,
|
||||||
|
nextObject: T,
|
||||||
|
modifier?: (partial: Partial<T>) => Partial<T>,
|
||||||
|
postProcess?: (
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
) => [Partial<T>, Partial<T>],
|
||||||
|
): Delta<T> {
|
||||||
|
if (prevObject === nextObject) {
|
||||||
|
return Delta.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = {} as Partial<T>;
|
||||||
|
const inserted = {} as Partial<T>;
|
||||||
|
|
||||||
|
// O(n^3) here for elements, but it's not as bad as it looks:
|
||||||
|
// - we do this only on store recordings, not on every frame (not for ephemerals)
|
||||||
|
// - we do this only on previously detected changed elements
|
||||||
|
// - we do shallow compare only on the first level of properties (not going any deeper)
|
||||||
|
// - # of properties is reasonably small
|
||||||
|
for (const key of this.distinctKeysIterator(
|
||||||
|
"full",
|
||||||
|
prevObject,
|
||||||
|
nextObject,
|
||||||
|
)) {
|
||||||
|
deleted[key as keyof T] = prevObject[key];
|
||||||
|
inserted[key as keyof T] = nextObject[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [processedDeleted, processedInserted] = postProcess
|
||||||
|
? postProcess(deleted, inserted)
|
||||||
|
: [deleted, inserted];
|
||||||
|
|
||||||
|
return Delta.create(processedDeleted, processedInserted, modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return new Delta({}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isEmpty<T>(delta: Delta<T>): boolean {
|
||||||
|
return (
|
||||||
|
!Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges deleted and inserted object partials.
|
||||||
|
*/
|
||||||
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
|
prev: T,
|
||||||
|
added: T,
|
||||||
|
removed: T,
|
||||||
|
) {
|
||||||
|
const cloned = { ...prev };
|
||||||
|
|
||||||
|
for (const key of Object.keys(removed)) {
|
||||||
|
delete cloned[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...cloned, ...added };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges deleted and inserted array partials.
|
||||||
|
*/
|
||||||
|
public static mergeArrays<T>(
|
||||||
|
prev: readonly T[] | null,
|
||||||
|
added: readonly T[] | null | undefined,
|
||||||
|
removed: readonly T[] | null | undefined,
|
||||||
|
predicate?: (value: T) => string,
|
||||||
|
) {
|
||||||
|
return Object.values(
|
||||||
|
Delta.mergeObjects(
|
||||||
|
arrayToObject(prev ?? [], predicate),
|
||||||
|
arrayToObject(added ?? [], predicate),
|
||||||
|
arrayToObject(removed ?? [], predicate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff object partials as part of the `postProcess`.
|
||||||
|
*/
|
||||||
|
public static diffObjects<T, K extends keyof T, V extends T[K][keyof T[K]]>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
property: K,
|
||||||
|
setValue: (prevValue: V | undefined) => V,
|
||||||
|
) {
|
||||||
|
if (!deleted[property] && !inserted[property]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof deleted[property] === "object" ||
|
||||||
|
typeof inserted[property] === "object"
|
||||||
|
) {
|
||||||
|
type RecordLike = Record<string, V | undefined>;
|
||||||
|
|
||||||
|
const deletedObject: RecordLike = deleted[property] ?? {};
|
||||||
|
const insertedObject: RecordLike = inserted[property] ?? {};
|
||||||
|
|
||||||
|
const deletedDifferences = Delta.getLeftDifferences(
|
||||||
|
deletedObject,
|
||||||
|
insertedObject,
|
||||||
|
).reduce((acc, curr) => {
|
||||||
|
acc[curr] = setValue(deletedObject[curr]);
|
||||||
|
return acc;
|
||||||
|
}, {} as RecordLike);
|
||||||
|
|
||||||
|
const insertedDifferences = Delta.getRightDifferences(
|
||||||
|
deletedObject,
|
||||||
|
insertedObject,
|
||||||
|
).reduce((acc, curr) => {
|
||||||
|
acc[curr] = setValue(insertedObject[curr]);
|
||||||
|
return acc;
|
||||||
|
}, {} as RecordLike);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(deletedDifferences).length ||
|
||||||
|
Object.keys(insertedDifferences).length
|
||||||
|
) {
|
||||||
|
Reflect.set(deleted, property, deletedDifferences);
|
||||||
|
Reflect.set(inserted, property, insertedDifferences);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(deleted, property);
|
||||||
|
Reflect.deleteProperty(inserted, property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff array partials as part of the `postProcess`.
|
||||||
|
*/
|
||||||
|
public static diffArrays<T, K extends keyof T, V extends T[K]>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
property: K,
|
||||||
|
groupBy: (value: V extends ArrayLike<infer T> ? T : never) => string,
|
||||||
|
) {
|
||||||
|
if (!deleted[property] && !inserted[property]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) {
|
||||||
|
const deletedArray = (
|
||||||
|
Array.isArray(deleted[property]) ? deleted[property] : []
|
||||||
|
) as [];
|
||||||
|
const insertedArray = (
|
||||||
|
Array.isArray(inserted[property]) ? inserted[property] : []
|
||||||
|
) as [];
|
||||||
|
|
||||||
|
const deletedDifferences = arrayToObject(
|
||||||
|
Delta.getLeftDifferences(
|
||||||
|
arrayToObject(deletedArray, groupBy),
|
||||||
|
arrayToObject(insertedArray, groupBy),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const insertedDifferences = arrayToObject(
|
||||||
|
Delta.getRightDifferences(
|
||||||
|
arrayToObject(deletedArray, groupBy),
|
||||||
|
arrayToObject(insertedArray, groupBy),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(deletedDifferences).length ||
|
||||||
|
Object.keys(insertedDifferences).length
|
||||||
|
) {
|
||||||
|
const deletedValue = deletedArray.filter(
|
||||||
|
(x) => deletedDifferences[groupBy ? groupBy(x) : String(x)],
|
||||||
|
);
|
||||||
|
const insertedValue = insertedArray.filter(
|
||||||
|
(x) => insertedDifferences[groupBy ? groupBy(x) : String(x)],
|
||||||
|
);
|
||||||
|
|
||||||
|
Reflect.set(deleted, property, deletedValue);
|
||||||
|
Reflect.set(inserted, property, insertedValue);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(deleted, property);
|
||||||
|
Reflect.deleteProperty(inserted, property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares if object1 contains any different value compared to the object2.
|
||||||
|
*/
|
||||||
|
public static isLeftDifferent<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
): boolean {
|
||||||
|
const anyDistinctKey = this.distinctKeysIterator(
|
||||||
|
"left",
|
||||||
|
object1,
|
||||||
|
object2,
|
||||||
|
skipShallowCompare,
|
||||||
|
).next().value;
|
||||||
|
|
||||||
|
return !!anyDistinctKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares if object2 contains any different value compared to the object1.
|
||||||
|
*/
|
||||||
|
public static isRightDifferent<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
): boolean {
|
||||||
|
const anyDistinctKey = this.distinctKeysIterator(
|
||||||
|
"right",
|
||||||
|
object1,
|
||||||
|
object2,
|
||||||
|
skipShallowCompare,
|
||||||
|
).next().value;
|
||||||
|
|
||||||
|
return !!anyDistinctKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the object1 keys that have distinct values.
|
||||||
|
*/
|
||||||
|
public static getLeftDifferences<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the object2 keys that have distinct values.
|
||||||
|
*/
|
||||||
|
public static getRightDifferences<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator comparing values of object properties based on the passed joining strategy.
|
||||||
|
*
|
||||||
|
* @yields keys of properties with different values
|
||||||
|
*
|
||||||
|
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
|
||||||
|
*/
|
||||||
|
private static *distinctKeysIterator<T extends {}>(
|
||||||
|
join: "left" | "right" | "full",
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
if (object1 === object2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys: string[] = [];
|
||||||
|
|
||||||
|
if (join === "left") {
|
||||||
|
keys = Object.keys(object1);
|
||||||
|
} else if (join === "right") {
|
||||||
|
keys = Object.keys(object2);
|
||||||
|
} else if (join === "full") {
|
||||||
|
keys = Array.from(
|
||||||
|
new Set([...Object.keys(object1), ...Object.keys(object2)]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assertNever(join, "Unknown distinctKeysIterator's join param");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const object1Value = object1[key as keyof T];
|
||||||
|
const object2Value = object2[key as keyof T];
|
||||||
|
|
||||||
|
if (object1Value !== object2Value) {
|
||||||
|
if (
|
||||||
|
!skipShallowCompare &&
|
||||||
|
typeof object1Value === "object" &&
|
||||||
|
typeof object2Value === "object" &&
|
||||||
|
object1Value !== null &&
|
||||||
|
object2Value !== null &&
|
||||||
|
this.isShallowEqual(object1Value, object2Value)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isShallowEqual(object1: any, object2: any) {
|
||||||
|
const keys1 = Object.keys(object1);
|
||||||
|
const keys2 = Object.keys(object1);
|
||||||
|
|
||||||
|
if (keys1.length !== keys2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys1) {
|
||||||
|
if (object1[key] !== object2[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
21
packages/deltas/src/common/interfaces.ts
Normal file
21
packages/deltas/src/common/interfaces.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Encapsulates a set of application-level `Delta`s.
|
||||||
|
*/
|
||||||
|
export interface DeltaContainer<T> {
|
||||||
|
/**
|
||||||
|
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
|
||||||
|
*/
|
||||||
|
inverse(): DeltaContainer<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the `Delta`s 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.
|
||||||
|
*/
|
||||||
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether all `Delta`s are empty.
|
||||||
|
*/
|
||||||
|
isEmpty(): boolean;
|
||||||
|
}
|
149
packages/deltas/src/common/utils.ts
Normal file
149
packages/deltas/src/common/utils.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { Random } from "roughjs/bin/math";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
ObservedAppState,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ElementUpdate,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform array into an object, use only when array order is irrelevant.
|
||||||
|
*/
|
||||||
|
export const arrayToObject = <T>(
|
||||||
|
array: readonly T[],
|
||||||
|
groupBy?: (value: T) => string | number,
|
||||||
|
) =>
|
||||||
|
array.reduce((acc, value) => {
|
||||||
|
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms array of elements with `id` property into into a Map grouped by `id`.
|
||||||
|
*/
|
||||||
|
export const elementsToMap = <T extends { id: string }>(
|
||||||
|
items: readonly T[],
|
||||||
|
) => {
|
||||||
|
return items.reduce((acc: Map<string, T>, element) => {
|
||||||
|
acc.set(element.id, element);
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
};
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
// hidden non-enumerable property for runtime checks
|
||||||
|
const hiddenObservedAppStateProp = "__observedAppState";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
value: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return observedAppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const assertNever = (value: never, message: string): never => {
|
||||||
|
throw new Error(`${message}: "${value}".`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
||||||
|
const nonDeletedGroupIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [, element] of elements) {
|
||||||
|
// defensive check
|
||||||
|
if (element.isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensive fallback
|
||||||
|
for (const groupId of element.groupIds ?? []) {
|
||||||
|
nonDeletedGroupIds.add(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonDeletedGroupIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const isTestEnv = () => import.meta.env.MODE === "test";
|
||||||
|
|
||||||
|
export const isDevEnv = () => import.meta.env.MODE === "development";
|
||||||
|
|
||||||
|
export const isServerEnv = () => import.meta.env.MODE === "server";
|
||||||
|
|
||||||
|
export const shouldThrow = () => isDevEnv() || isTestEnv() || isServerEnv();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
let random = new Random(Date.now());
|
||||||
|
let testIdBase = 0;
|
||||||
|
|
||||||
|
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
|
||||||
|
|
||||||
|
export const reseed = (seed: number) => {
|
||||||
|
random = new Random(seed);
|
||||||
|
testIdBase = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||||
|
element: TElement,
|
||||||
|
updates: ElementUpdate<TElement>,
|
||||||
|
/** pass `true` to always regenerate */
|
||||||
|
force = false,
|
||||||
|
): TElement => {
|
||||||
|
let didChange = false;
|
||||||
|
for (const key in updates) {
|
||||||
|
const value = (updates as any)[key];
|
||||||
|
if (typeof value !== "undefined") {
|
||||||
|
if (
|
||||||
|
(element as any)[key] === value &&
|
||||||
|
// if object, always update because its attrs could have changed
|
||||||
|
(typeof value !== "object" || value === null)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
didChange = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!didChange && !force) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
...updates,
|
||||||
|
updated: getUpdatedTimestamp(),
|
||||||
|
version: element.version + 1,
|
||||||
|
versionNonce: randomInteger(),
|
||||||
|
};
|
||||||
|
};
|
404
packages/deltas/src/containers/appstate.ts
Normal file
404
packages/deltas/src/containers/appstate.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
import { Delta } from "../common/delta";
|
||||||
|
import {
|
||||||
|
assertNever,
|
||||||
|
getNonDeletedGroupIds,
|
||||||
|
getObservedAppState,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
|
shouldThrow,
|
||||||
|
} from "../common/utils";
|
||||||
|
|
||||||
|
import type { DeltaContainer } from "../common/interfaces";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
ObservedAppState,
|
||||||
|
DTO,
|
||||||
|
SceneElementsMap,
|
||||||
|
ValueOf,
|
||||||
|
ObservedElementsAppState,
|
||||||
|
ObservedStandaloneAppState,
|
||||||
|
SubtypeOf,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
|
public static calculate<T extends ObservedAppState>(
|
||||||
|
prevAppState: T,
|
||||||
|
nextAppState: T,
|
||||||
|
): AppStateDelta {
|
||||||
|
const delta = Delta.calculate(
|
||||||
|
prevAppState,
|
||||||
|
nextAppState,
|
||||||
|
undefined,
|
||||||
|
AppStateDelta.postProcess,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||||
|
const { delta } = appStateDeltaDTO;
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return new AppStateDelta(Delta.create({}, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public inverse(): AppStateDelta {
|
||||||
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
|
return new AppStateDelta(inversedDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyTo(
|
||||||
|
appState: AppState,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
): [AppState, boolean] {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
selectedElementIds: removedSelectedElementIds = {},
|
||||||
|
selectedGroupIds: removedSelectedGroupIds = {},
|
||||||
|
} = this.delta.deleted;
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedElementIds: addedSelectedElementIds = {},
|
||||||
|
selectedGroupIds: addedSelectedGroupIds = {},
|
||||||
|
selectedLinearElementId,
|
||||||
|
editingLinearElementId,
|
||||||
|
...directlyApplicablePartial
|
||||||
|
} = this.delta.inserted;
|
||||||
|
|
||||||
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
appState.selectedElementIds,
|
||||||
|
addedSelectedElementIds,
|
||||||
|
removedSelectedElementIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
addedSelectedGroupIds,
|
||||||
|
removedSelectedGroupIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
// const selectedLinearElement =
|
||||||
|
// selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||||
|
// ? new LinearElementEditor(
|
||||||
|
// nextElements.get(
|
||||||
|
// selectedLinearElementId,
|
||||||
|
// ) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
// )
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
// const editingLinearElement =
|
||||||
|
// editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||||
|
// ? new LinearElementEditor(
|
||||||
|
// nextElements.get(
|
||||||
|
// editingLinearElementId,
|
||||||
|
// ) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
// )
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
const nextAppState = {
|
||||||
|
...appState,
|
||||||
|
...directlyApplicablePartial,
|
||||||
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
|
// selectedLinearElement:
|
||||||
|
// typeof selectedLinearElementId !== "undefined"
|
||||||
|
// ? selectedLinearElement // element was either inserted or deleted
|
||||||
|
// : appState.selectedLinearElement, // otherwise assign what we had before
|
||||||
|
// editingLinearElement:
|
||||||
|
// typeof editingLinearElementId !== "undefined"
|
||||||
|
// ? editingLinearElement // element was either inserted or deleted
|
||||||
|
// : appState.editingLinearElement, // otherwise assign what we had before
|
||||||
|
};
|
||||||
|
|
||||||
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
|
appState,
|
||||||
|
nextAppState,
|
||||||
|
nextElements,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [nextAppState, constainsVisibleChanges];
|
||||||
|
} catch (e) {
|
||||||
|
// shouldn't really happen, but just in case
|
||||||
|
console.error(`Couldn't apply appstate delta`, e);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [appState, false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty(): boolean {
|
||||||
|
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 (isDevEnv() || isTestEnv()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [deleted, inserted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates `nextAppState` be filtering out state related to deleted elements.
|
||||||
|
*
|
||||||
|
* @returns `true` if a visible change is found, `false` otherwise.
|
||||||
|
*/
|
||||||
|
private filterInvisibleChanges(
|
||||||
|
prevAppState: AppState,
|
||||||
|
nextAppState: AppState,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
): boolean {
|
||||||
|
// TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements
|
||||||
|
// which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates
|
||||||
|
const prevObservedAppState = getObservedAppState(prevAppState);
|
||||||
|
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
|
);
|
||||||
|
|
||||||
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
|
// no change in appstate was detected
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDifferenceFlag = {
|
||||||
|
value: containsStandaloneDifference,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (containsElementsDifference) {
|
||||||
|
// filter invisible changes on each iteration
|
||||||
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
|
|
||||||
|
if (
|
||||||
|
changedElementsProps.includes("editingGroupId") ||
|
||||||
|
changedElementsProps.includes("selectedGroupIds")
|
||||||
|
) {
|
||||||
|
// this one iterates through all the non deleted elements, so make sure it's not done twice
|
||||||
|
nonDeletedGroupIds = getNonDeletedGroupIds(nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check whether delta properties are related to the existing non-deleted elements
|
||||||
|
for (const key of changedElementsProps) {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedElementIds":
|
||||||
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
|
nextAppState[key],
|
||||||
|
nextElements,
|
||||||
|
visibleDifferenceFlag,
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "selectedGroupIds":
|
||||||
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
|
nextAppState[key],
|
||||||
|
nonDeletedGroupIds,
|
||||||
|
visibleDifferenceFlag,
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "croppingElementId": {
|
||||||
|
const croppingElementId = nextAppState[key];
|
||||||
|
const element =
|
||||||
|
croppingElementId && nextElements.get(croppingElementId);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
nextAppState[key] = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "editingGroupId":
|
||||||
|
const editingGroupId = nextAppState[key];
|
||||||
|
|
||||||
|
if (!editingGroupId) {
|
||||||
|
// previously there was an editingGroup (assuming visible), now there is none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else if (nonDeletedGroupIds.has(editingGroupId)) {
|
||||||
|
// previously there wasn't an editingGroup, now there is one which is visible
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
// there was assigned an editingGroup now, but it's related to deleted element
|
||||||
|
nextAppState[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
case "editingLinearElementId":
|
||||||
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
|
if (!linearElement) {
|
||||||
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
const element = nextElements.get(linearElement.elementId);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
// there was assigned a linear element now, but it's deleted
|
||||||
|
nextAppState[appStateKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
assertNever(key, `Unknown ObservedElementsAppState's key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleDifferenceFlag.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static convertToAppStateKey(
|
||||||
|
key: keyof Pick<
|
||||||
|
ObservedElementsAppState,
|
||||||
|
"selectedLinearElementId" | "editingLinearElementId"
|
||||||
|
>,
|
||||||
|
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
return "selectedLinearElement";
|
||||||
|
case "editingLinearElementId":
|
||||||
|
return "editingLinearElement";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static filterSelectedElements(
|
||||||
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
visibleDifferenceFlag: { value: boolean },
|
||||||
|
) {
|
||||||
|
const ids = Object.keys(selectedElementIds);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
// previously there were ids (assuming related to visible elements), now there are none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
return selectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelectedElementIds = { ...selectedElementIds };
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const element = elements.get(id);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
// there is a selected element id related to a visible element
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
delete nextSelectedElementIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSelectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static filterSelectedGroups(
|
||||||
|
selectedGroupIds: AppState["selectedGroupIds"],
|
||||||
|
nonDeletedGroupIds: Set<string>,
|
||||||
|
visibleDifferenceFlag: { value: boolean },
|
||||||
|
) {
|
||||||
|
const ids = Object.keys(selectedGroupIds);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
// previously there were ids (assuming related to visible groups), now there are none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
return selectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelectedGroupIds = { ...selectedGroupIds };
|
||||||
|
|
||||||
|
for (const id of Object.keys(nextSelectedGroupIds)) {
|
||||||
|
if (nonDeletedGroupIds.has(id)) {
|
||||||
|
// there is a selected group id related to a visible group
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
delete nextSelectedGroupIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSelectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripElementsProps(
|
||||||
|
delta: Partial<ObservedAppState>,
|
||||||
|
): Partial<ObservedStandaloneAppState> {
|
||||||
|
// WARN: Do not remove the type-casts as they here to ensure proper type checks
|
||||||
|
const {
|
||||||
|
editingGroupId,
|
||||||
|
selectedGroupIds,
|
||||||
|
selectedElementIds,
|
||||||
|
editingLinearElementId,
|
||||||
|
selectedLinearElementId,
|
||||||
|
croppingElementId,
|
||||||
|
...standaloneProps
|
||||||
|
} = delta as ObservedAppState;
|
||||||
|
|
||||||
|
return standaloneProps as SubtypeOf<
|
||||||
|
typeof standaloneProps,
|
||||||
|
ObservedStandaloneAppState
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripStandaloneProps(
|
||||||
|
delta: Partial<ObservedAppState>,
|
||||||
|
): Partial<ObservedElementsAppState> {
|
||||||
|
// WARN: Do not remove the type-casts as they here to ensure proper type checks
|
||||||
|
const { name, viewBackgroundColor, ...elementsProps } =
|
||||||
|
delta as ObservedAppState;
|
||||||
|
|
||||||
|
return elementsProps as SubtypeOf<
|
||||||
|
typeof elementsProps,
|
||||||
|
ObservedElementsAppState
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
825
packages/deltas/src/containers/elements.ts
Normal file
825
packages/deltas/src/containers/elements.ts
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
import { Delta } from "../common/delta";
|
||||||
|
import { newElementWith, shouldThrow } from "../common/utils";
|
||||||
|
|
||||||
|
import type { DeltaContainer } from "../common/interfaces";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ElementUpdate,
|
||||||
|
Ordered,
|
||||||
|
SceneElementsMap,
|
||||||
|
DTO,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
|
||||||
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
||||||
|
ElementUpdate<Ordered<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elements delta is a low level primitive to encapsulate property changes 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> {
|
||||||
|
private constructor(
|
||||||
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
// CFDO: don't forget to re-enable
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { shouldRedistribute } = options;
|
||||||
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
|
if (shouldRedistribute) {
|
||||||
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
const deltas = [
|
||||||
|
...Object.entries(added),
|
||||||
|
...Object.entries(removed),
|
||||||
|
...Object.entries(updated),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [id, delta] of deltas) {
|
||||||
|
if (this.satisfiesAddition(delta)) {
|
||||||
|
nextAdded[id] = delta;
|
||||||
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
|
nextRemoved[id] = delta;
|
||||||
|
} else {
|
||||||
|
nextUpdated[id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
|
} else {
|
||||||
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
|
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static satisfiesAddition = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) =>
|
||||||
|
// dissallowing added as "deleted", which could cause issues when resolving conflicts
|
||||||
|
deleted.isDeleted === true && !inserted.isDeleted;
|
||||||
|
|
||||||
|
private static satisfiesRemoval = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) =>
|
||||||
|
!deleted.isDeleted && inserted.isDeleted === true;
|
||||||
|
|
||||||
|
private static satisfiesUpdate = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
|
private static validate(
|
||||||
|
elementsDelta: ElementsDelta,
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||||
|
) {
|
||||||
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
|
if (!satifies(delta)) {
|
||||||
|
console.error(
|
||||||
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the `Delta`s between the previous and next set of elements.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
|
prevElements: Map<string, T>,
|
||||||
|
nextElements: Map<string, T>,
|
||||||
|
): ElementsDelta {
|
||||||
|
if (prevElements === nextElements) {
|
||||||
|
return ElementsDelta.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const updated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
||||||
|
for (const prevElement of prevElements.values()) {
|
||||||
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
|
if (!nextElement) {
|
||||||
|
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||||
|
const inserted = { isDeleted: true } as ElementPartial;
|
||||||
|
|
||||||
|
const delta = Delta.create(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
removed[prevElement.id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextElement of nextElements.values()) {
|
||||||
|
const prevElement = prevElements.get(nextElement.id);
|
||||||
|
|
||||||
|
if (!prevElement) {
|
||||||
|
const deleted = { isDeleted: true } as ElementPartial;
|
||||||
|
const inserted = {
|
||||||
|
...nextElement,
|
||||||
|
isDeleted: false,
|
||||||
|
} as ElementPartial;
|
||||||
|
|
||||||
|
const delta = Delta.create(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevElement.versionNonce !== nextElement.versionNonce) {
|
||||||
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
|
prevElement,
|
||||||
|
nextElement,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
ElementsDelta.postProcess,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
// making sure we don't get here some non-boolean values (i.e. undefined, null, etc.)
|
||||||
|
typeof prevElement.isDeleted === "boolean" &&
|
||||||
|
typeof nextElement.isDeleted === "boolean" &&
|
||||||
|
prevElement.isDeleted !== nextElement.isDeleted
|
||||||
|
) {
|
||||||
|
// notice that other props could have been updated as well
|
||||||
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
removed[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// making sure there are at least some changes
|
||||||
|
if (!Delta.isEmpty(delta)) {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return ElementsDelta.create({}, {}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public inverse(): ElementsDelta {
|
||||||
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inversedDeltas;
|
||||||
|
};
|
||||||
|
|
||||||
|
const added = inverseInternal(this.added);
|
||||||
|
const removed = inverseInternal(this.removed);
|
||||||
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
|
// notice we inverse removed with added not to break the invariants
|
||||||
|
// notice we force generate a new id
|
||||||
|
return ElementsDelta.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update delta/s based on the existing elements.
|
||||||
|
*
|
||||||
|
* @param elements current elements
|
||||||
|
* @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 {
|
||||||
|
const modifier =
|
||||||
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
|
||||||
|
// do not update following props:
|
||||||
|
// - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
|
||||||
|
switch (key) {
|
||||||
|
case "boundElements":
|
||||||
|
latestPartial[key] = partial[key];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
latestPartial[key] = element[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestPartial;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLatestChangesInternal = (
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
|
) => {
|
||||||
|
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
|
if (existingElement) {
|
||||||
|
const modifiedDelta = Delta.create(
|
||||||
|
delta.deleted,
|
||||||
|
delta.inserted,
|
||||||
|
modifier(existingElement),
|
||||||
|
modifierOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
|
} else {
|
||||||
|
modifiedDeltas[id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiedDeltas;
|
||||||
|
};
|
||||||
|
|
||||||
|
const added = applyLatestChangesInternal(this.added);
|
||||||
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
|
const updated = applyLatestChangesInternal(this.updated);
|
||||||
|
|
||||||
|
return ElementsDelta.create(added, removed, updated, {
|
||||||
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: does it make sense having a separate snapshot?
|
||||||
|
public applyTo(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
): [SceneElementsMap, boolean] {
|
||||||
|
const nextElements = new Map(elements) as SceneElementsMap;
|
||||||
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
containsVisibleDifference: false,
|
||||||
|
containsZindexDifference: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
|
try {
|
||||||
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
|
nextElements,
|
||||||
|
elementsSnapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const addedElements = applyDeltas("added", this.added);
|
||||||
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
|
// CFDO I: don't forget to fix this part
|
||||||
|
// const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
|
changedElements = new Map([
|
||||||
|
...addedElements,
|
||||||
|
...removedElements,
|
||||||
|
...updatedElements,
|
||||||
|
// ...affectedElements,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true`
|
||||||
|
// even though there is obviously no visible change, returning `false` could be dangerous, as i.e.:
|
||||||
|
// in the worst case, it could lead into iterating through the whole stack with no possibility to redo
|
||||||
|
// instead, the worst case when returning `true` is an empty undo / redo
|
||||||
|
return [elements, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// CFDO I: don't forget to fix this part
|
||||||
|
// // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||||
|
// ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||||
|
// // the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
|
// // (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
|
// nextElements = ElementsDelta.reorderElements(
|
||||||
|
// nextElements,
|
||||||
|
// changedElements,
|
||||||
|
// flags,
|
||||||
|
// );
|
||||||
|
// // Need ordered nextElements to avoid z-index binding issues
|
||||||
|
// ElementsDelta.redrawBoundArrows(nextElements, changedElements);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Couldn't mutate elements after applying elements change`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [nextElements, flags.containsVisibleDifference];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createApplier =
|
||||||
|
(
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
|
) => {
|
||||||
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
|
nextElements,
|
||||||
|
snapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
|
nextElements.set(newElement.id, newElement);
|
||||||
|
acc.set(newElement.id, newElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, new Map<string, OrderedExcalidrawElement>());
|
||||||
|
};
|
||||||
|
|
||||||
|
private static createGetter =
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
(id: string, partial: ElementPartial) => {
|
||||||
|
let element = elements.get(id);
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
// always fallback to the local snapshot, in cases when we cannot find the element in the elements array
|
||||||
|
element = snapshot.get(id);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
||||||
|
flags.containsZindexDifference = true;
|
||||||
|
|
||||||
|
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||||
|
if (
|
||||||
|
partial.isDeleted === false ||
|
||||||
|
(partial.isDeleted !== true && element.isDeleted === false)
|
||||||
|
) {
|
||||||
|
flags.containsVisibleDifference = true;
|
||||||
|
}
|
||||||
|
} else if (type === "added") {
|
||||||
|
// for additions the element does not have to exist (i.e. remote update)
|
||||||
|
// CFDO II: the version itself might be different!
|
||||||
|
element = newElementWith(
|
||||||
|
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||||
|
{
|
||||||
|
...partial,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
private static applyDelta(
|
||||||
|
element: OrderedExcalidrawElement,
|
||||||
|
delta: Delta<ElementPartial>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
} = {
|
||||||
|
// by default we don't care about about the flags
|
||||||
|
containsVisibleDifference: true,
|
||||||
|
containsZindexDifference: true,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
|
||||||
|
|
||||||
|
if (
|
||||||
|
delta.deleted.boundElements?.length ||
|
||||||
|
delta.inserted.boundElements?.length
|
||||||
|
) {
|
||||||
|
const mergedBoundElements = Delta.mergeArrays(
|
||||||
|
element.boundElements,
|
||||||
|
delta.inserted.boundElements,
|
||||||
|
delta.deleted.boundElements,
|
||||||
|
(x) => x.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(directlyApplicablePartial, {
|
||||||
|
boundElements: mergedBoundElements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: this looks wrong
|
||||||
|
if (element.type === "image") {
|
||||||
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
|
// when undoing/redoing unrelated change
|
||||||
|
if (_delta.deleted.crop || _delta.inserted.crop) {
|
||||||
|
Object.assign(directlyApplicablePartial, {
|
||||||
|
// apply change verbatim
|
||||||
|
crop: _delta.inserted.crop ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags.containsVisibleDifference) {
|
||||||
|
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||||
|
const { index, ...rest } = directlyApplicablePartial;
|
||||||
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags.containsZindexDifference) {
|
||||||
|
flags.containsZindexDifference =
|
||||||
|
delta.deleted.index !== delta.inserted.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, directlyApplicablePartial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for visible changes regardless of whether they were removed, added or updated.
|
||||||
|
*/
|
||||||
|
private static checkForVisibleDifference(
|
||||||
|
element: OrderedExcalidrawElement,
|
||||||
|
partial: ElementPartial,
|
||||||
|
) {
|
||||||
|
if (element.isDeleted && partial.isDeleted !== false) {
|
||||||
|
// when it's deleted and partial is not false, it cannot end up with a visible change
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.isDeleted && partial.isDeleted === false) {
|
||||||
|
// when we add an element, it results in a visible change
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.isDeleted === false && partial.isDeleted) {
|
||||||
|
// when we remove an element, it results in a visible change
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for any difference on a visible element
|
||||||
|
return Delta.isRightDifferent(element, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Resolves conflicts for all previously added, removed and updated elements.
|
||||||
|
// * Updates the previous deltas with all the changes after conflict resolution.
|
||||||
|
// *
|
||||||
|
// * // CFDO: revisit since arrow seem often redrawn incorrectly
|
||||||
|
// *
|
||||||
|
// * @returns all elements affected by the conflict resolution
|
||||||
|
// */
|
||||||
|
// private resolveConflicts(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// ) {
|
||||||
|
// const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
|
// const updater = (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => {
|
||||||
|
// const nextElement = nextElements.get(element.id); // only ever modify next element!
|
||||||
|
// if (!nextElement) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
|
// if (prevElements.get(element.id) === nextElement) {
|
||||||
|
// // create the new element instance in case we didn't modify the element yet
|
||||||
|
// // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
|
// affectedElement = newElementWith(
|
||||||
|
// nextElement,
|
||||||
|
// updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// affectedElement = mutateElement(
|
||||||
|
// nextElement,
|
||||||
|
// updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
|
// nextElements.set(affectedElement.id, affectedElement);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // 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);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 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);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 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),
|
||||||
|
// ),
|
||||||
|
// )) {
|
||||||
|
// const updatedElement = nextElements.get(id);
|
||||||
|
// if (!updatedElement || updatedElement.isDeleted) {
|
||||||
|
// // skip fixing bindings for updates on deleted elements
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // filter only previous elements, which were now affected
|
||||||
|
// const prevAffectedElements = new Map(
|
||||||
|
// Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // 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(
|
||||||
|
// prevAffectedElements,
|
||||||
|
// nextAffectedElements,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(added)) {
|
||||||
|
// this.added[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(removed)) {
|
||||||
|
// this.removed[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(updated)) {
|
||||||
|
// this.updated[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return nextAffectedElements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Non deleted affected elements of removed elements (before and after applying delta),
|
||||||
|
// * should be unbound ~ bindings should not point from non deleted into the deleted element/s.
|
||||||
|
// */
|
||||||
|
// private static unbindAffected(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// id: string,
|
||||||
|
// updater: (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => void,
|
||||||
|
// ) {
|
||||||
|
// // the instance could have been updated, so make sure we are passing the latest element to each function below
|
||||||
|
// const prevElement = () => prevElements.get(id); // element before removal
|
||||||
|
// const nextElement = () => nextElements.get(id); // element after removal
|
||||||
|
|
||||||
|
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BoundElement.unbindAffected(nextElements, nextElement(), updater);
|
||||||
|
|
||||||
|
// BindableElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BindableElement.unbindAffected(nextElements, nextElement(), updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Non deleted affected elements of added or updated element/s (before and after applying delta),
|
||||||
|
// * should be rebound (if possible) with the current element ~ bindings should be bidirectional.
|
||||||
|
// */
|
||||||
|
// private static rebindAffected(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// id: string,
|
||||||
|
// updater: (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => void,
|
||||||
|
// ) {
|
||||||
|
// // the instance could have been updated, so make sure we are passing the latest element to each function below
|
||||||
|
// const prevElement = () => prevElements.get(id); // element before addition / update
|
||||||
|
// const nextElement = () => nextElements.get(id); // element after addition / update
|
||||||
|
|
||||||
|
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BoundElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
|
|
||||||
|
// BindableElement.unbindAffected(
|
||||||
|
// nextElements,
|
||||||
|
// prevElement(),
|
||||||
|
// (element, updates) => {
|
||||||
|
// // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal)
|
||||||
|
// // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition
|
||||||
|
// if (isTextElement(element)) {
|
||||||
|
// updater(element, updates);
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static redrawTextBoundingBoxes(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// ) {
|
||||||
|
// const boxesToRedraw = new Map<
|
||||||
|
// string,
|
||||||
|
// { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
|
||||||
|
// >();
|
||||||
|
|
||||||
|
// for (const element of changed.values()) {
|
||||||
|
// if (isBoundToContainer(element)) {
|
||||||
|
// const { containerId } = element as ExcalidrawTextElement;
|
||||||
|
// const container = containerId ? elements.get(containerId) : undefined;
|
||||||
|
|
||||||
|
// if (container) {
|
||||||
|
// boxesToRedraw.set(container.id, {
|
||||||
|
// container,
|
||||||
|
// boundText: element as ExcalidrawTextElement,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (hasBoundTextElement(element)) {
|
||||||
|
// const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
// const boundText = boundTextElementId
|
||||||
|
// ? elements.get(boundTextElementId)
|
||||||
|
// : undefined;
|
||||||
|
|
||||||
|
// if (boundText) {
|
||||||
|
// boxesToRedraw.set(element.id, {
|
||||||
|
// container: element,
|
||||||
|
// boundText: boundText as ExcalidrawTextElement,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const { container, boundText } of boxesToRedraw.values()) {
|
||||||
|
// if (container.isDeleted || boundText.isDeleted) {
|
||||||
|
// // skip redraw if one of them is deleted, as it would not result in a meaningful redraw
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// redrawTextBoundingBox(boundText, container, elements, false);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static redrawBoundArrows(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// ) {
|
||||||
|
// for (const element of changed.values()) {
|
||||||
|
// if (!element.isDeleted && isBindableElement(element)) {
|
||||||
|
// updateBoundElements(element, elements, {
|
||||||
|
// changedElements: changed,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static reorderElements(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// flags: {
|
||||||
|
// containsVisibleDifference: boolean;
|
||||||
|
// containsZindexDifference: boolean;
|
||||||
|
// },
|
||||||
|
// ) {
|
||||||
|
// if (!flags.containsZindexDifference) {
|
||||||
|
// return elements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const unordered = Array.from(elements.values());
|
||||||
|
// const ordered = orderByFractionalIndex([...unordered]);
|
||||||
|
// const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||||
|
// (acc, arrayIndex) => {
|
||||||
|
// const candidate = unordered[Number(arrayIndex)];
|
||||||
|
// if (candidate && changed.has(candidate.id)) {
|
||||||
|
// acc.set(candidate.id, candidate);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return acc;
|
||||||
|
// },
|
||||||
|
// new Map(),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!flags.containsVisibleDifference && moved.size) {
|
||||||
|
// // we found a difference in order!
|
||||||
|
// flags.containsVisibleDifference = true;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // synchronize all elements that were actually moved
|
||||||
|
// // could fallback to synchronizing all invalid indices
|
||||||
|
// return elementsToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
deleted: ElementPartial,
|
||||||
|
inserted: ElementPartial,
|
||||||
|
): [ElementPartial, ElementPartial] {
|
||||||
|
try {
|
||||||
|
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.`);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [deleted, inserted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripIrrelevantProps(
|
||||||
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
|
): ElementPartial {
|
||||||
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
|
|
||||||
|
return strippedPartial;
|
||||||
|
}
|
||||||
|
}
|
26
packages/deltas/src/excalidraw-types.d.ts
vendored
Normal file
26
packages/deltas/src/excalidraw-types.d.ts
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export type {
|
||||||
|
AppState,
|
||||||
|
ObservedElementsAppState,
|
||||||
|
ObservedStandaloneAppState,
|
||||||
|
ObservedAppState,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
|
export type {
|
||||||
|
DTO,
|
||||||
|
SubtypeOf,
|
||||||
|
ValueOf,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/utility-types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
Ordered,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
ElementsMap,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||||
|
export type { ElementUpdate } from "@excalidraw/excalidraw/dist/excalidraw/element/mutateElement";
|
||||||
|
export type {
|
||||||
|
BindableProp,
|
||||||
|
BindingProp,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/binding";
|
5
packages/deltas/src/index.ts
Normal file
5
packages/deltas/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type { DeltaContainer } from "./common/interfaces";
|
||||||
|
|
||||||
|
export { Delta } from "./common/delta";
|
||||||
|
export { ElementsDelta } from "./containers/elements";
|
||||||
|
export { AppStateDelta } from "./containers/appstate";
|
19
packages/deltas/tsconfig.json
Normal file
19
packages/deltas/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist/types",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/tests/*",
|
||||||
|
"types",
|
||||||
|
"dist",
|
||||||
|
],
|
||||||
|
}
|
@ -13,7 +13,7 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/element/src/*.d.ts"
|
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rimraf types && tsc",
|
"gen:types": "rm -rf types && tsc",
|
||||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,21 +6,25 @@ import {
|
|||||||
toBrandedType,
|
toBrandedType,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
toArray,
|
isReadonlyArray,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isNonDeletedElement } from "@excalidraw/element";
|
import { isNonDeletedElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
import { getElementsInGroup } from "@excalidraw/element";
|
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
orderByFractionalIndex,
|
||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { getSelectedElements } from "@excalidraw/element";
|
import { getSelectedElements } from "@excalidraw/element/selection";
|
||||||
|
|
||||||
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
import {
|
||||||
|
mutateElement,
|
||||||
|
type ElementUpdate,
|
||||||
|
} from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -105,7 +109,7 @@ const hashSelectionOpts = (
|
|||||||
// in our codebase
|
// in our codebase
|
||||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||||
|
|
||||||
export class Scene {
|
class Scene {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// instance methods/props
|
// instance methods/props
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -264,13 +268,19 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
// ts doesn't like `Array.isArray` of `instanceof Map`
|
||||||
const _nextElements = toArray(nextElements);
|
if (!isReadonlyArray(nextElements)) {
|
||||||
|
// need to order by fractional indices to get the correct order
|
||||||
|
nextElements = orderByFractionalIndex(
|
||||||
|
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(_nextElements);
|
validateIndicesThrottled(nextElements);
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
this.elements.forEach((element) => {
|
this.elements.forEach((element) => {
|
||||||
if (isFrameLikeElement(element)) {
|
if (isFrameLikeElement(element)) {
|
||||||
@ -454,3 +464,5 @@ export class Scene {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Scene;
|
||||||
|
@ -2,7 +2,7 @@ import { updateBoundElements } from "./binding";
|
|||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
@ -33,7 +33,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
@ -66,7 +66,7 @@ import {
|
|||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@ -81,10 +81,10 @@ import type {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPoint,
|
FixedPoint,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
PointsPositionUpdates,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
@ -276,6 +276,15 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
zoom,
|
zoom,
|
||||||
)
|
)
|
||||||
: null // If binding is disabled and start is dragged, break all binds
|
: null // If binding is disabled and start is dragged, break all binds
|
||||||
|
: !isElbowArrow(selectedElement)
|
||||||
|
? // We have to update the focus and gap of the binding, so let's rebind
|
||||||
|
getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
elements,
|
||||||
|
zoom,
|
||||||
|
)
|
||||||
: "keep";
|
: "keep";
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
@ -287,6 +296,15 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
zoom,
|
zoom,
|
||||||
)
|
)
|
||||||
: null // If binding is disabled and end is dragged, break all binds
|
: null // If binding is disabled and end is dragged, break all binds
|
||||||
|
: !isElbowArrow(selectedElement)
|
||||||
|
? // We have to update the focus and gap of the binding, so let's rebind
|
||||||
|
getElligibleElementForBindingElement(
|
||||||
|
selectedElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
elements,
|
||||||
|
zoom,
|
||||||
|
)
|
||||||
: "keep";
|
: "keep";
|
||||||
|
|
||||||
return [start, end];
|
return [start, end];
|
||||||
@ -710,32 +728,29 @@ const calculateFocusAndGap = (
|
|||||||
|
|
||||||
// Supports translating, rotating and scaling `changedElement` with bound
|
// Supports translating, rotating and scaling `changedElement` with bound
|
||||||
// linear elements.
|
// linear elements.
|
||||||
|
// Because scaling involves moving the focus points as well, it is
|
||||||
|
// done before the `changedElement` is updated, and the `newSize` is passed
|
||||||
|
// in explicitly.
|
||||||
export const updateBoundElements = (
|
export const updateBoundElements = (
|
||||||
changedElement: NonDeletedExcalidrawElement,
|
changedElement: NonDeletedExcalidrawElement,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
options?: {
|
options?: {
|
||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
newSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
changedElements?: Map<string, ExcalidrawElement>;
|
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (!isBindableElement(changedElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
|
|
||||||
let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
|
if (!isBindableElement(changedElement)) {
|
||||||
if (options?.changedElements) {
|
return;
|
||||||
elementsMap = new Map(elementsMap) as typeof elementsMap;
|
|
||||||
options.changedElements.forEach((element) => {
|
|
||||||
elementsMap.set(element.id, element);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
boundElementsVisitor(elementsMap, changedElement, (element) => {
|
boundElementsVisitor(elementsMap, changedElement, (element) => {
|
||||||
if (!isLinearElement(element) || element.isDeleted) {
|
if (!isLinearElement(element) || element.isDeleted) {
|
||||||
return;
|
return;
|
||||||
@ -802,22 +817,28 @@ export const updateBoundElements = (
|
|||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (point) {
|
if (point) {
|
||||||
return [
|
return {
|
||||||
|
index:
|
||||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||||
{ point },
|
point,
|
||||||
] as MapEntry<PointsPositionUpdates>;
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
).filter(
|
).filter(
|
||||||
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
|
(
|
||||||
|
update,
|
||||||
|
): update is NonNullable<{
|
||||||
|
index: number;
|
||||||
|
point: LocalPoint;
|
||||||
|
isDragging?: boolean;
|
||||||
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, scene, new Map(updates), {
|
LinearElementEditor.movePoints(element, scene, updates, {
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
? { startBinding: bindings.startBinding }
|
? { startBinding: bindings.startBinding }
|
||||||
: {}),
|
: {}),
|
||||||
@ -833,25 +854,6 @@ export const updateBoundElements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateBindings = (
|
|
||||||
latestElement: ExcalidrawElement,
|
|
||||||
scene: Scene,
|
|
||||||
options?: {
|
|
||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
|
||||||
newSize?: { width: number; height: number };
|
|
||||||
zoom?: AppState["zoom"];
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
if (isLinearElement(latestElement)) {
|
|
||||||
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
|
|
||||||
} else {
|
|
||||||
updateBoundElements(latestElement, scene, {
|
|
||||||
...options,
|
|
||||||
changedElements: new Map([[latestElement.id, latestElement]]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doesNeedUpdate = (
|
const doesNeedUpdate = (
|
||||||
boundElement: NonDeleted<ExcalidrawLinearElement>,
|
boundElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
changedElement: ExcalidrawBindableElement,
|
changedElement: ExcalidrawBindableElement,
|
||||||
@ -1166,48 +1168,6 @@ export const snapToMid = (
|
|||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
} else if (element.type === "diamond") {
|
|
||||||
const distance = FIXED_BINDING_DISTANCE - 1;
|
|
||||||
const topLeft = pointFrom<GlobalPoint>(
|
|
||||||
x + width / 4 - distance,
|
|
||||||
y + height / 4 - distance,
|
|
||||||
);
|
|
||||||
const topRight = pointFrom<GlobalPoint>(
|
|
||||||
x + (3 * width) / 4 + distance,
|
|
||||||
y + height / 4 - distance,
|
|
||||||
);
|
|
||||||
const bottomLeft = pointFrom<GlobalPoint>(
|
|
||||||
x + width / 4 - distance,
|
|
||||||
y + (3 * height) / 4 + distance,
|
|
||||||
);
|
|
||||||
const bottomRight = pointFrom<GlobalPoint>(
|
|
||||||
x + (3 * width) / 4 + distance,
|
|
||||||
y + (3 * height) / 4 + distance,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pointDistance(topLeft, nonRotated) <
|
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
|
||||||
) {
|
|
||||||
return pointRotateRads(topLeft, center, angle);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pointDistance(topRight, nonRotated) <
|
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
|
||||||
) {
|
|
||||||
return pointRotateRads(topRight, center, angle);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pointDistance(bottomLeft, nonRotated) <
|
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
|
||||||
) {
|
|
||||||
return pointRotateRads(bottomLeft, center, angle);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pointDistance(bottomRight, nonRotated) <
|
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
|
||||||
) {
|
|
||||||
return pointRotateRads(bottomRight, center, angle);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
rescalePoints,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
invariant,
|
invariant,
|
||||||
rescalePoints,
|
|
||||||
sizeOf,
|
sizeOf,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
pointDistance,
|
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./ShapeCache";
|
||||||
|
import { generateRoughOptions } from "./Shape";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -52,20 +52,20 @@ import {
|
|||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
Arrowhead,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
NonDeleted,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawRectanguloidElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ElementsMapOrArray,
|
||||||
|
} from "./types";
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
import type {
|
|
||||||
Arrowhead,
|
|
||||||
ElementsMap,
|
|
||||||
ElementsMapOrArray,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawEllipseElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
ExcalidrawTextElementWithContainer,
|
|
||||||
NonDeleted,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -26,7 +26,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
@ -33,8 +33,6 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
|||||||
const RE_GH_GIST_EMBED =
|
const RE_GH_GIST_EMBED =
|
||||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||||
|
|
||||||
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
|
||||||
|
|
||||||
// not anchored to start to allow <blockquote> twitter embeds
|
// not anchored to start to allow <blockquote> twitter embeds
|
||||||
const RE_TWITTER =
|
const RE_TWITTER =
|
||||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||||
@ -71,7 +69,6 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"val.town",
|
"val.town",
|
||||||
"giphy.com",
|
"giphy.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
"forms.microsoft.com",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOW_SAME_ORIGIN = new Set([
|
const ALLOW_SAME_ORIGIN = new Set([
|
||||||
@ -85,7 +82,6 @@ const ALLOW_SAME_ORIGIN = new Set([
|
|||||||
"*.simplepdf.eu",
|
"*.simplepdf.eu",
|
||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"reddit.com",
|
"reddit.com",
|
||||||
"forms.microsoft.com",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const createSrcDoc = (body: string) => {
|
export const createSrcDoc = (body: string) => {
|
||||||
@ -210,10 +206,6 @@ export const getEmbedLink = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
|
|
||||||
link += link.includes("?") ? "&embed=true" : "?embed=true";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RE_TWITTER.test(link)) {
|
if (RE_TWITTER.test(link)) {
|
||||||
const postId = link.match(RE_TWITTER)![1];
|
const postId = link.match(RE_TWITTER)![1];
|
||||||
// the embed srcdoc still supports twitter.com domain only.
|
// the embed srcdoc still supports twitter.com domain only.
|
||||||
|
@ -39,7 +39,7 @@ import {
|
|||||||
type OrderedExcalidrawElement,
|
type OrderedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@ -462,18 +462,12 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(bindingArrow, scene, [
|
||||||
bindingArrow,
|
|
||||||
scene,
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
1,
|
|
||||||
{
|
{
|
||||||
|
index: 1,
|
||||||
point: bindingArrow.points[1],
|
point: bindingArrow.points[1],
|
||||||
},
|
},
|
||||||
],
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
const update = updateElbowArrowPoints(
|
||||||
bindingArrow,
|
bindingArrow,
|
||||||
|
@ -905,16 +905,13 @@ export const shouldApplyFrameClip = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_FRAME_NAME = "Frame";
|
|
||||||
const DEFAULT_AI_FRAME_NAME = "AI Frame";
|
|
||||||
|
|
||||||
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
|
|
||||||
// TODO name frames "AI" only if specific to AI frames
|
|
||||||
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||||
return element.name === null ? getDefaultFrameName(element) : element.name;
|
// TODO name frames "AI" only if specific to AI frames
|
||||||
|
return element.name === null
|
||||||
|
? isFrameElement(element)
|
||||||
|
? "Frame"
|
||||||
|
: "AI Frame"
|
||||||
|
: element.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getElementsOverlappingFrame = (
|
export const getElementsOverlappingFrame = (
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { toIterable } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
import { isLinearElementType } from "./typeChecks";
|
||||||
|
|
||||||
@ -7,7 +5,6 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
ElementsMapOrArray,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,10 +16,12 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
/**
|
/**
|
||||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||||
*/
|
*/
|
||||||
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
export const hashElementsVersion = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): number => {
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (const element of toIterable(elements)) {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
hash = (hash << 5) + hash + element.versionNonce;
|
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||||
}
|
}
|
||||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||||
};
|
};
|
||||||
@ -72,47 +71,3 @@ export const clearElementsForExport = (
|
|||||||
export const clearElementsForLocalStorage = (
|
export const clearElementsForLocalStorage = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => _clearElements(elements);
|
) => _clearElements(elements);
|
||||||
|
|
||||||
export * from "./align";
|
|
||||||
export * from "./binding";
|
|
||||||
export * from "./bounds";
|
|
||||||
export * from "./collision";
|
|
||||||
export * from "./comparisons";
|
|
||||||
export * from "./containerCache";
|
|
||||||
export * from "./cropElement";
|
|
||||||
export * from "./delta";
|
|
||||||
export * from "./distance";
|
|
||||||
export * from "./distribute";
|
|
||||||
export * from "./dragElements";
|
|
||||||
export * from "./duplicate";
|
|
||||||
export * from "./elbowArrow";
|
|
||||||
export * from "./elementLink";
|
|
||||||
export * from "./embeddable";
|
|
||||||
export * from "./flowchart";
|
|
||||||
export * from "./fractionalIndex";
|
|
||||||
export * from "./frame";
|
|
||||||
export * from "./groups";
|
|
||||||
export * from "./heading";
|
|
||||||
export * from "./image";
|
|
||||||
export * from "./linearElementEditor";
|
|
||||||
export * from "./mutateElement";
|
|
||||||
export * from "./newElement";
|
|
||||||
export * from "./renderElement";
|
|
||||||
export * from "./resizeElements";
|
|
||||||
export * from "./resizeTest";
|
|
||||||
export * from "./Scene";
|
|
||||||
export * from "./selection";
|
|
||||||
export * from "./Shape";
|
|
||||||
export * from "./ShapeCache";
|
|
||||||
export * from "./shapes";
|
|
||||||
export * from "./showSelectedShapeActions";
|
|
||||||
export * from "./sizeHelpers";
|
|
||||||
export * from "./sortElements";
|
|
||||||
export * from "./store";
|
|
||||||
export * from "./textElement";
|
|
||||||
export * from "./textMeasurements";
|
|
||||||
export * from "./textWrapping";
|
|
||||||
export * from "./transformHandles";
|
|
||||||
export * from "./typeChecks";
|
|
||||||
export * from "./utils";
|
|
||||||
export * from "./zindex";
|
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/element";
|
import type { Store } from "@excalidraw/excalidraw/store";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -67,7 +67,7 @@ import {
|
|||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
@ -82,9 +82,13 @@ import type {
|
|||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
PointsPositionUpdates,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
const editorMidPointsCache: {
|
||||||
|
version: number | null;
|
||||||
|
points: (GlobalPoint | null)[];
|
||||||
|
zoom: number | null;
|
||||||
|
} = { version: null, points: [], zoom: null };
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -302,22 +306,16 @@ export class LinearElementEditor {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, scene, [
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
selectedIndex,
|
|
||||||
{
|
{
|
||||||
|
index: selectedIndex,
|
||||||
point: pointFrom(
|
point: pointFrom(
|
||||||
width + referencePoint[0],
|
width + referencePoint[0],
|
||||||
height + referencePoint[1],
|
height + referencePoint[1],
|
||||||
),
|
),
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
},
|
},
|
||||||
],
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@ -333,7 +331,6 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
new Map(
|
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition: LocalPoint =
|
const newPointPosition: LocalPoint =
|
||||||
pointIndex === lastClickedPoint
|
pointIndex === lastClickedPoint
|
||||||
@ -342,23 +339,18 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD]
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
? null
|
|
||||||
: app.getEffectiveGridSize(),
|
|
||||||
)
|
)
|
||||||
: pointFrom(
|
: pointFrom(
|
||||||
element.points[pointIndex][0] + deltaX,
|
element.points[pointIndex][0] + deltaX,
|
||||||
element.points[pointIndex][1] + deltaY,
|
element.points[pointIndex][1] + deltaY,
|
||||||
);
|
);
|
||||||
return [
|
return {
|
||||||
pointIndex,
|
index: pointIndex,
|
||||||
{
|
|
||||||
point: newPointPosition,
|
point: newPointPosition,
|
||||||
isDragging: pointIndex === lastClickedPoint,
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
},
|
};
|
||||||
];
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,21 +451,15 @@ export class LinearElementEditor {
|
|||||||
selectedPoint === element.points.length - 1
|
selectedPoint === element.points.length - 1
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, scene, [
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
selectedPoint,
|
|
||||||
{
|
{
|
||||||
|
index: selectedPoint,
|
||||||
point:
|
point:
|
||||||
selectedPoint === 0
|
selectedPoint === 0
|
||||||
? element.points[element.points.length - 1]
|
? element.points[element.points.length - 1]
|
||||||
: element.points[0],
|
: element.points[0],
|
||||||
},
|
},
|
||||||
],
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
@ -531,7 +517,7 @@ export class LinearElementEditor {
|
|||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
): (GlobalPoint | null)[] => {
|
): typeof editorMidPointsCache["points"] => {
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
@ -543,7 +529,25 @@ export class LinearElementEditor {
|
|||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
editorMidPointsCache.version === element.version &&
|
||||||
|
editorMidPointsCache.zoom === appState.zoom.value
|
||||||
|
) {
|
||||||
|
return editorMidPointsCache.points;
|
||||||
|
}
|
||||||
|
LinearElementEditor.updateEditorMidPointsCache(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return editorMidPointsCache.points!;
|
||||||
|
};
|
||||||
|
|
||||||
|
static updateEditorMidPointsCache = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: InteractiveCanvasAppState,
|
||||||
|
) => {
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -575,8 +579,9 @@ export class LinearElementEditor {
|
|||||||
midpoints.push(segmentMidPoint);
|
midpoints.push(segmentMidPoint);
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
editorMidPointsCache.points = midpoints;
|
||||||
return midpoints;
|
editorMidPointsCache.version = element.version;
|
||||||
|
editorMidPointsCache.zoom = appState.zoom.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
static getSegmentMidpointHitCoords = (
|
static getSegmentMidpointHitCoords = (
|
||||||
@ -630,11 +635,8 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
const midPoints: typeof editorMidPointsCache["points"] =
|
||||||
element,
|
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||||
elementsMap,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
while (index < midPoints.length) {
|
while (index < midPoints.length) {
|
||||||
if (midPoints[index] !== null) {
|
if (midPoints[index] !== null) {
|
||||||
@ -986,18 +988,12 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, app.scene, [
|
||||||
element,
|
|
||||||
app.scene,
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
element.points.length - 1,
|
|
||||||
{
|
{
|
||||||
|
index: element.points.length - 1,
|
||||||
point: newPoint,
|
point: newPoint,
|
||||||
},
|
},
|
||||||
],
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
@ -1231,16 +1227,12 @@ export class LinearElementEditor {
|
|||||||
// potentially expanding the bounding box
|
// potentially expanding the bounding box
|
||||||
if (pointAddedToEnd) {
|
if (pointAddedToEnd) {
|
||||||
const lastPoint = element.points[element.points.length - 1];
|
const lastPoint = element.points[element.points.length - 1];
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, scene, [
|
||||||
element,
|
{
|
||||||
scene,
|
index: element.points.length - 1,
|
||||||
new Map([
|
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||||
[
|
},
|
||||||
element.points.length - 1,
|
]);
|
||||||
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1315,7 +1307,7 @@ export class LinearElementEditor {
|
|||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
pointUpdates: PointsPositionUpdates,
|
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
@ -1329,7 +1321,8 @@ export class LinearElementEditor {
|
|||||||
// offset it. We do the same with actual element.x/y position, so
|
// offset it. We do the same with actual element.x/y position, so
|
||||||
// this hacks are completely transparent to the user.
|
// this hacks are completely transparent to the user.
|
||||||
const [deltaX, deltaY] =
|
const [deltaX, deltaY] =
|
||||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
targetPoints.find(({ index }) => index === 0)?.point ??
|
||||||
|
pointFrom<LocalPoint>(0, 0);
|
||||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||||
deltaX - points[0][0],
|
deltaX - points[0][0],
|
||||||
deltaY - points[0][1],
|
deltaY - points[0][1],
|
||||||
@ -1337,12 +1330,12 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const nextPoints = isElbowArrow(element)
|
const nextPoints = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
pointUpdates.get(0)?.point ?? points[0],
|
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
||||||
pointUpdates.get(points.length - 1)?.point ??
|
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
||||||
points[points.length - 1],
|
points[points.length - 1],
|
||||||
]
|
]
|
||||||
: points.map((p, idx) => {
|
: points.map((p, idx) => {
|
||||||
const current = pointUpdates.get(idx)?.point ?? p;
|
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
||||||
|
|
||||||
return pointFrom<LocalPoint>(
|
return pointFrom<LocalPoint>(
|
||||||
current[0] - offsetX,
|
current[0] - offsetX,
|
||||||
@ -1358,7 +1351,11 @@ export class LinearElementEditor {
|
|||||||
offsetY,
|
offsetY,
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
isDragging: targetPoints.reduce(
|
||||||
|
(dragging, targetPoint): boolean =>
|
||||||
|
dragging || targetPoint.isDragging === true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1590,14 +1587,23 @@ export class LinearElementEditor {
|
|||||||
y = midPoint[1] - boundTextElement.height / 2;
|
y = midPoint[1] - boundTextElement.height / 2;
|
||||||
} else {
|
} else {
|
||||||
const index = element.points.length / 2 - 1;
|
const index = element.points.length / 2 - 1;
|
||||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
|
||||||
|
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,
|
element,
|
||||||
points[index],
|
points[index],
|
||||||
points[index + 1],
|
points[index + 1],
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,7 @@ import type {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -477,7 +478,7 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
endArrowhead?: Arrowhead | null;
|
endArrowhead?: Arrowhead | null;
|
||||||
points?: ExcalidrawArrowElement["points"];
|
points?: ExcalidrawArrowElement["points"];
|
||||||
elbowed?: T;
|
elbowed?: T;
|
||||||
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
|
fixedSegments?: FixedSegment[] | null;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): T extends true
|
): T extends true
|
||||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||||
|
@ -351,20 +351,12 @@ const generateElementCanvas = (
|
|||||||
|
|
||||||
export const DEFAULT_LINK_SIZE = 14;
|
export const DEFAULT_LINK_SIZE = 14;
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER_IMG =
|
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
||||||
typeof document !== "undefined"
|
|
||||||
? document.createElement("img")
|
|
||||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
|
||||||
|
|
||||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
||||||
typeof document !== "undefined"
|
|
||||||
? document.createElement("img")
|
|
||||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
|
||||||
|
|
||||||
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||||
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
||||||
)}`;
|
)}`;
|
||||||
|
@ -57,7 +57,7 @@ import {
|
|||||||
|
|
||||||
import { isInGroup } from "./groups";
|
import { isInGroup } from "./groups";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { BoundingBox } from "./bounds";
|
import type { BoundingBox } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
@ -962,6 +962,11 @@ export const resizeSingleElement = (
|
|||||||
isDragging: false,
|
isDragging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateBoundElements(latestElement, scene, {
|
||||||
|
// TODO: confirm with MARK if this actually makes sense
|
||||||
|
newSize: { width: nextWidth, height: nextHeight },
|
||||||
|
});
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont != null) {
|
if (boundTextElement && boundTextFont != null) {
|
||||||
scene.mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
fontSize: boundTextFont.fontSize,
|
fontSize: boundTextFont.fontSize,
|
||||||
@ -973,11 +978,6 @@ export const resizeSingleElement = (
|
|||||||
handleDirection,
|
handleDirection,
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
updateBoundElements(latestElement, scene, {
|
|
||||||
// TODO: confirm with MARK if this actually makes sense
|
|
||||||
newSize: { width: nextWidth, height: nextHeight },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -169,6 +169,25 @@ export const isSomeElementSelected = (function () {
|
|||||||
return ret;
|
return ret;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns common attribute (picked by `getAttribute` callback) of selected
|
||||||
|
* elements. If elements don't share the same value, returns `null`.
|
||||||
|
*/
|
||||||
|
export const getCommonAttributeOfSelectedElements = <T>(
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: Pick<AppState, "selectedElementIds">,
|
||||||
|
getAttribute: (element: ExcalidrawElement) => T,
|
||||||
|
): T | null => {
|
||||||
|
const attributes = Array.from(
|
||||||
|
new Set(
|
||||||
|
getSelectedElements(elements, appState).map((element) =>
|
||||||
|
getAttribute(element),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return attributes.length === 1 ? attributes[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const getSelectedElements = (
|
export const getSelectedElements = (
|
||||||
elements: ElementsMapOrArray,
|
elements: ElementsMapOrArray,
|
||||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||||
|
@ -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,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||||
import type {
|
import type {
|
||||||
|
@ -28,7 +28,6 @@ import type {
|
|||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
ExcalidrawLinearElementSubType,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
@ -120,20 +119,6 @@ export const isElbowArrow = (
|
|||||||
return isArrowElement(element) && element.elbowed;
|
return isArrowElement(element) && element.elbowed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isSharpArrow = (
|
|
||||||
element?: ExcalidrawElement,
|
|
||||||
): element is ExcalidrawArrowElement => {
|
|
||||||
return isArrowElement(element) && !element.elbowed && !element.roundness;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isCurvedArrow = (
|
|
||||||
element?: ExcalidrawElement,
|
|
||||||
): element is ExcalidrawArrowElement => {
|
|
||||||
return (
|
|
||||||
isArrowElement(element) && !element.elbowed && element.roundness !== null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isLinearElementType = (
|
export const isLinearElementType = (
|
||||||
elementType: ElementOrToolType,
|
elementType: ElementOrToolType,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@ -286,10 +271,6 @@ export const isBoundToContainer = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
|
|
||||||
return !!element.startBinding || !!element.endBinding;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isUsingAdaptiveRadius = (type: string) =>
|
export const isUsingAdaptiveRadius = (type: string) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
type === "embeddable" ||
|
type === "embeddable" ||
|
||||||
@ -357,18 +338,3 @@ export const isBounds = (box: unknown): box is Bounds =>
|
|||||||
typeof box[1] === "number" &&
|
typeof box[1] === "number" &&
|
||||||
typeof box[2] === "number" &&
|
typeof box[2] === "number" &&
|
||||||
typeof box[3] === "number";
|
typeof box[3] === "number";
|
||||||
|
|
||||||
export const getLinearElementSubType = (
|
|
||||||
element: ExcalidrawLinearElement,
|
|
||||||
): ExcalidrawLinearElementSubType => {
|
|
||||||
if (isSharpArrow(element)) {
|
|
||||||
return "sharpArrow";
|
|
||||||
}
|
|
||||||
if (isCurvedArrow(element)) {
|
|
||||||
return "curvedArrow";
|
|
||||||
}
|
|
||||||
if (isElbowArrow(element)) {
|
|
||||||
return "elbowArrow";
|
|
||||||
}
|
|
||||||
return "line";
|
|
||||||
};
|
|
||||||
|
@ -296,11 +296,6 @@ export type FixedPointBinding = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type PointsPositionUpdates = Map<
|
|
||||||
number,
|
|
||||||
{ point: LocalPoint; isDragging?: boolean }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type Arrowhead =
|
export type Arrowhead =
|
||||||
| "arrow"
|
| "arrow"
|
||||||
| "bar"
|
| "bar"
|
||||||
@ -417,13 +412,3 @@ export type NonDeletedSceneElementsMap = Map<
|
|||||||
export type ElementsMapOrArray =
|
export type ElementsMapOrArray =
|
||||||
| readonly ExcalidrawElement[]
|
| readonly ExcalidrawElement[]
|
||||||
| Readonly<ElementsMap>;
|
| Readonly<ElementsMap>;
|
||||||
|
|
||||||
export type ExcalidrawLinearElementSubType =
|
|
||||||
| "line"
|
|
||||||
| "sharpArrow"
|
|
||||||
| "curvedArrow"
|
|
||||||
| "elbowArrow";
|
|
||||||
|
|
||||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
|
||||||
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
|
||||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
|
||||||
|
@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
|
|||||||
|
|
||||||
import { getSelectedElements } from "./selection";
|
import { getSelectedElements } from "./selection";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type Scene from "./Scene";
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||||
|
|
||||||
|
@ -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 { bindLinearElement } from "../src/binding";
|
||||||
|
|
||||||
import { Scene } from "../src/Scene";
|
import Scene from "../src/Scene";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
@ -7,9 +7,9 @@ import {
|
|||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { deepCopyElement } from "@excalidraw/element";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import { normalizeElementOrder } from "../src/sortElements";
|
import { normalizeElementOrder } from "../src/sortElements";
|
||||||
|
|
||||||
|
1
packages/excalidraw/.gitignore
vendored
1
packages/excalidraw/.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
types
|
types
|
||||||
|
.wrangler
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||||
import { deepCopyElement } from "@excalidraw/element";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element";
|
import { alignElements } from "@excalidraw/element/align";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element";
|
import type { Alignment } from "@excalidraw/element/align";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import {
|
import {
|
||||||
@ -27,6 +25,7 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -10,14 +10,14 @@ import {
|
|||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/containerCache";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
@ -25,15 +25,13 @@ import {
|
|||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { measureText } from "@excalidraw/element";
|
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element";
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { newElement } from "@excalidraw/element";
|
import { newElement } from "@excalidraw/element/newElement";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -46,6 +44,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
@ -14,10 +14,8 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -46,6 +44,7 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { isTextElement } from "@excalidraw/element";
|
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||||
import { getTextFromElements } from "@excalidraw/element";
|
import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@ -17,6 +15,8 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
|||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { isImageElement } from "@excalidraw/element";
|
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { cropIcon } from "../components/icons";
|
import { cropIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,28 +1,27 @@
|
|||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { getContainerElement } from "@excalidraw/element";
|
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||||
import {
|
import {
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
import { getFrameChildren } from "@excalidraw/element";
|
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import { distributeElements } from "@excalidraw/element";
|
import { distributeElements } from "@excalidraw/element/distribute";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element";
|
import type { Distribution } from "@excalidraw/element/distribute";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import {
|
import {
|
||||||
@ -23,6 +21,7 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,24 +7,23 @@ import {
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
getSelectionStateForElements,
|
getSelectionStateForElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/selection";
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element";
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { duplicateElements } from "@excalidraw/element";
|
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -2,14 +2,13 @@ import {
|
|||||||
canCreateLinkFromElements,
|
canCreateLinkFromElements,
|
||||||
defaultGetElementLinkFromSelection,
|
defaultGetElementLinkFromSelection,
|
||||||
getLinkIdAndTypeFromSelection,
|
getLinkIdAndTypeFromSelection,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/elementLink";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,23 +1,18 @@
|
|||||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
elementsAreInSameGroup,
|
|
||||||
newElementWith,
|
|
||||||
selectGroupsFromGivenElements,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
|
||||||
|
|
||||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||||
elements.every((el) => !el.locked);
|
elements.every((el) => !el.locked);
|
||||||
|
|
||||||
@ -28,10 +23,15 @@ export const actionToggleElementLock = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: false,
|
includeBoundTextElement: false,
|
||||||
});
|
});
|
||||||
|
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||||
|
return selected[0].locked
|
||||||
|
? "labels.elementLock.unlock"
|
||||||
|
: "labels.elementLock.lock";
|
||||||
|
}
|
||||||
|
|
||||||
return shouldLock(selected)
|
return shouldLock(selected)
|
||||||
? "labels.elementLock.lock"
|
? "labels.elementLock.lockAll"
|
||||||
: "labels.elementLock.unlock";
|
: "labels.elementLock.unlockAll";
|
||||||
},
|
},
|
||||||
icon: (appState, elements) => {
|
icon: (appState, elements) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
@ -58,84 +58,19 @@ export const actionToggleElementLock = register({
|
|||||||
|
|
||||||
const nextLockState = shouldLock(selectedElements);
|
const nextLockState = shouldLock(selectedElements);
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
return {
|
||||||
const isAGroup =
|
elements: elements.map((element) => {
|
||||||
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)) {
|
if (!selectedElementsMap.has(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextGroupIds = element.groupIds;
|
return newElementWith(element, { locked: nextLockState });
|
||||||
|
}),
|
||||||
// if locking together, add to group
|
|
||||||
// if unlocking, remove the temporary group
|
|
||||||
if (nextLockState) {
|
|
||||||
if (newGroupId) {
|
|
||||||
nextGroupIds = [...nextGroupIds, newGroupId];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nextGroupIds = nextGroupIds.filter(
|
|
||||||
(groupId) => !appState.lockedMultiSelections[groupId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElementWith(element, {
|
|
||||||
locked: nextLockState,
|
|
||||||
// do not recreate the array unncessarily
|
|
||||||
groupIds:
|
|
||||||
nextGroupIds.length !== element.groupIds.length
|
|
||||||
? nextGroupIds
|
|
||||||
: element.groupIds,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextElementsMap = arrayToMap(nextElements);
|
|
||||||
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
|
||||||
? {}
|
|
||||||
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
|
||||||
const unlockedSelectedElements = selectedElements.map(
|
|
||||||
(el) => nextElementsMap.get(el.id) || el,
|
|
||||||
);
|
|
||||||
const nextSelectedGroupIds = nextLockState
|
|
||||||
? {}
|
|
||||||
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
|
|
||||||
|
|
||||||
const activeLockedId = nextLockState
|
|
||||||
? newGroupId
|
|
||||||
? newGroupId
|
|
||||||
: isAGroup
|
|
||||||
? selectedElements[0].groupIds.at(-1)!
|
|
||||||
: selectedElements[0].id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: nextSelectedElementIds,
|
|
||||||
selectedGroupIds: nextSelectedGroupIds,
|
|
||||||
selectedLinearElement: nextLockState
|
selectedLinearElement: nextLockState
|
||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
lockedMultiSelections: nextLockedMultiSelections,
|
|
||||||
activeLockedId,
|
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -168,44 +103,18 @@ export const actionUnlockAllElements = register({
|
|||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const lockedElements = elements.filter((el) => el.locked);
|
const lockedElements = elements.filter((el) => el.locked);
|
||||||
|
|
||||||
const nextElements = elements.map((element) => {
|
return {
|
||||||
|
elements: elements.map((element) => {
|
||||||
if (element.locked) {
|
if (element.locked) {
|
||||||
// remove the temporary groupId if it exists
|
return newElementWith(element, { locked: false });
|
||||||
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;
|
return element;
|
||||||
});
|
}),
|
||||||
|
|
||||||
const nextElementsMap = arrayToMap(nextElements);
|
|
||||||
|
|
||||||
const unlockedElements = lockedElements.map(
|
|
||||||
(el) => nextElementsMap.get(el.id) || el,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: Object.fromEntries(
|
selectedElementIds: Object.fromEntries(
|
||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
selectedGroupIds: selectGroupsFromGivenElements(
|
|
||||||
unlockedElements,
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
activeLockedId: null,
|
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { updateActiveTool } from "@excalidraw/common";
|
import { updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,8 +7,6 @@ import {
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
@ -26,6 +24,7 @@ import { resaveAsImageWithScene } from "../data/resave";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
|
||||||
|
@ -3,22 +3,24 @@ import { pointFrom } from "@excalidraw/math";
|
|||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/binding";
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
|
|
||||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
import {
|
||||||
|
isBindingElement,
|
||||||
|
isLinearElement,
|
||||||
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element/shapes";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -2,21 +2,19 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/binding";
|
||||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { deepCopyElement } from "@excalidraw/element";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
import { resizeMultipleElements } from "@excalidraw/element";
|
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
@ -26,6 +24,7 @@ import type {
|
|||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
|
||||||
|
@ -1,26 +1,25 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||||
import { newFrameElement } from "@excalidraw/element";
|
import { newFrameElement } from "@excalidraw/element/newElement";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
import {
|
import {
|
||||||
addElementsToFrame,
|
addElementsToFrame,
|
||||||
removeAllElementsFromFrame,
|
removeAllElementsFromFrame,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/frame";
|
||||||
import { getFrameChildren } from "@excalidraw/element";
|
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getElementsInGroup } from "@excalidraw/element";
|
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { getCommonBounds } from "@excalidraw/element";
|
import { getCommonBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import { isBoundToContainer } from "@excalidraw/element";
|
import { isBoundToContainer } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
frameAndChildrenSelectedTogether,
|
frameAndChildrenSelectedTogether,
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
groupByFrameLikes,
|
groupByFrameLikes,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
@ -24,11 +24,9 @@ import {
|
|||||||
addToGroup,
|
addToGroup,
|
||||||
removeFromSelectedGroups,
|
removeFromSelectedGroups,
|
||||||
isElementInGroup,
|
isElementInGroup,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element";
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -42,6 +40,7 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,9 +1,5 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { orderByFractionalIndex } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
@ -11,6 +7,7 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
|||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
@ -37,11 +34,7 @@ const executeHistoryAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [nextElementsMap, nextAppState] = result;
|
const [nextElementsMap, nextAppState] = result;
|
||||||
|
const nextElements = Array.from(nextElementsMap.values());
|
||||||
// order by fractional indices in case the map was accidently modified in the meantime
|
|
||||||
const nextElements = orderByFractionalIndex(
|
|
||||||
Array.from(nextElementsMap.values()),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
|
|
||||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
@ -13,6 +11,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { lineEditorIcon } from "../components/icons";
|
import { lineEditorIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import { isEmbeddableElement } from "@excalidraw/element";
|
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
@ -10,6 +8,7 @@ import {
|
|||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -20,7 +20,6 @@ import {
|
|||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
reduceToCommonValue,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
@ -31,16 +30,16 @@ import {
|
|||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/binding";
|
||||||
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@ -49,13 +48,11 @@ import {
|
|||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { hasStrokeColor } from "@excalidraw/element";
|
import { hasStrokeColor } from "@excalidraw/element/comparisons";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -71,12 +68,10 @@ import type {
|
|||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Scene } from "@excalidraw/element";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { RadioSelection } from "../components/RadioSelection";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
@ -131,13 +126,16 @@ import { Fonts } from "../fonts";
|
|||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import {
|
import {
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
|
getCommonAttributeOfSelectedElements,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
import type { CaptureUpdateActionType } from "../store";
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
@ -167,12 +165,12 @@ export const changeProperty = (
|
|||||||
|
|
||||||
export const getFormValue = function <T extends Primitive>(
|
export const getFormValue = function <T extends Primitive>(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
app: AppClassProperties,
|
appState: AppState,
|
||||||
getAttribute: (element: ExcalidrawElement) => T,
|
getAttribute: (element: ExcalidrawElement) => T,
|
||||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||||
): T {
|
): T {
|
||||||
const editingTextElement = app.state.editingTextElement;
|
const editingTextElement = appState.editingTextElement;
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
|
||||||
let ret: T | null = null;
|
let ret: T | null = null;
|
||||||
@ -182,17 +180,17 @@ export const getFormValue = function <T extends Primitive>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
const hasSelection = isSomeElementSelected(nonDeletedElements, app.state);
|
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
|
||||||
|
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
|
||||||
const targetElements =
|
|
||||||
isRelevantElement === true
|
|
||||||
? selectedElements
|
|
||||||
: selectedElements.filter((el) => isRelevantElement(el));
|
|
||||||
|
|
||||||
ret =
|
ret =
|
||||||
reduceToCommonValue(targetElements, getAttribute) ??
|
getCommonAttributeOfSelectedElements(
|
||||||
|
isRelevantElement === true
|
||||||
|
? nonDeletedElements
|
||||||
|
: nonDeletedElements.filter((el) => isRelevantElement(el)),
|
||||||
|
appState,
|
||||||
|
getAttribute,
|
||||||
|
) ??
|
||||||
(typeof defaultValue === "function"
|
(typeof defaultValue === "function"
|
||||||
? defaultValue(true)
|
? defaultValue(true)
|
||||||
: defaultValue);
|
: defaultValue);
|
||||||
@ -320,7 +318,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@ -330,11 +328,10 @@ export const actionChangeStrokeColor = register({
|
|||||||
label={t("labels.stroke")}
|
label={t("labels.stroke")}
|
||||||
color={getFormValue(
|
color={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
true,
|
true,
|
||||||
(hasSelection) =>
|
appState.currentItemStrokeColor,
|
||||||
!hasSelection ? appState.currentItemStrokeColor : null,
|
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@ -367,7 +364,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@ -377,11 +374,10 @@ export const actionChangeBackgroundColor = register({
|
|||||||
label={t("labels.background")}
|
label={t("labels.background")}
|
||||||
color={getFormValue(
|
color={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
true,
|
true,
|
||||||
(hasSelection) =>
|
appState.currentItemBackgroundColor,
|
||||||
!hasSelection ? appState.currentItemBackgroundColor : null,
|
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@ -412,7 +408,7 @@ export const actionChangeFillStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const allElementsZigZag =
|
const allElementsZigZag =
|
||||||
selectedElements.length > 0 &&
|
selectedElements.length > 0 &&
|
||||||
@ -421,8 +417,7 @@ export const actionChangeFillStyle = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fill")}</legend>
|
<legend>{t("labels.fill")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
type="button"
|
type="button"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -449,7 +444,7 @@ export const actionChangeFillStyle = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.fillStyle,
|
(element) => element.fillStyle,
|
||||||
(element) => element.hasOwnProperty("fillStyle"),
|
(element) => element.hasOwnProperty("fillStyle"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
@ -466,7 +461,6 @@ export const actionChangeFillStyle = register({
|
|||||||
updateData(nextValue);
|
updateData(nextValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -487,11 +481,10 @@ export const actionChangeStrokeWidth = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -515,7 +508,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.strokeWidth,
|
(element) => element.strokeWidth,
|
||||||
(element) => element.hasOwnProperty("strokeWidth"),
|
(element) => element.hasOwnProperty("strokeWidth"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
@ -523,7 +516,6 @@ export const actionChangeStrokeWidth = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -544,11 +536,10 @@ export const actionChangeSloppiness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -569,7 +560,7 @@ export const actionChangeSloppiness = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.roughness,
|
(element) => element.roughness,
|
||||||
(element) => element.hasOwnProperty("roughness"),
|
(element) => element.hasOwnProperty("roughness"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
@ -577,7 +568,6 @@ export const actionChangeSloppiness = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -597,11 +587,10 @@ export const actionChangeStrokeStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="strokeStyle"
|
group="strokeStyle"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -622,7 +611,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => element.strokeStyle,
|
(element) => element.strokeStyle,
|
||||||
(element) => element.hasOwnProperty("strokeStyle"),
|
(element) => element.hasOwnProperty("strokeStyle"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
@ -630,7 +619,6 @@ export const actionChangeStrokeStyle = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -654,8 +642,13 @@ export const actionChangeOpacity = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ app, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<Range updateData={updateData} app={app} testId="opacity" />
|
<Range
|
||||||
|
updateData={updateData}
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
|
testId="opacity"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -669,8 +662,7 @@ export const actionChangeFontSize = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="font-size"
|
group="font-size"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -700,7 +692,7 @@ export const actionChangeFontSize = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => {
|
(element) => {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.fontSize;
|
return element.fontSize;
|
||||||
@ -727,7 +719,6 @@ export const actionChangeFontSize = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -999,7 +990,7 @@ export const actionChangeFontFamily = register({
|
|||||||
) =>
|
) =>
|
||||||
getFormValue(
|
getFormValue(
|
||||||
elementsArray,
|
elementsArray,
|
||||||
app,
|
appState,
|
||||||
(element) => {
|
(element) => {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.fontFamily;
|
return element.fontFamily;
|
||||||
@ -1037,7 +1028,7 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||||
return prevSelectedFontFamilyRef.current;
|
return prevSelectedFontFamilyRef.current;
|
||||||
}, [batchedData.openPopup, appState, elements, app]);
|
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||||
@ -1199,8 +1190,7 @@ export const actionChangeTextAlign = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect<TextAlign | false>
|
||||||
<RadioSelection<TextAlign | false>
|
|
||||||
group="text-align"
|
group="text-align"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -1224,7 +1214,7 @@ export const actionChangeTextAlign = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => {
|
(element) => {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.textAlign;
|
return element.textAlign;
|
||||||
@ -1246,7 +1236,6 @@ export const actionChangeTextAlign = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -1289,8 +1278,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect<VerticalAlign | false>
|
||||||
<RadioSelection<VerticalAlign | false>
|
|
||||||
group="text-align"
|
group="text-align"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -1314,7 +1302,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => {
|
(element) => {
|
||||||
if (isTextElement(element) && element.containerId) {
|
if (isTextElement(element) && element.containerId) {
|
||||||
return element.verticalAlign;
|
return element.verticalAlign;
|
||||||
@ -1338,7 +1326,6 @@ export const actionChangeVerticalAlign = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -1373,7 +1360,7 @@ export const actionChangeRoundness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
@ -1386,8 +1373,7 @@ export const actionChangeRoundness = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.edges")}</legend>
|
<legend>{t("labels.edges")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="edges"
|
group="edges"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -1403,13 +1389,9 @@ export const actionChangeRoundness = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) =>
|
(element) =>
|
||||||
hasLegacyRoundness
|
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||||
? null
|
|
||||||
: element.roundness
|
|
||||||
? "round"
|
|
||||||
: "sharp",
|
|
||||||
(element) =>
|
(element) =>
|
||||||
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
@ -1417,7 +1399,6 @@ export const actionChangeRoundness = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -1538,7 +1519,7 @@ export const actionChangeArrowhead = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1550,7 +1531,7 @@ export const actionChangeArrowhead = register({
|
|||||||
options={getArrowheadOptions(!isRTL)}
|
options={getArrowheadOptions(!isRTL)}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) =>
|
(element) =>
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||||
? element.startArrowhead
|
? element.startArrowhead
|
||||||
@ -1567,7 +1548,7 @@ export const actionChangeArrowhead = register({
|
|||||||
options={getArrowheadOptions(!!isRTL)}
|
options={getArrowheadOptions(!!isRTL)}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) =>
|
(element) =>
|
||||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||||
? element.endArrowhead
|
? element.endArrowhead
|
||||||
@ -1723,6 +1704,12 @@ export const actionChangeArrowType = register({
|
|||||||
fixedSegments: null,
|
fixedSegments: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
LinearElementEditor.updateEditorMidPointsCache(
|
||||||
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
if (newElement.startBinding) {
|
if (newElement.startBinding) {
|
||||||
@ -1770,12 +1757,11 @@ export const actionChangeArrowType = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.arrowtypes")}</legend>
|
<legend>{t("labels.arrowtypes")}</legend>
|
||||||
<div className="buttonList">
|
<ButtonIconSelect
|
||||||
<RadioSelection
|
|
||||||
group="arrowtypes"
|
group="arrowtypes"
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
@ -1799,7 +1785,7 @@ export const actionChangeArrowType = register({
|
|||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
appState,
|
||||||
(element) => {
|
(element) => {
|
||||||
if (isArrowElement(element)) {
|
if (isArrowElement(element)) {
|
||||||
return element.elbowed
|
return element.elbowed
|
||||||
@ -1817,7 +1803,6 @@ export const actionChangeArrowType = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
import { isLinearElement, isTextElement } from "@excalidraw/element";
|
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { arrayToMap, KEYS } from "@excalidraw/common";
|
import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
getLineHeight,
|
getLineHeight,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
@ -17,14 +17,12 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isExcalidrawElement,
|
isExcalidrawElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -32,6 +30,7 @@ import { paintIcon } from "../components/icons";
|
|||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { getFontString } from "@excalidraw/common";
|
import { getFontString } from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { measureText } from "@excalidraw/element";
|
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||||
|
|
||||||
import { isTextElement } from "@excalidraw/element";
|
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { magnetIcon } from "../components/icons";
|
import { magnetIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -5,9 +5,8 @@ import {
|
|||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { searchIcon } from "../components/icons";
|
import { searchIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -34,6 +33,13 @@ export const actionToggleSearchMenu = register({
|
|||||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (searchInput?.matches(":focus")) {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, openSidebar: null },
|
||||||
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
searchInput?.focus();
|
searchInput?.focus();
|
||||||
searchInput?.select();
|
searchInput?.select();
|
||||||
return false;
|
return false;
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getConversionTypeFromElements,
|
|
||||||
convertElementTypePopupAtom,
|
|
||||||
} from "../components/ConvertElementTypePopup";
|
|
||||||
import { editorJotaiStore } from "../editor-jotai";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionToggleShapeSwitch = register({
|
|
||||||
name: "toggleShapeSwitch",
|
|
||||||
label: "labels.shapeSwitch",
|
|
||||||
icon: () => null,
|
|
||||||
viewMode: true,
|
|
||||||
trackEvent: {
|
|
||||||
category: "shape_switch",
|
|
||||||
action: "toggle",
|
|
||||||
},
|
|
||||||
keywords: ["change", "switch", "swap"],
|
|
||||||
perform(elements, appState, _, app) {
|
|
||||||
editorJotaiStore.set(convertElementTypePopupAtom, {
|
|
||||||
type: "panel",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState) => appState.gridModeEnabled,
|
|
||||||
predicate: (elements, appState, props) =>
|
|
||||||
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
|
|
||||||
});
|
|
@ -1,8 +1,7 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { abacusIcon } from "../components/icons";
|
import { abacusIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { eyeIcon } from "../components/icons";
|
import { eyeIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { coffeeIcon } from "../components/icons";
|
import { coffeeIcon } from "../components/icons";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -5,9 +5,7 @@ import {
|
|||||||
moveOneRight,
|
moveOneRight,
|
||||||
moveAllLeft,
|
moveAllLeft,
|
||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/zindex";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BringForwardIcon,
|
BringForwardIcon,
|
||||||
@ -16,6 +14,7 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -3,8 +3,7 @@ import type {
|
|||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
import type { CaptureUpdateActionType } from "../store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
@ -141,8 +140,7 @@ export type ActionName =
|
|||||||
| "linkToElement"
|
| "linkToElement"
|
||||||
| "cropEditor"
|
| "cropEditor"
|
||||||
| "wrapSelectionInFrame"
|
| "wrapSelectionInFrame"
|
||||||
| "toggleLassoTool"
|
| "toggleLassoTool";
|
||||||
| "toggleShapeSwitch";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -197,8 +195,7 @@ export interface Action {
|
|||||||
| "menu"
|
| "menu"
|
||||||
| "collab"
|
| "collab"
|
||||||
| "hyperlink"
|
| "hyperlink"
|
||||||
| "search_menu"
|
| "search_menu";
|
||||||
| "shape_switch";
|
|
||||||
action?: string;
|
action?: string;
|
||||||
predicate?: (
|
predicate?: (
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
|
@ -129,6 +129,7 @@ export class AnimatedTrail implements Trail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
|
this.pastTrails = [];
|
||||||
this.start();
|
this.start();
|
||||||
if (this.trailAnimation) {
|
if (this.trailAnimation) {
|
||||||
this.trailAnimation.setAttribute("begin", "indefinite");
|
this.trailAnimation.setAttribute("begin", "indefinite");
|
||||||
|
@ -121,9 +121,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
followedBy: new Set(),
|
followedBy: new Set(),
|
||||||
isCropping: false,
|
isCropping: false,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
searchMatches: null,
|
searchMatches: [],
|
||||||
lockedMultiSelections: {},
|
|
||||||
activeLockedId: null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -248,8 +246,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
isCropping: { browser: false, export: false, server: false },
|
isCropping: { browser: false, export: false, server: false },
|
||||||
croppingElementId: { browser: false, export: false, server: false },
|
croppingElementId: { browser: false, export: false, server: false },
|
||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
newTextElement,
|
newTextElement,
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
newElement,
|
newElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/newElement";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
@ -7,14 +7,14 @@ import {
|
|||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||||
import { deepCopyElement } from "@excalidraw/element";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element/frame";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
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