
Introducing independent change detection for appState and elements Generalizing object change, cleanup, refactoring, comments, solving typing issues Shaping increment, change, delta hierarchy Structural clone of elements Introducing store and incremental API Disabling buttons for canvas actions, smaller store and changes improvements Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands Solving concurrency issues, solving (partly) linear element issues, introducing commitToStore breaking change Fixing existing tests, updating snapshots Trying to be smarter on the appstate change detection Extending collab test, refactoring action / updateScene params, bugfixes Resetting snapshots Resetting snapshots UI / API tests for history - WIP Changing actions related to the observed appstate to at least update the store snapshot - WIP Adding skipping of snapshot update flag for most no-breaking changes compatible solution Ignoring uncomitted elements from local async actions, updating store directly in updateScene Bound element issues - WIP
349 lines
9.6 KiB
TypeScript
349 lines
9.6 KiB
TypeScript
import { compressData, decompressData } from "../../src/data/encode";
|
|
import {
|
|
decryptData,
|
|
generateEncryptionKey,
|
|
IV_LENGTH_BYTES,
|
|
} from "../../src/data/encryption";
|
|
import { serializeAsJSON } from "../../src/data/json";
|
|
import { restore } from "../../src/data/restore";
|
|
import { ImportedDataState } from "../../src/data/types";
|
|
import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
|
|
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
|
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
|
import { t } from "../../src/i18n";
|
|
import {
|
|
AppState,
|
|
BinaryFileData,
|
|
BinaryFiles,
|
|
UserIdleState,
|
|
} from "../../src/types";
|
|
import { bytesToHexString } from "../../src/utils";
|
|
import {
|
|
DELETED_ELEMENT_TIMEOUT,
|
|
FILE_UPLOAD_MAX_BYTES,
|
|
ROOM_ID_BYTES,
|
|
} from "../app_constants";
|
|
import { encodeFilesForUpload } from "./FileManager";
|
|
import { saveFilesToFirebase } from "./firebase";
|
|
|
|
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
|
_brand: "SyncableExcalidrawElement";
|
|
};
|
|
|
|
export const isSyncableElement = (
|
|
element: ExcalidrawElement,
|
|
): element is SyncableExcalidrawElement => {
|
|
if (element.isDeleted) {
|
|
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
return !isInvisiblySmallElement(element);
|
|
};
|
|
|
|
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
|
elements.filter((element) =>
|
|
isSyncableElement(element),
|
|
) as SyncableExcalidrawElement[];
|
|
|
|
const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
|
|
const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
|
|
|
|
const generateRoomId = async () => {
|
|
const buffer = new Uint8Array(ROOM_ID_BYTES);
|
|
window.crypto.getRandomValues(buffer);
|
|
return bytesToHexString(buffer);
|
|
};
|
|
|
|
/**
|
|
* Right now the reason why we resolve connection params (url, polling...)
|
|
* from upstream is to allow changing the params immediately when needed without
|
|
* having to wait for clients to update the SW.
|
|
*
|
|
* If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks)
|
|
*/
|
|
export const getCollabServer = async (): Promise<{
|
|
url: string;
|
|
polling: boolean;
|
|
}> => {
|
|
if (import.meta.env.VITE_APP_WS_SERVER_URL) {
|
|
return {
|
|
url: import.meta.env.VITE_APP_WS_SERVER_URL,
|
|
polling: true,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(
|
|
`${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
|
|
);
|
|
return await resp.json();
|
|
} catch (error) {
|
|
console.error(error);
|
|
throw new Error(t("errors.cannotResolveCollabServer"));
|
|
}
|
|
};
|
|
|
|
export type EncryptedData = {
|
|
data: ArrayBuffer;
|
|
iv: Uint8Array;
|
|
};
|
|
|
|
export type SocketUpdateDataSource = {
|
|
SCENE_INIT: {
|
|
type: "SCENE_INIT";
|
|
payload: {
|
|
elements: readonly ExcalidrawElement[];
|
|
};
|
|
};
|
|
SCENE_UPDATE: {
|
|
type: "SCENE_UPDATE";
|
|
payload: {
|
|
elements: readonly ExcalidrawElement[];
|
|
};
|
|
};
|
|
MOUSE_LOCATION: {
|
|
type: "MOUSE_LOCATION";
|
|
payload: {
|
|
socketId: string;
|
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
|
button: "down" | "up";
|
|
selectedElementIds: AppState["selectedElementIds"];
|
|
username: string;
|
|
};
|
|
};
|
|
IDLE_STATUS: {
|
|
type: "IDLE_STATUS";
|
|
payload: {
|
|
socketId: string;
|
|
userState: UserIdleState;
|
|
username: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
export type SocketUpdateDataIncoming =
|
|
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
|
| {
|
|
type: "INVALID_RESPONSE";
|
|
};
|
|
|
|
export type SocketUpdateData =
|
|
SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
|
_brand: "socketUpdateData";
|
|
};
|
|
|
|
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
|
|
|
|
export const isCollaborationLink = (link: string) => {
|
|
const hash = new URL(link).hash;
|
|
return RE_COLLAB_LINK.test(hash);
|
|
};
|
|
|
|
export const getCollaborationLinkData = (link: string) => {
|
|
const hash = new URL(link).hash;
|
|
const match = hash.match(RE_COLLAB_LINK);
|
|
if (match && match[2].length !== 22) {
|
|
window.alert(t("alerts.invalidEncryptionKey"));
|
|
return null;
|
|
}
|
|
return match ? { roomId: match[1], roomKey: match[2] } : null;
|
|
};
|
|
|
|
export const generateCollaborationLinkData = async () => {
|
|
const roomId = await generateRoomId();
|
|
const roomKey = await generateEncryptionKey();
|
|
|
|
if (!roomKey) {
|
|
throw new Error("Couldn't generate room key");
|
|
}
|
|
|
|
return { roomId, roomKey };
|
|
};
|
|
|
|
export const getCollaborationLink = (data: {
|
|
roomId: string;
|
|
roomKey: string;
|
|
}) => {
|
|
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
|
};
|
|
|
|
/**
|
|
* Decodes shareLink data using the legacy buffer format.
|
|
* @deprecated
|
|
*/
|
|
const legacy_decodeFromBackend = async ({
|
|
buffer,
|
|
decryptionKey,
|
|
}: {
|
|
buffer: ArrayBuffer;
|
|
decryptionKey: string;
|
|
}) => {
|
|
let decrypted: ArrayBuffer;
|
|
|
|
try {
|
|
// Buffer should contain both the IV (fixed length) and encrypted data
|
|
const iv = buffer.slice(0, IV_LENGTH_BYTES);
|
|
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
|
|
decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
|
|
} catch (error: any) {
|
|
// Fixed IV (old format, backward compatibility)
|
|
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
|
|
decrypted = await decryptData(fixedIv, buffer, decryptionKey);
|
|
}
|
|
|
|
// We need to convert the decrypted array buffer to a string
|
|
const string = new window.TextDecoder("utf-8").decode(
|
|
new Uint8Array(decrypted),
|
|
);
|
|
const data: ImportedDataState = JSON.parse(string);
|
|
|
|
return {
|
|
elements: data.elements || null,
|
|
appState: data.appState || null,
|
|
};
|
|
};
|
|
|
|
const importFromBackend = async (
|
|
id: string,
|
|
decryptionKey: string,
|
|
): Promise<ImportedDataState> => {
|
|
try {
|
|
const response = await fetch(`${BACKEND_V2_GET}${id}`);
|
|
|
|
if (!response.ok) {
|
|
window.alert(t("alerts.importBackendFailed"));
|
|
return {};
|
|
}
|
|
const buffer = await response.arrayBuffer();
|
|
|
|
try {
|
|
const { data: decodedBuffer } = await decompressData(
|
|
new Uint8Array(buffer),
|
|
{
|
|
decryptionKey,
|
|
},
|
|
);
|
|
const data: ImportedDataState = JSON.parse(
|
|
new TextDecoder().decode(decodedBuffer),
|
|
);
|
|
|
|
return {
|
|
elements: data.elements || null,
|
|
appState: data.appState || null,
|
|
};
|
|
} catch (error: any) {
|
|
console.warn(
|
|
"error when decoding shareLink data using the new format:",
|
|
error,
|
|
);
|
|
return legacy_decodeFromBackend({ buffer, decryptionKey });
|
|
}
|
|
} catch (error: any) {
|
|
window.alert(t("alerts.importBackendFailed"));
|
|
console.error(error);
|
|
return {};
|
|
}
|
|
};
|
|
|
|
export const loadScene = async (
|
|
id: string | null,
|
|
privateKey: string | null,
|
|
// Supply local state even if importing from backend to ensure we restore
|
|
// localStorage user settings which we do not persist on server.
|
|
// Non-optional so we don't forget to pass it even if `undefined`.
|
|
localDataState: ImportedDataState | undefined | null,
|
|
) => {
|
|
let data;
|
|
if (id != null && privateKey != null) {
|
|
// the private key is used to decrypt the content from the server, take
|
|
// extra care not to leak it
|
|
data = restore(
|
|
await importFromBackend(id, privateKey),
|
|
localDataState?.appState,
|
|
localDataState?.elements,
|
|
{ repairBindings: true, refreshDimensions: false },
|
|
);
|
|
} else {
|
|
data = restore(localDataState || null, null, null, {
|
|
repairBindings: true,
|
|
});
|
|
}
|
|
|
|
return {
|
|
elements: data.elements,
|
|
appState: data.appState,
|
|
// note: this will always be empty because we're not storing files
|
|
// in the scene database/localStorage, and instead fetch them async
|
|
// from a different database
|
|
files: data.files,
|
|
commitToStore: false,
|
|
};
|
|
};
|
|
|
|
type ExportToBackendResult =
|
|
| { url: null; errorMessage: string }
|
|
| { url: string; errorMessage: null };
|
|
|
|
export const exportToBackend = async (
|
|
elements: readonly ExcalidrawElement[],
|
|
appState: Partial<AppState>,
|
|
files: BinaryFiles,
|
|
): Promise<ExportToBackendResult> => {
|
|
const encryptionKey = await generateEncryptionKey("string");
|
|
|
|
const payload = await compressData(
|
|
new TextEncoder().encode(
|
|
serializeAsJSON(elements, appState, files, "database"),
|
|
),
|
|
{ encryptionKey },
|
|
);
|
|
|
|
try {
|
|
const filesMap = new Map<FileId, BinaryFileData>();
|
|
for (const element of elements) {
|
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
|
filesMap.set(element.fileId, files[element.fileId]);
|
|
}
|
|
}
|
|
|
|
const filesToUpload = await encodeFilesForUpload({
|
|
files: filesMap,
|
|
encryptionKey,
|
|
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
|
});
|
|
|
|
const response = await fetch(BACKEND_V2_POST, {
|
|
method: "POST",
|
|
body: payload.buffer,
|
|
});
|
|
const json = await response.json();
|
|
if (json.id) {
|
|
const url = new URL(window.location.href);
|
|
// We need to store the key (and less importantly the id) as hash instead
|
|
// of queryParam in order to never send it to the server
|
|
url.hash = `json=${json.id},${encryptionKey}`;
|
|
const urlString = url.toString();
|
|
|
|
await saveFilesToFirebase({
|
|
prefix: `/files/shareLinks/${json.id}`,
|
|
files: filesToUpload,
|
|
});
|
|
|
|
return { url: urlString, errorMessage: null };
|
|
} else if (json.error_class === "RequestTooLargeError") {
|
|
return {
|
|
url: null,
|
|
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
|
|
};
|
|
}
|
|
|
|
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
|
|
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
|
}
|
|
};
|