mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage
This commit is contained in:
commit
bff220e0f5
666 changed files with 15238 additions and 13351 deletions
900
excalidraw-app/App.tsx
Normal file
900
excalidraw-app/App.tsx
Normal file
|
@ -0,0 +1,900 @@
|
|||
import polyfill from "../packages/excalidraw/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
||||
import { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../packages/excalidraw/constants";
|
||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "../packages/excalidraw/element/types";
|
||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} from "../packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
} from "../packages/excalidraw/types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../packages/excalidraw/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import Collab, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
} from "./collab/Collab";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
restore,
|
||||
restoreAppState,
|
||||
RestoredDataState,
|
||||
} from "../packages/excalidraw/data/restore";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
} from "./components/ExportToExcalidrawPlus";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
useHandleLibrary,
|
||||
} from "../packages/excalidraw/data/library";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../packages/excalidraw/components/Trans";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
|
||||
polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
let isSelfEmbedding = false;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
try {
|
||||
const parentUrl = new URL(document.referrer);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
if (parentUrl.origin === currentUrl.origin) {
|
||||
isSelfEmbedding = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
||||
color: "danger",
|
||||
} as const;
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
| { isExternalScene: false; id?: null; key?: null }
|
||||
)
|
||||
> => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const id = searchParams.get("id");
|
||||
const jsonBackendMatch = window.location.hash.match(
|
||||
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
||||
);
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
if (isExternalScene) {
|
||||
if (
|
||||
// don't prompt if scene is empty
|
||||
!scene.elements.length ||
|
||||
// don't prompt for collab scenes because we don't override local storage
|
||||
roomLinkData ||
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
localDataState,
|
||||
);
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||
if (document.hidden) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => initializeScene(opts).then(resolve).catch(reject),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
roomLinkData = null;
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else if (externalUrlMatch) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
|
||||
const url = externalUrlMatch[1];
|
||||
try {
|
||||
const request = await fetch(window.decodeURIComponent(url));
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
if (
|
||||
!scene.elements.length ||
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
return { scene: data, isExternalScene };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
scene: {
|
||||
appState: {
|
||||
errorMessage: t("alerts.invalidSceneUrl"),
|
||||
},
|
||||
},
|
||||
isExternalScene,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (roomLinkData && opts.collabAPI) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
||||
return {
|
||||
// when collaborating, the state may have already been updated at this
|
||||
// point (we may have received updates from other clients), so reconcile
|
||||
// elements and appState with existing state
|
||||
scene: {
|
||||
...scene,
|
||||
appState: {
|
||||
...restoreAppState(
|
||||
{
|
||||
...scene?.appState,
|
||||
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
||||
},
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
// necessary if we're invoking from a hashchange handler which doesn't
|
||||
// go through App.initializeScene() that resets this flag
|
||||
isLoading: false,
|
||||
},
|
||||
elements: reconcileElements(
|
||||
scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
},
|
||||
isExternalScene: true,
|
||||
id: roomLinkData.roomId,
|
||||
key: roomLinkData.roomKey,
|
||||
};
|
||||
} else if (scene) {
|
||||
return isExternalScene && jsonBackendMatch
|
||||
? {
|
||||
scene,
|
||||
isExternalScene,
|
||||
id: jsonBackendMatch[1],
|
||||
key: jsonBackendMatch[2],
|
||||
}
|
||||
: { scene, isExternalScene: false };
|
||||
}
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialStatePromiseRef = useRef<{
|
||||
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
||||
}>({ promise: null! });
|
||||
if (!initialStatePromiseRef.current.promise) {
|
||||
initialStatePromiseRef.current.promise =
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
setTimeout(() => {
|
||||
trackEvent("load", "version", getVersion());
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
useMathSubtype(excalidrawAPI);
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
.fetchImageFilesFromFirebase({
|
||||
elements: data.scene.elements,
|
||||
forceFetchFiles: true,
|
||||
})
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const fileIds =
|
||||
data.scene.elements?.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element)) {
|
||||
return acc.concat(element.fileId);
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
fileIds,
|
||||
).then(({ loadedFiles, erroredFiles }) => {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
});
|
||||
|
||||
const onHashChange = async (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI?.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
}
|
||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToHistory: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!document.hidden &&
|
||||
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
}
|
||||
setLangCode(langCode);
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
});
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
||||
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
const currFiles = excalidrawAPI.getFiles();
|
||||
const fileIds =
|
||||
elements?.reduce((acc, element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
// only load and update images that aren't already loaded
|
||||
!currFiles[element.fileId]
|
||||
) {
|
||||
return acc.concat(element.fileId);
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, SYNC_BROWSER_TABS_TIMEOUT);
|
||||
|
||||
const onUnload = () => {
|
||||
LocalData.flushSave();
|
||||
};
|
||||
|
||||
const visibilityChange = (event: FocusEvent | Event) => {
|
||||
if (event.type === EVENT.BLUR || document.hidden) {
|
||||
LocalData.flushSave();
|
||||
}
|
||||
if (
|
||||
event.type === EVENT.VISIBILITY_CHANGE ||
|
||||
event.type === EVENT.FOCUS
|
||||
) {
|
||||
syncData();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.addEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
||||
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
document.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
LocalData.flushSave();
|
||||
|
||||
if (
|
||||
excalidrawAPI &&
|
||||
LocalData.fileStorage.shouldPreventUnload(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
)
|
||||
) {
|
||||
preventUnload(event);
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
(localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
||||
) as Theme | null) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
||||
// currently only used for body styling during init (see public/index.html),
|
||||
// but may change in the future
|
||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
||||
}, [theme]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
setTheme(appState.theme);
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
LocalData.save(elements, appState, files, () => {
|
||||
if (excalidrawAPI) {
|
||||
let didChange = false;
|
||||
|
||||
const elements = excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (
|
||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||
) {
|
||||
const newElement = newElementWith(element, { status: "saved" });
|
||||
if (newElement !== element) {
|
||||
didChange = true;
|
||||
}
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onExportToBackend = async (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
if (exportedElements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
viewBackgroundColor: appState.exportBackground
|
||||
? appState.viewBackgroundColor
|
||||
: getDefaultAppState().viewBackgroundColor,
|
||||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = appState;
|
||||
console.error(error, {
|
||||
width,
|
||||
height,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
});
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderCustomStats = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: UIAppState,
|
||||
) => {
|
||||
return (
|
||||
<CustomStats
|
||||
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const onLibraryChange = async (items: LibraryItems) => {
|
||||
if (!items.length) {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
return;
|
||||
}
|
||||
const serializedItems = JSON.stringify(items);
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
};
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
const onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
if (isSelfEmbedding) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<h1>I'm not a pretzel!</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className={clsx("excalidraw-app", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
langCode={langCode}
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<AppMainMenu
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<OverwriteConfirmDialog>
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
{excalidrawAPI && (
|
||||
<OverwriteConfirmDialog.Action
|
||||
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
||||
onClick={() => {
|
||||
exportToExcalidrawPlus(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
onCloseRequest={() => setLatestShareableLink(null)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
|
||||
<ShareDialog
|
||||
collabAPI={collabAPI}
|
||||
onExportToBackend={async () => {
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
await onExportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</Excalidraw>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcalidrawApp;
|
|
@ -1,14 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../src/utils";
|
||||
import { debounce, getVersion, nFormatter } from "../packages/excalidraw/utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../src/constants";
|
||||
import { t } from "../src/i18n";
|
||||
import { copyTextToSystemClipboard } from "../src/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../src/element/types";
|
||||
import { UIAppState } from "../src/types";
|
||||
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 };
|
||||
|
||||
|
|
|
@ -15,11 +15,17 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
|||
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_SCENE_EVENT_TYPES {
|
||||
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 = {
|
||||
|
|
11
excalidraw-app/bug-issue-template.js
Normal file
11
excalidraw-app/bug-issue-template.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export default (sentryErrorId) => `
|
||||
### Scene content
|
||||
|
||||
\`\`\`
|
||||
Paste scene content here
|
||||
\`\`\`
|
||||
|
||||
### Sentry Error ID
|
||||
|
||||
${sentryErrorId}
|
||||
`;
|
|
@ -1,36 +1,41 @@
|
|||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../src/types";
|
||||
import { ErrorDialog } from "../../src/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../src/constants";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
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 "../../src/element/types";
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
} from "../../src/packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../src/types";
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
} from "../../src/utils";
|
||||
throttleRAF,
|
||||
} from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
WS_SUBTYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getCollabServer,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
|
@ -47,42 +52,48 @@ import {
|
|||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { t } from "../../src/i18n";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
|
||||
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 "../../src/errors";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../src/element/typeChecks";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
} from "../../packages/excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../src/data/encryption";
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
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";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string;
|
||||
errorMessage: string | null;
|
||||
username: string;
|
||||
activeRoomLink: string;
|
||||
activeRoomLink: string | null;
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
export interface CollabAPI {
|
||||
|
@ -93,32 +104,33 @@ export interface CollabAPI {
|
|||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setErrorMessage: CollabInstance["setErrorMessage"];
|
||||
}
|
||||
|
||||
interface PublicProps {
|
||||
interface CollabProps {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}
|
||||
|
||||
type Props = PublicProps & { modalIsShown: boolean };
|
||||
|
||||
class Collab extends PureComponent<Props, CollabState> {
|
||||
class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
excalidrawAPI: CollabProps["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
constructor(props: Props) {
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorMessage: "",
|
||||
errorMessage: null,
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: "",
|
||||
activeRoomLink: null,
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
|
@ -151,12 +163,28 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
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 = {
|
||||
|
@ -167,6 +195,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setErrorMessage: this.setErrorMessage,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
|
@ -204,6 +235,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
this.onUmmount?.();
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
|
@ -313,9 +345,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
this.fileManager.reset();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.setActiveRoomLink(null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
|
@ -356,7 +386,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
) => {
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
try {
|
||||
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
|
||||
|
||||
|
@ -368,7 +398,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
return {
|
||||
type: "INVALID_RESPONSE",
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -381,7 +411,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.onUsernameChange(username);
|
||||
this.setUsername(username);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -423,13 +453,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||
|
||||
try {
|
||||
const socketServerData = await getCollabServer();
|
||||
|
||||
this.portal.socket = this.portal.open(
|
||||
socketIOClient(socketServerData.url, {
|
||||
transports: socketServerData.polling
|
||||
? ["websocket", "polling"]
|
||||
: ["websocket"],
|
||||
socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
|
||||
transports: ["websocket", "polling"],
|
||||
}),
|
||||
roomId,
|
||||
roomKey,
|
||||
|
@ -484,9 +510,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||
return;
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
|
@ -502,41 +528,75 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
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;
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
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({
|
||||
collaborators,
|
||||
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;
|
||||
}
|
||||
case "IDLE_STATUS": {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.userState = userState;
|
||||
user.username = username;
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
|
||||
default: {
|
||||
assertNever(decryptedData, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -553,11 +613,20 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
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.setState({
|
||||
activeRoomLink: window.location.href,
|
||||
});
|
||||
this.setActiveRoomLink(window.location.href);
|
||||
|
||||
return scenePromise;
|
||||
};
|
||||
|
@ -721,20 +790,39 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
setCollaborators(sockets: SocketId[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
@ -760,6 +848,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
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);
|
||||
};
|
||||
|
@ -769,7 +870,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
|
@ -782,7 +883,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
WS_SUBTYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
|
@ -808,41 +909,31 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
{ leading: false },
|
||||
);
|
||||
|
||||
handleClose = () => {
|
||||
appJotaiStore.set(collabDialogShownAtom, false);
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
this.setUsername(username);
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { username, errorMessage, activeRoomLink } = this.state;
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
const { modalIsShown } = this.props;
|
||||
setActiveRoomLink = (activeRoomLink: string | null) => {
|
||||
this.setState({ activeRoomLink });
|
||||
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
|
||||
};
|
||||
|
||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||
|
||||
setErrorMessage = (errorMessage: string | null) => {
|
||||
this.setState({ errorMessage });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { errorMessage } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<RoomDialog
|
||||
handleClose={this.handleClose}
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onRoomCreate={() => this.startCollaboration(null)}
|
||||
onRoomDestroy={this.stopCollaboration}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
this.setState({ errorMessage });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
|
||||
{errorMessage != null && (
|
||||
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
|
@ -861,11 +952,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
|||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
const _Collab: React.FC<PublicProps> = (props) => {
|
||||
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||
};
|
||||
|
||||
export default _Collab;
|
||||
export default Collab;
|
||||
|
||||
export type TCollabClass = Collab;
|
||||
|
|
|
@ -6,23 +6,24 @@ import {
|
|||
|
||||
import { TCollabClass } from "./Collab";
|
||||
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../src/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
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: SocketIOClient.Socket | null = null;
|
||||
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;
|
||||
|
@ -32,7 +33,7 @@ class Portal {
|
|||
this.collab = collab;
|
||||
}
|
||||
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
open(socket: Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
@ -46,12 +47,12 @@ class Portal {
|
|||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
WS_SUBTYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
|
@ -83,6 +84,7 @@ class Portal {
|
|||
async _broadcastSocketData(
|
||||
data: SocketUpdateData,
|
||||
volatile: boolean = false,
|
||||
roomId?: string,
|
||||
) {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
|
@ -91,7 +93,7 @@ class Portal {
|
|||
|
||||
this.socket?.emit(
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
this.roomId,
|
||||
roomId ?? this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
);
|
||||
|
@ -130,11 +132,11 @@ class Portal {
|
|||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
|
||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
|
@ -183,9 +185,9 @@ class Portal {
|
|||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: "IDLE_STATUS",
|
||||
type: WS_SUBTYPES.IDLE_STATUS,
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
socketId: this.socket.id as SocketId,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
|
@ -203,9 +205,9 @@ class Portal {
|
|||
}) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: "MOUSE_LOCATION",
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION,
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
socketId: this.socket.id as SocketId,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
|
@ -213,12 +215,43 @@ class Portal {
|
|||
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;
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../src/clipboard";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { KEYS } from "../../src/keys";
|
||||
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 "../../src/components/Dialog";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
|
@ -16,11 +16,11 @@ import {
|
|||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../src/components/icons";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { FilledButton } from "../../src/components/FilledButton";
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
|
||||
import { ReactComponent as CollabImage } from "../../src/assets/lock.svg";
|
||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
||||
import "./RoomDialog.scss";
|
||||
|
||||
const getShareIcon = () => {
|
||||
|
@ -120,7 +120,7 @@ export const RoomModal = ({
|
|||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
startIcon={getShareIcon()}
|
||||
icon={getShareIcon()}
|
||||
className="RoomDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
|
@ -130,7 +130,7 @@ export const RoomModal = ({
|
|||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
startIcon={copyIcon}
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
@ -166,7 +166,7 @@ export const RoomModal = ({
|
|||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
startIcon={playerStopFilledIcon}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
|
@ -195,7 +195,7 @@ export const RoomModal = ({
|
|||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
startIcon={playerPlayIcon}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import { arrayToMapWithIndex } from "../../src/utils";
|
||||
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";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Footer } from "../../src/packages/excalidraw/index";
|
||||
import { Footer } from "../../packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { MainMenu } from "../../src/packages/excalidraw/index";
|
||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
|
@ -17,7 +17,7 @@ export const AppMainMenu: React.FC<{
|
|||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { WelcomeScreen } from "../../src/packages/excalidraw/index";
|
||||
import { PlusPromoIcon } 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 "../../src/constants";
|
||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
|
@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{
|
|||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { shield } from "../../src/components/icons";
|
||||
import { Tooltip } from "../../src/components/Tooltip";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
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();
|
||||
|
|
|
@ -1,20 +1,30 @@
|
|||
import React from "react";
|
||||
import { Card } from "../../src/components/Card";
|
||||
import { ToolButton } from "../../src/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
encryptData,
|
||||
generateEncryptionKey,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||
import { encodeFilesForUpload } from "../data/FileManager";
|
||||
import { MIME_TYPES } from "../../src/constants";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../src/constants";
|
||||
import { Theme } from "../../src/element/types";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "..";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { languages } from "../../src/i18n";
|
||||
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();
|
||||
|
|
144
excalidraw-app/components/TopErrorBoundary.tsx
Normal file
144
excalidraw-app/components/TopErrorBoundary.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,19 +1,19 @@
|
|||
import { compressData } from "../../src/data/encode";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
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 "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
} from "../../src/types";
|
||||
} from "../../packages/excalidraw/types";
|
||||
|
||||
export class FileManager {
|
||||
/** files being fetched */
|
||||
|
|
|
@ -11,11 +11,18 @@
|
|||
*/
|
||||
|
||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import { debounce } from "../../src/utils";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/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";
|
||||
|
|
|
@ -1,20 +1,27 @@
|
|||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { getSceneVersion } from "../../src/element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../src/data/restore";
|
||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../src/types";
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
import { decompressData } from "../../src/data/encode";
|
||||
import { encryptData, decryptData } from "../../src/data/encryption";
|
||||
import { MIME_TYPES } from "../../src/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 "../../src/utility-types";
|
||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -132,12 +139,12 @@ const decryptElements = async (
|
|||
};
|
||||
|
||||
class FirebaseSceneVersionCache {
|
||||
private static cache = new WeakMap<SocketIOClient.Socket, number>();
|
||||
static get = (socket: SocketIOClient.Socket) => {
|
||||
private static cache = new WeakMap<Socket, number>();
|
||||
static get = (socket: Socket) => {
|
||||
return FirebaseSceneVersionCache.cache.get(socket);
|
||||
};
|
||||
static set = (
|
||||
socket: SocketIOClient.Socket,
|
||||
socket: Socket,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
||||
|
@ -279,7 +286,7 @@ export const saveToFirebase = async (
|
|||
export const loadFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
socket: SocketIOClient.Socket | null,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
|
|
@ -1,27 +1,36 @@
|
|||
import { compressData, decompressData } from "../../src/data/encode";
|
||||
import {
|
||||
compressData,
|
||||
decompressData,
|
||||
} from "../../packages/excalidraw/data/encode";
|
||||
import {
|
||||
decryptData,
|
||||
generateEncryptionKey,
|
||||
IV_LENGTH_BYTES,
|
||||
} from "../../src/data/encryption";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { restore } from "../../src/data/restore";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
} 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 "../../src/types";
|
||||
import { bytesToHexString } from "../../src/utils";
|
||||
} 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";
|
||||
|
@ -56,67 +65,49 @@ const generateRoomId = async () => {
|
|||
return bytesToHexString(buffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Right now the reason why we resolve connection params (url, polling...)
|
||||
* from upstream is to allow changing the params immediately when needed without
|
||||
* having to wait for clients to update the SW.
|
||||
*
|
||||
* If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks)
|
||||
*/
|
||||
export const getCollabServer = async (): Promise<{
|
||||
url: string;
|
||||
polling: boolean;
|
||||
}> => {
|
||||
if (import.meta.env.VITE_APP_WS_SERVER_URL) {
|
||||
return {
|
||||
url: import.meta.env.VITE_APP_WS_SERVER_URL,
|
||||
polling: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
|
||||
);
|
||||
return await resp.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error(t("errors.cannotResolveCollabServer"));
|
||||
}
|
||||
};
|
||||
|
||||
export type EncryptedData = {
|
||||
data: ArrayBuffer;
|
||||
iv: Uint8Array;
|
||||
};
|
||||
|
||||
export type SocketUpdateDataSource = {
|
||||
INVALID_RESPONSE: {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE;
|
||||
};
|
||||
SCENE_INIT: {
|
||||
type: "SCENE_INIT";
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: "SCENE_UPDATE";
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
type: "MOUSE_LOCATION";
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION;
|
||||
payload: {
|
||||
socketId: string;
|
||||
socketId: SocketId;
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: "IDLE_STATUS";
|
||||
USER_VISIBLE_SCENE_BOUNDS: {
|
||||
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
|
||||
payload: {
|
||||
socketId: string;
|
||||
socketId: SocketId;
|
||||
username: string;
|
||||
sceneBounds: SceneBounds;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: WS_SUBTYPES.IDLE_STATUS;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
userState: UserIdleState;
|
||||
username: string;
|
||||
};
|
||||
|
@ -124,10 +115,7 @@ export type SocketUpdateDataSource = {
|
|||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
||||
| {
|
||||
type: "INVALID_RESPONSE";
|
||||
};
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource];
|
||||
|
||||
export type SocketUpdateData =
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
} from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
|
|
3
excalidraw-app/global.d.ts
vendored
Normal file
3
excalidraw-app/global.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
interface Window {
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
}
|
234
excalidraw-app/index.html
Normal file
234
excalidraw-app/index.html
Normal file
|
@ -0,0 +1,234 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="referrer" content="origin" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#121212" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta property="og:image:alt" content="Excalidraw logo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:site" content="@excalidraw" />
|
||||
<meta property="twitter:url" content="https://excalidraw.com" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<style>
|
||||
html.dark {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
<% if ("%PROD%" === "true") { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
// Redirect only the bare root path, so link/room/library urls are not
|
||||
// redirected.
|
||||
//
|
||||
// Putting into index.html for best performance (can't redirect on server
|
||||
// due to location.hash checks).
|
||||
if (
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.hash &&
|
||||
!window.location.search &&
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url) {
|
||||
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||
console.info(
|
||||
"[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
var scriptEle = document.createElement("script");
|
||||
scriptEle.setAttribute(
|
||||
"src",
|
||||
"https://scripts.simpleanalyticscdn.com/latest.js",
|
||||
);
|
||||
scriptEle.setAttribute("type", "text/javascript");
|
||||
scriptEle.setAttribute("defer", true);
|
||||
scriptEle.setAttribute("async", true);
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.setAttribute("data-auto-collect", true);
|
||||
}
|
||||
|
||||
document.body.appendChild(scriptEle);
|
||||
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.addEventListener("load", () => {
|
||||
if (window.sa_pageview) {
|
||||
window.window.sa_event(action, {
|
||||
category: "iframe",
|
||||
label: "embed",
|
||||
value: window.location.pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
|
@ -1,874 +1,15 @@
|
|||
import polyfill from "../src/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../src/analytics";
|
||||
import { getDefaultAppState } from "../src/appState";
|
||||
import { ErrorDialog } from "../src/components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
|
||||
import { useMathSubtype } from "../src/element/subtypes/mathjax";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../src/constants";
|
||||
import { loadFromBlob } from "../src/data/blob";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "../src/element/types";
|
||||
import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
|
||||
import { t } from "../src/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} from "../src/packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
} from "../src/types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../src/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import Collab, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
collabDialogShownAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
} from "./collab/Collab";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
restore,
|
||||
restoreAppState,
|
||||
RestoredDataState,
|
||||
} from "../src/data/restore";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
} from "./components/ExportToExcalidrawPlus";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../src/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../src/element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
useHandleLibrary,
|
||||
} from "../src/data/library";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../src/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./App";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../src/utility-types";
|
||||
import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../src/components/Trans";
|
||||
|
||||
polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
let isSelfEmbedding = false;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
try {
|
||||
const parentUrl = new URL(document.referrer);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
if (parentUrl.origin === currentUrl.origin) {
|
||||
isSelfEmbedding = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
||||
color: "danger",
|
||||
} as const;
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
| { isExternalScene: false; id?: null; key?: null }
|
||||
)
|
||||
> => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const id = searchParams.get("id");
|
||||
const jsonBackendMatch = window.location.hash.match(
|
||||
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
||||
);
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
if (isExternalScene) {
|
||||
if (
|
||||
// don't prompt if scene is empty
|
||||
!scene.elements.length ||
|
||||
// don't prompt for collab scenes because we don't override local storage
|
||||
roomLinkData ||
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
localDataState,
|
||||
);
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||
if (document.hidden) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => initializeScene(opts).then(resolve).catch(reject),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
roomLinkData = null;
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
} else if (externalUrlMatch) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
|
||||
const url = externalUrlMatch[1];
|
||||
try {
|
||||
const request = await fetch(window.decodeURIComponent(url));
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
if (
|
||||
!scene.elements.length ||
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
return { scene: data, isExternalScene };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
scene: {
|
||||
appState: {
|
||||
errorMessage: t("alerts.invalidSceneUrl"),
|
||||
},
|
||||
},
|
||||
isExternalScene,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (roomLinkData && opts.collabAPI) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
||||
return {
|
||||
// when collaborating, the state may have already been updated at this
|
||||
// point (we may have received updates from other clients), so reconcile
|
||||
// elements and appState with existing state
|
||||
scene: {
|
||||
...scene,
|
||||
appState: {
|
||||
...restoreAppState(
|
||||
{
|
||||
...scene?.appState,
|
||||
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
||||
},
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
// necessary if we're invoking from a hashchange handler which doesn't
|
||||
// go through App.initializeScene() that resets this flag
|
||||
isLoading: false,
|
||||
},
|
||||
elements: reconcileElements(
|
||||
scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
},
|
||||
isExternalScene: true,
|
||||
id: roomLinkData.roomId,
|
||||
key: roomLinkData.roomKey,
|
||||
};
|
||||
} else if (scene) {
|
||||
return isExternalScene && jsonBackendMatch
|
||||
? {
|
||||
scene,
|
||||
isExternalScene,
|
||||
id: jsonBackendMatch[1],
|
||||
key: jsonBackendMatch[2],
|
||||
}
|
||||
: { scene, isExternalScene: false };
|
||||
}
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
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>,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialStatePromiseRef = useRef<{
|
||||
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
||||
}>({ promise: null! });
|
||||
if (!initialStatePromiseRef.current.promise) {
|
||||
initialStatePromiseRef.current.promise =
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
setTimeout(() => {
|
||||
trackEvent("load", "version", getVersion());
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
useMathSubtype(excalidrawAPI);
|
||||
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
.fetchImageFilesFromFirebase({
|
||||
elements: data.scene.elements,
|
||||
forceFetchFiles: true,
|
||||
})
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const fileIds =
|
||||
data.scene.elements?.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element)) {
|
||||
return acc.concat(element.fileId);
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
fileIds,
|
||||
).then(({ loadedFiles, erroredFiles }) => {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
});
|
||||
|
||||
const onHashChange = async (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI?.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
}
|
||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToHistory: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!document.hidden &&
|
||||
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
}
|
||||
setLangCode(langCode);
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
});
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
||||
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
const currFiles = excalidrawAPI.getFiles();
|
||||
const fileIds =
|
||||
elements?.reduce((acc, element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
// only load and update images that aren't already loaded
|
||||
!currFiles[element.fileId]
|
||||
) {
|
||||
return acc.concat(element.fileId);
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
updateStaleImageStatuses({
|
||||
excalidrawAPI,
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, SYNC_BROWSER_TABS_TIMEOUT);
|
||||
|
||||
const onUnload = () => {
|
||||
LocalData.flushSave();
|
||||
};
|
||||
|
||||
const visibilityChange = (event: FocusEvent | Event) => {
|
||||
if (event.type === EVENT.BLUR || document.hidden) {
|
||||
LocalData.flushSave();
|
||||
}
|
||||
if (
|
||||
event.type === EVENT.VISIBILITY_CHANGE ||
|
||||
event.type === EVENT.FOCUS
|
||||
) {
|
||||
syncData();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.addEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
||||
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
document.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
LocalData.flushSave();
|
||||
|
||||
if (
|
||||
excalidrawAPI &&
|
||||
LocalData.fileStorage.shouldPreventUnload(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
)
|
||||
) {
|
||||
preventUnload(event);
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
(localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
||||
) as Theme | null) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
||||
// currently only used for body styling during init (see public/index.html),
|
||||
// but may change in the future
|
||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
||||
}, [theme]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
setTheme(appState.theme);
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
LocalData.save(elements, appState, files, () => {
|
||||
if (excalidrawAPI) {
|
||||
let didChange = false;
|
||||
|
||||
const elements = excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (
|
||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||
) {
|
||||
const newElement = newElementWith(element, { status: "saved" });
|
||||
if (newElement !== element) {
|
||||
didChange = true;
|
||||
}
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onExportToBackend = async (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => {
|
||||
if (exportedElements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
viewBackgroundColor: appState.exportBackground
|
||||
? appState.viewBackgroundColor
|
||||
: getDefaultAppState().viewBackgroundColor,
|
||||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
console.error(error, { width, height });
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderCustomStats = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: UIAppState,
|
||||
) => {
|
||||
return (
|
||||
<CustomStats
|
||||
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const onLibraryChange = async (items: LibraryItems) => {
|
||||
if (!items.length) {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
return;
|
||||
}
|
||||
const serializedItems = JSON.stringify(items);
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
};
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
if (isSelfEmbedding) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<h1>I'm not a pretzel!</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className={clsx("excalidraw-app", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
langCode={langCode}
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => setCollabDialogShown(true)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<OverwriteConfirmDialog>
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
{excalidrawAPI && (
|
||||
<OverwriteConfirmDialog.Action
|
||||
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
||||
onClick={() => {
|
||||
exportToExcalidrawPlus(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
onCloseRequest={() => setLatestShareableLink(null)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</Excalidraw>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcalidrawApp;
|
||||
|
|
40
excalidraw-app/package.json
Normal file
40
excalidraw-app/package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"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": {},
|
||||
"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": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
@import "../../src/css/variables.module";
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog {
|
||||
.ShareDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
@ -10,8 +10,25 @@
|
|||
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 RoomDialog__popover__scaleIn {
|
||||
@keyframes ShareDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
@ -50,10 +67,10 @@
|
|||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
||||
animation: ShareDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__inactive {
|
||||
&__picker {
|
||||
font-family: "Assistant";
|
||||
|
||||
&__illustration {
|
||||
|
@ -95,7 +112,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__start_session {
|
||||
&__button {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
290
excalidraw-app/share/ShareDialog.tsx
Normal file
290
excalidraw-app/share/ShareDialog.tsx
Normal file
|
@ -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);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
collabAPI.setErrorMessage(error.message);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,13 @@
|
|||
import { defaultLang } from "../../src/i18n";
|
||||
import { UI } from "../../src/tests/helpers/ui";
|
||||
import { screen, fireEvent, waitFor, render } from "../../src/tests/test-utils";
|
||||
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 "../../excalidraw-app";
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import ExcalidrawApp from "../App";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../../src/tests/test-utils";
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
|
||||
import { UI } from "../../src/tests/helpers/ui";
|
||||
import { UI } from "../../packages/excalidraw/tests/helpers/ui";
|
||||
|
||||
describe("Test MobileMenu", () => {
|
||||
const { h } = window;
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import { vi } from "vitest";
|
||||
import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import { API } from "../../src/tests/helpers/api";
|
||||
import { createUndoAction } from "../../src/actions/actionHistory";
|
||||
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", {
|
||||
|
@ -16,17 +20,6 @@ Object.defineProperty(window, "crypto", {
|
|||
},
|
||||
});
|
||||
|
||||
vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => {
|
||||
const module = (await importActual()) as any;
|
||||
return {
|
||||
__esmodule: true,
|
||||
...module,
|
||||
getCollabServer: vi.fn(() => ({
|
||||
url: /* doesn't really matter */ "http://localhost:3002",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||
const loadFromFirebase = async () => null;
|
||||
const saveToFirebase = () => {};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
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 "../../src/random";
|
||||
import { AppState } from "../../src/types";
|
||||
import { cloneJSON } from "../../src/utils";
|
||||
import { randomInteger } from "../../packages/excalidraw/random";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
|
|
46
excalidraw-app/vite-env.d.ts
vendored
Normal file
46
excalidraw-app/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
interface ImportMetaEnv {
|
||||
// The port to run the dev server
|
||||
VITE_APP_PORT: string;
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL: string;
|
||||
VITE_APP_BACKEND_V2_POST_URL: string;
|
||||
|
||||
// collaboration WebSocket server (https: string
|
||||
VITE_APP_WS_SERVER_URL: string;
|
||||
|
||||
// set this only if using the collaboration workflow we use on excalidraw.com
|
||||
VITE_APP_PORTAL_URL: string;
|
||||
VITE_APP_AI_BACKEND: string;
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG: string;
|
||||
|
||||
// whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
// debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
|
||||
|
||||
VITE_APP_DISABLE_SENTRY: string;
|
||||
|
||||
// Set this flag to false if you want to open the overlay by default
|
||||
VITE_APP_COLLAPSE_OVERLAY: string;
|
||||
|
||||
// Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT: string;
|
||||
|
||||
VITE_APP_PLUS_LP: string;
|
||||
|
||||
VITE_APP_PLUS_APP: string;
|
||||
|
||||
VITE_APP_GIT_SHA: string;
|
||||
|
||||
MODE: string;
|
||||
|
||||
DEV: string;
|
||||
PROD: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
194
excalidraw-app/vite.config.mts
Normal file
194
excalidraw-app/vite.config.mts
Normal file
|
@ -0,0 +1,194 @@
|
|||
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,
|
||||
},
|
||||
// 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",
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue