init frontend

This commit is contained in:
Yuzhong Zhang
2025-07-05 23:22:48 +08:00
parent 94953a5eac
commit 602f4629ff
771 changed files with 194268 additions and 1 deletions
@@ -0,0 +1,242 @@
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
BinaryFiles,
} from "../../packages/excalidraw/types";
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/** files being saved */
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private _getFiles;
private _saveFiles;
constructor({
getFiles,
saveFiles,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
}
/**
* returns whether file is already saved or being processed
*/
isFileHandled = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
);
};
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
};
saveFiles = async ({
elements,
files,
}: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
}) => {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
if (
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, true);
}
}
try {
const { savedFiles, erroredFiles } = await this._saveFiles({
addedFiles,
});
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
}
return {
savedFiles,
erroredFiles,
};
} finally {
for (const [fileId] of addedFiles) {
this.savingFiles.delete(fileId);
}
}
};
getFiles = async (
ids: FileId[],
): Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}> => {
if (!ids.length) {
return {
loadedFiles: [],
erroredFiles: new Map(),
};
}
for (const id of ids) {
this.fetchingFiles.set(id, true);
}
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, true);
}
for (const [fileId] of erroredFiles) {
this.erroredFiles.set(fileId, true);
}
return { loadedFiles, erroredFiles };
} finally {
for (const id of ids) {
this.fetchingFiles.delete(id);
}
}
};
/** a file element prevents unload only if it's being saved regardless of
* its `status`. This ensures that elements who for any reason haven't
* beed set to `saved` status don't prevent unload in future sessions.
* Technically we should prevent unload when the origin client haven't
* yet saved the `status` update to storage, but that should be taken care
* of during regular beforeUnload unsaved files check.
*/
shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
return elements.some((element) => {
return (
isInitializedImageElement(element) &&
!element.isDeleted &&
this.savingFiles.has(element.fileId)
);
});
};
/**
* helper to determine if image element status needs updating
*/
shouldUpdateImageElementStatus = (
element: ExcalidrawElement,
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.isFileSaved(element.fileId) &&
element.status === "pending"
);
};
reset() {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles.clear();
}
}
export const encodeFilesForUpload = async ({
files,
maxBytes,
encryptionKey,
}: {
files: Map<FileId, BinaryFileData>;
maxBytes: number;
encryptionKey: string;
}) => {
const processedFiles: {
id: FileId;
buffer: Uint8Array;
}[] = [];
for (const [id, fileData] of files) {
const buffer = new TextEncoder().encode(fileData.dataURL);
const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
encryptionKey,
metadata: {
id,
mimeType: fileData.mimeType,
created: Date.now(),
lastRetrieved: Date.now(),
},
});
if (buffer.byteLength > maxBytes) {
throw new Error(
t("errors.fileTooBig", {
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
}),
);
}
processedFiles.push({
id,
buffer: encodedFile,
});
}
return processedFiles;
};
export const updateStaleImageStatuses = (params: {
excalidrawAPI: ExcalidrawImperativeAPI;
erroredFiles: Map<FileId, true>;
elements: readonly ExcalidrawElement[];
}) => {
if (!params.erroredFiles.size) {
return;
}
params.excalidrawAPI.updateScene({
elements: params.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
isInitializedImageElement(element) &&
params.erroredFiles.has(element.fileId)
) {
return newElementWith(element, {
status: "error",
});
}
return element;
}),
});
};
+245
View File
@@ -0,0 +1,245 @@
/**
* This file deals with saving data state (appState, elements, images, ...)
* locally to the browser.
*
* Notes:
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different storage
* (localStorage, indexedDB).
*/
import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
// if image is unused (not on canvas) & is older than 1 day, delete it
// from storage. We check `lastRetrieved` we care about the last time
// the image was used (loaded on canvas), not when it was initially
// created.
if (
(!imageData.lastRetrieved ||
Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
!opts.currentFileIds.includes(id as FileId)
) {
del(id, filesStore);
}
}
});
};
}
const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
type SavingLockTypes = "collaboration";
export class LocalData {
private static _save = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
/** Saves DataState, including files. Bails if saving is paused */
static save = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
// we need to make the `isSavePaused` check synchronously (undebounced)
if (!this.isSavePaused()) {
this._save(elements, appState, files, onFilesSaved);
}
};
static flushSave = () => {
this._save.flush();
};
private static locker = new Locker<SavingLockTypes>();
static pauseSave = (lockType: SavingLockTypes) => {
this.locker.lock(lockType);
};
static resumeSave = (lockType: SavingLockTypes) => {
this.locker.unlock(lockType);
};
static isSavePaused = () => {
return document.hidden || this.locker.isLocked();
};
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
const filesToSave: [FileId, BinaryFileData][] = [];
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
const _data: BinaryFileData = {
...data,
lastRetrieved: Date.now(),
};
filesToSave.push([id, _data]);
loadedFiles.push(_data);
} else {
erroredFiles.set(id, true);
}
});
try {
// save loaded files back to storage with updated `lastRetrieved`
setMany(filesToSave, filesStore);
} catch (error) {
console.warn(error);
}
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
}
export class LibraryIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
/** library data store key */
private static key = "libraryData";
private static store = createStore(
`${LibraryIndexedDBAdapter.idb_name}-db`,
`${LibraryIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<LibraryPersistedData>(
LibraryIndexedDBAdapter.key,
LibraryIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: LibraryPersistedData): MaybePromise<void> {
return set(
LibraryIndexedDBAdapter.key,
data,
LibraryIndexedDBAdapter.store,
);
}
}
/** LS Adapter used only for migrating LS library data
* to indexedDB */
export class LibraryLocalStorageMigrationAdapter {
static load() {
const LSData = localStorage.getItem(
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
);
if (LSData != null) {
const libraryItems: ImportedDataState["libraryItems"] =
JSON.parse(LSData);
if (libraryItems) {
return { libraryItems };
}
}
return null;
}
static clear() {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}
+18
View File
@@ -0,0 +1,18 @@
export class Locker<T extends string> {
private locks = new Map<T, true>();
lock = (lockType: T) => {
this.locks.set(lockType, true);
};
/** @returns whether no locks remaining */
unlock = (lockType: T) => {
this.locks.delete(lockType);
return !this.isLocked();
};
/** @returns whether some (or specific) locks are present */
isLocked(lockType?: T) {
return lockType ? this.locks.has(lockType) : !!this.locks.size;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NonDeletedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { BinaryFiles } from "../../packages/excalidraw/types";
// NOTE: This type is an ad-hoc implementation of the one in
// `@excalidraw/mermaid-to-excalidraw`. We are defining it here to avoid
// dependency issues.
export interface MermaidToExcalidrawResult {
code: string;
title: string;
theme: string;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
errors: any[];
isFirstSuccessfulImport: boolean;
}
const systemPrompt = `目的和目标:
* 理解用户提供的文档的结构和逻辑关系。
* 准确地将文档内容和关系转化为符合mermaid语法的图表代码。
* 确保图表中包含文档的所有关键元素和它们之间的联系。
行为和规则:
1. 分析文档:
a) 仔细阅读和分析用户提供的文档内容。
b) 识别文档中的不同元素(如概念、实体、步骤、流程等)。
c) 理解这些元素之间的各种关系(如从属、包含、流程、因果等)。
d) 识别文档中蕴含的逻辑结构和流程。
2. 图表生成:
a) 根据分析结果,选择最适合表达文档结构的mermaid图表类型(如流程图、时序图、状态图、甘特图等)。
b) 使用正确的mermaid语法创建图表代码,充分参考下面的Mermaid 语法特殊字符说明:"
* Mermaid 的核心特殊字符主要用于**定义图表结构和关系**。
* 要在节点 ID 或标签中**显示**这些特殊字符或包含**空格**,最常用方法是用**双引号 ""** 包裹。
* 在标签文本(引号内)中显示 HTML 特殊字符 (<, >, &) 或 # 等,应使用 **HTML 实体编码**。
* 要在标签内**换行**,使用 <br> 标签。
* 使用 %% 进行**注释**。
"
c) 确保图表清晰、易于理解,准确反映文档的内容和逻辑。
3. 细节处理:
a) 避免遗漏文档中的任何重要细节或关系。
b) 如果文档中存在不明确或多义性的内容,可以向用户提问以获取更清晰的信息。
c) 生成的图表代码应可以直接复制并粘贴到支持mermaid语法的工具或平台中使用。
整体语气:
* 保持专业和严谨的态度。
* 清晰、准确地表达图表的内容。
* 在需要时,可以提供简短的解释或建议。`;
const buildOpenAIPayload = (input: string, modelName: string) => {
return {
model: modelName,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: input,
},
],
};
};
export const generateMermaidCode = async (
input: string,
apiKey: string,
apiUrl: string,
modelName: string,
): Promise<MermaidToExcalidrawResult> => {
const payload = buildOpenAIPayload(input, modelName);
const url = `${apiUrl}/chat/completions`;
const isRelativePath = url.startsWith("/");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${
isRelativePath ? localStorage.getItem("token") || apiKey : apiKey
}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error.message || "OpenAI API request failed");
}
const data = await response.json();
const mermaidCode = data.choices[0]?.message?.content;
if (!mermaidCode) {
throw new Error("Failed to generate Mermaid code from OpenAI.");
}
// NOTE: a bit of a hack. The result of this function is what the TTD dialog
// expects. We are returning the mermaid code as if it were the result of
// a mermaid-to-excalidraw conversion, so the dialog can render it.
return {
code: mermaidCode,
title: "AI Generated Diagram",
theme: "light",
elements: [],
files: {},
errors: [],
isFirstSuccessfulImport: true,
};
};
+356
View File
@@ -0,0 +1,356 @@
import {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../packages/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../packages/excalidraw/data/encode";
import {
encryptData,
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
// private
// -----------------------------------------------------------------------------
let FIREBASE_CONFIG: Record<string, any>;
try {
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
} catch (error: any) {
console.warn(
`Error JSON parsing firebase config. Supplied value: ${
import.meta.env.VITE_APP_FIREBASE_CONFIG
}`,
);
FIREBASE_CONFIG = {};
}
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
if (!isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR)
if (error.code === "app/duplicate-app") {
console.warn(error.name, error.code);
} else {
throw error;
}
}
isFirebaseInitialized = true;
}
return firebase;
};
const _getFirebase = async (): Promise<
typeof import("firebase/app").default
> => {
if (!firebasePromise) {
firebasePromise = _loadFirebase();
}
return firebasePromise;
};
// -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebaseStoragePromise) {
firebaseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
}
if (firebaseStoragePromise !== true) {
await firebaseStoragePromise;
firebaseStoragePromise = true;
}
return firebase;
};
interface FirebaseStoredScene {
sceneVersion: number;
iv: firebase.default.firestore.Blob;
ciphertext: firebase.default.firestore.Blob;
}
const encryptElements = async (
key: string,
elements: readonly ExcalidrawElement[],
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
const json = JSON.stringify(elements);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(key, encoded);
return { ciphertext: encryptedBuffer, iv };
};
const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
};
class FirebaseSceneVersionCache {
private static cache = new WeakMap<Socket, number>();
static get = (socket: Socket) => {
return FirebaseSceneVersionCache.cache.get(socket);
};
static set = (
socket: Socket,
elements: readonly SyncableExcalidrawElement[],
) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
};
}
export const isSavedToFirebase = (
portal: Portal,
elements: readonly ExcalidrawElement[],
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
// prevent unload (there's nothing we could do at that point anyway)
return true;
};
export const saveFilesToFirebase = async ({
prefix,
files,
}: {
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
await firebase
.storage()
.ref(`${prefix}/${id}`)
.put(
new Blob([buffer], {
type: MIME_TYPES.binary,
}),
{
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.set(id, true);
} catch (error: any) {
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
};
const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[],
roomKey: string,
) => {
const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene;
};
export const saveToFirebase = async (
portal: Portal,
elements: readonly SyncableExcalidrawElement[],
appState: AppState,
) => {
const { roomId, roomKey, socket } = portal;
if (
// bail if no room exists as there's nothing we can do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return false;
}
const firebase = await loadFirestore();
const firestore = firebase.firestore();
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
return {
elements,
reconciledElements: null,
};
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements(
await decryptElements(prevDocData, roomKey),
);
const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState),
);
const sceneDocument = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
elements,
reconciledElements,
};
});
FirebaseSceneVersionCache.set(socket, savedData.elements);
return { reconciledElements: savedData.reconciledElements };
};
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);
const doc = await docRef.get();
if (!doc.exists) {
return null;
}
const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements(
await decryptElements(storedScene, roomKey),
);
if (socket) {
FirebaseSceneVersionCache.set(socket, elements);
}
return restoreElements(elements, null);
};
export const loadFilesFromFirebase = async (
prefix: string,
decryptionKey: string,
filesIds: readonly FileId[],
) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
await Promise.all(
[...new Set(filesIds)].map(async (id) => {
try {
const url = `https://firebasestorage.googleapis.com/v0/b/${
FIREBASE_CONFIG.storageBucket
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
const response = await fetch(`${url}?alt=media`);
if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer();
const { data, metadata } = await decompressData<BinaryFileMetadata>(
new Uint8Array(arrayBuffer),
{
decryptionKey,
},
);
const dataURL = new TextDecoder().decode(data) as DataURL;
loadedFiles.push({
mimeType: metadata.mimeType || MIME_TYPES.binary,
id,
dataURL,
created: metadata?.created || Date.now(),
lastRetrieved: metadata?.created || Date.now(),
});
} else {
erroredFiles.set(id, true);
}
} catch (error: any) {
erroredFiles.set(id, true);
console.error(error);
}
}),
);
return { loadedFiles, erroredFiles };
};
+342
View File
@@ -0,0 +1,342 @@
import {
compressData,
decompressData,
} from "../../packages/excalidraw/data/encode";
import {
decryptData,
generateEncryptionKey,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
WS_SUBTYPES,
} 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);
};
export type EncryptedData = {
data: ArrayBuffer;
iv: Uint8Array;
};
export type SocketUpdateDataSource = {
INVALID_RESPONSE: {
type: WS_SUBTYPES.INVALID_RESPONSE;
};
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly ExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly ExcalidrawElement[];
};
};
MOUSE_LOCATION: {
type: WS_SUBTYPES.MOUSE_LOCATION;
payload: {
socketId: SocketId;
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
};
};
USER_VISIBLE_SCENE_BOUNDS: {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
payload: {
socketId: SocketId;
username: string;
sceneBounds: SceneBounds;
};
};
IDLE_STATUS: {
type: WS_SUBTYPES.IDLE_STATUS;
payload: {
socketId: SocketId;
userState: UserIdleState;
username: string;
};
};
};
export type SocketUpdateDataIncoming =
SocketUpdateDataSource[keyof SocketUpdateDataSource];
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) => {
if (!link) {
return false;
}
const hash = new URL(link).hash;
return RE_COLLAB_LINK.test(hash);
};
export const getCollaborationLinkData = (link: string) => {
if (!link) {
return null;
}
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,
commitToHistory: 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 as ArrayBuffer,
});
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") };
}
};
@@ -0,0 +1,99 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
JSON.stringify({ username }),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const importUsernameFromLocalStorage = (): string | null => {
try {
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
if (data) {
return JSON.parse(data).username;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
export const importFromLocalStorage = () => {
let savedElements = null;
let savedState = null;
try {
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
} catch (error: any) {
console.error(error);
// Do nothing because elements array is already empty
}
}
let appState = null;
if (savedState) {
try {
appState = {
...getDefaultAppState(),
...clearAppStateForLocalStorage(
JSON.parse(savedState) as Partial<AppState>,
),
};
} catch (error: any) {
console.error(error);
// Do nothing because appState is already null
}
}
return { elements, appState };
};
export const getElementsStorageSize = () => {
try {
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
const elementsSize = elements?.length || 0;
return elementsSize;
} catch (error: any) {
console.error(error);
return 0;
}
};
export const getTotalStorageSize = () => {
try {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
return appStateSize + collabSize + getElementsStorageSize();
} catch (error: any) {
console.error(error);
return 0;
}
};
+121
View File
@@ -0,0 +1,121 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import {
AppState,
BinaryFiles,
Collaborator,
SocketId,
} from "../../packages/excalidraw/types";
/**
* Describes the metadata of a canvas.
*/
export interface CanvasMetadata {
id: string;
name: string;
createdAt: string;
updatedAt: string;
userId: number;
thumbnail?: string;
}
/**
* Encapsulates the complete data for a single canvas.
*/
export interface CanvasData {
elements: readonly ExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
thumbnail?: string;
}
/**
* Defines the contract for all storage adapters.
* Any storage backend (default server, Cloudflare KV, S3, etc.)
* must implement this interface to be compatible with the application.
*/
export interface IStorageAdapter {
/**
* Lists all canvases available for the current user.
*/
listCanvases(): Promise<CanvasMetadata[]>;
/**
* Loads a single canvas's data.
* @param id The unique identifier of the canvas to load.
* @returns The canvas data, or null if not found.
*/
loadCanvas(id: string): Promise<CanvasData | null>;
/**
* Saves a canvas's data. This is typically used for updating an existing canvas.
* @param id The unique identifier of the canvas to save.
* @param data The complete data of the canvas.
*/
saveCanvas(id: string, data: CanvasData): Promise<void>;
/**
* Creates a new canvas.
* @param data The initial data for the new canvas.
* @returns The metadata of the newly created canvas.
*/
createCanvas(data: CanvasData): Promise<CanvasMetadata>;
/**
* Deletes a canvas.
* @param id The unique identifier of the canvas to delete.
*/
deleteCanvas(id: string): Promise<void>;
/**
* Renames a canvas.
* @param id The unique identifier of the canvas to rename.
* @param newName The new name for the canvas.
*/
renameCanvas(id: string, newName: string): Promise<void>;
}
/**
* Converts a raw JSON object into a CanvasData object, ensuring complex types
* like Map are correctly instantiated.
* @param data The raw data from the API.
*/
export const hydrateCanvasData = (data: any): CanvasData => {
const canvasData: CanvasData = { ...data };
// Ensure collaborators is a Map, not an object.
if (
canvasData.appState &&
canvasData.appState.collaborators &&
!(canvasData.appState.collaborators instanceof Map)
) {
canvasData.appState.collaborators = new Map(
Object.entries(
canvasData.appState.collaborators as { [key: string]: Collaborator },
).map(([key, value]) => [key as SocketId, value]),
);
} else if (canvasData.appState && !canvasData.appState.collaborators) {
// Ensure collaborators is at least an empty Map if it's missing.
canvasData.appState.collaborators = new Map();
}
return canvasData;
};
/**
* Prepares canvas data for JSON serialization, converting complex types like Map
* into plain objects.
* @param data The CanvasData object to dehydrate.
*/
export const dehydrateCanvasData = (data: CanvasData) => {
const dehydratedData = {
...data,
appState: {
...data.appState,
collaborators:
data.appState.collaborators instanceof Map
? Object.fromEntries(data.appState.collaborators)
: data.appState.collaborators,
},
};
return dehydratedData;
};
@@ -0,0 +1,166 @@
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
import { nanoid } from "nanoid";
import { jwtDecode } from "jwt-decode";
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
const API_BASE_URL = "/api/v2/kv";
interface AppJwtPayload {
sub: string;
}
// The backend uses the GitHub user ID as the subject in the JWT.
// We can decode the token to get this ID for frontend purposes.
function getUserIdFromJwt(token: string): number | null {
try {
const decodedToken = jwtDecode<AppJwtPayload>(token);
if (decodedToken && decodedToken.sub) {
const userId = parseInt(decodedToken.sub, 10);
if (!isNaN(userId) && Number.isInteger(userId)) {
return userId;
}
}
return null;
} catch (e) {
console.error("Failed to decode JWT", e);
return null;
}
}
const getAuthHeaders = () => {
const token = localStorage.getItem("token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
};
export class BackendStorageAdapter implements IStorageAdapter {
async listCanvases(): Promise<CanvasMetadata[]> {
const response = await fetch(API_BASE_URL, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// For list, we can just return an empty array as if the user has no canvases.
// This prevents an error popup when a logged-out user opens the app.
return [];
}
throw new Error(`Failed to list canvases: ${response.statusText}`);
}
// Backend doesn't send userId, so we enrich the data here.
const canvases: Omit<CanvasMetadata, "userId">[] = await response.json();
const token = localStorage.getItem("token");
if (!token) {
return [];
}
const userId = getUserIdFromJwt(token);
if (!userId) {
console.error("Could not determine userId from token.");
return [];
}
return canvases.map((canvas) => ({ ...canvas, userId }));
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "GET",
headers: getAuthHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to load canvas: ${response.statusText}`);
}
const rawData = await response.json();
return hydrateCanvasData(rawData);
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const saveData = dehydrateCanvasData(data);
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(saveData),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to save canvas: ${response.statusText}`);
}
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = nanoid();
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Authentication token not found.");
}
const userId = getUserIdFromJwt(token);
if (!userId) {
throw new Error("Could not parse user ID from token.");
}
await this.saveCanvas(newId, data);
return {
id: newId,
name: data.appState?.name || "Untitled",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId,
};
}
async deleteCanvas(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to delete canvas: ${response.statusText}`);
}
}
async renameCanvas(id: string, newName: string): Promise<void> {
const canvasData = await this.loadCanvas(id);
if (!canvasData) {
throw new Error("Canvas not found, cannot rename.");
}
const updatedData: CanvasData = {
...canvasData,
appState: {
...canvasData.appState,
name: newName,
},
};
await this.saveCanvas(id, updatedData);
}
}
@@ -0,0 +1,230 @@
import { generateThumbnail } from "../thumbnail";
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta:";
const KEY_PREFIX_DATA = "excalidraw-canvas-data:";
export class CloudflareKVAdapter implements IStorageAdapter {
private kv_url: string;
private apiToken: string;
private baseUrl: string;
constructor(config: { kv_url: string; apiToken: string }) {
this.kv_url = config.kv_url;
this.apiToken = config.apiToken;
this.baseUrl = `https://${this.kv_url}`;
}
private getAuthHeaders() {
return {
Authorization: `Bearer ${this.apiToken}`,
"Content-Type": "application/json",
};
}
async listCanvases(): Promise<CanvasMetadata[]> {
const response = await fetch(
`${this.baseUrl}/keys?prefix=${KEY_PREFIX_METADATA}`,
{
headers: this.getAuthHeaders(),
},
);
if (!response.ok) {
console.error(
"Failed to list canvases from Cloudflare KV",
await response.text(),
);
throw new Error("Failed to list canvases from Cloudflare KV.");
}
const { result: keys } = (await response.json()) as {
result: { name: string }[];
};
if (!keys || keys.length === 0) {
return [];
}
const metadataPromises = keys.map((key) =>
this.getCanvasMetadata(key.name),
);
const metadata = await Promise.all(metadataPromises);
// Filter out any nulls that might have occurred if a key was deleted between listing and fetching
return metadata.filter((m): m is CanvasMetadata => m !== null);
}
private async getCanvasMetadata(key: string): Promise<CanvasMetadata | null> {
const response = await fetch(`${this.baseUrl}/values/${key}`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
console.error(
`Failed to fetch metadata for key ${key}`,
await response.text(),
);
return null;
}
return response.json();
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const key = `${KEY_PREFIX_DATA}${id}`;
const response = await fetch(`${this.baseUrl}/values/${key}`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`Failed to load canvas ${id} from Cloudflare KV.`);
}
const rawData = await response.json();
return hydrateCanvasData(rawData);
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const existingMetadata = await this.getCanvasMetadata(metadataKey);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const dehydratedData = dehydrateCanvasData(data);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error(`Failed to save canvas ${id} to Cloudflare KV.`);
}
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
// Assuming userId comes from a context, hardcoding for now
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
const dehydratedData = dehydrateCanvasData(data);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(newMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error("Failed to create canvas in Cloudflare KV.");
}
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
const keysToDelete = [
`${KEY_PREFIX_METADATA}${id}`,
`${KEY_PREFIX_DATA}${id}`,
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "DELETE",
headers: this.getAuthHeaders(),
body: JSON.stringify(keysToDelete),
});
if (!response.ok) {
throw new Error(`Failed to delete canvas ${id} from Cloudflare KV.`);
}
}
async renameCanvas(id: string, newName: string): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const [metadata, data] = await Promise.all([
this.getCanvasMetadata(metadataKey),
this.loadCanvas(id),
]);
if (!metadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
if (!data) {
throw new Error("Canvas data not found. Cannot rename.");
}
const updatedMetadata: CanvasMetadata = { ...metadata, name: newName };
const updatedData: CanvasData = {
...data,
appState: { ...data.appState, name: newName },
};
const dehydratedData = dehydrateCanvasData(updatedData);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error(`Failed to rename canvas ${id} in Cloudflare KV.`);
}
}
}
@@ -0,0 +1,94 @@
import { createStore, set, get, del, entries } from "idb-keyval";
import { CanvasData, CanvasMetadata, IStorageAdapter } from "../storage";
import { generateThumbnail } from "../thumbnail";
const metadataStore = createStore("excalidraw-canvases-metadata", "metadata");
const dataStore = createStore("excalidraw-canvases-data", "data");
export class IndexedDBStorageAdapter implements IStorageAdapter {
async listCanvases(): Promise<CanvasMetadata[]> {
const allEntries = await entries<string, CanvasMetadata>(metadataStore);
return allEntries.map(([, metadata]) => metadata);
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const data = await get<CanvasData>(id, dataStore);
return data === undefined ? null : data;
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
await set(id, updatedMetadata, metadataStore);
await set(id, data, dataStore);
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
// UserID is 0 for local, non-synced canvases
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
await set(newId, newMetadata, metadataStore);
await set(newId, data, dataStore);
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
await del(id, metadataStore);
await del(id, dataStore);
}
async renameCanvas(id: string, newName: string): Promise<void> {
// Update metadata
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
await set(id, { ...existingMetadata, name: newName }, metadataStore);
// Update canvas data
const existingData = await get<CanvasData>(id, dataStore);
if (!existingData) {
// This should not happen if metadata exists, but as a safeguard:
throw new Error("Canvas data not found. Cannot rename.");
}
await set(
id,
{
...existingData,
appState: { ...existingData.appState, name: newName },
},
dataStore,
);
}
}
@@ -0,0 +1,197 @@
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
DeleteObjectsCommand,
} from "@aws-sdk/client-s3";
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
import { generateThumbnail } from "../thumbnail";
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta-";
const KEY_PREFIX_DATA = "excalidraw-canvas-data-";
export class S3StorageAdapter implements IStorageAdapter {
private s3: S3Client;
private bucketName: string;
constructor(config: {
accessKeyId: string;
secretAccessKey: string;
region: string;
bucketName: string;
}) {
this.s3 = new S3Client({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
this.bucketName = config.bucketName;
}
private async getObject(key: string): Promise<any | null> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3.send(command);
const body = await response.Body?.transformToString();
return body ? JSON.parse(body) : null;
} catch (error: any) {
if (error.name === "NoSuchKey") {
return null;
}
throw error;
}
}
async listCanvases(): Promise<CanvasMetadata[]> {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: KEY_PREFIX_METADATA,
});
const response = await this.s3.send(command);
if (!response.Contents) {
return [];
}
const metadataPromises = response.Contents.map((obj: { Key?: string }) =>
this.getObject(obj.Key!),
);
const results = await Promise.all(metadataPromises);
return results.filter(
(m: CanvasMetadata | null): m is CanvasMetadata => m !== null,
);
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const rawData = await this.getObject(`${KEY_PREFIX_DATA}${id}`);
return rawData ? hydrateCanvasData(rawData) : null;
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const existingMetadata = await this.getObject(metadataKey);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const dehydratedData = dehydrateCanvasData(data);
await Promise.all([
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: metadataKey,
Body: JSON.stringify(updatedMetadata),
ContentType: "application/json",
}),
),
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: dataKey,
Body: JSON.stringify(dehydratedData),
ContentType: "application/json",
}),
),
]);
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
const dehydratedData = dehydrateCanvasData(data);
await Promise.all([
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: metadataKey,
Body: JSON.stringify(newMetadata),
ContentType: "application/json",
}),
),
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: dataKey,
Body: JSON.stringify(dehydratedData),
ContentType: "application/json",
}),
),
]);
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
const command = new DeleteObjectsCommand({
Bucket: this.bucketName,
Delete: {
Objects: [
{ Key: `${KEY_PREFIX_METADATA}${id}` },
{ Key: `${KEY_PREFIX_DATA}${id}` },
],
},
});
await this.s3.send(command);
}
async renameCanvas(id: string, newName: string): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const metadata = await this.getObject(metadataKey);
if (!metadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
metadata.name = newName;
metadata.updatedAt = new Date().toISOString();
const data = await this.loadCanvas(id);
if (!data) {
throw new Error("Canvas data not found. Cannot rename.");
}
data.appState.name = newName;
await this.saveCanvas(id, data);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { STORAGE_KEYS } from "../app_constants";
// in-memory state (this tab's current state) versions. Currently just
// timestamps of the last time the state was saved to browser storage.
const LOCAL_STATE_VERSIONS = {
[STORAGE_KEYS.VERSION_DATA_STATE]: -1,
[STORAGE_KEYS.VERSION_FILES]: -1,
};
type BrowserStateTypes = keyof typeof LOCAL_STATE_VERSIONS;
export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
const storageTimestamp = JSON.parse(localStorage.getItem(type) || "-1");
return storageTimestamp > LOCAL_STATE_VERSIONS[type];
};
export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
const timestamp = Date.now();
try {
localStorage.setItem(type, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[type] = timestamp;
} catch (error) {
console.error("error while updating browser state verison", error);
}
};
export const resetBrowserStateVersions = () => {
try {
for (const key of Object.keys(
LOCAL_STATE_VERSIONS,
) as BrowserStateTypes[]) {
const timestamp = -1;
localStorage.setItem(key, JSON.stringify(timestamp));
LOCAL_STATE_VERSIONS[key] = timestamp;
}
} catch (error) {
console.error("error while resetting browser state verison", error);
}
};
@@ -0,0 +1,46 @@
import { exportToCanvas } from "../../packages/utils/export";
import { AppState, BinaryFiles } from "../../packages/excalidraw/types";
import { NonDeletedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { DEFAULT_EXPORT_PADDING } from "../../packages/excalidraw/constants";
const THUMBNAIL_WIDTH = 200;
const THUMBNAIL_HEIGHT = 200;
export const generateThumbnail = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
): Promise<string> => {
const canvas = await exportToCanvas({
elements,
appState,
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT),
});
const SvgCanvas = document.createElement("canvas");
SvgCanvas.width = THUMBNAIL_WIDTH;
SvgCanvas.height = THUMBNAIL_HEIGHT;
const SvgCanvasContext = SvgCanvas.getContext("2d")!;
const sourceAspectRatio = canvas.width / canvas.height;
const targetAspectRatio = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT;
let drawWidth = THUMBNAIL_WIDTH;
let drawHeight = THUMBNAIL_HEIGHT;
let drawX = 0;
let drawY = 0;
if (sourceAspectRatio > targetAspectRatio) {
drawHeight = THUMBNAIL_WIDTH / sourceAspectRatio;
drawY = (THUMBNAIL_HEIGHT - drawHeight) / 2;
} else {
drawWidth = THUMBNAIL_HEIGHT * sourceAspectRatio;
drawX = (THUMBNAIL_WIDTH - drawWidth) / 2;
}
SvgCanvasContext.drawImage(canvas, drawX, drawY, drawWidth, drawHeight);
return SvgCanvas.toDataURL("image/png");
};