|
@@ -1,29 +1,15 @@
|
|
|
import clsx from "clsx";
|
|
|
-import React, {
|
|
|
- RefObject,
|
|
|
- useCallback,
|
|
|
- useEffect,
|
|
|
- useRef,
|
|
|
- useState,
|
|
|
-} from "react";
|
|
|
+import React, { useCallback } from "react";
|
|
|
import { ActionManager } from "../actions/manager";
|
|
|
import { CLASSES } from "../constants";
|
|
|
import { exportCanvas } from "../data";
|
|
|
-import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
|
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
|
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
|
import { Language, t } from "../i18n";
|
|
|
import { useIsMobile } from "../components/App";
|
|
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
|
|
import { ExportType } from "../scene/types";
|
|
|
-import {
|
|
|
- AppProps,
|
|
|
- AppState,
|
|
|
- ExcalidrawProps,
|
|
|
- BinaryFiles,
|
|
|
- LibraryItem,
|
|
|
- LibraryItems,
|
|
|
-} from "../types";
|
|
|
+import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
|
|
import { muteFSAbortError } from "../utils";
|
|
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
|
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
|
@@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog";
|
|
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
|
|
import { FixedSideContainer } from "./FixedSideContainer";
|
|
|
import { HintViewer } from "./HintViewer";
|
|
|
-import { exportFile, load, trash } from "./icons";
|
|
|
import { Island } from "./Island";
|
|
|
import "./LayerUI.scss";
|
|
|
-import { LibraryUnit } from "./LibraryUnit";
|
|
|
import { LoadingMessage } from "./LoadingMessage";
|
|
|
import { LockButton } from "./LockButton";
|
|
|
import { MobileMenu } from "./MobileMenu";
|
|
@@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
|
|
import { Section } from "./Section";
|
|
|
import { HelpDialog } from "./HelpDialog";
|
|
|
import Stack from "./Stack";
|
|
|
-import { ToolButton } from "./ToolButton";
|
|
|
import { Tooltip } from "./Tooltip";
|
|
|
import { UserList } from "./UserList";
|
|
|
import Library from "../data/library";
|
|
|
import { JSONExportDialog } from "./JSONExportDialog";
|
|
|
import { LibraryButton } from "./LibraryButton";
|
|
|
import { isImageFileHandle } from "../data/blob";
|
|
|
+import { LibraryMenu } from "./LibraryMenu";
|
|
|
|
|
|
interface LayerUIProps {
|
|
|
actionManager: ActionManager;
|
|
@@ -81,302 +65,6 @@ interface LayerUIProps {
|
|
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
|
|
}
|
|
|
|
|
|
-const useOnClickOutside = (
|
|
|
- ref: RefObject<HTMLElement>,
|
|
|
- cb: (event: MouseEvent) => void,
|
|
|
-) => {
|
|
|
- useEffect(() => {
|
|
|
- const listener = (event: MouseEvent) => {
|
|
|
- if (!ref.current) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (
|
|
|
- event.target instanceof Element &&
|
|
|
- (ref.current.contains(event.target) ||
|
|
|
- !document.body.contains(event.target))
|
|
|
- ) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- cb(event);
|
|
|
- };
|
|
|
- document.addEventListener("pointerdown", listener, false);
|
|
|
-
|
|
|
- return () => {
|
|
|
- document.removeEventListener("pointerdown", listener);
|
|
|
- };
|
|
|
- }, [ref, cb]);
|
|
|
-};
|
|
|
-
|
|
|
-const LibraryMenuItems = ({
|
|
|
- libraryItems,
|
|
|
- onRemoveFromLibrary,
|
|
|
- onAddToLibrary,
|
|
|
- onInsertShape,
|
|
|
- pendingElements,
|
|
|
- theme,
|
|
|
- setAppState,
|
|
|
- setLibraryItems,
|
|
|
- libraryReturnUrl,
|
|
|
- focusContainer,
|
|
|
- library,
|
|
|
- files,
|
|
|
- id,
|
|
|
-}: {
|
|
|
- libraryItems: LibraryItems;
|
|
|
- pendingElements: LibraryItem;
|
|
|
- onRemoveFromLibrary: (index: number) => void;
|
|
|
- onInsertShape: (elements: LibraryItem) => void;
|
|
|
- onAddToLibrary: (elements: LibraryItem) => void;
|
|
|
- theme: AppState["theme"];
|
|
|
- files: BinaryFiles;
|
|
|
- setAppState: React.Component<any, AppState>["setState"];
|
|
|
- setLibraryItems: (library: LibraryItems) => void;
|
|
|
- libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
|
- focusContainer: () => void;
|
|
|
- library: Library;
|
|
|
- id: string;
|
|
|
-}) => {
|
|
|
- const isMobile = useIsMobile();
|
|
|
- const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
|
|
- const CELLS_PER_ROW = isMobile ? 4 : 6;
|
|
|
- const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
|
|
- const rows = [];
|
|
|
- let addedPendingElements = false;
|
|
|
-
|
|
|
- const referrer =
|
|
|
- libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
|
-
|
|
|
- rows.push(
|
|
|
- <div className="layer-ui__library-header" key="library-header">
|
|
|
- <ToolButton
|
|
|
- key="import"
|
|
|
- type="button"
|
|
|
- title={t("buttons.load")}
|
|
|
- aria-label={t("buttons.load")}
|
|
|
- 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 });
|
|
|
- });
|
|
|
- }}
|
|
|
- />
|
|
|
- {!!libraryItems.length && (
|
|
|
- <>
|
|
|
- <ToolButton
|
|
|
- key="export"
|
|
|
- type="button"
|
|
|
- title={t("buttons.export")}
|
|
|
- aria-label={t("buttons.export")}
|
|
|
- icon={exportFile}
|
|
|
- onClick={() => {
|
|
|
- saveLibraryAsJSON(library)
|
|
|
- .catch(muteFSAbortError)
|
|
|
- .catch((error) => {
|
|
|
- setAppState({ errorMessage: error.message });
|
|
|
- });
|
|
|
- }}
|
|
|
- />
|
|
|
- <ToolButton
|
|
|
- key="reset"
|
|
|
- type="button"
|
|
|
- title={t("buttons.resetLibrary")}
|
|
|
- aria-label={t("buttons.resetLibrary")}
|
|
|
- icon={trash}
|
|
|
- onClick={() => {
|
|
|
- if (window.confirm(t("alerts.resetLibrary"))) {
|
|
|
- library.resetLibrary();
|
|
|
- setLibraryItems([]);
|
|
|
- focusContainer();
|
|
|
- }
|
|
|
- }}
|
|
|
- />
|
|
|
- </>
|
|
|
- )}
|
|
|
- <a
|
|
|
- href={`https://libraries.excalidraw.com?target=${
|
|
|
- window.name || "_blank"
|
|
|
- }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
|
|
- target="_excalidraw_libraries"
|
|
|
- >
|
|
|
- {t("labels.libraries")}
|
|
|
- </a>
|
|
|
- </div>,
|
|
|
- );
|
|
|
-
|
|
|
- for (let row = 0; row < numRows; row++) {
|
|
|
- const y = CELLS_PER_ROW * row;
|
|
|
- const children = [];
|
|
|
- for (let x = 0; x < CELLS_PER_ROW; x++) {
|
|
|
- const shouldAddPendingElements: boolean =
|
|
|
- pendingElements.length > 0 &&
|
|
|
- !addedPendingElements &&
|
|
|
- y + x >= libraryItems.length;
|
|
|
- addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
|
|
-
|
|
|
- children.push(
|
|
|
- <Stack.Col key={x}>
|
|
|
- <LibraryUnit
|
|
|
- elements={libraryItems[y + x]}
|
|
|
- files={files}
|
|
|
- pendingElements={
|
|
|
- shouldAddPendingElements ? pendingElements : undefined
|
|
|
- }
|
|
|
- onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
|
|
- onClick={
|
|
|
- shouldAddPendingElements
|
|
|
- ? onAddToLibrary.bind(null, pendingElements)
|
|
|
- : onInsertShape.bind(null, libraryItems[y + x])
|
|
|
- }
|
|
|
- />
|
|
|
- </Stack.Col>,
|
|
|
- );
|
|
|
- }
|
|
|
- rows.push(
|
|
|
- <Stack.Row align="center" gap={1} key={row}>
|
|
|
- {children}
|
|
|
- </Stack.Row>,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
|
|
- {rows}
|
|
|
- </Stack.Col>
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
-const LibraryMenu = ({
|
|
|
- onClickOutside,
|
|
|
- onInsertShape,
|
|
|
- pendingElements,
|
|
|
- onAddToLibrary,
|
|
|
- theme,
|
|
|
- setAppState,
|
|
|
- files,
|
|
|
- libraryReturnUrl,
|
|
|
- focusContainer,
|
|
|
- library,
|
|
|
- id,
|
|
|
-}: {
|
|
|
- pendingElements: LibraryItem;
|
|
|
- onClickOutside: (event: MouseEvent) => void;
|
|
|
- onInsertShape: (elements: LibraryItem) => void;
|
|
|
- onAddToLibrary: () => void;
|
|
|
- theme: AppState["theme"];
|
|
|
- files: BinaryFiles;
|
|
|
- setAppState: React.Component<any, AppState>["setState"];
|
|
|
- libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
|
- focusContainer: () => void;
|
|
|
- library: Library;
|
|
|
- id: string;
|
|
|
-}) => {
|
|
|
- const ref = useRef<HTMLDivElement | null>(null);
|
|
|
- useOnClickOutside(ref, (event) => {
|
|
|
- // If click on the library icon, do nothing.
|
|
|
- if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
|
|
- return;
|
|
|
- }
|
|
|
- onClickOutside(event);
|
|
|
- });
|
|
|
-
|
|
|
- const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
|
|
-
|
|
|
- const [loadingState, setIsLoading] = useState<
|
|
|
- "preloading" | "loading" | "ready"
|
|
|
- >("preloading");
|
|
|
-
|
|
|
- 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 removeFromLibrary = useCallback(
|
|
|
- async (indexToRemove) => {
|
|
|
- const items = await library.loadLibrary();
|
|
|
- const nextItems = items.filter((_, index) => index !== indexToRemove);
|
|
|
- library.saveLibrary(nextItems).catch((error) => {
|
|
|
- setLibraryItems(items);
|
|
|
- setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
|
- });
|
|
|
- setLibraryItems(nextItems);
|
|
|
- },
|
|
|
- [library, setAppState],
|
|
|
- );
|
|
|
-
|
|
|
- const addToLibrary = useCallback(
|
|
|
- async (elements: LibraryItem) => {
|
|
|
- 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 = [...items, elements];
|
|
|
- onAddToLibrary();
|
|
|
- library.saveLibrary(nextItems).catch((error) => {
|
|
|
- setLibraryItems(items);
|
|
|
- setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
|
|
- });
|
|
|
- setLibraryItems(nextItems);
|
|
|
- },
|
|
|
- [onAddToLibrary, library, setAppState],
|
|
|
- );
|
|
|
-
|
|
|
- return loadingState === "preloading" ? null : (
|
|
|
- <Island padding={1} ref={ref} className="layer-ui__library">
|
|
|
- {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}
|
|
|
- setLibraryItems={setLibraryItems}
|
|
|
- libraryReturnUrl={libraryReturnUrl}
|
|
|
- focusContainer={focusContainer}
|
|
|
- library={library}
|
|
|
- theme={theme}
|
|
|
- files={files}
|
|
|
- id={id}
|
|
|
- />
|
|
|
- )}
|
|
|
- </Island>
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
const LayerUI = ({
|
|
|
actionManager,
|
|
|
appState,
|
|
@@ -561,12 +249,15 @@ const LayerUI = ({
|
|
|
</Section>
|
|
|
);
|
|
|
|
|
|
- const closeLibrary = useCallback(
|
|
|
- (event) => {
|
|
|
- setAppState({ isLibraryOpen: false });
|
|
|
- },
|
|
|
- [setAppState],
|
|
|
- );
|
|
|
+ const closeLibrary = useCallback(() => {
|
|
|
+ const isDialogOpen = !!document.querySelector(".Dialog");
|
|
|
+
|
|
|
+ // Prevent closing if any dialog is open
|
|
|
+ if (isDialogOpen) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setAppState({ isLibraryOpen: false });
|
|
|
+ }, [setAppState]);
|
|
|
|
|
|
const deselectItems = useCallback(() => {
|
|
|
setAppState({
|
|
@@ -578,7 +269,7 @@ const LayerUI = ({
|
|
|
const libraryMenu = appState.isLibraryOpen ? (
|
|
|
<LibraryMenu
|
|
|
pendingElements={getSelectedElements(elements, appState)}
|
|
|
- onClickOutside={closeLibrary}
|
|
|
+ onClose={closeLibrary}
|
|
|
onInsertShape={onInsertElements}
|
|
|
onAddToLibrary={deselectItems}
|
|
|
setAppState={setAppState}
|
|
@@ -588,6 +279,7 @@ const LayerUI = ({
|
|
|
theme={appState.theme}
|
|
|
files={files}
|
|
|
id={id}
|
|
|
+ appState={appState}
|
|
|
/>
|
|
|
) : null;
|
|
|
|