feat: rewrite library state management & related refactor (#5067)
* support libraryItems promise for `updateScene()` and use `importLibrary` * fix typing for `getLibraryItemsFromStorage()` * remove `libraryItemsFromStorage` hack if there was a point to it then I'm missing it, but this part will be rewritten anyway * rewrite state handling (temporarily removed loading states) * add async support * refactor and deduplicate library importing logic * hide hints when library open * fix snaps * support promise in `initialData.libraryItems` * add default to params instead
This commit is contained in:
parent
55ccd5b79b
commit
cd942c3e3b
@ -36,6 +36,7 @@
|
|||||||
"i18next-browser-languagedetector": "6.1.2",
|
"i18next-browser-languagedetector": "6.1.2",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
|
"jotai": "1.6.4",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.32",
|
"nanoid": "3.1.32",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
|
@ -76,9 +76,8 @@ import {
|
|||||||
ZOOM_STEP,
|
ZOOM_STEP,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { loadFromBlob } from "../data";
|
import { loadFromBlob } from "../data";
|
||||||
import { isValidLibrary } from "../data/json";
|
|
||||||
import Library from "../data/library";
|
import Library from "../data/library";
|
||||||
import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
|
import { restore, restoreElements } from "../data/restore";
|
||||||
import {
|
import {
|
||||||
dragNewElement,
|
dragNewElement,
|
||||||
dragSelectedElements,
|
dragSelectedElements,
|
||||||
@ -231,6 +230,7 @@ import {
|
|||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
isSupportedImageFile,
|
isSupportedImageFile,
|
||||||
|
loadLibraryFromBlob,
|
||||||
resizeImageFile,
|
resizeImageFile,
|
||||||
SVGStringToFile,
|
SVGStringToFile,
|
||||||
} from "../data/blob";
|
} from "../data/blob";
|
||||||
@ -706,28 +706,21 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
try {
|
try {
|
||||||
const request = await fetch(decodeURIComponent(url));
|
const request = await fetch(decodeURIComponent(url));
|
||||||
const blob = await request.blob();
|
const blob = await request.blob();
|
||||||
const json = JSON.parse(await blob.text());
|
const defaultStatus = "published";
|
||||||
if (!isValidLibrary(json)) {
|
const libraryItems = await loadLibraryFromBlob(blob, defaultStatus);
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
token === this.id ||
|
token === this.id ||
|
||||||
window.confirm(
|
window.confirm(
|
||||||
t("alerts.confirmAddLibrary", {
|
t("alerts.confirmAddLibrary", {
|
||||||
numShapes: (json.libraryItems || json.library || []).length,
|
numShapes: libraryItems.length,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
await this.library.importLibrary(blob, "published");
|
await this.library.importLibrary(libraryItems, defaultStatus);
|
||||||
// hack to rerender the library items after import
|
|
||||||
if (this.state.isLibraryOpen) {
|
|
||||||
this.setState({ isLibraryOpen: false });
|
|
||||||
}
|
|
||||||
this.setState({ isLibraryOpen: true });
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
window.alert(t("alerts.errorLoadingLibrary"));
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
this.setState({ errorMessage: t("errors.importLibraryError") });
|
||||||
} finally {
|
} finally {
|
||||||
this.focusContainer();
|
this.focusContainer();
|
||||||
}
|
}
|
||||||
@ -792,10 +785,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
try {
|
try {
|
||||||
initialData = (await this.props.initialData) || null;
|
initialData = (await this.props.initialData) || null;
|
||||||
if (initialData?.libraryItems) {
|
if (initialData?.libraryItems) {
|
||||||
this.libraryItemsFromStorage = restoreLibraryItems(
|
this.library.importLibrary(initialData.libraryItems, "unpublished");
|
||||||
initialData.libraryItems,
|
|
||||||
"unpublished",
|
|
||||||
) as LibraryItems;
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -1681,7 +1671,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
appState?: Pick<AppState, K> | null;
|
appState?: Pick<AppState, K> | null;
|
||||||
collaborators?: SceneData["collaborators"];
|
collaborators?: SceneData["collaborators"];
|
||||||
commitToHistory?: SceneData["commitToHistory"];
|
commitToHistory?: SceneData["commitToHistory"];
|
||||||
libraryItems?: SceneData["libraryItems"];
|
libraryItems?:
|
||||||
|
| Required<SceneData>["libraryItems"]
|
||||||
|
| Promise<Required<SceneData>["libraryItems"]>;
|
||||||
}) => {
|
}) => {
|
||||||
if (sceneData.commitToHistory) {
|
if (sceneData.commitToHistory) {
|
||||||
this.history.resumeRecording();
|
this.history.resumeRecording();
|
||||||
@ -1700,14 +1692,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.libraryItems) {
|
if (sceneData.libraryItems) {
|
||||||
this.library.saveLibrary(
|
this.library.importLibrary(sceneData.libraryItems, "unpublished");
|
||||||
restoreLibraryItems(sceneData.libraryItems, "unpublished"),
|
|
||||||
);
|
|
||||||
if (this.state.isLibraryOpen) {
|
|
||||||
this.setState({ isLibraryOpen: false }, () => {
|
|
||||||
this.setState({ isLibraryOpen: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -5275,11 +5260,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
this.library
|
this.library
|
||||||
.importLibrary(file)
|
.importLibrary(file)
|
||||||
.then(() => {
|
|
||||||
// Close and then open to get the libraries updated
|
|
||||||
this.setState({ isLibraryOpen: false });
|
|
||||||
this.setState({ isLibraryOpen: true });
|
|
||||||
})
|
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
this.setState({ isLoading: false, errorMessage: error.message }),
|
this.setState({ isLoading: false, errorMessage: error.message }),
|
||||||
);
|
);
|
||||||
|
@ -23,6 +23,10 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
|||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
|
if (appState.isLibraryOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
return t("hints.eraserRevert");
|
return t("hints.eraserRevert");
|
||||||
}
|
}
|
||||||
|
@ -28,8 +28,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__library-message {
|
.layer-ui__library-message {
|
||||||
padding: 10px 20px;
|
padding: 2em 4em;
|
||||||
max-width: 200px;
|
min-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
.Spinner {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-library-success {
|
.publish-library-success {
|
||||||
|
@ -1,5 +1,12 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
import {
|
||||||
import Library from "../data/library";
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
RefObject,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import Library, { libraryItemsAtom } from "../data/library";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import {
|
||||||
@ -20,6 +27,9 @@ import { EVENT } from "../constants";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@ -54,6 +64,17 @@ const getSelectedItems = (
|
|||||||
selectedItems: LibraryItem["id"][],
|
selectedItems: LibraryItem["id"][],
|
||||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
const LibraryMenuWrapper = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ children: React.ReactNode }
|
||||||
|
>(({ children }, ref) => {
|
||||||
|
return (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{children}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const LibraryMenu = ({
|
export const LibraryMenu = ({
|
||||||
onClose,
|
onClose,
|
||||||
onInsertShape,
|
onInsertShape,
|
||||||
@ -103,11 +124,6 @@ export const LibraryMenu = ({
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
|
||||||
|
|
||||||
const [loadingState, setIsLoading] = useState<
|
|
||||||
"preloading" | "loading" | "ready"
|
|
||||||
>("preloading");
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -115,56 +131,35 @@ export const LibraryMenu = ({
|
|||||||
url: string;
|
url: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
}>(null);
|
}>(null);
|
||||||
const loadingTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
Promise.race([
|
|
||||||
new Promise((resolve) => {
|
|
||||||
loadingTimerRef.current = window.setTimeout(() => {
|
|
||||||
resolve("loading");
|
|
||||||
}, 100);
|
|
||||||
}),
|
|
||||||
library.loadLibrary().then((items) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setIsLoading("ready");
|
|
||||||
}),
|
|
||||||
]).then((data) => {
|
|
||||||
if (data === "loading") {
|
|
||||||
setIsLoading("loading");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
clearTimeout(loadingTimerRef.current!);
|
|
||||||
};
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(async () => {
|
const removeFromLibrary = useCallback(
|
||||||
const items = await library.loadLibrary();
|
async (libraryItems: LibraryItems) => {
|
||||||
|
const nextItems = libraryItems.filter(
|
||||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
(item) => !selectedItems.includes(item.id),
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
);
|
||||||
setLibraryItems(items);
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
});
|
});
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
setLibraryItems(nextItems);
|
},
|
||||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
[library, setAppState, selectedItems, setSelectedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const resetLibrary = useCallback(() => {
|
const resetLibrary = useCallback(() => {
|
||||||
library.resetLibrary();
|
library.resetLibrary();
|
||||||
setLibraryItems([]);
|
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}, [library, focusContainer]);
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem["elements"]) => {
|
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
if (elements.some((element) => element.type === "image")) {
|
if (elements.some((element) => element.type === "image")) {
|
||||||
return setAppState({
|
return setAppState({
|
||||||
errorMessage: "Support for adding images to the library coming soon!",
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const items = await library.loadLibrary();
|
|
||||||
const nextItems: LibraryItems = [
|
const nextItems: LibraryItems = [
|
||||||
{
|
{
|
||||||
status: "unpublished",
|
status: "unpublished",
|
||||||
@ -172,14 +167,12 @@ export const LibraryMenu = ({
|
|||||||
id: randomId(),
|
id: randomId(),
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
},
|
},
|
||||||
...items,
|
...libraryItems,
|
||||||
];
|
];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
library.saveLibrary(nextItems).catch((error) => {
|
library.saveLibrary(nextItems).catch(() => {
|
||||||
setLibraryItems(items);
|
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
});
|
});
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
},
|
||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary, library, setAppState],
|
||||||
);
|
);
|
||||||
@ -218,7 +211,7 @@ export const LibraryMenu = ({
|
|||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
const onPublishLibSuccess = useCallback(
|
||||||
(data) => {
|
(data, libraryItems: LibraryItems) => {
|
||||||
setShowPublishLibraryDialog(false);
|
setShowPublishLibraryDialog(false);
|
||||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
const nextLibItems = libraryItems.slice();
|
const nextLibItems = libraryItems.slice();
|
||||||
@ -228,47 +221,56 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
library.saveLibrary(nextLibItems);
|
library.saveLibrary(nextLibItems);
|
||||||
setLibraryItems(nextLibItems);
|
|
||||||
},
|
},
|
||||||
[
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
setShowPublishLibraryDialog,
|
|
||||||
setPublishLibSuccess,
|
|
||||||
libraryItems,
|
|
||||||
selectedItems,
|
|
||||||
library,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
LibraryItem["id"] | null
|
LibraryItem["id"] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
if (libraryItemsData.status === "loading") {
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
return (
|
||||||
|
<LibraryMenuWrapper ref={ref}>
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
<Spinner size="2em" />
|
||||||
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
|
</div>
|
||||||
|
</LibraryMenuWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryMenuWrapper ref={ref}>
|
||||||
{showPublishLibraryDialog && (
|
{showPublishLibraryDialog && (
|
||||||
<PublishLibrary
|
<PublishLibrary
|
||||||
onClose={() => setShowPublishLibraryDialog(false)}
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
libraryItems={getSelectedItems(
|
||||||
|
libraryItemsData.libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
)}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
onSuccess={onPublishLibSuccess}
|
onSuccess={(data) =>
|
||||||
|
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
onError={(error) => window.alert(error)}
|
onError={(error) => window.alert(error)}
|
||||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
updateItemsInStorage={() =>
|
||||||
|
library.saveLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
onRemove={(id: string) =>
|
onRemove={(id: string) =>
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{publishLibSuccess && renderPublishSuccess()}
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
|
|
||||||
{loadingState === "loading" ? (
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
{t("labels.libraryLoadingMessage")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LibraryMenuItems
|
<LibraryMenuItems
|
||||||
libraryItems={libraryItems}
|
libraryItems={libraryItemsData.libraryItems}
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
onRemoveFromLibrary={() =>
|
||||||
onAddToLibrary={addToLibrary}
|
removeFromLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
onAddToLibrary={(elements) =>
|
||||||
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
onInsertShape={onInsertShape}
|
onInsertShape={onInsertShape}
|
||||||
pendingElements={pendingElements}
|
pendingElements={pendingElements}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
@ -283,10 +285,10 @@ export const LibraryMenu = ({
|
|||||||
|
|
||||||
if (shouldSelect) {
|
if (shouldSelect) {
|
||||||
if (event.shiftKey && lastSelectedItem) {
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
const rangeStart = libraryItems.findIndex(
|
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||||
(item) => item.id === lastSelectedItem,
|
(item) => item.id === lastSelectedItem,
|
||||||
);
|
);
|
||||||
const rangeEnd = libraryItems.findIndex(
|
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||||
(item) => item.id === id,
|
(item) => item.id === id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -296,7 +298,7 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedItemsMap = arrayToMap(selectedItems);
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
const nextSelectedIds = libraryItems.reduce(
|
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||||
(acc: LibraryItem["id"][], item, idx) => {
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
if (
|
if (
|
||||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
@ -322,7 +324,6 @@ export const LibraryMenu = ({
|
|||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
resetLibrary={resetLibrary}
|
resetLibrary={resetLibrary}
|
||||||
/>
|
/>
|
||||||
)}
|
</LibraryMenuWrapper>
|
||||||
</Island>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -106,11 +106,6 @@ const LibraryMenuItems = ({
|
|||||||
icon={load}
|
icon={load}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
importLibraryFromJSON(library)
|
importLibraryFromJSON(library)
|
||||||
.then(() => {
|
|
||||||
// Close and then open to get the libraries updated
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
setAppState({ isLibraryOpen: true });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
|
@ -1,20 +1,16 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import {
|
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||||
ALLOWED_IMAGE_MIME_TYPES,
|
|
||||||
EXPORT_DATA_TYPES,
|
|
||||||
MIME_TYPES,
|
|
||||||
} from "../constants";
|
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement, FileId } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState, DataURL } from "../types";
|
import { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import { bytesToHexString } from "../utils";
|
import { bytesToHexString } from "../utils";
|
||||||
import { FileSystemHandle } from "./filesystem";
|
import { FileSystemHandle } from "./filesystem";
|
||||||
import { isValidExcalidrawData } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore } from "./restore";
|
import { restore, restoreLibraryItems } from "./restore";
|
||||||
import { ImportedLibraryData } from "./types";
|
import { ImportedLibraryData } from "./types";
|
||||||
|
|
||||||
const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File) => {
|
||||||
@ -163,13 +159,17 @@ export const loadFromBlob = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
export const loadLibraryFromBlob = async (
|
||||||
|
blob: Blob,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
const data: ImportedLibraryData = JSON.parse(contents);
|
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
if (!isValidLibrary(data)) {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error("Invalid library");
|
||||||
}
|
}
|
||||||
return data;
|
const libraryItems = data.libraryItems || data.library;
|
||||||
|
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canvasToBlob = async (
|
export const canvasToBlob = async (
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
ExportedDataState,
|
ExportedDataState,
|
||||||
ImportedDataState,
|
ImportedDataState,
|
||||||
ExportedLibraryData,
|
ExportedLibraryData,
|
||||||
|
ImportedLibraryData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import Library from "./library";
|
import Library from "./library";
|
||||||
|
|
||||||
@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidLibrary = (json: any) => {
|
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||||
return (
|
return (
|
||||||
typeof json === "object" &&
|
typeof json === "object" &&
|
||||||
json &&
|
json &&
|
||||||
|
@ -2,34 +2,24 @@ import { loadLibraryFromBlob } from "./blob";
|
|||||||
import { LibraryItems, LibraryItem } from "../types";
|
import { LibraryItems, LibraryItem } from "../types";
|
||||||
import { restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
|
import { ImportedDataState } from "./types";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStore } from "../jotai";
|
||||||
|
import { isPromiseLike } from "../utils";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
class Library {
|
export const libraryItemsAtom = atom<
|
||||||
private libraryCache: LibraryItems | null = null;
|
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
||||||
private app: App;
|
| { status: "loaded"; libraryItems: LibraryItems }
|
||||||
|
>({ status: "loaded", libraryItems: [] });
|
||||||
|
|
||||||
constructor(app: App) {
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||||
this.app = app;
|
JSON.parse(JSON.stringify(libraryItems));
|
||||||
}
|
|
||||||
|
|
||||||
resetLibrary = async () => {
|
|
||||||
await this.app.props.onLibraryChange?.([]);
|
|
||||||
this.libraryCache = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** imports library (currently merges, removing duplicates) */
|
|
||||||
async importLibrary(
|
|
||||||
blob: Blob,
|
|
||||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
|
||||||
) {
|
|
||||||
const libraryFile = await loadLibraryFromBlob(blob);
|
|
||||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* checks if library item does not exist already in current library
|
* checks if library item does not exist already in current library
|
||||||
*/
|
*/
|
||||||
const isUniqueitem = (
|
const isUniqueItem = (
|
||||||
existingLibraryItems: LibraryItems,
|
existingLibraryItems: LibraryItems,
|
||||||
targetLibraryItem: LibraryItem,
|
targetLibraryItem: LibraryItem,
|
||||||
) => {
|
) => {
|
||||||
@ -50,55 +40,106 @@ class Library {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingLibraryItems = await this.loadLibrary();
|
class Library {
|
||||||
|
/** cache for currently active promise when initializing/updating libaries
|
||||||
|
asynchronously */
|
||||||
|
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
||||||
|
/** last resolved libraryItems */
|
||||||
|
private lastLibraryItems: LibraryItems = [];
|
||||||
|
|
||||||
|
private app: App;
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLibrary = async () => {
|
||||||
|
this.saveLibrary([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** imports library (currently merges, removing duplicates) */
|
||||||
|
async importLibrary(
|
||||||
|
library:
|
||||||
|
| Blob
|
||||||
|
| Required<ImportedDataState>["libraryItems"]
|
||||||
|
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||||
|
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||||
|
) {
|
||||||
|
return this.saveLibrary(
|
||||||
|
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let libraryItems: LibraryItems;
|
||||||
|
if (library instanceof Blob) {
|
||||||
|
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||||
|
} else {
|
||||||
|
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLibraryItems = this.lastLibraryItems;
|
||||||
|
|
||||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
|
||||||
const restoredLibItems = restoreLibraryItems(library, defaultStatus);
|
|
||||||
const filteredItems = [];
|
const filteredItems = [];
|
||||||
for (const item of restoredLibItems) {
|
for (const item of libraryItems) {
|
||||||
if (isUniqueitem(existingLibraryItems, item)) {
|
if (isUniqueItem(existingLibraryItems, item)) {
|
||||||
filteredItems.push(item);
|
filteredItems.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
|
resolve([...filteredItems, ...existingLibraryItems]);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(t("errors.importLibraryError")));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadLibrary = (): Promise<LibraryItems> => {
|
loadLibrary = (): Promise<LibraryItems> => {
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
if (this.libraryCache) {
|
|
||||||
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const libraryItems = this.app.libraryItemsFromStorage;
|
resolve(
|
||||||
if (!libraryItems) {
|
cloneLibraryItems(
|
||||||
return resolve([]);
|
await (this.libraryItemsPromise || this.lastLibraryItems),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
const items = restoreLibraryItems(libraryItems, "unpublished");
|
} catch (error) {
|
||||||
|
return resolve(this.lastLibraryItems);
|
||||||
// clone to ensure we don't mutate the cached library elements in the app
|
|
||||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
|
||||||
|
|
||||||
resolve(items);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
resolve([]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
saveLibrary = async (items: LibraryItems) => {
|
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
||||||
const prevLibraryItems = this.libraryCache;
|
const prevLibraryItems = this.lastLibraryItems;
|
||||||
try {
|
try {
|
||||||
const serializedItems = JSON.stringify(items);
|
let nextLibraryItems;
|
||||||
// cache optimistically so that the app has access to the latest
|
if (isPromiseLike(items)) {
|
||||||
// immediately
|
const promise = items.then((items) => cloneLibraryItems(items));
|
||||||
this.libraryCache = JSON.parse(serializedItems);
|
this.libraryItemsPromise = promise;
|
||||||
await this.app.props.onLibraryChange?.(items);
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loading",
|
||||||
|
promise,
|
||||||
|
libraryItems: null,
|
||||||
|
});
|
||||||
|
nextLibraryItems = await promise;
|
||||||
|
} else {
|
||||||
|
nextLibraryItems = cloneLibraryItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastLibraryItems = nextLibraryItems;
|
||||||
|
this.libraryItemsPromise = null;
|
||||||
|
|
||||||
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loaded",
|
||||||
|
libraryItems: nextLibraryItems,
|
||||||
|
});
|
||||||
|
await this.app.props.onLibraryChange?.(
|
||||||
|
cloneLibraryItems(nextLibraryItems),
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.libraryCache = prevLibraryItems;
|
this.lastLibraryItems = prevLibraryItems;
|
||||||
|
this.libraryItemsPromise = null;
|
||||||
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
|
status: "loaded",
|
||||||
|
libraryItems: prevLibraryItems,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -280,7 +280,7 @@ export const restoreAppState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restore = (
|
export const restore = (
|
||||||
data: ImportedDataState | null,
|
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
||||||
/**
|
/**
|
||||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||||
* don't overwrite local state with default values (when values not
|
* don't overwrite local state with default values (when values not
|
||||||
@ -306,7 +306,7 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restoreLibraryItems = (
|
export const restoreLibraryItems = (
|
||||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||||
defaultStatus: LibraryItem["status"],
|
defaultStatus: LibraryItem["status"],
|
||||||
) => {
|
) => {
|
||||||
const restoredItems: LibraryItem[] = [];
|
const restoredItems: LibraryItem[] = [];
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
} from "../../appState";
|
} from "../../appState";
|
||||||
import { clearElementsForLocalStorage } from "../../element";
|
import { clearElementsForLocalStorage } from "../../element";
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
try {
|
try {
|
||||||
@ -102,14 +103,13 @@ export const getTotalStorageSize = () => {
|
|||||||
|
|
||||||
export const getLibraryItemsFromStorage = () => {
|
export const getLibraryItemsFromStorage = () => {
|
||||||
try {
|
try {
|
||||||
const libraryItems =
|
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
||||||
) || [];
|
);
|
||||||
|
|
||||||
return libraryItems;
|
return libraryItems || [];
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -12,7 +12,6 @@ import {
|
|||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { loadFromBlob } from "../data/blob";
|
import { loadFromBlob } from "../data/blob";
|
||||||
import { ImportedDataState } from "../data/types";
|
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
@ -29,6 +28,7 @@ import {
|
|||||||
LibraryItems,
|
LibraryItems,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
|
ExcalidrawInitialDataState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
@ -84,7 +84,7 @@ languageDetector.init({
|
|||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
collabAPI: CollabAPI;
|
collabAPI: CollabAPI;
|
||||||
}): Promise<
|
}): Promise<
|
||||||
{ scene: ImportedDataState | null } & (
|
{ scene: ExcalidrawInitialDataState | null } & (
|
||||||
| { isExternalScene: true; id: string; key: string }
|
| { isExternalScene: true; id: string; key: string }
|
||||||
| { isExternalScene: false; id?: null; key?: null }
|
| { isExternalScene: false; id?: null; key?: null }
|
||||||
)
|
)
|
||||||
@ -211,11 +211,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const initialStatePromiseRef = useRef<{
|
const initialStatePromiseRef = useRef<{
|
||||||
promise: ResolvablePromise<ImportedDataState | null>;
|
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
||||||
}>({ promise: null! });
|
}>({ promise: null! });
|
||||||
if (!initialStatePromiseRef.current.promise) {
|
if (!initialStatePromiseRef.current.promise) {
|
||||||
initialStatePromiseRef.current.promise =
|
initialStatePromiseRef.current.promise =
|
||||||
resolvablePromise<ImportedDataState | null>();
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@ -34,6 +34,8 @@ type Mutable<T> = {
|
|||||||
-readonly [P in keyof T]: T[P];
|
-readonly [P in keyof T]: T[P];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Merge<M, N> = Omit<M, keyof N> & N;
|
||||||
|
|
||||||
/** utility type to assert that the second type is a subtype of the first type.
|
/** utility type to assert that the second type is a subtype of the first type.
|
||||||
* Returns the subtype. */
|
* Returns the subtype. */
|
||||||
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
|
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
|
||||||
|
4
src/jotai.ts
Normal file
4
src/jotai.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { unstable_createStore } from "jotai";
|
||||||
|
|
||||||
|
export const jotaiScope = Symbol();
|
||||||
|
export const jotaiStore = unstable_createStore();
|
@ -172,7 +172,6 @@
|
|||||||
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
|
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
|
||||||
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
|
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
|
||||||
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
|
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
|
||||||
"errorLoadingLibrary": "There was an error loading the third party library.",
|
|
||||||
"errorAddingToLibrary": "Couldn't add item to the library",
|
"errorAddingToLibrary": "Couldn't add item to the library",
|
||||||
"errorRemovingFromLibrary": "Couldn't remove item from the library",
|
"errorRemovingFromLibrary": "Couldn't remove item from the library",
|
||||||
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
||||||
@ -189,7 +188,8 @@
|
|||||||
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
|
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
|
||||||
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
|
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
|
||||||
"invalidSVGString": "Invalid SVG.",
|
"invalidSVGString": "Invalid SVG.",
|
||||||
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again."
|
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
|
||||||
|
"importLibraryError": "Couldn't load library"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selection",
|
"selection": "Selection",
|
||||||
|
@ -10,6 +10,8 @@ import "../../css/styles.scss";
|
|||||||
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
|
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
|
||||||
import { defaultLang } from "../../i18n";
|
import { defaultLang } from "../../i18n";
|
||||||
import { DEFAULT_UI_OPTIONS } from "../../constants";
|
import { DEFAULT_UI_OPTIONS } from "../../constants";
|
||||||
|
import { Provider } from "jotai";
|
||||||
|
import { jotaiScope, jotaiStore } from "../../jotai";
|
||||||
|
|
||||||
const Excalidraw = (props: ExcalidrawProps) => {
|
const Excalidraw = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
@ -73,6 +75,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InitializeApp langCode={langCode}>
|
<InitializeApp langCode={langCode}>
|
||||||
|
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
|
||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
@ -99,6 +102,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
generateIdForFile={generateIdForFile}
|
generateIdForFile={generateIdForFile}
|
||||||
onLinkOpen={onLinkOpen}
|
onLinkOpen={onLinkOpen}
|
||||||
/>
|
/>
|
||||||
|
</Provider>
|
||||||
</InitializeApp>
|
</InitializeApp>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
14
src/types.ts
14
src/types.ts
@ -209,13 +209,25 @@ export type ExcalidrawAPIRefValue =
|
|||||||
ready?: false;
|
ready?: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExcalidrawInitialDataState = Merge<
|
||||||
|
ImportedDataState,
|
||||||
|
{
|
||||||
|
libraryItems?:
|
||||||
|
| Required<ImportedDataState>["libraryItems"]
|
||||||
|
| Promise<Required<ImportedDataState>["libraryItems"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export interface ExcalidrawProps {
|
export interface ExcalidrawProps {
|
||||||
onChange?: (
|
onChange?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
|
initialData?:
|
||||||
|
| ExcalidrawInitialDataState
|
||||||
|
| null
|
||||||
|
| Promise<ExcalidrawInitialDataState | null>;
|
||||||
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
isCollaborating?: boolean;
|
isCollaborating?: boolean;
|
||||||
|
@ -7678,6 +7678,11 @@ jest@26.6.0:
|
|||||||
import-local "^3.0.2"
|
import-local "^3.0.2"
|
||||||
jest-cli "^26.6.0"
|
jest-cli "^26.6.0"
|
||||||
|
|
||||||
|
jotai@1.6.4:
|
||||||
|
version "1.6.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.4.tgz#4d9904362c53c4293d32e21fb358d3de34b82912"
|
||||||
|
integrity sha512-XC0ExLhdE6FEBdIjKTe6kMlHaAUV/QiwN7vZond76gNr/WdcdonJOEW79+5t8u38sR41bJXi26B1dRi7cCRz9A==
|
||||||
|
|
||||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user