Syncing ephemeral element updates

This commit is contained in:
Marcel Mraz 2025-01-20 15:07:37 +01:00
parent c57249481e
commit 310a9ae4e0
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
60 changed files with 1104 additions and 906 deletions

View file

@ -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() {

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -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,
});
};

View file

@ -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,
);

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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 }) => (

View file

@ -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,
};
},
});

View file

@ -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) =>

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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,

View file

@ -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",

View file

@ -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 }) => (

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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;

View file

@ -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 }) => {

View file

@ -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" },

View file

@ -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,

View file

@ -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 }) => {

View file

@ -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 }) => {

View file

@ -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,

View file

@ -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) =>

View file

@ -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,
};
},
});

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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) =>

View file

@ -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;

View file

@ -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(),
);
}

View file

@ -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()) {

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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,
};
},
});

View file

@ -775,7 +775,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.shouldCaptureIncrement();
store.scheduleCapture();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {

View file

@ -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);
}
}

View file

@ -259,7 +259,7 @@ export {
bumpVersion,
} from "./element/mutateElement";
export { StoreAction } from "./store";
export { SnapshotAction } from "./store";
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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],
},
});
}

View file

@ -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();

View file

@ -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

View file

@ -10,6 +10,6 @@
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsx": "react-jsx"
}
}

View file

@ -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: (

View file

@ -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"]