mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-05 06:32:56 +00:00
init frontend
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../packages/excalidraw/utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import { UIAppState } from "../packages/excalidraw/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
}, STORAGE_SIZE_TIMEOUT);
|
||||
|
||||
type Props = {
|
||||
setToast: (message: string) => void;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
};
|
||||
const CustomStats = (props: Props) => {
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
}, [props.elements, props.appState]);
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
const version = getVersion();
|
||||
let hash;
|
||||
let timestamp;
|
||||
|
||||
if (version !== DEFAULT_VERSION) {
|
||||
timestamp = version.slice(0, 16).replace("T", " ");
|
||||
hash = version.slice(21);
|
||||
} else {
|
||||
timestamp = t("stats.versionNotAvailable");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.storage")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.scene")}</td>
|
||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.version")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{ textAlign: "center", cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(getVersion());
|
||||
props.setToast(t("toast.copyToClipboard"));
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomStats;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../packages/excalidraw/jotai";
|
||||
import { StorageType } from "./components/StorageSettingsDialog";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
githubId: number;
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export const userAtom = atom<User | null>(null);
|
||||
|
||||
const baseCurrentCanvasIdAtom = atom<string | null>(
|
||||
localStorage.getItem("excalidraw-current-canvas-id"),
|
||||
);
|
||||
|
||||
export const currentCanvasIdAtom = atom(
|
||||
(get) => get(baseCurrentCanvasIdAtom),
|
||||
(get, set, newId: string | null) => {
|
||||
set(baseCurrentCanvasIdAtom, newId);
|
||||
if (newId) {
|
||||
localStorage.setItem("excalidraw-current-canvas-id", newId);
|
||||
} else {
|
||||
localStorage.removeItem("excalidraw-current-canvas-id");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Storage Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface StorageConfig {
|
||||
type: StorageType;
|
||||
// Cloudflare KV
|
||||
kvUrl?: string;
|
||||
kvApiToken?: string;
|
||||
// AWS S3
|
||||
s3AccessKeyId?: string;
|
||||
s3SecretAccessKey?: string;
|
||||
s3Region?: string;
|
||||
s3BucketName?: string;
|
||||
}
|
||||
|
||||
const STORAGE_CONFIG_LOCAL_STORAGE_KEY = "excalidraw-storage-config-type";
|
||||
const STORAGE_CONFIG_SESSION_STORAGE_KEY =
|
||||
"excalidraw-storage-config-credentials";
|
||||
|
||||
const getInitialStorageConfig = (): StorageConfig => {
|
||||
const defaultConfig: StorageConfig = { type: "default" };
|
||||
|
||||
try {
|
||||
const nonSensitive = localStorage.getItem(STORAGE_CONFIG_LOCAL_STORAGE_KEY);
|
||||
const sensitive = sessionStorage.getItem(
|
||||
STORAGE_CONFIG_SESSION_STORAGE_KEY,
|
||||
);
|
||||
|
||||
const nonSensitiveConfig = nonSensitive ? JSON.parse(nonSensitive) : {};
|
||||
const sensitiveConfig = sensitive ? JSON.parse(sensitive) : {};
|
||||
|
||||
return { ...defaultConfig, ...nonSensitiveConfig, ...sensitiveConfig };
|
||||
} catch (e) {
|
||||
console.error("Failed to load storage config", e);
|
||||
return defaultConfig;
|
||||
}
|
||||
};
|
||||
|
||||
const baseStorageConfigAtom = atom<StorageConfig>(getInitialStorageConfig());
|
||||
|
||||
export const storageConfigAtom = atom(
|
||||
(get) => get(baseStorageConfigAtom),
|
||||
(get, set, newConfig: StorageConfig) => {
|
||||
const {
|
||||
type,
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
} = newConfig;
|
||||
|
||||
const nonSensitive = { type };
|
||||
const sensitive = {
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_CONFIG_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(nonSensitive),
|
||||
);
|
||||
sessionStorage.setItem(
|
||||
STORAGE_CONFIG_SESSION_STORAGE_KEY,
|
||||
JSON.stringify(sensitive),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save storage config", e);
|
||||
}
|
||||
|
||||
set(baseStorageConfigAtom, newConfig);
|
||||
},
|
||||
);
|
||||
|
||||
// Dialog States
|
||||
// -----------------------------------------------------------------------------
|
||||
export const createCanvasDialogAtom = atom({ isOpen: false });
|
||||
|
||||
export const renameCanvasDialogAtom = atom<{
|
||||
isOpen: boolean;
|
||||
canvasId: string | null;
|
||||
currentName: string | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
canvasId: null,
|
||||
currentName: null,
|
||||
});
|
||||
|
||||
export const saveAsDialogAtom = atom({ isOpen: false });
|
||||
|
||||
export const appJotaiStore = jotaiStore;
|
||||
@@ -0,0 +1,60 @@
|
||||
// time constants (ms)
|
||||
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
||||
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
||||
export const FILE_UPLOAD_TIMEOUT = 300;
|
||||
export const LOAD_IMAGES_TIMEOUT = 500;
|
||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
export const CREATIONS_SIDEBAR_NAME = "creations";
|
||||
|
||||
export const WS_EVENTS = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
USER_FOLLOW_CHANGE: "user-follow",
|
||||
USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
|
||||
} as const;
|
||||
|
||||
export enum WS_SUBTYPES {
|
||||
INVALID_RESPONSE = "INVALID_RESPONSE",
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
MOUSE_LOCATION = "MOUSE_LOCATION",
|
||||
IDLE_STATUS = "IDLE_STATUS",
|
||||
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
|
||||
}
|
||||
|
||||
export const FIREBASE_STORAGE_PREFIXES = {
|
||||
shareLinkFiles: `/files/shareLinks`,
|
||||
collabFiles: `/files/rooms`,
|
||||
};
|
||||
|
||||
export const ROOM_ID_BYTES = 10;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
export const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
export default (sentryErrorId) => `
|
||||
### Scene content
|
||||
|
||||
\`\`\`
|
||||
Paste scene content here
|
||||
\`\`\`
|
||||
|
||||
### Sentry Error ID
|
||||
|
||||
${sentryErrorId}
|
||||
`;
|
||||
@@ -0,0 +1,997 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import {
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
throttleRAF,
|
||||
} from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SUBTYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
loadFromFirebase,
|
||||
saveFilesToFirebase,
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import { UserIdleState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../packages/excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string | null;
|
||||
/** errors related to saving */
|
||||
dialogNotifiedErrors: Record<string, boolean>;
|
||||
username: string;
|
||||
activeRoomLink: string | null;
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
export interface CollabAPI {
|
||||
/** function so that we can access the latest value from stale callbacks */
|
||||
isCollaborating: () => boolean;
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
startCollaboration: CollabInstance["startCollaboration"];
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
}
|
||||
|
||||
interface CollabProps {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}
|
||||
|
||||
class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: CollabProps["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorMessage: null,
|
||||
dialogNotifiedErrors: {},
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: null,
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
|
||||
},
|
||||
saveFiles: async ({ addedFiles }) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return saveFilesToFirebase({
|
||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||
files: await encodeFilesForUpload({
|
||||
files: addedFiles,
|
||||
encryptionKey: roomKey,
|
||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
private onUmmount: (() => void) | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.addEventListener("online", this.onOfflineStatusToggle);
|
||||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
|
||||
this.portal.socket && this.portal.broadcastUserFollowed(payload);
|
||||
});
|
||||
const throttledRelayUserViewportBounds = throttleRAF(
|
||||
this.relayVisibleSceneBounds,
|
||||
);
|
||||
const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
|
||||
throttledRelayUserViewportBounds(),
|
||||
);
|
||||
this.onUmmount = () => {
|
||||
unsubOnUserFollow();
|
||||
unsubOnScrollChange();
|
||||
};
|
||||
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
startCollaboration: this.startCollaboration,
|
||||
syncElements: this.syncElements,
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setCollabError: this.setErrorDialog,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI as any);
|
||||
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
Object.defineProperties(window, {
|
||||
collab: {
|
||||
configurable: true,
|
||||
value: this,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onOfflineStatusToggle = () => {
|
||||
appJotaiStore.set(isOfflineAtom, !window.navigator.onLine as any);
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("online", this.onOfflineStatusToggle);
|
||||
window.removeEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
window.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
this.onVisibilityChange,
|
||||
);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
this.onUmmount?.();
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
|
||||
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||
appJotaiStore.set(isCollaboratingAtom, isCollaborating as any);
|
||||
};
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
if (
|
||||
this.isCollaborating() &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
// this won't run in time if user decides to leave the site, but
|
||||
// the purpose is to run in immediately after user decides to stay
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
|
||||
preventUnload(event);
|
||||
}
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
this.resetErrorIndicator();
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||
? t("errors.collabSaveFailed_sizeExceeded")
|
||||
: t("errors.collabSaveFailed");
|
||||
|
||||
if (
|
||||
!this.state.dialogNotifiedErrors[errorMessage] ||
|
||||
!this.isCollaborating()
|
||||
) {
|
||||
this.setErrorDialog(errorMessage);
|
||||
this.setState({
|
||||
dialogNotifiedErrors: {
|
||||
...this.state.dialogNotifiedErrors,
|
||||
[errorMessage]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isCollaborating()) {
|
||||
this.setErrorIndicator(errorMessage);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
stopCollaboration = (keepRemoteState = true) => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
this.resetErrorIndicator(true);
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepRemoteState) {
|
||||
LocalData.fileStorage.reset();
|
||||
this.destroySocketClient();
|
||||
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
// hack to ensure that we prefer we disregard any new browser state
|
||||
// that could have been saved in other tabs while we were collaborating
|
||||
resetBrowserStateVersions();
|
||||
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
|
||||
LocalData.fileStorage.reset();
|
||||
|
||||
const elements = this.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setActiveRoomLink(null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
LocalData.resumeSave("collaboration");
|
||||
}
|
||||
};
|
||||
|
||||
private fetchImageFilesFromFirebase = async (opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
/**
|
||||
* Indicates whether to fetch files that are errored or pending and older
|
||||
* than 10 seconds.
|
||||
*
|
||||
* Use this as a mechanism to fetch files which may be ok but for some
|
||||
* reason their status was not updated correctly.
|
||||
*/
|
||||
forceFetchFiles?: boolean;
|
||||
}) => {
|
||||
const unfetchedImages = opts.elements
|
||||
.filter((element) => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
!this.fileManager.isFileHandled(element.fileId) &&
|
||||
!element.isDeleted &&
|
||||
(opts.forceFetchFiles
|
||||
? element.status !== "pending" ||
|
||||
Date.now() - element.updated > 10000
|
||||
: element.status === "saved")
|
||||
);
|
||||
})
|
||||
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
|
||||
|
||||
return await this.fileManager.getFiles(unfetchedImages);
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
try {
|
||||
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
|
||||
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
return {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private fallbackInitializationHandler: null | (() => any) = null;
|
||||
|
||||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.setUsername(username);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let roomId;
|
||||
let roomKey;
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
({ roomId, roomKey } = existingRoomLinkData);
|
||||
} else {
|
||||
({ roomId, roomKey } = await generateCollaborationLinkData());
|
||||
window.history.pushState(
|
||||
{},
|
||||
APP_NAME,
|
||||
getCollaborationLink({ roomId, roomKey }),
|
||||
);
|
||||
}
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.setIsCollaborating(true);
|
||||
LocalData.pauseSave("collaboration");
|
||||
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
);
|
||||
|
||||
const fallbackInitializationHandler = () => {
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
}).then((scene) => {
|
||||
scenePromise.resolve(scene);
|
||||
});
|
||||
};
|
||||
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||
|
||||
try {
|
||||
this.portal.socket = this.portal.open(
|
||||
socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
}),
|
||||
roomId,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setErrorDialog(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existingRoomLinkData) {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.history.clear();
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_INIT message
|
||||
this.socketInitializationTimer = window.setTimeout(
|
||||
fallbackInitializationHandler,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedData = await this.decryptPayload(
|
||||
iv,
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||
return;
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({
|
||||
elements: reconciledElements,
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
const { pointer, button, username, selectedElementIds } =
|
||||
decryptedData.payload;
|
||||
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
this.updateCollaborator(socketId, {
|
||||
pointer,
|
||||
button,
|
||||
selectedElementIds,
|
||||
username,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
|
||||
const { sceneBounds, socketId } = decryptedData.payload;
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
// we're not following the user
|
||||
// (shouldn't happen, but could be late message or bug upstream)
|
||||
if (appState.userToFollow?.socketId !== socketId) {
|
||||
console.warn(
|
||||
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// cross-follow case, ignore updates in this case
|
||||
if (
|
||||
appState.userToFollow &&
|
||||
appState.followedBy.has(appState.userToFollow.socketId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: zoomToFitBounds({
|
||||
appState,
|
||||
bounds: sceneBounds,
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 1,
|
||||
}).appState,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.IDLE_STATUS: {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
this.updateCollaborator(socketId, {
|
||||
userState,
|
||||
username,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
assertNever(decryptedData, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.portal.socket.on("first-in-room", async () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
const sceneData = await this.initializeRoom({
|
||||
fetchScene: true,
|
||||
roomLinkData: existingRoomLinkData,
|
||||
});
|
||||
scenePromise.resolve(sceneData);
|
||||
});
|
||||
|
||||
this.portal.socket.on(
|
||||
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
|
||||
(followedBy: SocketId[]) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { followedBy: new Set(followedBy) },
|
||||
});
|
||||
|
||||
this.relayVisibleSceneBounds({ force: true });
|
||||
},
|
||||
);
|
||||
|
||||
this.initializeIdleDetector();
|
||||
|
||||
this.setActiveRoomLink(window.location.href);
|
||||
|
||||
return scenePromise;
|
||||
};
|
||||
|
||||
private initializeRoom = async ({
|
||||
fetchScene,
|
||||
roomLinkData,
|
||||
}:
|
||||
| {
|
||||
fetchScene: true;
|
||||
roomLinkData: { roomId: string; roomKey: string } | null;
|
||||
}
|
||||
| { fetchScene: false; roomLinkData?: null }) => {
|
||||
clearTimeout(this.socketInitializationTimer!);
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(
|
||||
roomLinkData.roomId,
|
||||
roomLinkData.roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(elements),
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
scrollToContent: true,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
} else {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
remoteElements = restoreElements(remoteElements, null);
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
localElements,
|
||||
remoteElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
// synchronously calls render.
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(reconciledElements),
|
||||
);
|
||||
|
||||
return reconciledElements;
|
||||
};
|
||||
|
||||
private loadImageFiles = throttle(async () => {
|
||||
const { loadedFiles, erroredFiles } =
|
||||
await this.fetchImageFilesFromFirebase({
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
|
||||
this.excalidrawAPI.addFiles(loadedFiles);
|
||||
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI: this.excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
}, LOAD_IMAGES_TIMEOUT);
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: !!init,
|
||||
});
|
||||
|
||||
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||
// right now we think this is the right tradeoff.
|
||||
this.excalidrawAPI.history.clear();
|
||||
|
||||
this.loadImageFiles();
|
||||
};
|
||||
|
||||
private onPointerMove = () => {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
|
||||
if (!this.activeIntervalId) {
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
this.onIdleStateChange(UserIdleState.AWAY);
|
||||
} else {
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
}
|
||||
};
|
||||
|
||||
private reportIdle = () => {
|
||||
this.onIdleStateChange(UserIdleState.IDLE);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
private reportActive = () => {
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
};
|
||||
|
||||
private initializeIdleDetector = () => {
|
||||
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: SocketId[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
collaborators.set(
|
||||
socketId,
|
||||
Object.assign({}, this.collaborators.get(socketId), {
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
}
|
||||
|
||||
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user: Mutable<Collaborator> = Object.assign(
|
||||
{},
|
||||
collaborators.get(socketId),
|
||||
updates,
|
||||
{
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
},
|
||||
);
|
||||
collaborators.set(socketId, user);
|
||||
this.collaborators = collaborators;
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
};
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
|
||||
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
||||
return this.lastBroadcastedOrReceivedSceneVersion;
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
onPointerUpdate = throttle(
|
||||
(payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => {
|
||||
payload.pointersMap.size < 2 &&
|
||||
this.portal.socket &&
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
},
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
);
|
||||
|
||||
relayVisibleSceneBounds = (props?: { force: boolean }) => {
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
|
||||
this.portal.broadcastVisibleSceneBounds(
|
||||
{
|
||||
sceneBounds: getVisibleSceneBounds(appState),
|
||||
},
|
||||
`follow@${this.portal.socket.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onIdleStateChange = (userState: UserIdleState) => {
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
|
||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
WS_SUBTYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
||||
const newVersion = Math.max(
|
||||
currentVersion,
|
||||
getSceneVersion(this.getSceneElementsIncludingDeleted()),
|
||||
);
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(
|
||||
() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
{ leading: false },
|
||||
);
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
setActiveRoomLink = (activeRoomLink: string | null) => {
|
||||
this.setState({ activeRoomLink });
|
||||
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink as any);
|
||||
};
|
||||
|
||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||
|
||||
setErrorIndicator = (errorMessage: string | null) => {
|
||||
appJotaiStore.set(collabErrorIndicatorAtom, {
|
||||
message: errorMessage,
|
||||
nonce: Date.now(),
|
||||
} as any);
|
||||
};
|
||||
|
||||
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
|
||||
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 } as any);
|
||||
if (resetDialogNotifiedErrors) {
|
||||
this.setState({
|
||||
dialogNotifiedErrors: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setErrorDialog = (errorMessage: string | null) => {
|
||||
this.setState({
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { errorMessage } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorMessage != null && (
|
||||
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
collab: InstanceType<typeof Collab>;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
export default Collab;
|
||||
|
||||
export type TCollabClass = Collab;
|
||||
@@ -0,0 +1,35 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.collab-errors-button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-inline-end: 1rem;
|
||||
|
||||
color: var(--color-danger);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collab-errors-button-shake {
|
||||
animation: strong-shake 0.15s 6;
|
||||
}
|
||||
|
||||
@keyframes strong-shake {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0eg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { warning } from "../../packages/excalidraw/components/icons";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./CollabError.scss";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export type ErrorIndicator = {
|
||||
message: string | null;
|
||||
/** used to rerun the useEffect responsible for animation */
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
const _collabErrorIndicatorAtom = atom<ErrorIndicator>({
|
||||
message: null,
|
||||
nonce: 0,
|
||||
});
|
||||
|
||||
export const collabErrorIndicatorAtom = atom(
|
||||
(get) => get(_collabErrorIndicatorAtom),
|
||||
(get, set, update: ErrorIndicator) => set(_collabErrorIndicatorAtom, update),
|
||||
);
|
||||
|
||||
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnimating(true);
|
||||
clearAnimationRef.current = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(clearAnimationRef.current);
|
||||
};
|
||||
}, [collabError.message, collabError.nonce]);
|
||||
|
||||
if (!collabError.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={collabError.message} long={true}>
|
||||
<div
|
||||
className={clsx("collab-errors-button", {
|
||||
"collab-errors-button-shake": isAnimating,
|
||||
})}
|
||||
>
|
||||
{warning}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
CollabError.displayName = "CollabError";
|
||||
|
||||
export default CollabError;
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
isSyncableElement,
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
} from "../data";
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
socket: Socket | null = null;
|
||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
broadcastedElementVersions: Map<string, number> = new Map();
|
||||
|
||||
constructor(collab: TCollabClass) {
|
||||
this.collab = collab;
|
||||
}
|
||||
|
||||
open(socket: Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
}
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SUBTYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.queueFileUpload.flush();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
this.roomKey = null;
|
||||
this.socketInitialized = false;
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !!(
|
||||
this.socketInitialized &&
|
||||
this.socket &&
|
||||
this.roomId &&
|
||||
this.roomKey
|
||||
);
|
||||
}
|
||||
|
||||
async _broadcastSocketData(
|
||||
data: SocketUpdateData,
|
||||
volatile: boolean = false,
|
||||
roomId?: string,
|
||||
) {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
roomId ?? this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queueFileUpload = throttle(async () => {
|
||||
try {
|
||||
await this.collab.fileManager.saveFiles({
|
||||
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
files: this.collab.excalidrawAPI.getFiles(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: this.collab.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||
// this will signal collaborators to pull image data from server
|
||||
// (using mutation instead of newElementWith otherwise it'd break
|
||||
// in-progress dragging)
|
||||
return newElementWith(element, { status: "saved" });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
});
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
// sync out only the elements we think we need to to save bandwidth.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
const syncableElements = allElements.reduce(
|
||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
||||
if (
|
||||
(syncAll ||
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version >
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
};
|
||||
|
||||
for (const syncableElement of syncableElements) {
|
||||
this.broadcastedElementVersions.set(
|
||||
syncableElement.id,
|
||||
syncableElement.version,
|
||||
);
|
||||
}
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: WS_SUBTYPES.IDLE_STATUS,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastMouseLocation = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
}) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastVisibleSceneBounds = (
|
||||
payload: {
|
||||
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
|
||||
},
|
||||
roomId: string,
|
||||
) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
|
||||
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
username: this.collab.state.username,
|
||||
sceneBounds: payload.sceneBounds,
|
||||
},
|
||||
};
|
||||
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
roomId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
|
||||
if (this.socket?.id) {
|
||||
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
playerStopFilledIcon,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
|
||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
||||
import "./RoomDialog.scss";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
export type RoomModalProps = {
|
||||
handleClose: () => void;
|
||||
activeRoomLink: string;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
};
|
||||
|
||||
export const RoomModal = ({
|
||||
activeRoomLink,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
username,
|
||||
onUsernameChange,
|
||||
handleClose,
|
||||
}: RoomModalProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const shareRoomLink = async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("roomDialog.shareTitle"),
|
||||
text: t("roomDialog.shareTitle"),
|
||||
url: activeRoomLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Just ignore.
|
||||
}
|
||||
};
|
||||
|
||||
if (activeRoomLink) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="RoomDialog__active__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</h3>
|
||||
<TextField
|
||||
value={username}
|
||||
placeholder="Your name"
|
||||
label="Your name"
|
||||
onChange={onUsernameChange}
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||
/>
|
||||
<div className="RoomDialog__active__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={activeRoomLink}
|
||||
/>
|
||||
{isShareSupported && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
icon={getShareIcon()}
|
||||
className="RoomDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="RoomDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="RoomDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="RoomDialog__active__description__emoji"
|
||||
>
|
||||
🔒{" "}
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__active__actions">
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="RoomDialog__inactive__illustration">
|
||||
<CollabImage />
|
||||
</div>
|
||||
<div className="RoomDialog__inactive__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__description">
|
||||
<strong>{t("roomDialog.desc_intro")}</strong>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__start_session">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomDialog = (props: RoomModalProps) => {
|
||||
return (
|
||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||
<div className="RoomDialog">
|
||||
<RoomModal {...props} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDialog;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
};
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: ExcalidrawElement | undefined,
|
||||
remote: BroadcastedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (
|
||||
local &&
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.draggingElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
// the lowest versionNonce
|
||||
(local.version === remote.version &&
|
||||
local.versionNonce < remote.versionNonce))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const reconcileElements = (
|
||||
localElements: readonly ExcalidrawElement[],
|
||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
||||
localAppState: AppState,
|
||||
): ReconciledElements => {
|
||||
const localElementsData =
|
||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
||||
|
||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
||||
|
||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
||||
|
||||
let cursor = 0;
|
||||
let offset = 0;
|
||||
|
||||
let remoteElementIdx = -1;
|
||||
for (const remoteElement of remoteElements) {
|
||||
remoteElementIdx++;
|
||||
|
||||
const local = localElementsData.get(remoteElement.id);
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// Unless the remote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
offset++;
|
||||
if (cursor === 0) {
|
||||
reconciledElements.unshift(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor - offset,
|
||||
]);
|
||||
} else {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
}
|
||||
} else {
|
||||
let idx = localElementsData.has(parent)
|
||||
? localElementsData.get(parent)![1]
|
||||
: null;
|
||||
if (idx != null) {
|
||||
idx += offset;
|
||||
}
|
||||
if (idx != null && idx >= cursor) {
|
||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
idx + 1 - offset,
|
||||
]);
|
||||
cursor = idx + 1;
|
||||
} else if (idx != null) {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
// no parent z-index information, local element exists → replace in place
|
||||
} else if (local) {
|
||||
reconciledElements[local[1]] = remoteElement;
|
||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
||||
// otherwise push to the end
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
||||
(element) => !duplicates.has(element),
|
||||
);
|
||||
|
||||
return ret as ReconciledElements;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { Footer } from "../../packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
|
||||
export const AppFooter = React.memo(() => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<EncryptedIcon />
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { userAtom, saveAsDialogAtom } from "../app-jotai";
|
||||
import {
|
||||
GithubIcon,
|
||||
saveAs,
|
||||
extraToolsIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import DropdownMenuItemLink from "../../packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
onStorageSettingsClick: () => void;
|
||||
}> = React.memo((props) => {
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const { t } = useI18n();
|
||||
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
|
||||
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/auth/github/login";
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
window.location.reload(); // Reload to clear all state
|
||||
};
|
||||
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.Item
|
||||
onSelect={() => setSaveAsDialog({ isOpen: true })}
|
||||
icon={saveAs}
|
||||
>
|
||||
Save as New Canvas...
|
||||
</MainMenu.Item>
|
||||
<MainMenu.DefaultItems.Export />
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Item
|
||||
onSelect={props.onStorageSettingsClick}
|
||||
icon={extraToolsIcon}
|
||||
>
|
||||
Data Source Settings...
|
||||
</MainMenu.Item>
|
||||
<MainMenu.Separator />
|
||||
{user ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
padding: "0 0.5rem",
|
||||
width: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
overflow: "hidden",
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login}
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{user.name || user.login}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
textAlign: "center",
|
||||
color: "inherit",
|
||||
flexShrink: 0,
|
||||
marginRight: "1rem",
|
||||
font: "var(--ui-font)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
onMouseOver={(e) =>
|
||||
(e.currentTarget.style.background = "var(--button-gray-1)")
|
||||
}
|
||||
onMouseOut={(e) =>
|
||||
(e.currentTarget.style.background = "transparent")
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<MainMenu.Item onSelect={handleLogin} icon={GithubIcon}>
|
||||
Login with GitHub
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<DropdownMenuItemLink
|
||||
icon={GithubIcon}
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</DropdownMenuItemLink>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</MainMenu.ItemCustom>
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { GithubIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "../app-jotai";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
const [user] = useAtom(userAtom);
|
||||
let headingContent;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
headingContent = t("welcomeScreen.app.center_heading_plus")
|
||||
.split(/(Excalidraw\+)/)
|
||||
.map((bit, idx) => {
|
||||
if (bit === "Excalidraw+") {
|
||||
return (
|
||||
<a
|
||||
style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeScreen>
|
||||
<WelcomeScreen.Hints.MenuHint>
|
||||
{t("welcomeScreen.app.menuHint")}
|
||||
</WelcomeScreen.Hints.MenuHint>
|
||||
<WelcomeScreen.Hints.ToolbarHint />
|
||||
<WelcomeScreen.Hints.HelpHint />
|
||||
<WelcomeScreen.Center>
|
||||
<WelcomeScreen.Center.Logo />
|
||||
<WelcomeScreen.Center.Heading>
|
||||
{headingContent}
|
||||
</WelcomeScreen.Center.Heading>
|
||||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
{!user && (
|
||||
<WelcomeScreen.Center.MenuItem
|
||||
onSelect={() => {
|
||||
window.location.href = "/auth/github/login";
|
||||
}}
|
||||
icon={GithubIcon}
|
||||
>
|
||||
Login with GitHub
|
||||
</WelcomeScreen.Center.MenuItem>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
</WelcomeScreen.Center>
|
||||
</WelcomeScreen>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { createCanvasDialogAtom } from "../app-jotai";
|
||||
|
||||
interface CreateCanvasDialogProps {
|
||||
onCanvasCreate: (name: string) => void;
|
||||
}
|
||||
|
||||
export const CreateCanvasDialog: React.FC<CreateCanvasDialogProps> = ({
|
||||
onCanvasCreate,
|
||||
}) => {
|
||||
const [name, setName] = useState("Untitled Canvas");
|
||||
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (name.trim()) {
|
||||
onCanvasCreate(name.trim());
|
||||
setCreateCanvasDialog({ isOpen: false });
|
||||
}
|
||||
}, [name, onCanvasCreate, setCreateCanvasDialog]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCreateCanvasDialog({ isOpen: false });
|
||||
}, [setCreateCanvasDialog]);
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Create New Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="Canvas Name"
|
||||
value={name}
|
||||
placeholder="Enter a name for your new canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Create"}
|
||||
onClick={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { shield } from "../../packages/excalidraw/components/icons";
|
||||
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const EncryptedIcon = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl" ? "marginLeft" : "marginRight"]:
|
||||
"calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
||||
const setLangCode = useSetAtom(appLangCodeAtom);
|
||||
|
||||
return (
|
||||
<select
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => setLangCode(target.value)}
|
||||
value={langCode}
|
||||
aria-label={t("buttons.selectLanguage")}
|
||||
style={style}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
.my-creations-tab {
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-primary-lighter);
|
||||
padding: 2rem;
|
||||
|
||||
svg {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--icon-fill-color-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--button-hover-border-color);
|
||||
}
|
||||
|
||||
&--active,
|
||||
&--active:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-thumbnail,
|
||||
&__card-thumbnail--placeholder {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: var(--canvas-background-color);
|
||||
border-bottom: 1px solid var(--button-border-color);
|
||||
}
|
||||
|
||||
&__card-thumbnail {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__card-thumbnail--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: var(--ui-font);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-muted-background);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__card-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__card-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__card-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-lighter);
|
||||
}
|
||||
|
||||
&__card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__card-rename,
|
||||
&__card-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary-lighter);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.my-creations-tab__item-thumbnail,
|
||||
.my-creations-tab__item-thumbnail--placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid var(--button-border-color);
|
||||
background-color: var(--canvas-background-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-rename,
|
||||
.my-creations-tab__item-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary-lighter);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.my-creations-tab__item-date {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
userAtom,
|
||||
createCanvasDialogAtom,
|
||||
renameCanvasDialogAtom,
|
||||
} from "../app-jotai";
|
||||
import { CanvasMetadata } from "../data/storage";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import {
|
||||
FreedrawIcon,
|
||||
LoadIcon,
|
||||
TrashIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import "./MyCreationsTab.scss";
|
||||
import clsx from "clsx";
|
||||
import { timeAgo } from "../utils/time";
|
||||
|
||||
interface MyCreationsTabProps {
|
||||
canvases: readonly CanvasMetadata[];
|
||||
onCanvasSelect: (id: string) => void;
|
||||
onCanvasDelete: (id: string) => void;
|
||||
currentCanvasId: string | null;
|
||||
}
|
||||
|
||||
export const MyCreationsTab: React.FC<MyCreationsTabProps> = ({
|
||||
canvases,
|
||||
onCanvasSelect,
|
||||
onCanvasDelete,
|
||||
currentCanvasId,
|
||||
}) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
|
||||
const setRenameCanvasDialog = useSetAtom(renameCanvasDialogAtom);
|
||||
|
||||
const sortedCanvases = [...canvases].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-creations-tab">
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<FilledButton
|
||||
label="Create New Canvas"
|
||||
onClick={() => setCreateCanvasDialog({ isOpen: true })}
|
||||
fullWidth
|
||||
>
|
||||
Create New Canvas
|
||||
</FilledButton>
|
||||
</div>
|
||||
<div className="my-creations-tab__grid">
|
||||
{canvases.length === 0 ? (
|
||||
<div className="my-creations-tab__empty">
|
||||
{LoadIcon}
|
||||
<p>You have no saved canvases yet.</p>
|
||||
<p>
|
||||
Create a new canvas to get started. It will be saved{" "}
|
||||
{user ? "to your account" : "in your browser"}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedCanvases.map((canvas) => (
|
||||
<div
|
||||
key={canvas.id}
|
||||
className={clsx("my-creations-tab__card", {
|
||||
"my-creations-tab__card--active": canvas.id === currentCanvasId,
|
||||
})}
|
||||
onClick={() => onCanvasSelect(canvas.id)}
|
||||
>
|
||||
{canvas.thumbnail ? (
|
||||
<img
|
||||
src={canvas.thumbnail}
|
||||
alt={canvas.name}
|
||||
className="my-creations-tab__card-thumbnail"
|
||||
/>
|
||||
) : (
|
||||
<div className="my-creations-tab__card-thumbnail--placeholder">
|
||||
空空如也
|
||||
</div>
|
||||
)}
|
||||
<div className="my-creations-tab__card-info">
|
||||
<div className="my-creations-tab__card-details">
|
||||
<span className="my-creations-tab__card-name">
|
||||
{canvas.name}
|
||||
</span>
|
||||
<span className="my-creations-tab__card-date">
|
||||
{timeAgo(canvas.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-creations-tab__card-actions">
|
||||
<button
|
||||
className="my-creations-tab__card-rename"
|
||||
title="Rename canvas"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameCanvasDialog({
|
||||
isOpen: true,
|
||||
canvasId: canvas.id,
|
||||
currentName: canvas.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{FreedrawIcon}
|
||||
</button>
|
||||
<button
|
||||
className="my-creations-tab__card-delete"
|
||||
title="Delete canvas"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCanvasDelete(canvas.id);
|
||||
}}
|
||||
>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useAtom } from "jotai";
|
||||
import { renameCanvasDialogAtom } from "../app-jotai";
|
||||
|
||||
interface RenameCanvasDialogProps {
|
||||
onCanvasRename: (id: string, newName: string) => void;
|
||||
}
|
||||
|
||||
export const RenameCanvasDialog: React.FC<RenameCanvasDialogProps> = ({
|
||||
onCanvasRename,
|
||||
}) => {
|
||||
const [dialogState, setDialogState] = useAtom(renameCanvasDialogAtom);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogState.isOpen && dialogState.currentName) {
|
||||
setName(dialogState.currentName);
|
||||
}
|
||||
}, [dialogState.isOpen, dialogState.currentName]);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
if (name.trim() && dialogState.canvasId) {
|
||||
onCanvasRename(dialogState.canvasId, name.trim());
|
||||
setDialogState({ isOpen: false, canvasId: null, currentName: null });
|
||||
}
|
||||
}, [name, dialogState.canvasId, onCanvasRename, setDialogState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDialogState({ isOpen: false, canvasId: null, currentName: null });
|
||||
}, [setDialogState]);
|
||||
|
||||
if (!dialogState.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Rename Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="New Name"
|
||||
value={name}
|
||||
placeholder="Enter a new name for the canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Rename"}
|
||||
onClick={handleRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { saveAsDialogAtom } from "../app-jotai";
|
||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||
|
||||
interface SaveAsDialogProps {
|
||||
onCanvasSaveAs: (name: string) => void;
|
||||
}
|
||||
|
||||
export const SaveAsDialog: React.FC<SaveAsDialogProps> = ({
|
||||
onCanvasSaveAs,
|
||||
}) => {
|
||||
const appState = useUIAppState();
|
||||
const [name, setName] = useState(appState.name || "Untitled Canvas");
|
||||
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
|
||||
|
||||
const handleSaveAs = useCallback(() => {
|
||||
if (name.trim()) {
|
||||
onCanvasSaveAs(name.trim());
|
||||
setSaveAsDialog({ isOpen: false });
|
||||
}
|
||||
}, [name, onCanvasSaveAs, setSaveAsDialog]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSaveAsDialog({ isOpen: false });
|
||||
}, [setSaveAsDialog]);
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Save as New Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="Canvas Name"
|
||||
value={name}
|
||||
placeholder="Enter a name for the new canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveAs()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Save As"}
|
||||
onClick={handleSaveAs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { ExportImageIcon } from "../../packages/excalidraw/components/icons";
|
||||
|
||||
export const SaveAsImageUI: React.FC<{
|
||||
onSuccess: () => void;
|
||||
}> = ({ onSuccess }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
<div className="Card-icon">
|
||||
{React.cloneElement(ExportImageIcon as React.ReactElement, {
|
||||
style: {
|
||||
width: "2.8rem",
|
||||
height: "2.8rem",
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<h2>{t("buttons.exportImage")}</h2>
|
||||
<div className="Card-details">
|
||||
Save your canvas to a file in PNG, SVG or WebP format.
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("buttons.exportImage")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={true}
|
||||
onClick={onSuccess}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { Island } from "../../packages/excalidraw/components/Island";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useAtom } from "jotai";
|
||||
import { storageConfigAtom } from "../app-jotai";
|
||||
|
||||
export type StorageType = "default" | "kv" | "s3" | "indexed-db";
|
||||
|
||||
const StorageSettingsDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
const [config, setConfig] = useAtom(storageConfigAtom);
|
||||
const [storageType, setStorageType] = useState<StorageType>(config.type);
|
||||
|
||||
// Local state for form inputs
|
||||
const [kvUrl, setKvUrl] = useState(config.kvUrl || "");
|
||||
const [kvApiToken, setKvApiToken] = useState(config.kvApiToken || "");
|
||||
const [s3AccessKeyId, setS3AccessKeyId] = useState(
|
||||
config.s3AccessKeyId || "",
|
||||
);
|
||||
const [s3SecretAccessKey, setS3SecretAccessKey] = useState(
|
||||
config.s3SecretAccessKey || "",
|
||||
);
|
||||
const [s3Region, setS3Region] = useState(config.s3Region || "");
|
||||
const [s3BucketName, setS3BucketName] = useState(config.s3BucketName || "");
|
||||
|
||||
const handleSave = () => {
|
||||
setConfig({
|
||||
type: storageType,
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (storageType) {
|
||||
case "kv":
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="KV URL"
|
||||
value={kvUrl}
|
||||
placeholder="Your Cloudflare KV URL"
|
||||
onChange={setKvUrl}
|
||||
/>
|
||||
<TextField
|
||||
label="API Token"
|
||||
value={kvApiToken}
|
||||
placeholder="Your Cloudflare API Token"
|
||||
onChange={setKvApiToken}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "s3":
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Access Key ID"
|
||||
value={s3AccessKeyId}
|
||||
placeholder="Your AWS Access Key ID"
|
||||
onChange={setS3AccessKeyId}
|
||||
/>
|
||||
<TextField
|
||||
label="Secret Access Key"
|
||||
value={s3SecretAccessKey}
|
||||
placeholder="Your AWS Secret Access Key"
|
||||
onChange={setS3SecretAccessKey}
|
||||
/>
|
||||
<TextField
|
||||
label="Region"
|
||||
value={s3Region}
|
||||
placeholder="e.g., us-east-1"
|
||||
onChange={setS3Region}
|
||||
/>
|
||||
<TextField
|
||||
label="Bucket Name"
|
||||
value={s3BucketName}
|
||||
placeholder="Your S3 Bucket Name"
|
||||
onChange={setS3BucketName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "indexed-db":
|
||||
return (
|
||||
<p>
|
||||
Your canvases are stored securely in your browser's local database.
|
||||
They are not synced online.
|
||||
</p>
|
||||
);
|
||||
case "default":
|
||||
default:
|
||||
return (
|
||||
<p>
|
||||
Your data is stored on the default backend of this Excalidraw
|
||||
instance. This requires you to be logged in.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onClose}
|
||||
title={"Data Source Settings"}
|
||||
className="storage-settings-dialog"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<p>
|
||||
Security Warning: Sensitive keys are stored only in your browser's
|
||||
session storage and are cleared when you close the tab.
|
||||
</p>
|
||||
|
||||
<select
|
||||
value={storageType}
|
||||
onChange={(e) => setStorageType(e.target.value as StorageType)}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
borderRadius: "var(--border-radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<option value="indexed-db">Browser (IndexedDB)</option>
|
||||
<option value="default">Default Backend (Online)</option>
|
||||
<option value="kv">Cloudflare KV (Online)</option>
|
||||
<option value="s3">Amazon S3 (Online)</option>
|
||||
</select>
|
||||
|
||||
<Island style={{ padding: "1rem" }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
>
|
||||
{renderForm()}
|
||||
</div>
|
||||
</Island>
|
||||
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton color="primary" label={"Save"} onClick={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageSettingsDialog;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import Trans from "../../packages/excalidraw/components/Trans";
|
||||
|
||||
interface TopErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
sentryEventId: string;
|
||||
localStorage: string;
|
||||
}
|
||||
|
||||
export class TopErrorBoundary extends React.Component<
|
||||
any,
|
||||
TopErrorBoundaryState
|
||||
> {
|
||||
state: TopErrorBoundaryState = {
|
||||
hasError: false,
|
||||
sentryEventId: "",
|
||||
localStorage: "",
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? this.errorSplash() : this.props.children;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: any) {
|
||||
const _localStorage: any = {};
|
||||
for (const [key, value] of Object.entries({ ...localStorage })) {
|
||||
try {
|
||||
_localStorage[key] = JSON.parse(value);
|
||||
} catch (error: any) {
|
||||
_localStorage[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras(errorInfo);
|
||||
const eventId = Sentry.captureException(error);
|
||||
|
||||
this.setState((state) => ({
|
||||
hasError: true,
|
||||
sentryEventId: eventId,
|
||||
localStorage: JSON.stringify(_localStorage),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
|
||||
if (event.target !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLTextAreaElement).select();
|
||||
}
|
||||
}
|
||||
|
||||
private async createGithubIssue() {
|
||||
let body = "";
|
||||
try {
|
||||
const templateStrFn = (
|
||||
await import(
|
||||
/* webpackChunkName: "bug-issue-template" */ "../bug-issue-template"
|
||||
)
|
||||
).default;
|
||||
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
private errorSplash() {
|
||||
return (
|
||||
<div className="ErrorSplash excalidraw">
|
||||
<div className="ErrorSplash-messageContainer">
|
||||
<div className="ErrorSplash-paragraph bigger align-center">
|
||||
<Trans
|
||||
i18nKey="errorSplash.headingMain"
|
||||
button={(el) => (
|
||||
<button onClick={() => window.location.reload()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph align-center">
|
||||
<Trans
|
||||
i18nKey="errorSplash.clearCanvasMessage"
|
||||
button={(el) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{el}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<br />
|
||||
<div className="smaller">
|
||||
<span role="img" aria-label="warning">
|
||||
⚠️
|
||||
</span>
|
||||
{t("errorSplash.clearCanvasCaveat")}
|
||||
<span role="img" aria-hidden="true">
|
||||
⚠️
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
{t("errorSplash.trackedToSentry", {
|
||||
eventId: this.state.sentryEventId,
|
||||
})}
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
<Trans
|
||||
i18nKey="errorSplash.openIssueMessage"
|
||||
button={(el) => (
|
||||
<button onClick={() => this.createGithubIssue()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
<div className="ErrorSplash-details">
|
||||
<label>{t("errorSplash.sceneContent")}</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
onPointerDown={this.selectTextArea}
|
||||
readOnly={true}
|
||||
value={this.state.localStorage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
|
||||
{};
|
||||
private static TIMES_AVG: Record<
|
||||
string,
|
||||
{ t: number; times: number[]; avg: number | null }
|
||||
> = {};
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
console.info(
|
||||
name,
|
||||
lessPrecise(times.reduce((a, b) => a + b)),
|
||||
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
|
||||
);
|
||||
Debug.TIMES_AGGR[name] = { t, times: [] };
|
||||
}
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
avg:
|
||||
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AGGR[name].t = now;
|
||||
};
|
||||
public static logTimeAverage = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AVG[name].t = now;
|
||||
};
|
||||
|
||||
private static logWrapper =
|
||||
(type: "logTime" | "logTimeAverage") =>
|
||||
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
public static logTimeWrap = Debug.logWrapper("logTime");
|
||||
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
|
||||
|
||||
public static perfWrap = <T extends any[], R>(
|
||||
fn: (...args: T) => R,
|
||||
name = "default",
|
||||
) => {
|
||||
return (...args: T) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.time(name);
|
||||
const ret = fn(...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd(name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
}
|
||||
//@ts-ignore
|
||||
window.debug = Debug;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
interface Window {
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { User } from "../app-jotai";
|
||||
|
||||
export const useAuth = (setUser: (user: User | null) => void) => {
|
||||
useEffect(() => {
|
||||
// Check for token in URL params, which happens after GitHub login redirect.
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = searchParams.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
// Clean the token from the URL.
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem("token");
|
||||
if (storedToken) {
|
||||
try {
|
||||
const decodedToken: any = jwtDecode(storedToken);
|
||||
// Check if token is expired.
|
||||
if (decodedToken.exp * 1000 > Date.now()) {
|
||||
setUser({
|
||||
id: decodedToken.userId,
|
||||
githubId: decodedToken.githubId,
|
||||
login: decodedToken.login,
|
||||
avatarUrl: decodedToken.avatarUrl,
|
||||
name: decodedToken.name,
|
||||
});
|
||||
} else {
|
||||
// Token is expired, remove it.
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid token:", error);
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}, [setUser]);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
IStorageAdapter,
|
||||
CanvasMetadata,
|
||||
CanvasData,
|
||||
} from "../data/storage";
|
||||
import { AuthError } from "../data/storageAdapters/BackendStorageAdapter";
|
||||
import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||
import { User, currentCanvasIdAtom } from "../app-jotai";
|
||||
import { CREATIONS_SIDEBAR_NAME } from "../app_constants";
|
||||
|
||||
export const useCanvasManagement = ({
|
||||
storageAdapter,
|
||||
excalidrawAPI,
|
||||
user,
|
||||
setErrorMessage,
|
||||
resetSaveStatus,
|
||||
}: {
|
||||
storageAdapter: IStorageAdapter;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI | null | undefined;
|
||||
user: User | null;
|
||||
setErrorMessage: (msg: string) => void;
|
||||
resetSaveStatus: () => void;
|
||||
}) => {
|
||||
const [canvases, setCanvases] = useState<CanvasMetadata[]>([]);
|
||||
const [currentCanvasId, setCurrentCanvasId] = useAtom(currentCanvasIdAtom);
|
||||
|
||||
const refreshCanvases = useCallback(async () => {
|
||||
try {
|
||||
const canvases = await storageAdapter.listCanvases();
|
||||
console.log("canvases", canvases);
|
||||
setCanvases(canvases);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage("Could not list your creations.");
|
||||
}
|
||||
}, [storageAdapter, setErrorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCanvases();
|
||||
}, [refreshCanvases]);
|
||||
|
||||
const openSidebar = excalidrawAPI?.getAppState().openSidebar;
|
||||
useEffect(() => {
|
||||
if (
|
||||
openSidebar?.name === "default" &&
|
||||
openSidebar?.tab === CREATIONS_SIDEBAR_NAME
|
||||
) {
|
||||
refreshCanvases();
|
||||
}
|
||||
}, [openSidebar, refreshCanvases]);
|
||||
|
||||
const handleCanvasSelect = useCallback(
|
||||
async (id: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canvasData = await storageAdapter.loadCanvas(id);
|
||||
if (canvasData) {
|
||||
excalidrawAPI.updateScene({ appState: { openSidebar: null } });
|
||||
excalidrawAPI.addFiles(Object.values(canvasData.files));
|
||||
excalidrawAPI.updateScene({
|
||||
elements: canvasData.elements,
|
||||
appState: canvasData.appState,
|
||||
commitToHistory: true,
|
||||
});
|
||||
setCurrentCanvasId(id);
|
||||
resetSaveStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage("Could not load the canvas.");
|
||||
}
|
||||
},
|
||||
[storageAdapter, excalidrawAPI, setErrorMessage, setCurrentCanvasId, resetSaveStatus],
|
||||
);
|
||||
|
||||
const handleCanvasDelete = useCallback(
|
||||
async (id: string) => {
|
||||
if (window.confirm("Are you sure you want to delete this canvas?")) {
|
||||
try {
|
||||
await storageAdapter.deleteCanvas(id);
|
||||
if (currentCanvasId === id) {
|
||||
setCurrentCanvasId(null);
|
||||
excalidrawAPI?.resetScene();
|
||||
resetSaveStatus();
|
||||
}
|
||||
await refreshCanvases();
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能删除此画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not delete the canvas.");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
currentCanvasId,
|
||||
setCurrentCanvasId,
|
||||
excalidrawAPI,
|
||||
resetSaveStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasCreate = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appState = { ...excalidrawAPI.getAppState(), name: newName };
|
||||
const newCanvasData = {
|
||||
elements: [],
|
||||
appState,
|
||||
files: {},
|
||||
};
|
||||
const createdCanvas = await storageAdapter.createCanvas(
|
||||
newCanvasData as CanvasData,
|
||||
);
|
||||
await refreshCanvases();
|
||||
excalidrawAPI.resetScene();
|
||||
excalidrawAPI.updateScene({ appState: { name: newName } });
|
||||
setCurrentCanvasId(createdCanvas.id);
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能创建新画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not create new canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
excalidrawAPI,
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
setCurrentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasRename = useCallback(
|
||||
async (id: string, newName: string) => {
|
||||
try {
|
||||
await storageAdapter.renameCanvas(id, newName);
|
||||
await refreshCanvases();
|
||||
if (excalidrawAPI && currentCanvasId === id) {
|
||||
excalidrawAPI.updateScene({ appState: { name: newName } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能重命名此画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not rename the canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
excalidrawAPI,
|
||||
currentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasSaveAs = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appState = { ...excalidrawAPI.getAppState(), name: newName };
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
const files = excalidrawAPI.getFiles();
|
||||
|
||||
const newCanvasData = {
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
};
|
||||
const createdCanvas = await storageAdapter.createCanvas(
|
||||
newCanvasData as CanvasData,
|
||||
);
|
||||
await refreshCanvases();
|
||||
// After saving as, we should switch to the new canvas
|
||||
setCurrentCanvasId(createdCanvas.id);
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能另存为新画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not save as new canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
excalidrawAPI,
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
setCurrentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
canvases,
|
||||
handleCanvasSelect,
|
||||
handleCanvasDelete,
|
||||
handleCanvasCreate,
|
||||
handleCanvasRename,
|
||||
handleCanvasSaveAs,
|
||||
refreshCanvases,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="referrer" content="origin" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#121212" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta property="og:image:alt" content="Excalidraw logo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:site" content="@excalidraw" />
|
||||
<meta property="twitter:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<style>
|
||||
html.dark {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
<% if ("%PROD%" === "true") { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
var scriptEle = document.createElement("script");
|
||||
scriptEle.setAttribute(
|
||||
"src",
|
||||
"https://scripts.simpleanalyticscdn.com/latest.js",
|
||||
);
|
||||
scriptEle.setAttribute("type", "text/javascript");
|
||||
scriptEle.setAttribute("defer", true);
|
||||
scriptEle.setAttribute("async", true);
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.setAttribute("data-auto-collect", true);
|
||||
}
|
||||
|
||||
document.body.appendChild(scriptEle);
|
||||
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.addEventListener("load", () => {
|
||||
if (window.sa_pageview) {
|
||||
window.window.sa_event(action, {
|
||||
category: "iframe",
|
||||
label: "embed",
|
||||
value: window.location.pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
&.theme--dark {
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.encrypted-icon {
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--color-primary);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
&.ExcalidrawPlus {
|
||||
color: var(--color-promo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #064e3c;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collab-offline-warning {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw-app.is-collaborating {
|
||||
[data-testid="clear-canvas-button"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-size: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-button {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./App";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "../excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
registerSW();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "excalidraw-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.842.0"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "pnpm build:app && pnpm build:version",
|
||||
"start": "vite",
|
||||
"start:production": "pnpm build && npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "pnpm build && vite preview --port 5000"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import * as SentryIntegrations from "@sentry/integrations";
|
||||
|
||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||
"excalidraw.com": "production",
|
||||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
|
||||
|
||||
// Disable Sentry locally or inside the Docker to avoid noise/respect privacy
|
||||
const onlineEnv =
|
||||
!SENTRY_DISABLED &&
|
||||
Object.keys(SentryEnvHostnameMap).find(
|
||||
(item) => window.location.hostname.indexOf(item) >= 0,
|
||||
);
|
||||
|
||||
Sentry.init({
|
||||
dsn: onlineEnv
|
||||
? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260"
|
||||
: undefined,
|
||||
environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
|
||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||
ignoreErrors: [
|
||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||
],
|
||||
integrations: [
|
||||
new SentryIntegrations.CaptureConsole({
|
||||
levels: ["error"],
|
||||
}),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.ShareDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
@include isMobile {
|
||||
height: calc(100vh - 5rem);
|
||||
}
|
||||
|
||||
&__separator {
|
||||
border-top: 1px solid var(--dialog-border-color);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1em;
|
||||
|
||||
span {
|
||||
background: var(--island-bg-color);
|
||||
padding: 0px 0.75rem;
|
||||
transform: translateY(-1ch);
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@keyframes ShareDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0.125rem 0.5rem;
|
||||
gap: 0.125rem;
|
||||
|
||||
height: 1.125rem;
|
||||
|
||||
border: none;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: ShareDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__picker {
|
||||
font-family: "Assistant";
|
||||
|
||||
&__illustration {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
& svg {
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: 1.3125rem;
|
||||
line-height: 130%;
|
||||
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
line-height: 150%;
|
||||
|
||||
text-align: center;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
& strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__active {
|
||||
&__share {
|
||||
display: none !important;
|
||||
|
||||
@include isMobile {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__linkRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
LinkIcon,
|
||||
playerPlayIcon,
|
||||
playerStopFilledIcon,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
|
||||
type OnExportToBackend = () => void;
|
||||
type ShareDialogType = "share" | "collaborationOnly";
|
||||
|
||||
export const shareDialogStateAtom = atom<
|
||||
{ isOpen: false } | { isOpen: true; type: ShareDialogType }
|
||||
>({ isOpen: false });
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
export type ShareDialogProps = {
|
||||
collabAPI: CollabAPI | null;
|
||||
handleClose: () => void;
|
||||
onExportToBackend: OnExportToBackend;
|
||||
type: ShareDialogType;
|
||||
};
|
||||
|
||||
const ActiveRoomDialog = ({
|
||||
collabAPI,
|
||||
activeRoomLink,
|
||||
handleClose,
|
||||
}: {
|
||||
collabAPI: CollabAPI;
|
||||
activeRoomLink: string;
|
||||
handleClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
} catch (e) {
|
||||
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const shareRoomLink = async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("roomDialog.shareTitle"),
|
||||
text: t("roomDialog.shareTitle"),
|
||||
url: activeRoomLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Just ignore.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="ShareDialog__active__header">
|
||||
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||
</h3>
|
||||
<TextField
|
||||
defaultValue={collabAPI.getUsername()}
|
||||
placeholder="Your name"
|
||||
label="Your name"
|
||||
onChange={collabAPI.setUsername}
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||
/>
|
||||
<div className="ShareDialog__active__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={activeRoomLink}
|
||||
/>
|
||||
{isShareSupported && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
icon={getShareIcon()}
|
||||
className="ShareDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="ShareDialog__active__description__emoji"
|
||||
>
|
||||
🔒{" "}
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__active__actions">
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
collabAPI.stopCollaboration();
|
||||
if (!collabAPI.isCollaborating()) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { collabAPI } = props;
|
||||
|
||||
const startCollabJSX = collabAPI ? (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__description">
|
||||
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
collabAPI.startCollaboration(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.type === "share" && (
|
||||
<div className="ShareDialog__separator">
|
||||
<span>{t("shareDialog.or")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{startCollabJSX}
|
||||
|
||||
{props.type === "share" && (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
{t("exportDialog.link_title")}
|
||||
</div>
|
||||
<div className="ShareDialog__picker__description">
|
||||
{t("exportDialog.link_details")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("exportDialog.link_button")}
|
||||
icon={LinkIcon}
|
||||
onClick={async () => {
|
||||
await props.onExportToBackend();
|
||||
props.handleClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShareDialogInner = (props: ShareDialogProps) => {
|
||||
const activeRoomLink = useAtomValue(activeRoomLinkAtom);
|
||||
|
||||
return (
|
||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||
<div className="ShareDialog">
|
||||
{props.collabAPI && activeRoomLink ? (
|
||||
<ActiveRoomDialog
|
||||
collabAPI={props.collabAPI}
|
||||
activeRoomLink={activeRoomLink}
|
||||
handleClose={props.handleClose}
|
||||
/>
|
||||
) : (
|
||||
<ShareDialogPicker {...props} />
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareDialog = (props: {
|
||||
collabAPI: CollabAPI | null;
|
||||
onExportToBackend: OnExportToBackend;
|
||||
}) => {
|
||||
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
|
||||
if (!shareDialogState.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareDialogInner
|
||||
handleClose={() => setShareDialogState({ isOpen: false })}
|
||||
collabAPI={props.collabAPI}
|
||||
onExportToBackend={props.onExportToBackend}
|
||||
type={shareDialogState.type}
|
||||
></ShareDialogInner>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defaultLang } from "../../packages/excalidraw/i18n";
|
||||
import { UI } from "../../packages/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
render,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
// select rectangle tool to show properties menu
|
||||
UI.clickTool("rectangle");
|
||||
// english lang should display `thin` label
|
||||
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
|
||||
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
|
||||
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: "de-DE" },
|
||||
});
|
||||
// switching to german, `thin` label should no longer exist
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
|
||||
// reset language
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: defaultLang.code },
|
||||
});
|
||||
// switching back to English
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import ExcalidrawApp from "../App";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
|
||||
import { UI } from "../../packages/excalidraw/tests/helpers/ui";
|
||||
|
||||
describe("Test MobileMenu", () => {
|
||||
const { h } = window;
|
||||
const dimensions = { height: 400, width: 800 };
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect(dimensions);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||
expect(document.querySelector(".welcome-screen-center")).toMatchSnapshot();
|
||||
UI.clickTool("rectangle");
|
||||
expect(document.querySelector(".welcome-screen-center")).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
updateSceneData,
|
||||
waitFor,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
import ExcalidrawApp from "../App";
|
||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
||||
const { h } = window;
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: {
|
||||
getRandomValues: (arr: number[]) =>
|
||||
arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))),
|
||||
subtle: {
|
||||
generateKey: () => {},
|
||||
exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||
const loadFromFirebase = async () => null;
|
||||
const saveToFirebase = () => {};
|
||||
const isSavedToFirebase = () => true;
|
||||
const loadFilesFromFirebase = async () => ({
|
||||
loadedFiles: [],
|
||||
erroredFiles: [],
|
||||
});
|
||||
const saveFilesToFirebase = async () => ({
|
||||
savedFiles: new Map(),
|
||||
erroredFiles: new Map(),
|
||||
});
|
||||
|
||||
return {
|
||||
loadFromFirebase,
|
||||
saveToFirebase,
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
saveFilesToFirebase,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("socket.io-client", () => {
|
||||
return {
|
||||
default: () => {
|
||||
return {
|
||||
close: () => {},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
off: () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("collaboration", () => {
|
||||
it("creating room should reset deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// To update the scene with deleted elements before starting collab
|
||||
updateSceneData({
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", id: "A" }),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "B",
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
window.collab.startCollaboration(null);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
// noop
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
BroadcastedExcalidrawElement,
|
||||
ReconciledElements,
|
||||
reconcileElements,
|
||||
} from "../../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../../packages/excalidraw/random";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
id: string;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
||||
};
|
||||
|
||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
||||
|
||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
||||
let uid: string;
|
||||
let id: string;
|
||||
let version: number | null;
|
||||
let parent: string | null = null;
|
||||
let versionNonce: number | null = null;
|
||||
if ("uid" in opts) {
|
||||
const match = opts.uid.match(
|
||||
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
||||
)!;
|
||||
parent = match[1];
|
||||
id = match[2];
|
||||
version = match[3] ? parseInt(match[3]) : null;
|
||||
uid = version ? `${id}:${version}` : id;
|
||||
} else {
|
||||
({ id, version, versionNonce } = opts);
|
||||
parent = parent || null;
|
||||
uid = id;
|
||||
}
|
||||
return {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
versionNonce: versionNonce || randomInteger(),
|
||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
||||
};
|
||||
};
|
||||
|
||||
const idsToElements = (
|
||||
ids: (Id | ElementLike)[],
|
||||
cache: Cache = {},
|
||||
): readonly ExcalidrawElement[] => {
|
||||
return ids.reduce((acc, _uid, idx) => {
|
||||
const {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
versionNonce,
|
||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
||||
const cached = cache[uid];
|
||||
const elem = {
|
||||
id,
|
||||
version: version ?? 0,
|
||||
versionNonce,
|
||||
...cached,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
} as BroadcastedExcalidrawElement;
|
||||
// @ts-ignore
|
||||
cache[uid] = elem;
|
||||
acc.push(elem);
|
||||
return acc;
|
||||
}, [] as ExcalidrawElement[]);
|
||||
};
|
||||
|
||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
||||
return elements.map((el, idx, els) => {
|
||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
||||
return el;
|
||||
});
|
||||
};
|
||||
|
||||
const cleanElements = (elements: ReconciledElements) => {
|
||||
return elements.map((el) => {
|
||||
// @ts-ignore
|
||||
delete el[PRECEDING_ELEMENT_KEY];
|
||||
// @ts-ignore
|
||||
delete el.next;
|
||||
// @ts-ignore
|
||||
delete el.prev;
|
||||
return el;
|
||||
});
|
||||
};
|
||||
|
||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
local: (Id | ElementLike)[],
|
||||
remote: (Id | ElementLike)[],
|
||||
target: U[],
|
||||
bidirectional = true,
|
||||
) => {
|
||||
const cache: Cache = {};
|
||||
const _local = idsToElements(local, cache);
|
||||
const _remote = idsToElements(remote, cache);
|
||||
const _target = target.map((uid) => {
|
||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
||||
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
||||
}) as any as ReconciledElements;
|
||||
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
||||
expect(target.length).equal(remoteReconciled.length);
|
||||
expect(cleanElements(remoteReconciled)).deep.equal(
|
||||
cleanElements(_target),
|
||||
"remote reconciliation",
|
||||
);
|
||||
|
||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
||||
if (bidirectional) {
|
||||
try {
|
||||
expect(
|
||||
cleanElements(
|
||||
reconcileElements(
|
||||
cloneJSON(__local),
|
||||
cloneJSON(__remote),
|
||||
{} as AppState,
|
||||
),
|
||||
),
|
||||
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
||||
} catch (error: any) {
|
||||
console.error("local original", __local);
|
||||
console.error("remote reconciled", __remote);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const findIndex = <T>(
|
||||
array: readonly T[],
|
||||
cb: (element: T, index: number, array: readonly T[]) => boolean,
|
||||
fromIndex: number = 0,
|
||||
) => {
|
||||
if (fromIndex < 0) {
|
||||
fromIndex = array.length + fromIndex;
|
||||
}
|
||||
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
||||
let index = fromIndex - 1;
|
||||
while (++index < array.length) {
|
||||
if (cb(array[index], index, array)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("elements reconciliation", () => {
|
||||
it("reconcileElements()", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
//
|
||||
// in following tests, we pass:
|
||||
// (1) an array of local elements and their version (:1, :2...)
|
||||
// (2) an array of remote elements and their version (:1, :2...)
|
||||
// (3) expected reconciled elements
|
||||
//
|
||||
// in the reconciled array:
|
||||
// :L means local element was resolved
|
||||
// :R means remote element was resolved
|
||||
//
|
||||
// if a remote element is prefixed with parentheses, the enclosed string:
|
||||
// (^) means the element is the first element in the array
|
||||
// (<id>) means the element is preceded by <id> element
|
||||
//
|
||||
// if versions are missing, it defaults to version 0
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// non-annotated elements
|
||||
// -------------------------------------------------------------------------
|
||||
// usually when we sync elements they should always be annotated with
|
||||
// their (preceding elements) parents, but let's test a couple of cases when
|
||||
// they're not for whatever reason (remote clients are on older version...),
|
||||
// in which case the first synced element either replaces existing element
|
||||
// or is pushed at the end of the array
|
||||
|
||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
||||
test(["A"], ["A:1"], ["A:R"]);
|
||||
|
||||
// C isn't added to the end because it follows B (even if B was resolved
|
||||
// to local version)
|
||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
||||
|
||||
// some of the following tests are kinda arbitrary and they're less
|
||||
// likely to happen in real-world cases
|
||||
|
||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:1", "A:3"],
|
||||
["B:L", "A:R", "C:L", "D:R"],
|
||||
);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:2", "A:3", "C"],
|
||||
["D:R", "B:L", "A:R", "C:L"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "C", "D", "E", "F"],
|
||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
||||
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
||||
);
|
||||
|
||||
// annotated elements
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["(B)X", "(A)Y", "(Y)Z"],
|
||||
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
||||
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
||||
|
||||
test(
|
||||
["A", "B"],
|
||||
["(A)C", "(^)D", "F"],
|
||||
["A:L", "C:R", "D:R", "F:R", "B:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(B)C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["(^)X", "(A)Y", "(B)Z"],
|
||||
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["B", "A", "C"],
|
||||
["(^)X", "(A)Y", "(B)Z"],
|
||||
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["(A)X", "(C)Y", "(D)Z"],
|
||||
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["X", "Y", "Z"],
|
||||
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["(C)X", "(A)Y", "(D)E:1"],
|
||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["A", "B", "C:1", "D:1"],
|
||||
["A:R", "B:L", "C:L", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "(C)B", "(B)D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "(C)B", "(B)D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
||||
["A:R", "B:L", "C:R", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(C)X", "(B)Y", "(A)Z"],
|
||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
||||
);
|
||||
|
||||
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
||||
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
||||
|
||||
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
||||
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
||||
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
||||
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
||||
});
|
||||
|
||||
it("test identical elements reconciliation", () => {
|
||||
const testIdentical = (
|
||||
local: ElementLike[],
|
||||
remote: ElementLike[],
|
||||
expected: Id[],
|
||||
) => {
|
||||
const ret = reconcileElements(
|
||||
local as any as ExcalidrawElement[],
|
||||
remote as any as ExcalidrawElement[],
|
||||
{} as AppState,
|
||||
);
|
||||
|
||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
||||
throw new Error("reconcileElements: duplicate elements found");
|
||||
}
|
||||
|
||||
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
||||
};
|
||||
|
||||
// identical id/version/versionNonce
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
testIdentical(
|
||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
||||
["A"],
|
||||
);
|
||||
testIdentical(
|
||||
[
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
],
|
||||
[
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
],
|
||||
["B", "A"],
|
||||
);
|
||||
testIdentical(
|
||||
[
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
],
|
||||
[
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
],
|
||||
["B", "A"],
|
||||
);
|
||||
|
||||
// actually identical (arrays and element objects)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const elements1 = [
|
||||
{
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
];
|
||||
|
||||
testIdentical(elements1, elements1, ["A", "B"]);
|
||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
||||
|
||||
const el1 = {
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
const el2 = {
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export const timeAgo = (date: string | number | Date): string => {
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 10) {
|
||||
return "刚刚";
|
||||
}
|
||||
if (diffInSeconds < 60) {
|
||||
return `${diffInSeconds} 秒前`;
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes} 分钟前`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} 小时前`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays} 天前`;
|
||||
}
|
||||
|
||||
return new Date(date).toLocaleDateString();
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
interface ImportMetaEnv {
|
||||
// The port to run the dev server
|
||||
VITE_APP_PORT: string;
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL: string;
|
||||
VITE_APP_BACKEND_V2_POST_URL: string;
|
||||
|
||||
// collaboration WebSocket server (https: string
|
||||
VITE_APP_WS_SERVER_URL: string;
|
||||
|
||||
// set this only if using the collaboration workflow we use on excalidraw.com
|
||||
VITE_APP_PORTAL_URL: string;
|
||||
VITE_APP_AI_BACKEND: string;
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG: string;
|
||||
|
||||
// whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
// debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
|
||||
|
||||
VITE_APP_DISABLE_SENTRY: string;
|
||||
|
||||
// Set this flag to false if you want to open the overlay by default
|
||||
VITE_APP_COLLAPSE_OVERLAY: string;
|
||||
|
||||
// Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT: string;
|
||||
|
||||
VITE_APP_PLUS_LP: string;
|
||||
|
||||
VITE_APP_PLUS_APP: string;
|
||||
|
||||
VITE_APP_GIT_SHA: string;
|
||||
|
||||
MODE: string;
|
||||
|
||||
DEV: string;
|
||||
PROD: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||
// open the browser
|
||||
open: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3002",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/auth": {
|
||||
target: "http://localhost:3002",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// We need to specify the envDir since now there are no
|
||||
//more located in parallel with the vite.config.ts file but in parent dir
|
||||
envDir: "../",
|
||||
build: {
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes("packages/excalidraw/locales") &&
|
||||
id.match(/en.json|percentages.json/) === null
|
||||
) {
|
||||
const index = id.indexOf("locales/");
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
eslint:
|
||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||
? undefined
|
||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||
overlay: {
|
||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||
},
|
||||
}),
|
||||
svgrPlugin(),
|
||||
ViteEjsPlugin(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
/* set this flag to true to enable in Development mode */
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("fonts.css"),
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "locales",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
name: "Excalidraw",
|
||||
description:
|
||||
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||
icons: [
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "apple-touch-icon.png",
|
||||
type: "image/png",
|
||||
sizes: "180x180",
|
||||
},
|
||||
{
|
||||
src: "favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
file_handlers: [
|
||||
{
|
||||
action: "/",
|
||||
accept: {
|
||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||
},
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: "/web-share-target",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
name: "file",
|
||||
accept: [
|
||||
"application/vnd.excalidraw+json",
|
||||
"application/json",
|
||||
".excalidraw",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/virtual-whiteboard.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/wireframe.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/illustration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/shapes.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/collaboration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/export.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
});
|
||||
Reference in New Issue
Block a user