mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-10-29 08:04:52 +00:00
init frontend
This commit is contained in:
1 parent
94953a5eac
commit
602f4629ff
771 files changed
+194268
-1
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../packages/excalidraw/utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import { UIAppState } from "../packages/excalidraw/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
}, STORAGE_SIZE_TIMEOUT);
|
||||
|
||||
type Props = {
|
||||
setToast: (message: string) => void;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
};
|
||||
const CustomStats = (props: Props) => {
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
}, [props.elements, props.appState]);
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
const version = getVersion();
|
||||
let hash;
|
||||
let timestamp;
|
||||
|
||||
if (version !== DEFAULT_VERSION) {
|
||||
timestamp = version.slice(0, 16).replace("T", " ");
|
||||
hash = version.slice(21);
|
||||
} else {
|
||||
timestamp = t("stats.versionNotAvailable");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.storage")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.scene")}</td>
|
||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.version")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{ textAlign: "center", cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(getVersion());
|
||||
props.setToast(t("toast.copyToClipboard"));
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomStats;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../packages/excalidraw/jotai";
|
||||
import { StorageType } from "./components/StorageSettingsDialog";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
githubId: number;
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export const userAtom = atom<User | null>(null);
|
||||
|
||||
const baseCurrentCanvasIdAtom = atom<string | null>(
|
||||
localStorage.getItem("excalidraw-current-canvas-id"),
|
||||
);
|
||||
|
||||
export const currentCanvasIdAtom = atom(
|
||||
(get) => get(baseCurrentCanvasIdAtom),
|
||||
(get, set, newId: string | null) => {
|
||||
set(baseCurrentCanvasIdAtom, newId);
|
||||
if (newId) {
|
||||
localStorage.setItem("excalidraw-current-canvas-id", newId);
|
||||
} else {
|
||||
localStorage.removeItem("excalidraw-current-canvas-id");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Storage Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface StorageConfig {
|
||||
type: StorageType;
|
||||
// Cloudflare KV
|
||||
kvUrl?: string;
|
||||
kvApiToken?: string;
|
||||
// AWS S3
|
||||
s3AccessKeyId?: string;
|
||||
s3SecretAccessKey?: string;
|
||||
s3Region?: string;
|
||||
s3BucketName?: string;
|
||||
}
|
||||
|
||||
const STORAGE_CONFIG_LOCAL_STORAGE_KEY = "excalidraw-storage-config-type";
|
||||
const STORAGE_CONFIG_SESSION_STORAGE_KEY =
|
||||
"excalidraw-storage-config-credentials";
|
||||
|
||||
const getInitialStorageConfig = (): StorageConfig => {
|
||||
const defaultConfig: StorageConfig = { type: "default" };
|
||||
|
||||
try {
|
||||
const nonSensitive = localStorage.getItem(STORAGE_CONFIG_LOCAL_STORAGE_KEY);
|
||||
const sensitive = sessionStorage.getItem(
|
||||
STORAGE_CONFIG_SESSION_STORAGE_KEY,
|
||||
);
|
||||
|
||||
const nonSensitiveConfig = nonSensitive ? JSON.parse(nonSensitive) : {};
|
||||
const sensitiveConfig = sensitive ? JSON.parse(sensitive) : {};
|
||||
|
||||
return { ...defaultConfig, ...nonSensitiveConfig, ...sensitiveConfig };
|
||||
} catch (e) {
|
||||
console.error("Failed to load storage config", e);
|
||||
return defaultConfig;
|
||||
}
|
||||
};
|
||||
|
||||
const baseStorageConfigAtom = atom<StorageConfig>(getInitialStorageConfig());
|
||||
|
||||
export const storageConfigAtom = atom(
|
||||
(get) => get(baseStorageConfigAtom),
|
||||
(get, set, newConfig: StorageConfig) => {
|
||||
const {
|
||||
type,
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
} = newConfig;
|
||||
|
||||
const nonSensitive = { type };
|
||||
const sensitive = {
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_CONFIG_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(nonSensitive),
|
||||
);
|
||||
sessionStorage.setItem(
|
||||
STORAGE_CONFIG_SESSION_STORAGE_KEY,
|
||||
JSON.stringify(sensitive),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save storage config", e);
|
||||
}
|
||||
|
||||
set(baseStorageConfigAtom, newConfig);
|
||||
},
|
||||
);
|
||||
|
||||
// Dialog States
|
||||
// -----------------------------------------------------------------------------
|
||||
export const createCanvasDialogAtom = atom({ isOpen: false });
|
||||
|
||||
export const renameCanvasDialogAtom = atom<{
|
||||
isOpen: boolean;
|
||||
canvasId: string | null;
|
||||
currentName: string | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
canvasId: null,
|
||||
currentName: null,
|
||||
});
|
||||
|
||||
export const saveAsDialogAtom = atom({ isOpen: false });
|
||||
|
||||
export const appJotaiStore = jotaiStore;
|
||||
@@ -0,0 +1,60 @@
|
||||
// time constants (ms)
|
||||
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
||||
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
||||
export const FILE_UPLOAD_TIMEOUT = 300;
|
||||
export const LOAD_IMAGES_TIMEOUT = 500;
|
||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
export const CREATIONS_SIDEBAR_NAME = "creations";
|
||||
|
||||
export const WS_EVENTS = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
USER_FOLLOW_CHANGE: "user-follow",
|
||||
USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
|
||||
} as const;
|
||||
|
||||
export enum WS_SUBTYPES {
|
||||
INVALID_RESPONSE = "INVALID_RESPONSE",
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
MOUSE_LOCATION = "MOUSE_LOCATION",
|
||||
IDLE_STATUS = "IDLE_STATUS",
|
||||
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
|
||||
}
|
||||
|
||||
export const FIREBASE_STORAGE_PREFIXES = {
|
||||
shareLinkFiles: `/files/shareLinks`,
|
||||
collabFiles: `/files/rooms`,
|
||||
};
|
||||
|
||||
export const ROOM_ID_BYTES = 10;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
export const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
export default (sentryErrorId) => `
|
||||
### Scene content
|
||||
|
||||
\`\`\`
|
||||
Paste scene content here
|
||||
\`\`\`
|
||||
|
||||
### Sentry Error ID
|
||||
|
||||
${sentryErrorId}
|
||||
`;
|
||||
@@ -0,0 +1,997 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import {
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
throttleRAF,
|
||||
} from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SUBTYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
loadFromFirebase,
|
||||
saveFilesToFirebase,
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import { UserIdleState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../packages/excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string | null;
|
||||
/** errors related to saving */
|
||||
dialogNotifiedErrors: Record<string, boolean>;
|
||||
username: string;
|
||||
activeRoomLink: string | null;
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
export interface CollabAPI {
|
||||
/** function so that we can access the latest value from stale callbacks */
|
||||
isCollaborating: () => boolean;
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
startCollaboration: CollabInstance["startCollaboration"];
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
}
|
||||
|
||||
interface CollabProps {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}
|
||||
|
||||
class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: CollabProps["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorMessage: null,
|
||||
dialogNotifiedErrors: {},
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: null,
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
|
||||
},
|
||||
saveFiles: async ({ addedFiles }) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return saveFilesToFirebase({
|
||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||
files: await encodeFilesForUpload({
|
||||
files: addedFiles,
|
||||
encryptionKey: roomKey,
|
||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
private onUmmount: (() => void) | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.addEventListener("online", this.onOfflineStatusToggle);
|
||||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
|
||||
this.portal.socket && this.portal.broadcastUserFollowed(payload);
|
||||
});
|
||||
const throttledRelayUserViewportBounds = throttleRAF(
|
||||
this.relayVisibleSceneBounds,
|
||||
);
|
||||
const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
|
||||
throttledRelayUserViewportBounds(),
|
||||
);
|
||||
this.onUmmount = () => {
|
||||
unsubOnUserFollow();
|
||||
unsubOnScrollChange();
|
||||
};
|
||||
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
startCollaboration: this.startCollaboration,
|
||||
syncElements: this.syncElements,
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setCollabError: this.setErrorDialog,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI as any);
|
||||
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
Object.defineProperties(window, {
|
||||
collab: {
|
||||
configurable: true,
|
||||
value: this,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onOfflineStatusToggle = () => {
|
||||
appJotaiStore.set(isOfflineAtom, !window.navigator.onLine as any);
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("online", this.onOfflineStatusToggle);
|
||||
window.removeEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
window.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
this.onVisibilityChange,
|
||||
);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
this.onUmmount?.();
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
|
||||
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||
appJotaiStore.set(isCollaboratingAtom, isCollaborating as any);
|
||||
};
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
if (
|
||||
this.isCollaborating() &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
// this won't run in time if user decides to leave the site, but
|
||||
// the purpose is to run in immediately after user decides to stay
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
|
||||
preventUnload(event);
|
||||
}
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
this.resetErrorIndicator();
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||
? t("errors.collabSaveFailed_sizeExceeded")
|
||||
: t("errors.collabSaveFailed");
|
||||
|
||||
if (
|
||||
!this.state.dialogNotifiedErrors[errorMessage] ||
|
||||
!this.isCollaborating()
|
||||
) {
|
||||
this.setErrorDialog(errorMessage);
|
||||
this.setState({
|
||||
dialogNotifiedErrors: {
|
||||
...this.state.dialogNotifiedErrors,
|
||||
[errorMessage]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isCollaborating()) {
|
||||
this.setErrorIndicator(errorMessage);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
stopCollaboration = (keepRemoteState = true) => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
this.resetErrorIndicator(true);
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepRemoteState) {
|
||||
LocalData.fileStorage.reset();
|
||||
this.destroySocketClient();
|
||||
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
// hack to ensure that we prefer we disregard any new browser state
|
||||
// that could have been saved in other tabs while we were collaborating
|
||||
resetBrowserStateVersions();
|
||||
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
|
||||
LocalData.fileStorage.reset();
|
||||
|
||||
const elements = this.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setActiveRoomLink(null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
LocalData.resumeSave("collaboration");
|
||||
}
|
||||
};
|
||||
|
||||
private fetchImageFilesFromFirebase = async (opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
/**
|
||||
* Indicates whether to fetch files that are errored or pending and older
|
||||
* than 10 seconds.
|
||||
*
|
||||
* Use this as a mechanism to fetch files which may be ok but for some
|
||||
* reason their status was not updated correctly.
|
||||
*/
|
||||
forceFetchFiles?: boolean;
|
||||
}) => {
|
||||
const unfetchedImages = opts.elements
|
||||
.filter((element) => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
!this.fileManager.isFileHandled(element.fileId) &&
|
||||
!element.isDeleted &&
|
||||
(opts.forceFetchFiles
|
||||
? element.status !== "pending" ||
|
||||
Date.now() - element.updated > 10000
|
||||
: element.status === "saved")
|
||||
);
|
||||
})
|
||||
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
|
||||
|
||||
return await this.fileManager.getFiles(unfetchedImages);
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
try {
|
||||
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
|
||||
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
return {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private fallbackInitializationHandler: null | (() => any) = null;
|
||||
|
||||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.setUsername(username);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let roomId;
|
||||
let roomKey;
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
({ roomId, roomKey } = existingRoomLinkData);
|
||||
} else {
|
||||
({ roomId, roomKey } = await generateCollaborationLinkData());
|
||||
window.history.pushState(
|
||||
{},
|
||||
APP_NAME,
|
||||
getCollaborationLink({ roomId, roomKey }),
|
||||
);
|
||||
}
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.setIsCollaborating(true);
|
||||
LocalData.pauseSave("collaboration");
|
||||
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
);
|
||||
|
||||
const fallbackInitializationHandler = () => {
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
}).then((scene) => {
|
||||
scenePromise.resolve(scene);
|
||||
});
|
||||
};
|
||||
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||
|
||||
try {
|
||||
this.portal.socket = this.portal.open(
|
||||
socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
}),
|
||||
roomId,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setErrorDialog(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existingRoomLinkData) {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.history.clear();
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_INIT message
|
||||
this.socketInitializationTimer = window.setTimeout(
|
||||
fallbackInitializationHandler,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedData = await this.decryptPayload(
|
||||
iv,
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||
return;
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({
|
||||
elements: reconciledElements,
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
const { pointer, button, username, selectedElementIds } =
|
||||
decryptedData.payload;
|
||||
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
this.updateCollaborator(socketId, {
|
||||
pointer,
|
||||
button,
|
||||
selectedElementIds,
|
||||
username,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
|
||||
const { sceneBounds, socketId } = decryptedData.payload;
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
// we're not following the user
|
||||
// (shouldn't happen, but could be late message or bug upstream)
|
||||
if (appState.userToFollow?.socketId !== socketId) {
|
||||
console.warn(
|
||||
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// cross-follow case, ignore updates in this case
|
||||
if (
|
||||
appState.userToFollow &&
|
||||
appState.followedBy.has(appState.userToFollow.socketId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: zoomToFitBounds({
|
||||
appState,
|
||||
bounds: sceneBounds,
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 1,
|
||||
}).appState,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.IDLE_STATUS: {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
this.updateCollaborator(socketId, {
|
||||
userState,
|
||||
username,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
assertNever(decryptedData, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.portal.socket.on("first-in-room", async () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
const sceneData = await this.initializeRoom({
|
||||
fetchScene: true,
|
||||
roomLinkData: existingRoomLinkData,
|
||||
});
|
||||
scenePromise.resolve(sceneData);
|
||||
});
|
||||
|
||||
this.portal.socket.on(
|
||||
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
|
||||
(followedBy: SocketId[]) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { followedBy: new Set(followedBy) },
|
||||
});
|
||||
|
||||
this.relayVisibleSceneBounds({ force: true });
|
||||
},
|
||||
);
|
||||
|
||||
this.initializeIdleDetector();
|
||||
|
||||
this.setActiveRoomLink(window.location.href);
|
||||
|
||||
return scenePromise;
|
||||
};
|
||||
|
||||
private initializeRoom = async ({
|
||||
fetchScene,
|
||||
roomLinkData,
|
||||
}:
|
||||
| {
|
||||
fetchScene: true;
|
||||
roomLinkData: { roomId: string; roomKey: string } | null;
|
||||
}
|
||||
| { fetchScene: false; roomLinkData?: null }) => {
|
||||
clearTimeout(this.socketInitializationTimer!);
|
||||
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||
this.portal.socket.off(
|
||||
"connect_error",
|
||||
this.fallbackInitializationHandler,
|
||||
);
|
||||
}
|
||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(
|
||||
roomLinkData.roomId,
|
||||
roomLinkData.roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(elements),
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
scrollToContent: true,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
} else {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
remoteElements = restoreElements(remoteElements, null);
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
localElements,
|
||||
remoteElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
// synchronously calls render.
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(reconciledElements),
|
||||
);
|
||||
|
||||
return reconciledElements;
|
||||
};
|
||||
|
||||
private loadImageFiles = throttle(async () => {
|
||||
const { loadedFiles, erroredFiles } =
|
||||
await this.fetchImageFilesFromFirebase({
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
|
||||
this.excalidrawAPI.addFiles(loadedFiles);
|
||||
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI: this.excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
}, LOAD_IMAGES_TIMEOUT);
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: !!init,
|
||||
});
|
||||
|
||||
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||
// right now we think this is the right tradeoff.
|
||||
this.excalidrawAPI.history.clear();
|
||||
|
||||
this.loadImageFiles();
|
||||
};
|
||||
|
||||
private onPointerMove = () => {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
|
||||
if (!this.activeIntervalId) {
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
this.onIdleStateChange(UserIdleState.AWAY);
|
||||
} else {
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
}
|
||||
};
|
||||
|
||||
private reportIdle = () => {
|
||||
this.onIdleStateChange(UserIdleState.IDLE);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
private reportActive = () => {
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
};
|
||||
|
||||
private initializeIdleDetector = () => {
|
||||
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: SocketId[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
collaborators.set(
|
||||
socketId,
|
||||
Object.assign({}, this.collaborators.get(socketId), {
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
}
|
||||
|
||||
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user: Mutable<Collaborator> = Object.assign(
|
||||
{},
|
||||
collaborators.get(socketId),
|
||||
updates,
|
||||
{
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
},
|
||||
);
|
||||
collaborators.set(socketId, user);
|
||||
this.collaborators = collaborators;
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
};
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
|
||||
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
||||
return this.lastBroadcastedOrReceivedSceneVersion;
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
onPointerUpdate = throttle(
|
||||
(payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => {
|
||||
payload.pointersMap.size < 2 &&
|
||||
this.portal.socket &&
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
},
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
);
|
||||
|
||||
relayVisibleSceneBounds = (props?: { force: boolean }) => {
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
|
||||
this.portal.broadcastVisibleSceneBounds(
|
||||
{
|
||||
sceneBounds: getVisibleSceneBounds(appState),
|
||||
},
|
||||
`follow@${this.portal.socket.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onIdleStateChange = (userState: UserIdleState) => {
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
|
||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
WS_SUBTYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
||||
const newVersion = Math.max(
|
||||
currentVersion,
|
||||
getSceneVersion(this.getSceneElementsIncludingDeleted()),
|
||||
);
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(
|
||||
() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
{ leading: false },
|
||||
);
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
setActiveRoomLink = (activeRoomLink: string | null) => {
|
||||
this.setState({ activeRoomLink });
|
||||
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink as any);
|
||||
};
|
||||
|
||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||
|
||||
setErrorIndicator = (errorMessage: string | null) => {
|
||||
appJotaiStore.set(collabErrorIndicatorAtom, {
|
||||
message: errorMessage,
|
||||
nonce: Date.now(),
|
||||
} as any);
|
||||
};
|
||||
|
||||
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
|
||||
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 } as any);
|
||||
if (resetDialogNotifiedErrors) {
|
||||
this.setState({
|
||||
dialogNotifiedErrors: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setErrorDialog = (errorMessage: string | null) => {
|
||||
this.setState({
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { errorMessage } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorMessage != null && (
|
||||
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
collab: InstanceType<typeof Collab>;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
export default Collab;
|
||||
|
||||
export type TCollabClass = Collab;
|
||||
@@ -0,0 +1,35 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.collab-errors-button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-inline-end: 1rem;
|
||||
|
||||
color: var(--color-danger);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collab-errors-button-shake {
|
||||
animation: strong-shake 0.15s 6;
|
||||
}
|
||||
|
||||
@keyframes strong-shake {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0eg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { warning } from "../../packages/excalidraw/components/icons";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./CollabError.scss";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export type ErrorIndicator = {
|
||||
message: string | null;
|
||||
/** used to rerun the useEffect responsible for animation */
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
const _collabErrorIndicatorAtom = atom<ErrorIndicator>({
|
||||
message: null,
|
||||
nonce: 0,
|
||||
});
|
||||
|
||||
export const collabErrorIndicatorAtom = atom(
|
||||
(get) => get(_collabErrorIndicatorAtom),
|
||||
(get, set, update: ErrorIndicator) => set(_collabErrorIndicatorAtom, update),
|
||||
);
|
||||
|
||||
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnimating(true);
|
||||
clearAnimationRef.current = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(clearAnimationRef.current);
|
||||
};
|
||||
}, [collabError.message, collabError.nonce]);
|
||||
|
||||
if (!collabError.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={collabError.message} long={true}>
|
||||
<div
|
||||
className={clsx("collab-errors-button", {
|
||||
"collab-errors-button-shake": isAnimating,
|
||||
})}
|
||||
>
|
||||
{warning}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
CollabError.displayName = "CollabError";
|
||||
|
||||
export default CollabError;
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
isSyncableElement,
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
} from "../data";
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
socket: Socket | null = null;
|
||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
broadcastedElementVersions: Map<string, number> = new Map();
|
||||
|
||||
constructor(collab: TCollabClass) {
|
||||
this.collab = collab;
|
||||
}
|
||||
|
||||
open(socket: Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
}
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SUBTYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.queueFileUpload.flush();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
this.roomKey = null;
|
||||
this.socketInitialized = false;
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !!(
|
||||
this.socketInitialized &&
|
||||
this.socket &&
|
||||
this.roomId &&
|
||||
this.roomKey
|
||||
);
|
||||
}
|
||||
|
||||
async _broadcastSocketData(
|
||||
data: SocketUpdateData,
|
||||
volatile: boolean = false,
|
||||
roomId?: string,
|
||||
) {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
roomId ?? this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queueFileUpload = throttle(async () => {
|
||||
try {
|
||||
await this.collab.fileManager.saveFiles({
|
||||
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
files: this.collab.excalidrawAPI.getFiles(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: this.collab.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||
// this will signal collaborators to pull image data from server
|
||||
// (using mutation instead of newElementWith otherwise it'd break
|
||||
// in-progress dragging)
|
||||
return newElementWith(element, { status: "saved" });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
});
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
// sync out only the elements we think we need to to save bandwidth.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
const syncableElements = allElements.reduce(
|
||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
||||
if (
|
||||
(syncAll ||
|
||||
!this.broadcastedElementVersions.has(element.id) ||
|
||||
element.version >
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
};
|
||||
|
||||
for (const syncableElement of syncableElements) {
|
||||
this.broadcastedElementVersions.set(
|
||||
syncableElement.id,
|
||||
syncableElement.version,
|
||||
);
|
||||
}
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: WS_SUBTYPES.IDLE_STATUS,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastMouseLocation = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
}) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastVisibleSceneBounds = (
|
||||
payload: {
|
||||
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
|
||||
},
|
||||
roomId: string,
|
||||
) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
|
||||
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
username: this.collab.state.username,
|
||||
sceneBounds: payload.sceneBounds,
|
||||
},
|
||||
};
|
||||
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
roomId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
|
||||
if (this.socket?.id) {
|
||||
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
playerStopFilledIcon,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
|
||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
||||
import "./RoomDialog.scss";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
export type RoomModalProps = {
|
||||
handleClose: () => void;
|
||||
activeRoomLink: string;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
};
|
||||
|
||||
export const RoomModal = ({
|
||||
activeRoomLink,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
username,
|
||||
onUsernameChange,
|
||||
handleClose,
|
||||
}: RoomModalProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const shareRoomLink = async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("roomDialog.shareTitle"),
|
||||
text: t("roomDialog.shareTitle"),
|
||||
url: activeRoomLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Just ignore.
|
||||
}
|
||||
};
|
||||
|
||||
if (activeRoomLink) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="RoomDialog__active__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</h3>
|
||||
<TextField
|
||||
value={username}
|
||||
placeholder="Your name"
|
||||
label="Your name"
|
||||
onChange={onUsernameChange}
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||
/>
|
||||
<div className="RoomDialog__active__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={activeRoomLink}
|
||||
/>
|
||||
{isShareSupported && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
icon={getShareIcon()}
|
||||
className="RoomDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="RoomDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="RoomDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="RoomDialog__active__description__emoji"
|
||||
>
|
||||
🔒{" "}
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__active__actions">
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="RoomDialog__inactive__illustration">
|
||||
<CollabImage />
|
||||
</div>
|
||||
<div className="RoomDialog__inactive__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__description">
|
||||
<strong>{t("roomDialog.desc_intro")}</strong>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__start_session">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomDialog = (props: RoomModalProps) => {
|
||||
return (
|
||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||
<div className="RoomDialog">
|
||||
<RoomModal {...props} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDialog;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
};
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: ExcalidrawElement | undefined,
|
||||
remote: BroadcastedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (
|
||||
local &&
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.draggingElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
// the lowest versionNonce
|
||||
(local.version === remote.version &&
|
||||
local.versionNonce < remote.versionNonce))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const reconcileElements = (
|
||||
localElements: readonly ExcalidrawElement[],
|
||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
||||
localAppState: AppState,
|
||||
): ReconciledElements => {
|
||||
const localElementsData =
|
||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
||||
|
||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
||||
|
||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
||||
|
||||
let cursor = 0;
|
||||
let offset = 0;
|
||||
|
||||
let remoteElementIdx = -1;
|
||||
for (const remoteElement of remoteElements) {
|
||||
remoteElementIdx++;
|
||||
|
||||
const local = localElementsData.get(remoteElement.id);
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// Unless the remote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
offset++;
|
||||
if (cursor === 0) {
|
||||
reconciledElements.unshift(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor - offset,
|
||||
]);
|
||||
} else {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
}
|
||||
} else {
|
||||
let idx = localElementsData.has(parent)
|
||||
? localElementsData.get(parent)![1]
|
||||
: null;
|
||||
if (idx != null) {
|
||||
idx += offset;
|
||||
}
|
||||
if (idx != null && idx >= cursor) {
|
||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
idx + 1 - offset,
|
||||
]);
|
||||
cursor = idx + 1;
|
||||
} else if (idx != null) {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
// no parent z-index information, local element exists → replace in place
|
||||
} else if (local) {
|
||||
reconciledElements[local[1]] = remoteElement;
|
||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
||||
// otherwise push to the end
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
||||
(element) => !duplicates.has(element),
|
||||
);
|
||||
|
||||
return ret as ReconciledElements;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { Footer } from "../../packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
|
||||
export const AppFooter = React.memo(() => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<EncryptedIcon />
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { userAtom, saveAsDialogAtom } from "../app-jotai";
|
||||
import {
|
||||
GithubIcon,
|
||||
saveAs,
|
||||
extraToolsIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import DropdownMenuItemLink from "../../packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
onStorageSettingsClick: () => void;
|
||||
}> = React.memo((props) => {
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const { t } = useI18n();
|
||||
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
|
||||
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/auth/github/login";
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
window.location.reload(); // Reload to clear all state
|
||||
};
|
||||
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.Item
|
||||
onSelect={() => setSaveAsDialog({ isOpen: true })}
|
||||
icon={saveAs}
|
||||
>
|
||||
Save as New Canvas...
|
||||
</MainMenu.Item>
|
||||
<MainMenu.DefaultItems.Export />
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Item
|
||||
onSelect={props.onStorageSettingsClick}
|
||||
icon={extraToolsIcon}
|
||||
>
|
||||
Data Source Settings...
|
||||
</MainMenu.Item>
|
||||
<MainMenu.Separator />
|
||||
{user ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
padding: "0 0.5rem",
|
||||
width: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
overflow: "hidden",
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login}
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{user.name || user.login}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
textAlign: "center",
|
||||
color: "inherit",
|
||||
flexShrink: 0,
|
||||
marginRight: "1rem",
|
||||
font: "var(--ui-font)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
onMouseOver={(e) =>
|
||||
(e.currentTarget.style.background = "var(--button-gray-1)")
|
||||
}
|
||||
onMouseOut={(e) =>
|
||||
(e.currentTarget.style.background = "transparent")
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<MainMenu.Item onSelect={handleLogin} icon={GithubIcon}>
|
||||
Login with GitHub
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<DropdownMenuItemLink
|
||||
icon={GithubIcon}
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</DropdownMenuItemLink>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</MainMenu.ItemCustom>
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { GithubIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "../app-jotai";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
const [user] = useAtom(userAtom);
|
||||
let headingContent;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
headingContent = t("welcomeScreen.app.center_heading_plus")
|
||||
.split(/(Excalidraw\+)/)
|
||||
.map((bit, idx) => {
|
||||
if (bit === "Excalidraw+") {
|
||||
return (
|
||||
<a
|
||||
style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeScreen>
|
||||
<WelcomeScreen.Hints.MenuHint>
|
||||
{t("welcomeScreen.app.menuHint")}
|
||||
</WelcomeScreen.Hints.MenuHint>
|
||||
<WelcomeScreen.Hints.ToolbarHint />
|
||||
<WelcomeScreen.Hints.HelpHint />
|
||||
<WelcomeScreen.Center>
|
||||
<WelcomeScreen.Center.Logo />
|
||||
<WelcomeScreen.Center.Heading>
|
||||
{headingContent}
|
||||
</WelcomeScreen.Center.Heading>
|
||||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
{!user && (
|
||||
<WelcomeScreen.Center.MenuItem
|
||||
onSelect={() => {
|
||||
window.location.href = "/auth/github/login";
|
||||
}}
|
||||
icon={GithubIcon}
|
||||
>
|
||||
Login with GitHub
|
||||
</WelcomeScreen.Center.MenuItem>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
</WelcomeScreen.Center>
|
||||
</WelcomeScreen>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { createCanvasDialogAtom } from "../app-jotai";
|
||||
|
||||
interface CreateCanvasDialogProps {
|
||||
onCanvasCreate: (name: string) => void;
|
||||
}
|
||||
|
||||
export const CreateCanvasDialog: React.FC<CreateCanvasDialogProps> = ({
|
||||
onCanvasCreate,
|
||||
}) => {
|
||||
const [name, setName] = useState("Untitled Canvas");
|
||||
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (name.trim()) {
|
||||
onCanvasCreate(name.trim());
|
||||
setCreateCanvasDialog({ isOpen: false });
|
||||
}
|
||||
}, [name, onCanvasCreate, setCreateCanvasDialog]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCreateCanvasDialog({ isOpen: false });
|
||||
}, [setCreateCanvasDialog]);
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Create New Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="Canvas Name"
|
||||
value={name}
|
||||
placeholder="Enter a name for your new canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Create"}
|
||||
onClick={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { shield } from "../../packages/excalidraw/components/icons";
|
||||
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const EncryptedIcon = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl" ? "marginLeft" : "marginRight"]:
|
||||
"calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
||||
const setLangCode = useSetAtom(appLangCodeAtom);
|
||||
|
||||
return (
|
||||
<select
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => setLangCode(target.value)}
|
||||
value={langCode}
|
||||
aria-label={t("buttons.selectLanguage")}
|
||||
style={style}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
.my-creations-tab {
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-primary-lighter);
|
||||
padding: 2rem;
|
||||
|
||||
svg {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--icon-fill-color-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--button-hover-border-color);
|
||||
}
|
||||
|
||||
&--active,
|
||||
&--active:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-thumbnail,
|
||||
&__card-thumbnail--placeholder {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: var(--canvas-background-color);
|
||||
border-bottom: 1px solid var(--button-border-color);
|
||||
}
|
||||
|
||||
&__card-thumbnail {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__card-thumbnail--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: var(--ui-font);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-muted-background);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__card-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__card-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__card-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-lighter);
|
||||
}
|
||||
|
||||
&__card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__card-rename,
|
||||
&__card-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary-lighter);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.my-creations-tab__item-thumbnail,
|
||||
.my-creations-tab__item-thumbnail--placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid var(--button-border-color);
|
||||
background-color: var(--canvas-background-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-rename,
|
||||
.my-creations-tab__item-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary-lighter);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.my-creations-tab__item-date {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.my-creations-tab__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
userAtom,
|
||||
createCanvasDialogAtom,
|
||||
renameCanvasDialogAtom,
|
||||
} from "../app-jotai";
|
||||
import { CanvasMetadata } from "../data/storage";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import {
|
||||
FreedrawIcon,
|
||||
LoadIcon,
|
||||
TrashIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import "./MyCreationsTab.scss";
|
||||
import clsx from "clsx";
|
||||
import { timeAgo } from "../utils/time";
|
||||
|
||||
interface MyCreationsTabProps {
|
||||
canvases: readonly CanvasMetadata[];
|
||||
onCanvasSelect: (id: string) => void;
|
||||
onCanvasDelete: (id: string) => void;
|
||||
currentCanvasId: string | null;
|
||||
}
|
||||
|
||||
export const MyCreationsTab: React.FC<MyCreationsTabProps> = ({
|
||||
canvases,
|
||||
onCanvasSelect,
|
||||
onCanvasDelete,
|
||||
currentCanvasId,
|
||||
}) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
|
||||
const setRenameCanvasDialog = useSetAtom(renameCanvasDialogAtom);
|
||||
|
||||
const sortedCanvases = [...canvases].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-creations-tab">
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<FilledButton
|
||||
label="Create New Canvas"
|
||||
onClick={() => setCreateCanvasDialog({ isOpen: true })}
|
||||
fullWidth
|
||||
>
|
||||
Create New Canvas
|
||||
</FilledButton>
|
||||
</div>
|
||||
<div className="my-creations-tab__grid">
|
||||
{canvases.length === 0 ? (
|
||||
<div className="my-creations-tab__empty">
|
||||
{LoadIcon}
|
||||
<p>You have no saved canvases yet.</p>
|
||||
<p>
|
||||
Create a new canvas to get started. It will be saved{" "}
|
||||
{user ? "to your account" : "in your browser"}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedCanvases.map((canvas) => (
|
||||
<div
|
||||
key={canvas.id}
|
||||
className={clsx("my-creations-tab__card", {
|
||||
"my-creations-tab__card--active": canvas.id === currentCanvasId,
|
||||
})}
|
||||
onClick={() => onCanvasSelect(canvas.id)}
|
||||
>
|
||||
{canvas.thumbnail ? (
|
||||
<img
|
||||
src={canvas.thumbnail}
|
||||
alt={canvas.name}
|
||||
className="my-creations-tab__card-thumbnail"
|
||||
/>
|
||||
) : (
|
||||
<div className="my-creations-tab__card-thumbnail--placeholder">
|
||||
空空如也
|
||||
</div>
|
||||
)}
|
||||
<div className="my-creations-tab__card-info">
|
||||
<div className="my-creations-tab__card-details">
|
||||
<span className="my-creations-tab__card-name">
|
||||
{canvas.name}
|
||||
</span>
|
||||
<span className="my-creations-tab__card-date">
|
||||
{timeAgo(canvas.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-creations-tab__card-actions">
|
||||
<button
|
||||
className="my-creations-tab__card-rename"
|
||||
title="Rename canvas"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameCanvasDialog({
|
||||
isOpen: true,
|
||||
canvasId: canvas.id,
|
||||
currentName: canvas.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{FreedrawIcon}
|
||||
</button>
|
||||
<button
|
||||
className="my-creations-tab__card-delete"
|
||||
title="Delete canvas"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCanvasDelete(canvas.id);
|
||||
}}
|
||||
>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useAtom } from "jotai";
|
||||
import { renameCanvasDialogAtom } from "../app-jotai";
|
||||
|
||||
interface RenameCanvasDialogProps {
|
||||
onCanvasRename: (id: string, newName: string) => void;
|
||||
}
|
||||
|
||||
export const RenameCanvasDialog: React.FC<RenameCanvasDialogProps> = ({
|
||||
onCanvasRename,
|
||||
}) => {
|
||||
const [dialogState, setDialogState] = useAtom(renameCanvasDialogAtom);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogState.isOpen && dialogState.currentName) {
|
||||
setName(dialogState.currentName);
|
||||
}
|
||||
}, [dialogState.isOpen, dialogState.currentName]);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
if (name.trim() && dialogState.canvasId) {
|
||||
onCanvasRename(dialogState.canvasId, name.trim());
|
||||
setDialogState({ isOpen: false, canvasId: null, currentName: null });
|
||||
}
|
||||
}, [name, dialogState.canvasId, onCanvasRename, setDialogState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDialogState({ isOpen: false, canvasId: null, currentName: null });
|
||||
}, [setDialogState]);
|
||||
|
||||
if (!dialogState.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Rename Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="New Name"
|
||||
value={name}
|
||||
placeholder="Enter a new name for the canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Rename"}
|
||||
onClick={handleRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { saveAsDialogAtom } from "../app-jotai";
|
||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||
|
||||
interface SaveAsDialogProps {
|
||||
onCanvasSaveAs: (name: string) => void;
|
||||
}
|
||||
|
||||
export const SaveAsDialog: React.FC<SaveAsDialogProps> = ({
|
||||
onCanvasSaveAs,
|
||||
}) => {
|
||||
const appState = useUIAppState();
|
||||
const [name, setName] = useState(appState.name || "Untitled Canvas");
|
||||
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
|
||||
|
||||
const handleSaveAs = useCallback(() => {
|
||||
if (name.trim()) {
|
||||
onCanvasSaveAs(name.trim());
|
||||
setSaveAsDialog({ isOpen: false });
|
||||
}
|
||||
}, [name, onCanvasSaveAs, setSaveAsDialog]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSaveAsDialog({ isOpen: false });
|
||||
}, [setSaveAsDialog]);
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={handleClose} title={"Save as New Canvas"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="Canvas Name"
|
||||
value={name}
|
||||
placeholder="Enter a name for the new canvas"
|
||||
onChange={setName}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveAs()}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton
|
||||
color="primary"
|
||||
label={"Save As"}
|
||||
onClick={handleSaveAs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { ExportImageIcon } from "../../packages/excalidraw/components/icons";
|
||||
|
||||
export const SaveAsImageUI: React.FC<{
|
||||
onSuccess: () => void;
|
||||
}> = ({ onSuccess }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
<div className="Card-icon">
|
||||
{React.cloneElement(ExportImageIcon as React.ReactElement, {
|
||||
style: {
|
||||
width: "2.8rem",
|
||||
height: "2.8rem",
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<h2>{t("buttons.exportImage")}</h2>
|
||||
<div className="Card-details">
|
||||
Save your canvas to a file in PNG, SVG or WebP format.
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("buttons.exportImage")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={true}
|
||||
onClick={onSuccess}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { Island } from "../../packages/excalidraw/components/Island";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { useAtom } from "jotai";
|
||||
import { storageConfigAtom } from "../app-jotai";
|
||||
|
||||
export type StorageType = "default" | "kv" | "s3" | "indexed-db";
|
||||
|
||||
const StorageSettingsDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
const [config, setConfig] = useAtom(storageConfigAtom);
|
||||
const [storageType, setStorageType] = useState<StorageType>(config.type);
|
||||
|
||||
// Local state for form inputs
|
||||
const [kvUrl, setKvUrl] = useState(config.kvUrl || "");
|
||||
const [kvApiToken, setKvApiToken] = useState(config.kvApiToken || "");
|
||||
const [s3AccessKeyId, setS3AccessKeyId] = useState(
|
||||
config.s3AccessKeyId || "",
|
||||
);
|
||||
const [s3SecretAccessKey, setS3SecretAccessKey] = useState(
|
||||
config.s3SecretAccessKey || "",
|
||||
);
|
||||
const [s3Region, setS3Region] = useState(config.s3Region || "");
|
||||
const [s3BucketName, setS3BucketName] = useState(config.s3BucketName || "");
|
||||
|
||||
const handleSave = () => {
|
||||
setConfig({
|
||||
type: storageType,
|
||||
kvUrl,
|
||||
kvApiToken,
|
||||
s3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
s3Region,
|
||||
s3BucketName,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (storageType) {
|
||||
case "kv":
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="KV URL"
|
||||
value={kvUrl}
|
||||
placeholder="Your Cloudflare KV URL"
|
||||
onChange={setKvUrl}
|
||||
/>
|
||||
<TextField
|
||||
label="API Token"
|
||||
value={kvApiToken}
|
||||
placeholder="Your Cloudflare API Token"
|
||||
onChange={setKvApiToken}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "s3":
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Access Key ID"
|
||||
value={s3AccessKeyId}
|
||||
placeholder="Your AWS Access Key ID"
|
||||
onChange={setS3AccessKeyId}
|
||||
/>
|
||||
<TextField
|
||||
label="Secret Access Key"
|
||||
value={s3SecretAccessKey}
|
||||
placeholder="Your AWS Secret Access Key"
|
||||
onChange={setS3SecretAccessKey}
|
||||
/>
|
||||
<TextField
|
||||
label="Region"
|
||||
value={s3Region}
|
||||
placeholder="e.g., us-east-1"
|
||||
onChange={setS3Region}
|
||||
/>
|
||||
<TextField
|
||||
label="Bucket Name"
|
||||
value={s3BucketName}
|
||||
placeholder="Your S3 Bucket Name"
|
||||
onChange={setS3BucketName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "indexed-db":
|
||||
return (
|
||||
<p>
|
||||
Your canvases are stored securely in your browser's local database.
|
||||
They are not synced online.
|
||||
</p>
|
||||
);
|
||||
case "default":
|
||||
default:
|
||||
return (
|
||||
<p>
|
||||
Your data is stored on the default backend of this Excalidraw
|
||||
instance. This requires you to be logged in.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onClose}
|
||||
title={"Data Source Settings"}
|
||||
className="storage-settings-dialog"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<p>
|
||||
Security Warning: Sensitive keys are stored only in your browser's
|
||||
session storage and are cleared when you close the tab.
|
||||
</p>
|
||||
|
||||
<select
|
||||
value={storageType}
|
||||
onChange={(e) => setStorageType(e.target.value as StorageType)}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
borderRadius: "var(--border-radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<option value="indexed-db">Browser (IndexedDB)</option>
|
||||
<option value="default">Default Backend (Online)</option>
|
||||
<option value="kv">Cloudflare KV (Online)</option>
|
||||
<option value="s3">Amazon S3 (Online)</option>
|
||||
</select>
|
||||
|
||||
<Island style={{ padding: "1rem" }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
>
|
||||
{renderForm()}
|
||||
</div>
|
||||
</Island>
|
||||
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
|
||||
>
|
||||
<FilledButton color="primary" label={"Save"} onClick={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageSettingsDialog;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import Trans from "../../packages/excalidraw/components/Trans";
|
||||
|
||||
interface TopErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
sentryEventId: string;
|
||||
localStorage: string;
|
||||
}
|
||||
|
||||
export class TopErrorBoundary extends React.Component<
|
||||
any,
|
||||
TopErrorBoundaryState
|
||||
> {
|
||||
state: TopErrorBoundaryState = {
|
||||
hasError: false,
|
||||
sentryEventId: "",
|
||||
localStorage: "",
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? this.errorSplash() : this.props.children;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: any) {
|
||||
const _localStorage: any = {};
|
||||
for (const [key, value] of Object.entries({ ...localStorage })) {
|
||||
try {
|
||||
_localStorage[key] = JSON.parse(value);
|
||||
} catch (error: any) {
|
||||
_localStorage[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras(errorInfo);
|
||||
const eventId = Sentry.captureException(error);
|
||||
|
||||
this.setState((state) => ({
|
||||
hasError: true,
|
||||
sentryEventId: eventId,
|
||||
localStorage: JSON.stringify(_localStorage),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
|
||||
if (event.target !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLTextAreaElement).select();
|
||||
}
|
||||
}
|
||||
|
||||
private async createGithubIssue() {
|
||||
let body = "";
|
||||
try {
|
||||
const templateStrFn = (
|
||||
await import(
|
||||
/* webpackChunkName: "bug-issue-template" */ "../bug-issue-template"
|
||||
)
|
||||
).default;
|
||||
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
private errorSplash() {
|
||||
return (
|
||||
<div className="ErrorSplash excalidraw">
|
||||
<div className="ErrorSplash-messageContainer">
|
||||
<div className="ErrorSplash-paragraph bigger align-center">
|
||||
<Trans
|
||||
i18nKey="errorSplash.headingMain"
|
||||
button={(el) => (
|
||||
<button onClick={() => window.location.reload()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph align-center">
|
||||
<Trans
|
||||
i18nKey="errorSplash.clearCanvasMessage"
|
||||
button={(el) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{el}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<br />
|
||||
<div className="smaller">
|
||||
<span role="img" aria-label="warning">
|
||||
⚠️
|
||||
</span>
|
||||
{t("errorSplash.clearCanvasCaveat")}
|
||||
<span role="img" aria-hidden="true">
|
||||
⚠️
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
{t("errorSplash.trackedToSentry", {
|
||||
eventId: this.state.sentryEventId,
|
||||
})}
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
<Trans
|
||||
i18nKey="errorSplash.openIssueMessage"
|
||||
button={(el) => (
|
||||
<button onClick={() => this.createGithubIssue()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
<div className="ErrorSplash-details">
|
||||
<label>{t("errorSplash.sceneContent")}</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
onPointerDown={this.selectTextArea}
|
||||
readOnly={true}
|
||||
value={this.state.localStorage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
|
||||
export class FileManager {
|
||||
/** files being fetched */
|
||||
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
/** files being saved */
|
||||
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
/* files already saved to persistent storage */
|
||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
erroredFiles: Map<FileId, true>;
|
||||
}>;
|
||||
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
||||
savedFiles: Map<FileId, true>;
|
||||
erroredFiles: Map<FileId, true>;
|
||||
}>;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns whether file is already saved or being processed
|
||||
*/
|
||||
isFileHandled = (id: FileId) => {
|
||||
return (
|
||||
this.savedFiles.has(id) ||
|
||||
this.fetchingFiles.has(id) ||
|
||||
this.savingFiles.has(id) ||
|
||||
this.erroredFiles.has(id)
|
||||
);
|
||||
};
|
||||
|
||||
isFileSaved = (id: FileId) => {
|
||||
return this.savedFiles.has(id);
|
||||
};
|
||||
|
||||
saveFiles = async ({
|
||||
elements,
|
||||
files,
|
||||
}: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
}) => {
|
||||
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
||||
|
||||
for (const element of elements) {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
files[element.fileId] &&
|
||||
!this.isFileHandled(element.fileId)
|
||||
) {
|
||||
addedFiles.set(element.fileId, files[element.fileId]);
|
||||
this.savingFiles.set(element.fileId, true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { savedFiles, erroredFiles } = await this._saveFiles({
|
||||
addedFiles,
|
||||
});
|
||||
|
||||
for (const [fileId] of savedFiles) {
|
||||
this.savedFiles.set(fileId, true);
|
||||
}
|
||||
|
||||
return {
|
||||
savedFiles,
|
||||
erroredFiles,
|
||||
};
|
||||
} finally {
|
||||
for (const [fileId] of addedFiles) {
|
||||
this.savingFiles.delete(fileId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getFiles = async (
|
||||
ids: FileId[],
|
||||
): Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
erroredFiles: Map<FileId, true>;
|
||||
}> => {
|
||||
if (!ids.length) {
|
||||
return {
|
||||
loadedFiles: [],
|
||||
erroredFiles: new Map(),
|
||||
};
|
||||
}
|
||||
for (const id of ids) {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
for (const file of loadedFiles) {
|
||||
this.savedFiles.set(file.id, true);
|
||||
}
|
||||
for (const [fileId] of erroredFiles) {
|
||||
this.erroredFiles.set(fileId, true);
|
||||
}
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
this.fetchingFiles.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** a file element prevents unload only if it's being saved regardless of
|
||||
* its `status`. This ensures that elements who for any reason haven't
|
||||
* beed set to `saved` status don't prevent unload in future sessions.
|
||||
* Technically we should prevent unload when the origin client haven't
|
||||
* yet saved the `status` update to storage, but that should be taken care
|
||||
* of during regular beforeUnload unsaved files check.
|
||||
*/
|
||||
shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
|
||||
return elements.some((element) => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
!element.isDeleted &&
|
||||
this.savingFiles.has(element.fileId)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* helper to determine if image element status needs updating
|
||||
*/
|
||||
shouldUpdateImageElementStatus = (
|
||||
element: ExcalidrawElement,
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
this.isFileSaved(element.fileId) &&
|
||||
element.status === "pending"
|
||||
);
|
||||
};
|
||||
|
||||
reset() {
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
this.erroredFiles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const encodeFilesForUpload = async ({
|
||||
files,
|
||||
maxBytes,
|
||||
encryptionKey,
|
||||
}: {
|
||||
files: Map<FileId, BinaryFileData>;
|
||||
maxBytes: number;
|
||||
encryptionKey: string;
|
||||
}) => {
|
||||
const processedFiles: {
|
||||
id: FileId;
|
||||
buffer: Uint8Array;
|
||||
}[] = [];
|
||||
|
||||
for (const [id, fileData] of files) {
|
||||
const buffer = new TextEncoder().encode(fileData.dataURL);
|
||||
|
||||
const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
|
||||
encryptionKey,
|
||||
metadata: {
|
||||
id,
|
||||
mimeType: fileData.mimeType,
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
throw new Error(
|
||||
t("errors.fileTooBig", {
|
||||
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
processedFiles.push({
|
||||
id,
|
||||
buffer: encodedFile,
|
||||
});
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
};
|
||||
|
||||
export const updateStaleImageStatuses = (params: {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
erroredFiles: Map<FileId, true>;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
}) => {
|
||||
if (!params.erroredFiles.size) {
|
||||
return;
|
||||
}
|
||||
params.excalidrawAPI.updateScene({
|
||||
elements: params.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
params.erroredFiles.has(element.fileId)
|
||||
) {
|
||||
return newElementWith(element, {
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* This file deals with saving data state (appState, elements, images, ...)
|
||||
* locally to the browser.
|
||||
*
|
||||
* Notes:
|
||||
*
|
||||
* - DataState refers to full state of the app: appState, elements, images,
|
||||
* though some state is saved separately (collab username, library) for one
|
||||
* reason or another. We also save different data to different storage
|
||||
* (localStorage, indexedDB).
|
||||
*/
|
||||
|
||||
import {
|
||||
createStore,
|
||||
entries,
|
||||
del,
|
||||
getMany,
|
||||
set,
|
||||
setMany,
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||
import { debounce } from "../../packages/excalidraw/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
await entries(filesStore).then((entries) => {
|
||||
for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
|
||||
// if image is unused (not on canvas) & is older than 1 day, delete it
|
||||
// from storage. We check `lastRetrieved` we care about the last time
|
||||
// the image was used (loaded on canvas), not when it was initially
|
||||
// created.
|
||||
if (
|
||||
(!imageData.lastRetrieved ||
|
||||
Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
|
||||
!opts.currentFileIds.includes(id as FileId)
|
||||
) {
|
||||
del(id, filesStore);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
type SavingLockTypes = "collaboration";
|
||||
|
||||
export class LocalData {
|
||||
private static _save = debounce(
|
||||
async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
saveDataStateToLocalStorage(elements, appState);
|
||||
|
||||
await this.fileStorage.saveFiles({
|
||||
elements,
|
||||
files,
|
||||
});
|
||||
onFilesSaved();
|
||||
},
|
||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||
);
|
||||
|
||||
/** Saves DataState, including files. Bails if saving is paused */
|
||||
static save = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
// we need to make the `isSavePaused` check synchronously (undebounced)
|
||||
if (!this.isSavePaused()) {
|
||||
this._save(elements, appState, files, onFilesSaved);
|
||||
}
|
||||
};
|
||||
|
||||
static flushSave = () => {
|
||||
this._save.flush();
|
||||
};
|
||||
|
||||
private static locker = new Locker<SavingLockTypes>();
|
||||
|
||||
static pauseSave = (lockType: SavingLockTypes) => {
|
||||
this.locker.lock(lockType);
|
||||
};
|
||||
|
||||
static resumeSave = (lockType: SavingLockTypes) => {
|
||||
this.locker.unlock(lockType);
|
||||
};
|
||||
|
||||
static isSavePaused = () => {
|
||||
return document.hidden || this.locker.isLocked();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
const loadedFiles: BinaryFileData[] = [];
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
const filesToSave: [FileId, BinaryFileData][] = [];
|
||||
|
||||
filesData.forEach((data, index) => {
|
||||
const id = ids[index];
|
||||
if (data) {
|
||||
const _data: BinaryFileData = {
|
||||
...data,
|
||||
lastRetrieved: Date.now(),
|
||||
};
|
||||
filesToSave.push([id, _data]);
|
||||
loadedFiles.push(_data);
|
||||
} else {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// save loaded files back to storage with updated `lastRetrieved`
|
||||
setMany(filesToSave, filesStore);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
},
|
||||
);
|
||||
},
|
||||
async saveFiles({ addedFiles }) {
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
// before we use `storage` event synchronization, let's update the flag
|
||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||
// before an IDB write finishes will read the latest value.
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
|
||||
|
||||
await Promise.all(
|
||||
[...addedFiles].map(async ([id, fileData]) => {
|
||||
try {
|
||||
await set(id, fileData, filesStore);
|
||||
savedFiles.set(id, true);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { savedFiles, erroredFiles };
|
||||
},
|
||||
});
|
||||
}
|
||||
export class LibraryIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
|
||||
/** library data store key */
|
||||
private static key = "libraryData";
|
||||
|
||||
private static store = createStore(
|
||||
`${LibraryIndexedDBAdapter.idb_name}-db`,
|
||||
`${LibraryIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<LibraryPersistedData>(
|
||||
LibraryIndexedDBAdapter.key,
|
||||
LibraryIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: LibraryPersistedData): MaybePromise<void> {
|
||||
return set(
|
||||
LibraryIndexedDBAdapter.key,
|
||||
data,
|
||||
LibraryIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** LS Adapter used only for migrating LS library data
|
||||
* to indexedDB */
|
||||
export class LibraryLocalStorageMigrationAdapter {
|
||||
static load() {
|
||||
const LSData = localStorage.getItem(
|
||||
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
|
||||
);
|
||||
if (LSData != null) {
|
||||
const libraryItems: ImportedDataState["libraryItems"] =
|
||||
JSON.parse(LSData);
|
||||
if (libraryItems) {
|
||||
return { libraryItems };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static clear() {
|
||||
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export class Locker<T extends string> {
|
||||
private locks = new Map<T, true>();
|
||||
|
||||
lock = (lockType: T) => {
|
||||
this.locks.set(lockType, true);
|
||||
};
|
||||
|
||||
/** @returns whether no locks remaining */
|
||||
unlock = (lockType: T) => {
|
||||
this.locks.delete(lockType);
|
||||
return !this.isLocked();
|
||||
};
|
||||
|
||||
/** @returns whether some (or specific) locks are present */
|
||||
isLocked(lockType?: T) {
|
||||
return lockType ? this.locks.has(lockType) : !!this.locks.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NonDeletedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { BinaryFiles } from "../../packages/excalidraw/types";
|
||||
|
||||
// NOTE: This type is an ad-hoc implementation of the one in
|
||||
// `@excalidraw/mermaid-to-excalidraw`. We are defining it here to avoid
|
||||
// dependency issues.
|
||||
export interface MermaidToExcalidrawResult {
|
||||
code: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
errors: any[];
|
||||
isFirstSuccessfulImport: boolean;
|
||||
}
|
||||
|
||||
const systemPrompt = `目的和目标:
|
||||
* 理解用户提供的文档的结构和逻辑关系。
|
||||
* 准确地将文档内容和关系转化为符合mermaid语法的图表代码。
|
||||
* 确保图表中包含文档的所有关键元素和它们之间的联系。
|
||||
|
||||
行为和规则:
|
||||
1. 分析文档:
|
||||
a) 仔细阅读和分析用户提供的文档内容。
|
||||
b) 识别文档中的不同元素(如概念、实体、步骤、流程等)。
|
||||
c) 理解这些元素之间的各种关系(如从属、包含、流程、因果等)。
|
||||
d) 识别文档中蕴含的逻辑结构和流程。
|
||||
2. 图表生成:
|
||||
a) 根据分析结果,选择最适合表达文档结构的mermaid图表类型(如流程图、时序图、状态图、甘特图等)。
|
||||
b) 使用正确的mermaid语法创建图表代码,充分参考下面的Mermaid 语法特殊字符说明:"
|
||||
* Mermaid 的核心特殊字符主要用于**定义图表结构和关系**。
|
||||
* 要在节点 ID 或标签中**显示**这些特殊字符或包含**空格**,最常用方法是用**双引号 ""** 包裹。
|
||||
* 在标签文本(引号内)中显示 HTML 特殊字符 (<, >, &) 或 # 等,应使用 **HTML 实体编码**。
|
||||
* 要在标签内**换行**,使用 <br> 标签。
|
||||
* 使用 %% 进行**注释**。
|
||||
"
|
||||
c) 确保图表清晰、易于理解,准确反映文档的内容和逻辑。
|
||||
|
||||
3. 细节处理:
|
||||
a) 避免遗漏文档中的任何重要细节或关系。
|
||||
b) 如果文档中存在不明确或多义性的内容,可以向用户提问以获取更清晰的信息。
|
||||
c) 生成的图表代码应可以直接复制并粘贴到支持mermaid语法的工具或平台中使用。
|
||||
整体语气:
|
||||
* 保持专业和严谨的态度。
|
||||
* 清晰、准确地表达图表的内容。
|
||||
* 在需要时,可以提供简短的解释或建议。`;
|
||||
|
||||
const buildOpenAIPayload = (input: string, modelName: string) => {
|
||||
return {
|
||||
model: modelName,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: input,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const generateMermaidCode = async (
|
||||
input: string,
|
||||
apiKey: string,
|
||||
apiUrl: string,
|
||||
modelName: string,
|
||||
): Promise<MermaidToExcalidrawResult> => {
|
||||
const payload = buildOpenAIPayload(input, modelName);
|
||||
const url = `${apiUrl}/chat/completions`;
|
||||
const isRelativePath = url.startsWith("/");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${
|
||||
isRelativePath ? localStorage.getItem("token") || apiKey : apiKey
|
||||
}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error.message || "OpenAI API request failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const mermaidCode = data.choices[0]?.message?.content;
|
||||
|
||||
if (!mermaidCode) {
|
||||
throw new Error("Failed to generate Mermaid code from OpenAI.");
|
||||
}
|
||||
|
||||
// NOTE: a bit of a hack. The result of this function is what the TTD dialog
|
||||
// expects. We are returning the mermaid code as if it were the result of
|
||||
// a mermaid-to-excalidraw conversion, so the dialog can render it.
|
||||
return {
|
||||
code: mermaidCode,
|
||||
title: "AI Generated Diagram",
|
||||
theme: "light",
|
||||
elements: [],
|
||||
files: {},
|
||||
errors: [],
|
||||
isFirstSuccessfulImport: true,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
import { decompressData } from "../../packages/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
decryptData,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let FIREBASE_CONFIG: Record<string, any>;
|
||||
try {
|
||||
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Error JSON parsing firebase config. Supplied value: ${
|
||||
import.meta.env.VITE_APP_FIREBASE_CONFIG
|
||||
}`,
|
||||
);
|
||||
FIREBASE_CONFIG = {};
|
||||
}
|
||||
|
||||
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||
null;
|
||||
let firestorePromise: Promise<any> | null | true = null;
|
||||
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||
|
||||
let isFirebaseInitialized = false;
|
||||
|
||||
const _loadFirebase = async () => {
|
||||
const firebase = (
|
||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||
).default;
|
||||
|
||||
if (!isFirebaseInitialized) {
|
||||
try {
|
||||
firebase.initializeApp(FIREBASE_CONFIG);
|
||||
} catch (error: any) {
|
||||
// trying initialize again throws. Usually this is harmless, and happens
|
||||
// mainly in dev (HMR)
|
||||
if (error.code === "app/duplicate-app") {
|
||||
console.warn(error.name, error.code);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
isFirebaseInitialized = true;
|
||||
}
|
||||
|
||||
return firebase;
|
||||
};
|
||||
|
||||
const _getFirebase = async (): Promise<
|
||||
typeof import("firebase/app").default
|
||||
> => {
|
||||
if (!firebasePromise) {
|
||||
firebasePromise = _loadFirebase();
|
||||
}
|
||||
return firebasePromise;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const loadFirestore = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firestorePromise) {
|
||||
firestorePromise = import(
|
||||
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||
);
|
||||
}
|
||||
if (firestorePromise !== true) {
|
||||
await firestorePromise;
|
||||
firestorePromise = true;
|
||||
}
|
||||
return firebase;
|
||||
};
|
||||
|
||||
export const loadFirebaseStorage = async () => {
|
||||
const firebase = await _getFirebase();
|
||||
if (!firebaseStoragePromise) {
|
||||
firebaseStoragePromise = import(
|
||||
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||
);
|
||||
}
|
||||
if (firebaseStoragePromise !== true) {
|
||||
await firebaseStoragePromise;
|
||||
firebaseStoragePromise = true;
|
||||
}
|
||||
return firebase;
|
||||
};
|
||||
|
||||
interface FirebaseStoredScene {
|
||||
sceneVersion: number;
|
||||
iv: firebase.default.firestore.Blob;
|
||||
ciphertext: firebase.default.firestore.Blob;
|
||||
}
|
||||
|
||||
const encryptElements = async (
|
||||
key: string,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
|
||||
const json = JSON.stringify(elements);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const { encryptedBuffer, iv } = await encryptData(key, encoded);
|
||||
|
||||
return { ciphertext: encryptedBuffer, iv };
|
||||
};
|
||||
|
||||
const decryptElements = async (
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
};
|
||||
|
||||
class FirebaseSceneVersionCache {
|
||||
private static cache = new WeakMap<Socket, number>();
|
||||
static get = (socket: Socket) => {
|
||||
return FirebaseSceneVersionCache.cache.get(socket);
|
||||
};
|
||||
static set = (
|
||||
socket: Socket,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
||||
};
|
||||
}
|
||||
|
||||
export const isSavedToFirebase = (
|
||||
portal: Portal,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): boolean => {
|
||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
|
||||
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
||||
}
|
||||
// if no room exists, consider the room saved so that we don't unnecessarily
|
||||
// prevent unload (there's nothing we could do at that point anyway)
|
||||
return true;
|
||||
};
|
||||
|
||||
export const saveFilesToFirebase = async ({
|
||||
prefix,
|
||||
files,
|
||||
}: {
|
||||
prefix: string;
|
||||
files: { id: FileId; buffer: Uint8Array }[];
|
||||
}) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
|
||||
await Promise.all(
|
||||
files.map(async ({ id, buffer }) => {
|
||||
try {
|
||||
await firebase
|
||||
.storage()
|
||||
.ref(`${prefix}/${id}`)
|
||||
.put(
|
||||
new Blob([buffer], {
|
||||
type: MIME_TYPES.binary,
|
||||
}),
|
||||
{
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
},
|
||||
);
|
||||
savedFiles.set(id, true);
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { savedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
return {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const { roomId, roomKey, socket } = portal;
|
||||
if (
|
||||
// bail if no room exists as there's nothing we can do at this point
|
||||
!roomId ||
|
||||
!roomKey ||
|
||||
!socket ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firebase = await loadFirestore();
|
||||
const firestore = firebase.firestore();
|
||||
|
||||
const docRef = firestore.collection("scenes").doc(roomId);
|
||||
|
||||
const savedData = await firestore.runTransaction(async (transaction) => {
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
elements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.set(docRef, sceneDocument);
|
||||
|
||||
return {
|
||||
elements,
|
||||
reconciledElements: null,
|
||||
};
|
||||
}
|
||||
|
||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||
const prevElements = getSyncableElements(
|
||||
await decryptElements(prevDocData, roomKey),
|
||||
);
|
||||
|
||||
const reconciledElements = getSyncableElements(
|
||||
reconcileElements(elements, prevElements, appState),
|
||||
);
|
||||
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.update(docRef, sceneDocument);
|
||||
return {
|
||||
elements,
|
||||
reconciledElements,
|
||||
};
|
||||
});
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
||||
|
||||
return { reconciledElements: savedData.reconciledElements };
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const doc = await docRef.get();
|
||||
if (!doc.exists) {
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
await decryptElements(storedScene, roomKey),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
FirebaseSceneVersionCache.set(socket, elements);
|
||||
}
|
||||
|
||||
return restoreElements(elements, null);
|
||||
};
|
||||
|
||||
export const loadFilesFromFirebase = async (
|
||||
prefix: string,
|
||||
decryptionKey: string,
|
||||
filesIds: readonly FileId[],
|
||||
) => {
|
||||
const loadedFiles: BinaryFileData[] = [];
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
await Promise.all(
|
||||
[...new Set(filesIds)].map(async (id) => {
|
||||
try {
|
||||
const url = `https://firebasestorage.googleapis.com/v0/b/${
|
||||
FIREBASE_CONFIG.storageBucket
|
||||
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
|
||||
const response = await fetch(`${url}?alt=media`);
|
||||
if (response.status < 400) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
const { data, metadata } = await decompressData<BinaryFileMetadata>(
|
||||
new Uint8Array(arrayBuffer),
|
||||
{
|
||||
decryptionKey,
|
||||
},
|
||||
);
|
||||
|
||||
const dataURL = new TextDecoder().decode(data) as DataURL;
|
||||
|
||||
loadedFiles.push({
|
||||
mimeType: metadata.mimeType || MIME_TYPES.binary,
|
||||
id,
|
||||
dataURL,
|
||||
created: metadata?.created || Date.now(),
|
||||
lastRetrieved: metadata?.created || Date.now(),
|
||||
});
|
||||
} else {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(id, true);
|
||||
console.error(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
};
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
compressData,
|
||||
decompressData,
|
||||
} from "../../packages/excalidraw/data/encode";
|
||||
import {
|
||||
decryptData,
|
||||
generateEncryptionKey,
|
||||
IV_LENGTH_BYTES,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { restore } from "../../packages/excalidraw/data/restore";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
ROOM_ID_BYTES,
|
||||
WS_SUBTYPES,
|
||||
} from "../app_constants";
|
||||
import { encodeFilesForUpload } from "./FileManager";
|
||||
import { saveFilesToFirebase } from "./firebase";
|
||||
|
||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
||||
_brand: "SyncableExcalidrawElement";
|
||||
};
|
||||
|
||||
export const isSyncableElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is SyncableExcalidrawElement => {
|
||||
if (element.isDeleted) {
|
||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !isInvisiblySmallElement(element);
|
||||
};
|
||||
|
||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter((element) =>
|
||||
isSyncableElement(element),
|
||||
) as SyncableExcalidrawElement[];
|
||||
|
||||
const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
|
||||
const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
|
||||
|
||||
const generateRoomId = async () => {
|
||||
const buffer = new Uint8Array(ROOM_ID_BYTES);
|
||||
window.crypto.getRandomValues(buffer);
|
||||
return bytesToHexString(buffer);
|
||||
};
|
||||
|
||||
export type EncryptedData = {
|
||||
data: ArrayBuffer;
|
||||
iv: Uint8Array;
|
||||
};
|
||||
|
||||
export type SocketUpdateDataSource = {
|
||||
INVALID_RESPONSE: {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE;
|
||||
};
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
USER_VISIBLE_SCENE_BOUNDS: {
|
||||
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
username: string;
|
||||
sceneBounds: SceneBounds;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: WS_SUBTYPES.IDLE_STATUS;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
userState: UserIdleState;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource];
|
||||
|
||||
export type SocketUpdateData =
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||
_brand: "socketUpdateData";
|
||||
};
|
||||
|
||||
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
|
||||
|
||||
export const isCollaborationLink = (link: string) => {
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
const hash = new URL(link).hash;
|
||||
return RE_COLLAB_LINK.test(hash);
|
||||
};
|
||||
|
||||
export const getCollaborationLinkData = (link: string) => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
const hash = new URL(link).hash;
|
||||
const match = hash.match(RE_COLLAB_LINK);
|
||||
if (match && match[2].length !== 22) {
|
||||
window.alert(t("alerts.invalidEncryptionKey"));
|
||||
return null;
|
||||
}
|
||||
return match ? { roomId: match[1], roomKey: match[2] } : null;
|
||||
};
|
||||
|
||||
export const generateCollaborationLinkData = async () => {
|
||||
const roomId = await generateRoomId();
|
||||
const roomKey = await generateEncryptionKey();
|
||||
|
||||
if (!roomKey) {
|
||||
throw new Error("Couldn't generate room key");
|
||||
}
|
||||
|
||||
return { roomId, roomKey };
|
||||
};
|
||||
|
||||
export const getCollaborationLink = (data: {
|
||||
roomId: string;
|
||||
roomKey: string;
|
||||
}) => {
|
||||
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes shareLink data using the legacy buffer format.
|
||||
* @deprecated
|
||||
*/
|
||||
const legacy_decodeFromBackend = async ({
|
||||
buffer,
|
||||
decryptionKey,
|
||||
}: {
|
||||
buffer: ArrayBuffer;
|
||||
decryptionKey: string;
|
||||
}) => {
|
||||
let decrypted: ArrayBuffer;
|
||||
|
||||
try {
|
||||
// Buffer should contain both the IV (fixed length) and encrypted data
|
||||
const iv = buffer.slice(0, IV_LENGTH_BYTES);
|
||||
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
|
||||
decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
|
||||
} catch (error: any) {
|
||||
// Fixed IV (old format, backward compatibility)
|
||||
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
|
||||
decrypted = await decryptData(fixedIv, buffer, decryptionKey);
|
||||
}
|
||||
|
||||
// We need to convert the decrypted array buffer to a string
|
||||
const string = new window.TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
const data: ImportedDataState = JSON.parse(string);
|
||||
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
};
|
||||
};
|
||||
|
||||
const importFromBackend = async (
|
||||
id: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_V2_GET}${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
return {};
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
try {
|
||||
const { data: decodedBuffer } = await decompressData(
|
||||
new Uint8Array(buffer),
|
||||
{
|
||||
decryptionKey,
|
||||
},
|
||||
);
|
||||
const data: ImportedDataState = JSON.parse(
|
||||
new TextDecoder().decode(decodedBuffer),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
"error when decoding shareLink data using the new format:",
|
||||
error,
|
||||
);
|
||||
return legacy_decodeFromBackend({ buffer, decryptionKey });
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply local state even if importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||
localDataState: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != null && privateKey != null) {
|
||||
// the private key is used to decrypt the content from the server, take
|
||||
// extra care not to leak it
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
// note: this will always be empty because we're not storing files
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
export const exportToBackend = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
): Promise<ExportToBackendResult> => {
|
||||
const encryptionKey = await generateEncryptionKey("string");
|
||||
|
||||
const payload = await compressData(
|
||||
new TextEncoder().encode(
|
||||
serializeAsJSON(elements, appState, files, "database"),
|
||||
),
|
||||
{ encryptionKey },
|
||||
);
|
||||
|
||||
try {
|
||||
const filesMap = new Map<FileId, BinaryFileData>();
|
||||
for (const element of elements) {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
filesMap.set(element.fileId, files[element.fileId]);
|
||||
}
|
||||
}
|
||||
|
||||
const filesToUpload = await encodeFilesForUpload({
|
||||
files: filesMap,
|
||||
encryptionKey,
|
||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||
});
|
||||
|
||||
const response = await fetch(BACKEND_V2_POST, {
|
||||
method: "POST",
|
||||
body: payload.buffer as ArrayBuffer,
|
||||
});
|
||||
const json = await response.json();
|
||||
if (json.id) {
|
||||
const url = new URL(window.location.href);
|
||||
// We need to store the key (and less importantly the id) as hash instead
|
||||
// of queryParam in order to never send it to the server
|
||||
url.hash = `json=${json.id},${encryptionKey}`;
|
||||
const urlString = url.toString();
|
||||
|
||||
await saveFilesToFirebase({
|
||||
prefix: `/files/shareLinks/${json.id}`,
|
||||
files: filesToUpload,
|
||||
});
|
||||
|
||||
return { url: urlString, errorMessage: null };
|
||||
} else if (json.error_class === "RequestTooLargeError") {
|
||||
return {
|
||||
url: null,
|
||||
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
|
||||
};
|
||||
}
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
|
||||
JSON.stringify({ username }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const importUsernameFromLocalStorage = (): string | null => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
if (data) {
|
||||
return JSON.parse(data).username;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const importFromLocalStorage = () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
|
||||
try {
|
||||
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// Do nothing because elements array is already empty
|
||||
}
|
||||
}
|
||||
|
||||
let appState = null;
|
||||
if (savedState) {
|
||||
try {
|
||||
appState = {
|
||||
...getDefaultAppState(),
|
||||
...clearAppStateForLocalStorage(
|
||||
JSON.parse(savedState) as Partial<AppState>,
|
||||
),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// Do nothing because appState is already null
|
||||
}
|
||||
}
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const getElementsStorageSize = () => {
|
||||
try {
|
||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
const elementsSize = elements?.length || 0;
|
||||
return elementsSize;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTotalStorageSize = () => {
|
||||
try {
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
|
||||
return appStateSize + collabSize + getElementsStorageSize();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
Collaborator,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
|
||||
/**
|
||||
* Describes the metadata of a canvas.
|
||||
*/
|
||||
export interface CanvasMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: number;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the complete data for a single canvas.
|
||||
*/
|
||||
export interface CanvasData {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the contract for all storage adapters.
|
||||
* Any storage backend (default server, Cloudflare KV, S3, etc.)
|
||||
* must implement this interface to be compatible with the application.
|
||||
*/
|
||||
export interface IStorageAdapter {
|
||||
/**
|
||||
* Lists all canvases available for the current user.
|
||||
*/
|
||||
listCanvases(): Promise<CanvasMetadata[]>;
|
||||
|
||||
/**
|
||||
* Loads a single canvas's data.
|
||||
* @param id The unique identifier of the canvas to load.
|
||||
* @returns The canvas data, or null if not found.
|
||||
*/
|
||||
loadCanvas(id: string): Promise<CanvasData | null>;
|
||||
|
||||
/**
|
||||
* Saves a canvas's data. This is typically used for updating an existing canvas.
|
||||
* @param id The unique identifier of the canvas to save.
|
||||
* @param data The complete data of the canvas.
|
||||
*/
|
||||
saveCanvas(id: string, data: CanvasData): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a new canvas.
|
||||
* @param data The initial data for the new canvas.
|
||||
* @returns The metadata of the newly created canvas.
|
||||
*/
|
||||
createCanvas(data: CanvasData): Promise<CanvasMetadata>;
|
||||
|
||||
/**
|
||||
* Deletes a canvas.
|
||||
* @param id The unique identifier of the canvas to delete.
|
||||
*/
|
||||
deleteCanvas(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a canvas.
|
||||
* @param id The unique identifier of the canvas to rename.
|
||||
* @param newName The new name for the canvas.
|
||||
*/
|
||||
renameCanvas(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw JSON object into a CanvasData object, ensuring complex types
|
||||
* like Map are correctly instantiated.
|
||||
* @param data The raw data from the API.
|
||||
*/
|
||||
export const hydrateCanvasData = (data: any): CanvasData => {
|
||||
const canvasData: CanvasData = { ...data };
|
||||
|
||||
// Ensure collaborators is a Map, not an object.
|
||||
if (
|
||||
canvasData.appState &&
|
||||
canvasData.appState.collaborators &&
|
||||
!(canvasData.appState.collaborators instanceof Map)
|
||||
) {
|
||||
canvasData.appState.collaborators = new Map(
|
||||
Object.entries(
|
||||
canvasData.appState.collaborators as { [key: string]: Collaborator },
|
||||
).map(([key, value]) => [key as SocketId, value]),
|
||||
);
|
||||
} else if (canvasData.appState && !canvasData.appState.collaborators) {
|
||||
// Ensure collaborators is at least an empty Map if it's missing.
|
||||
canvasData.appState.collaborators = new Map();
|
||||
}
|
||||
|
||||
return canvasData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares canvas data for JSON serialization, converting complex types like Map
|
||||
* into plain objects.
|
||||
* @param data The CanvasData object to dehydrate.
|
||||
*/
|
||||
export const dehydrateCanvasData = (data: CanvasData) => {
|
||||
const dehydratedData = {
|
||||
...data,
|
||||
appState: {
|
||||
...data.appState,
|
||||
collaborators:
|
||||
data.appState.collaborators instanceof Map
|
||||
? Object.fromEntries(data.appState.collaborators)
|
||||
: data.appState.collaborators,
|
||||
},
|
||||
};
|
||||
return dehydratedData;
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
CanvasData,
|
||||
CanvasMetadata,
|
||||
dehydrateCanvasData,
|
||||
hydrateCanvasData,
|
||||
IStorageAdapter,
|
||||
} from "../storage";
|
||||
import { nanoid } from "nanoid";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE_URL = "/api/v2/kv";
|
||||
|
||||
interface AppJwtPayload {
|
||||
sub: string;
|
||||
}
|
||||
|
||||
// The backend uses the GitHub user ID as the subject in the JWT.
|
||||
// We can decode the token to get this ID for frontend purposes.
|
||||
function getUserIdFromJwt(token: string): number | null {
|
||||
try {
|
||||
const decodedToken = jwtDecode<AppJwtPayload>(token);
|
||||
if (decodedToken && decodedToken.sub) {
|
||||
const userId = parseInt(decodedToken.sub, 10);
|
||||
if (!isNaN(userId) && Number.isInteger(userId)) {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Failed to decode JWT", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
export class BackendStorageAdapter implements IStorageAdapter {
|
||||
async listCanvases(): Promise<CanvasMetadata[]> {
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// For list, we can just return an empty array as if the user has no canvases.
|
||||
// This prevents an error popup when a logged-out user opens the app.
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Failed to list canvases: ${response.statusText}`);
|
||||
}
|
||||
// Backend doesn't send userId, so we enrich the data here.
|
||||
const canvases: Omit<CanvasMetadata, "userId">[] = await response.json();
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const userId = getUserIdFromJwt(token);
|
||||
if (!userId) {
|
||||
console.error("Could not determine userId from token.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return canvases.map((canvas) => ({ ...canvas, userId }));
|
||||
}
|
||||
|
||||
async loadCanvas(id: string): Promise<CanvasData | null> {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
method: "GET",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError("User is not authenticated");
|
||||
}
|
||||
throw new Error(`Failed to load canvas: ${response.statusText}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return hydrateCanvasData(rawData);
|
||||
}
|
||||
|
||||
async saveCanvas(id: string, data: CanvasData): Promise<void> {
|
||||
const saveData = dehydrateCanvasData(data);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(saveData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError("User is not authenticated");
|
||||
}
|
||||
throw new Error(`Failed to save canvas: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
|
||||
const newId = nanoid();
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not found.");
|
||||
}
|
||||
const userId = getUserIdFromJwt(token);
|
||||
if (!userId) {
|
||||
throw new Error("Could not parse user ID from token.");
|
||||
}
|
||||
|
||||
await this.saveCanvas(newId, data);
|
||||
return {
|
||||
id: newId,
|
||||
name: data.appState?.name || "Untitled",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteCanvas(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError("User is not authenticated");
|
||||
}
|
||||
throw new Error(`Failed to delete canvas: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async renameCanvas(id: string, newName: string): Promise<void> {
|
||||
const canvasData = await this.loadCanvas(id);
|
||||
if (!canvasData) {
|
||||
throw new Error("Canvas not found, cannot rename.");
|
||||
}
|
||||
|
||||
const updatedData: CanvasData = {
|
||||
...canvasData,
|
||||
appState: {
|
||||
...canvasData.appState,
|
||||
name: newName,
|
||||
},
|
||||
};
|
||||
|
||||
await this.saveCanvas(id, updatedData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
import {
|
||||
CanvasData,
|
||||
CanvasMetadata,
|
||||
dehydrateCanvasData,
|
||||
hydrateCanvasData,
|
||||
IStorageAdapter,
|
||||
} from "../storage";
|
||||
|
||||
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta:";
|
||||
const KEY_PREFIX_DATA = "excalidraw-canvas-data:";
|
||||
|
||||
export class CloudflareKVAdapter implements IStorageAdapter {
|
||||
private kv_url: string;
|
||||
private apiToken: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: { kv_url: string; apiToken: string }) {
|
||||
this.kv_url = config.kv_url;
|
||||
this.apiToken = config.apiToken;
|
||||
this.baseUrl = `https://${this.kv_url}`;
|
||||
}
|
||||
|
||||
private getAuthHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async listCanvases(): Promise<CanvasMetadata[]> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/keys?prefix=${KEY_PREFIX_METADATA}`,
|
||||
{
|
||||
headers: this.getAuthHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Failed to list canvases from Cloudflare KV",
|
||||
await response.text(),
|
||||
);
|
||||
throw new Error("Failed to list canvases from Cloudflare KV.");
|
||||
}
|
||||
|
||||
const { result: keys } = (await response.json()) as {
|
||||
result: { name: string }[];
|
||||
};
|
||||
if (!keys || keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metadataPromises = keys.map((key) =>
|
||||
this.getCanvasMetadata(key.name),
|
||||
);
|
||||
const metadata = await Promise.all(metadataPromises);
|
||||
|
||||
// Filter out any nulls that might have occurred if a key was deleted between listing and fetching
|
||||
return metadata.filter((m): m is CanvasMetadata => m !== null);
|
||||
}
|
||||
|
||||
private async getCanvasMetadata(key: string): Promise<CanvasMetadata | null> {
|
||||
const response = await fetch(`${this.baseUrl}/values/${key}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error(
|
||||
`Failed to fetch metadata for key ${key}`,
|
||||
await response.text(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async loadCanvas(id: string): Promise<CanvasData | null> {
|
||||
const key = `${KEY_PREFIX_DATA}${id}`;
|
||||
const response = await fetch(`${this.baseUrl}/values/${key}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to load canvas ${id} from Cloudflare KV.`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
return hydrateCanvasData(rawData);
|
||||
}
|
||||
|
||||
async saveCanvas(id: string, data: CanvasData): Promise<void> {
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
|
||||
const dataKey = `${KEY_PREFIX_DATA}${id}`;
|
||||
|
||||
const existingMetadata = await this.getCanvasMetadata(metadataKey);
|
||||
if (!existingMetadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot save.");
|
||||
}
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
|
||||
const updatedMetadata: CanvasMetadata = {
|
||||
...existingMetadata,
|
||||
name: data.appState.name || existingMetadata.name,
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
const dehydratedData = dehydrateCanvasData(data);
|
||||
const bulkPayload = [
|
||||
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
|
||||
{ key: dataKey, value: JSON.stringify(dehydratedData) },
|
||||
];
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/bulk`, {
|
||||
method: "PUT",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(bulkPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save canvas ${id} to Cloudflare KV.`);
|
||||
}
|
||||
}
|
||||
|
||||
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
|
||||
const newId = window.crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
const newMetadata: CanvasMetadata = {
|
||||
id: newId,
|
||||
name: data.appState.name || "Untitled Canvas",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// Assuming userId comes from a context, hardcoding for now
|
||||
userId: 0,
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
|
||||
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
|
||||
const dehydratedData = dehydrateCanvasData(data);
|
||||
|
||||
const bulkPayload = [
|
||||
{ key: metadataKey, value: JSON.stringify(newMetadata) },
|
||||
{ key: dataKey, value: JSON.stringify(dehydratedData) },
|
||||
];
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/bulk`, {
|
||||
method: "PUT",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(bulkPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create canvas in Cloudflare KV.");
|
||||
}
|
||||
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
async deleteCanvas(id: string): Promise<void> {
|
||||
const keysToDelete = [
|
||||
`${KEY_PREFIX_METADATA}${id}`,
|
||||
`${KEY_PREFIX_DATA}${id}`,
|
||||
];
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/bulk`, {
|
||||
method: "DELETE",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(keysToDelete),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete canvas ${id} from Cloudflare KV.`);
|
||||
}
|
||||
}
|
||||
|
||||
async renameCanvas(id: string, newName: string): Promise<void> {
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
|
||||
const dataKey = `${KEY_PREFIX_DATA}${id}`;
|
||||
|
||||
const [metadata, data] = await Promise.all([
|
||||
this.getCanvasMetadata(metadataKey),
|
||||
this.loadCanvas(id),
|
||||
]);
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot rename.");
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error("Canvas data not found. Cannot rename.");
|
||||
}
|
||||
|
||||
const updatedMetadata: CanvasMetadata = { ...metadata, name: newName };
|
||||
const updatedData: CanvasData = {
|
||||
...data,
|
||||
appState: { ...data.appState, name: newName },
|
||||
};
|
||||
|
||||
const dehydratedData = dehydrateCanvasData(updatedData);
|
||||
const bulkPayload = [
|
||||
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
|
||||
{ key: dataKey, value: JSON.stringify(dehydratedData) },
|
||||
];
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/bulk`, {
|
||||
method: "PUT",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(bulkPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to rename canvas ${id} in Cloudflare KV.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createStore, set, get, del, entries } from "idb-keyval";
|
||||
import { CanvasData, CanvasMetadata, IStorageAdapter } from "../storage";
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
|
||||
const metadataStore = createStore("excalidraw-canvases-metadata", "metadata");
|
||||
const dataStore = createStore("excalidraw-canvases-data", "data");
|
||||
|
||||
export class IndexedDBStorageAdapter implements IStorageAdapter {
|
||||
async listCanvases(): Promise<CanvasMetadata[]> {
|
||||
const allEntries = await entries<string, CanvasMetadata>(metadataStore);
|
||||
return allEntries.map(([, metadata]) => metadata);
|
||||
}
|
||||
|
||||
async loadCanvas(id: string): Promise<CanvasData | null> {
|
||||
const data = await get<CanvasData>(id, dataStore);
|
||||
return data === undefined ? null : data;
|
||||
}
|
||||
|
||||
async saveCanvas(id: string, data: CanvasData): Promise<void> {
|
||||
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
|
||||
if (!existingMetadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot save.");
|
||||
}
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
|
||||
const updatedMetadata: CanvasMetadata = {
|
||||
...existingMetadata,
|
||||
name: data.appState.name || existingMetadata.name,
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
await set(id, updatedMetadata, metadataStore);
|
||||
await set(id, data, dataStore);
|
||||
}
|
||||
|
||||
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
|
||||
const newId = window.crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
|
||||
const newMetadata: CanvasMetadata = {
|
||||
id: newId,
|
||||
name: data.appState.name || "Untitled Canvas",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// UserID is 0 for local, non-synced canvases
|
||||
userId: 0,
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
await set(newId, newMetadata, metadataStore);
|
||||
await set(newId, data, dataStore);
|
||||
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
async deleteCanvas(id: string): Promise<void> {
|
||||
await del(id, metadataStore);
|
||||
await del(id, dataStore);
|
||||
}
|
||||
|
||||
async renameCanvas(id: string, newName: string): Promise<void> {
|
||||
// Update metadata
|
||||
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
|
||||
if (!existingMetadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot rename.");
|
||||
}
|
||||
await set(id, { ...existingMetadata, name: newName }, metadataStore);
|
||||
|
||||
// Update canvas data
|
||||
const existingData = await get<CanvasData>(id, dataStore);
|
||||
if (!existingData) {
|
||||
// This should not happen if metadata exists, but as a safeguard:
|
||||
throw new Error("Canvas data not found. Cannot rename.");
|
||||
}
|
||||
await set(
|
||||
id,
|
||||
{
|
||||
...existingData,
|
||||
appState: { ...existingData.appState, name: newName },
|
||||
},
|
||||
dataStore,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
S3Client,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import {
|
||||
CanvasData,
|
||||
CanvasMetadata,
|
||||
dehydrateCanvasData,
|
||||
hydrateCanvasData,
|
||||
IStorageAdapter,
|
||||
} from "../storage";
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
|
||||
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta-";
|
||||
const KEY_PREFIX_DATA = "excalidraw-canvas-data-";
|
||||
|
||||
export class S3StorageAdapter implements IStorageAdapter {
|
||||
private s3: S3Client;
|
||||
private bucketName: string;
|
||||
|
||||
constructor(config: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region: string;
|
||||
bucketName: string;
|
||||
}) {
|
||||
this.s3 = new S3Client({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
});
|
||||
this.bucketName = config.bucketName;
|
||||
}
|
||||
|
||||
private async getObject(key: string): Promise<any | null> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
const response = await this.s3.send(command);
|
||||
const body = await response.Body?.transformToString();
|
||||
return body ? JSON.parse(body) : null;
|
||||
} catch (error: any) {
|
||||
if (error.name === "NoSuchKey") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listCanvases(): Promise<CanvasMetadata[]> {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: KEY_PREFIX_METADATA,
|
||||
});
|
||||
const response = await this.s3.send(command);
|
||||
if (!response.Contents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metadataPromises = response.Contents.map((obj: { Key?: string }) =>
|
||||
this.getObject(obj.Key!),
|
||||
);
|
||||
const results = await Promise.all(metadataPromises);
|
||||
return results.filter(
|
||||
(m: CanvasMetadata | null): m is CanvasMetadata => m !== null,
|
||||
);
|
||||
}
|
||||
|
||||
async loadCanvas(id: string): Promise<CanvasData | null> {
|
||||
const rawData = await this.getObject(`${KEY_PREFIX_DATA}${id}`);
|
||||
return rawData ? hydrateCanvasData(rawData) : null;
|
||||
}
|
||||
|
||||
async saveCanvas(id: string, data: CanvasData): Promise<void> {
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
|
||||
const dataKey = `${KEY_PREFIX_DATA}${id}`;
|
||||
|
||||
const existingMetadata = await this.getObject(metadataKey);
|
||||
if (!existingMetadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot save.");
|
||||
}
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
|
||||
const updatedMetadata: CanvasMetadata = {
|
||||
...existingMetadata,
|
||||
name: data.appState.name || existingMetadata.name,
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
const dehydratedData = dehydrateCanvasData(data);
|
||||
|
||||
await Promise.all([
|
||||
this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: metadataKey,
|
||||
Body: JSON.stringify(updatedMetadata),
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
),
|
||||
this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: dataKey,
|
||||
Body: JSON.stringify(dehydratedData),
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
|
||||
const newId = window.crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const thumbnail = await generateThumbnail(
|
||||
data.elements,
|
||||
data.appState,
|
||||
data.files,
|
||||
);
|
||||
const newMetadata: CanvasMetadata = {
|
||||
id: newId,
|
||||
name: data.appState.name || "Untitled Canvas",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
userId: 0,
|
||||
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
|
||||
};
|
||||
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
|
||||
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
|
||||
const dehydratedData = dehydrateCanvasData(data);
|
||||
|
||||
await Promise.all([
|
||||
this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: metadataKey,
|
||||
Body: JSON.stringify(newMetadata),
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
),
|
||||
this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: dataKey,
|
||||
Body: JSON.stringify(dehydratedData),
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
async deleteCanvas(id: string): Promise<void> {
|
||||
const command = new DeleteObjectsCommand({
|
||||
Bucket: this.bucketName,
|
||||
Delete: {
|
||||
Objects: [
|
||||
{ Key: `${KEY_PREFIX_METADATA}${id}` },
|
||||
{ Key: `${KEY_PREFIX_DATA}${id}` },
|
||||
],
|
||||
},
|
||||
});
|
||||
await this.s3.send(command);
|
||||
}
|
||||
|
||||
async renameCanvas(id: string, newName: string): Promise<void> {
|
||||
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
|
||||
const metadata = await this.getObject(metadataKey);
|
||||
if (!metadata) {
|
||||
throw new Error("Canvas metadata not found. Cannot rename.");
|
||||
}
|
||||
metadata.name = newName;
|
||||
metadata.updatedAt = new Date().toISOString();
|
||||
|
||||
const data = await this.loadCanvas(id);
|
||||
if (!data) {
|
||||
throw new Error("Canvas data not found. Cannot rename.");
|
||||
}
|
||||
data.appState.name = newName;
|
||||
|
||||
await this.saveCanvas(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
// in-memory state (this tab's current state) versions. Currently just
|
||||
// timestamps of the last time the state was saved to browser storage.
|
||||
const LOCAL_STATE_VERSIONS = {
|
||||
[STORAGE_KEYS.VERSION_DATA_STATE]: -1,
|
||||
[STORAGE_KEYS.VERSION_FILES]: -1,
|
||||
};
|
||||
|
||||
type BrowserStateTypes = keyof typeof LOCAL_STATE_VERSIONS;
|
||||
|
||||
export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
|
||||
const storageTimestamp = JSON.parse(localStorage.getItem(type) || "-1");
|
||||
return storageTimestamp > LOCAL_STATE_VERSIONS[type];
|
||||
};
|
||||
|
||||
export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
|
||||
const timestamp = Date.now();
|
||||
try {
|
||||
localStorage.setItem(type, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[type] = timestamp;
|
||||
} catch (error) {
|
||||
console.error("error while updating browser state verison", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetBrowserStateVersions = () => {
|
||||
try {
|
||||
for (const key of Object.keys(
|
||||
LOCAL_STATE_VERSIONS,
|
||||
) as BrowserStateTypes[]) {
|
||||
const timestamp = -1;
|
||||
localStorage.setItem(key, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[key] = timestamp;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while resetting browser state verison", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { exportToCanvas } from "../../packages/utils/export";
|
||||
import { AppState, BinaryFiles } from "../../packages/excalidraw/types";
|
||||
import { NonDeletedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../../packages/excalidraw/constants";
|
||||
|
||||
const THUMBNAIL_WIDTH = 200;
|
||||
const THUMBNAIL_HEIGHT = 200;
|
||||
|
||||
export const generateThumbnail = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
): Promise<string> => {
|
||||
const canvas = await exportToCanvas({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: Math.max(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT),
|
||||
});
|
||||
|
||||
const SvgCanvas = document.createElement("canvas");
|
||||
SvgCanvas.width = THUMBNAIL_WIDTH;
|
||||
SvgCanvas.height = THUMBNAIL_HEIGHT;
|
||||
const SvgCanvasContext = SvgCanvas.getContext("2d")!;
|
||||
|
||||
const sourceAspectRatio = canvas.width / canvas.height;
|
||||
const targetAspectRatio = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT;
|
||||
|
||||
let drawWidth = THUMBNAIL_WIDTH;
|
||||
let drawHeight = THUMBNAIL_HEIGHT;
|
||||
let drawX = 0;
|
||||
let drawY = 0;
|
||||
|
||||
if (sourceAspectRatio > targetAspectRatio) {
|
||||
drawHeight = THUMBNAIL_WIDTH / sourceAspectRatio;
|
||||
drawY = (THUMBNAIL_HEIGHT - drawHeight) / 2;
|
||||
} else {
|
||||
drawWidth = THUMBNAIL_HEIGHT * sourceAspectRatio;
|
||||
drawX = (THUMBNAIL_WIDTH - drawWidth) / 2;
|
||||
}
|
||||
|
||||
SvgCanvasContext.drawImage(canvas, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
return SvgCanvas.toDataURL("image/png");
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
|
||||
{};
|
||||
private static TIMES_AVG: Record<
|
||||
string,
|
||||
{ t: number; times: number[]; avg: number | null }
|
||||
> = {};
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
console.info(
|
||||
name,
|
||||
lessPrecise(times.reduce((a, b) => a + b)),
|
||||
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
|
||||
);
|
||||
Debug.TIMES_AGGR[name] = { t, times: [] };
|
||||
}
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
avg:
|
||||
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AGGR[name].t = now;
|
||||
};
|
||||
public static logTimeAverage = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AVG[name].t = now;
|
||||
};
|
||||
|
||||
private static logWrapper =
|
||||
(type: "logTime" | "logTimeAverage") =>
|
||||
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
public static logTimeWrap = Debug.logWrapper("logTime");
|
||||
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
|
||||
|
||||
public static perfWrap = <T extends any[], R>(
|
||||
fn: (...args: T) => R,
|
||||
name = "default",
|
||||
) => {
|
||||
return (...args: T) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.time(name);
|
||||
const ret = fn(...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd(name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
}
|
||||
//@ts-ignore
|
||||
window.debug = Debug;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
interface Window {
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { User } from "../app-jotai";
|
||||
|
||||
export const useAuth = (setUser: (user: User | null) => void) => {
|
||||
useEffect(() => {
|
||||
// Check for token in URL params, which happens after GitHub login redirect.
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = searchParams.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
// Clean the token from the URL.
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem("token");
|
||||
if (storedToken) {
|
||||
try {
|
||||
const decodedToken: any = jwtDecode(storedToken);
|
||||
// Check if token is expired.
|
||||
if (decodedToken.exp * 1000 > Date.now()) {
|
||||
setUser({
|
||||
id: decodedToken.userId,
|
||||
githubId: decodedToken.githubId,
|
||||
login: decodedToken.login,
|
||||
avatarUrl: decodedToken.avatarUrl,
|
||||
name: decodedToken.name,
|
||||
});
|
||||
} else {
|
||||
// Token is expired, remove it.
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid token:", error);
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}, [setUser]);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
IStorageAdapter,
|
||||
CanvasMetadata,
|
||||
CanvasData,
|
||||
} from "../data/storage";
|
||||
import { AuthError } from "../data/storageAdapters/BackendStorageAdapter";
|
||||
import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||
import { User, currentCanvasIdAtom } from "../app-jotai";
|
||||
import { CREATIONS_SIDEBAR_NAME } from "../app_constants";
|
||||
|
||||
export const useCanvasManagement = ({
|
||||
storageAdapter,
|
||||
excalidrawAPI,
|
||||
user,
|
||||
setErrorMessage,
|
||||
resetSaveStatus,
|
||||
}: {
|
||||
storageAdapter: IStorageAdapter;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI | null | undefined;
|
||||
user: User | null;
|
||||
setErrorMessage: (msg: string) => void;
|
||||
resetSaveStatus: () => void;
|
||||
}) => {
|
||||
const [canvases, setCanvases] = useState<CanvasMetadata[]>([]);
|
||||
const [currentCanvasId, setCurrentCanvasId] = useAtom(currentCanvasIdAtom);
|
||||
|
||||
const refreshCanvases = useCallback(async () => {
|
||||
try {
|
||||
const canvases = await storageAdapter.listCanvases();
|
||||
console.log("canvases", canvases);
|
||||
setCanvases(canvases);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage("Could not list your creations.");
|
||||
}
|
||||
}, [storageAdapter, setErrorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCanvases();
|
||||
}, [refreshCanvases]);
|
||||
|
||||
const openSidebar = excalidrawAPI?.getAppState().openSidebar;
|
||||
useEffect(() => {
|
||||
if (
|
||||
openSidebar?.name === "default" &&
|
||||
openSidebar?.tab === CREATIONS_SIDEBAR_NAME
|
||||
) {
|
||||
refreshCanvases();
|
||||
}
|
||||
}, [openSidebar, refreshCanvases]);
|
||||
|
||||
const handleCanvasSelect = useCallback(
|
||||
async (id: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canvasData = await storageAdapter.loadCanvas(id);
|
||||
if (canvasData) {
|
||||
excalidrawAPI.updateScene({ appState: { openSidebar: null } });
|
||||
excalidrawAPI.addFiles(Object.values(canvasData.files));
|
||||
excalidrawAPI.updateScene({
|
||||
elements: canvasData.elements,
|
||||
appState: canvasData.appState,
|
||||
commitToHistory: true,
|
||||
});
|
||||
setCurrentCanvasId(id);
|
||||
resetSaveStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage("Could not load the canvas.");
|
||||
}
|
||||
},
|
||||
[storageAdapter, excalidrawAPI, setErrorMessage, setCurrentCanvasId, resetSaveStatus],
|
||||
);
|
||||
|
||||
const handleCanvasDelete = useCallback(
|
||||
async (id: string) => {
|
||||
if (window.confirm("Are you sure you want to delete this canvas?")) {
|
||||
try {
|
||||
await storageAdapter.deleteCanvas(id);
|
||||
if (currentCanvasId === id) {
|
||||
setCurrentCanvasId(null);
|
||||
excalidrawAPI?.resetScene();
|
||||
resetSaveStatus();
|
||||
}
|
||||
await refreshCanvases();
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能删除此画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not delete the canvas.");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
currentCanvasId,
|
||||
setCurrentCanvasId,
|
||||
excalidrawAPI,
|
||||
resetSaveStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasCreate = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appState = { ...excalidrawAPI.getAppState(), name: newName };
|
||||
const newCanvasData = {
|
||||
elements: [],
|
||||
appState,
|
||||
files: {},
|
||||
};
|
||||
const createdCanvas = await storageAdapter.createCanvas(
|
||||
newCanvasData as CanvasData,
|
||||
);
|
||||
await refreshCanvases();
|
||||
excalidrawAPI.resetScene();
|
||||
excalidrawAPI.updateScene({ appState: { name: newName } });
|
||||
setCurrentCanvasId(createdCanvas.id);
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能创建新画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not create new canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
excalidrawAPI,
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
setCurrentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasRename = useCallback(
|
||||
async (id: string, newName: string) => {
|
||||
try {
|
||||
await storageAdapter.renameCanvas(id, newName);
|
||||
await refreshCanvases();
|
||||
if (excalidrawAPI && currentCanvasId === id) {
|
||||
excalidrawAPI.updateScene({ appState: { name: newName } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能重命名此画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not rename the canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
excalidrawAPI,
|
||||
currentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCanvasSaveAs = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appState = { ...excalidrawAPI.getAppState(), name: newName };
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
const files = excalidrawAPI.getFiles();
|
||||
|
||||
const newCanvasData = {
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
};
|
||||
const createdCanvas = await storageAdapter.createCanvas(
|
||||
newCanvasData as CanvasData,
|
||||
);
|
||||
await refreshCanvases();
|
||||
// After saving as, we should switch to the new canvas
|
||||
setCurrentCanvasId(createdCanvas.id);
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthError) {
|
||||
setErrorMessage("您需要登录才能另存为新画布。");
|
||||
} else {
|
||||
setErrorMessage("Could not save as new canvas.");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
excalidrawAPI,
|
||||
storageAdapter,
|
||||
refreshCanvases,
|
||||
setErrorMessage,
|
||||
setCurrentCanvasId,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
canvases,
|
||||
handleCanvasSelect,
|
||||
handleCanvasDelete,
|
||||
handleCanvasCreate,
|
||||
handleCanvasRename,
|
||||
handleCanvasSaveAs,
|
||||
refreshCanvases,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="referrer" content="origin" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#121212" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta property="og:image:alt" content="Excalidraw logo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:site" content="@excalidraw" />
|
||||
<meta property="twitter:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<style>
|
||||
html.dark {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
<% if ("%PROD%" === "true") { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
var scriptEle = document.createElement("script");
|
||||
scriptEle.setAttribute(
|
||||
"src",
|
||||
"https://scripts.simpleanalyticscdn.com/latest.js",
|
||||
);
|
||||
scriptEle.setAttribute("type", "text/javascript");
|
||||
scriptEle.setAttribute("defer", true);
|
||||
scriptEle.setAttribute("async", true);
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.setAttribute("data-auto-collect", true);
|
||||
}
|
||||
|
||||
document.body.appendChild(scriptEle);
|
||||
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.addEventListener("load", () => {
|
||||
if (window.sa_pageview) {
|
||||
window.window.sa_event(action, {
|
||||
category: "iframe",
|
||||
label: "embed",
|
||||
value: window.location.pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
&.theme--dark {
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.encrypted-icon {
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--color-primary);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
&.ExcalidrawPlus {
|
||||
color: var(--color-promo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #064e3c;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collab-offline-warning {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw-app.is-collaborating {
|
||||
[data-testid="clear-canvas-button"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-size: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-button {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./App";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "../excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
registerSW();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "excalidraw-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.842.0"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "pnpm build:app && pnpm build:version",
|
||||
"start": "vite",
|
||||
"start:production": "pnpm build && npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "pnpm build && vite preview --port 5000"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import * as SentryIntegrations from "@sentry/integrations";
|
||||
|
||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||
"excalidraw.com": "production",
|
||||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
|
||||
|
||||
// Disable Sentry locally or inside the Docker to avoid noise/respect privacy
|
||||
const onlineEnv =
|
||||
!SENTRY_DISABLED &&
|
||||
Object.keys(SentryEnvHostnameMap).find(
|
||||
(item) => window.location.hostname.indexOf(item) >= 0,
|
||||
);
|
||||
|
||||
Sentry.init({
|
||||
dsn: onlineEnv
|
||||
? "https://[email protected]/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test MobileMenu > should initialize with welcome screen and hide once user interacts 1`] = `
|
||||
<div
|
||||
class="welcome-screen-center"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
||||
>
|
||||
<div
|
||||
class="ExcalidrawLogo is-small"
|
||||
>
|
||||
<svg
|
||||
class="ExcalidrawLogo-icon"
|
||||
fill="none"
|
||||
viewBox="0 0 40 40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M39.9 32.889a.326.326 0 0 0-.279-.056c-2.094-3.083-4.774-6-7.343-8.833l-.419-.472a.212.212 0 0 0-.056-.139.586.586 0 0 0-.167-.111l-.084-.083-.056-.056c-.084-.167-.28-.278-.475-.167-.782.39-1.507.973-2.206 1.528-.92.722-1.842 1.445-2.708 2.25a8.405 8.405 0 0 0-.977 1.028c-.14.194-.028.361.14.444-.615.611-1.23 1.223-1.843 1.861a.315.315 0 0 0-.084.223c0 .083.056.166.111.194l1.09.833v.028c1.535 1.528 4.244 3.611 7.12 5.861.418.334.865.667 1.284 1 .195.223.39.473.558.695.084.11.28.139.391.055.056.056.14.111.196.167a.398.398 0 0 0 .167.056.255.255 0 0 0 .224-.111.394.394 0 0 0 .055-.167c.029 0 .028.028.056.028a.318.318 0 0 0 .224-.084l5.082-5.528a.309.309 0 0 0 0-.444Zm-14.63-1.917a.485.485 0 0 0 .111.14c.586.5 1.2 1 1.843 1.555l-2.569-1.945-.251-.166c-.056-.028-.112-.084-.168-.111l-.195-.167.056-.056.055-.055.112-.111c.866-.861 2.346-2.306 3.1-3.028-.81.805-2.43 3.167-2.095 3.944Zm8.767 6.89-2.122-1.612a44.713 44.713 0 0 0-2.625-2.5c1.145.861 2.122 1.611 2.262 1.75 1.117.972 1.06.806 1.815 1.445l.921.666a1.06 1.06 0 0 1-.251.25Zm.558.416-.056-.028c.084-.055.168-.111.252-.194l-.196.222ZM1.089 5.75c.055.361.14.722.195 1.056.335 1.833.67 3.5 1.284 4.75l.252.944c.084.361.223.806.363.917 1.424 1.25 3.602 3.11 5.947 4.889a.295.295 0 0 0 .363 0s0 .027.028.027a.254.254 0 0 0 .196.084.318.318 0 0 0 .223-.084c2.988-3.305 5.221-6.027 6.813-8.305.112-.111.14-.278.14-.417.111-.111.195-.25.307-.333.111-.111.111-.306 0-.39l-.028-.027c0-.055-.028-.139-.084-.167-.698-.666-1.2-1.138-1.731-1.638-.922-.862-1.871-1.75-3.881-3.75l-.028-.028c-.028-.028-.056-.056-.112-.056-.558-.194-1.703-.389-3.127-.639C6.087 2.223 3.21 1.723.614.944c0 0-.168 0-.196.028l-.083.084c-.028.027-.056.055-.224.11h.056-.056c.028.167.028.278.084.473 0 .055.112.5.112.555l.782 3.556Zm15.496 3.278-.335-.334c.084.112.196.195.335.334Zm-3.546 4.666-.056.056c0-.028.028-.056.056-.056Zm-2.038-10c.168.167.866.834 1.033.973-.726-.334-2.54-1.167-3.379-1.445.838.167 1.983.334 2.346.472ZM1.424 2.306c.419.722.754 3.222 1.089 5.666-.196-.778-.335-1.555-.503-2.278-.251-1.277-.503-2.416-.838-3.416.056 0 .14 0 .252.028Zm-.168-.584c-.112 0-.223-.028-.307-.028 0-.027 0-.055-.028-.055.14 0 .223.028.335.083Zm-1.089.222c0-.027 0-.027 0 0ZM39.453 1.333c.028-.11-.558-.61-.363-.639.42-.027.42-.666 0-.666-.558.028-1.144.166-1.675.25-.977.194-1.982.389-2.96.61-2.205.473-4.383.973-6.561 1.557-.67.194-1.424.333-2.066.666-.224.111-.196.333-.084.472-.056.028-.084.028-.14.056-.195.028-.363.056-.558.083-.168.028-.252.167-.224.334 0 .027.028.083.028.11-1.173 1.556-2.485 3.195-3.909 4.945-1.396 1.611-2.876 3.306-4.356 5.056-4.719 5.5-10.052 11.75-15.943 17.25a.268.268 0 0 0 0 .389c.028.027.056.055.084.055-.084.084-.168.14-.252.222-.056.056-.084.111-.084.167a.605.605 0 0 0-.111.139c-.112.111-.112.305.028.389.111.11.307.11.39-.028.029-.028.029-.056.056-.056a.44.44 0 0 1 .615 0c.335.362.67.723.977 1.028l-.698-.583c-.112-.111-.307-.083-.39.028-.113.11-.085.305.027.389l7.427 6.194c.056.056.112.056.196.056s.14-.028.195-.084l.168-.166c.028.027.083.027.111.027.084 0 .14-.027.196-.083 10.052-10.055 18.15-17.639 27.42-24.417.083-.055.111-.166.111-.25.112 0 .196-.083.251-.194 1.704-5.194 2.039-9.806 2.15-12.083v-.028c0-.028.028-.056.028-.083.028-.056.028-.084.028-.084a1.626 1.626 0 0 0-.111-1.028ZM21.472 9.5c.446-.5.893-1.028 1.34-1.5-2.876 3.778-7.65 9.583-14.408 16.5 4.607-5.083 9.242-10.333 13.068-15ZM5.193 35.778h.084-.084Zm3.462 3.194c-.027-.028-.027-.028 0-.028v.028Zm4.16-3.583c.224-.25.448-.472.699-.722 0 0 0 .027.028.027-.252.223-.475.445-.726.695Zm1.146-1.111c.14-.14.279-.334.446-.5l.028-.028c1.648-1.694 3.351-3.389 5.082-5.111l.028-.028c.419-.333.921-.694 1.368-1.028a379.003 379.003 0 0 0-6.952 6.695ZM24.794 6.472c-.921 1.195-1.954 2.778-2.82 4.028-2.736 3.944-11.532 13.583-11.727 13.75a1976.983 1976.983 0 0 1-8.042 7.639l-.167.167c-.14-.167-.14-.417.028-.556C14.49 19.861 22.03 10.167 25.074 5.917c-.084.194-.14.36-.28.555Zm4.83 5.695c-1.116-.64-1.646-1.64-1.34-2.611l.084-.334c.028-.083.084-.194.14-.277.307-.5.754-.917 1.257-1.167.027 0 .055 0 .083-.028-.028-.056-.028-.139-.028-.222.028-.167.14-.278.335-.278.335 0 1.369.306 1.76.639.111.083.223.194.335.305.14.167.363.445.474.667.056.028.112.306.196.445.056.222.111.472.084.694-.028.028 0 .194-.028.194a2.668 2.668 0 0 1-.363 1.028c-.028.028-.028.056-.056.084l-.028.027c-.14.223-.335.417-.53.556-.643.444-1.369.583-2.095.389 0 0-.195-.084-.28-.111Zm8.154-.834a39.098 39.098 0 0 1-.893 3.167c0 .028-.028.083 0 .111-.056 0-.084.028-.14.056-2.206 1.61-4.356 3.305-6.506 5.028 1.843-1.64 3.686-3.306 5.613-4.945.558-.5.949-1.139 1.06-1.861l.28-1.667v-.055c.14-.334.67-.195.586.166Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
class="ExcalidrawLogo-text"
|
||||
fill="none"
|
||||
viewBox="0 0 450 55"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M429.27 96.74c2.47-1.39 4.78-3.02 6.83-4.95 1.43-1.35 2.73-2.86 3.81-4.51-.66.9-1.4 1.77-2.23 2.59-2.91 2.84-5.72 5.09-8.42 6.87h.01ZM343.6 69.36c.33 3.13.58 6.27.79 9.4.09 1.37.18 2.75.25 4.12-.12-4.46-.27-8.93-.5-13.39-.11-2.08-.24-4.16-.4-6.24-.06 1.79-.11 3.85-.13 6.11h-.01ZM378.47 98.34c.01-.37.07-1.13.01-6.51-.11 1.9-.22 3.81-.31 5.71-.07 1.42-.22 2.91-.16 4.35.39.03.78.07 1.17.1-.92-.85-.76-2.01-.72-3.66l.01.01ZM344.09 86.12c-.09-2.41-.22-4.83-.39-7.24v12.21c.15-.05.32-.09.47-.14.05-1.61-.03-3.23-.09-4.83h.01ZM440.69 66.79c-.22-.34-.45-.67-.69-.99-3.71-4.87-9.91-7.14-15.65-8.55-1.05-.26-2.12-.49-3.18-.71 2.29.59 4.48 1.26 6.64 2.02 7.19 2.54 10.57 5.41 12.88 8.23ZM305.09 72.46l1.2 3.6c.84 2.53 1.67 5.06 2.46 7.61.24.78.5 1.57.73 2.36.22-.04.44-.08.67-.12a776.9 776.9 0 0 1-5.01-13.57c-.02.04-.03.09-.05.13v-.01ZM345.49 90.25v.31c1.48-.42 3.05-.83 4.66-1.2-1.56.25-3.12.52-4.66.89ZM371.02 90.22c0-.57-.04-1.14-.11-1.71-.06-.02-.12-.04-.19-.05-.21-.05-.43-.08-.65-.11.42.16.74.88.95 1.87ZM398.93 54.23c-.13 0-.27-.01-.4-.02l.03.4c.11-.15.23-.27.37-.38ZM401.57 62.28v-.15c-1.22-.24-2.86-.61-3.23-1.25-.09-.15-.18-.51-.27-.98-.09.37-.2.73-.33 1.09 1.24.56 2.52.98 3.83 1.29ZM421.73 88.68c-2.97 1.65-6.28 3.12-9.69 3.68v.18c4.72-.14 11.63-3.85 16.33-8.38-2.04 1.75-4.33 3.24-6.63 4.53l-.01-.01ZM411.28 80.92c-.05-1.2-.09-2.4-.15-3.6-.21 5.66-.46 11.38-.47 14.51.24-.02.48-.04.71-.07.15-3.61.05-7.23-.09-10.83v-.01Z"
|
||||
transform="translate(-144.023 -51.76)"
|
||||
/>
|
||||
<path
|
||||
d="M425.38 67.41c-3.5-1.45-7.19-2.57-14.06-3.62.09 1.97.06 4.88-.03 8.12.03.04.06.09.06.15.19 1.36.28 2.73.37 4.1.25 3.77.39 7.55.41 11.33 0 1.38-.01 2.76-.07 4.13 1.4-.25 2.78-.65 4.12-1.15 4.07-1.5 7.94-3.78 11.28-6.54 2.33-1.92 5.13-4.49 5.88-7.58.63-3.53-2.45-6.68-7.97-8.96l.01.02ZM411.35 92.53v-.06l-.34.03c.11.01.22.03.34.03ZM314.26 64.06c-.23-.59-.47-1.17-.7-1.75.57 1.62 1.11 3.25 1.6 4.9l.15.54 2.35 6.05c.32.82.66 1.64.98 2.46-1.38-4.1-2.83-8.17-4.39-12.2h.01ZM156.82 103.07c-.18.13-.38.23-.58.33 1.32-.03 2.66-.2 3.93-.34.86-.09 1.72-.22 2.58-.33-2.12.1-4.12.17-5.94.34h.01ZM210.14 68.88s.03.04.05.07c.18-.31.39-.64.58-.96-.21.3-.42.6-.64.89h.01ZM201.65 82.8c-.5.77-1.02 1.56-1.49 2.37 1.11-1.55 2.21-3.1 3.2-4.59-.23.23-.49.51-.75.79-.32.47-.65.95-.96 1.43ZM194.03 98.66c-.33-.4-.65-.84-1.05-1.17-.24-.2-.07-.49.17-.56-.23-.26-.42-.5-.63-.75 1.51-2.55 3.93-5.87 6.4-9.28-.17-.08-.29-.28-.2-.49.04-.09.09-.17.13-.26-1.21 1.78-2.42 3.55-3.61 5.33-.87 1.31-1.74 2.64-2.54 4-.29.5-.63 1.04-.87 1.61.81.65 1.63 1.27 2.47 1.88-.09-.11-.18-.21-.27-.32v.01ZM307.79 82.93c-1-3.17-2.05-6.32-3.1-9.48-1.62 4.08-3.69 9.17-6.16 15.19 3.32-1.04 6.77-1.87 10.27-2.5-.32-1.08-.67-2.15-1.01-3.21ZM149.5 80.7c.05-1.71.04-3.43 0-5.14-.1 2.26-.16 4.51-.22 6.77-.02.73-.03 1.46-.04 2.19.14-1.27.2-2.55.24-3.82h.02ZM228.98 98.3c.39 1.25.91 3.03.94 3.91.06-.03.12-.07.17-.1.08-1.29-.55-2.65-1.11-3.81ZM307.72 53.36c.81.5 1.53 1.04 2.07 1.49-.38-.8-.78-1.58-1.21-2.35-.17.03-.34.06-.51.11-.43.12-.86.26-1.29.41.35-.01.53.1.94.34ZM283.69 96.14c3.91-7.25 6.89-13.35 8.88-18.15l1.1-2.66c-1.27 2.64-2.56 5.27-3.83 7.9-1.53 3.15-3.06 6.31-4.58 9.47-.87 1.81-1.76 3.62-2.54 5.47.04.02.07.04.11.07.05.05.1.09.15.14.05-.73.27-1.48.71-2.24ZM289.92 103.23s-.04.01-.05.03c0-.02.04-.03.05-.04.05-.05.11-.1.16-.15l.21-.21c-.55 0-1.5-.27-2.55-.72.4.26.8.51 1.22.74.24.13.48.26.73.37.05.02.1.03.14.05a.27.27 0 0 1 .08-.07h.01ZM269.23 68.49c-.39-.19-.82-.48-1.33-.87-3.06-1.56-6.31-2.78-9.36-2.35-3.5.49-5.7 1.11-7.74 2.44 5.71-2.6 12.82-2.07 18.44.79l-.01-.01ZM177.87 53.69l1.06.03c-.96-.22-2-.25-2.89-.3-4.95-.26-9.99.33-14.86 1.19-2.44.43-4.88.95-7.28 1.59 9.09-1.76 15.69-2.77 23.97-2.51ZM219.85 55.51c-.18.12-.36.27-.56.45-.45.53-.86 1.11-1.26 1.66-1.91 2.61-3.71 5.31-5.57 7.95l-.12.18 8.05-10.11c-.18-.05-.36-.1-.55-.13h.01ZM510.71 54.1c.12-.15.29-.3.53-.45.69-.4 3.72-.63 5.87-.74-.36-.02-.73-.04-1.09-.05-1.84-.03-3.67.09-5.49.35.05.3.12.59.18.88v.01ZM510.76 86.02c1.37-3.07 2.49-6.27 3.57-9.46.55-1.64 1.12-3.3 1.6-4.97-1.59 4.01-3.67 9.14-6.2 15.3.24-.08.5-.14.74-.22.1-.22.19-.44.29-.65ZM566.95 75.76c.11-.02.23.03.31.11-.05-.13-.09-.26-.14-.39-.05.09-.11.18-.17.28ZM511.33 86.41c3.08-.89 6.24-1.62 9.46-2.14-1.51-3.98-2.98-7.96-4.39-11.87-.05.15-.09.31-.14.46-1.02 3.32-2.15 6.61-3.39 9.85-.48 1.25-.98 2.49-1.53 3.7h-.01ZM578.24 74.45c.11-.44.23-.87.35-1.31-.31.7-.64 1.39-.97 2.08.09.21.19.4.28.61.12-.46.23-.92.35-1.38h-.01ZM520.62 53.11c-.09 0-.18-.01-.28-.02.38.34.29 1.08.93 2.53l6.65 17.15c2.2 5.68 4.69 11.36 7.41 16.87l1.06 2.17c-2.95-7.05-5.92-14.08-8.87-21.13-1.58-3.79-3.16-7.59-4.7-11.4-.78-1.92-1.73-3.89-2.25-5.91-.03-.1 0-.19.04-.26h.01ZM578.78 77.87c1.45-5.77 3.07-10.43 3.58-13.36.05-.34.16-.88.31-1.55-.67 1.79-1.37 3.56-2.08 5.33-.12.43-.23.86-.35 1.29-.65 2.43-1.29 4.86-1.9 7.3.14.33.29.65.43 1l.01-.01ZM545.3 94.66c.02-.44.03-.83.05-1.12.02-1.01.05-2.02.11-3.02.03-6.66-.46-14.33-1.46-22.8-.13-.42-.27-1.24-.56-2.89 0-.02 0-.04-.01-.06.62 6.61.95 13.25 1.32 19.87.17 3.08.33 6.16.52 9.23.02.25.03.52.04.78l-.01.01ZM580.77 102.81c.13.2.27.38.37.49.27-.11.53-.22.8-.32-.43.09-.82.05-1.17-.16v-.01ZM530.48 104.07h.33c-.36-.13-.71-.32-1.04-.56.14.24.3.47.45.7.06-.08.14-.13.26-.13v-.01ZM542.63 58.82c.06.23.11.47.15.71.14-.33.36-.62.7-.86-.28.05-.57.11-.85.15ZM583.81 57.87c.15-.7.29-1.41.42-2.11-.14.45-.28.9-.42 1.34-.46 1.44-.89 2.89-1.31 4.34.44-1.19.88-2.37 1.31-3.57ZM523.62 91.48c-4.66 1.17-9.05 2.89-14.02 5.27 4.65-1.84 9.48-3.29 14.28-4.63-.09-.22-.17-.41-.26-.64ZM460.64 78.3c-.04-2.9-.11-5.81-.28-8.71-.1-1.68-.17-3.43-.5-5.09-.07.02-.14.03-.2.05.3 6.54.45 12.17.51 17.12.17-.07.34-.14.51-.2 0-1.06-.01-2.11-.03-3.17h-.01ZM470.63 63.24c-3.38-.26-6.81.32-10.1 1.1.41 2.01.47 4.14.57 6.18.18 3.55.25 7.11.27 10.67 3.31-1.38 6.5-3.12 9.3-5.35 1.96-1.56 3.86-3.41 5.02-5.66.73-1.41 1.19-3.22.26-4.65-1.09-1.7-3.46-2.14-5.32-2.29ZM460.29 63.68c1-.24 2.01-.46 3.04-.65-1.15.16-2.37.38-3.71.69v.13c.07-.02.15-.04.22-.05.11-.13.3-.18.45-.11v-.01ZM457.24 100.96c.43-.03.86-.07 1.29-.11.14-.49.27-.99.38-1.49-.44.7-1 1.23-1.67 1.6ZM482.88 104.98c-.18.23-.36.38-.55.47.14.09.27.19.4.28a70.76 70.76 0 0 0 4.37-4.63c.76-.89 1.52-1.81 2.19-2.77-.3-.27-.61-.53-.92-.79-.07 1.94-4.62 6.32-5.49 7.45v-.01Z"
|
||||
transform="translate(-144.023 -51.76)"
|
||||
/>
|
||||
<path
|
||||
d="M474.36 63.31c-.4-.16-.84-.27-1.29-.37 1.56.42 3.08 1.22 3.76 2.74.62 1.4.32 2.95-.28 4.32.7-1.22.94-2.34.74-3.47-.24-1.33-1.19-2.54-2.93-3.21v-.01ZM477.34 89.18c-1.2-.81-2.4-1.62-3.6-2.42-.14.1-.26.19-.4.29 1.4.67 2.73 1.39 4 2.13ZM465.88 93.85c.37.25.74.5 1.1.75.46.32.92.65 1.38.97-1.57-1.2-2.01-1.61-2.49-1.72h.01ZM574.92 90.06c-2.28-5.21-4.93-11.13-5.67-12.26-.1-.15-1.57-3.01-1.63-3.08 0 0-.01.02-.02.02.4 1.37 1.09 2.69 1.65 3.99 2.14 4.95 4.36 9.86 6.67 14.73.6 1.26 1.21 2.52 1.83 3.78-.75-2.01-1.64-4.45-2.83-7.18ZM448.73 65.29c.1.2.22.38.34.57.22-.02.43-.06.65-.08v-.08c-.14-.05-.25 0-.99-.41ZM460.16 94.81c-.02.31-.06.59-.1.89-.03 1.71-.33 3.43-.79 5.07.15-.02.3-.03.45-.05.01-.04.02-.08.03-.11.09-.34.15-.69.2-1.03.17-1.07.25-2.16.33-3.24.05-.69.08-1.39.12-2.08-.27.1-.27.26-.24.57v-.02Z"
|
||||
transform="translate(-144.023 -51.76)"
|
||||
/>
|
||||
<path
|
||||
d="m328.67 98.12-3.22-6.58c-1.29-2.63-2.53-5.29-3.72-7.97-.25-.85-.52-1.69-.79-2.53-.81-2.57-1.67-5.12-2.55-7.67-1.92-5.53-3.9-11.08-6.32-16.41-.72-1.58-1.46-3.44-2.63-4.79-.03-.17-.16-.29-.34-.36a.282.282 0 0 0-.23-.04c-.06-.01-.12 0-.18.01-.74.06-1.5.38-2.19.61-2.22.77-4.4 1.64-6.63 2.38-.03-.08-.06-.16-.09-.25-.15-.42-.82-.24-.67.19.03.09.07.19.1.28l-.18.06c-.36.11-.28.6 0 .68.18 1.18.63 2.36.98 3.49.03.09.06.17.08.26-.08.23-.17.46-.24.64-.37.98-.79 1.94-1.21 2.9-1.27 2.89-2.62 5.75-3.98 8.6-3.18 6.67-6.44 13.31-9.64 19.97-1.08 2.25-2.2 4.5-3.15 6.81-.13.32.24.5.5.37 1.34 1.33 2.84 2.5 4.4 3.57.65.44 1.31.87 2.01 1.24.4.22.86.48 1.33.5.24.01.35-.19.33-.37.11-.1.21-.21.28-.28.41-.41.81-.84 1.2-1.26.85-.92 1.69-1.87 2.5-2.84 6.31-2.34 12.6-4.31 18.71-5.84 2.14 5.3 3.43 8.43 3.97 9.58.55 1.05 1.15 1.88 1.82 2.52 1.32.56 6.96-.03 9.23-1.96.87-1.28 1.19-2.67.93-4.15-.09-.5-.22-.95-.4-1.33l-.01-.03Zm-20.09-45.61c.43.77.83 1.56 1.21 2.35-.54-.45-1.27-.99-2.07-1.49-.42-.24-.6-.35-.94-.34.43-.15.85-.29 1.29-.41.17-.05.34-.08.51-.11Zm-25.86 45.66c.78-1.85 1.67-3.66 2.54-5.47 1.51-3.16 3.05-6.31 4.58-9.47 1.28-2.63 2.56-5.26 3.83-7.9l-1.1 2.66c-1.99 4.79-4.97 10.9-8.88 18.15-.43.76-.66 1.51-.71 2.24-.05-.05-.1-.09-.15-.14a.259.259 0 0 0-.11-.07Zm6.24 4.71c-.42-.23-.82-.48-1.22-.74 1.05.45 2 .72 2.55.72l-.21.21c-.05.05-.11.1-.16.15-.01.01-.04.03-.05.04 0-.02.03-.02.05-.03a.27.27 0 0 0-.08.07c-.05-.02-.1-.03-.14-.05-.25-.1-.49-.24-.73-.37h-.01Zm15.73-29.43c1.05 3.15 2.1 6.31 3.1 9.48.34 1.06.69 2.13 1.01 3.21-3.5.63-6.95 1.46-10.27 2.5 2.48-6.03 4.54-11.11 6.16-15.19Zm4.79 12.57c-.23-.79-.49-1.58-.73-2.36-.79-2.54-1.63-5.08-2.46-7.61l-1.2-3.6c.02-.04.04-.09.05-.13 1.6 4.45 3.28 9 5.01 13.57l-.67.12v.01Zm5.83-18.27-.15-.54c-.49-1.64-1.03-3.28-1.6-4.9.23.58.47 1.17.7 1.75 1.56 4.03 3.01 8.1 4.39 12.2-.33-.82-.67-1.64-.98-2.46l-2.35-6.05h-.01ZM390.43 79.37c-.13-10.43-.22-17.5-.24-19.97-.24-1.6.21-2.88-.65-3.65-.14-.13-.32-.23-.52-.32h.03c.45 0 .45-.69 0-.7-1.75-.03-3.5-.04-5.25-.14-1.38-.08-2.76-.21-4.15-.31-.07 0-.12.01-.17.04-.21-.07-.47.03-.45.31l.03.45c-.11.14-.19.3-.22.5-.21 1.26-.32 13.67-.36 23.59-.32 5.79-.67 11.57-.97 17.36-.09 1.73-.29 3.54-.21 5.3-.39.02-.38.64.04.69v.12c.05.44.74.45.7 0v-.06c1.1.09 2.2.21 3.3.3 1.14.19 2.44.2 3.29.17 1.73-.05 2.92-.05 3.8-.37.45-.05.9-.11 1.35-.17.44-.06.25-.73-.19-.67h-.01c.24-.32.45-.72.62-1.25.66-1.84.41-6.36.34-11.33l-.13-9.9.02.01Zm-12.26 18.17c.09-1.91.2-3.81.31-5.71.06 5.38 0 6.14-.01 6.51-.05 1.65-.21 2.81.72 3.66-.39-.04-.78-.07-1.17-.1-.06-1.44.09-2.93.16-4.35l-.01-.01ZM588.97 53.85c-2.06-.25-3.17-.51-3.76-.6a.3.3 0 0 1 .04-.08c.22-.39-.39-.75-.6-.35-.56 1.02-.9 2.19-1.26 3.29-.61 1.88-1.17 3.78-1.72 5.68-.63 2.19-1.24 4.39-1.83 6.59-.81 2.03-1.67 4.05-2.61 6.03-1.7-3.64-3.11-6.04-4.03-7.57-2.26-3.74-2.85-5.48-3.57-6.08l.31-.09c.43-.12.25-.8-.19-.67-1.06.3-2.12.6-3.17.95-.93.32-1.85.69-2.76 1.07-.13.05-.19.16-.22.27-.04.02-.08.05-.11.07-.04-.06-.07-.12-.11-.18a.354.354 0 0 0-.48-.12c-.16.09-.22.32-.13.48l.33.54c0 .09.02.18.06.28.51 1.16.78 1.38.72 1.47-2.42 3.44-5.41 7.86-6.2 9.1-1.27 1.97-2.01 3.14-2.45 3.84l-.91-6.56-.43-4.1c-.19-1.85-.37-3.23-.53-4.13-.19-1.1-.3-2.15-.45-3.16-.2-1.36-.29-2.06-.47-2.42h.04c.45.02.45-.68 0-.7-3.43-.16-6.81.94-10.17 1.48-.24-.22-.73-.04-.58.32.24.59.33 1.25.43 1.87.17 1.06.29 2.13.4 3.2.32 3.09.53 6.2.74 9.3.44 6.75.77 13.51 1.17 20.26.11 1.95.13 3.96.46 5.89.05.3.37.31.55.14.74 1.71 2.87 1.27 6.13 1.27 1.34 0 2.39.04 2.99-.11.02.32.48.53.63.18 3.61-8.26 7.41-16.46 12.05-24.2.03-.05.04-.1.05-.15.3.73.64 1.45.94 2.16.97 2.26 1.97 4.52 2.98 6.76 2.26 5.03 4.54 10.07 7.09 14.96.47.9.94 1.79 1.47 2.65.2.32.4.67.66.96-.18.25 0 .68.34.54.91-.38 1.82-.75 2.76-1.07 1.04-.35 2.11-.65 3.17-.95.39-.11.28-.66-.07-.68.62-.4.95-.96.87-1.91-.3-3.34.72-7.47.86-8.52l2.14-11.43c1.75-10.74 3.13-17.51 3.23-20.86.02-.49.08-2.84.13-3.24.17-1.25.48-1-4.96-1.65l.03-.02Zm-46.19 5.67c-.04-.24-.09-.48-.15-.71l.85-.15c-.34.24-.56.53-.7.86Zm1.95 25.12c-.36-6.63-.7-13.26-1.32-19.87 0 .02 0 .04.01.06.29 1.65.44 2.47.56 2.89 1 8.46 1.5 16.14 1.46 22.8-.06.99-.1 2-.11 3.02-.01.29-.03.68-.05 1.12-.01-.26-.03-.53-.04-.78-.19-3.08-.35-6.16-.52-9.23l.01-.01Zm36.4 18.66c-.11-.11-.24-.29-.37-.49.35.21.74.26 1.17.16-.27.11-.53.22-.8.32v.01Zm-.89-33.72c.12-.43.23-.86.35-1.29.71-1.77 1.41-3.55 2.08-5.33-.15.68-.26 1.22-.31 1.55-.5 2.94-2.13 7.59-3.58 13.36-.15-.35-.29-.66-.43-1 .61-2.44 1.25-4.87 1.9-7.3l-.01.01Zm3.56-12.48c.14-.44.28-.89.42-1.34-.13.7-.27 1.41-.42 2.11-.43 1.19-.86 2.38-1.31 3.57.42-1.45.85-2.9 1.31-4.34Zm-5.22 16.05c-.11.44-.23.87-.35 1.31-.12.46-.23.92-.35 1.38-.1-.22-.19-.4-.28-.61.34-.69.66-1.38.97-2.08h.01Zm-11.64 2.62c.06-.1.12-.19.17-.28.05.13.09.26.14.39a.398.398 0 0 0-.31-.11Zm2.3 2.98c-.56-1.3-1.25-2.63-1.65-3.99 0 0 .01-.02.02-.02.06.08 1.52 2.93 1.63 3.08.73 1.13 3.38 7.04 5.67 12.26 1.2 2.73 2.08 5.17 2.83 7.18-.62-1.25-1.23-2.51-1.83-3.78-2.31-4.87-4.53-9.78-6.67-14.73ZM275.92 87.03c-1.06-2.18-1.13-3.45-2.44-2.93-1.52.57-2.94 1.3-Line truncated
|
||||
fill="currentColor"
|
||||
transform="translate(-144.023 -51.76)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
||||
>
|
||||
All your data is saved locally in your browser.
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu"
|
||||
>
|
||||
<button
|
||||
class="welcome-screen-menu-item "
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="welcome-screen-menu-item "
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="welcome-screen-menu-item "
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/>
|
||||
<path
|
||||
d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"
|
||||
/>
|
||||
<path
|
||||
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||
/>
|
||||
<path
|
||||
d="M21 21v-2a4 4 0 0 0 -3 -3.85"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Live collaboration...
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
class="welcome-screen-menu-item "
|
||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<rect
|
||||
height="4"
|
||||
rx="1"
|
||||
width="18"
|
||||
x="3"
|
||||
y="8"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="8"
|
||||
y2="21"
|
||||
/>
|
||||
<path
|
||||
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Try Excalidraw Plus!
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
updateSceneData,
|
||||
waitFor,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
import ExcalidrawApp from "../App";
|
||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
||||
const { h } = window;
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: {
|
||||
getRandomValues: (arr: number[]) =>
|
||||
arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))),
|
||||
subtle: {
|
||||
generateKey: () => {},
|
||||
exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||
const loadFromFirebase = async () => null;
|
||||
const saveToFirebase = () => {};
|
||||
const isSavedToFirebase = () => true;
|
||||
const loadFilesFromFirebase = async () => ({
|
||||
loadedFiles: [],
|
||||
erroredFiles: [],
|
||||
});
|
||||
const saveFilesToFirebase = async () => ({
|
||||
savedFiles: new Map(),
|
||||
erroredFiles: new Map(),
|
||||
});
|
||||
|
||||
return {
|
||||
loadFromFirebase,
|
||||
saveToFirebase,
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
saveFilesToFirebase,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("socket.io-client", () => {
|
||||
return {
|
||||
default: () => {
|
||||
return {
|
||||
close: () => {},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
off: () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("collaboration", () => {
|
||||
it("creating room should reset deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// To update the scene with deleted elements before starting collab
|
||||
updateSceneData({
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", id: "A" }),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "B",
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
window.collab.startCollaboration(null);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
// noop
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
BroadcastedExcalidrawElement,
|
||||
ReconciledElements,
|
||||
reconcileElements,
|
||||
} from "../../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../../packages/excalidraw/random";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
id: string;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
||||
};
|
||||
|
||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
||||
|
||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
||||
let uid: string;
|
||||
let id: string;
|
||||
let version: number | null;
|
||||
let parent: string | null = null;
|
||||
let versionNonce: number | null = null;
|
||||
if ("uid" in opts) {
|
||||
const match = opts.uid.match(
|
||||
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
||||
)!;
|
||||
parent = match[1];
|
||||
id = match[2];
|
||||
version = match[3] ? parseInt(match[3]) : null;
|
||||
uid = version ? `${id}:${version}` : id;
|
||||
} else {
|
||||
({ id, version, versionNonce } = opts);
|
||||
parent = parent || null;
|
||||
uid = id;
|
||||
}
|
||||
return {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
versionNonce: versionNonce || randomInteger(),
|
||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
||||
};
|
||||
};
|
||||
|
||||
const idsToElements = (
|
||||
ids: (Id | ElementLike)[],
|
||||
cache: Cache = {},
|
||||
): readonly ExcalidrawElement[] => {
|
||||
return ids.reduce((acc, _uid, idx) => {
|
||||
const {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
versionNonce,
|
||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
||||
const cached = cache[uid];
|
||||
const elem = {
|
||||
id,
|
||||
version: version ?? 0,
|
||||
versionNonce,
|
||||
...cached,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
} as BroadcastedExcalidrawElement;
|
||||
// @ts-ignore
|
||||
cache[uid] = elem;
|
||||
acc.push(elem);
|
||||
return acc;
|
||||
}, [] as ExcalidrawElement[]);
|
||||
};
|
||||
|
||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
||||
return elements.map((el, idx, els) => {
|
||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
||||
return el;
|
||||
});
|
||||
};
|
||||
|
||||
const cleanElements = (elements: ReconciledElements) => {
|
||||
return elements.map((el) => {
|
||||
// @ts-ignore
|
||||
delete el[PRECEDING_ELEMENT_KEY];
|
||||
// @ts-ignore
|
||||
delete el.next;
|
||||
// @ts-ignore
|
||||
delete el.prev;
|
||||
return el;
|
||||
});
|
||||
};
|
||||
|
||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
local: (Id | ElementLike)[],
|
||||
remote: (Id | ElementLike)[],
|
||||
target: U[],
|
||||
bidirectional = true,
|
||||
) => {
|
||||
const cache: Cache = {};
|
||||
const _local = idsToElements(local, cache);
|
||||
const _remote = idsToElements(remote, cache);
|
||||
const _target = target.map((uid) => {
|
||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
||||
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
||||
}) as any as ReconciledElements;
|
||||
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
||||
expect(target.length).equal(remoteReconciled.length);
|
||||
expect(cleanElements(remoteReconciled)).deep.equal(
|
||||
cleanElements(_target),
|
||||
"remote reconciliation",
|
||||
);
|
||||
|
||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
||||
if (bidirectional) {
|
||||
try {
|
||||
expect(
|
||||
cleanElements(
|
||||
reconcileElements(
|
||||
cloneJSON(__local),
|
||||
cloneJSON(__remote),
|
||||
{} as AppState,
|
||||
),
|
||||
),
|
||||
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
||||
} catch (error: any) {
|
||||
console.error("local original", __local);
|
||||
console.error("remote reconciled", __remote);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const findIndex = <T>(
|
||||
array: readonly T[],
|
||||
cb: (element: T, index: number, array: readonly T[]) => boolean,
|
||||
fromIndex: number = 0,
|
||||
) => {
|
||||
if (fromIndex < 0) {
|
||||
fromIndex = array.length + fromIndex;
|
||||
}
|
||||
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
||||
let index = fromIndex - 1;
|
||||
while (++index < array.length) {
|
||||
if (cb(array[index], index, array)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("elements reconciliation", () => {
|
||||
it("reconcileElements()", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
//
|
||||
// in following tests, we pass:
|
||||
// (1) an array of local elements and their version (:1, :2...)
|
||||
// (2) an array of remote elements and their version (:1, :2...)
|
||||
// (3) expected reconciled elements
|
||||
//
|
||||
// in the reconciled array:
|
||||
// :L means local element was resolved
|
||||
// :R means remote element was resolved
|
||||
//
|
||||
// if a remote element is prefixed with parentheses, the enclosed string:
|
||||
// (^) means the element is the first element in the array
|
||||
// (<id>) means the element is preceded by <id> element
|
||||
//
|
||||
// if versions are missing, it defaults to version 0
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// non-annotated elements
|
||||
// -------------------------------------------------------------------------
|
||||
// usually when we sync elements they should always be annotated with
|
||||
// their (preceding elements) parents, but let's test a couple of cases when
|
||||
// they're not for whatever reason (remote clients are on older version...),
|
||||
// in which case the first synced element either replaces existing element
|
||||
// or is pushed at the end of the array
|
||||
|
||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
||||
test(["A"], ["A:1"], ["A:R"]);
|
||||
|
||||
// C isn't added to the end because it follows B (even if B was resolved
|
||||
// to local version)
|
||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
||||
|
||||
// some of the following tests are kinda arbitrary and they're less
|
||||
// likely to happen in real-world cases
|
||||
|
||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:1", "A:3"],
|
||||
["B:L", "A:R", "C:L", "D:R"],
|
||||
);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:2", "A:3", "C"],
|
||||
["D:R", "B:L", "A:R", "C:L"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "C", "D", "E", "F"],
|
||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
||||
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
||||
);
|
||||
|
||||
// annotated elements
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["(B)X", "(A)Y", "(Y)Z"],
|
||||
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
||||
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
||||
|
||||
test(
|
||||
["A", "B"],
|
||||
["(A)C", "(^)D", "F"],
|
||||
["A:L", "C:R", "D:R", "F:R", "B:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(B)C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["(^)X", "(A)Y", "(B)Z"],
|
||||
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["B", "A", "C"],
|
||||
["(^)X", "(A)Y", "(B)Z"],
|
||||
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
||||
);
|
||||
|
||||
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["(A)X", "(C)Y", "(D)Z"],
|
||||
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["X", "Y", "Z"],
|
||||
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["(C)X", "(A)Y", "(D)E:1"],
|
||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["A", "B", "C:1", "D:1"],
|
||||
["A:R", "B:L", "C:L", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "(C)B", "(B)D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "(C)B", "(B)D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
||||
["A:R", "B:L", "C:R", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(C)X", "(B)Y", "(A)Z"],
|
||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
||||
);
|
||||
|
||||
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
||||
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["(A)C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
||||
|
||||
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
||||
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
||||
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
||||
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
||||
});
|
||||
|
||||
it("test identical elements reconciliation", () => {
|
||||
const testIdentical = (
|
||||
local: ElementLike[],
|
||||
remote: ElementLike[],
|
||||
expected: Id[],
|
||||
) => {
|
||||
const ret = reconcileElements(
|
||||
local as any as ExcalidrawElement[],
|
||||
remote as any as ExcalidrawElement[],
|
||||
{} as AppState,
|
||||
);
|
||||
|
||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
||||
throw new Error("reconcileElements: duplicate elements found");
|
||||
}
|
||||
|
||||
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
||||
};
|
||||
|
||||
// identical id/version/versionNonce
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
testIdentical(
|
||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
||||
["A"],
|
||||
);
|
||||
testIdentical(
|
||||
[
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
],
|
||||
[
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
],
|
||||
["B", "A"],
|
||||
);
|
||||
testIdentical(
|
||||
[
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
],
|
||||
[
|
||||
{ id: "B", version: 1, versionNonce: 1 },
|
||||
{ id: "A", version: 1, versionNonce: 1 },
|
||||
],
|
||||
["B", "A"],
|
||||
);
|
||||
|
||||
// actually identical (arrays and element objects)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const elements1 = [
|
||||
{
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
];
|
||||
|
||||
testIdentical(elements1, elements1, ["A", "B"]);
|
||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
||||
|
||||
const el1 = {
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
const el2 = {
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export const timeAgo = (date: string | number | Date): string => {
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 10) {
|
||||
return "刚刚";
|
||||
}
|
||||
if (diffInSeconds < 60) {
|
||||
return `${diffInSeconds} 秒前`;
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes} 分钟前`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} 小时前`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays} 天前`;
|
||||
}
|
||||
|
||||
return new Date(date).toLocaleDateString();
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
interface ImportMetaEnv {
|
||||
// The port to run the dev server
|
||||
VITE_APP_PORT: string;
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL: string;
|
||||
VITE_APP_BACKEND_V2_POST_URL: string;
|
||||
|
||||
// collaboration WebSocket server (https: string
|
||||
VITE_APP_WS_SERVER_URL: string;
|
||||
|
||||
// set this only if using the collaboration workflow we use on excalidraw.com
|
||||
VITE_APP_PORTAL_URL: string;
|
||||
VITE_APP_AI_BACKEND: string;
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG: string;
|
||||
|
||||
// whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
// debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
|
||||
|
||||
VITE_APP_DISABLE_SENTRY: string;
|
||||
|
||||
// Set this flag to false if you want to open the overlay by default
|
||||
VITE_APP_COLLAPSE_OVERLAY: string;
|
||||
|
||||
// Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT: string;
|
||||
|
||||
VITE_APP_PLUS_LP: string;
|
||||
|
||||
VITE_APP_PLUS_APP: string;
|
||||
|
||||
VITE_APP_GIT_SHA: string;
|
||||
|
||||
MODE: string;
|
||||
|
||||
DEV: string;
|
||||
PROD: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||
// open the browser
|
||||
open: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3002",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/auth": {
|
||||
target: "http://localhost:3002",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// We need to specify the envDir since now there are no
|
||||
//more located in parallel with the vite.config.ts file but in parent dir
|
||||
envDir: "../",
|
||||
build: {
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes("packages/excalidraw/locales") &&
|
||||
id.match(/en.json|percentages.json/) === null
|
||||
) {
|
||||
const index = id.indexOf("locales/");
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
eslint:
|
||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||
? undefined
|
||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||
overlay: {
|
||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||
},
|
||||
}),
|
||||
svgrPlugin(),
|
||||
ViteEjsPlugin(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
/* set this flag to true to enable in Development mode */
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("fonts.css"),
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "locales",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
name: "Excalidraw",
|
||||
description:
|
||||
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||
icons: [
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "apple-touch-icon.png",
|
||||
type: "image/png",
|
||||
sizes: "180x180",
|
||||
},
|
||||
{
|
||||
src: "favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
file_handlers: [
|
||||
{
|
||||
action: "/",
|
||||
accept: {
|
||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||
},
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: "/web-share-target",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
name: "file",
|
||||
accept: [
|
||||
"application/vnd.excalidraw+json",
|
||||
"application/json",
|
||||
".excalidraw",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/virtual-whiteboard.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/wireframe.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/illustration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/shapes.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/collaboration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/export.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
});
|
||||
Reference in new issue
Block a user