mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Syncing ephemeral element updates
This commit is contained in:
parent
c57249481e
commit
310a9ae4e0
60 changed files with 1104 additions and 906 deletions
|
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
|
||||||
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
||||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
||||||
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
||||||
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
|
| `SnapshotAction` | `SnapshotAction` | Implies if the change should be captured by the `store`. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `SnapshotAction.CAPTURE`. |
|
||||||
|
|
||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
StoreAction,
|
SnapshotAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
newElementWith,
|
newElementWith,
|
||||||
} from "../packages/excalidraw";
|
} from "../packages/excalidraw";
|
||||||
|
@ -44,6 +44,7 @@ import {
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
isRunningInIframe,
|
isRunningInIframe,
|
||||||
|
assertNever,
|
||||||
} from "../packages/excalidraw/utils";
|
} from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
FIREBASE_STORAGE_PREFIXES,
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
|
@ -109,7 +110,11 @@ import Trans from "../packages/excalidraw/components/Trans";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
||||||
import type { StoreIncrement } from "../packages/excalidraw/store";
|
import type {
|
||||||
|
DurableStoreIncrement,
|
||||||
|
EphemeralStoreIncrement,
|
||||||
|
} from "../packages/excalidraw/store";
|
||||||
|
import { StoreDelta, StoreIncrement } from "../packages/excalidraw/store";
|
||||||
import {
|
import {
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
|
@ -370,20 +375,30 @@ const ExcalidrawWrapper = () => {
|
||||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
const [collabAPI] = useAtom(collabAPIAtom);
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
const [syncAPI] = useAtom(syncApiAtom);
|
const [syncAPI] = useAtom(syncApiAtom);
|
||||||
const [nextVersion, setNextVersion] = useState(-1);
|
const [sliderVersion, setSliderVersion] = useState(0);
|
||||||
const currentVersion = useRef(-1);
|
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
|
||||||
const [acknowledgedIncrements, setAcknowledgedIncrements] = useState<
|
[],
|
||||||
StoreIncrement[]
|
);
|
||||||
>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setAcknowledgedIncrements([...(syncAPI?.acknowledgedIncrements ?? [])]);
|
const deltas = syncAPI?.acknowledgedDeltas ?? [];
|
||||||
}, 250);
|
|
||||||
|
// CFDO: buffer local deltas as well, not only acknowledged ones
|
||||||
|
if (deltas.length > acknowledgedDeltasRef.current.length) {
|
||||||
|
setAcknowledgedDeltas([...deltas]);
|
||||||
|
setSliderVersion(deltas.length);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
syncAPI?.connect();
|
syncAPI?.connect();
|
||||||
|
|
||||||
|
@ -512,7 +527,7 @@ const ExcalidrawWrapper = () => {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
...restore(data.scene, null, null, { repairBindings: true }),
|
...restore(data.scene, null, null, { repairBindings: true }),
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -539,7 +554,7 @@ const ExcalidrawWrapper = () => {
|
||||||
setLangCode(getPreferredLanguage());
|
setLangCode(getPreferredLanguage());
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
LibraryIndexedDBAdapter.load().then((data) => {
|
LibraryIndexedDBAdapter.load().then((data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
@ -671,7 +686,7 @@ const ExcalidrawWrapper = () => {
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -689,18 +704,31 @@ const ExcalidrawWrapper = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onIncrement = (increment: StoreIncrement) => {
|
const onIncrement = (
|
||||||
// ephemerals are not part of this (which is alright)
|
increment: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||||
// - wysiwyg, dragging elements / points, mouse movements, etc.
|
) => {
|
||||||
const { elementsChange } = increment;
|
try {
|
||||||
|
if (!syncAPI) {
|
||||||
// CFDO: some appState like selections should also be transfered (we could even persist it)
|
return;
|
||||||
if (!elementsChange.isEmpty()) {
|
|
||||||
try {
|
|
||||||
syncAPI?.push(increment);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -826,46 +854,48 @@ const ExcalidrawWrapper = () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedTimeTravel = debounce((value: number) => {
|
const debouncedTimeTravel = debounce(
|
||||||
let elements = new Map(
|
(value: number, direction: "forward" | "backward") => {
|
||||||
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
|
let elements = new Map(
|
||||||
);
|
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
|
||||||
|
|
||||||
let increments: StoreIncrement[] = [];
|
|
||||||
|
|
||||||
const goingLeft =
|
|
||||||
currentVersion.current === -1 || value - currentVersion.current <= 0;
|
|
||||||
|
|
||||||
if (goingLeft) {
|
|
||||||
increments = acknowledgedIncrements
|
|
||||||
.slice(value)
|
|
||||||
.reverse()
|
|
||||||
.map((x) => x.inverse());
|
|
||||||
} else {
|
|
||||||
increments =
|
|
||||||
acknowledgedIncrements.slice(currentVersion.current, value) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const increment of increments) {
|
|
||||||
[elements] = increment.elementsChange.applyTo(
|
|
||||||
elements as SceneElementsMap,
|
|
||||||
excalidrawAPI?.store.snapshot.elements!,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
excalidrawAPI?.updateScene({
|
let deltas: StoreDelta[] = [];
|
||||||
appState: {
|
|
||||||
...excalidrawAPI?.getAppState(),
|
|
||||||
viewModeEnabled: value !== -1,
|
|
||||||
},
|
|
||||||
elements: Array.from(elements.values()),
|
|
||||||
storeAction: StoreAction.UPDATE,
|
|
||||||
});
|
|
||||||
|
|
||||||
currentVersion.current = value;
|
switch (direction) {
|
||||||
}, 0);
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
const latestVersion = acknowledgedIncrements.length;
|
for (const delta of deltas) {
|
||||||
|
[elements] = delta.elements.applyTo(
|
||||||
|
elements as SceneElementsMap,
|
||||||
|
excalidrawAPI?.store.snapshot.elements!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
excalidrawAPI?.updateScene({
|
||||||
|
appState: {
|
||||||
|
...excalidrawAPI?.getAppState(),
|
||||||
|
viewModeEnabled: value !== acknowledgedDeltas.length,
|
||||||
|
},
|
||||||
|
elements: Array.from(elements.values()),
|
||||||
|
snapshotAction: SnapshotAction.NONE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -884,25 +914,30 @@ const ExcalidrawWrapper = () => {
|
||||||
}}
|
}}
|
||||||
step={1}
|
step={1}
|
||||||
min={0}
|
min={0}
|
||||||
max={latestVersion}
|
max={acknowledgedDeltas.length}
|
||||||
value={nextVersion === -1 ? latestVersion : nextVersion}
|
value={sliderVersion}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
let nextValue: number;
|
const nextSliderVersion = value as number;
|
||||||
|
// CFDO II: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
|
||||||
// CFDO: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
|
|
||||||
// CFDO: in safari the whole canvas gets selected when dragging
|
// CFDO: in safari the whole canvas gets selected when dragging
|
||||||
if (value !== acknowledgedIncrements.length) {
|
if (nextSliderVersion !== acknowledgedDeltas.length) {
|
||||||
// don't listen to updates in the detached mode
|
// don't listen to updates in the detached mode
|
||||||
syncAPI?.disconnect();
|
syncAPI?.disconnect();
|
||||||
nextValue = value as number;
|
|
||||||
} else {
|
} else {
|
||||||
// reconnect once we're back to the latest version
|
// reconnect once we're back to the latest version
|
||||||
syncAPI?.connect();
|
syncAPI?.connect();
|
||||||
nextValue = -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setNextVersion(nextValue);
|
if (nextSliderVersion === sliderVersion) {
|
||||||
debouncedTimeTravel(nextValue);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedTimeTravel(
|
||||||
|
nextSliderVersion,
|
||||||
|
nextSliderVersion < sliderVersion ? "backward" : "forward",
|
||||||
|
);
|
||||||
|
|
||||||
|
setSliderVersion(nextSliderVersion);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
|
|
|
@ -15,7 +15,7 @@ import type {
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
StoreAction,
|
SnapshotAction,
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
restoreElements,
|
restoreElements,
|
||||||
zoomToFitBounds,
|
zoomToFitBounds,
|
||||||
|
@ -393,7 +393,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -544,7 +544,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
// to database even if deleted before creating the room.
|
// to database even if deleted before creating the room.
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
|
@ -782,7 +782,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
) => {
|
) => {
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loadImageFiles();
|
this.loadImageFiles();
|
||||||
|
|
|
@ -19,7 +19,7 @@ import throttle from "lodash.throttle";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import { StoreAction } from "../../packages/excalidraw";
|
import { SnapshotAction } from "../../packages/excalidraw";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: TCollabClass;
|
collab: TCollabClass;
|
||||||
|
@ -133,7 +133,7 @@ class Portal {
|
||||||
if (isChanged) {
|
if (isChanged) {
|
||||||
this.collab.excalidrawAPI.updateScene({
|
this.collab.excalidrawAPI.updateScene({
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { StoreAction } from "../../packages/excalidraw";
|
import { SnapshotAction } from "../../packages/excalidraw";
|
||||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
|
@ -268,6 +268,6 @@ export const updateStaleImageStatuses = (params: {
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -45,7 +45,7 @@ import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
import { Locker } from "./Locker";
|
import { Locker } from "./Locker";
|
||||||
import { updateBrowserStateVersion } from "./tabSync";
|
import { updateBrowserStateVersion } from "./tabSync";
|
||||||
import { StoreIncrement } from "../../packages/excalidraw/store";
|
import { StoreDelta } from "../../packages/excalidraw/store";
|
||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
|
@ -260,7 +260,7 @@ export class LibraryLocalStorageMigrationAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncIncrementPersistedData = DTO<StoreIncrement>[];
|
type SyncDeltaPersistedData = DTO<StoreDelta>[];
|
||||||
|
|
||||||
type SyncMetaPersistedData = {
|
type SyncMetaPersistedData = {
|
||||||
lastAcknowledgedVersion: number;
|
lastAcknowledgedVersion: number;
|
||||||
|
@ -270,7 +270,7 @@ export class SyncIndexedDBAdapter {
|
||||||
/** IndexedDB database and store name */
|
/** IndexedDB database and store name */
|
||||||
private static idb_name = STORAGE_KEYS.IDB_SYNC;
|
private static idb_name = STORAGE_KEYS.IDB_SYNC;
|
||||||
/** library data store keys */
|
/** library data store keys */
|
||||||
private static incrementsKey = "increments";
|
private static deltasKey = "deltas";
|
||||||
private static metadataKey = "metadata";
|
private static metadataKey = "metadata";
|
||||||
|
|
||||||
private static store = createStore(
|
private static store = createStore(
|
||||||
|
@ -278,24 +278,22 @@ export class SyncIndexedDBAdapter {
|
||||||
`${SyncIndexedDBAdapter.idb_name}-store`,
|
`${SyncIndexedDBAdapter.idb_name}-store`,
|
||||||
);
|
);
|
||||||
|
|
||||||
static async loadIncrements() {
|
static async loadDeltas() {
|
||||||
const increments = await get<SyncIncrementPersistedData>(
|
const deltas = await get<SyncDeltaPersistedData>(
|
||||||
SyncIndexedDBAdapter.incrementsKey,
|
SyncIndexedDBAdapter.deltasKey,
|
||||||
SyncIndexedDBAdapter.store,
|
SyncIndexedDBAdapter.store,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (increments?.length) {
|
if (deltas?.length) {
|
||||||
return increments.map((storeIncrementDTO) =>
|
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
|
||||||
StoreIncrement.restore(storeIncrementDTO),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveIncrements(data: SyncIncrementPersistedData): Promise<void> {
|
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
|
||||||
return set(
|
return set(
|
||||||
SyncIndexedDBAdapter.incrementsKey,
|
SyncIndexedDBAdapter.deltasKey,
|
||||||
data,
|
data,
|
||||||
SyncIndexedDBAdapter.store,
|
SyncIndexedDBAdapter.store,
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "../../packages/excalidraw/actions/actionHistory";
|
} from "../../packages/excalidraw/actions/actionHistory";
|
||||||
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
import { SnapshotAction, newElementWith } from "../../packages/excalidraw";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ describe("collaboration", () => {
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1, rect2]),
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
|
@ -97,7 +97,7 @@ describe("collaboration", () => {
|
||||||
rect1,
|
rect1,
|
||||||
newElementWith(h.elements[1], { isDeleted: true }),
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
]),
|
]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -144,7 +144,7 @@ describe("collaboration", () => {
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -182,7 +182,7 @@ describe("collaboration", () => {
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
]),
|
]),
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -217,7 +217,7 @@ describe("collaboration", () => {
|
||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// snapshot was correctly updated and marked the element as deleted
|
// snapshot was correctly updated and marked the element as deleted
|
||||||
|
|
|
@ -49,8 +49,8 @@ Please add the latest change on the top under the correct section.
|
||||||
|
|
||||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be captured by the store & history. Should be used for the most of the local updates (excluding ephemeral updates such as dragging or resizing). These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||||
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be captured immediately (likely exceptions which are part of some async multi-step process) or those not meant to be captured at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being captured with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||||
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { deepCopyElement } from "../element/newElement";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
|
@ -18,7 +18,7 @@ export const actionAddToLibrary = register({
|
||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (selectedElements.some((element) => element.type === type)) {
|
if (selectedElements.some((element) => element.type === type)) {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
|
@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.addedToLibrary") },
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
|
@ -51,7 +51,7 @@ export const actionAddToLibrary = register({
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@ -72,7 +72,7 @@ export const actionAlignTop = register({
|
||||||
position: "start",
|
position: "start",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -106,7 +106,7 @@ export const actionAlignBottom = register({
|
||||||
position: "end",
|
position: "end",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -140,7 +140,7 @@ export const actionAlignLeft = register({
|
||||||
position: "start",
|
position: "start",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -174,7 +174,7 @@ export const actionAlignRight = register({
|
||||||
position: "end",
|
position: "end",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -208,7 +208,7 @@ export const actionAlignVerticallyCentered = register({
|
||||||
position: "center",
|
position: "center",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
|
@ -238,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||||
position: "center",
|
position: "center",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
|
|
|
@ -34,7 +34,7 @@ import type { Mutable } from "../utility-types";
|
||||||
import { arrayToMap, getFontString } from "../utils";
|
import { arrayToMap, getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
@ -86,7 +86,7 @@ export const actionUnbindText = register({
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -163,7 +163,7 @@ export const actionBindText = register({
|
||||||
return {
|
return {
|
||||||
elements: pushTextAboveContainer(elements, container, textElement),
|
elements: pushTextAboveContainer(elements, container, textElement),
|
||||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -323,7 +323,7 @@ export const actionWrapTextInContainer = register({
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: containerIds,
|
selectedElementIds: containerIds,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -37,7 +37,7 @@ import {
|
||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import type { SceneBounds } from "../element/bounds";
|
import type { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { clamp, roundToStep } from "../../math";
|
import { clamp, roundToStep } from "../../math";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
|
@ -55,8 +55,8 @@ export const actionChangeViewBackgroundColor = register({
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, ...value },
|
||||||
storeAction: !!value.viewBackgroundColor
|
storeAction: !!value.viewBackgroundColor
|
||||||
? StoreAction.CAPTURE
|
? SnapshotAction.CAPTURE
|
||||||
: StoreAction.NONE,
|
: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||||
|
@ -115,7 +115,7 @@ export const actionClearCanvas = register({
|
||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -140,7 +140,7 @@ export const actionZoomIn = register({
|
||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
|
@ -181,7 +181,7 @@ export const actionZoomOut = register({
|
||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
|
@ -222,7 +222,7 @@ export const actionResetZoom = register({
|
||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
|
@ -341,7 +341,7 @@ export const zoomToFitBounds = ({
|
||||||
scrollY: centerScroll.scrollY,
|
scrollY: centerScroll.scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -472,7 +472,7 @@ export const actionToggleTheme = register({
|
||||||
theme:
|
theme:
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||||
|
@ -510,7 +510,7 @@ export const actionToggleEraserTool = register({
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.E,
|
keyTest: (event) => event.key === KEYS.E,
|
||||||
|
@ -549,7 +549,7 @@ export const actionToggleHandTool = register({
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { getTextFromElements, isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
@ -32,7 +32,7 @@ export const actionCopy = register({
|
||||||
await copyToClipboard(elementsToCopy, app.files, event);
|
await copyToClipboard(elementsToCopy, app.files, event);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
|
@ -41,7 +41,7 @@ export const actionCopy = register({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
|
@ -67,7 +67,7 @@ export const actionPaste = register({
|
||||||
|
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("hints.firefox_clipboard_write"),
|
errorMessage: t("hints.firefox_clipboard_write"),
|
||||||
|
@ -76,7 +76,7 @@ export const actionPaste = register({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||||
|
@ -89,7 +89,7 @@ export const actionPaste = register({
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||||
|
@ -98,7 +98,7 @@ export const actionPaste = register({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
|
@ -125,7 +125,7 @@ export const actionCopyAsSvg = register({
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,7 +167,7 @@ export const actionCopyAsSvg = register({
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -175,7 +175,7 @@ export const actionCopyAsSvg = register({
|
||||||
appState: {
|
appState: {
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -193,7 +193,7 @@ export const actionCopyAsPng = register({
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
@ -227,7 +227,7 @@ export const actionCopyAsPng = register({
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -236,7 +236,7 @@ export const actionCopyAsPng = register({
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -263,7 +263,7 @@ export const copyText = register({
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { cropIcon } from "../components/icons";
|
import { cropIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isImageElement } from "../element/typeChecks";
|
import { isImageElement } from "../element/typeChecks";
|
||||||
|
@ -25,7 +25,7 @@ export const actionToggleCropEditor = register({
|
||||||
isCropping: false,
|
isCropping: false,
|
||||||
croppingElementId: selectedElement.id,
|
croppingElementId: selectedElement.id,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
@ -189,7 +189,7 @@ export const actionDeleteSelected = register({
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,7 +221,7 @@ export const actionDeleteSelected = register({
|
||||||
: [0],
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
|
@ -245,8 +245,8 @@ export const actionDeleteSelected = register({
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
)
|
)
|
||||||
? StoreAction.CAPTURE
|
? SnapshotAction.CAPTURE
|
||||||
: StoreAction.NONE,
|
: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@ -60,7 +60,7 @@ export const distributeHorizontally = register({
|
||||||
space: "between",
|
space: "between",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -91,7 +91,7 @@ export const distributeVertically = register({
|
||||||
space: "between",
|
space: "between",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -32,7 +32,7 @@ import {
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
@ -52,7 +52,7 @@ export const actionDuplicateSelection = register({
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: newAppState,
|
appState: newAppState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
@ -61,7 +61,7 @@ export const actionDuplicateSelection = register({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...duplicateElements(elements, appState),
|
...duplicateElements(elements, appState),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ export const actionToggleElementLock = register({
|
||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState, elements, app) => {
|
keyTest: (event, appState, elements, app) => {
|
||||||
|
@ -112,7 +112,7 @@ export const actionUnlockAllElements = register({
|
||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
label: "labels.elementLock.unlockAll",
|
label: "labels.elementLock.unlockAll",
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import type { Theme } from "../element/types";
|
import type { Theme } from "../element/types";
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
|
@ -28,7 +28,7 @@ export const actionChangeProjectName = register({
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, name: value },
|
appState: { ...appState, name: value },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||||
|
@ -48,7 +48,7 @@ export const actionChangeExportScale = register({
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportScale: value },
|
appState: { ...appState, exportScale: value },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||||
|
@ -98,7 +98,7 @@ export const actionChangeExportBackground = register({
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportBackground: value },
|
appState: { ...appState, exportBackground: value },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
@ -118,7 +118,7 @@ export const actionChangeExportEmbedScene = register({
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
@ -160,7 +160,7 @@ export const actionSaveToActiveFile = register({
|
||||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
|
@ -182,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { storeAction: StoreAction.NONE };
|
return { storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// CFDO: temporary
|
// CFDO: temporary
|
||||||
|
@ -208,7 +208,7 @@ export const actionSaveFileToDisk = register({
|
||||||
app.getName(),
|
app.getName(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
|
@ -222,7 +222,7 @@ export const actionSaveFileToDisk = register({
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { storeAction: StoreAction.NONE };
|
return { storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -261,7 +261,7 @@ export const actionLoadScene = register({
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
files,
|
files,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
|
@ -272,7 +272,7 @@ export const actionLoadScene = register({
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
files: app.files,
|
files: app.files,
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -286,7 +286,7 @@ export const actionExportWithDarkMode = register({
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportWithDarkMode: value },
|
appState: { ...appState, exportWithDarkMode: value },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
import { isPathALoop } from "../shapes";
|
import { isPathALoop } from "../shapes";
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ export const actionFinalize = register({
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -199,7 +199,7 @@ export const actionFinalize = register({
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
@ -47,7 +47,7 @@ export const actionFlipHorizontal = register({
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||||
|
@ -72,7 +72,7 @@ export const actionFlipVertical = register({
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -9,11 +9,11 @@ import { setCursorForShape } from "../cursor";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { newFrameElement } from "../element/newElement";
|
import { newFrameElement } from "../element/newElement";
|
||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
|
@ -49,14 +49,14 @@ export const actionSelectAllElementsInFrame = register({
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
|
@ -80,14 +80,14 @@ export const actionRemoveAllElementsFromFrame = register({
|
||||||
[selectedElement.id]: true,
|
[selectedElement.id]: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
|
@ -109,7 +109,7 @@ export const actionupdateFrameRendering = register({
|
||||||
enabled: !appState.frameRendering.enabled,
|
enabled: !appState.frameRendering.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||||
|
@ -139,7 +139,7 @@ export const actionSetFrameAsActiveTool = register({
|
||||||
type: "frame",
|
type: "frame",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -34,7 +34,7 @@ import {
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
|
@ -84,7 +84,7 @@ export const actionGroup = register({
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
return { appState, elements, storeAction: StoreAction.NONE };
|
return { appState, elements, storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
// if everything is already grouped into 1 group, there is nothing to do
|
// if everything is already grouped into 1 group, there is nothing to do
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
@ -104,7 +104,7 @@ export const actionGroup = register({
|
||||||
]);
|
]);
|
||||||
if (combinedSet.size === elementIdsInGroup.size) {
|
if (combinedSet.size === elementIdsInGroup.size) {
|
||||||
// no incremental ids in the selected ids
|
// no incremental ids in the selected ids
|
||||||
return { appState, elements, storeAction: StoreAction.NONE };
|
return { appState, elements, storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,7 +170,7 @@ export const actionGroup = register({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
elements: reorderedElements,
|
elements: reorderedElements,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
|
@ -200,7 +200,7 @@ export const actionUngroup = register({
|
||||||
const elementsMap = arrayToMap(elements);
|
const elementsMap = arrayToMap(elements);
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, storeAction: StoreAction.NONE };
|
return { appState, elements, storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextElements = [...elements];
|
let nextElements = [...elements];
|
||||||
|
@ -273,7 +273,7 @@ export const actionUngroup = register({
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...updateAppState },
|
appState: { ...appState, ...updateAppState },
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -9,8 +9,7 @@ import { KEYS, matchKey } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
import type { SceneElementsMap } from "../element/types";
|
import type { SceneElementsMap } from "../element/types";
|
||||||
import type { Store } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { StoreAction } from "../store";
|
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const executeHistoryAction = (
|
const executeHistoryAction = (
|
||||||
|
@ -30,7 +29,7 @@ const executeHistoryAction = (
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return { storeAction: StoreAction.NONE };
|
return { storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [nextElementsMap, nextAppState] = result;
|
const [nextElementsMap, nextAppState] = result;
|
||||||
|
@ -39,11 +38,11 @@ const executeHistoryAction = (
|
||||||
return {
|
return {
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: SnapshotAction.UPDATE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { storeAction: StoreAction.NONE };
|
return { storeAction: SnapshotAction.NONE };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History) => Action;
|
type ActionCreator = (history: History) => Action;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||||
import type { ExcalidrawLinearElement } from "../element/types";
|
import type { ExcalidrawLinearElement } from "../element/types";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
@ -51,7 +51,7 @@ export const actionToggleLinearEditor = register({
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement,
|
editingLinearElement,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, app }) => {
|
PanelComponent: ({ appState, updateData, app }) => {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ export const actionLink = register({
|
||||||
showHyperlinkPopup: "editor",
|
showHyperlinkPopup: "editor",
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
trackEvent: { category: "hyperlink", action: "click" },
|
trackEvent: { category: "hyperlink", action: "click" },
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { t } from "../i18n";
|
||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
|
@ -15,7 +15,7 @@ export const actionToggleCanvasMenu = register({
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@ -37,7 +37,7 @@ export const actionToggleEditMenu = register({
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@ -74,7 +74,7 @@ export const actionShortcuts = register({
|
||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import type { Collaborator } from "../types";
|
import type { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
@ -28,7 +28,7 @@ export const actionGoToCollaborator = register({
|
||||||
...appState,
|
...appState,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
|
||||||
// Close mobile menu
|
// Close mobile menu
|
||||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data, appState }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
import type { StoreActionType } from "../store";
|
import type { SnapshotActionType } from "../store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
|
@ -109,7 +109,7 @@ import {
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { Fonts, getLineHeight } from "../fonts";
|
import { Fonts, getLineHeight } from "../fonts";
|
||||||
import {
|
import {
|
||||||
bindLinearElement,
|
bindLinearElement,
|
||||||
|
@ -270,7 +270,7 @@ const changeFontSize = (
|
||||||
? [...newFontSizes][0]
|
? [...newFontSizes][0]
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -301,8 +301,8 @@ export const actionChangeStrokeColor = register({
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
storeAction: !!value.currentItemStrokeColor
|
storeAction: !!value.currentItemStrokeColor
|
||||||
? StoreAction.CAPTURE
|
? SnapshotAction.CAPTURE
|
||||||
: StoreAction.NONE,
|
: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
|
@ -347,8 +347,8 @@ export const actionChangeBackgroundColor = register({
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
storeAction: !!value.currentItemBackgroundColor
|
storeAction: !!value.currentItemBackgroundColor
|
||||||
? StoreAction.CAPTURE
|
? SnapshotAction.CAPTURE
|
||||||
: StoreAction.NONE,
|
: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
|
@ -392,7 +392,7 @@ export const actionChangeFillStyle = register({
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemFillStyle: value },
|
appState: { ...appState, currentItemFillStyle: value },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
@ -465,7 +465,7 @@ export const actionChangeStrokeWidth = register({
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeWidth: value },
|
appState: { ...appState, currentItemStrokeWidth: value },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
@ -520,7 +520,7 @@ export const actionChangeSloppiness = register({
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemRoughness: value },
|
appState: { ...appState, currentItemRoughness: value },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
@ -571,7 +571,7 @@ export const actionChangeStrokeStyle = register({
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeStyle: value },
|
appState: { ...appState, currentItemStrokeStyle: value },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
@ -626,7 +626,7 @@ export const actionChangeOpacity = register({
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
@ -814,22 +814,22 @@ export const actionChangeFontFamily = register({
|
||||||
...appState,
|
...appState,
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: SnapshotAction.UPDATE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||||
|
|
||||||
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
let nexStoreAction: SnapshotActionType = SnapshotAction.NONE;
|
||||||
let nextFontFamily: FontFamilyValues | undefined;
|
let nextFontFamily: FontFamilyValues | undefined;
|
||||||
let skipOnHoverRender = false;
|
let skipOnHoverRender = false;
|
||||||
|
|
||||||
if (currentItemFontFamily) {
|
if (currentItemFontFamily) {
|
||||||
nextFontFamily = currentItemFontFamily;
|
nextFontFamily = currentItemFontFamily;
|
||||||
nexStoreAction = StoreAction.CAPTURE;
|
nexStoreAction = SnapshotAction.CAPTURE;
|
||||||
} else if (currentHoveredFontFamily) {
|
} else if (currentHoveredFontFamily) {
|
||||||
nextFontFamily = currentHoveredFontFamily;
|
nextFontFamily = currentHoveredFontFamily;
|
||||||
nexStoreAction = StoreAction.NONE;
|
nexStoreAction = SnapshotAction.NONE;
|
||||||
|
|
||||||
const selectedTextElements = getSelectedElements(elements, appState, {
|
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
|
@ -1187,7 +1187,7 @@ export const actionChangeTextAlign = register({
|
||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
|
@ -1277,7 +1277,7 @@ export const actionChangeVerticalAlign = register({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
|
@ -1362,7 +1362,7 @@ export const actionChangeRoundness = register({
|
||||||
...appState,
|
...appState,
|
||||||
currentItemRoundness: value,
|
currentItemRoundness: value,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
@ -1521,7 +1521,7 @@ export const actionChangeArrowhead = register({
|
||||||
? "currentItemStartArrowhead"
|
? "currentItemStartArrowhead"
|
||||||
: "currentItemEndArrowhead"]: value.type,
|
: "currentItemEndArrowhead"]: value.type,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
@ -1731,7 +1731,7 @@ export const actionChangeArrowType = register({
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: newState,
|
appState: newState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { ExcalidrawElement } from "../element/types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
|
@ -50,7 +50,7 @@ export const actionSelectAll = register({
|
||||||
? new LinearElementEditor(elements[0])
|
? new LinearElementEditor(elements[0])
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
import { paintIcon } from "../components/icons";
|
import { paintIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
|
@ -53,7 +53,7 @@ export const actionCopyStyles = register({
|
||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.copyStyles") },
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -70,7 +70,7 @@ export const actionPasteStyles = register({
|
||||||
const pastedElement = elementsCopied[0];
|
const pastedElement = elementsCopied[0];
|
||||||
const boundTextElement = elementsCopied[1];
|
const boundTextElement = elementsCopied[1];
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, storeAction: StoreAction.NONE };
|
return { elements, storeAction: SnapshotAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, {
|
const selectedElements = getSelectedElements(elements, appState, {
|
||||||
|
@ -159,7 +159,7 @@ export const actionPasteStyles = register({
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { measureText } from "../element/textElement";
|
import { measureText } from "../element/textElement";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@ -42,7 +42,7 @@ export const actionTextAutoResize = register({
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
|
@ -21,7 +21,7 @@ export const actionToggleGridMode = register({
|
||||||
gridModeEnabled: !this.checked!(appState),
|
gridModeEnabled: !this.checked!(appState),
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { magnetIcon } from "../components/icons";
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleObjectsSnapMode = register({
|
export const actionToggleObjectsSnapMode = register({
|
||||||
|
@ -19,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({
|
||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridModeEnabled: false,
|
gridModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { searchIcon } from "../components/icons";
|
import { searchIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||||
|
|
||||||
export const actionToggleSearchMenu = register({
|
export const actionToggleSearchMenu = register({
|
||||||
|
@ -29,7 +29,7 @@ export const actionToggleSearchMenu = register({
|
||||||
if (searchInput?.matches(":focus")) {
|
if (searchInput?.matches(":focus")) {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, openSidebar: null },
|
appState: { ...appState, openSidebar: null },
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ export const actionToggleSearchMenu = register({
|
||||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { abacusIcon } from "../components/icons";
|
import { abacusIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
|
@ -17,7 +17,7 @@ export const actionToggleStats = register({
|
||||||
...appState,
|
...appState,
|
||||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.stats.open,
|
checked: (appState) => appState.stats.open,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { eyeIcon } from "../components/icons";
|
import { eyeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
export const actionToggleViewMode = register({
|
||||||
|
@ -19,7 +19,7 @@ export const actionToggleViewMode = register({
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.viewModeEnabled,
|
checked: (appState) => appState.viewModeEnabled,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { coffeeIcon } from "../components/icons";
|
import { coffeeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
export const actionToggleZenMode = register({
|
||||||
|
@ -19,7 +19,7 @@ export const actionToggleZenMode = register({
|
||||||
...appState,
|
...appState,
|
||||||
zenModeEnabled: !this.checked!(appState),
|
zenModeEnabled: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.zenModeEnabled,
|
checked: (appState) => appState.zenModeEnabled,
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { isDarwin } from "../constants";
|
import { isDarwin } from "../constants";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
|
@ -27,7 +27,7 @@ export const actionSendBackward = register({
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
|
@ -57,7 +57,7 @@ export const actionBringForward = register({
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
|
@ -87,7 +87,7 @@ export const actionSendToBack = register({
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -125,7 +125,7 @@ export const actionBringToFront = register({
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { StoreActionType } from "../store";
|
import type { SnapshotActionType } from "../store";
|
||||||
|
|
||||||
export type ActionSource =
|
export type ActionSource =
|
||||||
| "ui"
|
| "ui"
|
||||||
|
@ -25,7 +25,7 @@ export type ActionResult =
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: Partial<AppState> | null;
|
appState?: Partial<AppState> | null;
|
||||||
files?: BinaryFiles | null;
|
files?: BinaryFiles | null;
|
||||||
storeAction: StoreActionType;
|
storeAction: SnapshotActionType;
|
||||||
replaceFiles?: boolean;
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
|
@ -1,21 +1,17 @@
|
||||||
import type {
|
import type { DeltasRepository, DELTA, SERVER_DELTA } from "../sync/protocol";
|
||||||
IncrementsRepository,
|
|
||||||
CLIENT_INCREMENT,
|
|
||||||
SERVER_INCREMENT,
|
|
||||||
} from "../sync/protocol";
|
|
||||||
|
|
||||||
// CFDO: add senderId, possibly roomId as well
|
// CFDO: add senderId, possibly roomId as well
|
||||||
export class DurableIncrementsRepository implements IncrementsRepository {
|
export class DurableDeltasRepository implements DeltasRepository {
|
||||||
// there is a 2MB row limit, hence working max row size of 1.5 MB
|
// there is a 2MB row limit, hence working max row size of 1.5 MB
|
||||||
// and leaving a buffer for other row metadata
|
// and leaving a buffer for other row metadata
|
||||||
private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
|
private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
|
||||||
|
|
||||||
constructor(private storage: DurableObjectStorage) {
|
constructor(private storage: DurableObjectStorage) {
|
||||||
// #region DEV ONLY
|
// #region DEV ONLY
|
||||||
// this.storage.sql.exec(`DROP TABLE IF EXISTS increments;`);
|
// this.storage.sql.exec(`DROP TABLE IF EXISTS deltas;`);
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS increments(
|
this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS deltas(
|
||||||
id TEXT NOT NULL,
|
id TEXT NOT NULL,
|
||||||
version INTEGER NOT NULL,
|
version INTEGER NOT NULL,
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
|
@ -25,41 +21,41 @@ export class DurableIncrementsRepository implements IncrementsRepository {
|
||||||
);`);
|
);`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public save(increment: CLIENT_INCREMENT): SERVER_INCREMENT | null {
|
public save(delta: DELTA): SERVER_DELTA | null {
|
||||||
return this.storage.transactionSync(() => {
|
return this.storage.transactionSync(() => {
|
||||||
const existingIncrement = this.getById(increment.id);
|
const existingDelta = this.getById(delta.id);
|
||||||
|
|
||||||
// don't perist the same increment twice
|
// don't perist the same delta twice
|
||||||
if (existingIncrement) {
|
if (existingDelta) {
|
||||||
return existingIncrement;
|
return existingDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify(increment);
|
const payload = JSON.stringify(delta);
|
||||||
const payloadSize = new TextEncoder().encode(payload).byteLength;
|
const payloadSize = new TextEncoder().encode(payload).byteLength;
|
||||||
const nextVersion = this.getLastVersion() + 1;
|
const nextVersion = this.getLastVersion() + 1;
|
||||||
const chunksCount = Math.ceil(
|
const chunksCount = Math.ceil(
|
||||||
payloadSize / DurableIncrementsRepository.MAX_PAYLOAD_SIZE,
|
payloadSize / DurableDeltasRepository.MAX_PAYLOAD_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let position = 0; position < chunksCount; position++) {
|
for (let position = 0; position < chunksCount; position++) {
|
||||||
const start = position * DurableIncrementsRepository.MAX_PAYLOAD_SIZE;
|
const start = position * DurableDeltasRepository.MAX_PAYLOAD_SIZE;
|
||||||
const end = start + DurableIncrementsRepository.MAX_PAYLOAD_SIZE;
|
const end = start + DurableDeltasRepository.MAX_PAYLOAD_SIZE;
|
||||||
// slicing the chunk payload
|
// slicing the chunk payload
|
||||||
const chunkedPayload = payload.slice(start, end);
|
const chunkedPayload = payload.slice(start, end);
|
||||||
|
|
||||||
this.storage.sql.exec(
|
this.storage.sql.exec(
|
||||||
`INSERT INTO increments (id, version, position, payload) VALUES (?, ?, ?, ?);`,
|
`INSERT INTO deltas (id, version, position, payload) VALUES (?, ?, ?, ?);`,
|
||||||
increment.id,
|
delta.id,
|
||||||
nextVersion,
|
nextVersion,
|
||||||
position,
|
position,
|
||||||
chunkedPayload,
|
chunkedPayload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// check if the increment has been already acknowledged
|
// check if the delta has been already acknowledged
|
||||||
// in case client for some reason did not receive acknowledgement
|
// in case client for some reason did not receive acknowledgement
|
||||||
// and reconnected while the we still have the increment in the worker
|
// and reconnected while the we still have the delta in the worker
|
||||||
// otherwise the client is doomed to full a restore
|
// otherwise the client is doomed to full a restore
|
||||||
if (e instanceof Error && e.message.includes("SQLITE_CONSTRAINT")) {
|
if (e instanceof Error && e.message.includes("SQLITE_CONSTRAINT")) {
|
||||||
// continue;
|
// continue;
|
||||||
|
@ -68,68 +64,66 @@ export class DurableIncrementsRepository implements IncrementsRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const acknowledged = this.getById(increment.id);
|
const acknowledged = this.getById(delta.id);
|
||||||
return acknowledged;
|
return acknowledged;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CFDO: for versioning we need deletions, but not for the "snapshot" update;
|
// CFDO: for versioning we need deletions, but not for the "snapshot" update;
|
||||||
public getAllSinceVersion(version: number): Array<SERVER_INCREMENT> {
|
public getAllSinceVersion(version: number): Array<SERVER_DELTA> {
|
||||||
const increments = this.storage.sql
|
const deltas = this.storage.sql
|
||||||
.exec<SERVER_INCREMENT>(
|
.exec<SERVER_DELTA>(
|
||||||
`SELECT id, payload, version FROM increments WHERE version > (?) ORDER BY version, position, createdAt ASC;`,
|
`SELECT id, payload, version FROM deltas WHERE version > (?) ORDER BY version, position, createdAt ASC;`,
|
||||||
version,
|
version,
|
||||||
)
|
)
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
return this.restoreServerIncrements(increments);
|
return this.restoreServerDeltas(deltas);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLastVersion(): number {
|
public getLastVersion(): number {
|
||||||
// CFDO: might be in memory to reduce number of rows read (or position on version at least, if btree affect rows read)
|
// CFDO: might be in memory to reduce number of rows read (or position on version at least, if btree affect rows read)
|
||||||
const result = this.storage.sql
|
const result = this.storage.sql
|
||||||
.exec(`SELECT MAX(version) FROM increments;`)
|
.exec(`SELECT MAX(version) FROM deltas;`)
|
||||||
.one();
|
.one();
|
||||||
|
|
||||||
return result ? Number(result["MAX(version)"]) : 0;
|
return result ? Number(result["MAX(version)"]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getById(id: string): SERVER_INCREMENT | null {
|
public getById(id: string): SERVER_DELTA | null {
|
||||||
const increments = this.storage.sql
|
const deltas = this.storage.sql
|
||||||
.exec<SERVER_INCREMENT>(
|
.exec<SERVER_DELTA>(
|
||||||
`SELECT id, payload, version FROM increments WHERE id = (?) ORDER BY position ASC`,
|
`SELECT id, payload, version FROM deltas WHERE id = (?) ORDER BY position ASC`,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
if (!increments.length) {
|
if (!deltas.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoredIncrements = this.restoreServerIncrements(increments);
|
const restoredDeltas = this.restoreServerDeltas(deltas);
|
||||||
|
|
||||||
if (restoredIncrements.length !== 1) {
|
if (restoredDeltas.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected exactly one restored increment, but received "${restoredIncrements.length}".`,
|
`Expected exactly one restored delta, but received "${restoredDeltas.length}".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoredIncrements[0];
|
return restoredDeltas[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreServerIncrements(
|
private restoreServerDeltas(deltas: SERVER_DELTA[]): SERVER_DELTA[] {
|
||||||
increments: SERVER_INCREMENT[],
|
|
||||||
): SERVER_INCREMENT[] {
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
increments
|
deltas
|
||||||
.reduce((acc, curr) => {
|
.reduce((acc, curr) => {
|
||||||
const increment = acc.get(curr.version);
|
const delta = acc.get(curr.version);
|
||||||
|
|
||||||
if (increment) {
|
if (delta) {
|
||||||
acc.set(curr.version, {
|
acc.set(curr.version, {
|
||||||
...increment,
|
...delta,
|
||||||
// glueing the chunks payload back
|
// glueing the chunks payload back
|
||||||
payload: increment.payload + curr.payload,
|
payload: delta.payload + curr.payload,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// let's not unnecessarily expose more props than these
|
// let's not unnecessarily expose more props than these
|
||||||
|
@ -141,7 +135,7 @@ export class DurableIncrementsRepository implements IncrementsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map<number, SERVER_INCREMENT>())
|
}, new Map<number, SERVER_DELTA>())
|
||||||
.values(),
|
.values(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { DurableObject } from "cloudflare:workers";
|
import { DurableObject } from "cloudflare:workers";
|
||||||
import { DurableIncrementsRepository } from "./repository";
|
import { DurableDeltasRepository } from "./repository";
|
||||||
import { ExcalidrawSyncServer } from "../sync/server";
|
import { ExcalidrawSyncServer } from "../sync/server";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
|
@ -34,9 +34,8 @@ export class DurableRoom extends DurableObject {
|
||||||
this.roomId = (await this.ctx.storage.get("roomId")) || null;
|
this.roomId = (await this.ctx.storage.get("roomId")) || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sync = new ExcalidrawSyncServer(
|
const repository = new DurableDeltasRepository(ctx.storage);
|
||||||
new DurableIncrementsRepository(ctx.storage),
|
this.sync = new ExcalidrawSyncServer(repository);
|
||||||
);
|
|
||||||
|
|
||||||
// in case it hibernates, let's get take active connections
|
// in case it hibernates, let's get take active connections
|
||||||
for (const ws of this.ctx.getWebSockets()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
|
|
|
@ -419,7 +419,7 @@ import { COLOR_PALETTE } from "../colors";
|
||||||
import { ElementCanvasButton } from "./MagicButton";
|
import { ElementCanvasButton } from "./MagicButton";
|
||||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||||
import FollowMode from "./FollowMode/FollowMode";
|
import FollowMode from "./FollowMode/FollowMode";
|
||||||
import { Store, StoreAction } from "../store";
|
import { Store, SnapshotAction } from "../store";
|
||||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||||
import { AnimatedTrail } from "../animated-trail";
|
import { AnimatedTrail } from "../animated-trail";
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
|
@ -1819,7 +1819,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
public getSceneElementsMapIncludingDeleted = () => {
|
public getSceneElementsMapIncludingDeleted = () => {
|
||||||
return this.scene.getElementsMapIncludingDeleted();
|
return this.scene.getElementsMapIncludingDeleted();
|
||||||
}
|
};
|
||||||
|
|
||||||
public getSceneElements = () => {
|
public getSceneElements = () => {
|
||||||
return this.scene.getNonDeletedElements();
|
return this.scene.getNonDeletedElements();
|
||||||
|
@ -2093,12 +2093,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
if (shouldUpdateStrokeColor) {
|
if (shouldUpdateStrokeColor) {
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
appState: { ...this.state, currentItemStrokeColor: color },
|
appState: { ...this.state, currentItemStrokeColor: color },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
appState: { ...this.state, currentItemBackgroundColor: color },
|
appState: { ...this.state, currentItemBackgroundColor: color },
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -2112,7 +2112,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -2133,11 +2133,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
this.store.scheduleAction(actionResult.storeAction);
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
|
@ -2210,7 +2206,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) {
|
if (!didUpdate) {
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -2338,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.resetHistory();
|
this.resetHistory();
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
...scene,
|
...scene,
|
||||||
storeAction: StoreAction.UPDATE,
|
storeAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// clear the shape and image cache so that any images in initialData
|
// clear the shape and image cache so that any images in initialData
|
||||||
|
@ -3273,7 +3269,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(newElements);
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
@ -3530,7 +3526,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"] = (
|
||||||
|
@ -3873,52 +3869,47 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
elements?: SceneData["elements"];
|
elements?: SceneData["elements"];
|
||||||
appState?: Pick<AppState, K> | null;
|
appState?: Pick<AppState, K> | null;
|
||||||
collaborators?: SceneData["collaborators"];
|
collaborators?: SceneData["collaborators"];
|
||||||
/** @default StoreAction.NONE */
|
/** @default SnapshotAction.NONE */
|
||||||
storeAction?: SceneData["storeAction"];
|
snapshotAction?: SceneData["snapshotAction"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
// flush all pending updates (if any) most of the time it's no-op
|
||||||
|
flushSync(() => {});
|
||||||
|
|
||||||
if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) {
|
// flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction`
|
||||||
const prevCommittedAppState = this.store.snapshot.appState;
|
flushSync(() => {
|
||||||
const prevCommittedElements = this.store.snapshot.elements;
|
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||||
|
|
||||||
const nextCommittedAppState = sceneData.appState
|
if (sceneData.snapshotAction) {
|
||||||
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
const prevCommittedAppState = this.store.snapshot.appState;
|
||||||
: prevCommittedAppState;
|
const prevCommittedElements = this.store.snapshot.elements;
|
||||||
|
|
||||||
const nextCommittedElements = sceneData.elements
|
const nextCommittedAppState = sceneData.appState
|
||||||
? this.store.filterUncomittedElements(
|
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
: prevCommittedAppState;
|
||||||
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 nextCommittedElements = sceneData.elements
|
||||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
? this.store.filterUncomittedElements(
|
||||||
if (sceneData.storeAction === StoreAction.CAPTURE) {
|
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
||||||
this.store.captureIncrement(
|
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
||||||
nextCommittedElements,
|
)
|
||||||
nextCommittedAppState,
|
: prevCommittedElements;
|
||||||
);
|
|
||||||
} else if (sceneData.storeAction === StoreAction.UPDATE) {
|
this.store.scheduleAction(sceneData.snapshotAction);
|
||||||
this.store.updateSnapshot(
|
this.store.commit(nextCommittedElements, nextCommittedAppState);
|
||||||
nextCommittedElements,
|
|
||||||
nextCommittedAppState,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneData.appState) {
|
if (sceneData.appState) {
|
||||||
this.setState(sceneData.appState);
|
this.setState(sceneData.appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.elements) {
|
if (sceneData.elements) {
|
||||||
this.scene.replaceAllElements(nextElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.collaborators) {
|
if (sceneData.collaborators) {
|
||||||
this.setState({ collaborators: sceneData.collaborators });
|
this.setState({ collaborators: sceneData.collaborators });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -4372,7 +4363,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(
|
||||||
|
@ -4580,7 +4571,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (this.flowChartNavigator.isExploring) {
|
if (this.flowChartNavigator.isExploring) {
|
||||||
this.flowChartNavigator.clear();
|
this.flowChartNavigator.clear();
|
||||||
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4627,7 +4618,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.flowChartCreator.clear();
|
this.flowChartCreator.clear();
|
||||||
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -4691,7 +4682,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 !== "selection") {
|
if (nextActiveTool.type !== "selection") {
|
||||||
|
@ -4894,7 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!isDeleted || isExistingElement) {
|
if (!isDeleted || isExistingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
|
@ -5304,7 +5295,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,
|
||||||
});
|
});
|
||||||
|
@ -5312,7 +5303,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,
|
||||||
});
|
});
|
||||||
|
@ -5347,7 +5338,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(selectedElements[0]),
|
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||||
});
|
});
|
||||||
|
@ -5430,7 +5421,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(
|
||||||
|
@ -6356,10 +6347,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state,
|
this.state,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
storeAction:
|
snapshotAction:
|
||||||
this.state.openDialog?.name === "elementLinkSelector"
|
this.state.openDialog?.name === "elementLinkSelector"
|
||||||
? StoreAction.NONE
|
? SnapshotAction.NONE
|
||||||
: StoreAction.UPDATE,
|
: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -8913,7 +8904,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,
|
||||||
|
@ -9011,7 +9002,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
appState: {
|
appState: {
|
||||||
newElement: null,
|
newElement: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -9172,7 +9163,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)) {
|
||||||
|
@ -9181,7 +9172,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
elements: this.scene
|
elements: this.scene
|
||||||
.getElementsIncludingDeleted()
|
.getElementsIncludingDeleted()
|
||||||
.filter((el) => el.id !== resizingElement.id),
|
.filter((el) => el.id !== resizingElement.id),
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9501,7 +9492,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.selectedElementIds,
|
this.state.selectedElementIds,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -9590,7 +9581,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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -10146,7 +10137,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
},
|
},
|
||||||
replaceFiles: true,
|
replaceFiles: true,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
@ -10263,9 +10254,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(SnapshotAction.UPDATE);
|
||||||
|
this.store.commit(arrayToMap(elements), this.state);
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
|
@ -10275,7 +10266,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
},
|
},
|
||||||
replaceFiles: true,
|
replaceFiles: true,
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
} else if (ret.type === MIME_TYPES.excalidrawlib) {
|
} else if (ret.type === MIME_TYPES.excalidrawlib) {
|
||||||
await this.library
|
await this.library
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useApp } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import type { StatsInputProperty } from "./utils";
|
import type { StatsInputProperty } from "./utils";
|
||||||
import { SMALLEST_DELTA } from "./utils";
|
import { SMALLEST_DELTA } from "./utils";
|
||||||
import { StoreAction } from "../../store";
|
import { SnapshotAction } from "../../store";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
|
|
||||||
import "./DragInput.scss";
|
import "./DragInput.scss";
|
||||||
|
@ -132,7 +132,7 @@ const StatsDragInput = <
|
||||||
originalAppState: appState,
|
originalAppState: appState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
});
|
});
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -276,7 +276,7 @@ const StatsDragInput = <
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||||
|
|
||||||
lastPointer = null;
|
lastPointer = null;
|
||||||
accumulatedChange = 0;
|
accumulatedChange = 0;
|
||||||
|
|
|
@ -57,7 +57,7 @@ import {
|
||||||
*
|
*
|
||||||
* 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>,
|
||||||
|
@ -369,7 +369,8 @@ class Delta<T> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: order the keys based on the most common ones to change (i.e. x/y, width/height, isDeleted, etc.)
|
// 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];
|
||||||
|
@ -393,58 +394,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(public 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(
|
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||||
appStateChangeDTO: DTO<AppStateChange>,
|
const { delta } = appStateDeltaDTO;
|
||||||
): AppStateChange {
|
return new AppStateDelta(delta);
|
||||||
const { delta } = appStateChangeDTO;
|
|
||||||
return new AppStateChange(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(
|
||||||
|
@ -519,7 +518,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 (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -583,13 +582,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) {
|
||||||
|
@ -604,8 +603,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>();
|
||||||
|
@ -622,7 +621,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,
|
||||||
|
@ -630,7 +629,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,
|
||||||
|
@ -666,7 +665,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) {
|
||||||
|
@ -803,19 +802,15 @@ export class AppStateChange implements Change<AppState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CFDO: consider adding here (nonnullable) version & versionNonce & updated & seed (so that we have correct versions when recunstructing from remote)
|
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
|
||||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
||||||
ElementUpdate<Ordered<T>>;
|
ElementUpdate<Ordered<T>>;
|
||||||
|
|
||||||
type ElementsChangeOptions = {
|
|
||||||
shouldRedistribute: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(
|
||||||
public readonly added: Record<string, Delta<ElementPartial>>,
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
public readonly removed: Record<string, Delta<ElementPartial>>,
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
|
@ -826,12 +821,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
added: Record<string, Delta<ElementPartial>>,
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
removed: Record<string, Delta<ElementPartial>>,
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
updated: Record<string, Delta<ElementPartial>>,
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
options: ElementsChangeOptions = {
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
shouldRedistribute: false,
|
shouldRedistribute: false,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { shouldRedistribute } = options;
|
const { shouldRedistribute } = options;
|
||||||
let change: ElementsChange;
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
if (shouldRedistribute) {
|
if (shouldRedistribute) {
|
||||||
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
@ -854,25 +851,23 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
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(
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
elementsChangeDTO: DTO<ElementsChange>,
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
): ElementsChange {
|
return ElementsDelta.create(added, removed, updated);
|
||||||
const { added, removed, updated } = elementsChangeDTO;
|
|
||||||
return ElementsChange.create(added, removed, updated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static satisfiesAddition = ({
|
private static satisfiesAddition = ({
|
||||||
|
@ -894,17 +889,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 Object.entries(change[type])) {
|
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}".`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -915,14 +910,14 @@ 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: Record<string, Delta<ElementPartial>> = {};
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
@ -940,7 +935,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed[prevElement.id] = delta;
|
removed[prevElement.id] = delta;
|
||||||
|
@ -960,7 +955,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added[nextElement.id] = delta;
|
added[nextElement.id] = delta;
|
||||||
|
@ -972,8 +967,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 (
|
||||||
|
@ -999,14 +994,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated);
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return ElementsChange.create({}, {}, {});
|
return ElementsDelta.create({}, {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): ElementsChange {
|
public inverse(): ElementsDelta {
|
||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
@ -1023,7 +1018,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
|
|
||||||
// notice we inverse removed with added not to break the invariants
|
// notice we inverse removed with added not to break the invariants
|
||||||
// notice we force generate a new id
|
// notice we force generate a new id
|
||||||
return ElementsChange.create(removed, added, updated);
|
return ElementsDelta.create(removed, added, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
public isEmpty(): boolean {
|
||||||
|
@ -1041,7 +1036,7 @@ 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): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
|
@ -1090,7 +1085,7 @@ 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1109,7 +1104,7 @@ 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,
|
snapshot,
|
||||||
flags,
|
flags,
|
||||||
|
@ -1129,7 +1124,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 (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -1144,18 +1139,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||||
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||||
|
|
||||||
// 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
ElementsDelta.redrawBoundArrows(nextElements, 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`,
|
||||||
|
@ -1183,7 +1178,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
deltas: Record<string, Delta<ElementPartial>>,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const getElement = ElementsChange.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
type,
|
type,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
@ -1194,7 +1189,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
@ -1276,6 +1271,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CFDO II: 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
|
||||||
|
@ -1291,8 +1287,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;
|
||||||
}
|
}
|
||||||
|
@ -1335,6 +1333,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(
|
||||||
|
@ -1373,12 +1373,12 @@ 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 Object.keys(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 Object.keys(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
|
||||||
|
@ -1394,7 +1394,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
|
||||||
|
@ -1404,7 +1404,7 @@ 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,
|
||||||
);
|
);
|
||||||
|
@ -1590,7 +1590,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 (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
throw e;
|
throw e;
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
IframeData,
|
IframeData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { MarkRequired } from "../utility-types";
|
import type { MarkRequired } from "../utility-types";
|
||||||
import { StoreAction } from "../store";
|
import { SnapshotAction } from "../store";
|
||||||
|
|
||||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||||
|
|
||||||
|
@ -344,7 +344,7 @@ export const actionSetEmbeddableAsActiveTool = register({
|
||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: SnapshotAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -775,7 +775,7 @@ export class LinearElementEditor {
|
||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
store.shouldCaptureIncrement();
|
store.scheduleCapture();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
|
import { type Store, StoreDelta, StoreIncrement } from "./store";
|
||||||
import type { SceneElementsMap } from "./element/types";
|
import type { SceneElementsMap } from "./element/types";
|
||||||
import type { Store, StoreIncrement } from "./store";
|
|
||||||
import type { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
type HistoryEntry = StoreIncrement & {
|
export class HistoryEntry extends StoreDelta {}
|
||||||
skipRecording?: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HistoryStack = HistoryEntry[];
|
type HistoryStack = HistoryEntry[];
|
||||||
|
|
||||||
|
@ -42,12 +40,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(entry: HistoryEntry) {
|
public record(increment: StoreIncrement) {
|
||||||
if (!entry.skipRecording && !entry.isEmpty()) {
|
if (
|
||||||
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
StoreIncrement.isDurable(increment) &&
|
||||||
this.undoStack.push(entry.inverse());
|
!increment.delta.isEmpty() &&
|
||||||
|
!(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.elementsChange.isEmpty()) {
|
this.undoStack.push(entry);
|
||||||
|
|
||||||
|
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!
|
||||||
|
@ -91,6 +95,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;
|
||||||
|
@ -98,17 +103,18 @@ export class History {
|
||||||
// iterate through the history entries in case they result in no visible changes
|
// iterate through the history entries in case they result in no visible changes
|
||||||
while (historyEntry) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
// skip re-recording the history entry, as it gets emitted and is manually pushed to the undo / redo stack
|
|
||||||
Object.assign(historyEntry, { skipRecording: true });
|
|
||||||
// CFDO: consider encapsulating element & appState update inside applyIncrement
|
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
this.store.applyIncrementTo(
|
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
|
||||||
historyEntry,
|
triggerIncrement: true,
|
||||||
nextElements,
|
});
|
||||||
nextAppState,
|
|
||||||
);
|
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, even if the increment is corrupted
|
// make sure to always push, even if the delta is corrupted
|
||||||
push(historyEntry);
|
push(historyEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,7 +158,7 @@ export class History {
|
||||||
entry: HistoryEntry,
|
entry: HistoryEntry,
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const updatedEntry = entry.applyLatestChanges(prevElements);
|
const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements);
|
||||||
return stack.push(updatedEntry);
|
return stack.push(updatedEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -259,7 +259,7 @@ export {
|
||||||
bumpVersion,
|
bumpVersion,
|
||||||
} from "./element/mutateElement";
|
} from "./element/mutateElement";
|
||||||
|
|
||||||
export { StoreAction } from "./store";
|
export { SnapshotAction } from "./store";
|
||||||
|
|
||||||
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { ENV } from "./constants";
|
import { ENV } from "./constants";
|
||||||
import { Emitter } from "./emitter";
|
import { Emitter } from "./emitter";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
import { isShallowEqual } from "./utils";
|
|
||||||
import { getDefaultAppState } from "./appState";
|
import { getDefaultAppState } from "./appState";
|
||||||
import { AppStateChange, ElementsChange } from "./change";
|
import { AppStateDelta, Delta, ElementsDelta } from "./delta";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { deepCopyElement } from "./element/newElement";
|
import { deepCopyElement } from "./element/newElement";
|
||||||
import type { AppState, ObservedAppState } from "./types";
|
import type { AppState, ObservedAppState } from "./types";
|
||||||
|
@ -12,6 +11,8 @@ import type {
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
|
import { hashElementsVersion } from "./element";
|
||||||
|
import { assertNever } from "./utils";
|
||||||
|
|
||||||
// hidden non-enumerable property for runtime checks
|
// hidden non-enumerable property for runtime checks
|
||||||
const hiddenObservedAppStateProp = "__observedAppState";
|
const hiddenObservedAppStateProp = "__observedAppState";
|
||||||
|
@ -41,48 +42,52 @@ const isObservedAppState = (
|
||||||
): appState is ObservedAppState =>
|
): appState is ObservedAppState =>
|
||||||
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
||||||
|
|
||||||
export const StoreAction = {
|
// 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 SnapshotAction = {
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
CAPTURE: "capture",
|
CAPTURE: "CAPTURE_DELTA",
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
UPDATE: "update",
|
UPDATE: "UPDATE_SNAPSHOT",
|
||||||
/**
|
/**
|
||||||
* Eventually undoable.
|
* Eventually undoable.
|
||||||
*
|
*
|
||||||
* Use for updates which should not be captured immediately - likely
|
* Use for updates which should not be captured as deltas immediately, such as
|
||||||
* exceptions which are part of some async multi-step process. Otherwise, all
|
* exceptions which are part of some async multi-step proces.
|
||||||
* such updates would end up being captured with the next
|
*
|
||||||
* `StoreAction.CAPTURE` - triggered either by the next `updateScene`
|
* These updates will be captured with the next `SnapshotAction.CAPTURE`,
|
||||||
* or internally by the editor.
|
* triggered either by the next `updateScene` or internally by the editor.
|
||||||
*
|
*
|
||||||
* These updates will _eventually_ make it to the local undo / redo stacks.
|
* These updates will _eventually_ make it to the local undo / redo stacks.
|
||||||
*/
|
*/
|
||||||
NONE: "none",
|
// CFDO I: none is not really "none" anymore, as it at very least emits an ephemeral increment
|
||||||
|
// we should likely rename these somehow and keep "none" only for real "no action" cases
|
||||||
|
NONE: "NONE",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type StoreActionType = ValueOf<typeof StoreAction>;
|
export type SnapshotActionType = ValueOf<typeof SnapshotAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<[StoreIncrement]>();
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
|
[DurableStoreIncrement | EphemeralStoreIncrement]
|
||||||
|
>();
|
||||||
|
|
||||||
private scheduledActions: Set<StoreActionType> = new Set();
|
|
||||||
private _snapshot = StoreSnapshot.empty();
|
private _snapshot = StoreSnapshot.empty();
|
||||||
|
|
||||||
public get snapshot() {
|
public get snapshot() {
|
||||||
|
@ -93,41 +98,61 @@ export class Store {
|
||||||
this._snapshot = snapshot;
|
this._snapshot = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private scheduledActions: Set<SnapshotActionType> = new Set();
|
||||||
* Use to schedule calculation of a store increment.
|
|
||||||
*/
|
|
||||||
// TODO: Suspicious that this is called so many places. Seems error-prone.
|
|
||||||
public shouldCaptureIncrement() {
|
|
||||||
this.scheduleAction(StoreAction.CAPTURE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
public scheduleAction(action: SnapshotActionType) {
|
||||||
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
|
|
||||||
*/
|
|
||||||
public shouldUpdateSnapshot() {
|
|
||||||
this.scheduleAction(StoreAction.UPDATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleAction(action: StoreActionType) {
|
|
||||||
this.scheduledActions.add(action);
|
this.scheduledActions.add(action);
|
||||||
this.satisfiesScheduledActionsInvariant();
|
this.satisfiesScheduledActionsInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrement`.
|
* 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(SnapshotAction.CAPTURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get scheduledAction() {
|
||||||
|
// Capture has a precedence over update, since it also performs snapshot update
|
||||||
|
if (this.scheduledActions.has(SnapshotAction.CAPTURE)) {
|
||||||
|
return SnapshotAction.CAPTURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update has a precedence over none, since it also emits an (ephemeral) increment
|
||||||
|
if (this.scheduledActions.has(SnapshotAction.UPDATE)) {
|
||||||
|
return SnapshotAction.UPDATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit ephemeral increment, don't update the snapshot
|
||||||
|
return SnapshotAction.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the incoming `SnapshotAction` and emits the corresponding `StoreIncrement`.
|
||||||
|
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
|
||||||
*
|
*
|
||||||
* @emits StoreIncrement when increment is calculated.
|
* @emits StoreIncrement
|
||||||
*/
|
*/
|
||||||
public commit(
|
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(StoreAction.CAPTURE)) {
|
|
||||||
this.captureIncrement(elements, appState);
|
switch (scheduledAction) {
|
||||||
} else if (this.scheduledActions.has(StoreAction.UPDATE)) {
|
case SnapshotAction.CAPTURE:
|
||||||
this.updateSnapshot(elements, appState);
|
this.snapshot = this.captureDurableIncrement(elements, appState);
|
||||||
|
break;
|
||||||
|
case SnapshotAction.UPDATE:
|
||||||
|
this.snapshot = this.emitEphemeralIncrement(elements);
|
||||||
|
break;
|
||||||
|
case SnapshotAction.NONE:
|
||||||
|
this.emitEphemeralIncrement(elements);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
assertNever(scheduledAction, `Unknown store action`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.satisfiesScheduledActionsInvariant();
|
this.satisfiesScheduledActionsInvariant();
|
||||||
|
@ -137,11 +162,11 @@ export class Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs diff calculation, calculates and emits the increment.
|
* Performs delta calculation and emits the increment.
|
||||||
*
|
*
|
||||||
* @emits StoreIncrement when increment is calculated.
|
* @emits StoreIncrement.
|
||||||
*/
|
*/
|
||||||
public captureIncrement(
|
private captureDurableIncrement(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
) {
|
) {
|
||||||
|
@ -149,41 +174,53 @@ export class Store {
|
||||||
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
||||||
|
|
||||||
// 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.metadata.didElementsChange
|
|
||||||
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
|
||||||
: ElementsChange.empty();
|
|
||||||
|
|
||||||
const appStateChange = nextSnapshot.metadata.didAppStateChange
|
|
||||||
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
|
||||||
: AppStateChange.empty();
|
|
||||||
|
|
||||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onStoreIncrementEmitter.trigger(
|
|
||||||
StoreIncrement.create(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();
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the snapshot without performing any diff calculation.
|
* When change is detected, emits an ephemeral increment and returns the next snapshot.
|
||||||
|
*
|
||||||
|
* @emits EphemeralStoreIncrement
|
||||||
*/
|
*/
|
||||||
public updateSnapshot(
|
private emitEphemeralIncrement(
|
||||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState | ObservedAppState | undefined,
|
|
||||||
) {
|
) {
|
||||||
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(elements, undefined);
|
||||||
|
|
||||||
if (this.snapshot !== nextSnapshot) {
|
if (prevSnapshot === nextSnapshot) {
|
||||||
// Update snapshot
|
// nothing has changed
|
||||||
this.snapshot = nextSnapshot;
|
return prevSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
|
const increment = new EphemeralStoreIncrement(change);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
|
||||||
|
return nextSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -210,7 +247,7 @@ export class Store {
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -223,21 +260,37 @@ export class Store {
|
||||||
*
|
*
|
||||||
* @emits StoreIncrement when increment is applied.
|
* @emits StoreIncrement when increment is applied.
|
||||||
*/
|
*/
|
||||||
public applyIncrementTo(
|
public applyDeltaTo(
|
||||||
increment: StoreIncrement,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
options: {
|
||||||
|
triggerIncrement: boolean;
|
||||||
|
} = {
|
||||||
|
triggerIncrement: false,
|
||||||
|
},
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
increment.elementsChange.applyTo(elements, this.snapshot.elements);
|
elements,
|
||||||
|
this.snapshot.elements,
|
||||||
|
);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
increment.appStateChange.applyTo(appState, nextElements);
|
delta.appState.applyTo(appState, nextElements);
|
||||||
|
|
||||||
const appliedVisibleChanges =
|
const appliedVisibleChanges =
|
||||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||||
|
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState);
|
||||||
|
|
||||||
|
if (options.triggerIncrement) {
|
||||||
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
|
const increment = new DurableStoreIncrement(change, delta);
|
||||||
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = nextSnapshot;
|
||||||
|
|
||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
}
|
}
|
||||||
|
@ -251,7 +304,12 @@ export class Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
private satisfiesScheduledActionsInvariant() {
|
private satisfiesScheduledActionsInvariant() {
|
||||||
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
|
if (
|
||||||
|
!(
|
||||||
|
this.scheduledActions.size >= 0 &&
|
||||||
|
this.scheduledActions.size <= Object.keys(SnapshotAction).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());
|
||||||
|
|
||||||
|
@ -263,90 +321,161 @@ export class Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represent an increment to the Store.
|
* Repsents a change to the store containg changed elements and appState.
|
||||||
*/
|
*/
|
||||||
export class StoreIncrement {
|
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(
|
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 id: string,
|
||||||
public readonly elementsChange: ElementsChange,
|
public readonly elements: ElementsDelta,
|
||||||
public readonly appStateChange: AppStateChange,
|
public readonly appState: AppStateDelta,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new instance of `StoreIncrement`.
|
* Create a new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public static create(
|
public static create(
|
||||||
elementsChange: ElementsChange,
|
elements: ElementsDelta,
|
||||||
appStateChange: AppStateChange,
|
appState: AppStateDelta,
|
||||||
opts: {
|
opts: {
|
||||||
id: string;
|
id: string;
|
||||||
} = {
|
} = {
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return new StoreIncrement(opts.id, elementsChange, appStateChange);
|
return new this(opts.id, elements, appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a store increment instance from a DTO.
|
* Restore a store delta instance from a DTO.
|
||||||
*/
|
*/
|
||||||
public static restore(storeIncrementDTO: DTO<StoreIncrement>) {
|
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||||
const { id, elementsChange, appStateChange } = storeIncrementDTO;
|
const { id, elements, appState } = storeDeltaDTO;
|
||||||
return new StoreIncrement(
|
return new this(
|
||||||
id,
|
id,
|
||||||
ElementsChange.restore(elementsChange),
|
ElementsDelta.restore(elements),
|
||||||
AppStateChange.restore(appStateChange),
|
AppStateDelta.restore(appState),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CFDO: why it would be a string if it can be a DTO?
|
|
||||||
/**
|
/**
|
||||||
* Parse and load the increment from the remote payload.
|
* Parse and load the delta from the remote payload.
|
||||||
*/
|
*/
|
||||||
|
// CFDO: why it would be a string if it can be a DTO?
|
||||||
public static load(payload: string) {
|
public static load(payload: string) {
|
||||||
// CFDO: ensure typesafety
|
// CFDO: ensure typesafety
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
elementsChange: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
} = JSON.parse(payload);
|
} = JSON.parse(payload);
|
||||||
|
|
||||||
const elementsChange = ElementsChange.create(added, removed, updated, {
|
const elements = ElementsDelta.create(added, removed, updated, {
|
||||||
shouldRedistribute: false,
|
shouldRedistribute: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StoreIncrement(id, elementsChange, AppStateChange.empty());
|
return new this(id, elements, AppStateDelta.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse store increment, creates new instance of `StoreIncrement`.
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public inverse(): StoreIncrement {
|
public static inverse(delta: StoreDelta): StoreDelta {
|
||||||
return new StoreIncrement(
|
return this.create(delta.elements.inverse(), delta.appState.inverse(), {
|
||||||
randomId(),
|
id: delta.id,
|
||||||
this.elementsChange.inverse(),
|
});
|
||||||
this.appStateChange.inverse(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply latest (remote) changes to the increment, creates new instance of `StoreIncrement`.
|
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): StoreIncrement {
|
public static applyLatestChanges(
|
||||||
const inversedIncrement = this.inverse();
|
delta: StoreDelta,
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
): StoreDelta {
|
||||||
|
const inversedDelta = this.inverse(delta);
|
||||||
|
|
||||||
return new StoreIncrement(
|
return this.create(
|
||||||
inversedIncrement.id,
|
inversedDelta.elements.applyLatestChanges(elements),
|
||||||
inversedIncrement.elementsChange.applyLatestChanges(elements),
|
inversedDelta.appState,
|
||||||
inversedIncrement.appStateChange,
|
{
|
||||||
|
id: inversedDelta.id,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elementsChange.isEmpty() && this.appStateChange.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 {
|
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,
|
||||||
|
@ -369,6 +498,34 @@ export class StoreSnapshot {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.metadata.isEmpty;
|
return this.metadata.isEmpty;
|
||||||
}
|
}
|
||||||
|
@ -434,10 +591,8 @@ export class StoreSnapshot {
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
selectedElementIds: isShallowEqual,
|
return Delta.isRightDifferent(this.appState, nextObservedAppState);
|
||||||
selectedGroupIds: isShallowEqual,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeCreateElementsSnapshot(
|
private maybeCreateElementsSnapshot(
|
||||||
|
@ -447,13 +602,13 @@ export class StoreSnapshot {
|
||||||
return this.elements;
|
return this.elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const didElementsChange = this.detectChangedElements(elements);
|
const changedElements = this.detectChangedElements(elements);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -466,67 +621,72 @@ export class StoreSnapshot {
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||||
) {
|
) {
|
||||||
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.version !== next.version ||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,18 +6,15 @@ import ReconnectingWebSocket, {
|
||||||
} from "reconnecting-websocket";
|
} from "reconnecting-websocket";
|
||||||
import { Utils } from "./utils";
|
import { Utils } from "./utils";
|
||||||
import {
|
import {
|
||||||
SyncQueue,
|
LocalDeltasQueue,
|
||||||
type MetadataRepository,
|
type MetadataRepository,
|
||||||
type IncrementsRepository,
|
type DeltasRepository,
|
||||||
} from "./queue";
|
} from "./queue";
|
||||||
import { StoreIncrement } from "../store";
|
import { SnapshotAction, StoreDelta } from "../store";
|
||||||
|
import type { StoreChange } from "../store";
|
||||||
import type { ExcalidrawImperativeAPI } from "../types";
|
import type { ExcalidrawImperativeAPI } from "../types";
|
||||||
import type { SceneElementsMap } from "../element/types";
|
import type { SceneElementsMap } from "../element/types";
|
||||||
import type {
|
import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol";
|
||||||
CLIENT_INCREMENT,
|
|
||||||
CLIENT_MESSAGE_RAW,
|
|
||||||
SERVER_INCREMENT,
|
|
||||||
} from "./protocol";
|
|
||||||
import { debounce } from "../utils";
|
import { debounce } from "../utils";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
|
|
||||||
|
@ -38,12 +35,6 @@ class SocketClient {
|
||||||
// thus working with a slighter smaller limit of 800 kB (leaving 224kB for metadata)
|
// thus working with a slighter smaller limit of 800 kB (leaving 224kB for metadata)
|
||||||
private static readonly MAX_MESSAGE_SIZE = 800_000;
|
private static readonly MAX_MESSAGE_SIZE = 800_000;
|
||||||
|
|
||||||
private static readonly NORMAL_CLOSURE_CODE = 1000;
|
|
||||||
// Chrome throws "Uncaught InvalidAccessError" with message:
|
|
||||||
// "The close code must be either 1000, or between 3000 and 4999. 1009 is neither."
|
|
||||||
// therefore using custom codes instead.
|
|
||||||
private static readonly MESSAGE_IS_TOO_LARGE_ERROR_CODE = 3009;
|
|
||||||
|
|
||||||
private isOffline = true;
|
private isOffline = true;
|
||||||
private socket: ReconnectingWebSocket | null = null;
|
private socket: ReconnectingWebSocket | null = null;
|
||||||
|
|
||||||
|
@ -129,11 +120,11 @@ class SocketClient {
|
||||||
|
|
||||||
public send(message: {
|
public send(message: {
|
||||||
type: "relay" | "pull" | "push";
|
type: "relay" | "pull" | "push";
|
||||||
payload: any;
|
payload: Record<string, unknown>;
|
||||||
}): void {
|
}): void {
|
||||||
if (this.isOffline) {
|
if (this.isOffline) {
|
||||||
// connection opened, don't let the WS buffer the messages,
|
// connection opened, don't let the WS buffer the messages,
|
||||||
// as we do explicitly buffer unacknowledged increments
|
// as we do explicitly buffer unacknowledged deltas
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -145,6 +136,7 @@ class SocketClient {
|
||||||
|
|
||||||
const { type, payload } = message;
|
const { type, payload } = message;
|
||||||
|
|
||||||
|
// CFDO II: could be slowish for large payloads, thing about a better solution (i.e. msgpack 10x faster, 2x smaller)
|
||||||
const stringifiedPayload = JSON.stringify(payload);
|
const stringifiedPayload = JSON.stringify(payload);
|
||||||
const payloadSize = new TextEncoder().encode(stringifiedPayload).byteLength;
|
const payloadSize = new TextEncoder().encode(stringifiedPayload).byteLength;
|
||||||
|
|
||||||
|
@ -193,8 +185,8 @@ class SocketClient {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AcknowledgedIncrement {
|
interface AcknowledgedDelta {
|
||||||
increment: StoreIncrement;
|
delta: StoreDelta;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,21 +200,19 @@ export class SyncClient {
|
||||||
: "test_room_prod";
|
: "test_room_prod";
|
||||||
|
|
||||||
private readonly api: ExcalidrawImperativeAPI;
|
private readonly api: ExcalidrawImperativeAPI;
|
||||||
private readonly queue: SyncQueue;
|
private readonly localDeltas: LocalDeltasQueue;
|
||||||
private readonly metadata: MetadataRepository;
|
private readonly metadata: MetadataRepository;
|
||||||
private readonly client: SocketClient;
|
private readonly client: SocketClient;
|
||||||
|
|
||||||
// #region ACKNOWLEDGED INCREMENTS & METADATA
|
// #region ACKNOWLEDGED DELTAS & METADATA
|
||||||
// CFDO: shouldn't be stateful, only request / response
|
// CFDO: shouldn't be stateful, only request / response
|
||||||
private readonly acknowledgedIncrementsMap: Map<
|
private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
|
||||||
string,
|
new Map();
|
||||||
AcknowledgedIncrement
|
|
||||||
> = new Map();
|
|
||||||
|
|
||||||
public get acknowledgedIncrements() {
|
public get acknowledgedDeltas() {
|
||||||
return Array.from(this.acknowledgedIncrementsMap.values())
|
return Array.from(this.acknowledgedDeltasMap.values())
|
||||||
.sort((a, b) => (a.version < b.version ? -1 : 1))
|
.sort((a, b) => (a.version < b.version ? -1 : 1))
|
||||||
.map((x) => x.increment);
|
.map((x) => x.delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _lastAcknowledgedVersion = 0;
|
private _lastAcknowledgedVersion = 0;
|
||||||
|
@ -240,12 +230,12 @@ export class SyncClient {
|
||||||
private constructor(
|
private constructor(
|
||||||
api: ExcalidrawImperativeAPI,
|
api: ExcalidrawImperativeAPI,
|
||||||
repository: MetadataRepository,
|
repository: MetadataRepository,
|
||||||
queue: SyncQueue,
|
queue: LocalDeltasQueue,
|
||||||
options: { host: string; roomId: string; lastAcknowledgedVersion: number },
|
options: { host: string; roomId: string; lastAcknowledgedVersion: number },
|
||||||
) {
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.metadata = repository;
|
this.metadata = repository;
|
||||||
this.queue = queue;
|
this.localDeltas = queue;
|
||||||
this.lastAcknowledgedVersion = options.lastAcknowledgedVersion;
|
this.lastAcknowledgedVersion = options.lastAcknowledgedVersion;
|
||||||
this.client = new SocketClient(options.host, options.roomId, {
|
this.client = new SocketClient(options.host, options.roomId, {
|
||||||
onOpen: this.onOpen,
|
onOpen: this.onOpen,
|
||||||
|
@ -257,16 +247,16 @@ export class SyncClient {
|
||||||
// #region SYNC_CLIENT FACTORY
|
// #region SYNC_CLIENT FACTORY
|
||||||
public static async create(
|
public static async create(
|
||||||
api: ExcalidrawImperativeAPI,
|
api: ExcalidrawImperativeAPI,
|
||||||
repository: IncrementsRepository & MetadataRepository,
|
repository: DeltasRepository & MetadataRepository,
|
||||||
) {
|
) {
|
||||||
const queue = await SyncQueue.create(repository);
|
const queue = await LocalDeltasQueue.create(repository);
|
||||||
// CFDO: temporary for custom roomId (though E+ will be similar)
|
// CFDO: temporary for custom roomId (though E+ will be similar)
|
||||||
const roomId = window.location.pathname.split("/").at(-1);
|
const roomId = window.location.pathname.split("/").at(-1);
|
||||||
|
|
||||||
return new SyncClient(api, repository, queue, {
|
return new SyncClient(api, repository, queue, {
|
||||||
host: SyncClient.HOST_URL,
|
host: SyncClient.HOST_URL,
|
||||||
roomId: roomId ?? SyncClient.ROOM_ID,
|
roomId: roomId ?? SyncClient.ROOM_ID,
|
||||||
// CFDO: temporary, so that all increments are loaded and applied on init
|
// CFDO: temporary, so that all deltas are loaded and applied on init
|
||||||
lastAcknowledgedVersion: 0,
|
lastAcknowledgedVersion: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -290,26 +280,27 @@ export class SyncClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public push(increment?: StoreIncrement): void {
|
public push(delta?: StoreDelta): void {
|
||||||
if (increment) {
|
if (delta) {
|
||||||
this.queue.add(increment);
|
this.localDeltas.add(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-send all already queued increments
|
// re-send all already queued deltas
|
||||||
for (const queuedIncrement of this.queue.getAll()) {
|
for (const delta of this.localDeltas.getAll()) {
|
||||||
this.client.send({
|
this.client.send({
|
||||||
type: "push",
|
type: "push",
|
||||||
payload: {
|
payload: {
|
||||||
...queuedIncrement,
|
...delta,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public relay(buffer: ArrayBuffer): void {
|
// CFDO: should be throttled! 60 fps for live scenes, 10s or so for single player
|
||||||
|
public relay(change: StoreChange): void {
|
||||||
this.client.send({
|
this.client.send({
|
||||||
type: "relay",
|
type: "relay",
|
||||||
payload: { buffer },
|
payload: { ...change },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// #endregion
|
// #endregion
|
||||||
|
@ -349,76 +340,110 @@ export class SyncClient {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// CFDO: refactor by applying all operations to store, not to the elements
|
private handleRelayed = (payload: CHANGE) => {
|
||||||
private handleAcknowledged = (payload: {
|
// CFDO: retrieve the map already
|
||||||
increments: Array<SERVER_INCREMENT>;
|
const nextElements = new Map(
|
||||||
}) => {
|
|
||||||
let nextAcknowledgedVersion = this.lastAcknowledgedVersion;
|
|
||||||
let elements = new Map(
|
|
||||||
// CFDO: retrieve the map already
|
|
||||||
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
|
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
|
||||||
) as SceneElementsMap;
|
) as SceneElementsMap;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { increments: remoteIncrements } = payload;
|
const { elements: relayedElements } = payload;
|
||||||
|
|
||||||
// apply remote increments
|
for (const [id, relayedElement] of Object.entries(relayedElements)) {
|
||||||
for (const { id, version, payload } of remoteIncrements) {
|
const existingElement = nextElements.get(id);
|
||||||
// CFDO: temporary to load all increments on init
|
|
||||||
this.acknowledgedIncrementsMap.set(id, {
|
if (
|
||||||
increment: StoreIncrement.load(payload),
|
!existingElement || // new element
|
||||||
|
existingElement.version < relayedElement.version // updated element
|
||||||
|
) {
|
||||||
|
nextElements.set(id, relayedElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api.updateScene({
|
||||||
|
elements: Array.from(nextElements.values()),
|
||||||
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to apply relayed change:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// CFDO: refactor by applying all operations to store, not to the elements
|
||||||
|
private handleAcknowledged = (payload: { deltas: Array<SERVER_DELTA> }) => {
|
||||||
|
let prevSnapshot = this.api.store.snapshot;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remoteDeltas = Array.from(payload.deltas);
|
||||||
|
const applicableDeltas: Array<StoreDelta> = [];
|
||||||
|
const appState = this.api.getAppState();
|
||||||
|
|
||||||
|
let nextAcknowledgedVersion = this.lastAcknowledgedVersion;
|
||||||
|
let nextElements = new Map(
|
||||||
|
// CFDO: retrieve the map already
|
||||||
|
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
|
||||||
|
) as SceneElementsMap;
|
||||||
|
|
||||||
|
for (const { id, version, payload } of remoteDeltas) {
|
||||||
|
// CFDO: temporary to load all deltas on init
|
||||||
|
this.acknowledgedDeltasMap.set(id, {
|
||||||
|
delta: StoreDelta.load(payload),
|
||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
|
|
||||||
// we've already applied this increment
|
// we've already applied this delta!
|
||||||
if (version <= nextAcknowledgedVersion) {
|
if (version <= nextAcknowledgedVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version === nextAcknowledgedVersion + 1) {
|
// CFDO:strictly checking for out of order deltas; might be relaxed if it becomes a problem
|
||||||
nextAcknowledgedVersion = version;
|
if (version !== nextAcknowledgedVersion + 1) {
|
||||||
} else {
|
throw new Error(
|
||||||
// it's fine to apply increments our of order,
|
`Received out of order delta, expected "${
|
||||||
// as they are idempontent, so that we can re-apply them again,
|
|
||||||
// as long as we don't mark their version as acknowledged
|
|
||||||
console.debug(
|
|
||||||
`Received out of order increment, expected "${
|
|
||||||
nextAcknowledgedVersion + 1
|
nextAcknowledgedVersion + 1
|
||||||
}", but received "${version}"`,
|
}", but received "${version}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// local increment shall not have to be applied again
|
if (this.localDeltas.has(id)) {
|
||||||
if (this.queue.has(id)) {
|
// local delta does not have to be applied again
|
||||||
this.queue.remove(id);
|
this.localDeltas.remove(id);
|
||||||
} else {
|
} else {
|
||||||
// apply remote increment with higher version than the last acknowledged one
|
// this is a new remote delta, adding it to the list of applicable deltas
|
||||||
const remoteIncrement = StoreIncrement.load(payload);
|
const remoteDelta = StoreDelta.load(payload);
|
||||||
[elements] = remoteIncrement.elementsChange.applyTo(
|
applicableDeltas.push(remoteDelta);
|
||||||
elements,
|
|
||||||
this.api.store.snapshot.elements,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply local increments
|
nextAcknowledgedVersion = version;
|
||||||
for (const localIncrement of this.queue.getAll()) {
|
|
||||||
// CFDO: in theory only necessary when remote increments modified same element properties!
|
|
||||||
[elements] = localIncrement.elementsChange.applyTo(
|
|
||||||
elements,
|
|
||||||
this.api.store.snapshot.elements,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.api.updateScene({
|
|
||||||
elements: Array.from(elements.values()),
|
|
||||||
storeAction: "update",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adding all yet unacknowledged local deltas
|
||||||
|
const localDeltas = this.localDeltas.getAll();
|
||||||
|
applicableDeltas.push(...localDeltas);
|
||||||
|
|
||||||
|
for (const delta of applicableDeltas) {
|
||||||
|
[nextElements] = this.api.store.applyDeltaTo(
|
||||||
|
delta,
|
||||||
|
nextElements,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
prevSnapshot = this.api.store.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: I still need to filter out uncomitted elements
|
||||||
|
// I still need to update snapshot with the new elements
|
||||||
|
this.api.updateScene({
|
||||||
|
elements: Array.from(nextElements.values()),
|
||||||
|
snapshotAction: SnapshotAction.NONE,
|
||||||
|
});
|
||||||
|
|
||||||
this.lastAcknowledgedVersion = nextAcknowledgedVersion;
|
this.lastAcknowledgedVersion = nextAcknowledgedVersion;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to apply acknowledged increments:", e);
|
console.error("Failed to apply acknowledged deltas:", e);
|
||||||
// CFDO: might just be on error
|
// rollback to the previous snapshot, so that we don't end up in an incosistent state
|
||||||
|
this.api.store.snapshot = prevSnapshot;
|
||||||
|
// schedule another fresh pull in case of a failure
|
||||||
this.schedulePull();
|
this.schedulePull();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -427,17 +452,10 @@ export class SyncClient {
|
||||||
ids: Array<string>;
|
ids: Array<string>;
|
||||||
message: string;
|
message: string;
|
||||||
}) => {
|
}) => {
|
||||||
// handle rejected increments
|
// handle rejected deltas
|
||||||
console.error("Rejected message received:", payload);
|
console.error("Rejected message received:", payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleRelayed = (payload: {
|
|
||||||
increments: Array<CLIENT_INCREMENT>;
|
|
||||||
}) => {
|
|
||||||
// apply relayed increments / buffer
|
|
||||||
console.log("Relayed message received:", payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
private schedulePull = debounce(() => this.pull(), 1000);
|
private schedulePull = debounce(() => this.pull(), 1000);
|
||||||
// #endregion
|
// #endregion
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { StoreIncrement } from "../store";
|
import type { StoreChange, StoreDelta } from "../store";
|
||||||
import type { DTO } from "../utility-types";
|
import type { DTO } from "../utility-types";
|
||||||
|
|
||||||
export type CLIENT_INCREMENT = DTO<StoreIncrement>;
|
export type DELTA = DTO<StoreDelta>;
|
||||||
|
export type CHANGE = DTO<StoreChange>;
|
||||||
|
|
||||||
export type RELAY_PAYLOAD = { buffer: ArrayBuffer };
|
export type RELAY_PAYLOAD = CHANGE;
|
||||||
|
export type PUSH_PAYLOAD = DELTA;
|
||||||
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
|
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
|
||||||
export type PUSH_PAYLOAD = CLIENT_INCREMENT;
|
|
||||||
|
|
||||||
export type CHUNK_INFO = {
|
export type CHUNK_INFO = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -25,22 +26,21 @@ export type CLIENT_MESSAGE = { chunkInfo: CHUNK_INFO } & (
|
||||||
| { type: "push"; payload: PUSH_PAYLOAD }
|
| { type: "push"; payload: PUSH_PAYLOAD }
|
||||||
);
|
);
|
||||||
|
|
||||||
export type SERVER_INCREMENT = { id: string; version: number; payload: string };
|
export type SERVER_DELTA = { id: string; version: number; payload: string };
|
||||||
export type SERVER_MESSAGE =
|
export type SERVER_MESSAGE =
|
||||||
| {
|
| {
|
||||||
type: "relayed";
|
type: "relayed";
|
||||||
// CFDO: should likely be just elements
|
payload: RELAY_PAYLOAD;
|
||||||
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD;
|
|
||||||
}
|
}
|
||||||
| { type: "acknowledged"; payload: { increments: Array<SERVER_INCREMENT> } }
|
| { type: "acknowledged"; payload: { deltas: Array<SERVER_DELTA> } }
|
||||||
| {
|
| {
|
||||||
type: "rejected";
|
type: "rejected";
|
||||||
payload: { increments: Array<CLIENT_INCREMENT>; message: string };
|
payload: { deltas: Array<DELTA>; message: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IncrementsRepository {
|
export interface DeltasRepository {
|
||||||
save(increment: CLIENT_INCREMENT): SERVER_INCREMENT | null;
|
save(delta: DELTA): SERVER_DELTA | null;
|
||||||
getAllSinceVersion(version: number): Array<SERVER_INCREMENT>;
|
getAllSinceVersion(version: number): Array<SERVER_DELTA>;
|
||||||
getLastVersion(): number;
|
getLastVersion(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import type { StoreIncrement } from "../store";
|
import type { StoreDelta } from "../store";
|
||||||
|
|
||||||
export interface IncrementsRepository {
|
export interface DeltasRepository {
|
||||||
loadIncrements(): Promise<Array<StoreIncrement> | null>;
|
loadDeltas(): Promise<Array<StoreDelta> | null>;
|
||||||
saveIncrements(params: StoreIncrement[]): Promise<void>;
|
saveDeltas(params: StoreDelta[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetadataRepository {
|
export interface MetadataRepository {
|
||||||
|
@ -11,24 +11,24 @@ export interface MetadataRepository {
|
||||||
saveMetadata(metadata: { lastAcknowledgedVersion: number }): Promise<void>;
|
saveMetadata(metadata: { lastAcknowledgedVersion: number }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CFDO: make sure the increments are always acknowledged (deleted from the repository)
|
// CFDO: make sure the deltas are always acknowledged (deleted from the repository)
|
||||||
export class SyncQueue {
|
export class LocalDeltasQueue {
|
||||||
private readonly queue: Map<string, StoreIncrement>;
|
private readonly queue: Map<string, StoreDelta>;
|
||||||
private readonly repository: IncrementsRepository;
|
private readonly repository: DeltasRepository;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
queue: Map<string, StoreIncrement> = new Map(),
|
queue: Map<string, StoreDelta> = new Map(),
|
||||||
repository: IncrementsRepository,
|
repository: DeltasRepository,
|
||||||
) {
|
) {
|
||||||
this.queue = queue;
|
this.queue = queue;
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async create(repository: IncrementsRepository) {
|
public static async create(repository: DeltasRepository) {
|
||||||
const increments = await repository.loadIncrements();
|
const deltas = await repository.loadDeltas();
|
||||||
|
|
||||||
return new SyncQueue(
|
return new LocalDeltasQueue(
|
||||||
new Map(increments?.map((increment) => [increment.id, increment])),
|
new Map(deltas?.map((delta) => [delta.id, delta])),
|
||||||
repository,
|
repository,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -37,23 +37,23 @@ export class SyncQueue {
|
||||||
return Array.from(this.queue.values());
|
return Array.from(this.queue.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(id: StoreIncrement["id"]) {
|
public get(id: StoreDelta["id"]) {
|
||||||
return this.queue.get(id);
|
return this.queue.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public has(id: StoreIncrement["id"]) {
|
public has(id: StoreDelta["id"]) {
|
||||||
return this.queue.has(id);
|
return this.queue.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(...increments: StoreIncrement[]) {
|
public add(...deltas: StoreDelta[]) {
|
||||||
for (const increment of increments) {
|
for (const delta of deltas) {
|
||||||
this.queue.set(increment.id, increment);
|
this.queue.set(delta.id, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove(...ids: StoreIncrement["id"][]) {
|
public remove(...ids: StoreDelta["id"][]) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.queue.delete(id);
|
this.queue.delete(id);
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class SyncQueue {
|
||||||
public persist = throttle(
|
public persist = throttle(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
await this.repository.saveIncrements(this.getAll());
|
await this.repository.saveDeltas(this.getAll());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to persist the sync queue:", e);
|
console.error("Failed to persist the sync queue:", e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,15 @@ import AsyncLock from "async-lock";
|
||||||
import { Utils } from "./utils";
|
import { Utils } from "./utils";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IncrementsRepository,
|
DeltasRepository,
|
||||||
CLIENT_MESSAGE,
|
CLIENT_MESSAGE,
|
||||||
PULL_PAYLOAD,
|
PULL_PAYLOAD,
|
||||||
PUSH_PAYLOAD,
|
PUSH_PAYLOAD,
|
||||||
SERVER_MESSAGE,
|
SERVER_MESSAGE,
|
||||||
SERVER_INCREMENT,
|
SERVER_DELTA,
|
||||||
CLIENT_MESSAGE_RAW,
|
CLIENT_MESSAGE_RAW,
|
||||||
CHUNK_INFO,
|
CHUNK_INFO,
|
||||||
|
RELAY_PAYLOAD,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
|
|
||||||
// CFDO: message could be binary (cbor, protobuf, etc.)
|
// CFDO: message could be binary (cbor, protobuf, etc.)
|
||||||
|
@ -25,7 +26,7 @@ export class ExcalidrawSyncServer {
|
||||||
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_RAW["payload"]>
|
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_RAW["payload"]>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(private readonly incrementsRepository: IncrementsRepository) {}
|
constructor(private readonly repository: DeltasRepository) {}
|
||||||
|
|
||||||
// CFDO: should send a message about collaborators (no collaborators => no need to send ephemerals)
|
// CFDO: should send a message about collaborators (no collaborators => no need to send ephemerals)
|
||||||
public onConnect(client: WebSocket) {
|
public onConnect(client: WebSocket) {
|
||||||
|
@ -65,8 +66,8 @@ export class ExcalidrawSyncServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
// case "relay":
|
case "relay":
|
||||||
// return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
||||||
case "pull":
|
case "pull":
|
||||||
return this.pull(client, parsedPayload as PULL_PAYLOAD);
|
return this.pull(client, parsedPayload as PULL_PAYLOAD);
|
||||||
case "push":
|
case "push":
|
||||||
|
@ -137,24 +138,21 @@ export class ExcalidrawSyncServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// private relay(
|
private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
|
||||||
// client: WebSocket,
|
// CFDO: we should likely apply these to the snapshot
|
||||||
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD,
|
return this.broadcast(
|
||||||
// ) {
|
{
|
||||||
// return this.broadcast(
|
type: "relayed",
|
||||||
// {
|
payload,
|
||||||
// type: "relayed",
|
},
|
||||||
// payload,
|
client,
|
||||||
// },
|
);
|
||||||
// client,
|
}
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
private pull(client: WebSocket, payload: PULL_PAYLOAD) {
|
private pull(client: WebSocket, payload: PULL_PAYLOAD) {
|
||||||
// CFDO: test for invalid payload
|
// CFDO: test for invalid payload
|
||||||
const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion;
|
const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion;
|
||||||
const lastAcknowledgedServerVersion =
|
const lastAcknowledgedServerVersion = this.repository.getLastVersion();
|
||||||
this.incrementsRepository.getLastVersion();
|
|
||||||
|
|
||||||
const versionΔ =
|
const versionΔ =
|
||||||
lastAcknowledgedServerVersion - lastAcknowledgedClientVersion;
|
lastAcknowledgedServerVersion - lastAcknowledgedClientVersion;
|
||||||
|
@ -167,37 +165,33 @@ export class ExcalidrawSyncServer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const increments: SERVER_INCREMENT[] = [];
|
const deltas: SERVER_DELTA[] = [];
|
||||||
|
|
||||||
if (versionΔ > 0) {
|
if (versionΔ > 0) {
|
||||||
increments.push(
|
deltas.push(
|
||||||
...this.incrementsRepository.getAllSinceVersion(
|
...this.repository.getAllSinceVersion(lastAcknowledgedClientVersion),
|
||||||
lastAcknowledgedClientVersion,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.send(client, {
|
this.send(client, {
|
||||||
type: "acknowledged",
|
type: "acknowledged",
|
||||||
payload: {
|
payload: {
|
||||||
increments,
|
deltas,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private push(client: WebSocket, increment: PUSH_PAYLOAD) {
|
private push(client: WebSocket, delta: PUSH_PAYLOAD) {
|
||||||
// CFDO: try to apply the increments to the snapshot
|
// CFDO: try to apply the deltas to the snapshot
|
||||||
const [acknowledged, error] = Utils.try(() =>
|
const [acknowledged, error] = Utils.try(() => this.repository.save(delta));
|
||||||
this.incrementsRepository.save(increment),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error || !acknowledged) {
|
if (error || !acknowledged) {
|
||||||
// everything should be automatically rolled-back -> double-check
|
// everything should be automatically rolled-back -> double-check
|
||||||
return this.send(client, {
|
return this.send(client, {
|
||||||
type: "rejected",
|
type: "rejected",
|
||||||
payload: {
|
payload: {
|
||||||
message: error ? error.message : "Coudn't persist the increment.",
|
message: error ? error.message : "Coudn't persist the delta.",
|
||||||
increments: [increment],
|
deltas: [delta],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -205,7 +199,7 @@ export class ExcalidrawSyncServer {
|
||||||
return this.broadcast({
|
return this.broadcast({
|
||||||
type: "acknowledged",
|
type: "acknowledged",
|
||||||
payload: {
|
payload: {
|
||||||
increments: [acknowledged],
|
deltas: [acknowledged],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,8 +41,8 @@ import {
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { queryByText } from "@testing-library/react";
|
import { queryByText } from "@testing-library/react";
|
||||||
import { AppStateChange, ElementsChange } from "../change";
|
import { AppStateDelta, ElementsDelta } from "../delta";
|
||||||
import { StoreAction, StoreIncrement } from "../store";
|
import { SnapshotAction, StoreDelta } from "../store";
|
||||||
import type { LocalPoint, Radians } from "../../math";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
import type { AppState } from "../types.js";
|
import type { AppState } from "../types.js";
|
||||||
|
@ -91,10 +91,10 @@ const checkpoint = (name: string) => {
|
||||||
h.history.undoStack.map((x) => ({
|
h.history.undoStack.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
elementsChange: {
|
elementsChange: {
|
||||||
...x.elementsChange,
|
...x.elements,
|
||||||
added: stripSeed(x.elementsChange.added),
|
added: stripSeed(x.elements.added),
|
||||||
removed: stripSeed(x.elementsChange.updated),
|
removed: stripSeed(x.elements.updated),
|
||||||
updated: stripSeed(x.elementsChange.removed),
|
updated: stripSeed(x.elements.removed),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
).toMatchSnapshot(`[${name}] undo stack`);
|
).toMatchSnapshot(`[${name}] undo stack`);
|
||||||
|
@ -103,10 +103,10 @@ const checkpoint = (name: string) => {
|
||||||
h.history.redoStack.map((x) => ({
|
h.history.redoStack.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
elementsChange: {
|
elementsChange: {
|
||||||
...x.elementsChange,
|
...x.elements,
|
||||||
added: stripSeed(x.elementsChange.added),
|
added: stripSeed(x.elements.added),
|
||||||
removed: stripSeed(x.elementsChange.updated),
|
removed: stripSeed(x.elements.updated),
|
||||||
updated: stripSeed(x.elementsChange.removed),
|
updated: stripSeed(x.elements.removed),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
).toMatchSnapshot(`[${name}] redo stack`);
|
).toMatchSnapshot(`[${name}] redo stack`);
|
||||||
|
@ -137,16 +137,14 @@ describe("history", () => {
|
||||||
|
|
||||||
API.setElements([rect]);
|
API.setElements([rect]);
|
||||||
|
|
||||||
const corrupedEntry = StoreIncrement.create(
|
const corrupedEntry = StoreDelta.create(
|
||||||
ElementsChange.empty(),
|
ElementsDelta.empty(),
|
||||||
AppStateChange.empty(),
|
AppStateDelta.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(corrupedEntry.elementsChange, "applyTo").mockImplementation(
|
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||||
() => {
|
throw new Error("Oh no, I am corrupted!");
|
||||||
throw new Error("Oh no, I am corrupted!");
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
(h.history as any).undoStack.push(corrupedEntry);
|
(h.history as any).undoStack.push(corrupedEntry);
|
||||||
|
|
||||||
|
@ -218,7 +216,7 @@ describe("history", () => {
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
@ -230,7 +228,7 @@ describe("history", () => {
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
storeAction: StoreAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
|
snapshotAction: SnapshotAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
@ -598,7 +596,7 @@ describe("history", () => {
|
||||||
appState: {
|
appState: {
|
||||||
name: "New name",
|
name: "New name",
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
@ -609,7 +607,7 @@ describe("history", () => {
|
||||||
appState: {
|
appState: {
|
||||||
viewBackgroundColor: "#000",
|
viewBackgroundColor: "#000",
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
@ -622,7 +620,7 @@ describe("history", () => {
|
||||||
name: "New name",
|
name: "New name",
|
||||||
viewBackgroundColor: "#000",
|
viewBackgroundColor: "#000",
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
@ -1329,7 +1327,7 @@ describe("history", () => {
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, text, rect2],
|
elements: [rect1, text, rect2],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// bind text1 to rect1
|
// bind text1 to rect1
|
||||||
|
@ -1901,7 +1899,7 @@ describe("history", () => {
|
||||||
strokeColor: blue,
|
strokeColor: blue,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -1939,7 +1937,7 @@ describe("history", () => {
|
||||||
strokeColor: yellow,
|
strokeColor: yellow,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -1987,7 +1985,7 @@ describe("history", () => {
|
||||||
backgroundColor: yellow,
|
backgroundColor: yellow,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
||||||
|
@ -2003,7 +2001,7 @@ describe("history", () => {
|
||||||
backgroundColor: violet,
|
backgroundColor: violet,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
||||||
|
@ -2048,7 +2046,7 @@ describe("history", () => {
|
||||||
|
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect, diamond],
|
elements: [rect, diamond],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect the arrow
|
// Connect the arrow
|
||||||
|
@ -2097,7 +2095,7 @@ describe("history", () => {
|
||||||
} as FixedPointBinding,
|
} as FixedPointBinding,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2112,7 +2110,7 @@ describe("history", () => {
|
||||||
}
|
}
|
||||||
: el,
|
: el,
|
||||||
),
|
),
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2136,7 +2134,7 @@ describe("history", () => {
|
||||||
// Initialize scene
|
// Initialize scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -2145,7 +2143,7 @@ describe("history", () => {
|
||||||
newElementWith(h.elements[0], { groupIds: ["A"] }),
|
newElementWith(h.elements[0], { groupIds: ["A"] }),
|
||||||
newElementWith(h.elements[1], { groupIds: ["A"] }),
|
newElementWith(h.elements[1], { groupIds: ["A"] }),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
|
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
|
||||||
|
@ -2159,7 +2157,7 @@ describe("history", () => {
|
||||||
rect3,
|
rect3,
|
||||||
rect4,
|
rect4,
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2205,7 +2203,7 @@ describe("history", () => {
|
||||||
] as LocalPoint[],
|
] as LocalPoint[],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo(); // undo `actionFinalize`
|
Keyboard.undo(); // undo `actionFinalize`
|
||||||
|
@ -2300,7 +2298,7 @@ describe("history", () => {
|
||||||
isDeleted: false, // undeletion might happen due to concurrency between clients
|
isDeleted: false, // undeletion might happen due to concurrency between clients
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
expect(API.getSelectedElements()).toEqual([]);
|
||||||
|
@ -2377,7 +2375,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
|
@ -2439,7 +2437,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2515,7 +2513,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2554,7 +2552,7 @@ describe("history", () => {
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
@ -2600,7 +2598,7 @@ describe("history", () => {
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
@ -2610,7 +2608,7 @@ describe("history", () => {
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
@ -2631,7 +2629,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2656,7 +2654,7 @@ describe("history", () => {
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
@ -2667,7 +2665,7 @@ describe("history", () => {
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
@ -2713,7 +2711,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2734,7 +2732,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2777,7 +2775,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2820,7 +2818,7 @@ describe("history", () => {
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
@ -2859,7 +2857,7 @@ describe("history", () => {
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
@ -2910,7 +2908,7 @@ describe("history", () => {
|
||||||
h.elements[0], // rect2
|
h.elements[0], // rect2
|
||||||
h.elements[1], // rect1
|
h.elements[1], // rect1
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2940,7 +2938,7 @@ describe("history", () => {
|
||||||
h.elements[0], // rect3
|
h.elements[0], // rect3
|
||||||
h.elements[2], // rect1
|
h.elements[2], // rect1
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -2970,7 +2968,7 @@ describe("history", () => {
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [...h.elements, rect],
|
elements: [...h.elements, rect],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(60, 60);
|
mouse.moveTo(60, 60);
|
||||||
|
@ -3022,7 +3020,7 @@ describe("history", () => {
|
||||||
// // Simulate remote update
|
// // Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [...h.elements, rect3],
|
elements: [...h.elements, rect3],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
|
@ -3112,7 +3110,7 @@ describe("history", () => {
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [...h.elements, rect3],
|
elements: [...h.elements, rect3],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
|
@ -3289,7 +3287,7 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -3302,7 +3300,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -3333,7 +3331,7 @@ describe("history", () => {
|
||||||
x: h.elements[1].x + 10,
|
x: h.elements[1].x + 10,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3376,7 +3374,7 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -3389,7 +3387,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -3423,7 +3421,7 @@ describe("history", () => {
|
||||||
remoteText,
|
remoteText,
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3479,7 +3477,7 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -3492,7 +3490,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -3529,7 +3527,7 @@ describe("history", () => {
|
||||||
containerId: remoteContainer.id,
|
containerId: remoteContainer.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3539,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,
|
||||||
}),
|
}),
|
||||||
|
@ -3587,7 +3585,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3598,7 +3596,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3648,7 +3646,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3659,7 +3657,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3708,7 +3706,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3721,7 +3719,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -3758,7 +3756,7 @@ describe("history", () => {
|
||||||
// rebinding the container with a new text element!
|
// rebinding the container with a new text element!
|
||||||
remoteText,
|
remoteText,
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3815,7 +3813,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3828,7 +3826,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -3865,7 +3863,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3921,7 +3919,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3935,7 +3933,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -3978,7 +3976,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -3992,7 +3990,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -4035,7 +4033,7 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -4047,7 +4045,7 @@ describe("history", () => {
|
||||||
angle: 90 as Radians,
|
angle: 90 as Radians,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -4060,7 +4058,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
|
@ -4153,7 +4151,7 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -4165,7 +4163,7 @@ describe("history", () => {
|
||||||
angle: 90 as Radians,
|
angle: 90 as Radians,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -4180,7 +4178,7 @@ describe("history", () => {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
|
@ -4271,7 +4269,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
|
@ -4360,7 +4358,7 @@ describe("history", () => {
|
||||||
x: h.elements[1].x + 50,
|
x: h.elements[1].x + 50,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -4504,7 +4502,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
remoteContainer,
|
remoteContainer,
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -4611,7 +4609,7 @@ describe("history", () => {
|
||||||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -4688,7 +4686,7 @@ describe("history", () => {
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [arrow],
|
elements: [arrow],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
|
@ -4715,7 +4713,7 @@ describe("history", () => {
|
||||||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
|
@ -4847,7 +4845,7 @@ describe("history", () => {
|
||||||
newElementWith(h.elements[1], { x: 500, y: -500 }),
|
newElementWith(h.elements[1], { x: 500, y: -500 }),
|
||||||
h.elements[2],
|
h.elements[2],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
@ -4919,13 +4917,13 @@ describe("history", () => {
|
||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [frame],
|
elements: [frame],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [rect, h.elements[0]],
|
elements: [rect, h.elements[0]],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
|
@ -4936,7 +4934,7 @@ describe("history", () => {
|
||||||
}),
|
}),
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
@ -4980,7 +4978,7 @@ describe("history", () => {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.UPDATE,
|
snapshotAction: SnapshotAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { Excalidraw, StoreAction } from "../../index";
|
import { Excalidraw, SnapshotAction } from "../../index";
|
||||||
import type { ExcalidrawImperativeAPI } from "../../types";
|
import type { ExcalidrawImperativeAPI } from "../../types";
|
||||||
import { resolvablePromise } from "../../utils";
|
import { resolvablePromise } from "../../utils";
|
||||||
import { render } from "../test-utils";
|
import { render } from "../test-utils";
|
||||||
|
@ -31,7 +31,7 @@ describe("event callbacks", () => {
|
||||||
excalidrawAPI.onChange(onChange);
|
excalidrawAPI.onChange(onChange);
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
appState: { viewBackgroundColor: "red" },
|
appState: { viewBackgroundColor: "red" },
|
||||||
storeAction: StoreAction.CAPTURE,
|
snapshotAction: SnapshotAction.CAPTURE,
|
||||||
});
|
});
|
||||||
expect(onChange).toHaveBeenCalledWith(
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
// elements
|
// elements
|
||||||
|
|
|
@ -10,6 +10,6 @@
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,11 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import type { SnapLine } from "./snapping";
|
import type { SnapLine } from "./snapping";
|
||||||
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
|
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
|
||||||
import type { StoreActionType, StoreIncrement } from "./store";
|
import type {
|
||||||
|
DurableStoreIncrement,
|
||||||
|
EphemeralStoreIncrement,
|
||||||
|
SnapshotActionType,
|
||||||
|
} from "./store";
|
||||||
|
|
||||||
export type SocketId = string & { _brand: "SocketId" };
|
export type SocketId = string & { _brand: "SocketId" };
|
||||||
|
|
||||||
|
@ -498,7 +502,9 @@ export interface ExcalidrawProps {
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
onIncrement?: (event: StoreIncrement) => void;
|
onIncrement?: (
|
||||||
|
event: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||||
|
) => void;
|
||||||
initialData?:
|
initialData?:
|
||||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||||
|
@ -572,7 +578,7 @@ export type SceneData = {
|
||||||
elements?: ImportedDataState["elements"];
|
elements?: ImportedDataState["elements"];
|
||||||
appState?: ImportedDataState["appState"];
|
appState?: ImportedDataState["appState"];
|
||||||
collaborators?: Map<SocketId, Collaborator>;
|
collaborators?: Map<SocketId, Collaborator>;
|
||||||
storeAction?: StoreActionType;
|
snapshotAction?: SnapshotActionType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum UserIdleState {
|
export enum UserIdleState {
|
||||||
|
@ -785,7 +791,7 @@ export interface ExcalidrawImperativeAPI {
|
||||||
) => void,
|
) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
onIncrement: (
|
onIncrement: (
|
||||||
callback: (event: StoreIncrement) => void,
|
callback: (event: DurableStoreIncrement | EphemeralStoreIncrement) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
onPointerDown: (
|
onPointerDown: (
|
||||||
callback: (
|
callback: (
|
||||||
|
|
2
packages/excalidraw/worker-runtime.d.ts
vendored
2
packages/excalidraw/worker-runtime.d.ts
vendored
|
@ -1,4 +1,4 @@
|
||||||
// Runtime types generated with workerd@1.20241106.1 2024-11-12
|
// Runtime types generated with workerd@1.20241106.1 2024-11-12
|
||||||
/*! *****************************************************************************
|
/*! *****************************************************************************
|
||||||
Copyright (c) Cloudflare. All rights reserved.
|
Copyright (c) Cloudflare. All rights reserved.
|
||||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["packages", "excalidraw-app"],
|
"include": ["packages", "excalidraw-app"],
|
||||||
"exclude": ["packages/excalidraw/types", "examples"]
|
"exclude": ["packages/excalidraw/types", "examples"]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue