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:
David Luzar 2022-04-20 14:40:03 +02:00 committed by GitHub
parent 55ccd5b79b
commit cd942c3e3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 342 additions and 283 deletions

View file

@ -76,9 +76,8 @@ import {
ZOOM_STEP,
} from "../constants";
import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json";
import Library from "../data/library";
import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
import { restore, restoreElements } from "../data/restore";
import {
dragNewElement,
dragSelectedElements,
@ -231,6 +230,7 @@ import {
generateIdFromFile,
getDataURL,
isSupportedImageFile,
loadLibraryFromBlob,
resizeImageFile,
SVGStringToFile,
} from "../data/blob";
@ -706,28 +706,21 @@ class App extends React.Component<AppProps, AppState> {
try {
const request = await fetch(decodeURIComponent(url));
const blob = await request.blob();
const json = JSON.parse(await blob.text());
if (!isValidLibrary(json)) {
throw new Error();
}
const defaultStatus = "published";
const libraryItems = await loadLibraryFromBlob(blob, defaultStatus);
if (
token === this.id ||
window.confirm(
t("alerts.confirmAddLibrary", {
numShapes: (json.libraryItems || json.library || []).length,
numShapes: libraryItems.length,
}),
)
) {
await this.library.importLibrary(blob, "published");
// hack to rerender the library items after import
if (this.state.isLibraryOpen) {
this.setState({ isLibraryOpen: false });
}
this.setState({ isLibraryOpen: true });
await this.library.importLibrary(libraryItems, defaultStatus);
}
} catch (error: any) {
window.alert(t("alerts.errorLoadingLibrary"));
console.error(error);
this.setState({ errorMessage: t("errors.importLibraryError") });
} finally {
this.focusContainer();
}
@ -792,10 +785,7 @@ class App extends React.Component<AppProps, AppState> {
try {
initialData = (await this.props.initialData) || null;
if (initialData?.libraryItems) {
this.libraryItemsFromStorage = restoreLibraryItems(
initialData.libraryItems,
"unpublished",
) as LibraryItems;
this.library.importLibrary(initialData.libraryItems, "unpublished");
}
} catch (error: any) {
console.error(error);
@ -1681,7 +1671,9 @@ class App extends React.Component<AppProps, AppState> {
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"];
libraryItems?: SceneData["libraryItems"];
libraryItems?:
| Required<SceneData>["libraryItems"]
| Promise<Required<SceneData>["libraryItems"]>;
}) => {
if (sceneData.commitToHistory) {
this.history.resumeRecording();
@ -1700,14 +1692,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (sceneData.libraryItems) {
this.library.saveLibrary(
restoreLibraryItems(sceneData.libraryItems, "unpublished"),
);
if (this.state.isLibraryOpen) {
this.setState({ isLibraryOpen: false }, () => {
this.setState({ isLibraryOpen: true });
});
}
this.library.importLibrary(sceneData.libraryItems, "unpublished");
}
},
);
@ -5275,11 +5260,6 @@ class App extends React.Component<AppProps, AppState> {
) {
this.library
.importLibrary(file)
.then(() => {
// Close and then open to get the libraries updated
this.setState({ isLibraryOpen: false });
this.setState({ isLibraryOpen: true });
})
.catch((error) =>
this.setState({ isLoading: false, errorMessage: error.message }),
);

View file

@ -23,6 +23,10 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}

View file

@ -28,8 +28,17 @@
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
padding: 2em 4em;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
}
.publish-library-success {

View file

@ -1,5 +1,12 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import {
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
@ -20,6 +27,9 @@ import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@ -54,6 +64,17 @@ const getSelectedItems = (
selectedItems: LibraryItem["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 = ({
onClose,
onInsertShape,
@ -103,11 +124,6 @@ export const LibraryMenu = ({
};
}, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
@ -115,56 +131,35 @@ export const LibraryMenu = ({
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
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 [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
@ -172,14 +167,12 @@ export const LibraryMenu = ({
id: randomId(),
created: Date.now(),
},
...items,
...libraryItems,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
@ -218,7 +211,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
(data, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
@ -228,101 +221,109 @@ export const LibraryMenu = ({
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
if (libraryItemsData.status === "loading") {
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 && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState}
onSuccess={onPublishLibSuccess}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
updateItemsInStorage={() =>
library.saveLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItemsData.libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItemsData.libraryItems.findIndex(
(item) => item.id === id,
);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
setLastSelectedItem(id);
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
setSelectedItems([...selectedItems, id]);
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
);
};

View file

@ -106,11 +106,6 @@ const LibraryMenuItems = ({
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });