diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index cd808cac71..baf3861e47 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -140,7 +140,6 @@ import DebugCanvas, { import { AIComponents } from "./components/AI"; import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; import { isElementLink } from "../packages/excalidraw/element/elementLink"; -import type { ElementsChange } from "../packages/excalidraw/change"; import Slider from "rc-slider"; import "rc-slider/assets/index.css"; diff --git a/packages/excalidraw/cloudflare/repository.ts b/packages/excalidraw/cloudflare/repository.ts index f1948cc87e..3f8925f796 100644 --- a/packages/excalidraw/cloudflare/repository.ts +++ b/packages/excalidraw/cloudflare/repository.ts @@ -9,7 +9,7 @@ import { Network } from "../sync/utils"; // CFDO II: add senderId, possibly roomId as well export class DurableDeltasRepository implements DeltasRepository { // there is a 2MB row limit, hence working with max payload size of 1.5 MB - // and leaving a buffer for other row metadata + // and leaving a ~500kB buffer for other row metadata private static readonly MAX_PAYLOAD_SIZE = 1_500_000; constructor(private storage: DurableObjectStorage) { diff --git a/packages/excalidraw/cloudflare/room.ts b/packages/excalidraw/cloudflare/room.ts index ae1c230035..445d1fafa4 100644 --- a/packages/excalidraw/cloudflare/room.ts +++ b/packages/excalidraw/cloudflare/room.ts @@ -2,8 +2,6 @@ import { DurableObject } from "cloudflare:workers"; import { DurableDeltasRepository } from "./repository"; import { ExcalidrawSyncServer } from "../sync/server"; -import type { ExcalidrawElement } from "../element/types"; - /** * Durable Object impl. of Excalidraw room. */ @@ -11,26 +9,10 @@ export class DurableRoom extends DurableObject { private roomId: string | null = null; private sync: ExcalidrawSyncServer; - private snapshot!: { - appState: Record; - elements: Map; - version: number; - }; - constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.ctx.blockConcurrencyWhile(async () => { - // CFDO I: snapshot should likely be a transient store - // CFDO II: loaded the latest state from the db - this.snapshot = { - // CFDO: start persisting acknowledged version (not a scene version!) - // CFDO: we don't persist appState, should we? - appState: {}, - elements: new Map(), - version: 0, - }; - this.roomId = (await this.ctx.storage.get("roomId")) || null; }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97747f0f89..6e1d1f09a4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5367,7 +5367,7 @@ class App extends React.Component { : -1; if (midPoint && midPoint > -1) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint); const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( diff --git a/packages/excalidraw/delta.ts b/packages/excalidraw/delta.ts index 2e96f98f09..e746dce78b 100644 --- a/packages/excalidraw/delta.ts +++ b/packages/excalidraw/delta.ts @@ -32,7 +32,7 @@ import type { } from "./element/types"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { getNonDeletedGroupIds } from "./groups"; -import { getObservedAppState } from "./store"; +import { getObservedAppState, StoreSnapshot } from "./store"; import type { AppState, ObservedAppState, @@ -1036,7 +1036,10 @@ export class ElementsDelta implements DeltaContainer { * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @returns new instance with modified delta/s */ - public applyLatestChanges(elements: SceneElementsMap): ElementsDelta { + public applyLatestChanges( + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): ElementsDelta { const modifier = (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { const latestPartial: { [key: string]: unknown } = {}; @@ -1069,7 +1072,7 @@ export class ElementsDelta implements DeltaContainer { delta.deleted, delta.inserted, modifier(existingElement), - "inserted", + modifierOptions, ); modifiedDeltas[id] = modifiedDelta; @@ -1092,7 +1095,10 @@ export class ElementsDelta implements DeltaContainer { public applyTo( elements: SceneElementsMap, - snapshot: Map, + elementsSnapshot: Map< + string, + OrderedExcalidrawElement + > = StoreSnapshot.empty().elements, ): [SceneElementsMap, boolean] { let nextElements = toBrandedType(new Map(elements)); let changedElements: Map; @@ -1106,7 +1112,7 @@ export class ElementsDelta implements DeltaContainer { try { const applyDeltas = ElementsDelta.createApplier( nextElements, - snapshot, + elementsSnapshot, flags, ); diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index bc204d574c..66054b336a 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -159,7 +159,13 @@ export class History { entry: HistoryEntry, prevElements: SceneElementsMap, ) { - const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements); + const inversedEntry = HistoryEntry.inverse(entry); + const updatedEntry = HistoryEntry.applyLatestChanges( + inversedEntry, + prevElements, + "inserted", + ); + return stack.push(updatedEntry); } } diff --git a/packages/excalidraw/store.ts b/packages/excalidraw/store.ts index ed184ba8f6..d8c2a4b970 100644 --- a/packages/excalidraw/store.ts +++ b/packages/excalidraw/store.ts @@ -474,14 +474,13 @@ export class StoreDelta { public static applyLatestChanges( delta: StoreDelta, elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", ): StoreDelta { - const inversedDelta = this.inverse(delta); - return this.create( - inversedDelta.elements.applyLatestChanges(elements), - inversedDelta.appState, + delta.elements.applyLatestChanges(elements, modifierOptions), + delta.appState, { - id: inversedDelta.id, + id: delta.id, }, ); } diff --git a/packages/excalidraw/sync/protocol.ts b/packages/excalidraw/sync/protocol.ts index 715afce664..bc9a5de855 100644 --- a/packages/excalidraw/sync/protocol.ts +++ b/packages/excalidraw/sync/protocol.ts @@ -4,6 +4,7 @@ import type { DTO } from "../utility-types"; export type CLIENT_DELTA = DTO; export type CLIENT_CHANGE = DTO; +export type RESTORE_PAYLOAD = {}; export type RELAY_PAYLOAD = CLIENT_CHANGE; export type PUSH_PAYLOAD = CLIENT_DELTA; export type PULL_PAYLOAD = { lastAcknowledgedVersion: number }; @@ -15,6 +16,7 @@ export type CHUNK_INFO = { }; export type CLIENT_MESSAGE = ( + | { type: "restore"; payload: RESTORE_PAYLOAD } | { type: "relay"; payload: RELAY_PAYLOAD } | { type: "pull"; payload: PULL_PAYLOAD } | { type: "push"; payload: PUSH_PAYLOAD } @@ -48,7 +50,8 @@ export type SERVER_MESSAGE = | { type: "rejected"; payload: { deltas: Array; message: string }; - }; + } + | { type: "restored"; payload: { elements: Array } }; export interface DeltasRepository { save(delta: CLIENT_DELTA): SERVER_DELTA | null; diff --git a/packages/excalidraw/sync/server.ts b/packages/excalidraw/sync/server.ts index 6ea34ce856..4421f6587a 100644 --- a/packages/excalidraw/sync/server.ts +++ b/packages/excalidraw/sync/server.ts @@ -3,7 +3,6 @@ import { Network, Utils } from "./utils"; import type { DeltasRepository, - CLIENT_MESSAGE, PULL_PAYLOAD, PUSH_PAYLOAD, SERVER_MESSAGE, @@ -11,7 +10,10 @@ import type { CHUNK_INFO, RELAY_PAYLOAD, CLIENT_MESSAGE_BINARY, + CLIENT_MESSAGE, + ExcalidrawElement, } from "./protocol"; +import { StoreDelta } from "../store"; /** * Core excalidraw sync logic. @@ -24,7 +26,22 @@ export class ExcalidrawSyncServer { Map >(); - constructor(private readonly repository: DeltasRepository) {} + // CFDO II: load from the db + private elements = new Map(); + + constructor(private readonly repository: DeltasRepository) { + // CFDO II: load from the db + const deltas = this.repository.getAllSinceVersion(0); + + for (const delta of deltas) { + const storeDelta = StoreDelta.load(delta.payload); + + // CFDO II: fix types (everywhere) + const [nextElements] = storeDelta.elements.applyTo(this.elements as any); + + this.elements = nextElements; + } + } // CFDO: optimize, should send a message about collaborators (no collaborators => no need to send ephemerals) public onConnect(client: WebSocket) { @@ -48,11 +65,12 @@ export class ExcalidrawSyncServer { return; } - const { type, payload, chunkInfo } = rawMessage; - // if there is chunkInfo, there are more than 1 chunks => process them first - if (chunkInfo) { - return this.processChunks(client, { type, payload, chunkInfo }); + if (rawMessage.chunkInfo) { + return this.processChunks(client, { + ...rawMessage, + chunkInfo: rawMessage.chunkInfo, + }); } return this.processMessage(client, rawMessage); @@ -132,6 +150,8 @@ export class ExcalidrawSyncServer { } switch (type) { + case "restore": + return this.restore(client); case "relay": return this.relay(client, parsedPayload as RELAY_PAYLOAD); case "pull": @@ -147,6 +167,15 @@ export class ExcalidrawSyncServer { } } + private restore(client: WebSocket) { + return this.send(client, { + type: "restored", + payload: { + elements: Array.from(this.elements.values()), + }, + }); + } + private relay(client: WebSocket, payload: RELAY_PAYLOAD) { // CFDO I: we should likely apply these to the snapshot return this.broadcast( @@ -191,10 +220,38 @@ export class ExcalidrawSyncServer { } private push(client: WebSocket, delta: PUSH_PAYLOAD) { - // CFDO I: apply latest changes to delt & apply the deltas to the snapshot - const [acknowledged, savingError] = Utils.try(() => - this.repository.save(delta), - ); + const [storeDelta, applyingError] = Utils.try(() => { + // update the "deleted" delta according to the latest changes (in case of concurrent changes) + const storeDelta = StoreDelta.applyLatestChanges( + StoreDelta.load(delta), + this.elements as any, + "deleted", + ); + + // apply the delta to the elements snapshot + const [nextElements] = storeDelta.elements.applyTo(this.elements as any); + + this.elements = nextElements; + + return storeDelta; + }); + + if (applyingError) { + // CFDO: everything should be automatically rolled-back in the db -> double-check + return this.send(client, { + type: "rejected", + payload: { + message: applyingError + ? applyingError.message + : "Couldn't apply the delta.", + deltas: [delta], + }, + }); + } + + const [acknowledged, savingError] = Utils.try(() => { + return this.repository.save(storeDelta); + }); if (savingError || !acknowledged) { // CFDO: everything should be automatically rolled-back in the db -> double-check @@ -204,7 +261,7 @@ export class ExcalidrawSyncServer { message: savingError ? savingError.message : "Coudn't persist the delta.", - deltas: [delta], + deltas: [storeDelta], }, }); } diff --git a/yarn.lock b/yarn.lock index 5caac2433a..121880dec7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7986,7 +7986,7 @@ lodash.camelcase@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== -lodash.debounce@^4.0.8: +lodash.debounce@4.0.8, lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==