mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-04 22:32:55 +00:00
init frontend
This commit is contained in:
@@ -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;
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
Reference in New Issue
Block a user