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 |
|
||||
| `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. |
|
||||
| `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
|
||||
function App() {
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
Excalidraw,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialogTrigger,
|
||||
StoreAction,
|
||||
SnapshotAction,
|
||||
reconcileElements,
|
||||
newElementWith,
|
||||
} from "../packages/excalidraw";
|
||||
|
@ -44,6 +44,7 @@ import {
|
|||
preventUnload,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
assertNever,
|
||||
} from "../packages/excalidraw/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
|
@ -109,7 +110,11 @@ import Trans from "../packages/excalidraw/components/Trans";
|
|||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||
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 {
|
||||
CommandPalette,
|
||||
DEFAULT_CATEGORIES,
|
||||
|
@ -370,20 +375,30 @@ const ExcalidrawWrapper = () => {
|
|||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [syncAPI] = useAtom(syncApiAtom);
|
||||
const [nextVersion, setNextVersion] = useState(-1);
|
||||
const currentVersion = useRef(-1);
|
||||
const [acknowledgedIncrements, setAcknowledgedIncrements] = useState<
|
||||
StoreIncrement[]
|
||||
>([]);
|
||||
const [sliderVersion, setSliderVersion] = useState(0);
|
||||
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
|
||||
[],
|
||||
);
|
||||
const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
|
||||
useEffect(() => {
|
||||
acknowledgedDeltasRef.current = acknowledgedDeltas;
|
||||
}, [acknowledgedDeltas]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAcknowledgedIncrements([...(syncAPI?.acknowledgedIncrements ?? [])]);
|
||||
}, 250);
|
||||
const deltas = syncAPI?.acknowledgedDeltas ?? [];
|
||||
|
||||
// CFDO: buffer local deltas as well, not only acknowledged ones
|
||||
if (deltas.length > acknowledgedDeltasRef.current.length) {
|
||||
setAcknowledgedDeltas([...deltas]);
|
||||
setSliderVersion(deltas.length);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
syncAPI?.connect();
|
||||
|
||||
|
@ -512,7 +527,7 @@ const ExcalidrawWrapper = () => {
|
|||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -539,7 +554,7 @@ const ExcalidrawWrapper = () => {
|
|||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
if (data) {
|
||||
|
@ -671,7 +686,7 @@ const ExcalidrawWrapper = () => {
|
|||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -689,18 +704,31 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onIncrement = (increment: StoreIncrement) => {
|
||||
// ephemerals are not part of this (which is alright)
|
||||
// - wysiwyg, dragging elements / points, mouse movements, etc.
|
||||
const { elementsChange } = increment;
|
||||
|
||||
// CFDO: some appState like selections should also be transfered (we could even persist it)
|
||||
if (!elementsChange.isEmpty()) {
|
||||
const onIncrement = (
|
||||
increment: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||
) => {
|
||||
try {
|
||||
syncAPI?.push(increment);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!syncAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
// push only if there are element changes
|
||||
if (!increment.delta.elements.isEmpty()) {
|
||||
syncAPI.push(increment.delta);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StoreIncrement.isEphemeral(increment)) {
|
||||
syncAPI.relay(increment.change);
|
||||
return;
|
||||
}
|
||||
|
||||
assertNever(increment, `Unknown increment type`);
|
||||
} catch (e) {
|
||||
console.error("Error during onIncrement handler", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -826,28 +854,32 @@ const ExcalidrawWrapper = () => {
|
|||
},
|
||||
};
|
||||
|
||||
const debouncedTimeTravel = debounce((value: number) => {
|
||||
const debouncedTimeTravel = debounce(
|
||||
(value: number, direction: "forward" | "backward") => {
|
||||
let elements = new Map(
|
||||
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
|
||||
);
|
||||
|
||||
let increments: StoreIncrement[] = [];
|
||||
let deltas: StoreDelta[] = [];
|
||||
|
||||
const goingLeft =
|
||||
currentVersion.current === -1 || value - currentVersion.current <= 0;
|
||||
|
||||
if (goingLeft) {
|
||||
increments = acknowledgedIncrements
|
||||
switch (direction) {
|
||||
case "forward": {
|
||||
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
|
||||
break;
|
||||
}
|
||||
case "backward": {
|
||||
deltas = acknowledgedDeltas
|
||||
.slice(value)
|
||||
.reverse()
|
||||
.map((x) => x.inverse());
|
||||
} else {
|
||||
increments =
|
||||
acknowledgedIncrements.slice(currentVersion.current, value) ?? [];
|
||||
.map((x) => StoreDelta.inverse(x));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(direction, `Unknown direction: ${direction}`);
|
||||
}
|
||||
|
||||
for (const increment of increments) {
|
||||
[elements] = increment.elementsChange.applyTo(
|
||||
for (const delta of deltas) {
|
||||
[elements] = delta.elements.applyTo(
|
||||
elements as SceneElementsMap,
|
||||
excalidrawAPI?.store.snapshot.elements!,
|
||||
);
|
||||
|
@ -856,16 +888,14 @@ const ExcalidrawWrapper = () => {
|
|||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
...excalidrawAPI?.getAppState(),
|
||||
viewModeEnabled: value !== -1,
|
||||
viewModeEnabled: value !== acknowledgedDeltas.length,
|
||||
},
|
||||
elements: Array.from(elements.values()),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.NONE,
|
||||
});
|
||||
|
||||
currentVersion.current = value;
|
||||
}, 0);
|
||||
|
||||
const latestVersion = acknowledgedIncrements.length;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -884,25 +914,30 @@ const ExcalidrawWrapper = () => {
|
|||
}}
|
||||
step={1}
|
||||
min={0}
|
||||
max={latestVersion}
|
||||
value={nextVersion === -1 ? latestVersion : nextVersion}
|
||||
max={acknowledgedDeltas.length}
|
||||
value={sliderVersion}
|
||||
onChange={(value) => {
|
||||
let nextValue: number;
|
||||
|
||||
// CFDO: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
|
||||
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: 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
|
||||
syncAPI?.disconnect();
|
||||
nextValue = value as number;
|
||||
} else {
|
||||
// reconnect once we're back to the latest version
|
||||
syncAPI?.connect();
|
||||
nextValue = -1;
|
||||
}
|
||||
|
||||
setNextVersion(nextValue);
|
||||
debouncedTimeTravel(nextValue);
|
||||
if (nextSliderVersion === sliderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
debouncedTimeTravel(
|
||||
nextSliderVersion,
|
||||
nextSliderVersion < sliderVersion ? "backward" : "forward",
|
||||
);
|
||||
|
||||
setSliderVersion(nextSliderVersion);
|
||||
}}
|
||||
/>
|
||||
<Excalidraw
|
||||
|
|
|
@ -15,7 +15,7 @@ import type {
|
|||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
StoreAction,
|
||||
SnapshotAction,
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
|
@ -393,7 +393,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||
|
||||
this.excalidrawAPI.updateScene({
|
||||
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.
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
|
@ -782,7 +782,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
this.loadImageFiles();
|
||||
|
|
|
@ -19,7 +19,7 @@ import throttle from "lodash.throttle";
|
|||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
import { SnapshotAction } from "../../packages/excalidraw";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
|
@ -133,7 +133,7 @@ class Portal {
|
|||
if (isChanged) {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: newElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}, 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 { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
|
@ -268,6 +268,6 @@ export const updateStaleImageStatuses = (params: {
|
|||
}
|
||||
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 { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
import { StoreIncrement } from "../../packages/excalidraw/store";
|
||||
import { StoreDelta } from "../../packages/excalidraw/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 = {
|
||||
lastAcknowledgedVersion: number;
|
||||
|
@ -270,7 +270,7 @@ export class SyncIndexedDBAdapter {
|
|||
/** IndexedDB database and store name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_SYNC;
|
||||
/** library data store keys */
|
||||
private static incrementsKey = "increments";
|
||||
private static deltasKey = "deltas";
|
||||
private static metadataKey = "metadata";
|
||||
|
||||
private static store = createStore(
|
||||
|
@ -278,24 +278,22 @@ export class SyncIndexedDBAdapter {
|
|||
`${SyncIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async loadIncrements() {
|
||||
const increments = await get<SyncIncrementPersistedData>(
|
||||
SyncIndexedDBAdapter.incrementsKey,
|
||||
static async loadDeltas() {
|
||||
const deltas = await get<SyncDeltaPersistedData>(
|
||||
SyncIndexedDBAdapter.deltasKey,
|
||||
SyncIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
if (increments?.length) {
|
||||
return increments.map((storeIncrementDTO) =>
|
||||
StoreIncrement.restore(storeIncrementDTO),
|
||||
);
|
||||
if (deltas?.length) {
|
||||
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async saveIncrements(data: SyncIncrementPersistedData): Promise<void> {
|
||||
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
|
||||
return set(
|
||||
SyncIndexedDBAdapter.incrementsKey,
|
||||
SyncIndexedDBAdapter.deltasKey,
|
||||
data,
|
||||
SyncIndexedDBAdapter.store,
|
||||
);
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "../../packages/excalidraw/actions/actionHistory";
|
||||
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||
import { SnapshotAction, newElementWith } from "../../packages/excalidraw";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -89,7 +89,7 @@ describe("collaboration", () => {
|
|||
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1, rect2]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
API.updateScene({
|
||||
|
@ -97,7 +97,7 @@ describe("collaboration", () => {
|
|||
rect1,
|
||||
newElementWith(h.elements[1], { isDeleted: true }),
|
||||
]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -144,7 +144,7 @@ describe("collaboration", () => {
|
|||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -182,7 +182,7 @@ describe("collaboration", () => {
|
|||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -217,7 +217,7 @@ describe("collaboration", () => {
|
|||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| _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. |
|
||||
| _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. |
|
||||
| _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 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. |
|
||||
|
||||
- `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 { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
|
@ -18,7 +18,7 @@ export const actionAddToLibrary = register({
|
|||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
|
@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.then(() => {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
|
@ -51,7 +51,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
|
|
@ -16,7 +16,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
@ -72,7 +72,7 @@ export const actionAlignTop = register({
|
|||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -106,7 +106,7 @@ export const actionAlignBottom = register({
|
|||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -140,7 +140,7 @@ export const actionAlignLeft = register({
|
|||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -174,7 +174,7 @@ export const actionAlignRight = register({
|
|||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -208,7 +208,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
@ -238,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
|
|
@ -34,7 +34,7 @@ import type { Mutable } from "../utility-types";
|
|||
import { arrayToMap, getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
|
@ -86,7 +86,7 @@ export const actionUnbindText = register({
|
|||
return {
|
||||
elements,
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -163,7 +163,7 @@ export const actionBindText = register({
|
|||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -323,7 +323,7 @@ export const actionWrapTextInContainer = register({
|
|||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -37,7 +37,7 @@ import {
|
|||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
|
@ -55,8 +55,8 @@ export const actionChangeViewBackgroundColor = register({
|
|||
return {
|
||||
appState: { ...appState, ...value },
|
||||
storeAction: !!value.viewBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
? SnapshotAction.CAPTURE
|
||||
: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
|
@ -115,7 +115,7 @@ export const actionClearCanvas = register({
|
|||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -140,7 +140,7 @@ export const actionZoomIn = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -181,7 +181,7 @@ export const actionZoomOut = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -222,7 +222,7 @@ export const actionResetZoom = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -341,7 +341,7 @@ export const zoomToFitBounds = ({
|
|||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -472,7 +472,7 @@ export const actionToggleTheme = register({
|
|||
theme:
|
||||
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,
|
||||
|
@ -510,7 +510,7 @@ export const actionToggleEraserTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
|
@ -549,7 +549,7 @@ export const actionToggleHandTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { getTextFromElements, isTextElement } from "../element";
|
|||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
|
@ -32,7 +32,7 @@ export const actionCopy = register({
|
|||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
@ -41,7 +41,7 @@ export const actionCopy = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
|
@ -67,7 +67,7 @@ export const actionPaste = register({
|
|||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
|
@ -76,7 +76,7 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
|
@ -89,7 +89,7 @@ export const actionPaste = register({
|
|||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
|
@ -98,7 +98,7 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
// 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) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -167,7 +167,7 @@ export const actionCopyAsSvg = register({
|
|||
}),
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -175,7 +175,7 @@ export const actionCopyAsSvg = register({
|
|||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -193,7 +193,7 @@ export const actionCopyAsPng = register({
|
|||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
|
@ -227,7 +227,7 @@ export const actionCopyAsPng = register({
|
|||
}),
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -236,7 +236,7 @@ export const actionCopyAsPng = register({
|
|||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -263,7 +263,7 @@ export const copyText = register({
|
|||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { register } from "./register";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { isImageElement } from "../element/typeChecks";
|
||||
|
@ -25,7 +25,7 @@ export const actionToggleCropEditor = register({
|
|||
isCropping: false,
|
||||
croppingElementId: selectedElement.id,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -189,7 +189,7 @@ export const actionDeleteSelected = register({
|
|||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -221,7 +221,7 @@ export const actionDeleteSelected = register({
|
|||
: [0],
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
|
@ -245,8 +245,8 @@ export const actionDeleteSelected = register({
|
|||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
? SnapshotAction.CAPTURE
|
||||
: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, elements) =>
|
||||
|
|
|
@ -12,7 +12,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
@ -60,7 +60,7 @@ export const distributeHorizontally = register({
|
|||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -91,7 +91,7 @@ export const distributeVertically = register({
|
|||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -32,7 +32,7 @@ import {
|
|||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
|
@ -52,7 +52,7 @@ export const actionDuplicateSelection = register({
|
|||
return {
|
||||
elements,
|
||||
appState: newAppState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
|
@ -61,7 +61,7 @@ export const actionDuplicateSelection = register({
|
|||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
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 { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -67,7 +67,7 @@ export const actionToggleElementLock = register({
|
|||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, elements, app) => {
|
||||
|
@ -112,7 +112,7 @@ export const actionUnlockAllElements = register({
|
|||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
label: "labels.elementLock.unlockAll",
|
||||
|
|
|
@ -19,7 +19,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
|||
import type { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
|
@ -28,7 +28,7 @@ export const actionChangeProjectName = register({
|
|||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, name: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||
|
@ -48,7 +48,7 @@ export const actionChangeExportScale = register({
|
|||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
|
@ -98,7 +98,7 @@ export const actionChangeExportBackground = register({
|
|||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -118,7 +118,7 @@ export const actionChangeExportEmbedScene = register({
|
|||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -160,7 +160,7 @@ export const actionSaveToActiveFile = register({
|
|||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
|
@ -182,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
|||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { storeAction: StoreAction.NONE };
|
||||
return { storeAction: SnapshotAction.NONE };
|
||||
}
|
||||
},
|
||||
// CFDO: temporary
|
||||
|
@ -208,7 +208,7 @@ export const actionSaveFileToDisk = register({
|
|||
app.getName(),
|
||||
);
|
||||
return {
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
|
@ -222,7 +222,7 @@ export const actionSaveFileToDisk = register({
|
|||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { storeAction: StoreAction.NONE };
|
||||
return { storeAction: SnapshotAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -261,7 +261,7 @@ export const actionLoadScene = register({
|
|||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
|
@ -272,7 +272,7 @@ export const actionLoadScene = register({
|
|||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -286,7 +286,7 @@ export const actionExportWithDarkMode = register({
|
|||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { pointFrom } from "../../math";
|
||||
import { isPathALoop } from "../shapes";
|
||||
|
||||
|
@ -52,7 +52,7 @@ export const actionFinalize = register({
|
|||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -199,7 +199,7 @@ export const actionFinalize = register({
|
|||
pendingImageElementId: null,
|
||||
},
|
||||
// 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) =>
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
|
@ -47,7 +47,7 @@ export const actionFlipHorizontal = register({
|
|||
app,
|
||||
),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
|
@ -72,7 +72,7 @@ export const actionFlipVertical = register({
|
|||
app,
|
||||
),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -9,11 +9,11 @@ import { setCursorForShape } from "../cursor";
|
|||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { newFrameElement } from "../element/newElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
|
@ -49,14 +49,14 @@ export const actionSelectAllElementsInFrame = register({
|
|||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) =>
|
||||
|
@ -80,14 +80,14 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) =>
|
||||
|
@ -109,7 +109,7 @@ export const actionupdateFrameRendering = register({
|
|||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
|
@ -139,7 +139,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||
type: "frame",
|
||||
}),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -34,7 +34,7 @@ import {
|
|||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
|
@ -84,7 +84,7 @@ export const actionGroup = register({
|
|||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// 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
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
|
@ -104,7 +104,7 @@ export const actionGroup = register({
|
|||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// 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,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) =>
|
||||
|
@ -200,7 +200,7 @@ export const actionUngroup = register({
|
|||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
return { appState, elements, storeAction: SnapshotAction.NONE };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
@ -273,7 +273,7 @@ export const actionUngroup = register({
|
|||
return {
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -9,8 +9,7 @@ import { KEYS, matchKey } from "../keys";
|
|||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const executeHistoryAction = (
|
||||
|
@ -30,7 +29,7 @@ const executeHistoryAction = (
|
|||
const result = updater();
|
||||
|
||||
if (!result) {
|
||||
return { storeAction: StoreAction.NONE };
|
||||
return { storeAction: SnapshotAction.NONE };
|
||||
}
|
||||
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
|
@ -39,11 +38,11 @@ const executeHistoryAction = (
|
|||
return {
|
||||
appState: nextAppState,
|
||||
elements: nextElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
storeAction: SnapshotAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
return { storeAction: StoreAction.NONE };
|
||||
return { storeAction: SnapshotAction.NONE };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
|
|||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawLinearElement } from "../element/types";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { register } from "./register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
@ -51,7 +51,7 @@ export const actionToggleLinearEditor = register({
|
|||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks";
|
|||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -25,7 +25,7 @@ export const actionLink = register({
|
|||
showHyperlinkPopup: "editor",
|
||||
openMenu: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
|
|
|
@ -4,7 +4,7 @@ import { t } from "../i18n";
|
|||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
|
@ -15,7 +15,7 @@ export const actionToggleCanvasMenu = register({
|
|||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -37,7 +37,7 @@ export const actionToggleEditMenu = register({
|
|||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -74,7 +74,7 @@ export const actionShortcuts = register({
|
|||
name: "help",
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import type { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
@ -28,7 +28,7 @@ export const actionGoToCollaborator = register({
|
|||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
|
|||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import type { SnapshotActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
|
@ -109,7 +109,7 @@ import {
|
|||
tupleToCoors,
|
||||
} from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
import {
|
||||
bindLinearElement,
|
||||
|
@ -270,7 +270,7 @@ const changeFontSize = (
|
|||
? [...newFontSizes][0]
|
||||
: fallbackValue ?? appState.currentItemFontSize,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -301,8 +301,8 @@ export const actionChangeStrokeColor = register({
|
|||
...value,
|
||||
},
|
||||
storeAction: !!value.currentItemStrokeColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
? SnapshotAction.CAPTURE
|
||||
: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
|
@ -347,8 +347,8 @@ export const actionChangeBackgroundColor = register({
|
|||
...value,
|
||||
},
|
||||
storeAction: !!value.currentItemBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
? SnapshotAction.CAPTURE
|
||||
: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
|
@ -392,7 +392,7 @@ export const actionChangeFillStyle = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemFillStyle: value },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -465,7 +465,7 @@ export const actionChangeStrokeWidth = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeWidth: value },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -520,7 +520,7 @@ export const actionChangeSloppiness = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemRoughness: value },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -571,7 +571,7 @@ export const actionChangeStrokeStyle = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeStyle: value },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -626,7 +626,7 @@ export const actionChangeOpacity = register({
|
|||
true,
|
||||
),
|
||||
appState: { ...appState, currentItemOpacity: value },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -814,22 +814,22 @@ export const actionChangeFontFamily = register({
|
|||
...appState,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.UPDATE,
|
||||
storeAction: SnapshotAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||
|
||||
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
||||
let nexStoreAction: SnapshotActionType = SnapshotAction.NONE;
|
||||
let nextFontFamily: FontFamilyValues | undefined;
|
||||
let skipOnHoverRender = false;
|
||||
|
||||
if (currentItemFontFamily) {
|
||||
nextFontFamily = currentItemFontFamily;
|
||||
nexStoreAction = StoreAction.CAPTURE;
|
||||
nexStoreAction = SnapshotAction.CAPTURE;
|
||||
} else if (currentHoveredFontFamily) {
|
||||
nextFontFamily = currentHoveredFontFamily;
|
||||
nexStoreAction = StoreAction.NONE;
|
||||
nexStoreAction = SnapshotAction.NONE;
|
||||
|
||||
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
|
@ -1187,7 +1187,7 @@ export const actionChangeTextAlign = register({
|
|||
...appState,
|
||||
currentItemTextAlign: value,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
|
@ -1277,7 +1277,7 @@ export const actionChangeVerticalAlign = register({
|
|||
appState: {
|
||||
...appState,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
|
@ -1362,7 +1362,7 @@ export const actionChangeRoundness = register({
|
|||
...appState,
|
||||
currentItemRoundness: value,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -1521,7 +1521,7 @@ export const actionChangeArrowhead = register({
|
|||
? "currentItemStartArrowhead"
|
||||
: "currentItemEndArrowhead"]: value.type,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -1731,7 +1731,7 @@ export const actionChangeArrowType = register({
|
|||
return {
|
||||
elements: newElements,
|
||||
appState: newState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
|
|
@ -6,7 +6,7 @@ import type { ExcalidrawElement } from "../element/types";
|
|||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
|
@ -50,7 +50,7 @@ export const actionSelectAll = register({
|
|||
? new LinearElementEditor(elements[0])
|
||||
: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
|
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { getSelectedElements } from "../scene";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
|
@ -53,7 +53,7 @@ export const actionCopyStyles = register({
|
|||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -70,7 +70,7 @@ export const actionPasteStyles = register({
|
|||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
return { elements, storeAction: StoreAction.NONE };
|
||||
return { elements, storeAction: SnapshotAction.NONE };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
|
@ -159,7 +159,7 @@ export const actionPasteStyles = register({
|
|||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { isTextElement } from "../element";
|
|||
import { newElementWith } from "../element/mutateElement";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
@ -42,7 +42,7 @@ export const actionTextAutoResize = register({
|
|||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { CODES, KEYS } from "../keys";
|
|||
import { register } from "./register";
|
||||
import type { AppState } from "../types";
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
|
@ -21,7 +21,7 @@ export const actionToggleGridMode = register({
|
|||
gridModeEnabled: !this.checked!(appState),
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { magnetIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
|
@ -19,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({
|
|||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridModeEnabled: false,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
|||
import { register } from "./register";
|
||||
import type { AppState } from "../types";
|
||||
import { searchIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
export const actionToggleSearchMenu = register({
|
||||
|
@ -29,7 +29,7 @@ export const actionToggleSearchMenu = register({
|
|||
if (searchInput?.matches(":focus")) {
|
||||
return {
|
||||
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 },
|
||||
openDialog: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { abacusIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
|
@ -17,7 +17,7 @@ export const actionToggleStats = register({
|
|||
...appState,
|
||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.stats.open,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { eyeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
|
@ -19,7 +19,7 @@ export const actionToggleViewMode = register({
|
|||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { coffeeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
|
@ -19,7 +19,7 @@ export const actionToggleZenMode = register({
|
|||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
|
@ -27,7 +27,7 @@ export const actionSendBackward = register({
|
|||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyPriority: 40,
|
||||
|
@ -57,7 +57,7 @@ export const actionBringForward = register({
|
|||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyPriority: 40,
|
||||
|
@ -87,7 +87,7 @@ export const actionSendToBack = register({
|
|||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -125,7 +125,7 @@ export const actionBringToFront = register({
|
|||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
|||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import type { SnapshotActionType } from "../store";
|
||||
|
||||
export type ActionSource =
|
||||
| "ui"
|
||||
|
@ -25,7 +25,7 @@ export type ActionResult =
|
|||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Partial<AppState> | null;
|
||||
files?: BinaryFiles | null;
|
||||
storeAction: StoreActionType;
|
||||
storeAction: SnapshotActionType;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
|
|
@ -1,21 +1,17 @@
|
|||
import type {
|
||||
IncrementsRepository,
|
||||
CLIENT_INCREMENT,
|
||||
SERVER_INCREMENT,
|
||||
} from "../sync/protocol";
|
||||
import type { DeltasRepository, DELTA, SERVER_DELTA } from "../sync/protocol";
|
||||
|
||||
// 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
|
||||
// and leaving a buffer for other row metadata
|
||||
private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
|
||||
|
||||
constructor(private storage: DurableObjectStorage) {
|
||||
// #region DEV ONLY
|
||||
// this.storage.sql.exec(`DROP TABLE IF EXISTS increments;`);
|
||||
// this.storage.sql.exec(`DROP TABLE IF EXISTS deltas;`);
|
||||
// #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,
|
||||
version 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(() => {
|
||||
const existingIncrement = this.getById(increment.id);
|
||||
const existingDelta = this.getById(delta.id);
|
||||
|
||||
// don't perist the same increment twice
|
||||
if (existingIncrement) {
|
||||
return existingIncrement;
|
||||
// don't perist the same delta twice
|
||||
if (existingDelta) {
|
||||
return existingDelta;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.stringify(increment);
|
||||
const payload = JSON.stringify(delta);
|
||||
const payloadSize = new TextEncoder().encode(payload).byteLength;
|
||||
const nextVersion = this.getLastVersion() + 1;
|
||||
const chunksCount = Math.ceil(
|
||||
payloadSize / DurableIncrementsRepository.MAX_PAYLOAD_SIZE,
|
||||
payloadSize / DurableDeltasRepository.MAX_PAYLOAD_SIZE,
|
||||
);
|
||||
|
||||
for (let position = 0; position < chunksCount; position++) {
|
||||
const start = position * DurableIncrementsRepository.MAX_PAYLOAD_SIZE;
|
||||
const end = start + DurableIncrementsRepository.MAX_PAYLOAD_SIZE;
|
||||
const start = position * DurableDeltasRepository.MAX_PAYLOAD_SIZE;
|
||||
const end = start + DurableDeltasRepository.MAX_PAYLOAD_SIZE;
|
||||
// slicing the chunk payload
|
||||
const chunkedPayload = payload.slice(start, end);
|
||||
|
||||
this.storage.sql.exec(
|
||||
`INSERT INTO increments (id, version, position, payload) VALUES (?, ?, ?, ?);`,
|
||||
increment.id,
|
||||
`INSERT INTO deltas (id, version, position, payload) VALUES (?, ?, ?, ?);`,
|
||||
delta.id,
|
||||
nextVersion,
|
||||
position,
|
||||
chunkedPayload,
|
||||
);
|
||||
}
|
||||
} 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
|
||||
// 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
|
||||
if (e instanceof Error && e.message.includes("SQLITE_CONSTRAINT")) {
|
||||
// continue;
|
||||
|
@ -68,68 +64,66 @@ export class DurableIncrementsRepository implements IncrementsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
const acknowledged = this.getById(increment.id);
|
||||
const acknowledged = this.getById(delta.id);
|
||||
return acknowledged;
|
||||
});
|
||||
}
|
||||
|
||||
// CFDO: for versioning we need deletions, but not for the "snapshot" update;
|
||||
public getAllSinceVersion(version: number): Array<SERVER_INCREMENT> {
|
||||
const increments = this.storage.sql
|
||||
.exec<SERVER_INCREMENT>(
|
||||
`SELECT id, payload, version FROM increments WHERE version > (?) ORDER BY version, position, createdAt ASC;`,
|
||||
public getAllSinceVersion(version: number): Array<SERVER_DELTA> {
|
||||
const deltas = this.storage.sql
|
||||
.exec<SERVER_DELTA>(
|
||||
`SELECT id, payload, version FROM deltas WHERE version > (?) ORDER BY version, position, createdAt ASC;`,
|
||||
version,
|
||||
)
|
||||
.toArray();
|
||||
|
||||
return this.restoreServerIncrements(increments);
|
||||
return this.restoreServerDeltas(deltas);
|
||||
}
|
||||
|
||||
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)
|
||||
const result = this.storage.sql
|
||||
.exec(`SELECT MAX(version) FROM increments;`)
|
||||
.exec(`SELECT MAX(version) FROM deltas;`)
|
||||
.one();
|
||||
|
||||
return result ? Number(result["MAX(version)"]) : 0;
|
||||
}
|
||||
|
||||
public getById(id: string): SERVER_INCREMENT | null {
|
||||
const increments = this.storage.sql
|
||||
.exec<SERVER_INCREMENT>(
|
||||
`SELECT id, payload, version FROM increments WHERE id = (?) ORDER BY position ASC`,
|
||||
public getById(id: string): SERVER_DELTA | null {
|
||||
const deltas = this.storage.sql
|
||||
.exec<SERVER_DELTA>(
|
||||
`SELECT id, payload, version FROM deltas WHERE id = (?) ORDER BY position ASC`,
|
||||
id,
|
||||
)
|
||||
.toArray();
|
||||
|
||||
if (!increments.length) {
|
||||
if (!deltas.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const restoredIncrements = this.restoreServerIncrements(increments);
|
||||
const restoredDeltas = this.restoreServerDeltas(deltas);
|
||||
|
||||
if (restoredIncrements.length !== 1) {
|
||||
if (restoredDeltas.length !== 1) {
|
||||
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(
|
||||
increments: SERVER_INCREMENT[],
|
||||
): SERVER_INCREMENT[] {
|
||||
private restoreServerDeltas(deltas: SERVER_DELTA[]): SERVER_DELTA[] {
|
||||
return Array.from(
|
||||
increments
|
||||
deltas
|
||||
.reduce((acc, curr) => {
|
||||
const increment = acc.get(curr.version);
|
||||
const delta = acc.get(curr.version);
|
||||
|
||||
if (increment) {
|
||||
if (delta) {
|
||||
acc.set(curr.version, {
|
||||
...increment,
|
||||
...delta,
|
||||
// glueing the chunks payload back
|
||||
payload: increment.payload + curr.payload,
|
||||
payload: delta.payload + curr.payload,
|
||||
});
|
||||
} else {
|
||||
// let's not unnecessarily expose more props than these
|
||||
|
@ -141,7 +135,7 @@ export class DurableIncrementsRepository implements IncrementsRepository {
|
|||
}
|
||||
|
||||
return acc;
|
||||
}, new Map<number, SERVER_INCREMENT>())
|
||||
}, new Map<number, SERVER_DELTA>())
|
||||
.values(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { DurableObject } from "cloudflare:workers";
|
||||
import { DurableIncrementsRepository } from "./repository";
|
||||
import { DurableDeltasRepository } from "./repository";
|
||||
import { ExcalidrawSyncServer } from "../sync/server";
|
||||
|
||||
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.sync = new ExcalidrawSyncServer(
|
||||
new DurableIncrementsRepository(ctx.storage),
|
||||
);
|
||||
const repository = new DurableDeltasRepository(ctx.storage);
|
||||
this.sync = new ExcalidrawSyncServer(repository);
|
||||
|
||||
// in case it hibernates, let's get take active connections
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
|
|
|
@ -419,7 +419,7 @@ import { COLOR_PALETTE } from "../colors";
|
|||
import { ElementCanvasButton } from "./MagicButton";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import FollowMode from "./FollowMode/FollowMode";
|
||||
import { Store, StoreAction } from "../store";
|
||||
import { Store, SnapshotAction } from "../store";
|
||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
import { LaserTrails } from "../laser-trails";
|
||||
|
@ -1819,7 +1819,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
public getSceneElementsMapIncludingDeleted = () => {
|
||||
return this.scene.getElementsMapIncludingDeleted();
|
||||
}
|
||||
};
|
||||
|
||||
public getSceneElements = () => {
|
||||
return this.scene.getNonDeletedElements();
|
||||
|
@ -2093,12 +2093,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (shouldUpdateStrokeColor) {
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemStrokeColor: color },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
} else {
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemBackgroundColor: color },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
@ -2112,7 +2112,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
return el;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -2133,11 +2133,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
||||
this.store.shouldUpdateSnapshot();
|
||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
this.store.scheduleAction(actionResult.storeAction);
|
||||
|
||||
let didUpdate = false;
|
||||
|
||||
|
@ -2210,7 +2206,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
didUpdate = true;
|
||||
}
|
||||
|
||||
if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) {
|
||||
if (!didUpdate) {
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
});
|
||||
|
@ -2338,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.resetHistory();
|
||||
this.syncActionResult({
|
||||
...scene,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
storeAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// 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.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
@ -3530,7 +3526,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||
}
|
||||
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
setAppState: React.Component<any, AppState>["setState"] = (
|
||||
|
@ -3873,12 +3869,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||
elements?: SceneData["elements"];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
collaborators?: SceneData["collaborators"];
|
||||
/** @default StoreAction.NONE */
|
||||
storeAction?: SceneData["storeAction"];
|
||||
/** @default SnapshotAction.NONE */
|
||||
snapshotAction?: SceneData["snapshotAction"];
|
||||
}) => {
|
||||
// flush all pending updates (if any) most of the time it's no-op
|
||||
flushSync(() => {});
|
||||
|
||||
// flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction`
|
||||
flushSync(() => {
|
||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||
|
||||
if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) {
|
||||
if (sceneData.snapshotAction) {
|
||||
const prevCommittedAppState = this.store.snapshot.appState;
|
||||
const prevCommittedElements = this.store.snapshot.elements;
|
||||
|
||||
|
@ -3893,19 +3894,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
)
|
||||
: 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
|
||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
||||
if (sceneData.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.captureIncrement(
|
||||
nextCommittedElements,
|
||||
nextCommittedAppState,
|
||||
);
|
||||
} else if (sceneData.storeAction === StoreAction.UPDATE) {
|
||||
this.store.updateSnapshot(
|
||||
nextCommittedElements,
|
||||
nextCommittedAppState,
|
||||
);
|
||||
}
|
||||
this.store.scheduleAction(sceneData.snapshotAction);
|
||||
this.store.commit(nextCommittedElements, nextCommittedAppState);
|
||||
}
|
||||
|
||||
if (sceneData.appState) {
|
||||
|
@ -3919,6 +3909,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (sceneData.collaborators) {
|
||||
this.setState({ collaborators: sceneData.collaborators });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -4372,7 +4363,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
if (!isElbowArrow(selectedElement)) {
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
|
@ -4580,7 +4571,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (!event.altKey) {
|
||||
if (this.flowChartNavigator.isExploring) {
|
||||
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.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -4691,7 +4682,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
} as const;
|
||||
|
||||
if (nextActiveTool.type === "freedraw") {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (nextActiveTool.type !== "selection") {
|
||||
|
@ -4894,7 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
|
@ -5304,7 +5295,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
};
|
||||
|
||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
this.setState({
|
||||
croppingElementId: image.id,
|
||||
});
|
||||
|
@ -5312,7 +5303,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
private finishImageCropping = () => {
|
||||
if (this.state.croppingElementId) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
this.setState({
|
||||
croppingElementId: null,
|
||||
});
|
||||
|
@ -5347,7 +5338,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
selectedElements[0].id) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||
});
|
||||
|
@ -5430,7 +5421,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||
|
||||
if (selectedGroupId) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
this.setState((prevState) => ({
|
||||
...prevState,
|
||||
...selectGroupsForSelectedElements(
|
||||
|
@ -6356,10 +6347,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.state,
|
||||
),
|
||||
},
|
||||
storeAction:
|
||||
snapshotAction:
|
||||
this.state.openDialog?.name === "elementLinkSelector"
|
||||
? StoreAction.NONE
|
||||
: StoreAction.UPDATE,
|
||||
? SnapshotAction.NONE
|
||||
: SnapshotAction.UPDATE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -8913,7 +8904,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
if (isLinearElement(newElement)) {
|
||||
if (newElement!.points.length > 1) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
|
@ -9011,7 +9002,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
appState: {
|
||||
newElement: null,
|
||||
},
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
return;
|
||||
|
@ -9172,7 +9163,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (resizingElement) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||
|
@ -9181,7 +9172,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
elements: this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.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.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -9590,7 +9581,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.elementsPendingErasure = new Set();
|
||||
|
||||
if (didChange) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.store.scheduleCapture();
|
||||
this.scene.replaceAllElements(elements);
|
||||
}
|
||||
};
|
||||
|
@ -10146,7 +10137,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
|
@ -10263,9 +10254,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (ret.type === MIME_TYPES.excalidraw) {
|
||||
// restore the fractional indices by mutating 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
|
||||
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
||||
this.store.scheduleAction(SnapshotAction.UPDATE);
|
||||
this.store.commit(arrayToMap(elements), this.state);
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
this.syncActionResult({
|
||||
|
@ -10275,7 +10266,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
} else if (ret.type === MIME_TYPES.excalidrawlib) {
|
||||
await this.library
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useApp } from "../App";
|
|||
import { InlineIcon } from "../InlineIcon";
|
||||
import type { StatsInputProperty } from "./utils";
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
import { StoreAction } from "../../store";
|
||||
import { SnapshotAction } from "../../store";
|
||||
import type Scene from "../../scene/Scene";
|
||||
|
||||
import "./DragInput.scss";
|
||||
|
@ -132,7 +132,7 @@ const StatsDragInput = <
|
|||
originalAppState: appState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
});
|
||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -276,7 +276,7 @@ const StatsDragInput = <
|
|||
false,
|
||||
);
|
||||
|
||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
|
||||
|
||||
lastPointer = null;
|
||||
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.
|
||||
*/
|
||||
class Delta<T> {
|
||||
export class Delta<T> {
|
||||
private constructor(
|
||||
public readonly deleted: 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) {
|
||||
const object1Value = object1[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];
|
||||
|
||||
/**
|
||||
* Checks whether there are actually `Delta`s.
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
|
||||
export class AppStateChange implements Change<AppState> {
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
nextAppState: T,
|
||||
): AppStateChange {
|
||||
): AppStateDelta {
|
||||
const delta = Delta.calculate(
|
||||
prevAppState,
|
||||
nextAppState,
|
||||
undefined,
|
||||
AppStateChange.postProcess,
|
||||
AppStateDelta.postProcess,
|
||||
);
|
||||
|
||||
return new AppStateChange(delta);
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static restore(
|
||||
appStateChangeDTO: DTO<AppStateChange>,
|
||||
): AppStateChange {
|
||||
const { delta } = appStateChangeDTO;
|
||||
return new AppStateChange(delta);
|
||||
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||
const { delta } = appStateDeltaDTO;
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
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);
|
||||
return new AppStateChange(inversedDelta);
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
|
@ -519,7 +518,7 @@ export class AppStateChange implements Change<AppState> {
|
|||
return [nextAppState, constainsVisibleChanges];
|
||||
} catch (e) {
|
||||
// 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) {
|
||||
throw e;
|
||||
|
@ -583,13 +582,13 @@ export class AppStateChange implements Change<AppState> {
|
|||
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||
|
||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
||||
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||
);
|
||||
|
||||
const containsElementsDifference = Delta.isRightDifferent(
|
||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||
);
|
||||
|
||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||
|
@ -604,8 +603,8 @@ export class AppStateChange implements Change<AppState> {
|
|||
if (containsElementsDifference) {
|
||||
// filter invisible changes on each iteration
|
||||
const changedElementsProps = Delta.getRightDifferences(
|
||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||
) as Array<keyof ObservedElementsAppState>;
|
||||
|
||||
let nonDeletedGroupIds = new Set<string>();
|
||||
|
@ -622,7 +621,7 @@ export class AppStateChange implements Change<AppState> {
|
|||
for (const key of changedElementsProps) {
|
||||
switch (key) {
|
||||
case "selectedElementIds":
|
||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
||||
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||
nextAppState[key],
|
||||
nextElements,
|
||||
visibleDifferenceFlag,
|
||||
|
@ -630,7 +629,7 @@ export class AppStateChange implements Change<AppState> {
|
|||
|
||||
break;
|
||||
case "selectedGroupIds":
|
||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
||||
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||
nextAppState[key],
|
||||
nonDeletedGroupIds,
|
||||
visibleDifferenceFlag,
|
||||
|
@ -666,7 +665,7 @@ export class AppStateChange implements Change<AppState> {
|
|||
break;
|
||||
case "selectedLinearElementId":
|
||||
case "editingLinearElementId":
|
||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
|
||||
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> =
|
||||
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.
|
||||
*/
|
||||
export class ElementsChange implements Change<SceneElementsMap> {
|
||||
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private constructor(
|
||||
public readonly added: 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>>,
|
||||
removed: Record<string, Delta<ElementPartial>>,
|
||||
updated: Record<string, Delta<ElementPartial>>,
|
||||
options: ElementsChangeOptions = {
|
||||
options: {
|
||||
shouldRedistribute: boolean;
|
||||
} = {
|
||||
shouldRedistribute: false,
|
||||
},
|
||||
) {
|
||||
const { shouldRedistribute } = options;
|
||||
let change: ElementsChange;
|
||||
let delta: ElementsDelta;
|
||||
|
||||
if (shouldRedistribute) {
|
||||
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 {
|
||||
change = new ElementsChange(added, removed, updated);
|
||||
delta = new ElementsDelta(added, removed, updated);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
||||
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return change;
|
||||
return delta;
|
||||
}
|
||||
|
||||
public static restore(
|
||||
elementsChangeDTO: DTO<ElementsChange>,
|
||||
): ElementsChange {
|
||||
const { added, removed, updated } = elementsChangeDTO;
|
||||
return ElementsChange.create(added, removed, updated);
|
||||
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||
const { added, removed, updated } = elementsDeltaDTO;
|
||||
return ElementsDelta.create(added, removed, updated);
|
||||
}
|
||||
|
||||
private static satisfiesAddition = ({
|
||||
|
@ -894,17 +889,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||
|
||||
private static validate(
|
||||
change: ElementsChange,
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
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)) {
|
||||
console.error(
|
||||
`Broken invariant for "${type}" delta, element "${id}", 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 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>(
|
||||
prevElements: Map<string, T>,
|
||||
nextElements: Map<string, T>,
|
||||
): ElementsChange {
|
||||
): ElementsDelta {
|
||||
if (prevElements === nextElements) {
|
||||
return ElementsChange.empty();
|
||||
return ElementsDelta.empty();
|
||||
}
|
||||
|
||||
const added: Record<string, Delta<ElementPartial>> = {};
|
||||
|
@ -940,7 +935,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const delta = Delta.create(
|
||||
deleted,
|
||||
inserted,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
|
@ -960,7 +955,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const delta = Delta.create(
|
||||
deleted,
|
||||
inserted,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
|
@ -972,8 +967,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const delta = Delta.calculate<ElementPartial>(
|
||||
prevElement,
|
||||
nextElement,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
ElementsChange.postProcess,
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
ElementsDelta.postProcess,
|
||||
);
|
||||
|
||||
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() {
|
||||
return ElementsChange.create({}, {}, {});
|
||||
return ElementsDelta.create({}, {}, {});
|
||||
}
|
||||
|
||||
public inverse(): ElementsChange {
|
||||
public inverse(): ElementsDelta {
|
||||
const inverseInternal = (deltas: 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 force generate a new id
|
||||
return ElementsChange.create(removed, added, updated);
|
||||
return ElementsDelta.create(removed, added, updated);
|
||||
}
|
||||
|
||||
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
|
||||
* @returns new instance with modified delta/s
|
||||
*/
|
||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
||||
public applyLatestChanges(elements: SceneElementsMap): ElementsDelta {
|
||||
const modifier =
|
||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||
const latestPartial: { [key: string]: unknown } = {};
|
||||
|
@ -1090,7 +1085,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const removed = applyLatestChangesInternal(this.removed);
|
||||
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
|
||||
});
|
||||
}
|
||||
|
@ -1109,7 +1104,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsChange.createApplier(
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
nextElements,
|
||||
snapshot,
|
||||
flags,
|
||||
|
@ -1129,7 +1124,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
...affectedElements,
|
||||
]);
|
||||
} 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) {
|
||||
throw e;
|
||||
|
@ -1144,18 +1139,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
|
||||
try {
|
||||
// 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
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
nextElements = ElementsChange.reorderElements(
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
||||
ElementsDelta.redrawBoundArrows(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
|
@ -1183,7 +1178,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
type: "added" | "removed" | "updated",
|
||||
deltas: Record<string, Delta<ElementPartial>>,
|
||||
) => {
|
||||
const getElement = ElementsChange.createGetter(
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
type,
|
||||
nextElements,
|
||||
snapshot,
|
||||
|
@ -1194,7 +1189,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
||||
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||
nextElements.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)) {
|
||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||
// 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) {
|
||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
const containsVisibleDifference =
|
||||
ElementsChange.checkForVisibleDifference(element, rest);
|
||||
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||
element,
|
||||
rest,
|
||||
);
|
||||
|
||||
flags.containsVisibleDifference = containsVisibleDifference;
|
||||
}
|
||||
|
@ -1335,6 +1333,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
* Resolves conflicts for all previously added, removed and updated elements.
|
||||
* Updates the previous deltas with all the changes after conflict resolution.
|
||||
*
|
||||
* // CFDO: revisit since arrow seem often redrawn incorrectly
|
||||
*
|
||||
* @returns all elements affected by the conflict resolution
|
||||
*/
|
||||
private resolveConflicts(
|
||||
|
@ -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
|
||||
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
|
||||
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
|
||||
|
@ -1394,7 +1394,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
continue;
|
||||
}
|
||||
|
||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
||||
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
nextAffectedElements,
|
||||
);
|
||||
|
@ -1590,7 +1590,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||
} catch (e) {
|
||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess elements change deltas.`);
|
||||
console.error(`Couldn't postprocess elements delta.`);
|
||||
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||
throw e;
|
|
@ -16,7 +16,7 @@ import type {
|
|||
IframeData,
|
||||
} from "./types";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import { StoreAction } from "../store";
|
||||
import { SnapshotAction } from "../store";
|
||||
|
||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
|
||||
|
@ -344,7 +344,7 @@ export const actionSetEmbeddableAsActiveTool = register({
|
|||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
storeAction: SnapshotAction.NONE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -775,7 +775,7 @@ export class LinearElementEditor {
|
|||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
store.shouldCaptureIncrement();
|
||||
store.scheduleCapture();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Emitter } from "./emitter";
|
||||
import { type Store, StoreDelta, StoreIncrement } from "./store";
|
||||
import type { SceneElementsMap } from "./element/types";
|
||||
import type { Store, StoreIncrement } from "./store";
|
||||
import type { AppState } from "./types";
|
||||
|
||||
type HistoryEntry = StoreIncrement & {
|
||||
skipRecording?: true;
|
||||
};
|
||||
export class HistoryEntry extends StoreDelta {}
|
||||
|
||||
type HistoryStack = HistoryEntry[];
|
||||
|
||||
|
@ -42,12 +40,18 @@ export class History {
|
|||
/**
|
||||
* Record a local change which will go into the history
|
||||
*/
|
||||
public record(entry: HistoryEntry) {
|
||||
if (!entry.skipRecording && !entry.isEmpty()) {
|
||||
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
||||
this.undoStack.push(entry.inverse());
|
||||
public record(increment: StoreIncrement) {
|
||||
if (
|
||||
StoreIncrement.isDurable(increment) &&
|
||||
!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,
|
||||
// as a simple click (unselect) could lead to losing all the redo entries
|
||||
// only reset on non empty elements changes!
|
||||
|
@ -91,6 +95,7 @@ export class History {
|
|||
return;
|
||||
}
|
||||
|
||||
let prevSnapshot = this.store.snapshot;
|
||||
let nextElements = elements;
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
@ -98,17 +103,18 @@ export class History {
|
|||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyEntry) {
|
||||
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] =
|
||||
this.store.applyIncrementTo(
|
||||
historyEntry,
|
||||
nextElements,
|
||||
nextAppState,
|
||||
);
|
||||
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
|
||||
triggerIncrement: true,
|
||||
});
|
||||
|
||||
prevSnapshot = this.store.snapshot;
|
||||
} catch (e) {
|
||||
console.error("Failed to apply history entry:", e);
|
||||
// rollback to the previous snapshot, so that we don't end up in an incosistent state
|
||||
this.store.snapshot = prevSnapshot;
|
||||
} finally {
|
||||
// make sure to always push, even if the increment is corrupted
|
||||
// make sure to always push, even if the delta is corrupted
|
||||
push(historyEntry);
|
||||
}
|
||||
|
||||
|
@ -152,7 +158,7 @@ export class History {
|
|||
entry: HistoryEntry,
|
||||
prevElements: SceneElementsMap,
|
||||
) {
|
||||
const updatedEntry = entry.applyLatestChanges(prevElements);
|
||||
const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements);
|
||||
return stack.push(updatedEntry);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -259,7 +259,7 @@ export {
|
|||
bumpVersion,
|
||||
} from "./element/mutateElement";
|
||||
|
||||
export { StoreAction } from "./store";
|
||||
export { SnapshotAction } from "./store";
|
||||
|
||||
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { ENV } from "./constants";
|
||||
import { Emitter } from "./emitter";
|
||||
import { randomId } from "./random";
|
||||
import { isShallowEqual } from "./utils";
|
||||
import { getDefaultAppState } from "./appState";
|
||||
import { AppStateChange, ElementsChange } from "./change";
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "./delta";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import type { AppState, ObservedAppState } from "./types";
|
||||
|
@ -12,6 +11,8 @@ import type {
|
|||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./element/types";
|
||||
import { hashElementsVersion } from "./element";
|
||||
import { assertNever } from "./utils";
|
||||
|
||||
// hidden non-enumerable property for runtime checks
|
||||
const hiddenObservedAppStateProp = "__observedAppState";
|
||||
|
@ -41,48 +42,52 @@ const isObservedAppState = (
|
|||
): appState is ObservedAppState =>
|
||||
!!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.
|
||||
*
|
||||
* Use for updates which should be captured.
|
||||
* Should be used for most of the local updates.
|
||||
* Use for updates which should be captured as durable deltas.
|
||||
* 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.
|
||||
*/
|
||||
CAPTURE: "capture",
|
||||
CAPTURE: "CAPTURE_DELTA",
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* These updates will _never_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
UPDATE: "update",
|
||||
UPDATE: "UPDATE_SNAPSHOT",
|
||||
/**
|
||||
* Eventually undoable.
|
||||
*
|
||||
* Use for updates which should not be captured immediately - likely
|
||||
* exceptions which are part of some async multi-step process. Otherwise, all
|
||||
* such updates would end up being captured with the next
|
||||
* `StoreAction.CAPTURE` - triggered either by the next `updateScene`
|
||||
* or internally by the editor.
|
||||
* Use for updates which should not be captured as deltas immediately, such as
|
||||
* exceptions which are part of some async multi-step proces.
|
||||
*
|
||||
* These updates will be captured with the next `SnapshotAction.CAPTURE`,
|
||||
* triggered either by the next `updateScene` or internally by the editor.
|
||||
*
|
||||
* 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;
|
||||
|
||||
export type StoreActionType = ValueOf<typeof StoreAction>;
|
||||
export type SnapshotActionType = ValueOf<typeof SnapshotAction>;
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
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();
|
||||
|
||||
public get snapshot() {
|
||||
|
@ -93,41 +98,61 @@ export class Store {
|
|||
this._snapshot = snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
private scheduledActions: Set<SnapshotActionType> = new Set();
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
public scheduleAction(action: SnapshotActionType) {
|
||||
this.scheduledActions.add(action);
|
||||
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(
|
||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
): void {
|
||||
try {
|
||||
// Capture has precedence since it also performs update
|
||||
if (this.scheduledActions.has(StoreAction.CAPTURE)) {
|
||||
this.captureIncrement(elements, appState);
|
||||
} else if (this.scheduledActions.has(StoreAction.UPDATE)) {
|
||||
this.updateSnapshot(elements, appState);
|
||||
const { scheduledAction } = this;
|
||||
|
||||
switch (scheduledAction) {
|
||||
case SnapshotAction.CAPTURE:
|
||||
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 {
|
||||
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,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
) {
|
||||
|
@ -149,41 +174,53 @@ export class Store {
|
|||
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
||||
|
||||
// Optimisation, don't continue if nothing has changed
|
||||
if (prevSnapshot !== nextSnapshot) {
|
||||
// Calculate and record the changes based on the previous and next snapshot
|
||||
const elementsChange = nextSnapshot.metadata.didElementsChange
|
||||
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||
: ElementsChange.empty();
|
||||
if (prevSnapshot === nextSnapshot) {
|
||||
return prevSnapshot;
|
||||
}
|
||||
// Calculate the deltas based on the previous and next snapshot
|
||||
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
||||
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||
: ElementsDelta.empty();
|
||||
|
||||
const appStateChange = nextSnapshot.metadata.didAppStateChange
|
||||
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||
: AppStateChange.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);
|
||||
|
||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
||||
// Notify listeners with the increment
|
||||
this.onStoreIncrementEmitter.trigger(
|
||||
StoreIncrement.create(elementsChange, appStateChange),
|
||||
);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
|
||||
// Update snapshot
|
||||
this.snapshot = nextSnapshot;
|
||||
}
|
||||
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,
|
||||
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) {
|
||||
// Update snapshot
|
||||
this.snapshot = nextSnapshot;
|
||||
if (prevSnapshot === nextSnapshot) {
|
||||
// nothing has changed
|
||||
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
|
||||
nextElements.delete(id);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
@ -223,21 +260,37 @@ export class Store {
|
|||
*
|
||||
* @emits StoreIncrement when increment is applied.
|
||||
*/
|
||||
public applyIncrementTo(
|
||||
increment: StoreIncrement,
|
||||
public applyDeltaTo(
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options: {
|
||||
triggerIncrement: boolean;
|
||||
} = {
|
||||
triggerIncrement: false,
|
||||
},
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
increment.elementsChange.applyTo(elements, this.snapshot.elements);
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
this.snapshot.elements,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
increment.appStateChange.applyTo(appState, nextElements);
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
|
||||
const appliedVisibleChanges =
|
||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||
|
||||
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];
|
||||
}
|
||||
|
@ -251,7 +304,12 @@ export class Store {
|
|||
}
|
||||
|
||||
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}".`;
|
||||
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(
|
||||
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 elementsChange: ElementsChange,
|
||||
public readonly appStateChange: AppStateChange,
|
||||
public readonly elements: ElementsDelta,
|
||||
public readonly appState: AppStateDelta,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new instance of `StoreIncrement`.
|
||||
* Create a new instance of `StoreDelta`.
|
||||
*/
|
||||
public static create(
|
||||
elementsChange: ElementsChange,
|
||||
appStateChange: AppStateChange,
|
||||
elements: ElementsDelta,
|
||||
appState: AppStateDelta,
|
||||
opts: {
|
||||
id: string;
|
||||
} = {
|
||||
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>) {
|
||||
const { id, elementsChange, appStateChange } = storeIncrementDTO;
|
||||
return new StoreIncrement(
|
||||
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||
const { id, elements, appState } = storeDeltaDTO;
|
||||
return new this(
|
||||
id,
|
||||
ElementsChange.restore(elementsChange),
|
||||
AppStateChange.restore(appStateChange),
|
||||
ElementsDelta.restore(elements),
|
||||
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) {
|
||||
// CFDO: ensure typesafety
|
||||
const {
|
||||
id,
|
||||
elementsChange: { added, removed, updated },
|
||||
elements: { added, removed, updated },
|
||||
} = JSON.parse(payload);
|
||||
|
||||
const elementsChange = ElementsChange.create(added, removed, updated, {
|
||||
const elements = ElementsDelta.create(added, removed, updated, {
|
||||
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 {
|
||||
return new StoreIncrement(
|
||||
randomId(),
|
||||
this.elementsChange.inverse(),
|
||||
this.appStateChange.inverse(),
|
||||
);
|
||||
public static inverse(delta: StoreDelta): StoreDelta {
|
||||
return this.create(delta.elements.inverse(), delta.appState.inverse(), {
|
||||
id: delta.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const inversedIncrement = this.inverse();
|
||||
public static applyLatestChanges(
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
): StoreDelta {
|
||||
const inversedDelta = this.inverse(delta);
|
||||
|
||||
return new StoreIncrement(
|
||||
inversedIncrement.id,
|
||||
inversedIncrement.elementsChange.applyLatestChanges(elements),
|
||||
inversedIncrement.appStateChange,
|
||||
return this.create(
|
||||
inversedDelta.elements.applyLatestChanges(elements),
|
||||
inversedDelta.appState,
|
||||
{
|
||||
id: inversedDelta.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
private _lastChangedElementsHash: number = 0;
|
||||
|
||||
private constructor(
|
||||
public readonly elements: Map<string, OrderedExcalidrawElement>,
|
||||
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() {
|
||||
return this.metadata.isEmpty;
|
||||
}
|
||||
|
@ -434,10 +591,8 @@ export class StoreSnapshot {
|
|||
}
|
||||
|
||||
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
|
||||
return !isShallowEqual(this.appState, nextObservedAppState, {
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
});
|
||||
// CFDO: could we optimize by checking only reference changes? (i.e. selectedElementIds should be stable now)
|
||||
return Delta.isRightDifferent(this.appState, nextObservedAppState);
|
||||
}
|
||||
|
||||
private maybeCreateElementsSnapshot(
|
||||
|
@ -447,13 +602,13 @@ export class StoreSnapshot {
|
|||
return this.elements;
|
||||
}
|
||||
|
||||
const didElementsChange = this.detectChangedElements(elements);
|
||||
const changedElements = this.detectChangedElements(elements);
|
||||
|
||||
if (!didElementsChange) {
|
||||
if (!changedElements?.size) {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
const elementsSnapshot = this.createElementsSnapshot(elements);
|
||||
const elementsSnapshot = this.createElementsSnapshot(changedElements);
|
||||
return elementsSnapshot;
|
||||
}
|
||||
|
||||
|
@ -466,67 +621,72 @@ export class StoreSnapshot {
|
|||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
if (this.elements === nextElements) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.elements.size !== nextElements.size) {
|
||||
return true;
|
||||
}
|
||||
const changedElements: Map<string, OrderedExcalidrawElement> = new Map();
|
||||
|
||||
// loop from right to left as changes are likelier to happen on new elements
|
||||
const keys = Array.from(nextElements.keys());
|
||||
for (const [id, prevElement] of this.elements) {
|
||||
const nextElement = nextElements.get(id);
|
||||
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
const prev = this.elements.get(keys[i]);
|
||||
const next = nextElements.get(keys[i]);
|
||||
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(
|
||||
if (!nextElement) {
|
||||
// element was deleted
|
||||
changedElements.set(
|
||||
id,
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
);
|
||||
} else {
|
||||
clonedElements.set(id, prevElement);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextElement] of nextElements.entries()) {
|
||||
const prevElement = clonedElements.get(id);
|
||||
for (const [id, nextElement] of nextElements) {
|
||||
const prevElement = this.elements.get(id);
|
||||
|
||||
// At this point our elements are reconcilled already, meaning the next element is always newer
|
||||
if (
|
||||
!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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,18 +6,15 @@ import ReconnectingWebSocket, {
|
|||
} from "reconnecting-websocket";
|
||||
import { Utils } from "./utils";
|
||||
import {
|
||||
SyncQueue,
|
||||
LocalDeltasQueue,
|
||||
type MetadataRepository,
|
||||
type IncrementsRepository,
|
||||
type DeltasRepository,
|
||||
} from "./queue";
|
||||
import { StoreIncrement } from "../store";
|
||||
import { SnapshotAction, StoreDelta } from "../store";
|
||||
import type { StoreChange } from "../store";
|
||||
import type { ExcalidrawImperativeAPI } from "../types";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
import type {
|
||||
CLIENT_INCREMENT,
|
||||
CLIENT_MESSAGE_RAW,
|
||||
SERVER_INCREMENT,
|
||||
} from "./protocol";
|
||||
import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol";
|
||||
import { debounce } from "../utils";
|
||||
import { randomId } from "../random";
|
||||
|
||||
|
@ -38,12 +35,6 @@ class SocketClient {
|
|||
// 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 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 socket: ReconnectingWebSocket | null = null;
|
||||
|
||||
|
@ -129,11 +120,11 @@ class SocketClient {
|
|||
|
||||
public send(message: {
|
||||
type: "relay" | "pull" | "push";
|
||||
payload: any;
|
||||
payload: Record<string, unknown>;
|
||||
}): void {
|
||||
if (this.isOffline) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@ -145,6 +136,7 @@ class SocketClient {
|
|||
|
||||
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 payloadSize = new TextEncoder().encode(stringifiedPayload).byteLength;
|
||||
|
||||
|
@ -193,8 +185,8 @@ class SocketClient {
|
|||
};
|
||||
}
|
||||
|
||||
interface AcknowledgedIncrement {
|
||||
increment: StoreIncrement;
|
||||
interface AcknowledgedDelta {
|
||||
delta: StoreDelta;
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
@ -208,21 +200,19 @@ export class SyncClient {
|
|||
: "test_room_prod";
|
||||
|
||||
private readonly api: ExcalidrawImperativeAPI;
|
||||
private readonly queue: SyncQueue;
|
||||
private readonly localDeltas: LocalDeltasQueue;
|
||||
private readonly metadata: MetadataRepository;
|
||||
private readonly client: SocketClient;
|
||||
|
||||
// #region ACKNOWLEDGED INCREMENTS & METADATA
|
||||
// #region ACKNOWLEDGED DELTAS & METADATA
|
||||
// CFDO: shouldn't be stateful, only request / response
|
||||
private readonly acknowledgedIncrementsMap: Map<
|
||||
string,
|
||||
AcknowledgedIncrement
|
||||
> = new Map();
|
||||
private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
|
||||
new Map();
|
||||
|
||||
public get acknowledgedIncrements() {
|
||||
return Array.from(this.acknowledgedIncrementsMap.values())
|
||||
public get acknowledgedDeltas() {
|
||||
return Array.from(this.acknowledgedDeltasMap.values())
|
||||
.sort((a, b) => (a.version < b.version ? -1 : 1))
|
||||
.map((x) => x.increment);
|
||||
.map((x) => x.delta);
|
||||
}
|
||||
|
||||
private _lastAcknowledgedVersion = 0;
|
||||
|
@ -240,12 +230,12 @@ export class SyncClient {
|
|||
private constructor(
|
||||
api: ExcalidrawImperativeAPI,
|
||||
repository: MetadataRepository,
|
||||
queue: SyncQueue,
|
||||
queue: LocalDeltasQueue,
|
||||
options: { host: string; roomId: string; lastAcknowledgedVersion: number },
|
||||
) {
|
||||
this.api = api;
|
||||
this.metadata = repository;
|
||||
this.queue = queue;
|
||||
this.localDeltas = queue;
|
||||
this.lastAcknowledgedVersion = options.lastAcknowledgedVersion;
|
||||
this.client = new SocketClient(options.host, options.roomId, {
|
||||
onOpen: this.onOpen,
|
||||
|
@ -257,16 +247,16 @@ export class SyncClient {
|
|||
// #region SYNC_CLIENT FACTORY
|
||||
public static async create(
|
||||
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)
|
||||
const roomId = window.location.pathname.split("/").at(-1);
|
||||
|
||||
return new SyncClient(api, repository, queue, {
|
||||
host: SyncClient.HOST_URL,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
@ -290,26 +280,27 @@ export class SyncClient {
|
|||
});
|
||||
}
|
||||
|
||||
public push(increment?: StoreIncrement): void {
|
||||
if (increment) {
|
||||
this.queue.add(increment);
|
||||
public push(delta?: StoreDelta): void {
|
||||
if (delta) {
|
||||
this.localDeltas.add(delta);
|
||||
}
|
||||
|
||||
// re-send all already queued increments
|
||||
for (const queuedIncrement of this.queue.getAll()) {
|
||||
// re-send all already queued deltas
|
||||
for (const delta of this.localDeltas.getAll()) {
|
||||
this.client.send({
|
||||
type: "push",
|
||||
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({
|
||||
type: "relay",
|
||||
payload: { buffer },
|
||||
payload: { ...change },
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
@ -349,76 +340,110 @@ export class SyncClient {
|
|||
}
|
||||
};
|
||||
|
||||
// CFDO: refactor by applying all operations to store, not to the elements
|
||||
private handleAcknowledged = (payload: {
|
||||
increments: Array<SERVER_INCREMENT>;
|
||||
}) => {
|
||||
let nextAcknowledgedVersion = this.lastAcknowledgedVersion;
|
||||
let elements = new Map(
|
||||
private handleRelayed = (payload: CHANGE) => {
|
||||
// CFDO: retrieve the map already
|
||||
const nextElements = new Map(
|
||||
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
|
||||
) as SceneElementsMap;
|
||||
|
||||
try {
|
||||
const { increments: remoteIncrements } = payload;
|
||||
const { elements: relayedElements } = payload;
|
||||
|
||||
// apply remote increments
|
||||
for (const { id, version, payload } of remoteIncrements) {
|
||||
// CFDO: temporary to load all increments on init
|
||||
this.acknowledgedIncrementsMap.set(id, {
|
||||
increment: StoreIncrement.load(payload),
|
||||
for (const [id, relayedElement] of Object.entries(relayedElements)) {
|
||||
const existingElement = nextElements.get(id);
|
||||
|
||||
if (
|
||||
!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,
|
||||
});
|
||||
|
||||
// we've already applied this increment
|
||||
// we've already applied this delta!
|
||||
if (version <= nextAcknowledgedVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version === nextAcknowledgedVersion + 1) {
|
||||
nextAcknowledgedVersion = version;
|
||||
} else {
|
||||
// it's fine to apply increments our of order,
|
||||
// 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 "${
|
||||
// CFDO:strictly checking for out of order deltas; might be relaxed if it becomes a problem
|
||||
if (version !== nextAcknowledgedVersion + 1) {
|
||||
throw new Error(
|
||||
`Received out of order delta, expected "${
|
||||
nextAcknowledgedVersion + 1
|
||||
}", but received "${version}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// local increment shall not have to be applied again
|
||||
if (this.queue.has(id)) {
|
||||
this.queue.remove(id);
|
||||
if (this.localDeltas.has(id)) {
|
||||
// local delta does not have to be applied again
|
||||
this.localDeltas.remove(id);
|
||||
} else {
|
||||
// apply remote increment with higher version than the last acknowledged one
|
||||
const remoteIncrement = StoreIncrement.load(payload);
|
||||
[elements] = remoteIncrement.elementsChange.applyTo(
|
||||
elements,
|
||||
this.api.store.snapshot.elements,
|
||||
);
|
||||
// this is a new remote delta, adding it to the list of applicable deltas
|
||||
const remoteDelta = StoreDelta.load(payload);
|
||||
applicableDeltas.push(remoteDelta);
|
||||
}
|
||||
|
||||
// apply local increments
|
||||
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,
|
||||
);
|
||||
nextAcknowledgedVersion = version;
|
||||
}
|
||||
|
||||
// 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(elements.values()),
|
||||
storeAction: "update",
|
||||
elements: Array.from(nextElements.values()),
|
||||
snapshotAction: SnapshotAction.NONE,
|
||||
});
|
||||
}
|
||||
|
||||
this.lastAcknowledgedVersion = nextAcknowledgedVersion;
|
||||
} catch (e) {
|
||||
console.error("Failed to apply acknowledged increments:", e);
|
||||
// CFDO: might just be on error
|
||||
console.error("Failed to apply acknowledged deltas:", e);
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
@ -427,17 +452,10 @@ export class SyncClient {
|
|||
ids: Array<string>;
|
||||
message: string;
|
||||
}) => {
|
||||
// handle rejected increments
|
||||
// handle rejected deltas
|
||||
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);
|
||||
// #endregion
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { StoreIncrement } from "../store";
|
||||
import type { StoreChange, StoreDelta } from "../store";
|
||||
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 PUSH_PAYLOAD = CLIENT_INCREMENT;
|
||||
|
||||
export type CHUNK_INFO = {
|
||||
id: string;
|
||||
|
@ -25,22 +26,21 @@ export type CLIENT_MESSAGE = { chunkInfo: CHUNK_INFO } & (
|
|||
| { 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 =
|
||||
| {
|
||||
type: "relayed";
|
||||
// CFDO: should likely be just elements
|
||||
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD;
|
||||
payload: RELAY_PAYLOAD;
|
||||
}
|
||||
| { type: "acknowledged"; payload: { increments: Array<SERVER_INCREMENT> } }
|
||||
| { type: "acknowledged"; payload: { deltas: Array<SERVER_DELTA> } }
|
||||
| {
|
||||
type: "rejected";
|
||||
payload: { increments: Array<CLIENT_INCREMENT>; message: string };
|
||||
payload: { deltas: Array<DELTA>; message: string };
|
||||
};
|
||||
|
||||
export interface IncrementsRepository {
|
||||
save(increment: CLIENT_INCREMENT): SERVER_INCREMENT | null;
|
||||
getAllSinceVersion(version: number): Array<SERVER_INCREMENT>;
|
||||
export interface DeltasRepository {
|
||||
save(delta: DELTA): SERVER_DELTA | null;
|
||||
getAllSinceVersion(version: number): Array<SERVER_DELTA>;
|
||||
getLastVersion(): number;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import throttle from "lodash.throttle";
|
||||
import type { StoreIncrement } from "../store";
|
||||
import type { StoreDelta } from "../store";
|
||||
|
||||
export interface IncrementsRepository {
|
||||
loadIncrements(): Promise<Array<StoreIncrement> | null>;
|
||||
saveIncrements(params: StoreIncrement[]): Promise<void>;
|
||||
export interface DeltasRepository {
|
||||
loadDeltas(): Promise<Array<StoreDelta> | null>;
|
||||
saveDeltas(params: StoreDelta[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MetadataRepository {
|
||||
|
@ -11,24 +11,24 @@ export interface MetadataRepository {
|
|||
saveMetadata(metadata: { lastAcknowledgedVersion: number }): Promise<void>;
|
||||
}
|
||||
|
||||
// CFDO: make sure the increments are always acknowledged (deleted from the repository)
|
||||
export class SyncQueue {
|
||||
private readonly queue: Map<string, StoreIncrement>;
|
||||
private readonly repository: IncrementsRepository;
|
||||
// CFDO: make sure the deltas are always acknowledged (deleted from the repository)
|
||||
export class LocalDeltasQueue {
|
||||
private readonly queue: Map<string, StoreDelta>;
|
||||
private readonly repository: DeltasRepository;
|
||||
|
||||
private constructor(
|
||||
queue: Map<string, StoreIncrement> = new Map(),
|
||||
repository: IncrementsRepository,
|
||||
queue: Map<string, StoreDelta> = new Map(),
|
||||
repository: DeltasRepository,
|
||||
) {
|
||||
this.queue = queue;
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public static async create(repository: IncrementsRepository) {
|
||||
const increments = await repository.loadIncrements();
|
||||
public static async create(repository: DeltasRepository) {
|
||||
const deltas = await repository.loadDeltas();
|
||||
|
||||
return new SyncQueue(
|
||||
new Map(increments?.map((increment) => [increment.id, increment])),
|
||||
return new LocalDeltasQueue(
|
||||
new Map(deltas?.map((delta) => [delta.id, delta])),
|
||||
repository,
|
||||
);
|
||||
}
|
||||
|
@ -37,23 +37,23 @@ export class SyncQueue {
|
|||
return Array.from(this.queue.values());
|
||||
}
|
||||
|
||||
public get(id: StoreIncrement["id"]) {
|
||||
public get(id: StoreDelta["id"]) {
|
||||
return this.queue.get(id);
|
||||
}
|
||||
|
||||
public has(id: StoreIncrement["id"]) {
|
||||
public has(id: StoreDelta["id"]) {
|
||||
return this.queue.has(id);
|
||||
}
|
||||
|
||||
public add(...increments: StoreIncrement[]) {
|
||||
for (const increment of increments) {
|
||||
this.queue.set(increment.id, increment);
|
||||
public add(...deltas: StoreDelta[]) {
|
||||
for (const delta of deltas) {
|
||||
this.queue.set(delta.id, delta);
|
||||
}
|
||||
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public remove(...ids: StoreIncrement["id"][]) {
|
||||
public remove(...ids: StoreDelta["id"][]) {
|
||||
for (const id of ids) {
|
||||
this.queue.delete(id);
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ export class SyncQueue {
|
|||
public persist = throttle(
|
||||
async () => {
|
||||
try {
|
||||
await this.repository.saveIncrements(this.getAll());
|
||||
await this.repository.saveDeltas(this.getAll());
|
||||
} catch (e) {
|
||||
console.error("Failed to persist the sync queue:", e);
|
||||
}
|
||||
|
|
|
@ -2,14 +2,15 @@ import AsyncLock from "async-lock";
|
|||
import { Utils } from "./utils";
|
||||
|
||||
import type {
|
||||
IncrementsRepository,
|
||||
DeltasRepository,
|
||||
CLIENT_MESSAGE,
|
||||
PULL_PAYLOAD,
|
||||
PUSH_PAYLOAD,
|
||||
SERVER_MESSAGE,
|
||||
SERVER_INCREMENT,
|
||||
SERVER_DELTA,
|
||||
CLIENT_MESSAGE_RAW,
|
||||
CHUNK_INFO,
|
||||
RELAY_PAYLOAD,
|
||||
} from "./protocol";
|
||||
|
||||
// CFDO: message could be binary (cbor, protobuf, etc.)
|
||||
|
@ -25,7 +26,7 @@ export class ExcalidrawSyncServer {
|
|||
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)
|
||||
public onConnect(client: WebSocket) {
|
||||
|
@ -65,8 +66,8 @@ export class ExcalidrawSyncServer {
|
|||
}
|
||||
|
||||
switch (type) {
|
||||
// case "relay":
|
||||
// return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
||||
case "relay":
|
||||
return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
||||
case "pull":
|
||||
return this.pull(client, parsedPayload as PULL_PAYLOAD);
|
||||
case "push":
|
||||
|
@ -137,24 +138,21 @@ export class ExcalidrawSyncServer {
|
|||
}
|
||||
}
|
||||
|
||||
// private relay(
|
||||
// client: WebSocket,
|
||||
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD,
|
||||
// ) {
|
||||
// return this.broadcast(
|
||||
// {
|
||||
// type: "relayed",
|
||||
// payload,
|
||||
// },
|
||||
// client,
|
||||
// );
|
||||
// }
|
||||
private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
|
||||
// CFDO: we should likely apply these to the snapshot
|
||||
return this.broadcast(
|
||||
{
|
||||
type: "relayed",
|
||||
payload,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
private pull(client: WebSocket, payload: PULL_PAYLOAD) {
|
||||
// CFDO: test for invalid payload
|
||||
const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion;
|
||||
const lastAcknowledgedServerVersion =
|
||||
this.incrementsRepository.getLastVersion();
|
||||
const lastAcknowledgedServerVersion = this.repository.getLastVersion();
|
||||
|
||||
const versionΔ =
|
||||
lastAcknowledgedServerVersion - lastAcknowledgedClientVersion;
|
||||
|
@ -167,37 +165,33 @@ export class ExcalidrawSyncServer {
|
|||
return;
|
||||
}
|
||||
|
||||
const increments: SERVER_INCREMENT[] = [];
|
||||
const deltas: SERVER_DELTA[] = [];
|
||||
|
||||
if (versionΔ > 0) {
|
||||
increments.push(
|
||||
...this.incrementsRepository.getAllSinceVersion(
|
||||
lastAcknowledgedClientVersion,
|
||||
),
|
||||
deltas.push(
|
||||
...this.repository.getAllSinceVersion(lastAcknowledgedClientVersion),
|
||||
);
|
||||
}
|
||||
|
||||
this.send(client, {
|
||||
type: "acknowledged",
|
||||
payload: {
|
||||
increments,
|
||||
deltas,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private push(client: WebSocket, increment: PUSH_PAYLOAD) {
|
||||
// CFDO: try to apply the increments to the snapshot
|
||||
const [acknowledged, error] = Utils.try(() =>
|
||||
this.incrementsRepository.save(increment),
|
||||
);
|
||||
private push(client: WebSocket, delta: PUSH_PAYLOAD) {
|
||||
// CFDO: try to apply the deltas to the snapshot
|
||||
const [acknowledged, error] = Utils.try(() => this.repository.save(delta));
|
||||
|
||||
if (error || !acknowledged) {
|
||||
// everything should be automatically rolled-back -> double-check
|
||||
return this.send(client, {
|
||||
type: "rejected",
|
||||
payload: {
|
||||
message: error ? error.message : "Coudn't persist the increment.",
|
||||
increments: [increment],
|
||||
message: error ? error.message : "Coudn't persist the delta.",
|
||||
deltas: [delta],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -205,7 +199,7 @@ export class ExcalidrawSyncServer {
|
|||
return this.broadcast({
|
||||
type: "acknowledged",
|
||||
payload: {
|
||||
increments: [acknowledged],
|
||||
deltas: [acknowledged],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -41,8 +41,8 @@ import {
|
|||
} from "../actions";
|
||||
import { vi } from "vitest";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
import { AppStateChange, ElementsChange } from "../change";
|
||||
import { StoreAction, StoreIncrement } from "../store";
|
||||
import { AppStateDelta, ElementsDelta } from "../delta";
|
||||
import { SnapshotAction, StoreDelta } from "../store";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import type { AppState } from "../types.js";
|
||||
|
@ -91,10 +91,10 @@ const checkpoint = (name: string) => {
|
|||
h.history.undoStack.map((x) => ({
|
||||
...x,
|
||||
elementsChange: {
|
||||
...x.elementsChange,
|
||||
added: stripSeed(x.elementsChange.added),
|
||||
removed: stripSeed(x.elementsChange.updated),
|
||||
updated: stripSeed(x.elementsChange.removed),
|
||||
...x.elements,
|
||||
added: stripSeed(x.elements.added),
|
||||
removed: stripSeed(x.elements.updated),
|
||||
updated: stripSeed(x.elements.removed),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] undo stack`);
|
||||
|
@ -103,10 +103,10 @@ const checkpoint = (name: string) => {
|
|||
h.history.redoStack.map((x) => ({
|
||||
...x,
|
||||
elementsChange: {
|
||||
...x.elementsChange,
|
||||
added: stripSeed(x.elementsChange.added),
|
||||
removed: stripSeed(x.elementsChange.updated),
|
||||
updated: stripSeed(x.elementsChange.removed),
|
||||
...x.elements,
|
||||
added: stripSeed(x.elements.added),
|
||||
removed: stripSeed(x.elements.updated),
|
||||
updated: stripSeed(x.elements.removed),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] redo stack`);
|
||||
|
@ -137,16 +137,14 @@ describe("history", () => {
|
|||
|
||||
API.setElements([rect]);
|
||||
|
||||
const corrupedEntry = StoreIncrement.create(
|
||||
ElementsChange.empty(),
|
||||
AppStateChange.empty(),
|
||||
const corrupedEntry = StoreDelta.create(
|
||||
ElementsDelta.empty(),
|
||||
AppStateDelta.empty(),
|
||||
);
|
||||
|
||||
vi.spyOn(corrupedEntry.elementsChange, "applyTo").mockImplementation(
|
||||
() => {
|
||||
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||
throw new Error("Oh no, I am corrupted!");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
(h.history as any).undoStack.push(corrupedEntry);
|
||||
|
||||
|
@ -218,7 +216,7 @@ describe("history", () => {
|
|||
|
||||
API.updateScene({
|
||||
elements: [rect1, rect2],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
|
@ -230,7 +228,7 @@ describe("history", () => {
|
|||
|
||||
API.updateScene({
|
||||
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.getRedoStack().length).toBe(0);
|
||||
|
@ -598,7 +596,7 @@ describe("history", () => {
|
|||
appState: {
|
||||
name: "New name",
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
|
@ -609,7 +607,7 @@ describe("history", () => {
|
|||
appState: {
|
||||
viewBackgroundColor: "#000",
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
@ -622,7 +620,7 @@ describe("history", () => {
|
|||
name: "New name",
|
||||
viewBackgroundColor: "#000",
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
@ -1329,7 +1327,7 @@ describe("history", () => {
|
|||
|
||||
API.updateScene({
|
||||
elements: [rect1, text, rect2],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// bind text1 to rect1
|
||||
|
@ -1901,7 +1899,7 @@ describe("history", () => {
|
|||
strokeColor: blue,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -1939,7 +1937,7 @@ describe("history", () => {
|
|||
strokeColor: yellow,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -1987,7 +1985,7 @@ describe("history", () => {
|
|||
backgroundColor: yellow,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
||||
|
@ -2003,7 +2001,7 @@ describe("history", () => {
|
|||
backgroundColor: violet,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
||||
|
@ -2048,7 +2046,7 @@ describe("history", () => {
|
|||
|
||||
API.updateScene({
|
||||
elements: [rect, diamond],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Connect the arrow
|
||||
|
@ -2097,7 +2095,7 @@ describe("history", () => {
|
|||
} as FixedPointBinding,
|
||||
},
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2112,7 +2110,7 @@ describe("history", () => {
|
|||
}
|
||||
: el,
|
||||
),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2136,7 +2134,7 @@ describe("history", () => {
|
|||
// Initialize scene
|
||||
API.updateScene({
|
||||
elements: [rect1, rect2],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -2145,7 +2143,7 @@ describe("history", () => {
|
|||
newElementWith(h.elements[0], { groupIds: ["A"] }),
|
||||
newElementWith(h.elements[1], { groupIds: ["A"] }),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
|
||||
|
@ -2159,7 +2157,7 @@ describe("history", () => {
|
|||
rect3,
|
||||
rect4,
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2205,7 +2203,7 @@ describe("history", () => {
|
|||
] as LocalPoint[],
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo(); // undo `actionFinalize`
|
||||
|
@ -2300,7 +2298,7 @@ describe("history", () => {
|
|||
isDeleted: false, // undeletion might happen due to concurrency between clients
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(API.getSelectedElements()).toEqual([]);
|
||||
|
@ -2377,7 +2375,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(h.elements).toEqual([
|
||||
|
@ -2439,7 +2437,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2515,7 +2513,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2554,7 +2552,7 @@ describe("history", () => {
|
|||
isDeleted: false,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
|
@ -2600,7 +2598,7 @@ describe("history", () => {
|
|||
// Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [rect1, rect2],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
|
@ -2610,7 +2608,7 @@ describe("history", () => {
|
|||
// Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
|
@ -2631,7 +2629,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2656,7 +2654,7 @@ describe("history", () => {
|
|||
isDeleted: false,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
|
@ -2667,7 +2665,7 @@ describe("history", () => {
|
|||
// Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
|
@ -2713,7 +2711,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2734,7 +2732,7 @@ describe("history", () => {
|
|||
}),
|
||||
h.elements[1],
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2777,7 +2775,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2820,7 +2818,7 @@ describe("history", () => {
|
|||
h.elements[0],
|
||||
h.elements[1],
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
|
@ -2859,7 +2857,7 @@ describe("history", () => {
|
|||
h.elements[0],
|
||||
h.elements[1],
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
|
@ -2910,7 +2908,7 @@ describe("history", () => {
|
|||
h.elements[0], // rect2
|
||||
h.elements[1], // rect1
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2940,7 +2938,7 @@ describe("history", () => {
|
|||
h.elements[0], // rect3
|
||||
h.elements[2], // rect1
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -2970,7 +2968,7 @@ describe("history", () => {
|
|||
// Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [...h.elements, rect],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
mouse.moveTo(60, 60);
|
||||
|
@ -3022,7 +3020,7 @@ describe("history", () => {
|
|||
// // Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [...h.elements, rect3],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
mouse.moveTo(100, 100);
|
||||
|
@ -3112,7 +3110,7 @@ describe("history", () => {
|
|||
// Simulate remote update
|
||||
API.updateScene({
|
||||
elements: [...h.elements, rect3],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
mouse.moveTo(100, 100);
|
||||
|
@ -3289,7 +3287,7 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [container, text],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -3302,7 +3300,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -3333,7 +3331,7 @@ describe("history", () => {
|
|||
x: h.elements[1].x + 10,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3376,7 +3374,7 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [container, text],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -3389,7 +3387,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -3423,7 +3421,7 @@ describe("history", () => {
|
|||
remoteText,
|
||||
h.elements[1],
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3479,7 +3477,7 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [container, text],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -3492,7 +3490,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -3529,7 +3527,7 @@ describe("history", () => {
|
|||
containerId: remoteContainer.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3539,7 +3537,7 @@ describe("history", () => {
|
|||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
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" }],
|
||||
isDeleted: false,
|
||||
}),
|
||||
|
@ -3587,7 +3585,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [container],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3598,7 +3596,7 @@ describe("history", () => {
|
|||
}),
|
||||
newElementWith(text, { containerId: container.id }),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3648,7 +3646,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [text],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3659,7 +3657,7 @@ describe("history", () => {
|
|||
}),
|
||||
newElementWith(text, { containerId: container.id }),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3708,7 +3706,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [container],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3721,7 +3719,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -3758,7 +3756,7 @@ describe("history", () => {
|
|||
// rebinding the container with a new text element!
|
||||
remoteText,
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3815,7 +3813,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [text],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3828,7 +3826,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -3865,7 +3863,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3921,7 +3919,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [container],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3935,7 +3933,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -3978,7 +3976,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [text],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -3992,7 +3990,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -4035,7 +4033,7 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [container],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -4047,7 +4045,7 @@ describe("history", () => {
|
|||
angle: 90 as Radians,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -4060,7 +4058,7 @@ describe("history", () => {
|
|||
}),
|
||||
newElementWith(text, { containerId: container.id }),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(h.elements).toEqual([
|
||||
|
@ -4153,7 +4151,7 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [text],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -4165,7 +4163,7 @@ describe("history", () => {
|
|||
angle: 90 as Radians,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -4180,7 +4178,7 @@ describe("history", () => {
|
|||
containerId: container.id,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
|
@ -4271,7 +4269,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [rect1, rect2],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
mouse.reset();
|
||||
|
@ -4360,7 +4358,7 @@ describe("history", () => {
|
|||
x: h.elements[1].x + 50,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -4504,7 +4502,7 @@ describe("history", () => {
|
|||
}),
|
||||
remoteContainer,
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -4611,7 +4609,7 @@ describe("history", () => {
|
|||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -4688,7 +4686,7 @@ describe("history", () => {
|
|||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [arrow],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
|
@ -4715,7 +4713,7 @@ describe("history", () => {
|
|||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
runTwice(() => {
|
||||
|
@ -4847,7 +4845,7 @@ describe("history", () => {
|
|||
newElementWith(h.elements[1], { x: 500, y: -500 }),
|
||||
h.elements[2],
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
|
@ -4919,13 +4917,13 @@ describe("history", () => {
|
|||
// Initialize the scene
|
||||
API.updateScene({
|
||||
elements: [frame],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
API.updateScene({
|
||||
elements: [rect, h.elements[0]],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
// Simulate local update
|
||||
|
@ -4936,7 +4934,7 @@ describe("history", () => {
|
|||
}),
|
||||
h.elements[1],
|
||||
],
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
|
@ -4980,7 +4978,7 @@ describe("history", () => {
|
|||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import { Excalidraw, StoreAction } from "../../index";
|
||||
import { Excalidraw, SnapshotAction } from "../../index";
|
||||
import type { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { resolvablePromise } from "../../utils";
|
||||
import { render } from "../test-utils";
|
||||
|
@ -31,7 +31,7 @@ describe("event callbacks", () => {
|
|||
excalidrawAPI.onChange(onChange);
|
||||
API.updateScene({
|
||||
appState: { viewBackgroundColor: "red" },
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
// elements
|
||||
|
|
|
@ -10,6 +10,6 @@
|
|||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"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 { SnapLine } from "./snapping";
|
||||
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" };
|
||||
|
||||
|
@ -498,7 +502,9 @@ export interface ExcalidrawProps {
|
|||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => void;
|
||||
onIncrement?: (event: StoreIncrement) => void;
|
||||
onIncrement?: (
|
||||
event: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||
) => void;
|
||||
initialData?:
|
||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||
|
@ -572,7 +578,7 @@ export type SceneData = {
|
|||
elements?: ImportedDataState["elements"];
|
||||
appState?: ImportedDataState["appState"];
|
||||
collaborators?: Map<SocketId, Collaborator>;
|
||||
storeAction?: StoreActionType;
|
||||
snapshotAction?: SnapshotActionType;
|
||||
};
|
||||
|
||||
export enum UserIdleState {
|
||||
|
@ -785,7 +791,7 @@ export interface ExcalidrawImperativeAPI {
|
|||
) => void,
|
||||
) => UnsubscribeCallback;
|
||||
onIncrement: (
|
||||
callback: (event: StoreIncrement) => void,
|
||||
callback: (event: DurableStoreIncrement | EphemeralStoreIncrement) => void,
|
||||
) => UnsubscribeCallback;
|
||||
onPointerDown: (
|
||||
callback: (
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["packages", "excalidraw-app"],
|
||||
"exclude": ["packages/excalidraw/types", "examples"]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue