init frontend

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