feat: Merge upstream/master into b310-digital/excalidraw#master (#3)

This commit is contained in:
Sören Johanson
2024-08-23 15:54:59 +02:00
committed by GitHub
parent aaca099bc3
commit efdae58832
549 changed files with 79065 additions and 30661 deletions

View File

@ -1,6 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useEffect, useRef, useState } from "react";
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";
@ -13,48 +12,47 @@ import {
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
ExcalidrawElement,
import type {
FileId,
NonDeletedExcalidrawElement,
Theme,
OrderedExcalidrawElement,
} 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 {
StoreAction,
reconcileElements,
} from "../packages/excalidraw";
import type {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import type { ResolvablePromise } from "../packages/excalidraw/utils";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
CollabAPI,
collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
@ -65,16 +63,12 @@ import {
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
@ -82,11 +76,13 @@ import {
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 {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
@ -94,22 +90,74 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import { getStorageBackend } from "./data/config";
import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types";
import type { 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";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
DiscordIcon,
ExcalLogo,
usersIcon,
exportToPlus,
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
declare global {
interface BeforeInstallPromptEventChoiceResult {
outcome: "accepted" | "dismissed";
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
}
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
let pwaEvent: BeforeInstallPromptEvent | null = null;
// Adding a listener outside of the component as it may (?) need to be
// subscribed early to catch the event.
//
// Also note that it will fire only if certain heuristics are met (user has
// used the app for some time, etc.)
window.addEventListener(
"beforeinstallprompt",
(event: BeforeInstallPromptEvent) => {
// prevent Chrome <= 67 from automatically showing the prompt
event.preventDefault();
// cache for later use
pwaEvent = event;
},
);
let isSelfEmbedding = false;
if (window.self !== window.top) {
@ -124,11 +172,6 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@ -252,8 +295,8 @@ const initializeScene = async (opts: {
isLoading: false,
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
(scene?.elements as OrderedExcalidrawElement[]) || [],
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
excalidrawAPI.getAppState(),
),
},
@ -274,16 +317,15 @@ const initializeScene = async (opts: {
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();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@ -306,15 +348,18 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
adapter: LibraryIndexedDBAdapter,
// TODO maybe remove this in several months (shipped: 24-03-11)
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
useEffect(() => {
@ -356,21 +401,21 @@ const ExcalidrawWrapper = () => {
if (data.isExternalScene) {
getStorageBackend()
.then((storageBackend) => {
return storageBackend.loadFilesFromStorageBackend(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
);
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
.then((storageBackend) => {
return storageBackend.loadFilesFromStorageBackend(
`${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
@ -416,7 +461,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
});
@ -440,16 +485,17 @@ const ExcalidrawWrapper = () => {
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);
setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
excalidrawAPI.updateLibrary({
libraryItems: data.libraryItems,
});
}
});
collabAPI?.setUsername(username || "");
}
@ -540,29 +586,8 @@ const ExcalidrawWrapper = () => {
};
}, [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[],
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
@ -570,8 +595,6 @@ const ExcalidrawWrapper = () => {
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()) {
@ -597,6 +620,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
@ -612,37 +636,38 @@ const ExcalidrawWrapper = () => {
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,
);
try {
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
files,
);
if (errorMessage) {
throw new Error(errorMessage);
}
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);
}
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);
}
}
};
@ -660,17 +685,13 @@ const ExcalidrawWrapper = () => {
);
};
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
@ -690,6 +711,45 @@ const ExcalidrawWrapper = () => {
);
}
const ExcalidrawPlusCommand = {
label: "Excalidraw+",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: ["plus", "cloud", "server"],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
const ExcalidrawPlusAppCommand = {
label: "Sign up",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: [
"excalidraw",
"plus",
"cloud",
"server",
"signin",
"login",
"signup",
],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
return (
<div
style={{ height: "100%" }}
@ -708,27 +768,30 @@ const ExcalidrawWrapper = () => {
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 },
});
}}
/>
);
},
renderCustomUI: excalidrawAPI
? (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
name={excalidrawAPI.getName()}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI.updateScene({
appState: { openDialog: null },
});
}}
/>
);
}
: undefined,
},
},
}}
@ -736,28 +799,34 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
theme={editorTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => setCollabDialogShown(true)}
/>
<div className="top-right-ui">
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
</div>
);
}}
>
<AppMainMenu
setCollabDialogShown={setCollabDialogShown}
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
setCollabDialogShown={setCollabDialogShown}
onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
@ -772,6 +841,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}}
>
@ -853,11 +923,219 @@ const ExcalidrawWrapper = () => {
{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>
)}
<CommandPalette
customCommandPaletteItems={[
{
label: t("labels.liveCollaboration"),
category: DEFAULT_CATEGORIES.app,
keywords: [
"team",
"multiplayer",
"share",
"public",
"session",
"invite",
],
icon: usersIcon,
perform: () => {
setShareDialogState({
isOpen: true,
type: "collaborationOnly",
});
},
},
{
label: t("roomDialog.button_stopSession"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!collabAPI?.isCollaborating(),
keywords: [
"stop",
"session",
"end",
"leave",
"close",
"exit",
"collaboration",
],
perform: () => {
if (collabAPI) {
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
setShareDialogState({ isOpen: false });
}
}
},
},
{
label: t("labels.share"),
category: DEFAULT_CATEGORIES.app,
predicate: true,
icon: share,
keywords: [
"link",
"shareable",
"readonly",
"export",
"publish",
"snapshot",
"url",
"collaborate",
"invite",
],
perform: async () => {
setShareDialogState({ isOpen: true, type: "share" });
},
},
{
label: "GitHub",
icon: GithubIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: [
"issues",
"bugs",
"requests",
"report",
"features",
"social",
"community",
],
perform: () => {
window.open(
"https://github.com/excalidraw/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.followUs"),
icon: XBrandIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["twitter", "contact", "social", "community"],
perform: () => {
window.open(
"https://x.com/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.discordChat"),
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: DiscordIcon,
keywords: [
"chat",
"talk",
"contact",
"bugs",
"requests",
"report",
"feedback",
"suggestions",
"social",
"community",
],
perform: () => {
window.open(
"https://discord.gg/UexuTaE",
"_blank",
"noopener noreferrer",
);
},
},
{
label: "YouTube",
icon: youtubeIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["features", "tutorials", "howto", "help", "community"],
perform: () => {
window.open(
"https://youtube.com/@excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
...(isExcalidrawPlusSignedUser
? [
{
...ExcalidrawPlusAppCommand,
label: "Sign in / Go to Excalidraw+",
},
]
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
{
label: t("overwriteConfirm.action.excalidrawPlus.button"),
category: DEFAULT_CATEGORIES.export,
icon: exportToPlus,
predicate: true,
keywords: ["plus", "export", "save", "backup"],
perform: () => {
if (excalidrawAPI) {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!pwaEvent,
perform: () => {
if (pwaEvent) {
pwaEvent.prompt();
pwaEvent.userChoice.then(() => {
// event cannot be reused, but we'll hopefully
// grab new one as the event should be fired again
pwaEvent = null;
});
}
},
},
]}
/>
</Excalidraw>
</div>
);

View File

@ -7,8 +7,9 @@ import {
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";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number };
@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
}
return (
<>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
<Stats.StatsRows order={-1}>
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<Stats.StatsRow
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</Stats.StatsRow>
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.scene")}</div>
<div>{nFormatter(storageSizes.scene, 1)}</div>
</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.total")}</div>
<div>{nFormatter(storageSizes.total, 1)}</div>
</Stats.StatsRow>
</Stats.StatsRows>
);
};

View File

@ -1,8 +1,7 @@
import { useSetAtom } from "jotai";
import React from "react";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();

View File

@ -0,0 +1,25 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@ -0,0 +1,15 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
LOCAL_STORAGE_THEME: "excalidraw-theme",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
export const COOKIES = {

View File

@ -1,28 +1,30 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import {
import type {
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 {
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
reconcileElements,
} from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
preventUnload,
resolvablePromise,
throttleRAF,
withBatchedUpdates,
} from "../../packages/excalidraw/utils";
import {
CURSOR_SYNC_TIMEOUT,
@ -34,27 +36,21 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import {
generateCollaborationLinkData,
getCollaborationLink,
getCollabServer,
getSyncableElements,
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "../data/firebase";
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
} from "../data";
import { isSavedToFirebase } from "../data/firebase";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { t } from "../../packages/excalidraw/i18n";
import { UserIdleState } from "../../packages/excalidraw/types";
import {
@ -72,30 +68,35 @@ import {
isInitializedImageElement,
} from "../../packages/excalidraw/element/typeChecks";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { getStorageBackend } from "../data/config";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile";
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;
/** errors related to saving */
dialogNotifiedErrors: Record<string, boolean>;
username: string;
activeRoomLink: string;
activeRoomLink: string | null;
}
export const activeRoomLinkAtom = atom<string | null>(null);
type CollabInstance = InstanceType<typeof Collab>;
export interface CollabAPI {
@ -106,19 +107,20 @@ 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"];
setCollabError: CollabInstance["setErrorDialog"];
}
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;
@ -126,12 +128,13 @@ class Collab extends PureComponent<Props, CollabState> {
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<SocketId, Collaborator>();
constructor(props: Props) {
constructor(props: CollabProps) {
super(props);
this.state = {
errorMessage: "",
errorMessage: null,
dialogNotifiedErrors: {},
username: importUsernameFromLocalStorage() || "",
activeRoomLink: "",
activeRoomLink: null,
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
@ -202,6 +205,9 @@ class Collab extends PureComponent<Props, CollabState> {
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration,
setUsername: this.setUsername,
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setCollabError: this.setErrorDialog,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@ -275,20 +281,39 @@ class Collab extends PureComponent<Props, CollabState> {
) => {
try {
const storageBackend = await getStorageBackend();
const savedData = await storageBackend.saveToStorageBackend(this.portal, syncableElements, this.excalidrawAPI.getAppState());
const storedElements = await storageBackend.saveToStorageBackend(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
this.resetErrorIndicator();
if (this.isCollaborating() && storedElements) {
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
}
} catch (error: any) {
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
const errorMessage = /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed");
if (
!this.state.dialogNotifiedErrors[errorMessage] ||
!this.isCollaborating()
) {
this.setErrorDialog(errorMessage);
this.setState({
dialogNotifiedErrors: {
...this.state.dialogNotifiedErrors,
[errorMessage]: true,
},
});
}
if (this.isCollaborating()) {
this.setErrorIndicator(errorMessage);
}
console.error(error);
}
};
@ -297,6 +322,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.resetErrorIndicator(true);
this.saveCollabRoomToFirebase(
getSyncableElements(
@ -335,7 +361,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
storeAction: StoreAction.UPDATE,
});
}
};
@ -346,9 +372,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,
@ -410,11 +434,11 @@ class Collab extends PureComponent<Props, CollabState> {
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
) => {
if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername();
this.onUsernameChange(username);
this.setUsername(username);
});
}
@ -436,7 +460,12 @@ class Collab extends PureComponent<Props, CollabState> {
);
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
// TODO: `ImportedDataState` type here seems abused
const scenePromise = resolvablePromise<
| ImportedDataState
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
| null
>();
this.setIsCollaborating(true);
LocalData.pauseSave("collaboration");
@ -456,13 +485,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,
@ -471,7 +496,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
this.setErrorDialog(error.message);
return null;
}
@ -482,14 +507,13 @@ class Collab extends PureComponent<Props, CollabState> {
}
return element;
});
// remove deleted elements from elements array & history to ensure we don't
// remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
storeAction: StoreAction.UPDATE,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -523,10 +547,9 @@ class Collab extends PureComponent<Props, CollabState> {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@ -537,7 +560,7 @@ class Collab extends PureComponent<Props, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
this._reconcileElements(decryptedData.payload.elements),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@ -633,9 +656,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeIdleDetector();
this.setState({
activeRoomLink: window.location.href,
});
this.setActiveRoomLink(window.location.href);
return scenePromise;
};
@ -662,10 +683,10 @@ class Collab extends PureComponent<Props, CollabState> {
try {
const storageBackend = await getStorageBackend();
const elements = await storageBackend.loadFromStorageBackend(
roomLinkData.roomId,
roomLinkData.roomKey,
this.portal.socket,
);
roomLinkData.roomId,
roomLinkData.roomKey,
this.portal.socket,
);
if (elements) {
this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(elements),
@ -688,17 +709,15 @@ class Collab extends PureComponent<Props, CollabState> {
return null;
};
private reconcileElements = (
private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
): ReconciledElements => {
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements(
const restoredRemoteElements = restoreElements(remoteElements, null);
const reconciledElements = reconcileElements(
localElements,
remoteElements,
restoredRemoteElements as RemoteExcalidrawElement[],
appState,
);
@ -729,20 +748,13 @@ class Collab extends PureComponent<Props, CollabState> {
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
elements: ReconciledExcalidrawElement[],
) => {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
storeAction: StoreAction.UPDATE,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
@ -875,7 +887,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@ -886,7 +898,7 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
syncElements = (elements: readonly ExcalidrawElement[]) => {
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
@ -919,41 +931,49 @@ 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;
setErrorIndicator = (errorMessage: string | null) => {
appJotaiStore.set(collabErrorIndicatorAtom, {
message: errorMessage,
nonce: Date.now(),
});
};
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
if (resetDialogNotifiedErrors) {
this.setState({
dialogNotifiedErrors: {},
});
}
};
setErrorDialog = (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.setErrorDialog(null)}>
{errorMessage}
</ErrorDialog>
)}
@ -972,11 +992,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;

View File

@ -0,0 +1,35 @@
@import "../../packages/excalidraw/css/variables.module.scss";
.excalidraw {
.collab-errors-button {
width: 26px;
height: 26px;
margin-inline-end: 1rem;
color: var(--color-danger);
flex-shrink: 0;
}
.collab-errors-button-shake {
animation: strong-shake 0.15s 6;
}
@keyframes strong-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
}

View File

@ -0,0 +1,54 @@
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
/** used to rerun the useEffect responsible for animation */
nonce: number;
};
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
message: null,
nonce: 0,
});
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
const [isAnimating, setIsAnimating] = useState(false);
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
useEffect(() => {
setIsAnimating(true);
clearAnimationRef.current = setTimeout(() => {
setIsAnimating(false);
}, 1000);
return () => {
clearTimeout(clearAnimationRef.current);
};
}, [collabError.message, collabError.nonce]);
if (!collabError.message) {
return null;
}
return (
<Tooltip label={collabError.message} long={true}>
<div
className={clsx("collab-errors-button", {
"collab-errors-button-shake": isAnimating,
})}
>
{warning}
</div>
</Tooltip>
);
};
CollabError.displayName = "CollabError";
export default CollabError;

View File

@ -1,14 +1,15 @@
import {
isSyncableElement,
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import { TCollabClass } from "./Collab";
import type { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import {
import type {
OnUserFollowedPayload,
SocketId,
UserIdleState,
@ -16,10 +17,9 @@ import {
import { trackEvent } from "../../packages/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../packages/excalidraw/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";
class Portal {
collab: TCollabClass;
@ -128,12 +128,13 @@ class Portal {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
allElements: readonly ExcalidrawElement[],
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
@ -143,25 +144,17 @@ class Portal {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = allElements.reduce(
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version >
this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,

View File

@ -65,19 +65,18 @@ export const RoomModal = ({
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) {
setErrorMessage(error.message);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
@ -120,7 +119,7 @@ export const RoomModal = ({
size="large"
variant="icon"
label="Share"
startIcon={getShareIcon()}
icon={getShareIcon()}
className="RoomDialog__active__share"
onClick={shareRoomLink}
/>
@ -130,7 +129,7 @@ export const RoomModal = ({
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
@ -166,7 +165,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 +194,7 @@ export const RoomModal = ({
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
startIcon={playerPlayIcon}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate();

View File

@ -1,154 +0,0 @@
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
}
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// Unless the remote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement[PRECEDING_ELEMENT_KEY] ||
remoteElements[remoteElementIdx - 1]?.id ||
null;
if (parent != null) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
}
} else {
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};

View File

@ -1,12 +1,19 @@
import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import {
loginIcon,
ExcalLogo,
} from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
export const AppMainMenu: React.FC<{
setCollabDialogShown: (toggle: boolean) => any;
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => {
return (
<MainMenu>
@ -17,25 +24,38 @@ export const AppMainMenu: React.FC<{
{props.isCollabEnabled && (
<MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={props.isCollaborating}
onSelect={() => props.setCollabDialogShown(true)}
onSelect={() => props.onCollabDialogOpen()}
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={PlusPromoIcon}
icon={ExcalLogo}
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className="ExcalidrawPlus"
className=""
>
Excalidraw+
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.ItemLink
icon={loginIcon}
href={`${import.meta.env.VITE_APP_PLUS_APP}${
isExcalidrawPlusSignedUser ? "" : "/sign-up"
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>

View File

@ -1,12 +1,12 @@
import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import { loginIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
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 && (
@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
shortcut={null}
icon={PlusPromoIcon}
icon={loginIcon}
>
Try Excalidraw Plus!
Sign up
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>

View File

@ -3,11 +3,11 @@ 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 {
import type {
FileId,
NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
name: string,
) => {
const firebase = await loadFirebaseStorage();
@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name: appState.name }),
data: JSON.stringify({ version: 2, name }),
created: Date.now().toString(),
},
});
@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: Partial<AppState>;
files: BinaryFiles;
name: string;
onError: (error: Error) => void;
onSuccess: () => void;
}> = ({ elements, appState, files, onError, onSuccess }) => {
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
const { t } = useI18n();
return (
<Card color="primary">
@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
onClick={async () => {
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files);
await exportToExcalidrawPlus(elements, appState, files, name);
onSuccess();
} catch (error: any) {
console.error(error);

View File

@ -1,7 +1,7 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../packages/excalidraw/constants";
import { Theme } from "../../packages/excalidraw/element/types";
import type { Theme } from "../../packages/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
window.open(
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
"_blank",
"noopener noreferrer",
);
}

View File

@ -1,14 +1,15 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};

View File

@ -10,18 +10,29 @@
* (localStorage, indexedDB).
*/
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
@ -183,3 +194,52 @@ export class LocalData {
},
});
}
export class LibraryIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
/** library data store key */
private static key = "libraryData";
private static store = createStore(
`${LibraryIndexedDBAdapter.idb_name}-db`,
`${LibraryIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<LibraryPersistedData>(
LibraryIndexedDBAdapter.key,
LibraryIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: LibraryPersistedData): MaybePromise<void> {
return set(
LibraryIndexedDBAdapter.key,
data,
LibraryIndexedDBAdapter.store,
);
}
}
/** LS Adapter used only for migrating LS library data
* to indexedDB */
export class LibraryLocalStorageMigrationAdapter {
static load() {
const LSData = localStorage.getItem(
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
);
if (LSData != null) {
const libraryItems: ImportedDataState["libraryItems"] =
JSON.parse(LSData);
if (libraryItems) {
return { libraryItems };
}
}
return null;
}
static clear() {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}

View File

@ -1,7 +1,10 @@
import { SyncableExcalidrawElement } from ".";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types";
import { AppState, BinaryFileData } from "../../packages/excalidraw/types";
import Portal from "../collab/Portal";
import type { SyncableExcalidrawElement } from ".";
import type {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../../packages/excalidraw/types";
import type Portal from "../collab/Portal";
import type { Socket } from "socket.io-client";
export interface StorageBackend {
@ -10,7 +13,7 @@ export interface StorageBackend {
portal: Portal,
elements: readonly SyncableExcalidrawElement[],
appState: AppState,
) => Promise<false | { reconciledElements: any }>;
) => Promise<false | readonly SyncableExcalidrawElement[] | null>;
loadFromStorageBackend: (
roomId: string,
roomKey: string,

View File

@ -1,54 +1,54 @@
import {
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "./firebase";
import {
isSavedToHttpStorage,
loadFilesFromHttpStorage,
loadFromHttpStorage,
saveFilesToHttpStorage,
saveToHttpStorage,
} from "./httpStorage";
import { StorageBackend } from "./StorageBackend";
const firebaseStorage: StorageBackend = {
isSaved: isSavedToFirebase,
saveToStorageBackend: saveToFirebase,
loadFromStorageBackend: loadFromFirebase,
saveFilesToStorageBackend: saveFilesToFirebase,
loadFilesFromStorageBackend: loadFilesFromFirebase,
};
const httpStorage: StorageBackend = {
isSaved: isSavedToHttpStorage,
saveToStorageBackend: saveToHttpStorage,
loadFromStorageBackend: loadFromHttpStorage,
saveFilesToStorageBackend: saveFilesToHttpStorage,
loadFilesFromStorageBackend: loadFilesFromHttpStorage,
};
const storageBackends = new Map<string, StorageBackend>()
.set("firebase", firebaseStorage)
.set("http", httpStorage);
export let storageBackend: StorageBackend | null = null;
export async function getStorageBackend() {
if (storageBackend) {
return storageBackend;
}
const storageBackendName = import.meta.env.VITE_APP_STORAGE_BACKEND || '';
if (storageBackends.has(storageBackendName)) {
storageBackend = storageBackends.get(storageBackendName) as StorageBackend;
} else {
console.warn("No storage backend found, default to firebase");
storageBackend = firebaseStorage;
}
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "./firebase";
import {
isSavedToHttpStorage,
loadFilesFromHttpStorage,
loadFromHttpStorage,
saveFilesToHttpStorage,
saveToHttpStorage,
} from "./httpStorage";
import type { StorageBackend } from "./StorageBackend";
const firebaseStorage: StorageBackend = {
isSaved: isSavedToFirebase,
saveToStorageBackend: saveToFirebase,
loadFromStorageBackend: loadFromFirebase,
saveFilesToStorageBackend: saveFilesToFirebase,
loadFilesFromStorageBackend: loadFilesFromFirebase,
};
const httpStorage: StorageBackend = {
isSaved: isSavedToHttpStorage,
saveToStorageBackend: saveToHttpStorage,
loadFromStorageBackend: loadFromHttpStorage,
saveFilesToStorageBackend: saveFilesToHttpStorage,
loadFilesFromStorageBackend: loadFilesFromHttpStorage,
};
const storageBackends = new Map<string, StorageBackend>()
.set("firebase", firebaseStorage)
.set("http", httpStorage);
export let storageBackend: StorageBackend | null = null;
export async function getStorageBackend() {
if (storageBackend) {
return storageBackend;
}
}
const storageBackendName = import.meta.env.VITE_APP_STORAGE_BACKEND || "";
if (storageBackends.has(storageBackendName)) {
storageBackend = storageBackends.get(storageBackendName) as StorageBackend;
} else {
console.warn("No storage backend found, default to firebase");
storageBackend = firebaseStorage;
}
return storageBackend;
}

View File

@ -1,11 +1,13 @@
import {
import { reconcileElements } from "../../packages/excalidraw";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal";
import type Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import {
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
@ -18,10 +20,11 @@ import {
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------
@ -232,7 +235,7 @@ export const saveToFirebase = async (
!socket ||
isSavedToFirebase(portal, elements)
) {
return false;
return null;
}
const firebase = await loadFirestore();
@ -240,56 +243,59 @@ export const saveToFirebase = async (
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const storedScene = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
const storedScene = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
transaction.set(docRef, storedScene);
return {
elements,
reconciledElements: null,
};
return storedScene;
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements(
await decryptElements(prevDocData, roomKey),
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
const prevStoredElements = getSyncableElements(
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
);
const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState),
reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
);
const sceneDocument = await createFirebaseSceneDocument(
const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
elements,
reconciledElements,
};
transaction.update(docRef, storedScene);
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
return storedScene;
});
FirebaseSceneVersionCache.set(socket, savedData.elements);
const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);
return { reconciledElements: savedData.reconciledElements };
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
};
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
@ -300,14 +306,14 @@ export const loadFromFirebase = async (
}
const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements(
await decryptElements(storedScene, roomKey),
restoreElements(await decryptElements(storedScene, roomKey), null),
);
if (socket) {
FirebaseSceneVersionCache.set(socket, elements);
}
return restoreElements(elements, null);
return elements;
};
export const loadFilesFromFirebase = async (

View File

@ -1,34 +1,42 @@
// Inspired and partly copied from https://gitlab.com/kiliandeca/excalidraw-fork
// MIT, Kilian Decaderincourt
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { decompressData } from "../../packages/excalidraw/data/encode";
import { encryptData, decryptData, IV_LENGTH_BYTES } from "../../packages/excalidraw/data/encryption";
import {
encryptData,
decryptData,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import { getSceneVersion } from "../../packages/excalidraw/element";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types";
import {
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../packages/excalidraw/types";
import Portal from "../collab/Portal";
import { reconcileElements } from "../collab/reconciliation";
import { StoredScene } from "./StorageBackend";
import type Portal from "../collab/Portal";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
import { reconcileElements } from "../../packages/excalidraw/data/reconcile";
import type { StoredScene } from "./StorageBackend";
import type { Socket } from "socket.io-client";
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const SCENE_VERSION_LENGTH_BYTES = 4
const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const SCENE_VERSION_LENGTH_BYTES = 4;
// There is a lot of intentional duplication with the firebase file
// to prevent modifying upstream files and ease futur maintenance of this fork
const httpStorageSceneVersionCache = new WeakMap<
Socket,
number
>();
const httpStorageSceneVersionCache = new WeakMap<Socket, number>();
export const isSavedToHttpStorage = (
portal: Portal,
@ -68,17 +76,20 @@ export const saveToHttpStorage = async (
if (!getResponse.ok && getResponse.status !== 404) {
return false;
};
}
if(getResponse.status === 404) {
const result: boolean = await saveElementsToBackend(roomKey, roomId, [...elements], sceneVersion)
if(result) {
return {
reconciledElements: null
}
if (getResponse.status === 404) {
const result: boolean = await saveElementsToBackend(
roomKey,
roomId,
[...elements],
sceneVersion,
);
if (result) {
return null;
}
return false
};
return false;
}
// If room already exist, we compare scene versions to check
// if we're up to date before saving our scene
@ -90,16 +101,23 @@ export const saveToHttpStorage = async (
const existingElements = await getElementsFromBuffer(buffer, roomKey);
const reconciledElements = getSyncableElements(
reconcileElements(elements, existingElements, appState),
reconcileElements(
elements,
existingElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
);
const result: boolean = await saveElementsToBackend(roomKey, roomId, reconciledElements, sceneVersion)
const result: boolean = await saveElementsToBackend(
roomKey,
roomId,
reconciledElements,
sceneVersion,
);
if (result) {
httpStorageSceneVersionCache.set(socket, sceneVersion);
return {
reconciledElements: elements
};
return elements;
}
return false;
};
@ -109,7 +127,8 @@ export const loadFromHttpStorage = async (
roomKey: string,
socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const getResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
);
@ -130,12 +149,20 @@ const getElementsFromBuffer = async (
): Promise<readonly ExcalidrawElement[]> => {
// Buffer should contain both the IV (fixed length) and encrypted data
const sceneVersion = parseSceneVersionFromRequest(buffer);
const iv = new Uint8Array(buffer.slice(SCENE_VERSION_LENGTH_BYTES, IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES));
const encrypted = buffer.slice(IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES, buffer.byteLength);
const iv = new Uint8Array(
buffer.slice(
SCENE_VERSION_LENGTH_BYTES,
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
),
);
const encrypted = buffer.slice(
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
buffer.byteLength,
);
return await decryptElements(
{ sceneVersion: sceneVersion, ciphertext: encrypted, iv },
key
{ sceneVersion, ciphertext: encrypted, iv },
key,
);
};
@ -149,7 +176,8 @@ export const saveFilesToHttpStorage = async ({
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
await Promise.all(
files.map(async ({ id, buffer }) => {
@ -182,7 +210,8 @@ export const loadFilesFromHttpStorage = async (
await Promise.all(
[...new Set(filesIds)].map(async (id) => {
try {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const HTTP_STORAGE_BACKEND_URL = import.meta.env
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const response = await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`);
if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer();
@ -216,7 +245,12 @@ export const loadFilesFromHttpStorage = async (
return { loadedFiles, erroredFiles };
};
const saveElementsToBackend = async (roomKey: string, roomId: string, elements: SyncableExcalidrawElement[], sceneVersion: number) => {
const saveElementsToBackend = async (
roomKey: string,
roomId: string,
elements: SyncableExcalidrawElement[],
sceneVersion: number,
) => {
const { ciphertext, iv } = await encryptElements(roomKey, elements);
// Concatenate Scene Version, IV with encrypted data (IV does not have to be secret).
@ -224,7 +258,9 @@ const saveElementsToBackend = async (roomKey: string, roomId: string, elements:
const numberView = new DataView(numberBuffer);
numberView.setUint32(0, sceneVersion, false);
const sceneVersionBuffer = numberView.buffer;
const payloadBlob = await new Response(new Blob([sceneVersionBuffer, iv.buffer, ciphertext])).arrayBuffer();
const payloadBlob = await new Response(
new Blob([sceneVersionBuffer, iv.buffer, ciphertext]),
).arrayBuffer();
const putResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
{
@ -233,13 +269,13 @@ const saveElementsToBackend = async (roomKey: string, roomId: string, elements:
},
);
return putResponse.ok
}
return putResponse.ok;
};
const parseSceneVersionFromRequest = (buffer: ArrayBuffer) => {
const view = new DataView(buffer);
return view.getUint32(0, false);
}
};
const decryptElements = async (
data: StoredScene,
@ -264,4 +300,4 @@ const encryptElements = async (
const { encryptedBuffer, iv } = await encryptData(key, encoded);
return { ciphertext: encryptedBuffer, iv };
};
};

View File

@ -4,44 +4,44 @@ import {
} from "../../packages/excalidraw/data/encode";
import {
decryptData,
encryptData,
generateEncryptionKey,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { getStorageBackend } from "./config";
export type SyncableExcalidrawElement = ExcalidrawElement & {
_brand: "SyncableExcalidrawElement";
};
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
export const isSyncableElement = (
element: ExcalidrawElement,
element: OrderedExcalidrawElement,
): element is SyncableExcalidrawElement => {
if (element.isDeleted) {
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
@ -52,7 +52,9 @@ export const isSyncableElement = (
return !isInvisiblySmallElement(element);
};
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
export const getSyncableElements = (
elements: readonly OrderedExcalidrawElement[],
) =>
elements.filter((element) =>
isSyncableElement(element),
) as SyncableExcalidrawElement[];
@ -66,35 +68,6 @@ 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;
@ -296,7 +269,6 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
};
};

View File

@ -1,12 +1,11 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { AppState } from "../../packages/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
export const saveUsernameToLocalStorage = (username: string) => {
try {
@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
try {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
const librarySize = library?.length || 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize();
return appStateSize + collabSize + getElementsStorageSize();
} catch (error: any) {
console.error(error);
return 0;
}
};
export const getLibraryItemsFromStorage = () => {
try {
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
);
return libraryItems || [];
} catch (error) {
console.error(error);
return [];
}
};

View File

@ -20,7 +20,7 @@
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" />
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" />
@ -35,7 +35,7 @@
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" />
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
@ -51,7 +51,7 @@
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-twitter-v2.png"
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
@ -64,12 +64,30 @@
<!-- 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");
function setTheme(theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
} catch {}
function getTheme() {
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme && theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} else {
return theme || "light";
}
}
setTheme(getTheme());
} catch (e) {
console.error("Error setting dark mode", e);
}
</script>
<style>
html.dark {
@ -77,8 +95,13 @@
color: #fff;
}
</style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!------------------------------------------------------------------------->
<% if ("%PROD%" === "true") { %>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
@ -97,8 +120,55 @@
window.location.href = "https://app.excalidraw.com";
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<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" />
@ -106,23 +176,8 @@
<!-- 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.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
{
const _WebSocket = window.WebSocket;
@ -139,7 +194,6 @@
</script>
<% } %>
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
@ -196,7 +250,6 @@
</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
@ -229,6 +282,5 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>

View File

@ -4,6 +4,13 @@
&.theme--dark {
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center {
justify-content: flex-end;
margin-top: auto;
@ -18,6 +25,7 @@
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg {
width: 1.2rem;
@ -31,8 +39,12 @@
background-color: #ecfdf5;
color: #064e3c;
}
&.ExcalidrawPlus {
&.highlighted {
color: var(--color-promo);
font-weight: 700;
.dropdown-menu-item__icon g {
stroke-width: 2;
}
}
}
}

View File

@ -1,4 +1,8 @@
{
"name": "excalidraw-app",
"version": "1.0.0",
"private": true,
"homepage": ".",
"browserslist": {
"production": [
">0.2%",
@ -22,21 +26,28 @@
"node": ">=18.0.0"
},
"dependencies": {
"packages/excalidraw": "*"
"firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
},
"homepage": ".",
"name": "excalidraw-app",
"prettier": "@excalidraw/prettier-config",
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_ESLINT=false 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",
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../",
"start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
}
}

View File

@ -1,7 +1,7 @@
@import "../../packages/excalidraw/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;

View File

@ -0,0 +1,300 @@
import { useEffect, 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 type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
export const shareDialogStateAtom = atom<
{ isOpen: false } | { isOpen: true; type: ShareDialogType }
>({ isOpen: false });
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
export type ShareDialogProps = {
collabAPI: CollabAPI | null;
handleClose: () => void;
onExportToBackend: OnExportToBackend;
type: ShareDialogType;
};
const ActiveRoomDialog = ({
collabAPI,
activeRoomLink,
handleClose,
}: {
collabAPI: CollabAPI;
activeRoomLink: string;
handleClose: () => void;
}) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
const shareRoomLink = async () => {
try {
await navigator.share({
title: t("roomDialog.shareTitle"),
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
// Just ignore.
}
};
return (
<>
<h3 className="ShareDialog__active__header">
{t("labels.liveCollaboration").replace(/\./g, "")}
</h3>
<TextField
defaultValue={collabAPI.getUsername()}
placeholder="Your name"
label="Your name"
onChange={collabAPI.setUsername}
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
/>
<div className="ShareDialog__active__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={activeRoomLink}
/>
{isShareSupported && (
<FilledButton
size="large"
variant="icon"
label="Share"
icon={getShareIcon()}
className="ShareDialog__active__share"
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareDialog__active__description">
<p>
<span
role="img"
aria-hidden="true"
className="ShareDialog__active__description__emoji"
>
🔒{" "}
</span>
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
</div>
<div className="ShareDialog__active__actions">
<FilledButton
size="large"
variant="outlined"
color="danger"
label={t("roomDialog.button_stopSession")}
icon={playerStopFilledIcon}
onClick={() => {
trackEvent("share", "room closed");
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
handleClose();
}
}}
/>
</div>
</>
);
};
const ShareDialogPicker = (props: ShareDialogProps) => {
const { t } = useI18n();
const { collabAPI } = props;
const startCollabJSX = collabAPI ? (
<>
<div className="ShareDialog__picker__header">
{t("labels.liveCollaboration").replace(/\./g, "")}
</div>
<div className="ShareDialog__picker__description">
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
{t("roomDialog.desc_privacy")}
</div>
<div className="ShareDialog__picker__button">
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
collabAPI.startCollaboration(null);
}}
/>
</div>
{props.type === "share" && (
<div className="ShareDialog__separator">
<span>{t("shareDialog.or")}</span>
</div>
)}
</>
) : null;
return (
<>
{startCollabJSX}
{props.type === "share" && (
<>
<div className="ShareDialog__picker__header">
{t("exportDialog.link_title")}
</div>
<div className="ShareDialog__picker__description">
{t("exportDialog.link_details")}
</div>
<div className="ShareDialog__picker__button">
<FilledButton
size="large"
label={t("exportDialog.link_button")}
icon={LinkIcon}
onClick={async () => {
await props.onExportToBackend();
props.handleClose();
}}
/>
</div>
</>
)}
</>
);
};
const ShareDialogInner = (props: ShareDialogProps) => {
const activeRoomLink = useAtomValue(activeRoomLinkAtom);
return (
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
<div className="ShareDialog">
{props.collabAPI && activeRoomLink ? (
<ActiveRoomDialog
collabAPI={props.collabAPI}
activeRoomLink={activeRoomLink}
handleClose={props.handleClose}
/>
) : (
<ShareDialogPicker {...props} />
)}
</div>
</Dialog>
);
};
export const ShareDialog = (props: {
collabAPI: CollabAPI | null;
onExportToBackend: OnExportToBackend;
}) => {
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
const { openDialog } = useUIAppState();
useEffect(() => {
if (openDialog) {
setShareDialogState({ isOpen: false });
}
}, [openDialog, setShareDialogState]);
if (!shareDialogState.isOpen) {
return null;
}
return (
<ShareDialogInner
handleClose={() => setShareDialogState({ isOpen: false })}
collabAPI={props.collabAPI}
onExportToBackend={props.onExportToBackend}
type={shareDialogState.type}
/>
);
};

View File

@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center"
>
<div
class="welcome-screen-center__logo virgil welcome-screen-decor"
class="welcome-screen-center__logo excalifont welcome-screen-decor"
>
<div
class="ExcalidrawLogo is-small"
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div>
</div>
<div
class="welcome-screen-center__heading welcome-screen-decor virgil"
class="welcome-screen-center__heading welcome-screen-decor excalifont"
>
All your data is saved locally in your browser.
</div>
@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
fill="none"
stroke="none"
/>
<rect
height="4"
rx="1"
width="18"
x="3"
y="8"
/>
<line
x1="12"
x2="12"
y1="8"
y2="21"
<path
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
/>
<path
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
d="M21 12h-13l3 -3"
/>
<path
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
d="M11 15l-3 -3"
/>
</g>
</svg>
@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-menu-item__text"
>
Try Excalidraw Plus!
Sign up
</div>
</a>
</div>

View File

@ -1,12 +1,18 @@
import { vi } from "vitest";
import {
act,
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";
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { StoreAction, newElementWith } from "../../packages/excalidraw";
const { h } = window;
Object.defineProperty(window, "crypto", {
@ -20,17 +26,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 = () => {};
@ -67,39 +62,190 @@ vi.mock("socket.io-client", () => {
};
});
/**
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
* while having access to both scenes, appstates stores, histories and etc.
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("creating room should reset deleted elements", async () => {
it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
updateSceneData({
elements: [
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({
type: "rectangle",
id: "B",
isDeleted: true,
}),
],
});
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
window.collab.startCollaboration(null);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
const rect1Props = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
} as const;
const rect2Props = {
type: "rectangle",
id: "B",
width: 100,
height: 200,
} as const;
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
API.updateScene({
elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE,
});
API.updateScene({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
storeAction: StoreAction.CAPTURE,
});
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(2);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
// we never delete from the local snapshot as it is used for correct diff calculation
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@ -1,421 +0,0 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import {
BroadcastedExcalidrawElement,
ReconciledElements,
reconcileElements,
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../packages/excalidraw/random";
import { AppState } from "../../packages/excalidraw/types";
import { cloneJSON } from "../../packages/excalidraw/utils";
type Id = string;
type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
const createElement = (opts: { uid: string } | ElementLike) => {
let uid: string;
let id: string;
let version: number | null;
let parent: string | null = null;
let versionNonce: number | null = null;
if ("uid" in opts) {
const match = opts.uid.match(
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
)!;
parent = match[1];
id = match[2];
version = match[3] ? parseInt(match[3]) : null;
uid = version ? `${id}:${version}` : id;
} else {
({ id, version, versionNonce } = opts);
parent = parent || null;
uid = id;
}
return {
uid,
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
const idsToElements = (
ids: (Id | ElementLike)[],
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid];
const elem = {
id,
version: version ?? 0,
versionNonce,
...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem;
acc.push(elem);
return acc;
}, [] as ExcalidrawElement[]);
};
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
const cleanElements = (elements: ReconciledElements) => {
return elements.map((el) => {
// @ts-ignore
delete el[PRECEDING_ELEMENT_KEY];
// @ts-ignore
delete el.next;
// @ts-ignore
delete el.prev;
return el;
});
};
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
target: U[],
bidirectional = true,
) => {
const cache: Cache = {};
const _local = idsToElements(local, cache);
const _remote = idsToElements(remote, cache);
const _target = target.map((uid) => {
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
}) as any as ReconciledElements;
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
expect(target.length).equal(remoteReconciled.length);
expect(cleanElements(remoteReconciled)).deep.equal(
cleanElements(_target),
"remote reconciliation",
);
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
} catch (error: any) {
console.error("local original", __local);
console.error("remote reconciled", __remote);
throw error;
}
}
};
export const findIndex = <T>(
array: readonly T[],
cb: (element: T, index: number, array: readonly T[]) => boolean,
fromIndex: number = 0,
) => {
if (fromIndex < 0) {
fromIndex = array.length + fromIndex;
}
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
let index = fromIndex - 1;
while (++index < array.length) {
if (cb(array[index], index, array)) {
return index;
}
}
return -1;
};
// -----------------------------------------------------------------------------
describe("elements reconciliation", () => {
it("reconcileElements()", () => {
// -------------------------------------------------------------------------
//
// in following tests, we pass:
// (1) an array of local elements and their version (:1, :2...)
// (2) an array of remote elements and their version (:1, :2...)
// (3) expected reconciled elements
//
// in the reconciled array:
// :L means local element was resolved
// :R means remote element was resolved
//
// if a remote element is prefixed with parentheses, the enclosed string:
// (^) means the element is the first element in the array
// (<id>) means the element is preceded by <id> element
//
// if versions are missing, it defaults to version 0
// -------------------------------------------------------------------------
// non-annotated elements
// -------------------------------------------------------------------------
// usually when we sync elements they should always be annotated with
// their (preceding elements) parents, but let's test a couple of cases when
// they're not for whatever reason (remote clients are on older version...),
// in which case the first synced element either replaces existing element
// or is pushed at the end of the array
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
test(["A"], ["A", "B"], ["A:L", "B:R"]);
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
test(["A"], ["A:1"], ["A:R"]);
// C isn't added to the end because it follows B (even if B was resolved
// to local version)
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
// some of the following tests are kinda arbitrary and they're less
// likely to happen in real-world cases
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(
["A:2", "B:2", "C"],
["D", "B:1", "A:3"],
["B:L", "A:R", "C:L", "D:R"],
);
test(
["A:2", "B:2", "C"],
["D", "B:2", "A:3", "C"],
["D:R", "B:L", "A:R", "C:L"],
);
test(
["A", "B", "C", "D", "E", "F"],
["A", "B:2", "X", "E:2", "F", "Y"],
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
);
// annotated elements
// -------------------------------------------------------------------------
test(
["A", "B", "C"],
["(B)X", "(A)Y", "(Y)Z"],
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
);
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
test(
["A", "B"],
["(A)C", "(^)D", "F"],
["A:L", "C:R", "D:R", "F:R", "B:L"],
);
test(
["A", "B", "C", "D"],
["(B)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
);
test(
["B", "A", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
);
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
test(
["A", "B", "C", "D", "E"],
["(A)X", "(C)Y", "(D)Z"],
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
);
test(
["X", "Y", "Z"],
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
);
test(
["A", "B", "C", "D", "E"],
["(C)X", "(A)Y", "(D)E:1"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
);
test(
["C:1", "B", "D:1"],
["A", "B", "C:1", "D:1"],
["A:R", "B:L", "C:L", "D:L"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["C:1", "B", "D:1"],
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
["A:R", "B:L", "C:R", "D:L"],
);
test(
["A", "B", "C", "D"],
["(C)X", "(B)Y", "(A)Z"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
);
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
test(
["A", "B", "C", "D"],
["(A)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
});
it("test identical elements reconciliation", () => {
const testIdentical = (
local: ElementLike[],
remote: ElementLike[],
expected: Id[],
) => {
const ret = reconcileElements(
local as any as ExcalidrawElement[],
remote as any as ExcalidrawElement[],
{} as AppState,
);
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
throw new Error("reconcileElements: duplicate elements found");
}
expect(ret.map((x) => x.id)).to.deep.equal(expected);
};
// identical id/version/versionNonce
// -------------------------------------------------------------------------
testIdentical(
[{ id: "A", version: 1, versionNonce: 1 }],
[{ id: "A", version: 1, versionNonce: 1 }],
["A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
// actually identical (arrays and element objects)
// -------------------------------------------------------------------------
const elements1 = [
{
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
testIdentical(elements1, elements1, ["A", "B"]);
testIdentical(elements1, elements1.slice(), ["A", "B"]);
testIdentical(elements1.slice(), elements1, ["A", "B"]);
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
const el1 = {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
});

View File

@ -0,0 +1,70 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
const mediaQuery = getDarkThemeMediaQuery();
const handleChange = (e: MediaQueryListEvent) => {
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
};
if (appTheme === "system") {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
if (appTheme === "system") {
setEditorTheme(
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
);
} else {
setEditorTheme(appTheme);
}
}, [appTheme]);
return { editorTheme };
};

View File

@ -4,6 +4,8 @@ import svgrPlugin from "vite-plugin-svgr";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@ -20,6 +22,14 @@ export default defineConfig({
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// 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
@ -39,6 +49,7 @@ export default defineConfig({
sourcemap: true,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
@ -188,6 +199,9 @@ export default defineConfig({
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
});