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