mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Applying & emitting increments on undo / redo
This commit is contained in:
parent
59a0653fd4
commit
15d2942aaa
18 changed files with 8287 additions and 8355 deletions
|
@ -46,9 +46,9 @@ const executeHistoryAction = (
|
|||
return { storeAction: StoreAction.NONE };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History, store: Store) => Action;
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
|
@ -56,11 +56,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||
|
@ -87,19 +83,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||
},
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) =>
|
||||
perform: (elements, appState, __, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
|
|
|
@ -370,6 +370,7 @@ class Delta<T> {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: order the keys based on the most common ones to change (i.e. x/y, width/height, isDeleted, etc.)
|
||||
for (const key of keys) {
|
||||
const object1Value = object1[key as keyof T];
|
||||
const object2Value = object2[key as keyof T];
|
||||
|
@ -796,37 +797,41 @@ export class AppStateChange implements Change<AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
||||
ElementUpdate<Ordered<T>>;
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||
ElementUpdate<Ordered<T>>,
|
||||
"seed"
|
||||
>;
|
||||
|
||||
type ElementsChangeOptions = {
|
||||
id: string;
|
||||
shouldRedistribute: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Elements change is a low level primitive to capture a change 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> {
|
||||
public readonly id: string;
|
||||
|
||||
private constructor(
|
||||
public readonly id: string,
|
||||
private readonly added: Record<string, Delta<ElementPartial>>,
|
||||
private readonly removed: Record<string, Delta<ElementPartial>>,
|
||||
private readonly updated: Record<string, Delta<ElementPartial>>,
|
||||
options: { changeId: string },
|
||||
) {
|
||||
this.id = options.changeId;
|
||||
}
|
||||
) {}
|
||||
|
||||
public static create(
|
||||
added: Record<string, Delta<ElementPartial>>,
|
||||
removed: Record<string, Delta<ElementPartial>>,
|
||||
updated: Record<string, Delta<ElementPartial>>,
|
||||
options: { changeId: string; shouldRedistribute: boolean } = {
|
||||
changeId: randomId(),
|
||||
options: ElementsChangeOptions = {
|
||||
id: randomId(),
|
||||
shouldRedistribute: false,
|
||||
},
|
||||
) {
|
||||
const { id, shouldRedistribute } = options;
|
||||
let change: ElementsChange;
|
||||
|
||||
if (options.shouldRedistribute) {
|
||||
if (shouldRedistribute) {
|
||||
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||
|
@ -847,13 +852,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
}
|
||||
}
|
||||
|
||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated, {
|
||||
changeId: options.changeId,
|
||||
});
|
||||
change = new ElementsChange(id, nextAdded, nextRemoved, nextUpdated);
|
||||
} else {
|
||||
change = new ElementsChange(added, removed, updated, {
|
||||
changeId: options.changeId,
|
||||
});
|
||||
change = new ElementsChange(id, added, removed, updated);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||
|
@ -1000,7 +1001,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const { id, added, removed, updated } = JSON.parse(payload);
|
||||
|
||||
return ElementsChange.create(added, removed, updated, {
|
||||
changeId: id,
|
||||
id,
|
||||
shouldRedistribute: false,
|
||||
});
|
||||
}
|
||||
|
@ -1021,6 +1022,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const updated = inverseInternal(this.updated);
|
||||
|
||||
// notice we inverse removed with added not to break the invariants
|
||||
// notice we force generate a new id
|
||||
return ElementsChange.create(removed, added, updated);
|
||||
}
|
||||
|
||||
|
@ -1089,7 +1091,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
const updated = applyLatestChangesInternal(this.updated);
|
||||
|
||||
return ElementsChange.create(added, removed, updated, {
|
||||
changeId: this.id,
|
||||
id: this.id,
|
||||
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||
});
|
||||
}
|
||||
|
@ -1232,7 +1234,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
}
|
||||
} else if (type === "added") {
|
||||
// for additions the element does not have to exist (i.e. remote update)
|
||||
// TODO: the version itself might be different!
|
||||
// CFDO: the version itself might be different!
|
||||
element = newElementWith(
|
||||
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||
{
|
||||
|
@ -1602,7 +1604,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
private static stripIrrelevantProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
||||
partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import type {
|
|||
SERVER_CHANGE,
|
||||
} from "../sync/protocol";
|
||||
|
||||
// TODO: add senderId, possibly roomId as well
|
||||
// CFDO: add senderId, possibly roomId as well
|
||||
export class DurableChangesRepository implements ChangesRepository {
|
||||
constructor(private storage: DurableObjectStorage) {
|
||||
// #region DEV ONLY
|
||||
|
@ -24,7 +24,7 @@ export class DurableChangesRepository implements ChangesRepository {
|
|||
const prevVersion = this.getLastVersion();
|
||||
const nextVersion = prevVersion + changes.length;
|
||||
|
||||
// TODO: in theory payload could contain array of changes, if we would need to optimize writes
|
||||
// CFDO: in theory payload could contain array of changes, if we would need to optimize writes
|
||||
for (const [index, change] of changes.entries()) {
|
||||
const version = prevVersion + index + 1;
|
||||
// unique id ensures that we don't acknowledge the same change twice
|
||||
|
|
|
@ -21,11 +21,11 @@ export class DurableRoom extends DurableObject {
|
|||
super(ctx, env);
|
||||
|
||||
this.ctx.blockConcurrencyWhile(async () => {
|
||||
// TODO: snapshot should likely be a transient store
|
||||
// TODO: loaded the latest state from the db
|
||||
// CFDO: snapshot should likely be a transient store
|
||||
// CFDO: loaded the latest state from the db
|
||||
this.snapshot = {
|
||||
// TODO: start persisting acknowledged version (not a scene version!)
|
||||
// TODO: we don't persist appState, should we?
|
||||
// CFDO: start persisting acknowledged version (not a scene version!)
|
||||
// CFDO: we don't persist appState, should we?
|
||||
appState: {},
|
||||
elements: new Map(),
|
||||
version: 0,
|
||||
|
|
|
@ -4,13 +4,12 @@ export { DurableRoom } from "./room";
|
|||
* Worker relay for Durable Room.
|
||||
*/
|
||||
export default {
|
||||
// TODO: ensure it's wss in the prod
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
// TODO: only auth user should reach this
|
||||
// CFDO: only auth user should reach this
|
||||
const upgrade = request.headers.get("upgrade");
|
||||
if (!upgrade || upgrade !== "websocket") {
|
||||
return new Response(null, { status: 426 /* upgrade required */ });
|
||||
|
@ -25,7 +24,7 @@ export default {
|
|||
return new Response(null, { status: 403 /* forbidden */ });
|
||||
}
|
||||
|
||||
// TODO: double check that the scene exists
|
||||
// CFDO: double check that the scene exists
|
||||
const roomId = url.searchParams.get("roomId");
|
||||
if (!roomId) {
|
||||
return new Response(null, { status: 400 /* bad request */ });
|
||||
|
|
|
@ -710,7 +710,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.visibleElements = [];
|
||||
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
this.history = new History(this.store);
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
|
@ -759,15 +759,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||
};
|
||||
|
||||
this.fonts = new Fonts(this.scene);
|
||||
this.history = new History();
|
||||
this.history = new History(this.store);
|
||||
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(
|
||||
createUndoAction(this.history, this.store),
|
||||
);
|
||||
this.actionManager.registerAction(
|
||||
createRedoAction(this.history, this.store),
|
||||
);
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
}
|
||||
|
||||
private onWindowMessage(event: MessageEvent) {
|
||||
|
@ -1821,6 +1817,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return this.scene.getElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
public getSceneElementsMapIncludingDeleted = () => {
|
||||
return this.scene.getElementsMapIncludingDeleted();
|
||||
}
|
||||
|
||||
public getSceneElements = () => {
|
||||
return this.scene.getNonDeletedElements();
|
||||
};
|
||||
|
@ -2463,7 +2463,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
||||
this.history.record(increment);
|
||||
this.props.onIncrement?.(increment);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import type { AppStateChange, ElementsChange } from "./change";
|
||||
import type { SceneElementsMap } from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import type { Snapshot } from "./store";
|
||||
import type { SceneElementsMap } from "./element/types";
|
||||
import type { Store, StoreIncrement } from "./store";
|
||||
import type { AppState } from "./types";
|
||||
|
||||
type HistoryEntry = StoreIncrement & {
|
||||
skipRecording?: true;
|
||||
};
|
||||
|
||||
type HistoryStack = HistoryEntry[];
|
||||
|
||||
export class HistoryChangedEvent {
|
||||
|
@ -18,8 +21,8 @@ export class History {
|
|||
[HistoryChangedEvent]
|
||||
>();
|
||||
|
||||
private readonly undoStack: HistoryStack = [];
|
||||
private readonly redoStack: HistoryStack = [];
|
||||
public readonly undoStack: HistoryStack = [];
|
||||
public readonly redoStack: HistoryStack = [];
|
||||
|
||||
public get isUndoStackEmpty() {
|
||||
return this.undoStack.length === 0;
|
||||
|
@ -29,6 +32,8 @@ export class History {
|
|||
return this.redoStack.length === 0;
|
||||
}
|
||||
|
||||
constructor(private readonly store: Store) {}
|
||||
|
||||
public clear() {
|
||||
this.undoStack.length = 0;
|
||||
this.redoStack.length = 0;
|
||||
|
@ -37,13 +42,8 @@ export class History {
|
|||
/**
|
||||
* Record a local change which will go into the history
|
||||
*/
|
||||
public record(
|
||||
elementsChange: ElementsChange,
|
||||
appStateChange: AppStateChange,
|
||||
) {
|
||||
const entry = HistoryEntry.create(appStateChange, elementsChange);
|
||||
|
||||
if (!entry.isEmpty()) {
|
||||
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());
|
||||
|
||||
|
@ -60,29 +60,19 @@ export class History {
|
|||
}
|
||||
}
|
||||
|
||||
public undo(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
) {
|
||||
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||
return this.perform(
|
||||
elements,
|
||||
appState,
|
||||
snapshot,
|
||||
() => History.pop(this.undoStack),
|
||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||
);
|
||||
}
|
||||
|
||||
public redo(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
) {
|
||||
public redo(elements: SceneElementsMap, appState: AppState) {
|
||||
return this.perform(
|
||||
elements,
|
||||
appState,
|
||||
snapshot,
|
||||
() => History.pop(this.redoStack),
|
||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||
);
|
||||
|
@ -91,7 +81,6 @@ export class History {
|
|||
private perform(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
pop: () => HistoryEntry | null,
|
||||
push: (entry: HistoryEntry) => void,
|
||||
): [SceneElementsMap, AppState] | void {
|
||||
|
@ -109,10 +98,17 @@ 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] =
|
||||
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
||||
this.store.applyIncrementTo(
|
||||
historyEntry,
|
||||
nextElements,
|
||||
nextAppState,
|
||||
);
|
||||
} finally {
|
||||
// make sure to always push / pop, even if the increment is corrupted
|
||||
// make sure to always push, even if the increment is corrupted
|
||||
push(historyEntry);
|
||||
}
|
||||
|
||||
|
@ -123,6 +119,10 @@ export class History {
|
|||
historyEntry = pop();
|
||||
}
|
||||
|
||||
if (nextElements === null || nextAppState === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [nextElements, nextAppState];
|
||||
} finally {
|
||||
// trigger the history change event before returning completely
|
||||
|
@ -156,55 +156,3 @@ export class History {
|
|||
return stack.push(updatedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryEntry {
|
||||
private constructor(
|
||||
public readonly appStateChange: AppStateChange,
|
||||
public readonly elementsChange: ElementsChange,
|
||||
) {}
|
||||
|
||||
public static create(
|
||||
appStateChange: AppStateChange,
|
||||
elementsChange: ElementsChange,
|
||||
) {
|
||||
return new HistoryEntry(appStateChange, elementsChange);
|
||||
}
|
||||
|
||||
public inverse(): HistoryEntry {
|
||||
return new HistoryEntry(
|
||||
this.appStateChange.inverse(),
|
||||
this.elementsChange.inverse(),
|
||||
);
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
this.elementsChange.applyTo(elements, snapshot.elements);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
this.appStateChange.applyTo(appState, nextElements);
|
||||
|
||||
const appliedVisibleChanges =
|
||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||
|
||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
|
||||
*/
|
||||
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
|
||||
const updatedElementsChange =
|
||||
this.elementsChange.applyLatestChanges(elements);
|
||||
|
||||
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@types/async-lock": "1.4.2",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"async-lock": "^1.4.1",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
|
|
|
@ -3,7 +3,10 @@ import { AppStateChange, ElementsChange } from "./change";
|
|||
import { ENV } from "./constants";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import type { OrderedExcalidrawElement } from "./element/types";
|
||||
import type {
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import type { AppState, ObservedAppState } from "./types";
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
@ -73,82 +76,33 @@ export const StoreAction = {
|
|||
export type StoreActionType = ValueOf<typeof StoreAction>;
|
||||
|
||||
/**
|
||||
* Represent an increment to the Store.
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class StoreIncrementEvent {
|
||||
constructor(
|
||||
public readonly elementsChange: ElementsChange,
|
||||
public readonly appStateChange: AppStateChange,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
||||
*
|
||||
* @experimental this interface is experimental and subject to change.
|
||||
*/
|
||||
export interface IStore {
|
||||
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
|
||||
get snapshot(): Snapshot;
|
||||
set snapshot(snapshot: Snapshot);
|
||||
|
||||
/**
|
||||
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
|
||||
*/
|
||||
shouldUpdateSnapshot(): void;
|
||||
|
||||
/**
|
||||
* Use to schedule calculation of a store increment.
|
||||
*/
|
||||
shouldCaptureIncrement(): void;
|
||||
|
||||
/**
|
||||
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
|
||||
*
|
||||
* @emits StoreIncrementEvent when increment is calculated.
|
||||
*/
|
||||
commit(
|
||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Clears the store instance.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
||||
*
|
||||
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
||||
*/
|
||||
filterUncomittedElements(
|
||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
): Map<string, OrderedExcalidrawElement>;
|
||||
}
|
||||
|
||||
export class Store implements IStore {
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[StoreIncrementEvent]
|
||||
>();
|
||||
export class Store {
|
||||
public readonly onStoreIncrementEmitter = new Emitter<[StoreIncrement]>();
|
||||
|
||||
private scheduledActions: Set<StoreActionType> = new Set();
|
||||
private _snapshot = Snapshot.empty();
|
||||
private _snapshot = StoreSnapshot.empty();
|
||||
|
||||
public get snapshot() {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
public set snapshot(snapshot: Snapshot) {
|
||||
public set snapshot(snapshot: StoreSnapshot) {
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
@ -158,6 +112,11 @@ export class Store implements IStore {
|
|||
this.satisfiesScheduledActionsInvariant();
|
||||
};
|
||||
|
||||
/**
|
||||
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrement`.
|
||||
*
|
||||
* @emits StoreIncrement when increment is calculated.
|
||||
*/
|
||||
public commit = (
|
||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
|
@ -176,6 +135,11 @@ export class Store implements IStore {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs diff calculation, calculates and emits the increment.
|
||||
*
|
||||
* @emits StoreIncrement when increment is calculated.
|
||||
*/
|
||||
public captureIncrement = (
|
||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
|
@ -186,18 +150,18 @@ export class Store implements IStore {
|
|||
// 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.meta.didElementsChange
|
||||
const elementsChange = nextSnapshot.metadata.didElementsChange
|
||||
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||
: ElementsChange.empty();
|
||||
|
||||
const appStateChange = nextSnapshot.meta.didAppStateChange
|
||||
const appStateChange = nextSnapshot.metadata.didAppStateChange
|
||||
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||
: AppStateChange.empty();
|
||||
|
||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
||||
// Notify listeners with the increment
|
||||
this.onStoreIncrementEmitter.trigger(
|
||||
new StoreIncrementEvent(elementsChange, appStateChange),
|
||||
new StoreIncrement(elementsChange, appStateChange),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -206,6 +170,9 @@ export class Store implements IStore {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the snapshot without performing any diff calculation.
|
||||
*/
|
||||
public updateSnapshot = (
|
||||
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
|
@ -218,6 +185,11 @@ export class Store implements IStore {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
||||
*
|
||||
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
||||
*/
|
||||
public filterUncomittedElements = (
|
||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
|
@ -245,8 +217,35 @@ export class Store implements IStore {
|
|||
return nextElements;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply and emit increment.
|
||||
*
|
||||
* @emits StoreIncrement when increment is applied.
|
||||
*/
|
||||
public applyIncrementTo = (
|
||||
increment: StoreIncrement,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
): [SceneElementsMap, AppState, boolean] => {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
increment.elementsChange.applyTo(elements, this.snapshot.elements);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
increment.appStateChange.applyTo(appState, nextElements);
|
||||
|
||||
const appliedVisibleChanges =
|
||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
|
||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the store instance.
|
||||
*/
|
||||
public clear = (): void => {
|
||||
this.snapshot = Snapshot.empty();
|
||||
this.snapshot = StoreSnapshot.empty();
|
||||
this.scheduledActions = new Set();
|
||||
};
|
||||
|
||||
|
@ -262,11 +261,42 @@ export class Store implements IStore {
|
|||
};
|
||||
}
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
* Represent an increment to the Store.
|
||||
*/
|
||||
export class StoreIncrement {
|
||||
constructor(
|
||||
public readonly elementsChange: ElementsChange,
|
||||
public readonly appStateChange: AppStateChange,
|
||||
) {}
|
||||
|
||||
public inverse(): StoreIncrement {
|
||||
return new StoreIncrement(
|
||||
this.elementsChange.inverse(),
|
||||
this.appStateChange.inverse(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply latest (remote) changes to the increment, creates new instance of `StoreIncrement`.
|
||||
*/
|
||||
public applyLatestChanges(elements: SceneElementsMap): StoreIncrement {
|
||||
const updatedElementsChange =
|
||||
this.elementsChange.applyLatestChanges(elements);
|
||||
|
||||
return new StoreIncrement(updatedElementsChange, this.appStateChange);
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elementsChange.isEmpty() && this.appStateChange.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
export class StoreSnapshot {
|
||||
private constructor(
|
||||
public readonly elements: Map<string, OrderedExcalidrawElement>,
|
||||
public readonly appState: ObservedAppState,
|
||||
public readonly meta: {
|
||||
public readonly metadata: {
|
||||
didElementsChange: boolean;
|
||||
didAppStateChange: boolean;
|
||||
isEmpty?: boolean;
|
||||
|
@ -278,7 +308,7 @@ export class Snapshot {
|
|||
) {}
|
||||
|
||||
public static empty() {
|
||||
return new Snapshot(
|
||||
return new StoreSnapshot(
|
||||
new Map(),
|
||||
getObservedAppState(getDefaultAppState() as AppState),
|
||||
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
|
||||
|
@ -286,7 +316,7 @@ export class Snapshot {
|
|||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.meta.isEmpty;
|
||||
return this.metadata.isEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -316,10 +346,14 @@ export class Snapshot {
|
|||
return this;
|
||||
}
|
||||
|
||||
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
|
||||
didElementsChange,
|
||||
didAppStateChange,
|
||||
});
|
||||
const snapshot = new StoreSnapshot(
|
||||
nextElementsSnapshot,
|
||||
nextAppStateSnapshot,
|
||||
{
|
||||
didElementsChange,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import type { CLIENT_CHANGE, PUSH_PAYLOAD, SERVER_CHANGE } from "./protocol";
|
|||
import throttle from "lodash.throttle";
|
||||
|
||||
export class ExcalidrawSyncClient {
|
||||
// TODO: add prod url
|
||||
private static readonly HOST_URL = import.meta.env.DEV
|
||||
? "ws://localhost:8787"
|
||||
: "https://excalidraw-sync.marcel-529.workers.dev";
|
||||
|
@ -46,11 +45,11 @@ export class ExcalidrawSyncClient {
|
|||
this.api = api;
|
||||
this.roomId = roomId;
|
||||
|
||||
// TODO: persist in idb
|
||||
// CFDO: persist in idb
|
||||
this.lastAcknowledgedVersion = 0;
|
||||
}
|
||||
|
||||
// TODO: throttle does not work that well here (after some period it tries to reconnect too often)
|
||||
// CFDO: throttle does not work that well here (after some period it tries to reconnect too often)
|
||||
public reconnect = throttle(
|
||||
async () => {
|
||||
try {
|
||||
|
@ -164,7 +163,7 @@ export class ExcalidrawSyncClient {
|
|||
);
|
||||
};
|
||||
|
||||
// TODO: could be an array buffer
|
||||
// CFDO: could be an array buffer
|
||||
private onMessage = (event: MessageEvent) => {
|
||||
const [result, error] = Utils.try(() => JSON.parse(event.data as string));
|
||||
|
||||
|
@ -202,7 +201,7 @@ export class ExcalidrawSyncClient {
|
|||
const payload: PUSH_PAYLOAD = { type, changes: [] };
|
||||
|
||||
if (type === "durable") {
|
||||
// TODO: persist in idb (with insertion order)
|
||||
// CFDO: persist in idb (with insertion order)
|
||||
for (const change of changes) {
|
||||
this.queuedChanges.set(change.id, {
|
||||
queuedAt: Date.now(),
|
||||
|
@ -231,7 +230,7 @@ export class ExcalidrawSyncClient {
|
|||
});
|
||||
}
|
||||
|
||||
// TODO: refactor by applying all operations to store, not to the elements
|
||||
// CFDO: refactor by applying all operations to store, not to the elements
|
||||
private handleAcknowledged(payload: { changes: Array<SERVER_CHANGE> }) {
|
||||
const { changes: remoteChanges } = payload;
|
||||
|
||||
|
@ -254,7 +253,7 @@ export class ExcalidrawSyncClient {
|
|||
// local change acknowledge by the server, safe to remove
|
||||
this.queuedChanges.delete(remoteChange.id);
|
||||
} else {
|
||||
// TODO: we might not need to be that strict here
|
||||
// CFDO: we might not need to be that strict here
|
||||
if (this.lastAcknowledgedVersion + 1 !== remoteChange.version) {
|
||||
throw new Error(
|
||||
`Received out of order change, expected "${
|
||||
|
@ -282,7 +281,7 @@ export class ExcalidrawSyncClient {
|
|||
);
|
||||
|
||||
// apply local changes
|
||||
// TODO: only necessary when remote changes modified same element properties!
|
||||
// CFDO: only necessary when remote changes modified same element properties!
|
||||
for (const localChange of this.localChanges) {
|
||||
[elements] = localChange.applyTo(
|
||||
elements,
|
||||
|
|
|
@ -32,7 +32,7 @@ export interface ChangesRepository {
|
|||
getLastVersion(): number;
|
||||
}
|
||||
|
||||
// TODO: should come from the shared types package
|
||||
// CFDO: should come from the shared types package
|
||||
export type ExcalidrawElement = {
|
||||
id: string;
|
||||
type: any;
|
||||
|
|
|
@ -11,7 +11,7 @@ import type {
|
|||
SERVER_MESSAGE,
|
||||
} from "./protocol";
|
||||
|
||||
// TODO: message could be binary (cbor, protobuf, etc.)
|
||||
// CFDO: message could be binary (cbor, protobuf, etc.)
|
||||
|
||||
/**
|
||||
* Core excalidraw sync logic.
|
||||
|
@ -48,7 +48,7 @@ export class ExcalidrawSyncServer {
|
|||
return this.pull(client, payload);
|
||||
case "push":
|
||||
// apply each one-by-one to avoid race conditions
|
||||
// TODO: in theory we do not need to block ephemeral appState changes
|
||||
// CFDO: in theory we do not need to block ephemeral appState changes
|
||||
return this.lock.acquire("push", () => this.push(client, payload));
|
||||
default:
|
||||
console.error(`Unknown message type: ${type}`);
|
||||
|
@ -56,7 +56,7 @@ export class ExcalidrawSyncServer {
|
|||
}
|
||||
|
||||
private pull(client: WebSocket, payload: PULL_PAYLOAD) {
|
||||
// TODO: test for invalid payload
|
||||
// CFDO: test for invalid payload
|
||||
const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion;
|
||||
const lastAcknowledgedServerVersion =
|
||||
this.changesRepository.getLastVersion();
|
||||
|
@ -70,7 +70,7 @@ export class ExcalidrawSyncServer {
|
|||
}
|
||||
|
||||
if (versionΔ < 0) {
|
||||
// TODO: restore the client from the snapshot / deltas?
|
||||
// CFDO: restore the client from the snapshot / deltas?
|
||||
console.error(
|
||||
`Panic! Client claims to have higher acknowledged version than the latest one on the server!`,
|
||||
);
|
||||
|
@ -78,7 +78,7 @@ export class ExcalidrawSyncServer {
|
|||
}
|
||||
|
||||
if (versionΔ > 0) {
|
||||
// TODO: for versioning we need deletions, but not for the "snapshot" update
|
||||
// CFDO: for versioning we need deletions, but not for the "snapshot" update
|
||||
const changes = this.changesRepository.getSinceVersion(
|
||||
lastAcknowledgedClientVersion,
|
||||
);
|
||||
|
@ -99,7 +99,7 @@ export class ExcalidrawSyncServer {
|
|||
return this.relay(client, { changes });
|
||||
case "durable":
|
||||
const [acknowledged, error] = Utils.try(() => {
|
||||
// TODO: try to apply the changes to the snapshot
|
||||
// CFDO: try to apply the changes to the snapshot
|
||||
return this.changesRepository.saveAll(changes);
|
||||
});
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -16,7 +16,6 @@ import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
|||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import type { AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
|
@ -42,11 +41,11 @@ import {
|
|||
} from "../actions";
|
||||
import { vi } from "vitest";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
import { HistoryEntry } from "../history";
|
||||
import { AppStateChange, ElementsChange } from "../change";
|
||||
import { Snapshot, StoreAction } from "../store";
|
||||
import { StoreAction, StoreIncrement } from "../store";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import type { AppState } from "../types.js";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -65,7 +64,8 @@ const checkpoint = (name: string) => {
|
|||
...strippedAppState
|
||||
} = h.state;
|
||||
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
||||
expect(h.history.undoStack).toMatchSnapshot(`[${name}] undo stack`);
|
||||
expect(h.history.redoStack).toMatchSnapshot(`[${name}] redo stack`);
|
||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||
h.elements
|
||||
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
||||
|
@ -99,14 +99,16 @@ describe("history", () => {
|
|||
|
||||
API.setElements([rect]);
|
||||
|
||||
const corrupedEntry = HistoryEntry.create(
|
||||
AppStateChange.empty(),
|
||||
const corrupedEntry = new StoreIncrement(
|
||||
ElementsChange.empty(),
|
||||
AppStateChange.empty(),
|
||||
);
|
||||
|
||||
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
|
||||
throw new Error("Oh no, I am corrupted!");
|
||||
});
|
||||
vi.spyOn(corrupedEntry.elementsChange, "applyTo").mockImplementation(
|
||||
() => {
|
||||
throw new Error("Oh no, I am corrupted!");
|
||||
},
|
||||
);
|
||||
|
||||
(h.history as any).undoStack.push(corrupedEntry);
|
||||
|
||||
|
@ -119,7 +121,6 @@ describe("history", () => {
|
|||
h.history.undo(
|
||||
arrayToMap(h.elements) as SceneElementsMap,
|
||||
appState,
|
||||
Snapshot.empty(),
|
||||
) as any,
|
||||
);
|
||||
} catch (e) {
|
||||
|
@ -140,7 +141,6 @@ describe("history", () => {
|
|||
h.history.redo(
|
||||
arrayToMap(h.elements) as SceneElementsMap,
|
||||
appState,
|
||||
Snapshot.empty(),
|
||||
) as any,
|
||||
);
|
||||
} catch (e) {
|
||||
|
@ -437,8 +437,8 @@ describe("history", () => {
|
|||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
// noop
|
||||
API.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
|
@ -514,8 +514,8 @@ describe("history", () => {
|
|||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
]);
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
API.executeAction(undoAction);
|
||||
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
|
@ -1714,8 +1714,8 @@ describe("history", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
|
@ -1764,7 +1764,7 @@ describe("history", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
|
|
|
@ -40,7 +40,7 @@ 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, StoreIncrementEvent } from "./store";
|
||||
import type { StoreActionType, StoreIncrement } from "./store";
|
||||
|
||||
export type SocketId = string & { _brand: "SocketId" };
|
||||
|
||||
|
@ -498,7 +498,7 @@ export interface ExcalidrawProps {
|
|||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => void;
|
||||
onIncrement?: (event: StoreIncrementEvent) => void;
|
||||
onIncrement?: (event: StoreIncrement) => void;
|
||||
initialData?:
|
||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||
|
@ -785,7 +785,7 @@ export interface ExcalidrawImperativeAPI {
|
|||
) => void,
|
||||
) => UnsubscribeCallback;
|
||||
onIncrement: (
|
||||
callback: (event: StoreIncrementEvent) => void,
|
||||
callback: (event: StoreIncrement) => void,
|
||||
) => UnsubscribeCallback;
|
||||
onPointerDown: (
|
||||
callback: (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue