Compare commits
25 Commits
| 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 |
@@ -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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-1
@@ -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";
|
||||||
@@ -56,6 +61,7 @@ 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")}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -73,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,
|
||||||
@@ -95,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);
|
||||||
@@ -241,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, {
|
||||||
@@ -274,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?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>`,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
|
|||||||
@@ -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+
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
@@ -154,7 +154,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(redoAction));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
|||||||
@@ -119,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";
|
||||||
|
|||||||
@@ -66,5 +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>;
|
||||||
|
|
||||||
|
/** Strip all the methods or functions from a type */
|
||||||
|
export type DTO<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
// get union of all keys from the union of types
|
// get union of all keys from the union of types
|
||||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -43,12 +43,6 @@ import {
|
|||||||
import { intersectElementWithLineSegment } from "./collision";
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToBindableElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
|
||||||
HEADING_DOWN,
|
|
||||||
HEADING_LEFT,
|
|
||||||
HEADING_RIGHT,
|
|
||||||
HEADING_UP,
|
|
||||||
headingForPoint,
|
|
||||||
headingForPointFromElement,
|
headingForPointFromElement,
|
||||||
headingIsHorizontal,
|
headingIsHorizontal,
|
||||||
vectorToHeading,
|
vectorToHeading,
|
||||||
@@ -87,6 +81,7 @@ import type {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPoint,
|
FixedPoint,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
@@ -281,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
|
||||||
@@ -292,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];
|
||||||
@@ -715,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;
|
||||||
@@ -844,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,
|
||||||
@@ -1031,14 +1022,7 @@ export const avoidRectangularCorner = (
|
|||||||
|
|
||||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||||
// Top left
|
// Top left
|
||||||
const heading = headingForPoint(
|
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||||
nonRotatedPoint,
|
|
||||||
pointFrom(element.x, element.y),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
compareHeading(heading, HEADING_DOWN) ||
|
|
||||||
compareHeading(heading, HEADING_LEFT)
|
|
||||||
) {
|
|
||||||
return pointRotateRads<GlobalPoint>(
|
return pointRotateRads<GlobalPoint>(
|
||||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||||
center,
|
center,
|
||||||
@@ -1055,14 +1039,7 @@ export const avoidRectangularCorner = (
|
|||||||
nonRotatedPoint[1] > element.y + element.height
|
nonRotatedPoint[1] > element.y + element.height
|
||||||
) {
|
) {
|
||||||
// Bottom left
|
// Bottom left
|
||||||
const heading = headingForPoint(
|
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||||
nonRotatedPoint,
|
|
||||||
pointFrom(element.x, element.y + element.height),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
compareHeading(heading, HEADING_DOWN) ||
|
|
||||||
compareHeading(heading, HEADING_RIGHT)
|
|
||||||
) {
|
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
pointFrom(
|
pointFrom(
|
||||||
element.x,
|
element.x,
|
||||||
@@ -1082,13 +1059,9 @@ export const avoidRectangularCorner = (
|
|||||||
nonRotatedPoint[1] > element.y + element.height
|
nonRotatedPoint[1] > element.y + element.height
|
||||||
) {
|
) {
|
||||||
// Bottom right
|
// Bottom right
|
||||||
const heading = headingForPoint(
|
|
||||||
nonRotatedPoint,
|
|
||||||
pointFrom(element.x + element.width, element.y + element.height),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
compareHeading(heading, HEADING_DOWN) ||
|
nonRotatedPoint[0] - element.x <
|
||||||
compareHeading(heading, HEADING_LEFT)
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
pointFrom(
|
pointFrom(
|
||||||
@@ -1112,13 +1085,9 @@ export const avoidRectangularCorner = (
|
|||||||
nonRotatedPoint[1] < element.y
|
nonRotatedPoint[1] < element.y
|
||||||
) {
|
) {
|
||||||
// Top right
|
// Top right
|
||||||
const heading = headingForPoint(
|
|
||||||
nonRotatedPoint,
|
|
||||||
pointFrom(element.x + element.width, element.y),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
compareHeading(heading, HEADING_UP) ||
|
nonRotatedPoint[0] - element.x <
|
||||||
compareHeading(heading, HEADING_LEFT)
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
pointFrom(
|
pointFrom(
|
||||||
@@ -1136,17 +1105,6 @@ export const avoidRectangularCorner = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Break up explicit border bindings to have better elbow arrow routing
|
|
||||||
if (p[0] === element.x) {
|
|
||||||
return pointFrom(p[0] - FIXED_BINDING_DISTANCE, p[1]);
|
|
||||||
} else if (p[0] === element.x + element.width) {
|
|
||||||
return pointFrom(p[0] + FIXED_BINDING_DISTANCE, p[1]);
|
|
||||||
} else if (p[1] === element.y) {
|
|
||||||
return pointFrom(p[0], p[1] - FIXED_BINDING_DISTANCE);
|
|
||||||
} else if (p[1] === element.y + element.height) {
|
|
||||||
return pointFrom(p[0], p[1] + FIXED_BINDING_DISTANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { aabbForElement, aabbForPoints, pointInsideBounds } from "./shapes";
|
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@@ -65,8 +65,6 @@ import type {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
|
|
||||||
|
|
||||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||||
|
|
||||||
type Node = {
|
type Node = {
|
||||||
@@ -108,32 +106,8 @@ type ElbowArrowData = {
|
|||||||
hoveredEndElement: ExcalidrawBindableElement | null;
|
hoveredEndElement: ExcalidrawBindableElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateDedupTreshhold = <Point extends GlobalPoint | LocalPoint>(
|
const DEDUP_TRESHOLD = 1;
|
||||||
a: Point,
|
export const BASE_PADDING = 40;
|
||||||
b: Point,
|
|
||||||
) => 1 + pointDistance(a, b) / 300;
|
|
||||||
|
|
||||||
const calculatePadding = (
|
|
||||||
aabb: Bounds,
|
|
||||||
startBoundingBox: Bounds,
|
|
||||||
endBoundingBox: Bounds,
|
|
||||||
) => {
|
|
||||||
return Math.max(
|
|
||||||
Math.min(
|
|
||||||
Math.hypot(
|
|
||||||
startBoundingBox[2] - startBoundingBox[0],
|
|
||||||
startBoundingBox[3] - startBoundingBox[1],
|
|
||||||
) / 4,
|
|
||||||
Math.hypot(
|
|
||||||
endBoundingBox[2] - endBoundingBox[0],
|
|
||||||
endBoundingBox[3] - endBoundingBox[1],
|
|
||||||
) / 4,
|
|
||||||
Math.hypot(aabb[2] - aabb[0], aabb[3] - aabb[1]) / 4,
|
|
||||||
40,
|
|
||||||
),
|
|
||||||
30,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSegmentRenormalization = (
|
const handleSegmentRenormalization = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
@@ -209,11 +183,7 @@ const handleSegmentRenormalization = (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
// Remove segments that are too short
|
// Remove segments that are too short
|
||||||
pointDistance(points[i - 2], points[i - 1]) <
|
pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD
|
||||||
calculateDedupTreshhold(
|
|
||||||
points[i - 3] ?? points[i - 3],
|
|
||||||
points[i] ?? points[i - 1],
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
const prevPrevSegmentIdx =
|
const prevPrevSegmentIdx =
|
||||||
nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
|
nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
|
||||||
@@ -389,10 +359,6 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!restoredPoints) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@@ -497,13 +463,6 @@ const handleSegmentMove = (
|
|||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
const BASE_PADDING = calculatePadding(
|
|
||||||
aabbForElement(arrow),
|
|
||||||
hoveredStartElement
|
|
||||||
? aabbForElement(hoveredStartElement)
|
|
||||||
: [10, 10, 10, 10],
|
|
||||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
|
||||||
);
|
|
||||||
const activelyModifiedSegmentIdx = fixedSegments
|
const activelyModifiedSegmentIdx = fixedSegments
|
||||||
.map((segment, i) => {
|
.map((segment, i) => {
|
||||||
if (
|
if (
|
||||||
@@ -748,13 +707,6 @@ const handleEndpointDrag = (
|
|||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
) => {
|
) => {
|
||||||
const BASE_PADDING = calculatePadding(
|
|
||||||
aabbForPoints([startGlobalPoint, endGlobalPoint]),
|
|
||||||
hoveredStartElement
|
|
||||||
? aabbForElement(hoveredStartElement)
|
|
||||||
: [10, 10, 10, 10],
|
|
||||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
|
||||||
);
|
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@@ -789,7 +741,6 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
startIsSpecial = arrow.startIsSpecial && globalUpdatedPoints.length > 2;
|
|
||||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
@@ -850,7 +801,6 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
endIsSpecial = arrow.endIsSpecial && globalUpdatedPoints.length > 2;
|
|
||||||
const secondToLastPoint =
|
const secondToLastPoint =
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||||
const thirdToLastPoint =
|
const thirdToLastPoint =
|
||||||
@@ -1343,28 +1293,29 @@ const getElbowArrowData = (
|
|||||||
endGlobalPoint[0] + 2,
|
endGlobalPoint[0] + 2,
|
||||||
endGlobalPoint[1] + 2,
|
endGlobalPoint[1] + 2,
|
||||||
] as Bounds;
|
] as Bounds;
|
||||||
const BASE_PADDING = calculatePadding(
|
|
||||||
aabbForPoints([startGlobalPoint, endGlobalPoint]),
|
|
||||||
hoveredStartElement
|
|
||||||
? aabbForElement(hoveredStartElement)
|
|
||||||
: [10, 10, 10, 10],
|
|
||||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
|
|
||||||
);
|
|
||||||
const startOffsets = offsetFromHeading(
|
|
||||||
startHeading,
|
|
||||||
arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
const endOffsets = offsetFromHeading(
|
|
||||||
endHeading,
|
|
||||||
arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
const startElementBounds = hoveredStartElement
|
const startElementBounds = hoveredStartElement
|
||||||
? aabbForElement(hoveredStartElement, startOffsets)
|
? aabbForElement(
|
||||||
|
hoveredStartElement,
|
||||||
|
offsetFromHeading(
|
||||||
|
startHeading,
|
||||||
|
arrow.startArrowhead
|
||||||
|
? FIXED_BINDING_DISTANCE * 6
|
||||||
|
: FIXED_BINDING_DISTANCE * 2,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
: startPointBounds;
|
: startPointBounds;
|
||||||
const endElementBounds = hoveredEndElement
|
const endElementBounds = hoveredEndElement
|
||||||
? aabbForElement(hoveredEndElement, endOffsets)
|
? aabbForElement(
|
||||||
|
hoveredEndElement,
|
||||||
|
offsetFromHeading(
|
||||||
|
endHeading,
|
||||||
|
arrow.endArrowhead
|
||||||
|
? FIXED_BINDING_DISTANCE * 6
|
||||||
|
: FIXED_BINDING_DISTANCE * 2,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
: endPointBounds;
|
: endPointBounds;
|
||||||
const boundsOverlap =
|
const boundsOverlap =
|
||||||
pointInsideBounds(
|
pointInsideBounds(
|
||||||
@@ -1407,7 +1358,7 @@ const getElbowArrowData = (
|
|||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.startArrowhead
|
(arrow.startArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? FIXED_BINDING_DISTANCE * 6
|
||||||
: FIXED_BINDING_DISTANCE),
|
: FIXED_BINDING_DISTANCE * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap
|
boundsOverlap
|
||||||
@@ -1423,29 +1374,13 @@ const getElbowArrowData = (
|
|||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.endArrowhead
|
(arrow.endArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? FIXED_BINDING_DISTANCE * 6
|
||||||
: FIXED_BINDING_DISTANCE),
|
: FIXED_BINDING_DISTANCE * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
hoveredStartElement
|
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||||
? aabbForElement(hoveredStartElement)
|
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||||
: startPointBounds,
|
|
||||||
hoveredEndElement ? aabbForElement(hoveredEndElement) : endPointBounds,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
debugDrawBounds(startElementBounds, {
|
|
||||||
permanent: false,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
debugDrawBounds(endElementBounds, {
|
|
||||||
permanent: false,
|
|
||||||
color: "green",
|
|
||||||
});
|
|
||||||
debugDrawBounds(dynamicAABBs, {
|
|
||||||
permanent: false,
|
|
||||||
color: "blue",
|
|
||||||
});
|
|
||||||
|
|
||||||
const startDonglePosition = getDonglePosition(
|
const startDonglePosition = getDonglePosition(
|
||||||
dynamicAABBs[0],
|
dynamicAABBs[0],
|
||||||
startHeading,
|
startHeading,
|
||||||
@@ -1716,11 +1651,11 @@ const generateDynamicAABBs = (
|
|||||||
a: Bounds,
|
a: Bounds,
|
||||||
b: Bounds,
|
b: Bounds,
|
||||||
common: Bounds,
|
common: Bounds,
|
||||||
startDifference: [number, number, number, number],
|
startDifference?: [number, number, number, number],
|
||||||
endDifference: [number, number, number, number],
|
endDifference?: [number, number, number, number],
|
||||||
disableSideHack: boolean,
|
disableSideHack?: boolean,
|
||||||
startElementBounds: Bounds,
|
startElementBounds?: Bounds | null,
|
||||||
endElementBounds: Bounds,
|
endElementBounds?: Bounds | null,
|
||||||
): Bounds[] => {
|
): Bounds[] => {
|
||||||
const startEl = startElementBounds ?? a;
|
const startEl = startElementBounds ?? a;
|
||||||
const endEl = endElementBounds ?? b;
|
const endEl = endElementBounds ?? b;
|
||||||
@@ -1800,24 +1735,15 @@ const generateDynamicAABBs = (
|
|||||||
(second[0] + second[2]) / 2,
|
(second[0] + second[2]) / 2,
|
||||||
(second[1] + second[3]) / 2,
|
(second[1] + second[3]) / 2,
|
||||||
];
|
];
|
||||||
if (
|
if (b[0] > a[2] && a[1] > b[3]) {
|
||||||
endElementBounds[0] > startElementBounds[2] &&
|
|
||||||
startElementBounds[1] > endElementBounds[3]
|
|
||||||
) {
|
|
||||||
// BOTTOM LEFT
|
// BOTTOM LEFT
|
||||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
vectorCross(
|
vectorCross(
|
||||||
vector(
|
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||||
startElementBounds[2] - endCenterX,
|
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||||
startElementBounds[1] - endCenterY,
|
|
||||||
),
|
|
||||||
vector(
|
|
||||||
startElementBounds[0] - endCenterX,
|
|
||||||
startElementBounds[3] - endCenterY,
|
|
||||||
),
|
|
||||||
) > 0
|
) > 0
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
@@ -1830,24 +1756,15 @@ const generateDynamicAABBs = (
|
|||||||
[first[0], cY, first[2], first[3]],
|
[first[0], cY, first[2], first[3]],
|
||||||
[second[0], second[1], second[2], cY],
|
[second[0], second[1], second[2], cY],
|
||||||
];
|
];
|
||||||
} else if (
|
} else if (a[2] < b[0] && a[3] < b[1]) {
|
||||||
startElementBounds[2] < endElementBounds[0] &&
|
|
||||||
startElementBounds[3] < endElementBounds[1]
|
|
||||||
) {
|
|
||||||
// TOP LEFT
|
// TOP LEFT
|
||||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
vectorCross(
|
vectorCross(
|
||||||
vector(
|
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||||
startElementBounds[0] - endCenterX,
|
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||||
startElementBounds[1] - endCenterY,
|
|
||||||
),
|
|
||||||
vector(
|
|
||||||
startElementBounds[2] - endCenterX,
|
|
||||||
startElementBounds[3] - endCenterY,
|
|
||||||
),
|
|
||||||
) > 0
|
) > 0
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
@@ -1860,24 +1777,15 @@ const generateDynamicAABBs = (
|
|||||||
[first[0], first[1], cX, first[3]],
|
[first[0], first[1], cX, first[3]],
|
||||||
[cX, second[1], second[2], second[3]],
|
[cX, second[1], second[2], second[3]],
|
||||||
];
|
];
|
||||||
} else if (
|
} else if (a[0] > b[2] && a[3] < b[1]) {
|
||||||
startElementBounds[0] > endElementBounds[2] &&
|
|
||||||
startElementBounds[3] < endElementBounds[1]
|
|
||||||
) {
|
|
||||||
// TOP RIGHT
|
// TOP RIGHT
|
||||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
vectorCross(
|
vectorCross(
|
||||||
vector(
|
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||||
startElementBounds[2] - endCenterX,
|
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||||
startElementBounds[1] - endCenterY,
|
|
||||||
),
|
|
||||||
vector(
|
|
||||||
startElementBounds[0] - endCenterX,
|
|
||||||
startElementBounds[3] - endCenterY,
|
|
||||||
),
|
|
||||||
) > 0
|
) > 0
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
@@ -1890,24 +1798,15 @@ const generateDynamicAABBs = (
|
|||||||
[first[0], first[1], first[2], cY],
|
[first[0], first[1], first[2], cY],
|
||||||
[second[0], cY, second[2], second[3]],
|
[second[0], cY, second[2], second[3]],
|
||||||
];
|
];
|
||||||
} else if (
|
} else if (a[0] > b[2] && a[1] > b[3]) {
|
||||||
startElementBounds[0] > endElementBounds[2] &&
|
|
||||||
startElementBounds[1] > endElementBounds[3]
|
|
||||||
) {
|
|
||||||
// BOTTOM RIGHT
|
// BOTTOM RIGHT
|
||||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
vectorCross(
|
vectorCross(
|
||||||
vector(
|
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||||
startElementBounds[0] - endCenterX,
|
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||||
startElementBounds[1] - endCenterY,
|
|
||||||
),
|
|
||||||
vector(
|
|
||||||
startElementBounds[2] - endCenterX,
|
|
||||||
startElementBounds[3] - endCenterY,
|
|
||||||
),
|
|
||||||
) > 0
|
) > 0
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
@@ -2189,11 +2088,16 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
): {
|
||||||
if (global.length === 0) {
|
points: LocalPoint[];
|
||||||
return {};
|
x: number;
|
||||||
}
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fixedSegments: readonly FixedSegment[] | null;
|
||||||
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
|
} => {
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
@@ -2281,10 +2185,7 @@ const removeElbowArrowShortSegments = (
|
|||||||
|
|
||||||
const prev = points[idx - 1];
|
const prev = points[idx - 1];
|
||||||
const prevDist = pointDistance(prev, p);
|
const prevDist = pointDistance(prev, p);
|
||||||
return (
|
return prevDist > DEDUP_TRESHOLD;
|
||||||
prevDist >
|
|
||||||
calculateDedupTreshhold(points[idx - 2] ?? prev, points[idx + 1] ?? p)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2387,16 +2288,13 @@ const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
|||||||
|
|
||||||
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
|
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
|
||||||
points: readonly P[],
|
points: readonly P[],
|
||||||
tolerance?: number,
|
tolerance: number = DEDUP_TRESHOLD,
|
||||||
) =>
|
) =>
|
||||||
points
|
points
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.map((p, i) => {
|
.map(
|
||||||
const t =
|
(p, i) =>
|
||||||
tolerance ??
|
Math.abs(p[0] - points[i][0]) < tolerance ||
|
||||||
calculateDedupTreshhold(points[i - 1] ?? points[i], points[i + 2] ?? p);
|
Math.abs(p[1] - points[i][1]) < tolerance,
|
||||||
return (
|
)
|
||||||
Math.abs(p[0] - points[i][0]) < t || Math.abs(p[1] - points[i][1]) < t
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.every(Boolean);
|
.every(Boolean);
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
store.shouldCaptureIncrement();
|
store.scheduleCapture();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -282,15 +282,6 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const aabbForPoints = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
points: Point[],
|
|
||||||
): Bounds => [
|
|
||||||
Math.min(...points.map((point) => point[0])),
|
|
||||||
Math.min(...points.map((point) => point[1])),
|
|
||||||
Math.max(...points.map((point) => point[0])),
|
|
||||||
Math.max(...points.map((point) => point[1])),
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the axis-aligned bounding box for a given element
|
* Get the axis-aligned bounding box for a given element
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,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 => {
|
||||||
@@ -285,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" ||
|
||||||
|
|||||||
@@ -412,11 +412,3 @@ export type NonDeletedSceneElementsMap = Map<
|
|||||||
export type ElementsMapOrArray =
|
export type ElementsMapOrArray =
|
||||||
| readonly ExcalidrawElement[]
|
| readonly ExcalidrawElement[]
|
||||||
| Readonly<ElementsMap>;
|
| Readonly<ElementsMap>;
|
||||||
|
|
||||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
|
||||||
export type ConvertibleLinearTypes =
|
|
||||||
| "line"
|
|
||||||
| "sharpArrow"
|
|
||||||
| "curvedArrow"
|
|
||||||
| "elbowArrow";
|
|
||||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
scene.mutateElement(arrow, {
|
h.app.scene.mutateElement(arrow, {
|
||||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,11 +295,11 @@ describe("elbow arrow ui", () => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
UI.updateInput(inputAngle, String("40"));
|
UI.updateInput(inputAngle, String("40"));
|
||||||
|
|
||||||
expect(arrow.points).toCloselyEqualPoints([
|
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[34.7791, 0],
|
[35, 0],
|
||||||
[34.7791, 164.67],
|
[35, 165],
|
||||||
[102.931, 164.67],
|
[103, 165],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -510,12 +510,12 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize(rectangle, "se", [-200, -150]);
|
UI.resize(rectangle, "se", [-200, -150]);
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -538,11 +538,11 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
types
|
types
|
||||||
|
.wrangler
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
|
|||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||||
expect(rec1.x).toBeCloseTo(97.8678, 0);
|
expect(rec1.x).toBeCloseTo(100, 0);
|
||||||
expect(rec1.y).toBeCloseTo(97.444, 0);
|
expect(rec1.y).toBeCloseTo(100, 0);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||||
expect(rec2.x).toBeCloseTo(218, 0);
|
expect(rec2.x).toBeCloseTo(220, 0);
|
||||||
expect(rec2.y).toBeCloseTo(247, 0);
|
expect(rec2.y).toBeCloseTo(250, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { t } from "../i18n";
|
|||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { Store } from "../store";
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import type { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
|
|
||||||
@@ -47,9 +46,9 @@ const executeHistoryAction = (
|
|||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History, store: Store) => Action;
|
type ActionCreator = (history: History) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
label: "buttons.undo",
|
label: "buttons.undo",
|
||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
@@ -57,11 +56,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
@@ -88,19 +83,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
label: "buttons.redo",
|
label: "buttons.redo",
|
||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, __, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getConversionTypeFromElements,
|
|
||||||
convertElementTypePopupAtom,
|
|
||||||
} from "../components/ConvertElementTypePopup";
|
|
||||||
import { editorJotaiStore } from "../editor-jotai";
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
@@ -140,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[];
|
||||||
@@ -196,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");
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
type EXPORT_IMAGE_TYPES,
|
type EXPORT_IMAGE_TYPES,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -432,7 +431,7 @@ import {
|
|||||||
} from "../components/hyperlink/Hyperlink";
|
} from "../components/hyperlink/Hyperlink";
|
||||||
|
|
||||||
import { Fonts } from "../fonts";
|
import { Fonts } from "../fonts";
|
||||||
import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
|
import { editorJotaiStore } from "../editor-jotai";
|
||||||
import { ImageSceneDataError } from "../errors";
|
import { ImageSceneDataError } from "../errors";
|
||||||
import {
|
import {
|
||||||
getSnapLinesAtPointer,
|
getSnapLinesAtPointer,
|
||||||
@@ -468,12 +467,6 @@ import { LassoTrail } from "../lasso";
|
|||||||
|
|
||||||
import { EraserTrail } from "../eraser";
|
import { EraserTrail } from "../eraser";
|
||||||
|
|
||||||
import ConvertElementTypePopup, {
|
|
||||||
getConversionTypeFromElements,
|
|
||||||
convertElementTypePopupAtom,
|
|
||||||
convertElementTypes,
|
|
||||||
} from "./ConvertElementTypePopup";
|
|
||||||
|
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
||||||
@@ -505,6 +498,7 @@ import type { ExportedElements } from "../data";
|
|||||||
import type { ContextMenuItems } from "./ContextMenu";
|
import type { ContextMenuItems } from "./ContextMenu";
|
||||||
import type { FileSystemHandle } from "../data/filesystem";
|
import type { FileSystemHandle } from "../data/filesystem";
|
||||||
import type { ExcalidrawElementSkeleton } from "../data/transform";
|
import type { ExcalidrawElementSkeleton } from "../data/transform";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppProps,
|
AppProps,
|
||||||
@@ -762,7 +756,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.visibleElements = [];
|
this.visibleElements = [];
|
||||||
|
|
||||||
this.store = new Store();
|
this.store = new Store();
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
if (excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
const api: ExcalidrawImperativeAPI = {
|
const api: ExcalidrawImperativeAPI = {
|
||||||
@@ -772,6 +766,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
addFiles: this.addFiles,
|
addFiles: this.addFiles,
|
||||||
resetScene: this.resetScene,
|
resetScene: this.resetScene,
|
||||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||||
|
store: this.store,
|
||||||
history: {
|
history: {
|
||||||
clear: this.resetHistory,
|
clear: this.resetHistory,
|
||||||
},
|
},
|
||||||
@@ -792,6 +787,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
updateFrameRendering: this.updateFrameRendering,
|
updateFrameRendering: this.updateFrameRendering,
|
||||||
toggleSidebar: this.toggleSidebar,
|
toggleSidebar: this.toggleSidebar,
|
||||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||||
|
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||||
@@ -810,26 +806,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.fonts = new Fonts(this.scene);
|
this.fonts = new Fonts(this.scene);
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
this.actionManager.registerAction(
|
this.actionManager.registerAction(createUndoAction(this.history));
|
||||||
createUndoAction(this.history, this.store),
|
this.actionManager.registerAction(createRedoAction(this.history));
|
||||||
);
|
|
||||||
this.actionManager.registerAction(
|
|
||||||
createRedoAction(this.history, this.store),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
|
||||||
atom: WritableAtom<Value, Args, Result>,
|
|
||||||
...args: Args
|
|
||||||
): Result => {
|
|
||||||
const result = editorJotaiStore.set(atom, ...args);
|
|
||||||
this.triggerRender();
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onWindowMessage(event: MessageEvent) {
|
private onWindowMessage(event: MessageEvent) {
|
||||||
if (
|
if (
|
||||||
event.origin !== "https://player.vimeo.com" &&
|
event.origin !== "https://player.vimeo.com" &&
|
||||||
@@ -1598,9 +1581,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const firstSelectedElement = selectedElements[0];
|
const firstSelectedElement = selectedElements[0];
|
||||||
|
|
||||||
const showShapeSwitchPanel =
|
|
||||||
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("excalidraw excalidraw-container", {
|
className={clsx("excalidraw excalidraw-container", {
|
||||||
@@ -1875,9 +1855,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.renderFrameNames()}
|
{this.renderFrameNames()}
|
||||||
{showShapeSwitchPanel && (
|
|
||||||
<ConvertElementTypePopup app={this} />
|
|
||||||
)}
|
|
||||||
</ExcalidrawActionManagerContext.Provider>
|
</ExcalidrawActionManagerContext.Provider>
|
||||||
{this.renderEmbeddables()}
|
{this.renderEmbeddables()}
|
||||||
</ExcalidrawElementsContext.Provider>
|
</ExcalidrawElementsContext.Provider>
|
||||||
@@ -1899,6 +1876,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return this.scene.getElementsIncludingDeleted();
|
return this.scene.getElementsIncludingDeleted();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public getSceneElementsMapIncludingDeleted = () => {
|
||||||
|
return this.scene.getElementsMapIncludingDeleted();
|
||||||
|
};
|
||||||
|
|
||||||
public getSceneElements = () => {
|
public getSceneElements = () => {
|
||||||
return this.scene.getNonDeletedElements();
|
return this.scene.getNonDeletedElements();
|
||||||
};
|
};
|
||||||
@@ -2159,7 +2140,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
||||||
this.updateEditorAtom(activeEyeDropperAtom, {
|
editorJotaiStore.set(activeEyeDropperAtom, {
|
||||||
swapPreviewOnAlt: true,
|
swapPreviewOnAlt: true,
|
||||||
colorPickerType:
|
colorPickerType:
|
||||||
type === "stroke" ? "elementStroke" : "elementBackground",
|
type === "stroke" ? "elementStroke" : "elementBackground",
|
||||||
@@ -2215,11 +2196,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
|
this.store.scheduleAction(actionResult.captureUpdate);
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
@@ -2292,10 +2269,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!didUpdate) {
|
||||||
!didUpdate &&
|
|
||||||
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
|
||||||
) {
|
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2548,7 +2522,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
this.history.record(increment);
|
||||||
|
this.props.onIncrement?.(increment);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.onUpdate(this.triggerRender);
|
this.scene.onUpdate(this.triggerRender);
|
||||||
@@ -3358,7 +3333,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addMissingFiles(opts.files);
|
this.addMissingFiles(opts.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
|
|
||||||
const nextElementsToSelect =
|
const nextElementsToSelect =
|
||||||
excludeElementsInFramesFromSelection(duplicatedElements);
|
excludeElementsInFramesFromSelection(duplicatedElements);
|
||||||
@@ -3619,7 +3594,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppState: React.Component<any, AppState>["setState"] = (
|
setAppState: React.Component<any, AppState>["setState"] = (
|
||||||
@@ -3975,52 +3950,48 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
*/
|
*/
|
||||||
captureUpdate?: SceneData["captureUpdate"];
|
captureUpdate?: SceneData["captureUpdate"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
// flush all pending updates (if any), most of the time it should be a no-op
|
||||||
|
flushSync(() => {});
|
||||||
|
|
||||||
if (
|
// flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction`
|
||||||
sceneData.captureUpdate &&
|
flushSync(() => {
|
||||||
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
const { elements, appState, collaborators, captureUpdate } = sceneData;
|
||||||
) {
|
|
||||||
const prevCommittedAppState = this.store.snapshot.appState;
|
|
||||||
const prevCommittedElements = this.store.snapshot.elements;
|
|
||||||
|
|
||||||
const nextCommittedAppState = sceneData.appState
|
const nextElements = elements
|
||||||
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
? syncInvalidIndices(elements)
|
||||||
: prevCommittedAppState;
|
: undefined;
|
||||||
|
|
||||||
const nextCommittedElements = sceneData.elements
|
if (captureUpdate) {
|
||||||
? this.store.filterUncomittedElements(
|
const prevCommittedAppState = this.store.snapshot.appState;
|
||||||
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
const prevCommittedElements = this.store.snapshot.elements;
|
||||||
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
|
||||||
)
|
|
||||||
: prevCommittedElements;
|
|
||||||
|
|
||||||
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
|
const nextCommittedAppState = appState
|
||||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
: prevCommittedAppState;
|
||||||
this.store.captureIncrement(
|
|
||||||
nextCommittedElements,
|
const nextCommittedElements = elements
|
||||||
nextCommittedAppState,
|
? this.store.filterUncomittedElements(
|
||||||
);
|
this.scene.getElementsMapIncludingDeleted(), // only used to detect uncomitted local elements
|
||||||
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
|
arrayToMap(nextElements ?? []), // we expect all (already reconciled) elements
|
||||||
this.store.updateSnapshot(
|
)
|
||||||
nextCommittedElements,
|
: prevCommittedElements;
|
||||||
nextCommittedAppState,
|
|
||||||
);
|
this.store.scheduleAction(captureUpdate);
|
||||||
|
this.store.commit(nextCommittedElements, nextCommittedAppState);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneData.appState) {
|
if (appState) {
|
||||||
this.setState(sceneData.appState);
|
this.setState(appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.elements) {
|
if (nextElements) {
|
||||||
this.scene.replaceAllElements(nextElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.collaborators) {
|
if (collaborators) {
|
||||||
this.setState({ collaborators: sceneData.collaborators });
|
this.setState({ collaborators });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4178,40 +4149,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape switching
|
|
||||||
if (event.key === KEYS.ESCAPE) {
|
|
||||||
this.updateEditorAtom(convertElementTypePopupAtom, null);
|
|
||||||
} else if (
|
|
||||||
event.key === KEYS.TAB &&
|
|
||||||
(document.activeElement === this.excalidrawContainerRef?.current ||
|
|
||||||
document.activeElement?.classList.contains(
|
|
||||||
CLASSES.CONVERT_ELEMENT_TYPE_POPUP,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const conversionType =
|
|
||||||
getConversionTypeFromElements(selectedElements);
|
|
||||||
|
|
||||||
if (
|
|
||||||
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
convertElementTypes(this, {
|
|
||||||
conversionType,
|
|
||||||
direction: event.shiftKey ? "left" : "right",
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conversionType) {
|
|
||||||
this.updateEditorAtom(convertElementTypePopupAtom, {
|
|
||||||
type: "panel",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.key === KEYS.ESCAPE &&
|
event.key === KEYS.ESCAPE &&
|
||||||
this.flowChartCreator.isCreatingChart
|
this.flowChartCreator.isCreatingChart
|
||||||
@@ -4519,7 +4456,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.editingLinearElement.elementId !==
|
this.state.editingLinearElement.elementId !==
|
||||||
selectedElements[0].id
|
selectedElements[0].id
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
if (!isElbowArrow(selectedElement)) {
|
if (!isElbowArrow(selectedElement)) {
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
@@ -4670,7 +4607,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||||
) {
|
) {
|
||||||
this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
|
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
}
|
}
|
||||||
|
|
||||||
// eye dropper
|
// eye dropper
|
||||||
@@ -4845,7 +4782,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (nextActiveTool.type === "freedraw") {
|
if (nextActiveTool.type === "freedraw") {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextActiveTool.type === "lasso") {
|
if (nextActiveTool.type === "lasso") {
|
||||||
@@ -5062,7 +4999,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!isDeleted || isExistingElement) {
|
if (!isDeleted || isExistingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -5475,7 +5412,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: image.id,
|
croppingElementId: image.id,
|
||||||
});
|
});
|
||||||
@@ -5483,7 +5420,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private finishImageCropping = () => {
|
private finishImageCropping = () => {
|
||||||
if (this.state.croppingElementId) {
|
if (this.state.croppingElementId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
});
|
});
|
||||||
@@ -5518,7 +5455,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
selectedElements[0].id) &&
|
selectedElements[0].id) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
@@ -5546,7 +5483,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
if (midPoint && midPoint > -1) {
|
if (midPoint && midPoint > -1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
LinearElementEditor.deleteFixedSegment(
|
LinearElementEditor.deleteFixedSegment(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
this.scene,
|
this.scene,
|
||||||
@@ -5608,7 +5545,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||||
|
|
||||||
if (selectedGroupId) {
|
if (selectedGroupId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -6419,11 +6356,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
focus: false,
|
focus: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
this.updateEditorAtom(searchItemInFocusAtom, null);
|
editorJotaiStore.set(searchItemInFocusAtom, null);
|
||||||
}
|
|
||||||
|
|
||||||
if (editorJotaiStore.get(convertElementTypePopupAtom)) {
|
|
||||||
this.updateEditorAtom(convertElementTypePopupAtom, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// since contextMenu options are potentially evaluated on each render,
|
// since contextMenu options are potentially evaluated on each render,
|
||||||
@@ -9131,7 +9064,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (isLinearElement(newElement)) {
|
if (isLinearElement(newElement)) {
|
||||||
if (newElement!.points.length > 1) {
|
if (newElement!.points.length > 1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
const pointerCoords = viewportCoordsToSceneCoords(
|
const pointerCoords = viewportCoordsToSceneCoords(
|
||||||
childEvent,
|
childEvent,
|
||||||
@@ -9404,7 +9337,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement) {
|
if (resizingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||||
@@ -9744,7 +9677,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.selectedElementIds,
|
this.state.selectedElementIds,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -9837,7 +9770,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.elementsPendingErasure = new Set();
|
this.elementsPendingErasure = new Set();
|
||||||
|
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.scene.replaceAllElements(elements);
|
this.scene.replaceAllElements(elements);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -10516,9 +10449,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (ret.type === MIME_TYPES.excalidraw) {
|
if (ret.type === MIME_TYPES.excalidraw) {
|
||||||
// restore the fractional indices by mutating elements
|
// restore the fractional indices by mutating elements
|
||||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||||
|
|
||||||
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
||||||
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
this.store.scheduleAction(CaptureUpdateAction.NEVER);
|
||||||
|
this.store.commit(arrayToMap(elements), this.state);
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
|
||||||
|
|
||||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -412,14 +410,6 @@ function CommandPaletteInner({
|
|||||||
actionManager.executeAction(actionToggleSearchMenu);
|
actionManager.executeAction(actionToggleSearchMenu);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("labels.shapeSwitch"),
|
|
||||||
category: DEFAULT_CATEGORIES.elements,
|
|
||||||
icon: boltIcon,
|
|
||||||
perform: () => {
|
|
||||||
actionManager.executeAction(actionToggleShapeSwitch);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("labels.changeStroke"),
|
label: t("labels.changeStroke"),
|
||||||
keywords: ["color", "outline"],
|
keywords: ["color", "outline"],
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
@import "../css//variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.ConvertElementTypePopup {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--island-bg-color);
|
|
||||||
box-shadow: var(--shadow-island);
|
|
||||||
padding: 0.5rem;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ const Header = () => (
|
|||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
href="https://docs.excalidraw.com"
|
href="https://docs.excalidraw.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||||
{t("helpDialog.documentation")}
|
{t("helpDialog.documentation")}
|
||||||
@@ -30,7 +30,7 @@ const Header = () => (
|
|||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
href="https://plus.excalidraw.com/blog"
|
href="https://plus.excalidraw.com/blog"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||||
{t("helpDialog.blog")}
|
{t("helpDialog.blog")}
|
||||||
@@ -247,11 +247,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("toolBar.link")}
|
label={t("toolBar.link")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("toolBar.convertElementType")}
|
|
||||||
shortcuts={["Tab", "Shift+Tab"]}
|
|
||||||
isOr={true}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
<ShortcutIsland
|
<ShortcutIsland
|
||||||
className="HelpDialog__island--view"
|
className="HelpDialog__island--view"
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ const PublishLibrary = ({
|
|||||||
<a
|
<a
|
||||||
href="https://libraries.excalidraw.com"
|
href="https://libraries.excalidraw.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{el}
|
{el}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import type Scene from "@excalidraw/element/Scene";
|
|||||||
|
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
|
|
||||||
import { updateBindings } from "../../../element/src/binding";
|
|
||||||
|
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
|||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import {
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
getAtomicUnits,
|
|
||||||
getStepSizedValue,
|
|
||||||
isPropertyEditable,
|
|
||||||
STEP_SIZE,
|
|
||||||
} from "./utils";
|
|
||||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
@@ -31,6 +26,8 @@ interface MultiPositionProps {
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
const moveElements = (
|
const moveElements = (
|
||||||
property: MultiPositionProps["property"],
|
property: MultiPositionProps["property"],
|
||||||
changeInTopX: number,
|
changeInTopX: number,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
|||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import { getStepSizedValue, moveElement, STEP_SIZE } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
@@ -24,6 +24,8 @@ interface PositionProps {
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STEP_SIZE = 10;
|
||||||
|
|
||||||
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
instantChange,
|
instantChange,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bindOrUnbindLinearElements,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "@excalidraw/element/binding";
|
||||||
import { getBoundTextElement } from "@excalidraw/element/textElement";
|
import { getBoundTextElement } from "@excalidraw/element/textElement";
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
@@ -12,8 +17,6 @@ import {
|
|||||||
isInGroup,
|
isInGroup,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -24,8 +27,6 @@ import type {
|
|||||||
|
|
||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import { updateBindings } from "../../../element/src/binding";
|
|
||||||
|
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
export type StatsInputProperty =
|
export type StatsInputProperty =
|
||||||
@@ -38,7 +39,6 @@ export type StatsInputProperty =
|
|||||||
| "gridStep";
|
| "gridStep";
|
||||||
|
|
||||||
export const SMALLEST_DELTA = 0.01;
|
export const SMALLEST_DELTA = 0.01;
|
||||||
export const STEP_SIZE = 10;
|
|
||||||
|
|
||||||
export const isPropertyEditable = (
|
export const isPropertyEditable = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
@@ -172,68 +172,6 @@ export const moveElement = (
|
|||||||
{ informMutation: shouldInformMutation, isDragging: false },
|
{ informMutation: shouldInformMutation, isDragging: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFrameLikeElement(originalElement)) {
|
|
||||||
const originalChildren = getFrameChildren(
|
|
||||||
originalElementsMap,
|
|
||||||
originalElement.id,
|
|
||||||
);
|
|
||||||
originalChildren.forEach((child) => {
|
|
||||||
const latestChildElement = elementsMap.get(child.id);
|
|
||||||
|
|
||||||
if (!latestChildElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [childCX, childCY] = [
|
|
||||||
child.x + child.width / 2,
|
|
||||||
child.y + child.height / 2,
|
|
||||||
];
|
|
||||||
const [childTopLeftX, childTopLeftY] = pointRotateRads(
|
|
||||||
pointFrom(child.x, child.y),
|
|
||||||
pointFrom(childCX, childCY),
|
|
||||||
child.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const childNewTopLeftX = Math.round(childTopLeftX + changeInX);
|
|
||||||
const childNewTopLeftY = Math.round(childTopLeftY + changeInY);
|
|
||||||
|
|
||||||
const [childX, childY] = pointRotateRads(
|
|
||||||
pointFrom(childNewTopLeftX, childNewTopLeftY),
|
|
||||||
pointFrom(childCX + changeInX, childCY + changeInY),
|
|
||||||
-child.angle as Radians,
|
|
||||||
);
|
|
||||||
|
|
||||||
scene.mutateElement(
|
|
||||||
latestChildElement,
|
|
||||||
{
|
|
||||||
x: childX,
|
|
||||||
y: childY,
|
|
||||||
},
|
|
||||||
{ informMutation: shouldInformMutation, isDragging: false },
|
|
||||||
);
|
|
||||||
updateBindings(latestChildElement, scene, {
|
|
||||||
simultaneouslyUpdated: originalChildren,
|
|
||||||
});
|
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(
|
|
||||||
latestChildElement,
|
|
||||||
originalElementsMap,
|
|
||||||
);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
|
||||||
latestBoundTextElement &&
|
|
||||||
scene.mutateElement(
|
|
||||||
latestBoundTextElement,
|
|
||||||
{
|
|
||||||
x: boundTextElement.x + changeInX,
|
|
||||||
y: boundTextElement.y + changeInY,
|
|
||||||
},
|
|
||||||
{ informMutation: shouldInformMutation, isDragging: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAtomicUnits = (
|
export const getAtomicUnits = (
|
||||||
@@ -256,3 +194,19 @@ export const getAtomicUnits = (
|
|||||||
});
|
});
|
||||||
return _atomicUnits;
|
return _atomicUnits;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const DropdownMenuItemLink = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
className = "",
|
className = "",
|
||||||
selected,
|
selected,
|
||||||
rel = "noopener",
|
rel = "noreferrer",
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -31,12 +31,11 @@ const DropdownMenuItemLink = ({
|
|||||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/jsx-no-target-blank
|
|
||||||
<a
|
<a
|
||||||
{...rest}
|
{...rest}
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel={rel || "noopener"}
|
rel="noreferrer"
|
||||||
className={getDropdownMenuItemClassName(className, selected)}
|
className={getDropdownMenuItemClassName(className, selected)}
|
||||||
title={rest.title ?? rest["aria-label"]}
|
title={rest.title ?? rest["aria-label"]}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const WelcomeScreenMenuItemLink = ({
|
|||||||
className={`welcome-screen-menu-item ${className}`}
|
className={`welcome-screen-menu-item ${className}`}
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
|
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { bumpVersion } from "@excalidraw/element/mutateElement";
|
|||||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||||
import { detectLineHeight } from "@excalidraw/element/textMeasurements";
|
import { detectLineHeight } from "@excalidraw/element/textMeasurements";
|
||||||
import {
|
import {
|
||||||
isArrowBoundToElement,
|
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
@@ -595,7 +594,8 @@ export const restoreElements = (
|
|||||||
return restoredElements.map((element) => {
|
return restoredElements.map((element) => {
|
||||||
if (
|
if (
|
||||||
isElbowArrow(element) &&
|
isElbowArrow(element) &&
|
||||||
!isArrowBoundToElement(element) &&
|
element.startBinding == null &&
|
||||||
|
element.endBinding == null &&
|
||||||
!validateElbowPoints(element.points)
|
!validateElbowPoints(element.points)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ import type {
|
|||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getObservedAppState } from "./store";
|
import { getObservedAppState, StoreSnapshot } from "./store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -74,7 +74,7 @@ import type {
|
|||||||
*
|
*
|
||||||
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
||||||
*/
|
*/
|
||||||
class Delta<T> {
|
export class Delta<T> {
|
||||||
private constructor(
|
private constructor(
|
||||||
public readonly deleted: Partial<T>,
|
public readonly deleted: Partial<T>,
|
||||||
public readonly inserted: Partial<T>,
|
public readonly inserted: Partial<T>,
|
||||||
@@ -386,6 +386,8 @@ class Delta<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CFDO: order the keys based on the most common ones to change
|
||||||
|
// (i.e. x/y, width/height, isDeleted, etc.) for quick exit
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const object1Value = object1[key as keyof T];
|
const object1Value = object1[key as keyof T];
|
||||||
const object2Value = object2[key as keyof T];
|
const object2Value = object2[key as keyof T];
|
||||||
@@ -409,51 +411,56 @@ class Delta<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the modifications captured as `Delta`/s.
|
* Encapsulates a set of application-level `Delta`s.
|
||||||
*/
|
*/
|
||||||
interface Change<T> {
|
interface DeltaContainer<T> {
|
||||||
/**
|
/**
|
||||||
* Inverses the `Delta`s inside while creating a new `Change`.
|
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
|
||||||
*/
|
*/
|
||||||
inverse(): Change<T>;
|
inverse(): DeltaContainer<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the `Change` to the previous object.
|
* Applies the `Delta`s to the previous object.
|
||||||
*
|
*
|
||||||
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
|
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
|
||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether there are actually `Delta`s.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
isEmpty(): boolean;
|
isEmpty(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateChange implements Change<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
nextAppState: T,
|
nextAppState: T,
|
||||||
): AppStateChange {
|
): AppStateDelta {
|
||||||
const delta = Delta.calculate(
|
const delta = Delta.calculate(
|
||||||
prevAppState,
|
prevAppState,
|
||||||
nextAppState,
|
nextAppState,
|
||||||
undefined,
|
undefined,
|
||||||
AppStateChange.postProcess,
|
AppStateDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new AppStateChange(delta);
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||||
|
const { delta } = appStateDeltaDTO;
|
||||||
|
return new AppStateDelta(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return new AppStateChange(Delta.create({}, {}));
|
return new AppStateDelta(Delta.create({}, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): AppStateChange {
|
public inverse(): AppStateDelta {
|
||||||
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
return new AppStateChange(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
@@ -530,7 +537,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
return [nextAppState, constainsVisibleChanges];
|
return [nextAppState, constainsVisibleChanges];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// shouldn't really happen, but just in case
|
// shouldn't really happen, but just in case
|
||||||
console.error(`Couldn't apply appstate change`, e);
|
console.error(`Couldn't apply appstate delta`, e);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -594,13 +601,13 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
const nextObservedAppState = getObservedAppState(nextAppState);
|
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containsElementsDifference = Delta.isRightDifferent(
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
@@ -615,8 +622,8 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
if (containsElementsDifference) {
|
if (containsElementsDifference) {
|
||||||
// filter invisible changes on each iteration
|
// filter invisible changes on each iteration
|
||||||
const changedElementsProps = Delta.getRightDifferences(
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
) as Array<keyof ObservedElementsAppState>;
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
let nonDeletedGroupIds = new Set<string>();
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
@@ -633,7 +640,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
for (const key of changedElementsProps) {
|
for (const key of changedElementsProps) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "selectedElementIds":
|
case "selectedElementIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nextElements,
|
nextElements,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@@ -641,7 +648,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedGroupIds":
|
case "selectedGroupIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nonDeletedGroupIds,
|
nonDeletedGroupIds,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@@ -677,7 +684,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
break;
|
break;
|
||||||
case "selectedLinearElementId":
|
case "selectedLinearElementId":
|
||||||
case "editingLinearElementId":
|
case "editingLinearElementId":
|
||||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
const linearElement = nextAppState[appStateKey];
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!linearElement) {
|
if (!linearElement) {
|
||||||
@@ -814,59 +821,72 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
|
||||||
ElementUpdate<Ordered<T>>,
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
||||||
"seed"
|
ElementUpdate<Ordered<T>>;
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
* 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.
|
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
||||||
*/
|
*/
|
||||||
export class ElementsChange implements Change<SceneElementsMap> {
|
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly added: Map<string, Delta<ElementPartial>>,
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly removed: Map<string, Delta<ElementPartial>>,
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly updated: Map<string, Delta<ElementPartial>>,
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
added: Map<string, Delta<ElementPartial>>,
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
removed: Map<string, Delta<ElementPartial>>,
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
updated: Map<string, Delta<ElementPartial>>,
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
options = { shouldRedistribute: false },
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let change: ElementsChange;
|
const { shouldRedistribute } = options;
|
||||||
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
if (options.shouldRedistribute) {
|
if (shouldRedistribute) {
|
||||||
const nextAdded = new Map<string, Delta<ElementPartial>>();
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextUpdated = new Map<string, Delta<ElementPartial>>();
|
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
const deltas = [...added, ...removed, ...updated];
|
const deltas = [
|
||||||
|
...Object.entries(added),
|
||||||
|
...Object.entries(removed),
|
||||||
|
...Object.entries(updated),
|
||||||
|
];
|
||||||
|
|
||||||
for (const [id, delta] of deltas) {
|
for (const [id, delta] of deltas) {
|
||||||
if (this.satisfiesAddition(delta)) {
|
if (this.satisfiesAddition(delta)) {
|
||||||
nextAdded.set(id, delta);
|
nextAdded[id] = delta;
|
||||||
} else if (this.satisfiesRemoval(delta)) {
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
nextRemoved.set(id, delta);
|
nextRemoved[id] = delta;
|
||||||
} else {
|
} else {
|
||||||
nextUpdated.set(id, delta);
|
nextUpdated[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
} else {
|
} else {
|
||||||
change = new ElementsChange(added, removed, updated);
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return change;
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static satisfiesAddition = ({
|
private static satisfiesAddition = ({
|
||||||
@@ -888,17 +908,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
change: ElementsChange,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
satifies: (delta: Delta<ElementPartial>) => boolean,
|
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||||
) {
|
) {
|
||||||
for (const [id, delta] of change[type].entries()) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (!satifies(delta)) {
|
if (!satifies(delta)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
delta,
|
delta,
|
||||||
);
|
);
|
||||||
throw new Error(`ElementsChange invariant broken for element "${id}".`);
|
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,19 +929,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param prevElements - Map representing the previous state of elements.
|
* @param prevElements - Map representing the previous state of elements.
|
||||||
* @param nextElements - Map representing the next state of elements.
|
* @param nextElements - Map representing the next state of elements.
|
||||||
*
|
*
|
||||||
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
|
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
|
||||||
*/
|
*/
|
||||||
public static calculate<T extends OrderedExcalidrawElement>(
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
prevElements: Map<string, T>,
|
prevElements: Map<string, T>,
|
||||||
nextElements: Map<string, T>,
|
nextElements: Map<string, T>,
|
||||||
): ElementsChange {
|
): ElementsDelta {
|
||||||
if (prevElements === nextElements) {
|
if (prevElements === nextElements) {
|
||||||
return ElementsChange.empty();
|
return ElementsDelta.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = new Map<string, Delta<ElementPartial>>();
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
const removed = new Map<string, Delta<ElementPartial>>();
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
const updated = new Map<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
|
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
||||||
for (const prevElement of prevElements.values()) {
|
for (const prevElement of prevElements.values()) {
|
||||||
@@ -934,10 +954,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed.set(prevElement.id, delta);
|
removed[prevElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,10 +974,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -966,8 +986,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.calculate<ElementPartial>(
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
prevElement,
|
prevElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
ElementsChange.postProcess,
|
ElementsDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -978,9 +998,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
// notice that other props could have been updated as well
|
// notice that other props could have been updated as well
|
||||||
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
} else {
|
} else {
|
||||||
removed.set(nextElement.id, delta);
|
removed[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -988,24 +1008,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// making sure there are at least some changes
|
// making sure there are at least some changes
|
||||||
if (!Delta.isEmpty(delta)) {
|
if (!Delta.isEmpty(delta)) {
|
||||||
updated.set(nextElement.id, delta);
|
updated[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated);
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return ElementsChange.create(new Map(), new Map(), new Map());
|
return ElementsDelta.create({}, {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): ElementsChange {
|
public inverse(): ElementsDelta {
|
||||||
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@@ -1016,14 +1036,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const updated = inverseInternal(this.updated);
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
// notice we inverse removed with added not to break the invariants
|
// notice we inverse removed with added not to break the invariants
|
||||||
return ElementsChange.create(removed, added, updated);
|
// notice we force generate a new id
|
||||||
|
return ElementsDelta.create(removed, added, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
public isEmpty(): boolean {
|
||||||
return (
|
return (
|
||||||
this.added.size === 0 &&
|
Object.keys(this.added).length === 0 &&
|
||||||
this.removed.size === 0 &&
|
Object.keys(this.removed).length === 0 &&
|
||||||
this.updated.size === 0
|
Object.keys(this.updated).length === 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1055,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
* @returns new instance with modified delta/s
|
* @returns new instance with modified delta/s
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
@@ -1055,11 +1079,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyLatestChangesInternal = (
|
const applyLatestChangesInternal = (
|
||||||
deltas: Map<string, Delta<ElementPartial>>,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
|
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
const existingElement = elements.get(id);
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
if (existingElement) {
|
if (existingElement) {
|
||||||
@@ -1067,12 +1091,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
delta.deleted,
|
delta.deleted,
|
||||||
delta.inserted,
|
delta.inserted,
|
||||||
modifier(existingElement),
|
modifier(existingElement),
|
||||||
"inserted",
|
modifierOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
modifiedDeltas.set(id, modifiedDelta);
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
} else {
|
} else {
|
||||||
modifiedDeltas.set(id, delta);
|
modifiedDeltas[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,14 +1107,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const removed = applyLatestChangesInternal(this.removed);
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
const updated = applyLatestChangesInternal(this.updated);
|
const updated = applyLatestChangesInternal(this.updated);
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated, {
|
return ElementsDelta.create(added, removed, updated, {
|
||||||
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
elementsSnapshot: Map<
|
||||||
|
string,
|
||||||
|
OrderedExcalidrawElement
|
||||||
|
> = StoreSnapshot.empty().elements,
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@@ -1102,15 +1129,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsChange.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas("added", this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
@@ -1122,7 +1149,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
...affectedElements,
|
...affectedElements,
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Couldn't apply elements change`, e);
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -1138,7 +1165,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
try {
|
try {
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
nextElements = ElementsChange.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
@@ -1149,9 +1176,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
// so we are creating a temp scene just to query and mutate elements
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
const tempScene = new Scene(nextElements);
|
const tempScene = new Scene(nextElements);
|
||||||
|
|
||||||
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
ElementsChange.redrawBoundArrows(tempScene, changedElements);
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1166,36 +1193,42 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createApplier = (
|
private static createApplier =
|
||||||
nextElements: SceneElementsMap,
|
(
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
nextElements: SceneElementsMap,
|
||||||
flags: {
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
containsVisibleDifference: boolean;
|
flags: {
|
||||||
containsZindexDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
},
|
containsZindexDifference: boolean;
|
||||||
) => {
|
},
|
||||||
const getElement = ElementsChange.createGetter(
|
) =>
|
||||||
nextElements,
|
(
|
||||||
snapshot,
|
type: "added" | "removed" | "updated",
|
||||||
flags,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
);
|
) => {
|
||||||
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
|
nextElements,
|
||||||
|
snapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
return (deltas: Map<string, Delta<ElementPartial>>) =>
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
|
|
||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(newElement.id, newElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map<string, OrderedExcalidrawElement>());
|
}, new Map<string, OrderedExcalidrawElement>());
|
||||||
};
|
};
|
||||||
|
|
||||||
private static createGetter =
|
private static createGetter =
|
||||||
(
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
@@ -1221,6 +1254,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
flags.containsVisibleDifference = true;
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,6 +1299,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CFDO: this looks wrong
|
||||||
if (isImageElement(element)) {
|
if (isImageElement(element)) {
|
||||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
// we want to override `crop` only if modified so that we don't reset
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
@@ -1272,8 +1315,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
if (!flags.containsVisibleDifference) {
|
if (!flags.containsVisibleDifference) {
|
||||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
||||||
const { index, ...rest } = directlyApplicablePartial;
|
const { index, ...rest } = directlyApplicablePartial;
|
||||||
const containsVisibleDifference =
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
ElementsChange.checkForVisibleDifference(element, rest);
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
flags.containsVisibleDifference = containsVisibleDifference;
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
}
|
}
|
||||||
@@ -1316,6 +1361,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* Resolves conflicts for all previously added, removed and updated elements.
|
* Resolves conflicts for all previously added, removed and updated elements.
|
||||||
* Updates the previous deltas with all the changes after conflict resolution.
|
* Updates the previous deltas with all the changes after conflict resolution.
|
||||||
*
|
*
|
||||||
|
* // CFDO: revisit since arrow seem often redrawn incorrectly
|
||||||
|
*
|
||||||
* @returns all elements affected by the conflict resolution
|
* @returns all elements affected by the conflict resolution
|
||||||
*/
|
*/
|
||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
@@ -1354,20 +1401,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
||||||
for (const [id] of this.removed) {
|
for (const id of Object.keys(this.removed)) {
|
||||||
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
|
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
|
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
||||||
for (const [id] of this.added) {
|
for (const id of Object.keys(this.added)) {
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
||||||
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
|
for (const [id] of Array.from(Object.entries(this.updated)).filter(
|
||||||
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
([_, delta]) =>
|
||||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||||
),
|
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||||
|
),
|
||||||
)) {
|
)) {
|
||||||
const updatedElement = nextElements.get(id);
|
const updatedElement = nextElements.get(id);
|
||||||
if (!updatedElement || updatedElement.isDeleted) {
|
if (!updatedElement || updatedElement.isDeleted) {
|
||||||
@@ -1375,7 +1423,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter only previous elements, which were now affected
|
// filter only previous elements, which were now affected
|
||||||
@@ -1385,21 +1433,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
// technically we could do better here if perf. would become an issue
|
||||||
const { added, removed, updated } = ElementsChange.calculate(
|
const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
prevAffectedElements,
|
prevAffectedElements,
|
||||||
nextAffectedElements,
|
nextAffectedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of added) {
|
for (const [id, delta] of Object.entries(added)) {
|
||||||
this.added.set(id, delta);
|
this.added[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of removed) {
|
for (const [id, delta] of Object.entries(removed)) {
|
||||||
this.removed.set(id, delta);
|
this.removed[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of updated) {
|
for (const [id, delta] of Object.entries(updated)) {
|
||||||
this.updated.set(id, delta);
|
this.updated[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
@@ -1572,7 +1620,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess elements change deltas.`);
|
console.error(`Couldn't postprocess elements delta.`);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -1585,8 +1633,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
): ElementPartial {
|
): ElementPartial {
|
||||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
partial;
|
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial;
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import {
|
import { atom, createStore, type PrimitiveAtom } from "jotai";
|
||||||
atom,
|
|
||||||
createStore,
|
|
||||||
type PrimitiveAtom,
|
|
||||||
type WritableAtom,
|
|
||||||
} from "jotai";
|
|
||||||
import { createIsolation } from "jotai-scope";
|
import { createIsolation } from "jotai-scope";
|
||||||
|
|
||||||
const jotai = createIsolation();
|
const jotai = createIsolation();
|
||||||
|
|
||||||
export { atom, PrimitiveAtom, WritableAtom };
|
export { atom, PrimitiveAtom };
|
||||||
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
|
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
|
||||||
export const EditorJotaiProvider: ReturnType<
|
export const EditorJotaiProvider: ReturnType<
|
||||||
typeof createIsolation
|
typeof createIsolation
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
|
import { type Store, StoreDelta, StoreIncrement } from "./store";
|
||||||
|
|
||||||
import type { AppStateChange, ElementsChange } from "./change";
|
|
||||||
import type { Snapshot } from "./store";
|
|
||||||
import type { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
|
export class HistoryEntry extends StoreDelta {}
|
||||||
|
|
||||||
type HistoryStack = HistoryEntry[];
|
type HistoryStack = HistoryEntry[];
|
||||||
|
|
||||||
export class HistoryChangedEvent {
|
export class HistoryChangedEvent {
|
||||||
@@ -20,8 +21,8 @@ export class History {
|
|||||||
[HistoryChangedEvent]
|
[HistoryChangedEvent]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly undoStack: HistoryStack = [];
|
public readonly undoStack: HistoryStack = [];
|
||||||
private readonly redoStack: HistoryStack = [];
|
public readonly redoStack: HistoryStack = [];
|
||||||
|
|
||||||
public get isUndoStackEmpty() {
|
public get isUndoStackEmpty() {
|
||||||
return this.undoStack.length === 0;
|
return this.undoStack.length === 0;
|
||||||
@@ -31,6 +32,8 @@ export class History {
|
|||||||
return this.redoStack.length === 0;
|
return this.redoStack.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(private readonly store: Store) {}
|
||||||
|
|
||||||
public clear() {
|
public clear() {
|
||||||
this.undoStack.length = 0;
|
this.undoStack.length = 0;
|
||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
@@ -39,17 +42,18 @@ export class History {
|
|||||||
/**
|
/**
|
||||||
* Record a local change which will go into the history
|
* Record a local change which will go into the history
|
||||||
*/
|
*/
|
||||||
public record(
|
public record(increment: StoreIncrement) {
|
||||||
elementsChange: ElementsChange,
|
if (
|
||||||
appStateChange: AppStateChange,
|
StoreIncrement.isDurable(increment) &&
|
||||||
) {
|
!increment.delta.isEmpty() &&
|
||||||
const entry = HistoryEntry.create(appStateChange, elementsChange);
|
!(increment.delta instanceof HistoryEntry)
|
||||||
|
) {
|
||||||
|
// construct history entry, so once it's emitted, it's not recorded again
|
||||||
|
const entry = HistoryEntry.inverse(increment.delta);
|
||||||
|
|
||||||
if (!entry.isEmpty()) {
|
this.undoStack.push(entry);
|
||||||
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
|
||||||
this.undoStack.push(entry.inverse());
|
|
||||||
|
|
||||||
if (!entry.elementsChange.isEmpty()) {
|
if (!entry.elements.isEmpty()) {
|
||||||
// don't reset redo stack on local appState changes,
|
// don't reset redo stack on local appState changes,
|
||||||
// as a simple click (unselect) could lead to losing all the redo entries
|
// as a simple click (unselect) could lead to losing all the redo entries
|
||||||
// only reset on non empty elements changes!
|
// only reset on non empty elements changes!
|
||||||
@@ -62,29 +66,19 @@ export class History {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public undo(
|
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public redo(
|
public redo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
);
|
);
|
||||||
@@ -93,7 +87,6 @@ export class History {
|
|||||||
private perform(
|
private perform(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
pop: () => HistoryEntry | null,
|
pop: () => HistoryEntry | null,
|
||||||
push: (entry: HistoryEntry) => void,
|
push: (entry: HistoryEntry) => void,
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
@@ -104,6 +97,7 @@ export class History {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prevSnapshot = this.store.snapshot;
|
||||||
let nextElements = elements;
|
let nextElements = elements;
|
||||||
let nextAppState = appState;
|
let nextAppState = appState;
|
||||||
let containsVisibleChange = false;
|
let containsVisibleChange = false;
|
||||||
@@ -112,9 +106,18 @@ export class History {
|
|||||||
while (historyEntry) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
|
||||||
|
triggerIncrement: true,
|
||||||
|
updateSnapshot: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
prevSnapshot = this.store.snapshot;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to apply history entry:", e);
|
||||||
|
// rollback to the previous snapshot, so that we don't end up in an incosistent state
|
||||||
|
this.store.snapshot = prevSnapshot;
|
||||||
} finally {
|
} finally {
|
||||||
// make sure to always push / pop, even if the increment is corrupted
|
// make sure to always push, even if the delta is corrupted
|
||||||
push(historyEntry);
|
push(historyEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +128,10 @@ export class History {
|
|||||||
historyEntry = pop();
|
historyEntry = pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextElements === null || nextAppState === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return [nextElements, nextAppState];
|
return [nextElements, nextAppState];
|
||||||
} finally {
|
} finally {
|
||||||
// trigger the history change event before returning completely
|
// trigger the history change event before returning completely
|
||||||
@@ -154,59 +161,13 @@ export class History {
|
|||||||
entry: HistoryEntry,
|
entry: HistoryEntry,
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
|
const inversedEntry = HistoryEntry.inverse(entry);
|
||||||
|
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||||
|
inversedEntry,
|
||||||
|
prevElements,
|
||||||
|
"inserted",
|
||||||
|
);
|
||||||
|
|
||||||
return stack.push(updatedEntry);
|
return stack.push(updatedEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HistoryEntry {
|
|
||||||
private constructor(
|
|
||||||
public readonly appStateChange: AppStateChange,
|
|
||||||
public readonly elementsChange: ElementsChange,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static create(
|
|
||||||
appStateChange: AppStateChange,
|
|
||||||
elementsChange: ElementsChange,
|
|
||||||
) {
|
|
||||||
return new HistoryEntry(appStateChange, elementsChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
public inverse(): HistoryEntry {
|
|
||||||
return new HistoryEntry(
|
|
||||||
this.appStateChange.inverse(),
|
|
||||||
this.elementsChange.inverse(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyTo(
|
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
|
||||||
this.elementsChange.applyTo(elements, snapshot.elements);
|
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
|
||||||
this.appStateChange.applyTo(appState, nextElements);
|
|
||||||
|
|
||||||
const appliedVisibleChanges =
|
|
||||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
|
||||||
|
|
||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
|
|
||||||
*/
|
|
||||||
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
|
|
||||||
const updatedElementsChange =
|
|
||||||
this.elementsChange.applyLatestChanges(elements);
|
|
||||||
|
|
||||||
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
|
||||||
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function useOutsideClick<T extends HTMLElement>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInsideOverride = isInside?.(_event, ref.current);
|
const isInsideOverride = isInside?.(_event as any, ref.current);
|
||||||
|
|
||||||
if (isInsideOverride === true) {
|
if (isInsideOverride === true) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ polyfill();
|
|||||||
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
|
onIncrement,
|
||||||
initialData,
|
initialData,
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
isCollaborating = false,
|
isCollaborating = false,
|
||||||
@@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
<InitializeApp langCode={langCode} theme={theme}>
|
<InitializeApp langCode={langCode} theme={theme}>
|
||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onIncrement={onIncrement}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
excalidrawAPI={excalidrawAPI}
|
excalidrawAPI={excalidrawAPI}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
|
|||||||
@@ -165,9 +165,7 @@
|
|||||||
"unCroppedDimension": "Uncropped dimension",
|
"unCroppedDimension": "Uncropped dimension",
|
||||||
"copyElementLink": "Copy link to object",
|
"copyElementLink": "Copy link to object",
|
||||||
"linkToElement": "Link to object",
|
"linkToElement": "Link to object",
|
||||||
"wrapSelectionInFrame": "Wrap selection in frame",
|
"wrapSelectionInFrame": "Wrap selection in frame"
|
||||||
"tab": "Tab",
|
|
||||||
"shapeSwitch": "Switch shape"
|
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "Link to object",
|
"title": "Link to object",
|
||||||
@@ -298,8 +296,7 @@
|
|||||||
"laser": "Laser pointer",
|
"laser": "Laser pointer",
|
||||||
"hand": "Hand (panning tool)",
|
"hand": "Hand (panning tool)",
|
||||||
"extraTools": "More tools",
|
"extraTools": "More tools",
|
||||||
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
"mermaidToExcalidraw": "Mermaid to Excalidraw"
|
||||||
"convertElementType": "Toggle shape type"
|
|
||||||
},
|
},
|
||||||
"element": {
|
"element": {
|
||||||
"rectangle": "Rectangle",
|
"rectangle": "Rectangle",
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
|
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
||||||
|
|
||||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
|
||||||
import { getDiamondPoints } from "@excalidraw/element/bounds";
|
|
||||||
import { getCornerRadius } from "@excalidraw/element/shapes";
|
|
||||||
|
|
||||||
import {
|
|
||||||
bezierEquation,
|
|
||||||
curve,
|
|
||||||
curveTangent,
|
|
||||||
type GlobalPoint,
|
|
||||||
pointFrom,
|
|
||||||
pointFromVector,
|
|
||||||
pointRotateRads,
|
|
||||||
vector,
|
|
||||||
vectorNormal,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawDiamondElement,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import type { StaticCanvasRenderConfig } from "../scene/types";
|
import type { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import type { AppState, StaticCanvasAppState } from "../types";
|
import type { StaticCanvasAppState, AppState } from "../types";
|
||||||
|
|
||||||
export const fillCircle = (
|
export const fillCircle = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -95,399 +72,3 @@ export const bootstrapCanvas = ({
|
|||||||
|
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawCatmullRomQuadraticApprox(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
points: GlobalPoint[],
|
|
||||||
segments = 20,
|
|
||||||
) {
|
|
||||||
ctx.lineTo(points[0][0], points[0][1]);
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length - 1; i++) {
|
|
||||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
|
||||||
const p1 = points[i];
|
|
||||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
|
||||||
|
|
||||||
for (let t = 0; t <= 1; t += 1 / segments) {
|
|
||||||
const t2 = t * t;
|
|
||||||
|
|
||||||
const x =
|
|
||||||
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
|
|
||||||
|
|
||||||
const y =
|
|
||||||
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
|
|
||||||
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCatmullRomCubicApprox(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
points: GlobalPoint[],
|
|
||||||
segments = 20,
|
|
||||||
) {
|
|
||||||
ctx.lineTo(points[0][0], points[0][1]);
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length - 1; i++) {
|
|
||||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
|
||||||
const p1 = points[i];
|
|
||||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
|
||||||
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
|
|
||||||
|
|
||||||
for (let t = 0; t <= 1; t += 1 / segments) {
|
|
||||||
const t2 = t * t;
|
|
||||||
const t3 = t2 * t;
|
|
||||||
|
|
||||||
const x =
|
|
||||||
0.5 *
|
|
||||||
(2 * p1[0] +
|
|
||||||
(-p0[0] + p2[0]) * t +
|
|
||||||
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
|
|
||||||
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
|
|
||||||
|
|
||||||
const y =
|
|
||||||
0.5 *
|
|
||||||
(2 * p1[1] +
|
|
||||||
(-p0[1] + p2[1]) * t +
|
|
||||||
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
|
|
||||||
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
|
|
||||||
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const drawHighlightForRectWithRotation = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
element: ExcalidrawRectanguloidElement,
|
|
||||||
padding: number,
|
|
||||||
) => {
|
|
||||||
const [x, y] = pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x, element.y),
|
|
||||||
elementCenterPoint(element),
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.save();
|
|
||||||
context.translate(x, y);
|
|
||||||
context.rotate(element.angle);
|
|
||||||
|
|
||||||
let radius = getCornerRadius(
|
|
||||||
Math.min(element.width, element.height),
|
|
||||||
element,
|
|
||||||
);
|
|
||||||
if (radius === 0) {
|
|
||||||
radius = 0.01;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.beginPath();
|
|
||||||
|
|
||||||
{
|
|
||||||
const topLeftApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(0, 0 + radius),
|
|
||||||
pointFrom(0, 0),
|
|
||||||
pointFrom(0 + radius, 0),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const topRightApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(element.width - radius, 0),
|
|
||||||
pointFrom(element.width, 0),
|
|
||||||
pointFrom(element.width, radius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const bottomRightApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(element.width, element.height - radius),
|
|
||||||
pointFrom(element.width, element.height),
|
|
||||||
pointFrom(element.width - radius, element.height),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const bottomLeftApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(radius, element.height),
|
|
||||||
pointFrom(0, element.height),
|
|
||||||
pointFrom(0, element.height - radius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.moveTo(
|
|
||||||
topLeftApprox[topLeftApprox.length - 1][0],
|
|
||||||
topLeftApprox[topLeftApprox.length - 1][1],
|
|
||||||
);
|
|
||||||
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, topRightApprox);
|
|
||||||
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
|
|
||||||
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
|
|
||||||
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, topLeftApprox);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
|
|
||||||
// mask" on a filled shape for the diamond highlight, because stroking creates
|
|
||||||
// sharp inset edges on line joins < 90 degrees.
|
|
||||||
{
|
|
||||||
const topLeftApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(0 + radius, 0),
|
|
||||||
pointFrom(0, 0),
|
|
||||||
pointFrom(0, 0 + radius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const topRightApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(element.width, radius),
|
|
||||||
pointFrom(element.width, 0),
|
|
||||||
pointFrom(element.width - radius, 0),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const bottomRightApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(element.width - radius, element.height),
|
|
||||||
pointFrom(element.width, element.height),
|
|
||||||
pointFrom(element.width, element.height - radius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const bottomLeftApprox = offsetQuadraticBezier(
|
|
||||||
pointFrom(0, element.height - radius),
|
|
||||||
pointFrom(0, element.height),
|
|
||||||
pointFrom(radius, element.height),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.moveTo(
|
|
||||||
topLeftApprox[topLeftApprox.length - 1][0],
|
|
||||||
topLeftApprox[topLeftApprox.length - 1][1],
|
|
||||||
);
|
|
||||||
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
|
|
||||||
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
|
|
||||||
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, topRightApprox);
|
|
||||||
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
|
|
||||||
drawCatmullRomQuadraticApprox(context, topLeftApprox);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
|
|
||||||
context.restore();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const strokeEllipseWithRotation = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
angle: number,
|
|
||||||
) => {
|
|
||||||
context.beginPath();
|
|
||||||
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
|
|
||||||
context.stroke();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const strokeRectWithRotation = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
angle: number,
|
|
||||||
fill: boolean = false,
|
|
||||||
/** should account for zoom */
|
|
||||||
radius: number = 0,
|
|
||||||
) => {
|
|
||||||
context.save();
|
|
||||||
context.translate(cx, cy);
|
|
||||||
context.rotate(angle);
|
|
||||||
if (fill) {
|
|
||||||
context.fillRect(x - cx, y - cy, width, height);
|
|
||||||
}
|
|
||||||
if (radius && context.roundRect) {
|
|
||||||
context.beginPath();
|
|
||||||
context.roundRect(x - cx, y - cy, width, height, radius);
|
|
||||||
context.stroke();
|
|
||||||
context.closePath();
|
|
||||||
} else {
|
|
||||||
context.strokeRect(x - cx, y - cy, width, height);
|
|
||||||
}
|
|
||||||
context.restore();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const drawHighlightForDiamondWithRotation = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
padding: number,
|
|
||||||
element: ExcalidrawDiamondElement,
|
|
||||||
) => {
|
|
||||||
const [x, y] = pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x, element.y),
|
|
||||||
elementCenterPoint(element),
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
context.save();
|
|
||||||
context.translate(x, y);
|
|
||||||
context.rotate(element.angle);
|
|
||||||
|
|
||||||
{
|
|
||||||
context.beginPath();
|
|
||||||
|
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
|
||||||
: (topX - leftX) * 0.01;
|
|
||||||
const horizontalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
|
||||||
: (rightY - topY) * 0.01;
|
|
||||||
const topApprox = offsetCubicBezier(
|
|
||||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
|
||||||
pointFrom(topX, topY),
|
|
||||||
pointFrom(topX, topY),
|
|
||||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const rightApprox = offsetCubicBezier(
|
|
||||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
|
||||||
pointFrom(rightX, rightY),
|
|
||||||
pointFrom(rightX, rightY),
|
|
||||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const bottomApprox = offsetCubicBezier(
|
|
||||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
|
||||||
pointFrom(bottomX, bottomY),
|
|
||||||
pointFrom(bottomX, bottomY),
|
|
||||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
const leftApprox = offsetCubicBezier(
|
|
||||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
|
||||||
pointFrom(leftX, leftY),
|
|
||||||
pointFrom(leftX, leftY),
|
|
||||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
|
||||||
padding,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.moveTo(
|
|
||||||
topApprox[topApprox.length - 1][0],
|
|
||||||
topApprox[topApprox.length - 1][1],
|
|
||||||
);
|
|
||||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, rightApprox);
|
|
||||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
|
||||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, leftApprox);
|
|
||||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, topApprox);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
|
|
||||||
// mask" on a filled shape for the diamond highlight, because stroking creates
|
|
||||||
// sharp inset edges on line joins < 90 degrees.
|
|
||||||
{
|
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
|
||||||
: (topX - leftX) * 0.01;
|
|
||||||
const horizontalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
|
||||||
: (rightY - topY) * 0.01;
|
|
||||||
const topApprox = offsetCubicBezier(
|
|
||||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
|
||||||
pointFrom(topX, topY),
|
|
||||||
pointFrom(topX, topY),
|
|
||||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const rightApprox = offsetCubicBezier(
|
|
||||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
|
||||||
pointFrom(rightX, rightY),
|
|
||||||
pointFrom(rightX, rightY),
|
|
||||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const bottomApprox = offsetCubicBezier(
|
|
||||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
|
||||||
pointFrom(bottomX, bottomY),
|
|
||||||
pointFrom(bottomX, bottomY),
|
|
||||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
const leftApprox = offsetCubicBezier(
|
|
||||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
|
||||||
pointFrom(leftX, leftY),
|
|
||||||
pointFrom(leftX, leftY),
|
|
||||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
|
||||||
-FIXED_BINDING_DISTANCE,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.moveTo(
|
|
||||||
topApprox[topApprox.length - 1][0],
|
|
||||||
topApprox[topApprox.length - 1][1],
|
|
||||||
);
|
|
||||||
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, leftApprox);
|
|
||||||
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, bottomApprox);
|
|
||||||
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, rightApprox);
|
|
||||||
context.lineTo(topApprox[0][0], topApprox[0][1]);
|
|
||||||
drawCatmullRomCubicApprox(context, topApprox);
|
|
||||||
}
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
context.restore();
|
|
||||||
};
|
|
||||||
|
|
||||||
function offsetCubicBezier(
|
|
||||||
p0: GlobalPoint,
|
|
||||||
p1: GlobalPoint,
|
|
||||||
p2: GlobalPoint,
|
|
||||||
p3: GlobalPoint,
|
|
||||||
offsetDist: number,
|
|
||||||
steps = 20,
|
|
||||||
) {
|
|
||||||
const offsetPoints = [];
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const t = i / steps;
|
|
||||||
const c = curve(p0, p1, p2, p3);
|
|
||||||
const point = bezierEquation(c, t);
|
|
||||||
const tangent = vectorNormalize(curveTangent(c, t));
|
|
||||||
const normal = vectorNormal(tangent);
|
|
||||||
|
|
||||||
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
|
|
||||||
}
|
|
||||||
|
|
||||||
return offsetPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
function offsetQuadraticBezier(
|
|
||||||
p0: GlobalPoint,
|
|
||||||
p1: GlobalPoint,
|
|
||||||
p2: GlobalPoint,
|
|
||||||
offsetDist: number,
|
|
||||||
steps = 20,
|
|
||||||
) {
|
|
||||||
const offsetPoints = [];
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const t = i / steps;
|
|
||||||
const t1 = 1 - t;
|
|
||||||
const point = pointFrom<GlobalPoint>(
|
|
||||||
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
|
|
||||||
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
|
|
||||||
);
|
|
||||||
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
|
|
||||||
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
|
|
||||||
const tangent = vectorNormalize(vector(tangentX, tangentY));
|
|
||||||
const normal = vectorNormal(tangent);
|
|
||||||
|
|
||||||
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
|
|
||||||
}
|
|
||||||
|
|
||||||
return offsetPoints;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
|
import oc from "open-color";
|
||||||
import {
|
import {
|
||||||
pointFrom,
|
pointFrom,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
type Radians,
|
type Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import oc from "open-color";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
|
||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
invariant,
|
|
||||||
THEME,
|
THEME,
|
||||||
|
arrayToMap,
|
||||||
|
invariant,
|
||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FIXED_BINDING_DISTANCE,
|
BINDING_HIGHLIGHT_OFFSET,
|
||||||
|
BINDING_HIGHLIGHT_THICKNESS,
|
||||||
maxBindingGap,
|
maxBindingGap,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
@@ -34,12 +35,14 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { getCornerRadius } from "@excalidraw/element/shapes";
|
||||||
|
|
||||||
import { renderSelectionElement } from "@excalidraw/element/renderElement";
|
import { renderSelectionElement } from "@excalidraw/element/renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementsInGroup,
|
|
||||||
getSelectedGroupIds,
|
|
||||||
isSelectedViaGroup,
|
isSelectedViaGroup,
|
||||||
|
getSelectedGroupIds,
|
||||||
|
getElementsInGroup,
|
||||||
selectGroupsFromGivenElements,
|
selectGroupsFromGivenElements,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
@@ -83,12 +86,8 @@ import { getClientColor, renderRemoteCursors } from "../clients";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bootstrapCanvas,
|
bootstrapCanvas,
|
||||||
drawHighlightForDiamondWithRotation,
|
|
||||||
drawHighlightForRectWithRotation,
|
|
||||||
fillCircle,
|
fillCircle,
|
||||||
getNormalizedCanvasDimensions,
|
getNormalizedCanvasDimensions,
|
||||||
strokeEllipseWithRotation,
|
|
||||||
strokeRectWithRotation,
|
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -161,6 +160,57 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const strokeRectWithRotation = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
angle: number,
|
||||||
|
fill: boolean = false,
|
||||||
|
/** should account for zoom */
|
||||||
|
radius: number = 0,
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.translate(cx, cy);
|
||||||
|
context.rotate(angle);
|
||||||
|
if (fill) {
|
||||||
|
context.fillRect(x - cx, y - cy, width, height);
|
||||||
|
}
|
||||||
|
if (radius && context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(x - cx, y - cy, width, height, radius);
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
} else {
|
||||||
|
context.strokeRect(x - cx, y - cy, width, height);
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const strokeDiamondWithRotation = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
angle: number,
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.translate(cx, cy);
|
||||||
|
context.rotate(angle);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, height / 2);
|
||||||
|
context.lineTo(width / 2, 0);
|
||||||
|
context.lineTo(0, -height / 2);
|
||||||
|
context.lineTo(-width / 2, 0);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
@@ -187,6 +237,19 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const strokeEllipseWithRotation = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
angle: number,
|
||||||
|
) => {
|
||||||
|
context.beginPath();
|
||||||
|
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
const renderBindingHighlightForBindableElement = (
|
const renderBindingHighlightForBindableElement = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
@@ -198,10 +261,16 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
|
|
||||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||||
context.fillStyle = "rgba(0,0,0,.05)";
|
// When zooming out, make line width greater for visibility
|
||||||
|
const zoomValue = zoom.value < 1 ? zoom.value : 1;
|
||||||
|
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
|
||||||
// To ensure the binding highlight doesn't overlap the element itself
|
// To ensure the binding highlight doesn't overlap the element itself
|
||||||
const padding = maxBindingGap(element, element.width, element.height, zoom);
|
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
|
||||||
|
|
||||||
|
const radius = getCornerRadius(
|
||||||
|
Math.min(element.width, element.height),
|
||||||
|
element,
|
||||||
|
);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
@@ -211,20 +280,37 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
drawHighlightForRectWithRotation(context, element, padding);
|
strokeRectWithRotation(
|
||||||
|
context,
|
||||||
|
x1 - padding,
|
||||||
|
y1 - padding,
|
||||||
|
width + padding * 2,
|
||||||
|
height + padding * 2,
|
||||||
|
x1 + width / 2,
|
||||||
|
y1 + height / 2,
|
||||||
|
element.angle,
|
||||||
|
undefined,
|
||||||
|
radius,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "diamond":
|
case "diamond":
|
||||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
const side = Math.hypot(width, height);
|
||||||
|
const wPadding = (padding * side) / height;
|
||||||
|
const hPadding = (padding * side) / width;
|
||||||
|
strokeDiamondWithRotation(
|
||||||
|
context,
|
||||||
|
width + wPadding * 2,
|
||||||
|
height + hPadding * 2,
|
||||||
|
x1 + width / 2,
|
||||||
|
y1 + height / 2,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
context.lineWidth =
|
|
||||||
maxBindingGap(element, element.width, element.height, zoom) -
|
|
||||||
FIXED_BINDING_DISTANCE;
|
|
||||||
|
|
||||||
strokeEllipseWithRotation(
|
strokeEllipseWithRotation(
|
||||||
context,
|
context,
|
||||||
width + padding + FIXED_BINDING_DISTANCE,
|
width + padding * 2,
|
||||||
height + padding + FIXED_BINDING_DISTANCE,
|
height + padding * 2,
|
||||||
x1 + width / 2,
|
x1 + width / 2,
|
||||||
y1 + height / 2,
|
y1 + height / 2,
|
||||||
element.angle,
|
element.angle,
|
||||||
|
|||||||
+486
-189
@@ -1,18 +1,30 @@
|
|||||||
import { isDevEnv, isShallowEqual, isTestEnv } from "@excalidraw/common";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
assertNever,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
|
randomId,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { hashElementsVersion } from "@excalidraw/element";
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getDefaultAppState } from "./appState";
|
import { getDefaultAppState } from "./appState";
|
||||||
import { AppStateChange, ElementsChange } from "./change";
|
|
||||||
|
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
|
|
||||||
|
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||||
|
|
||||||
import type { AppState, ObservedAppState } from "./types";
|
import type { AppState, ObservedAppState } from "./types";
|
||||||
|
|
||||||
// hidden non-enumerable property for runtime checks
|
// hidden non-enumerable property for runtime checks
|
||||||
@@ -43,12 +55,13 @@ const isObservedAppState = (
|
|||||||
): appState is ObservedAppState =>
|
): appState is ObservedAppState =>
|
||||||
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
||||||
|
|
||||||
|
// CFDO: consider adding a "remote" action, which should perform update but never be emitted (so that it we don't have to filter it when pushing it into sync api)
|
||||||
export const CaptureUpdateAction = {
|
export const CaptureUpdateAction = {
|
||||||
/**
|
/**
|
||||||
* Immediately undoable.
|
* Immediately undoable.
|
||||||
*
|
*
|
||||||
* Use for updates which should be captured.
|
* Use for updates which should be captured as durable deltas.
|
||||||
* Should be used for most of the local updates.
|
* 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.
|
* These updates will _immediately_ make it to the local undo / redo stacks.
|
||||||
*/
|
*/
|
||||||
@@ -56,7 +69,7 @@ export const CaptureUpdateAction = {
|
|||||||
/**
|
/**
|
||||||
* Never undoable.
|
* Never undoable.
|
||||||
*
|
*
|
||||||
* Use for updates which should never be recorded, such as remote updates
|
* Use for updates which should never be captured as deltas, such as remote updates
|
||||||
* or scene initialization.
|
* or scene initialization.
|
||||||
*
|
*
|
||||||
* These updates will _never_ make it to the local undo / redo stacks.
|
* These updates will _never_ make it to the local undo / redo stacks.
|
||||||
@@ -79,160 +92,169 @@ export const CaptureUpdateAction = {
|
|||||||
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
|
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represent an increment to the Store.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
class StoreIncrementEvent {
|
export class Store {
|
||||||
constructor(
|
|
||||||
public readonly elementsChange: ElementsChange,
|
|
||||||
public readonly appStateChange: AppStateChange,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
|
||||||
*
|
|
||||||
* @experimental this interface is experimental and subject to change.
|
|
||||||
*/
|
|
||||||
export interface IStore {
|
|
||||||
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
|
|
||||||
get snapshot(): Snapshot;
|
|
||||||
set snapshot(snapshot: Snapshot);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
|
|
||||||
*/
|
|
||||||
shouldUpdateSnapshot(): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use to schedule calculation of a store increment.
|
|
||||||
*/
|
|
||||||
shouldCaptureIncrement(): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
|
|
||||||
*
|
|
||||||
* @emits StoreIncrementEvent when increment is calculated.
|
|
||||||
*/
|
|
||||||
commit(
|
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
|
||||||
appState: AppState | ObservedAppState | undefined,
|
|
||||||
): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the store instance.
|
|
||||||
*/
|
|
||||||
clear(): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
|
||||||
*
|
|
||||||
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
|
||||||
*/
|
|
||||||
filterUncomittedElements(
|
|
||||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
|
||||||
): Map<string, OrderedExcalidrawElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Store implements IStore {
|
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[StoreIncrementEvent]
|
[DurableStoreIncrement | EphemeralStoreIncrement]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private scheduledActions: Set<CaptureUpdateActionType> = new Set();
|
private scheduledActions: Set<CaptureUpdateActionType> = new Set();
|
||||||
private _snapshot = Snapshot.empty();
|
private _snapshot = StoreSnapshot.empty();
|
||||||
|
|
||||||
public get snapshot() {
|
public get snapshot() {
|
||||||
return this._snapshot;
|
return this._snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public set snapshot(snapshot: Snapshot) {
|
public set snapshot(snapshot: StoreSnapshot) {
|
||||||
this._snapshot = snapshot;
|
this._snapshot = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Suspicious that this is called so many places. Seems error-prone.
|
public scheduleAction(action: CaptureUpdateActionType) {
|
||||||
public shouldCaptureIncrement = () => {
|
|
||||||
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
|
|
||||||
};
|
|
||||||
|
|
||||||
public shouldUpdateSnapshot = () => {
|
|
||||||
this.scheduleAction(CaptureUpdateAction.NEVER);
|
|
||||||
};
|
|
||||||
|
|
||||||
private scheduleAction = (action: CaptureUpdateActionType) => {
|
|
||||||
this.scheduledActions.add(action);
|
this.scheduledActions.add(action);
|
||||||
this.satisfiesScheduledActionsInvariant();
|
this.satisfiesScheduledActionsInvariant();
|
||||||
};
|
}
|
||||||
|
|
||||||
public commit = (
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get scheduledAction() {
|
||||||
|
// Capture has a precedence over update, since it also performs snapshot update
|
||||||
|
if (this.scheduledActions.has(CaptureUpdateAction.IMMEDIATELY)) {
|
||||||
|
return CaptureUpdateAction.IMMEDIATELY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update has a precedence over none, since it also emits an (ephemeral) increment
|
||||||
|
if (this.scheduledActions.has(CaptureUpdateAction.NEVER)) {
|
||||||
|
return CaptureUpdateAction.NEVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: maybe it should be explicitly set so that we don't clone on every single component update
|
||||||
|
// Emit ephemeral increment, don't update the snapshot
|
||||||
|
return CaptureUpdateAction.EVENTUALLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
|
||||||
|
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
|
||||||
|
*
|
||||||
|
* @emits StoreIncrement
|
||||||
|
*/
|
||||||
|
public commit(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
): void => {
|
): void {
|
||||||
try {
|
try {
|
||||||
// Capture has precedence since it also performs update
|
const { scheduledAction } = this;
|
||||||
if (this.scheduledActions.has(CaptureUpdateAction.IMMEDIATELY)) {
|
|
||||||
this.captureIncrement(elements, appState);
|
switch (scheduledAction) {
|
||||||
} else if (this.scheduledActions.has(CaptureUpdateAction.NEVER)) {
|
case CaptureUpdateAction.IMMEDIATELY:
|
||||||
this.updateSnapshot(elements, appState);
|
this.snapshot = this.captureDurableIncrement(elements, appState);
|
||||||
|
break;
|
||||||
|
case CaptureUpdateAction.NEVER:
|
||||||
|
this.snapshot = this.emitEphemeralIncrement(elements);
|
||||||
|
break;
|
||||||
|
case CaptureUpdateAction.EVENTUALLY:
|
||||||
|
// ÇFDO: consider perf. optimisation without creating a snapshot if it is not updated in the end, it shall not be needed (more complex though)
|
||||||
|
this.emitEphemeralIncrement(elements);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
assertNever(scheduledAction, `Unknown store action`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.satisfiesScheduledActionsInvariant();
|
this.satisfiesScheduledActionsInvariant();
|
||||||
// Defensively reset all scheduled actions, potentially cleans up other runtime garbage
|
// Defensively reset all scheduled actions, potentially cleans up other runtime garbage
|
||||||
this.scheduledActions = new Set();
|
this.scheduledActions = new Set();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
public captureIncrement = (
|
/**
|
||||||
|
* Performs delta calculation and emits the increment.
|
||||||
|
*
|
||||||
|
* @emits StoreIncrement.
|
||||||
|
*/
|
||||||
|
private captureDurableIncrement(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
) => {
|
) {
|
||||||
const prevSnapshot = this.snapshot;
|
const prevSnapshot = this.snapshot;
|
||||||
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
const nextSnapshot = this.snapshot.maybeClone(elements, appState, {
|
||||||
|
shouldIgnoreCache: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Optimisation, don't continue if nothing has changed
|
// Optimisation, don't continue if nothing has changed
|
||||||
if (prevSnapshot !== nextSnapshot) {
|
if (prevSnapshot === nextSnapshot) {
|
||||||
// Calculate and record the changes based on the previous and next snapshot
|
return prevSnapshot;
|
||||||
const elementsChange = nextSnapshot.meta.didElementsChange
|
|
||||||
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
|
||||||
: ElementsChange.empty();
|
|
||||||
|
|
||||||
const appStateChange = nextSnapshot.meta.didAppStateChange
|
|
||||||
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
|
||||||
: AppStateChange.empty();
|
|
||||||
|
|
||||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onStoreIncrementEmitter.trigger(
|
|
||||||
new StoreIncrementEvent(elementsChange, appStateChange),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update snapshot
|
|
||||||
this.snapshot = nextSnapshot;
|
|
||||||
}
|
}
|
||||||
};
|
// Calculate the deltas based on the previous and next snapshot
|
||||||
|
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
||||||
|
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||||
|
: ElementsDelta.empty();
|
||||||
|
|
||||||
public updateSnapshot = (
|
const appStateDelta = nextSnapshot.metadata.didAppStateChange
|
||||||
|
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||||
|
: AppStateDelta.empty();
|
||||||
|
|
||||||
|
if (!elementsDelta.isEmpty() || !appStateDelta.isEmpty()) {
|
||||||
|
const delta = StoreDelta.create(elementsDelta, appStateDelta);
|
||||||
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
|
const increment = new DurableStoreIncrement(change, delta);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When change is detected, emits an ephemeral increment and returns the next snapshot.
|
||||||
|
*
|
||||||
|
* @emits EphemeralStoreIncrement
|
||||||
|
*/
|
||||||
|
private emitEphemeralIncrement(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
) {
|
||||||
) => {
|
const prevSnapshot = this.snapshot;
|
||||||
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
const nextSnapshot = this.snapshot.maybeClone(elements, undefined);
|
||||||
|
|
||||||
if (this.snapshot !== nextSnapshot) {
|
if (prevSnapshot === nextSnapshot) {
|
||||||
// Update snapshot
|
// nothing has changed
|
||||||
this.snapshot = nextSnapshot;
|
return prevSnapshot;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
public filterUncomittedElements = (
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
const increment = new EphemeralStoreIncrement(change);
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
|
||||||
) => {
|
// Notify listeners with the increment
|
||||||
|
// CFDO: consider having this async instead, possibly should also happen after the component updates;
|
||||||
|
// or get rid of filtering local in progress elements, switch to unidirectional store flow and keep it synchronous
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
|
||||||
|
return nextSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
||||||
|
*
|
||||||
|
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
||||||
|
*/
|
||||||
|
public filterUncomittedElements(
|
||||||
|
prevElements: Map<string, ExcalidrawElement>,
|
||||||
|
nextElements: Map<string, ExcalidrawElement>,
|
||||||
|
): Map<string, OrderedExcalidrawElement> {
|
||||||
|
const movedElements = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
for (const [id, prevElement] of prevElements.entries()) {
|
for (const [id, prevElement] of prevElements.entries()) {
|
||||||
const nextElement = nextElements.get(id);
|
const nextElement = nextElements.get(id);
|
||||||
|
|
||||||
if (!nextElement) {
|
if (!nextElement) {
|
||||||
// Nothing to care about here, elements were forcefully deleted
|
// Nothing to care about here, element was forcefully deleted
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,21 +265,86 @@ export class Store implements IStore {
|
|||||||
// Detected yet uncomitted local element
|
// Detected yet uncomitted local element
|
||||||
nextElements.delete(id);
|
nextElements.delete(id);
|
||||||
} else if (elementSnapshot.version < prevElement.version) {
|
} else if (elementSnapshot.version < prevElement.version) {
|
||||||
// Element was already commited, but the snapshot version is lower than current current local version
|
// Element was already commited, but the snapshot version is lower than current local version
|
||||||
nextElements.set(id, elementSnapshot);
|
nextElements.set(id, elementSnapshot);
|
||||||
|
// Mark the element as potentially moved, as it could have
|
||||||
|
movedElements.set(id, elementSnapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextElements;
|
// Make sure to sync only potentially invalid indices for all elements restored from the snapshot
|
||||||
};
|
const syncedElements = syncMovedIndices(
|
||||||
|
Array.from(nextElements.values()),
|
||||||
|
movedElements,
|
||||||
|
);
|
||||||
|
|
||||||
public clear = (): void => {
|
return arrayToMap(syncedElements);
|
||||||
this.snapshot = Snapshot.empty();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply and emit increment.
|
||||||
|
*
|
||||||
|
* @emits StoreIncrement when increment is applied.
|
||||||
|
*/
|
||||||
|
public applyDeltaTo(
|
||||||
|
delta: StoreDelta,
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
options: {
|
||||||
|
triggerIncrement: boolean;
|
||||||
|
updateSnapshot: boolean;
|
||||||
|
} = {
|
||||||
|
triggerIncrement: false,
|
||||||
|
updateSnapshot: false,
|
||||||
|
},
|
||||||
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
|
elements,
|
||||||
|
this.snapshot.elements,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
|
delta.appState.applyTo(appState, nextElements);
|
||||||
|
|
||||||
|
const appliedVisibleChanges =
|
||||||
|
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||||
|
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState, {
|
||||||
|
shouldIgnoreCache: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.triggerIncrement) {
|
||||||
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
|
const increment = new DurableStoreIncrement(change, delta);
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO II: maybe I should not update the snapshot here so that it always syncs ephemeral change after durable change,
|
||||||
|
// so that clients exchange the latest element versions between each other,
|
||||||
|
// meaning if it will be ignored on other clients, other clients would initiate a relay with current version instead of doing nothing
|
||||||
|
if (options.updateSnapshot) {
|
||||||
|
this.snapshot = nextSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the store instance.
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.snapshot = StoreSnapshot.empty();
|
||||||
this.scheduledActions = new Set();
|
this.scheduledActions = new Set();
|
||||||
};
|
}
|
||||||
|
|
||||||
private satisfiesScheduledActionsInvariant = () => {
|
private satisfiesScheduledActionsInvariant() {
|
||||||
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
|
if (
|
||||||
|
!(
|
||||||
|
this.scheduledActions.size >= 0 &&
|
||||||
|
this.scheduledActions.size <= Object.keys(CaptureUpdateAction).length
|
||||||
|
)
|
||||||
|
) {
|
||||||
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
|
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
|
||||||
console.error(message, this.scheduledActions.values());
|
console.error(message, this.scheduledActions.values());
|
||||||
|
|
||||||
@@ -265,14 +352,162 @@ export class Store implements IStore {
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Snapshot {
|
/**
|
||||||
|
* Repsents a change to the store containg changed elements and appState.
|
||||||
|
*/
|
||||||
|
export class StoreChange {
|
||||||
|
// CFDO: consider adding (observed & syncable) appState, though bare in mind that it's processed on every component update,
|
||||||
|
// so figuring out what has changed should ideally be just quick reference checks
|
||||||
|
private constructor(
|
||||||
|
public readonly elements: Record<string, OrderedExcalidrawElement>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
prevSnapshot: StoreSnapshot,
|
||||||
|
nextSnapshot: StoreSnapshot,
|
||||||
|
) {
|
||||||
|
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
|
||||||
|
return new StoreChange(changedElements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 DurableStoreIncrement {
|
||||||
|
return increment.type === "durable";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isEphemeral(
|
||||||
|
increment: StoreIncrement,
|
||||||
|
): increment is EphemeralStoreIncrement {
|
||||||
|
return increment.type === "ephemeral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a durable change to the store.
|
||||||
|
*/
|
||||||
|
export class DurableStoreIncrement extends StoreIncrement {
|
||||||
|
constructor(
|
||||||
|
public readonly change: StoreChange,
|
||||||
|
public readonly delta: StoreDelta,
|
||||||
|
) {
|
||||||
|
super("durable", change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an ephemeral change to the store.
|
||||||
|
*/
|
||||||
|
export class EphemeralStoreIncrement 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 constructor(
|
private constructor(
|
||||||
public readonly elements: Map<string, OrderedExcalidrawElement>,
|
public readonly elements: Map<string, OrderedExcalidrawElement>,
|
||||||
public readonly appState: ObservedAppState,
|
public readonly appState: ObservedAppState,
|
||||||
public readonly meta: {
|
public readonly metadata: {
|
||||||
didElementsChange: boolean;
|
didElementsChange: boolean;
|
||||||
didAppStateChange: boolean;
|
didAppStateChange: boolean;
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
@@ -284,15 +519,43 @@ export class Snapshot {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return new Snapshot(
|
return new StoreSnapshot(
|
||||||
new Map(),
|
new Map(),
|
||||||
getObservedAppState(getDefaultAppState() as AppState),
|
getObservedAppState(getDefaultAppState() as AppState),
|
||||||
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
|
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getChangedElements(prevSnapshot: StoreSnapshot) {
|
||||||
|
const changedElements: Record<string, OrderedExcalidrawElement> = {};
|
||||||
|
|
||||||
|
for (const [id, nextElement] of this.elements.entries()) {
|
||||||
|
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
|
||||||
|
if (prevSnapshot.elements.get(id) !== nextElement) {
|
||||||
|
changedElements[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() {
|
public isEmpty() {
|
||||||
return this.meta.isEmpty;
|
return this.metadata.isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,8 +566,16 @@ export class Snapshot {
|
|||||||
public maybeClone(
|
public maybeClone(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
options: {
|
||||||
|
shouldIgnoreCache: boolean;
|
||||||
|
} = {
|
||||||
|
shouldIgnoreCache: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
|
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
|
||||||
|
elements,
|
||||||
|
options,
|
||||||
|
);
|
||||||
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
|
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
|
||||||
|
|
||||||
let didElementsChange = false;
|
let didElementsChange = false;
|
||||||
@@ -322,10 +593,14 @@ export class Snapshot {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
|
const snapshot = new StoreSnapshot(
|
||||||
didElementsChange,
|
nextElementsSnapshot,
|
||||||
didAppStateChange,
|
nextAppStateSnapshot,
|
||||||
});
|
{
|
||||||
|
didElementsChange,
|
||||||
|
didAppStateChange,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
@@ -352,26 +627,29 @@ export class Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
|
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
|
||||||
return !isShallowEqual(this.appState, nextObservedAppState, {
|
// CFDO: could we optimize by checking only reference changes? (i.e. selectedElementIds should be stable now); this is not used for now
|
||||||
selectedElementIds: isShallowEqual,
|
return Delta.isRightDifferent(this.appState, nextObservedAppState);
|
||||||
selectedGroupIds: isShallowEqual,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeCreateElementsSnapshot(
|
private maybeCreateElementsSnapshot(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
|
options: {
|
||||||
|
shouldIgnoreCache: boolean;
|
||||||
|
} = {
|
||||||
|
shouldIgnoreCache: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
if (!elements) {
|
if (!elements) {
|
||||||
return this.elements;
|
return this.elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const didElementsChange = this.detectChangedElements(elements);
|
const changedElements = this.detectChangedElements(elements, options);
|
||||||
|
|
||||||
if (!didElementsChange) {
|
if (!changedElements?.size) {
|
||||||
return this.elements;
|
return this.elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementsSnapshot = this.createElementsSnapshot(elements);
|
const elementsSnapshot = this.createElementsSnapshot(changedElements);
|
||||||
return elementsSnapshot;
|
return elementsSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,68 +660,87 @@ export class Snapshot {
|
|||||||
*/
|
*/
|
||||||
private detectChangedElements(
|
private detectChangedElements(
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||||
|
options: {
|
||||||
|
shouldIgnoreCache: boolean;
|
||||||
|
} = {
|
||||||
|
shouldIgnoreCache: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
if (this.elements === nextElements) {
|
if (this.elements === nextElements) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.elements.size !== nextElements.size) {
|
const changedElements: Map<string, OrderedExcalidrawElement> = new Map();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop from right to left as changes are likelier to happen on new elements
|
for (const [id, prevElement] of this.elements) {
|
||||||
const keys = Array.from(nextElements.keys());
|
const nextElement = nextElements.get(id);
|
||||||
|
|
||||||
for (let i = keys.length - 1; i >= 0; i--) {
|
if (!nextElement) {
|
||||||
const prev = this.elements.get(keys[i]);
|
// element was deleted
|
||||||
const next = nextElements.get(keys[i]);
|
changedElements.set(
|
||||||
if (
|
|
||||||
!prev ||
|
|
||||||
!next ||
|
|
||||||
prev.id !== next.id ||
|
|
||||||
prev.versionNonce !== next.versionNonce
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform structural clone, cloning only elements that changed.
|
|
||||||
*/
|
|
||||||
private createElementsSnapshot(
|
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
|
||||||
) {
|
|
||||||
const clonedElements = new Map();
|
|
||||||
|
|
||||||
for (const [id, prevElement] of this.elements.entries()) {
|
|
||||||
// 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
|
|
||||||
if (!nextElements.get(id)) {
|
|
||||||
// When we cannot find the prev element in the next elements, we mark it as deleted
|
|
||||||
clonedElements.set(
|
|
||||||
id,
|
id,
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
clonedElements.set(id, prevElement);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, nextElement] of nextElements.entries()) {
|
for (const [id, nextElement] of nextElements) {
|
||||||
const prevElement = clonedElements.get(id);
|
const prevElement = this.elements.get(id);
|
||||||
|
|
||||||
// At this point our elements are reconcilled already, meaning the next element is always newer
|
|
||||||
if (
|
if (
|
||||||
!prevElement || // element was added
|
!prevElement || // element was added
|
||||||
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
|
prevElement.version < nextElement.version // element was updated
|
||||||
) {
|
) {
|
||||||
clonedElements.set(id, deepCopyElement(nextElement));
|
changedElements.set(id, nextElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!changedElements.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we wouldn't ignore a cache, durable increment would be skipped
|
||||||
|
// in case there was an ephemeral increment emitter just before
|
||||||
|
// with the same changed elements
|
||||||
|
if (options.shouldIgnoreCache) {
|
||||||
|
return changedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// due to snapshot containing only durable changes,
|
||||||
|
// we might have already processed these elements in a previous run,
|
||||||
|
// hence additionally check whether the hash of the elements has changed
|
||||||
|
// since if it didn't, we don't need to process them again
|
||||||
|
// otherwise we would have ephemeral increments even for component updates unrelated to elements
|
||||||
|
const changedElementsHash = hashElementsVersion(
|
||||||
|
Array.from(changedElements.values()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this._lastChangedElementsHash === changedElementsHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastChangedElementsHash = changedElementsHash;
|
||||||
|
return changedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform structural clone, deep cloning only elements that changed.
|
||||||
|
*/
|
||||||
|
private createElementsSnapshot(
|
||||||
|
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||||
|
) {
|
||||||
|
const clonedElements = new Map();
|
||||||
|
|
||||||
|
for (const [id, prevElement] of 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(id, prevElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, changedElement] of changedElements) {
|
||||||
|
clonedElements.set(id, deepCopyElement(changedElement));
|
||||||
|
}
|
||||||
|
|
||||||
return clonedElements;
|
return clonedElements;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const load = (): Promise<{
|
|||||||
try {
|
try {
|
||||||
const module = await WebAssembly.instantiate(binary);
|
const module = await WebAssembly.instantiate(binary);
|
||||||
const harfbuzzJsWasm = module.instance.exports;
|
const harfbuzzJsWasm = module.instance.exports;
|
||||||
// @ts-expect-error since `.buffer` is custom prop
|
// @ts-expect-error
|
||||||
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
|
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
|
||||||
|
|
||||||
const hbSubset = {
|
const hbSubset = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
|||||||
<a
|
<a
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
href="blog.excalidaw.com"
|
href="blog.excalidaw.com"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -392,7 +392,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
href="https://github.com/excalidraw/excalidraw"
|
href="https://github.com/excalidraw/excalidraw"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="GitHub"
|
title="GitHub"
|
||||||
>
|
>
|
||||||
@@ -426,7 +426,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
aria-label="X"
|
aria-label="X"
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
href="https://x.com/excalidraw"
|
href="https://x.com/excalidraw"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="X"
|
title="X"
|
||||||
>
|
>
|
||||||
@@ -472,7 +472,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
aria-label="Discord"
|
aria-label="Discord"
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
href="https://discord.gg/UexuTaE"
|
href="https://discord.gg/UexuTaE"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Discord"
|
title="Discord"
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -45,12 +45,12 @@ import {
|
|||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
|
import { CaptureUpdateAction, StoreDelta } from "../store";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { HistoryEntry } from "../history";
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import * as StaticScene from "../renderer/staticScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { Snapshot, CaptureUpdateAction } from "../store";
|
|
||||||
import { AppStateChange, ElementsChange } from "../change";
|
import { ElementsDelta, AppStateDelta } from "../delta.js";
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
@@ -82,13 +82,52 @@ const checkpoint = (name: string) => {
|
|||||||
...strippedAppState
|
...strippedAppState
|
||||||
} = h.state;
|
} = h.state;
|
||||||
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
||||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
|
||||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||||
h.elements
|
h.elements
|
||||||
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
||||||
.forEach((element, i) =>
|
.forEach((element, i) =>
|
||||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
|
||||||
|
Object.entries(deltas).reduce((acc, curr) => {
|
||||||
|
const { inserted, deleted, ...rest } = curr[1];
|
||||||
|
|
||||||
|
delete inserted.seed;
|
||||||
|
delete deleted.seed;
|
||||||
|
|
||||||
|
acc[curr[0]] = {
|
||||||
|
inserted,
|
||||||
|
deleted,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
h.history.undoStack.map((x) => ({
|
||||||
|
...x,
|
||||||
|
elementsChange: {
|
||||||
|
...x.elements,
|
||||||
|
added: stripSeed(x.elements.added),
|
||||||
|
removed: stripSeed(x.elements.updated),
|
||||||
|
updated: stripSeed(x.elements.removed),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).toMatchSnapshot(`[${name}] undo stack`);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
h.history.redoStack.map((x) => ({
|
||||||
|
...x,
|
||||||
|
elementsChange: {
|
||||||
|
...x.elements,
|
||||||
|
added: stripSeed(x.elements.added),
|
||||||
|
removed: stripSeed(x.elements.updated),
|
||||||
|
updated: stripSeed(x.elements.removed),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).toMatchSnapshot(`[${name}] redo stack`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
@@ -116,12 +155,12 @@ describe("history", () => {
|
|||||||
|
|
||||||
API.setElements([rect]);
|
API.setElements([rect]);
|
||||||
|
|
||||||
const corrupedEntry = HistoryEntry.create(
|
const corrupedEntry = StoreDelta.create(
|
||||||
AppStateChange.empty(),
|
ElementsDelta.empty(),
|
||||||
ElementsChange.empty(),
|
AppStateDelta.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
|
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||||
throw new Error("Oh no, I am corrupted!");
|
throw new Error("Oh no, I am corrupted!");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +175,6 @@ describe("history", () => {
|
|||||||
h.history.undo(
|
h.history.undo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -157,7 +195,6 @@ describe("history", () => {
|
|||||||
h.history.redo(
|
h.history.redo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -454,8 +491,8 @@ describe("history", () => {
|
|||||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
// noop
|
// noop
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@@ -531,8 +568,8 @@ describe("history", () => {
|
|||||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
@@ -1713,8 +1750,8 @@ describe("history", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
@@ -1763,7 +1800,7 @@ describe("history", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
@@ -3500,7 +3537,7 @@ describe("history", () => {
|
|||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: container.id,
|
id: container.id,
|
||||||
// rebound the text as we captured the full bidirectional binding in history!
|
// rebound the text as we recorded the full bidirectional binding in history!
|
||||||
boundElements: [{ id: text.id, type: "text" }],
|
boundElements: [{ id: text.id, type: "text" }],
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ import type Library from "./data/library";
|
|||||||
import type { FileSystemHandle } from "./data/filesystem";
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import type { SnapLine } from "./snapping";
|
import type { SnapLine } from "./snapping";
|
||||||
import type { CaptureUpdateActionType } from "./store";
|
import type {
|
||||||
|
CaptureUpdateActionType,
|
||||||
|
DurableStoreIncrement,
|
||||||
|
EphemeralStoreIncrement,
|
||||||
|
} from "./store";
|
||||||
import type { ImportedDataState } from "./data/types";
|
import type { ImportedDataState } from "./data/types";
|
||||||
|
|
||||||
import type { Language } from "./i18n";
|
import type { Language } from "./i18n";
|
||||||
@@ -518,6 +522,9 @@ export interface ExcalidrawProps {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
onIncrement?: (
|
||||||
|
event: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||||
|
) => void;
|
||||||
initialData?:
|
initialData?:
|
||||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||||
@@ -714,7 +721,6 @@ export type AppClassProperties = {
|
|||||||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||||||
|
|
||||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||||
updateEditorAtom: App["updateEditorAtom"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
@@ -794,6 +800,7 @@ export interface ExcalidrawImperativeAPI {
|
|||||||
history: {
|
history: {
|
||||||
clear: InstanceType<typeof App>["resetHistory"];
|
clear: InstanceType<typeof App>["resetHistory"];
|
||||||
};
|
};
|
||||||
|
store: InstanceType<typeof App>["store"];
|
||||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||||
getAppState: () => InstanceType<typeof App>["state"];
|
getAppState: () => InstanceType<typeof App>["state"];
|
||||||
getFiles: () => InstanceType<typeof App>["files"];
|
getFiles: () => InstanceType<typeof App>["files"];
|
||||||
@@ -821,6 +828,9 @@ export interface ExcalidrawImperativeAPI {
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void,
|
) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
|
onIncrement: (
|
||||||
|
callback: (event: DurableStoreIncrement | EphemeralStoreIncrement) => void,
|
||||||
|
) => UnsubscribeCallback;
|
||||||
onPointerDown: (
|
onPointerDown: (
|
||||||
callback: (
|
callback: (
|
||||||
activeTool: AppState["activeTool"],
|
activeTool: AppState["activeTool"],
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ const getTransform = (
|
|||||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubmitHandler = () => void;
|
|
||||||
|
|
||||||
export const textWysiwyg = ({
|
export const textWysiwyg = ({
|
||||||
id,
|
id,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -108,7 +106,7 @@ export const textWysiwyg = ({
|
|||||||
excalidrawContainer: HTMLDivElement | null;
|
excalidrawContainer: HTMLDivElement | null;
|
||||||
app: App;
|
app: App;
|
||||||
autoSelect?: boolean;
|
autoSelect?: boolean;
|
||||||
}): SubmitHandler => {
|
}) => {
|
||||||
const textPropertiesUpdated = (
|
const textPropertiesUpdated = (
|
||||||
updatedTextElement: ExcalidrawTextElement,
|
updatedTextElement: ExcalidrawTextElement,
|
||||||
editable: HTMLTextAreaElement,
|
editable: HTMLTextAreaElement,
|
||||||
@@ -188,6 +186,7 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
||||||
|
|
||||||
maxHeight = getBoundTextMaxHeight(
|
maxHeight = getBoundTextMaxHeight(
|
||||||
container,
|
container,
|
||||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
@@ -736,6 +735,4 @@ export const textWysiwyg = ({
|
|||||||
excalidrawContainer
|
excalidrawContainer
|
||||||
?.querySelector(".excalidraw-textEditorContainer")!
|
?.querySelector(".excalidraw-textEditorContainer")!
|
||||||
.appendChild(editable);
|
.appendChild(editable);
|
||||||
|
|
||||||
return handleSubmit;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Bounds } from "@excalidraw/element/bounds";
|
|||||||
|
|
||||||
import { isPoint, pointDistance, pointFrom } from "./point";
|
import { isPoint, pointDistance, pointFrom } from "./point";
|
||||||
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
||||||
import { vector } from "./vector";
|
|
||||||
|
|
||||||
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ function solve(
|
|||||||
return [t0, s0];
|
return [t0, s0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
||||||
c: Curve<Point>,
|
c: Curve<Point>,
|
||||||
t: number,
|
t: number,
|
||||||
) =>
|
) =>
|
||||||
@@ -275,26 +274,6 @@ export function isCurve<P extends GlobalPoint | LocalPoint>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function curveTangent<Point extends GlobalPoint | LocalPoint>(
|
|
||||||
[p0, p1, p2, p3]: Curve<Point>,
|
|
||||||
t: number,
|
|
||||||
) {
|
|
||||||
return vector(
|
|
||||||
-3 * (1 - t) * (1 - t) * p0[0] +
|
|
||||||
3 * (1 - t) * (1 - t) * p1[0] -
|
|
||||||
6 * t * (1 - t) * p1[0] -
|
|
||||||
3 * t * t * p2[0] +
|
|
||||||
6 * t * (1 - t) * p2[0] +
|
|
||||||
3 * t * t * p3[0],
|
|
||||||
-3 * (1 - t) * (1 - t) * p0[1] +
|
|
||||||
3 * (1 - t) * (1 - t) * p1[1] -
|
|
||||||
6 * t * (1 - t) * p1[1] -
|
|
||||||
3 * t * t * p2[1] +
|
|
||||||
6 * t * (1 - t) * p2[1] +
|
|
||||||
3 * t * t * p3[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function curveBounds<Point extends GlobalPoint | LocalPoint>(
|
function curveBounds<Point extends GlobalPoint | LocalPoint>(
|
||||||
c: Curve<Point>,
|
c: Curve<Point>,
|
||||||
): Bounds {
|
): Bounds {
|
||||||
|
|||||||
@@ -143,8 +143,3 @@ export const vectorNormalize = (v: Vector): Vector => {
|
|||||||
|
|
||||||
return vector(v[0] / m, v[1] / m);
|
return vector(v[0] / m, v[1] / m);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the right-hand normal of the vector.
|
|
||||||
*/
|
|
||||||
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const { build } = require("esbuild");
|
||||||
|
|
||||||
|
const rawConfig = {
|
||||||
|
entryPoints: ["src/index.ts"],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
metafile: true,
|
||||||
|
treeShaking: true,
|
||||||
|
external: ["*.scss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createESMRawBuild = async () => {
|
||||||
|
// Development unminified build with source maps
|
||||||
|
const dev = await build({
|
||||||
|
...rawConfig,
|
||||||
|
outdir: "dist/dev",
|
||||||
|
sourcemap: true,
|
||||||
|
define: {
|
||||||
|
"import.meta.env": JSON.stringify({ DEV: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync("meta-dev.json", JSON.stringify(dev.metafile));
|
||||||
|
|
||||||
|
// production minified build without sourcemaps
|
||||||
|
const prod = await build({
|
||||||
|
...rawConfig,
|
||||||
|
outdir: "dist/prod",
|
||||||
|
minify: true,
|
||||||
|
define: {
|
||||||
|
"import.meta.env": JSON.stringify({ PROD: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync("meta-prod.json", JSON.stringify(prod.metafile));
|
||||||
|
};
|
||||||
|
|
||||||
|
createESMRawBuild();
|
||||||
@@ -4049,6 +4049,11 @@ chrome-trace-event@^1.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
|
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
|
||||||
integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==
|
integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==
|
||||||
|
|
||||||
|
classnames@^2.2.5:
|
||||||
|
version "2.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
|
||||||
|
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
||||||
|
|
||||||
clean-css@^5.2.2:
|
clean-css@^5.2.2:
|
||||||
version "5.3.3"
|
version "5.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd"
|
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd"
|
||||||
@@ -7400,6 +7405,11 @@ nanoid@4.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
|
||||||
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
|
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
|
||||||
|
|
||||||
|
nanoid@5.0.9:
|
||||||
|
version "5.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.9.tgz#977dcbaac055430ce7b1e19cf0130cea91a20e50"
|
||||||
|
integrity sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==
|
||||||
|
|
||||||
nanoid@^3.3.2:
|
nanoid@^3.3.2:
|
||||||
version "3.3.9"
|
version "3.3.9"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.9.tgz#e0097d8e026b3343ff053e9ccd407360a03f503a"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.9.tgz#e0097d8e026b3343ff053e9ccd407360a03f503a"
|
||||||
@@ -8062,6 +8072,23 @@ randombytes@^2.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "^5.1.0"
|
safe-buffer "^5.1.0"
|
||||||
|
|
||||||
|
rc-slider@11.1.7:
|
||||||
|
version "11.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-11.1.7.tgz#3de333b1ec84d53a7bda2f816bb4779423628f09"
|
||||||
|
integrity sha512-ytYbZei81TX7otdC0QvoYD72XSlxvTihNth5OeZ6PMXyEDq/vHdWFulQmfDGyXK1NwKwSlKgpvINOa88uT5g2A==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.10.1"
|
||||||
|
classnames "^2.2.5"
|
||||||
|
rc-util "^5.36.0"
|
||||||
|
|
||||||
|
rc-util@^5.36.0:
|
||||||
|
version "5.43.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.43.0.tgz#bba91fbef2c3e30ea2c236893746f3e9b05ecc4c"
|
||||||
|
integrity sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.18.3"
|
||||||
|
react-is "^18.2.0"
|
||||||
|
|
||||||
react-dom@19.0.0:
|
react-dom@19.0.0:
|
||||||
version "19.0.0"
|
version "19.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57"
|
||||||
@@ -8079,7 +8106,7 @@ react-is@^17.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||||
|
|
||||||
react-is@^18.0.0:
|
react-is@^18.0.0, react-is@^18.2.0:
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||||
@@ -8364,6 +8391,16 @@ roughjs@4.6.4:
|
|||||||
points-on-curve "^0.2.0"
|
points-on-curve "^0.2.0"
|
||||||
points-on-path "^0.2.1"
|
points-on-path "^0.2.1"
|
||||||
|
|
||||||
|
roughjs@4.6.6:
|
||||||
|
version "4.6.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.6.6.tgz#1059f49a5e0c80dee541a005b20cc322b222158b"
|
||||||
|
integrity sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==
|
||||||
|
dependencies:
|
||||||
|
hachure-fill "^0.5.2"
|
||||||
|
path-data-parser "^0.1.0"
|
||||||
|
points-on-curve "^0.2.0"
|
||||||
|
points-on-path "^0.2.1"
|
||||||
|
|
||||||
rrweb-cssom@^0.6.0:
|
rrweb-cssom@^0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1"
|
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1"
|
||||||
|
|||||||
Reference in New Issue
Block a user