diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 3165a069b5..1bb1cb48d5 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -16,6 +16,7 @@ import type { FileId, NonDeletedExcalidrawElement, OrderedExcalidrawElement, + SceneElementsMap, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -134,6 +135,10 @@ 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"; polyfill(); @@ -365,16 +370,26 @@ const ExcalidrawWrapper = () => { const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [collabAPI] = useAtom(collabAPIAtom); const [syncAPI] = useAtom(syncAPIAtom); + const [nextVersion, setNextVersion] = useState(-1); + const currentVersion = useRef(-1); + const [acknowledgedChanges, setAcknowledgedChanges] = useState< + ElementsChange[] + >([]); const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); const collabError = useAtomValue(collabErrorIndicatorAtom); useEffect(() => { + const interval = setInterval(() => { + setAcknowledgedChanges([...(syncAPI?.acknowledgedChanges ?? [])]); + }, 250); + syncAPI?.reconnect(); return () => { syncAPI?.disconnect(); + clearInterval(interval); }; }, [syncAPI]); @@ -807,6 +822,44 @@ const ExcalidrawWrapper = () => { }, }; + const debouncedTimeTravel = debounce((value: number) => { + let elements = new Map( + excalidrawAPI?.getSceneElements().map((x) => [x.id, x]), + ); + + let changes: ElementsChange[] = []; + + const goingLeft = + currentVersion.current === -1 || value - currentVersion.current <= 0; + + if (goingLeft) { + changes = acknowledgedChanges + .slice(value) + .reverse() + .map((x) => x.inverse()); + } else { + changes = acknowledgedChanges.slice(currentVersion.current, value) ?? []; + } + + for (const change of changes) { + [elements] = change.applyTo( + elements as SceneElementsMap, + excalidrawAPI?.store.snapshot.elements!, + ); + } + + excalidrawAPI?.updateScene({ + appState: { + ...excalidrawAPI?.getAppState(), + viewModeEnabled: value !== acknowledgedChanges.length, + }, + elements: Array.from(elements.values()), + storeAction: StoreAction.UPDATE, + }); + + currentVersion.current = value; + }, 0); + return (
{ "is-collaborating": isCollaborating, })} > + { + setNextVersion(value as number); + debouncedTimeTravel(value as number); + }} + /> ) => - this.storage.transactionSync(() => { + public saveAll = (changes: Array) => { + return this.storage.transactionSync(() => { const prevVersion = this.getLastVersion(); const nextVersion = prevVersion + changes.length; @@ -45,14 +45,16 @@ export class DurableChangesRepository implements ChangesRepository { return this.getSinceVersion(prevVersion); }); + }; - public getSinceVersion = (version: number): Array => - this.storage.sql + public getSinceVersion = (version: number): Array => { + return this.storage.sql .exec( `SELECT id, payload, version FROM changes WHERE version > (?) ORDER BY version ASC;`, version, ) .toArray(); + }; public getLastVersion = (): number => { const result = this.storage.sql diff --git a/packages/excalidraw/sync/client.ts b/packages/excalidraw/sync/client.ts index 174c8d25c6..a54599d18f 100644 --- a/packages/excalidraw/sync/client.ts +++ b/packages/excalidraw/sync/client.ts @@ -13,7 +13,7 @@ export class ExcalidrawSyncClient { : "https://excalidraw-sync.marcel-529.workers.dev"; private static readonly ROOM_ID = import.meta.env.DEV - ? "test_room_dev" + ? "test_room_x" : "test_room_prod"; private static readonly RECONNECT_INTERVAL = 10_000; @@ -22,9 +22,14 @@ export class ExcalidrawSyncClient { private readonly api: ExcalidrawImperativeAPI; private readonly roomId: string; - private readonly queuedChanges: Map = new Map(); + private readonly queuedChanges: Map< + string, + { queuedAt: number; change: CLIENT_CHANGE } + > = new Map(); + public readonly acknowledgedChanges: Array = []; + private get localChanges() { - return Array.from(this.queuedChanges.values()); + return Array.from(this.queuedChanges.values()).map(({ change }) => change); } private server: WebSocket | null = null; @@ -45,6 +50,7 @@ export class ExcalidrawSyncClient { this.lastAcknowledgedVersion = 0; } + // TODO: throttle does not work that well here (after some period it tries to reconnect too often) public reconnect = throttle( async () => { try { @@ -58,7 +64,7 @@ export class ExcalidrawSyncClient { return; } - console.trace("Reconnecting to the sync server..."); + console.info("Reconnecting to the sync server..."); const isConnecting = { done: () => {}, @@ -109,6 +115,7 @@ export class ExcalidrawSyncClient { this.server?.removeEventListener("close", this.onClose); this.server?.removeEventListener("error", this.onError); this.server?.removeEventListener("open", this.onOpen); + this.server?.close(); if (error) { this.isConnecting?.done(error); @@ -143,15 +150,19 @@ export class ExcalidrawSyncClient { this.pull(); }; - private onClose = () => + private onClose = (event: CloseEvent) => { + console.log("close", event); this.disconnect( - new Error(`Received "closed" event on the sync connection`), + new Error(`Received "${event.type}" event on the sync connection`), ); + }; - private onError = (event: Event) => + private onError = (event: Event) => { + console.log("error", event); this.disconnect( new Error(`Received "${event.type}" on the sync connection`), ); + }; // TODO: could be an array buffer private onMessage = (event: MessageEvent) => { @@ -193,7 +204,10 @@ export class ExcalidrawSyncClient { if (type === "durable") { // TODO: persist in idb (with insertion order) for (const change of changes) { - this.queuedChanges.set(change.id, change); + this.queuedChanges.set(change.id, { + queuedAt: Date.now(), + change, + }); } // batch all queued changes @@ -226,21 +240,20 @@ export class ExcalidrawSyncClient { this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]), ) as SceneElementsMap; - console.log("remote changes", remoteChanges); - console.log("local changes", this.localChanges); - try { // apply remote changes for (const remoteChange of remoteChanges) { if (this.queuedChanges.has(remoteChange.id)) { + const { change, queuedAt } = this.queuedChanges.get(remoteChange.id)!; + this.acknowledgedChanges.push(change); + console.info( + `Acknowledged change "${remoteChange.id}" after ${ + Date.now() - queuedAt + }ms`, + ); // local change acknowledge by the server, safe to remove this.queuedChanges.delete(remoteChange.id); } else { - [elements] = ElementsChange.load(remoteChange.payload).applyTo( - elements, - this.api.store.snapshot.elements, - ); - // TODO: we might not need to be that strict here if (this.lastAcknowledgedVersion + 1 !== remoteChange.version) { throw new Error( @@ -249,11 +262,25 @@ export class ExcalidrawSyncClient { }", but received "${remoteChange.version}"`, ); } + + const change = ElementsChange.load(remoteChange.payload); + [elements] = change.applyTo( + elements, + this.api.store.snapshot.elements, + ); + this.acknowledgedChanges.push(change); } this.lastAcknowledgedVersion = remoteChange.version; } + console.debug(`${now()} remote changes`, remoteChanges); + console.debug(`${now()} local changes`, this.localChanges); + console.debug( + `${now()} acknowledged changes`, + this.acknowledgedChanges.slice(-remoteChanges.length), + ); + // apply local changes // TODO: only necessary when remote changes modified same element properties! for (const localChange of this.localChanges) { @@ -298,3 +325,8 @@ export class ExcalidrawSyncClient { this.server?.send(JSON.stringify(message)); } } + +const now = () => { + const date = new Date(); + return `[${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}]`; +}; diff --git a/yarn.lock b/yarn.lock index f235442a03..5108787d8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1412,6 +1412,13 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== +"@babel/runtime@^7.10.1", "@babel/runtime@^7.18.3": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e" @@ -4829,6 +4836,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +classnames@^2.2.5: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clean-css@^5.2.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" @@ -9272,6 +9284,23 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +rc-slider@11.1.7: + version "11.1.7" + resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-11.1.7.tgz#3de333b1ec84d53a7bda2f816bb4779423628f09" + integrity sha512-ytYbZei81TX7otdC0QvoYD72XSlxvTihNth5OeZ6PMXyEDq/vHdWFulQmfDGyXK1NwKwSlKgpvINOa88uT5g2A== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.5" + rc-util "^5.36.0" + +rc-util@^5.36.0: + version "5.43.0" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.43.0.tgz#bba91fbef2c3e30ea2c236893746f3e9b05ecc4c" + integrity sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^18.2.0" + react-dom@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -9290,7 +9319,7 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0: +react-is@^18.0.0, react-is@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==