diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 62eb3656..b5c94154 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -1,12 +1,12 @@ import clsx from "clsx"; -import React, { useCallback } from "react"; +import React from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { exportCanvas } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; -import { calculateScrollCenter, getSelectedElements } from "../scene"; +import { calculateScrollCenter } from "../scene"; import { ExportType } from "../scene/types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { muteFSAbortError } from "../utils"; @@ -26,7 +26,7 @@ import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; import { UserList } from "./UserList"; -import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; +import Library from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; import { LibraryButton } from "./LibraryButton"; import { isImageFileHandle } from "../data/blob"; @@ -40,7 +40,7 @@ import { useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; import Footer from "./Footer"; -import { hostSidebarCountersAtom, Sidebar } from "./Sidebar/Sidebar"; +import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; import { useAtom } from "jotai"; @@ -247,42 +247,6 @@ const LayerUI = ({ ); - const closeLibrary = useCallback(() => { - const isDialogOpen = !!document.querySelector(".Dialog"); - - // Prevent closing if any dialog is open - if (isDialogOpen) { - return; - } - setAppState({ openSidebar: null }); - }, [setAppState]); - - const deselectItems = useCallback(() => { - setAppState({ - selectedElementIds: {}, - selectedGroupIds: {}, - }); - }, [setAppState]); - - const libraryMenu = - appState.openSidebar === "library" ? ( - { - onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); - }} - onAddToLibrary={deselectItems} - setAppState={setAppState} - libraryReturnUrl={libraryReturnUrl} - focusContainer={focusContainer} - library={library} - files={files} - id={id} - appState={appState} - /> - ) : null; - const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( appState, @@ -381,6 +345,21 @@ const LayerUI = ({ ); }; + const renderSidebars = () => { + return appState.openSidebar === "customSidebar" ? ( + renderCustomSidebar?.() || null + ) : appState.openSidebar === "library" ? ( + + ) : null; + }; + const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); return ( @@ -416,7 +395,6 @@ const LayerUI = ({ appState={appState} elements={elements} actionManager={actionManager} - libraryMenu={libraryMenu} renderJSONExportDialog={renderJSONExportDialog} renderImageExportDialog={renderImageExportDialog} setAppState={setAppState} @@ -429,7 +407,7 @@ const LayerUI = ({ onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} - renderCustomSidebar={renderCustomSidebar} + renderSidebars={renderSidebars} device={device} /> )} @@ -484,26 +462,7 @@ const LayerUI = ({ )} - {appState.openSidebar === "customSidebar" ? ( - renderCustomSidebar?.() - ) : appState.openSidebar === "library" ? ( - { - trackEvent( - "library", - `toggleLibraryDock (${docked ? "dock" : "undock"})`, - `sidebar (${device.isMobile ? "mobile" : "desktop"})`, - ); - }} - > - {libraryMenu} - - ) : null} + {renderSidebars()} )} diff --git a/src/components/LibraryMenu.scss b/src/components/LibraryMenu.scss index 9358a444..c76a8046 100644 --- a/src/components/LibraryMenu.scss +++ b/src/components/LibraryMenu.scss @@ -1,10 +1,16 @@ @import "open-color/open-color"; .excalidraw { + .layer-ui__library-sidebar { + display: flex; + flex-direction: column; + } + .layer-ui__library { display: flex; - align-items: center; - justify-content: center; + flex-direction: column; + + flex: 1 1 auto; .layer-ui__library-header { display: flex; @@ -23,16 +29,100 @@ } .layer-ui__sidebar { - .layer-ui__library { - padding: 0; - height: 100%; - } .library-menu-items-container { height: 100%; width: 100%; } } + .library-actions { + width: 100%; + display: flex; + margin-right: auto; + align-items: center; + + button .library-actions-counter { + position: absolute; + right: 2px; + bottom: 2px; + border-radius: 50%; + width: 1em; + height: 1em; + padding: 1px; + font-size: 0.7rem; + background: #fff; + } + + &--remove { + background-color: $oc-red-7; + &:hover { + background-color: $oc-red-8; + } + &:active { + background-color: $oc-red-9; + } + svg { + color: $oc-white; + } + .library-actions-counter { + color: $oc-red-7; + } + } + + &--export { + background-color: $oc-lime-5; + + &:hover { + background-color: $oc-lime-7; + } + + &:active { + background-color: $oc-lime-8; + } + svg { + color: $oc-white; + } + .library-actions-counter { + color: $oc-lime-5; + } + } + + &--publish { + background-color: $oc-cyan-6; + &:hover { + background-color: $oc-cyan-7; + } + &:active { + background-color: $oc-cyan-9; + } + svg { + color: $oc-white; + } + label { + margin-left: -0.2em; + margin-right: 1.1em; + color: $oc-white; + font-size: 0.86em; + } + .library-actions-counter { + color: $oc-cyan-6; + } + } + + &--load { + background-color: $oc-blue-6; + &:hover { + background-color: $oc-blue-7; + } + &:active { + background-color: $oc-blue-9; + } + svg { + color: $oc-white; + } + } + } + .layer-ui__library-message { padding: 2em 4em; min-width: 200px; diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 63dd587d..692ade64 100644 --- a/src/components/LibraryMenu.tsx +++ b/src/components/LibraryMenu.tsx @@ -6,29 +6,31 @@ import { RefObject, forwardRef, } from "react"; -import Library, { libraryItemsAtom } from "../data/library"; +import Library, { + distributeLibraryItemsOnSquareGrid, + libraryItemsAtom, +} from "../data/library"; import { t } from "../i18n"; import { randomId } from "../random"; -import { - LibraryItems, - LibraryItem, - AppState, - BinaryFiles, - ExcalidrawProps, -} from "../types"; -import { Dialog } from "./Dialog"; -import PublishLibrary from "./PublishLibrary"; -import { ToolButton } from "./ToolButton"; +import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types"; import "./LibraryMenu.scss"; import LibraryMenuItems from "./LibraryMenuItems"; -import { EVENT } from "../constants"; +import { EVENT, VERSIONS } from "../constants"; import { KEYS } from "../keys"; import { trackEvent } from "../analytics"; import { useAtom } from "jotai"; import { jotaiScope } from "../jotai"; import Spinner from "./Spinner"; -import { useDevice } from "./App"; +import { + useDevice, + useExcalidrawElements, + useExcalidrawSetAppState, +} from "./App"; +import { Sidebar } from "./Sidebar/Sidebar"; +import { getSelectedElements } from "../scene"; +import { NonDeletedExcalidrawElement } from "../element/types"; +import { LibraryMenuHeader } from "./LibraryMenuHeaderContent"; const useOnClickOutside = ( ref: RefObject, @@ -58,11 +60,6 @@ const useOnClickOutside = ( }, [ref, cb]); }; -const getSelectedItems = ( - libraryItems: LibraryItems, - selectedItems: LibraryItem["id"][], -) => libraryItems.filter((item) => selectedItems.includes(item.id)); - const LibraryMenuWrapper = forwardRef< HTMLDivElement, { children: React.ReactNode } @@ -74,94 +71,34 @@ const LibraryMenuWrapper = forwardRef< ); }); -export const LibraryMenu = ({ - onClose, +export const LibraryMenuContent = ({ onInsertLibraryItems, pendingElements, onAddToLibrary, setAppState, - files, libraryReturnUrl, - focusContainer, library, id, appState, + selectedItems, + onSelectItems, }: { pendingElements: LibraryItem["elements"]; - onClose: () => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void; onAddToLibrary: () => void; - files: BinaryFiles; setAppState: React.Component["setState"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; - focusContainer: () => void; library: Library; id: string; appState: AppState; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; }) => { - const ref = useRef(null); - - const device = useDevice(); - useOnClickOutside( - ref, - useCallback( - (event) => { - // If click on the library icon, do nothing so that LibraryButton - // can toggle library menu - if ((event.target as Element).closest(".ToolIcon__library")) { - return; - } - if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) { - onClose(); - } - }, - [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar], - ), - ); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === KEYS.ESCAPE && - (!appState.isSidebarDocked || !device.canDeviceFitSidebar) - ) { - onClose(); - } - }; - document.addEventListener(EVENT.KEYDOWN, handleKeyDown); - return () => { - document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); - }; - }, [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar]); - - const [selectedItems, setSelectedItems] = useState([]); - const [showPublishLibraryDialog, setShowPublishLibraryDialog] = - useState(false); - const [publishLibSuccess, setPublishLibSuccess] = useState(null); + const referrer = + libraryReturnUrl || window.location.origin + window.location.pathname; const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - const removeFromLibrary = useCallback( - async (libraryItems: LibraryItems) => { - const nextItems = libraryItems.filter( - (item) => !selectedItems.includes(item.id), - ); - library.setLibrary(nextItems).catch(() => { - setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); - }); - setSelectedItems([]); - }, - [library, setAppState, selectedItems, setSelectedItems], - ); - - const resetLibrary = useCallback(() => { - library.resetLibrary(); - focusContainer(); - }, [library, focusContainer]); - const addToLibrary = useCallback( async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { trackEvent("element", "addToLibrary", "ui"); @@ -187,60 +124,12 @@ export const LibraryMenu = ({ [onAddToLibrary, library, setAppState], ); - const renderPublishSuccess = useCallback(() => { - return ( - setPublishLibSuccess(null)} - title={t("publishSuccessDialog.title")} - className="publish-library-success" - small={true} - > -

- {t("publishSuccessDialog.content", { - authorName: publishLibSuccess!.authorName, - })}{" "} - - {t("publishSuccessDialog.link")} - -

- setPublishLibSuccess(null)} - data-testid="publish-library-success-close" - className="publish-library-success-close" - /> -
- ); - }, [setPublishLibSuccess, publishLibSuccess]); - - const onPublishLibSuccess = useCallback( - (data: { url: string; authorName: string }, libraryItems: LibraryItems) => { - setShowPublishLibraryDialog(false); - setPublishLibSuccess({ url: data.url, authorName: data.authorName }); - const nextLibItems = libraryItems.slice(); - nextLibItems.forEach((libItem) => { - if (selectedItems.includes(libItem.id)) { - libItem.status = "published"; - } - }); - library.setLibrary(nextLibItems); - }, - [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], - ); - if ( libraryItemsData.status === "loading" && !libraryItemsData.isInitialized ) { return ( - +
{t("labels.libraryLoadingMessage")} @@ -250,51 +139,168 @@ export const LibraryMenu = ({ } return ( - - {showPublishLibraryDialog && ( - setShowPublishLibraryDialog(false)} - libraryItems={getSelectedItems( - libraryItemsData.libraryItems, - selectedItems, - )} - appState={appState} - onSuccess={(data) => - onPublishLibSuccess(data, libraryItemsData.libraryItems) - } - onError={(error) => window.alert(error)} - updateItemsInStorage={() => - library.setLibrary(libraryItemsData.libraryItems) - } - onRemove={(id: string) => - setSelectedItems(selectedItems.filter((_id) => _id !== id)) - } - /> - )} - {publishLibSuccess && renderPublishSuccess()} + - removeFromLibrary(libraryItemsData.libraryItems) - } onAddToLibrary={(elements) => addToLibrary(elements, libraryItemsData.libraryItems) } onInsertLibraryItems={onInsertLibraryItems} pendingElements={pendingElements} - setAppState={setAppState} - appState={appState} - libraryReturnUrl={libraryReturnUrl} - library={library} - theme={appState.theme} - files={files} - id={id} selectedItems={selectedItems} - onSelectItems={(ids) => setSelectedItems(ids)} - onPublish={() => setShowPublishLibraryDialog(true)} - resetLibrary={resetLibrary} + onSelectItems={onSelectItems} /> + + {t("labels.libraries")} + ); }; + +export const LibraryMenu: React.FC<{ + appState: AppState; + onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; + libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + focusContainer: () => void; + library: Library; + id: string; +}> = ({ + appState, + onInsertElements, + libraryReturnUrl, + focusContainer, + library, + id, +}) => { + const setAppState = useExcalidrawSetAppState(); + const elements = useExcalidrawElements(); + const device = useDevice(); + + const [selectedItems, setSelectedItems] = useState([]); + const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); + + const ref = useRef(null); + + const closeLibrary = useCallback(() => { + const isDialogOpen = !!document.querySelector(".Dialog"); + + // Prevent closing if any dialog is open + if (isDialogOpen) { + return; + } + setAppState({ openSidebar: null }); + }, [setAppState]); + + useOnClickOutside( + ref, + useCallback( + (event) => { + // If click on the library icon, do nothing so that LibraryButton + // can toggle library menu + if ((event.target as Element).closest(".ToolIcon__library")) { + return; + } + if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) { + closeLibrary(); + } + }, + [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar], + ), + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === KEYS.ESCAPE && + (!appState.isSidebarDocked || !device.canDeviceFitSidebar) + ) { + closeLibrary(); + } + }; + document.addEventListener(EVENT.KEYDOWN, handleKeyDown); + return () => { + document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); + }; + }, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]); + + const deselectItems = useCallback(() => { + setAppState({ + selectedElementIds: {}, + selectedGroupIds: {}, + }); + }, [setAppState]); + + const removeFromLibrary = useCallback( + async (libraryItems: LibraryItems) => { + const nextItems = libraryItems.filter( + (item) => !selectedItems.includes(item.id), + ); + library.setLibrary(nextItems).catch(() => { + setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); + }); + setSelectedItems([]); + }, + [library, setAppState, selectedItems, setSelectedItems], + ); + + const resetLibrary = useCallback(() => { + library.resetLibrary(); + focusContainer(); + }, [library, focusContainer]); + + return ( + { + trackEvent( + "library", + `toggleLibraryDock (${docked ? "dock" : "undock"})`, + `sidebar (${device.isMobile ? "mobile" : "desktop"})`, + ); + }} + ref={ref} + > + + + removeFromLibrary(libraryItemsData.libraryItems) + } + resetLibrary={resetLibrary} + /> + + { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }} + onAddToLibrary={deselectItems} + setAppState={setAppState} + libraryReturnUrl={libraryReturnUrl} + library={library} + id={id} + appState={appState} + selectedItems={selectedItems} + onSelectItems={setSelectedItems} + /> + + ); +}; diff --git a/src/components/LibraryMenuHeaderContent.tsx b/src/components/LibraryMenuHeaderContent.tsx new file mode 100644 index 00000000..c29040ba --- /dev/null +++ b/src/components/LibraryMenuHeaderContent.tsx @@ -0,0 +1,258 @@ +import React, { useCallback, useState } from "react"; +import { saveLibraryAsJSON } from "../data/json"; +import Library, { libraryItemsAtom } from "../data/library"; +import { t } from "../i18n"; +import { AppState, LibraryItem, LibraryItems } from "../types"; +import { exportToFileIcon, load, publishIcon, trash } from "./icons"; +import { ToolButton } from "./ToolButton"; +import { Tooltip } from "./Tooltip"; +import { fileOpen } from "../data/filesystem"; +import { muteFSAbortError } from "../utils"; +import { useAtom } from "jotai"; +import { jotaiScope } from "../jotai"; +import ConfirmDialog from "./ConfirmDialog"; +import PublishLibrary from "./PublishLibrary"; +import { Dialog } from "./Dialog"; + +const getSelectedItems = ( + libraryItems: LibraryItems, + selectedItems: LibraryItem["id"][], +) => libraryItems.filter((item) => selectedItems.includes(item.id)); + +export const LibraryMenuHeader: React.FC<{ + setAppState: React.Component["setState"]; + selectedItems: LibraryItem["id"][]; + library: Library; + onRemoveFromLibrary: () => void; + resetLibrary: () => void; + onSelectItems: (items: LibraryItem["id"][]) => void; + appState: AppState; +}> = ({ + setAppState, + selectedItems, + library, + onRemoveFromLibrary, + resetLibrary, + onSelectItems, + appState, +}) => { + const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); + + const renderRemoveLibAlert = useCallback(() => { + const content = selectedItems.length + ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) + : t("alerts.resetLibrary"); + const title = selectedItems.length + ? t("confirmDialog.removeItemsFromLib") + : t("confirmDialog.resetLibrary"); + return ( + { + if (selectedItems.length) { + onRemoveFromLibrary(); + } else { + resetLibrary(); + } + setShowRemoveLibAlert(false); + }} + onCancel={() => { + setShowRemoveLibAlert(false); + }} + title={title} + > +

{content}

+
+ ); + }, [selectedItems, onRemoveFromLibrary, resetLibrary]); + + const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); + + const itemsSelected = !!selectedItems.length; + const items = itemsSelected + ? libraryItemsData.libraryItems.filter((item) => + selectedItems.includes(item.id), + ) + : libraryItemsData.libraryItems; + const resetLabel = itemsSelected + ? t("buttons.remove") + : t("buttons.resetLibrary"); + + const [showPublishLibraryDialog, setShowPublishLibraryDialog] = + useState(false); + const [publishLibSuccess, setPublishLibSuccess] = useState(null); + const renderPublishSuccess = useCallback(() => { + return ( + setPublishLibSuccess(null)} + title={t("publishSuccessDialog.title")} + className="publish-library-success" + small={true} + > +

+ {t("publishSuccessDialog.content", { + authorName: publishLibSuccess!.authorName, + })}{" "} + + {t("publishSuccessDialog.link")} + +

+ setPublishLibSuccess(null)} + data-testid="publish-library-success-close" + className="publish-library-success-close" + /> +
+ ); + }, [setPublishLibSuccess, publishLibSuccess]); + + const onPublishLibSuccess = useCallback( + (data: { url: string; authorName: string }, libraryItems: LibraryItems) => { + setShowPublishLibraryDialog(false); + setPublishLibSuccess({ url: data.url, authorName: data.authorName }); + const nextLibItems = libraryItems.slice(); + nextLibItems.forEach((libItem) => { + if (selectedItems.includes(libItem.id)) { + libItem.status = "published"; + } + }); + library.setLibrary(nextLibItems); + }, + [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], + ); + + const onLibraryImport = async () => { + try { + await library.updateLibrary({ + libraryItems: fileOpen({ + description: "Excalidraw library files", + // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 + // gets resolved. Else, iOS users cannot open `.excalidraw` files. + /* + extensions: [".json", ".excalidrawlib"], + */ + }), + merge: true, + openLibraryMenu: true, + }); + } catch (error: any) { + if (error?.name === "AbortError") { + console.warn(error); + return; + } + setAppState({ errorMessage: t("errors.importLibraryError") }); + } + }; + + const onLibraryExport = async () => { + const libraryItems = itemsSelected + ? items + : await library.getLatestLibrary(); + saveLibraryAsJSON(libraryItems) + .catch(muteFSAbortError) + .catch((error) => { + setAppState({ errorMessage: error.message }); + }); + }; + + return ( +
+ {showRemoveLibAlert && renderRemoveLibAlert()} + {showPublishLibraryDialog && ( + setShowPublishLibraryDialog(false)} + libraryItems={getSelectedItems( + libraryItemsData.libraryItems, + selectedItems, + )} + appState={appState} + onSuccess={(data) => + onPublishLibSuccess(data, libraryItemsData.libraryItems) + } + onError={(error) => window.alert(error)} + updateItemsInStorage={() => + library.setLibrary(libraryItemsData.libraryItems) + } + onRemove={(id: string) => + onSelectItems(selectedItems.filter((_id) => _id !== id)) + } + /> + )} + {publishLibSuccess && renderPublishSuccess()} + {!itemsSelected && ( + + )} + {!!items.length && ( + <> + + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} + + setShowRemoveLibAlert(true)} + className="library-actions--remove" + > + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} + + + )} + {itemsSelected && ( + + setShowPublishLibraryDialog(true)} + > + + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} + + + )} +
+ ); +}; diff --git a/src/components/LibraryMenuItems.scss b/src/components/LibraryMenuItems.scss index 466bcc34..ea3460ac 100644 --- a/src/components/LibraryMenuItems.scss +++ b/src/components/LibraryMenuItems.scss @@ -6,93 +6,6 @@ flex-direction: column; height: 100%; - .library-actions { - width: 100%; - display: flex; - margin-right: auto; - align-items: center; - - button .library-actions-counter { - position: absolute; - right: 2px; - bottom: 2px; - border-radius: 50%; - width: 1em; - height: 1em; - padding: 1px; - font-size: 0.7rem; - background: #fff; - } - - &--remove { - background-color: $oc-red-7; - &:hover { - background-color: $oc-red-8; - } - &:active { - background-color: $oc-red-9; - } - svg { - color: $oc-white; - } - .library-actions-counter { - color: $oc-red-7; - } - } - - &--export { - background-color: $oc-lime-5; - - &:hover { - background-color: $oc-lime-7; - } - - &:active { - background-color: $oc-lime-8; - } - svg { - color: $oc-white; - } - .library-actions-counter { - color: $oc-lime-5; - } - } - - &--publish { - background-color: $oc-cyan-6; - &:hover { - background-color: $oc-cyan-7; - } - &:active { - background-color: $oc-cyan-9; - } - svg { - color: $oc-white; - } - label { - margin-left: -0.2em; - margin-right: 1.1em; - color: $oc-white; - font-size: 0.86em; - } - .library-actions-counter { - color: $oc-cyan-6; - } - } - - &--load { - background-color: $oc-blue-6; - &:hover { - background-color: $oc-blue-7; - } - &:active { - background-color: $oc-blue-9; - } - svg { - color: $oc-white; - } - } - } &__items { flex: 1; overflow-y: auto; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 9110a918..3c3c11e6 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,223 +1,35 @@ -import React, { useCallback, useState } from "react"; -import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json"; -import Library from "../data/library"; +import React, { useState } from "react"; +import { serializeLibraryAsJSON } from "../data/json"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { t } from "../i18n"; -import { - AppState, - BinaryFiles, - ExcalidrawProps, - LibraryItem, - LibraryItems, -} from "../types"; -import { arrayToMap, chunk, muteFSAbortError } from "../utils"; -import { useDevice } from "./App"; -import ConfirmDialog from "./ConfirmDialog"; -import { exportToFileIcon, load, publishIcon, trash } from "./icons"; +import { LibraryItem, LibraryItems } from "../types"; +import { arrayToMap, chunk } from "../utils"; import { LibraryUnit } from "./LibraryUnit"; import Stack from "./Stack"; -import { ToolButton } from "./ToolButton"; -import { Tooltip } from "./Tooltip"; import "./LibraryMenuItems.scss"; -import { MIME_TYPES, VERSIONS } from "../constants"; +import { MIME_TYPES } from "../constants"; import Spinner from "./Spinner"; -import { fileOpen } from "../data/filesystem"; -import { Sidebar } from "./Sidebar/Sidebar"; + +const CELLS_PER_ROW = 4; const LibraryMenuItems = ({ isLoading, libraryItems, - onRemoveFromLibrary, onAddToLibrary, onInsertLibraryItems, pendingElements, - theme, - setAppState, - appState, - libraryReturnUrl, - library, - files, - id, selectedItems, onSelectItems, - onPublish, - resetLibrary, }: { isLoading: boolean; libraryItems: LibraryItems; pendingElements: LibraryItem["elements"]; - onRemoveFromLibrary: () => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void; - theme: AppState["theme"]; - files: BinaryFiles; - setAppState: React.Component["setState"]; - appState: AppState; - libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; - library: Library; - id: string; selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; - onPublish: () => void; - resetLibrary: () => void; }) => { - const renderRemoveLibAlert = useCallback(() => { - const content = selectedItems.length - ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) - : t("alerts.resetLibrary"); - const title = selectedItems.length - ? t("confirmDialog.removeItemsFromLib") - : t("confirmDialog.resetLibrary"); - return ( - { - if (selectedItems.length) { - onRemoveFromLibrary(); - } else { - resetLibrary(); - } - setShowRemoveLibAlert(false); - }} - onCancel={() => { - setShowRemoveLibAlert(false); - }} - title={title} - > -

{content}

-
- ); - }, [selectedItems, onRemoveFromLibrary, resetLibrary]); - - const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); - const device = useDevice(); - const renderLibraryActions = () => { - const itemsSelected = !!selectedItems.length; - const items = itemsSelected - ? libraryItems.filter((item) => selectedItems.includes(item.id)) - : libraryItems; - const resetLabel = itemsSelected - ? t("buttons.remove") - : t("buttons.resetLibrary"); - return ( -
- {!itemsSelected && ( - { - try { - await library.updateLibrary({ - libraryItems: fileOpen({ - description: "Excalidraw library files", - // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 - // gets resolved. Else, iOS users cannot open `.excalidraw` files. - /* - extensions: [".json", ".excalidrawlib"], - */ - }), - merge: true, - openLibraryMenu: true, - }); - } catch (error: any) { - if (error?.name === "AbortError") { - console.warn(error); - return; - } - setAppState({ errorMessage: t("errors.importLibraryError") }); - } - }} - className="library-actions--load" - /> - )} - {!!items.length && ( - <> - { - const libraryItems = itemsSelected - ? items - : await library.getLatestLibrary(); - saveLibraryAsJSON(libraryItems) - .catch(muteFSAbortError) - .catch((error) => { - setAppState({ errorMessage: error.message }); - }); - }} - className="library-actions--export" - > - {selectedItems.length > 0 && ( - - {selectedItems.length} - - )} - - setShowRemoveLibAlert(true)} - className="library-actions--remove" - > - {selectedItems.length > 0 && ( - - {selectedItems.length} - - )} - - - )} - {itemsSelected && ( - - - {!device.isMobile && } - {selectedItems.length > 0 && ( - - {selectedItems.length} - - )} - - - )} - {device.isMobile && ( - - )} -
- ); - }; - - const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4; - - const referrer = - libraryReturnUrl || window.location.origin + window.location.pathname; - const [lastSelectedItem, setLastSelectedItem] = useState< LibraryItem["id"] | null >(null); @@ -294,7 +106,6 @@ const LibraryMenuItems = ({ {})} id={params.item?.id || null} @@ -370,8 +181,21 @@ const LibraryMenuItems = ({ (item) => item.status === "published", ); - const renderLibraryMenuItems = () => { - return ( + return ( +
{(publishedItems.length > 0 || - (!device.isMobile && - (pendingElements.length > 0 || unpublishedItems.length > 0))) && ( + pendingElements.length > 0 || + unpublishedItems.length > 0) && (
{t("labels.excalidrawLib")}
)} {publishedItems.length > 0 ? ( @@ -466,45 +290,6 @@ const LibraryMenuItems = ({ ) : null}
- ); - }; - - const renderLibraryFooter = () => { - return ( - - {t("labels.libraries")} - - ); - }; - - return ( -
- {showRemoveLibAlert && renderRemoveLibAlert()} - {/* NOTE using SidebarHeader here isn't semantic since this may render - outside of a sidebar, but for now it doesn't matter */} - - {renderLibraryActions()} - - {renderLibraryMenuItems()} - {!device.isMobile && renderLibraryFooter()}
); }; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 54bb6ff4..5d9a56ae 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -3,7 +3,7 @@ import oc from "open-color"; import { useEffect, useRef, useState } from "react"; import { useDevice } from "../components/App"; import { exportToSvg } from "../scene/export"; -import { BinaryFiles, LibraryItem } from "../types"; +import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; import { CheckboxItem } from "./CheckboxItem"; @@ -23,7 +23,6 @@ const PLUS_ICON = ( export const LibraryUnit = ({ id, elements, - files, isPending, onClick, selected, @@ -32,7 +31,6 @@ export const LibraryUnit = ({ }: { id: LibraryItem["id"] | /** for pending item */ null; elements?: LibraryItem["elements"]; - files: BinaryFiles; isPending?: boolean; onClick: () => void; selected: boolean; @@ -56,7 +54,7 @@ export const LibraryUnit = ({ exportBackground: false, viewBackgroundColor: oc.white, }, - files, + null, ); node.innerHTML = svg.outerHTML; })(); @@ -64,7 +62,7 @@ export const LibraryUnit = ({ return () => { node.innerHTML = ""; }; - }, [elements, files]); + }, [elements]); const [isHovered, setIsHovered] = useState(false); const isMobile = useDevice().isMobile; diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index d379c474..9abafeb9 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -28,7 +28,6 @@ type MobileMenuProps = { renderImageExportDialog: () => React.ReactNode; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; - libraryMenu: JSX.Element | null; onCollabButtonClick?: () => void; onLockToggle: () => void; onPenModeToggle: () => void; @@ -44,14 +43,13 @@ type MobileMenuProps = { appState: AppState, ) => JSX.Element | null; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; - renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; + renderSidebars: () => JSX.Element | null; device: Device; }; export const MobileMenu = ({ appState, elements, - libraryMenu, actionManager, renderJSONExportDialog, renderImageExportDialog, @@ -65,7 +63,7 @@ export const MobileMenu = ({ onImageAction, renderTopRightUI, renderCustomStats, - renderCustomSidebar, + renderSidebars, device, }: MobileMenuProps) => { const renderToolbar = () => { @@ -111,7 +109,6 @@ export const MobileMenu = ({ penDetected={appState.penDetected} /> - {libraryMenu && {libraryMenu}} )} @@ -184,7 +181,7 @@ export const MobileMenu = ({ }; return ( <> - {appState.openSidebar === "customSidebar" && renderCustomSidebar?.()} + {renderSidebars()} {!appState.viewModeEnabled && renderToolbar()} {!appState.openMenu && appState.showStats && ( ) => { - const [hostSidebarCounters, setHostSidebarCounters] = useAtom( - hostSidebarCountersAtom, - jotaiScope, - ); +export const Sidebar = Object.assign( + forwardRef( + ( + { + children, + onClose, + onDock, + docked, + dockable = true, + className, + __isInternal, + }: SidebarProps<{ + // NOTE sidebars we use internally inside the editor must have this flag set. + // It indicates that this sidebar should have lower precedence over host + // sidebars, if both are open. + /** @private internal */ + __isInternal?: boolean; + }>, + ref: React.ForwardedRef, + ) => { + const [hostSidebarCounters, setHostSidebarCounters] = useAtom( + hostSidebarCountersAtom, + jotaiScope, + ); - const setAppState = useExcalidrawSetAppState(); + const setAppState = useExcalidrawSetAppState(); - const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false); + const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false); - useLayoutEffect(() => { - if (docked === undefined) { - // ugly hack to get initial state out of AppState without susbcribing - // to it as a whole (once we have granular subscriptions, we'll move - // to that) - // - // NOTE this means that is updated `state.isSidebarDocked` changes outside - // of this compoent, it won't be reflected here. Currently doesn't happen. - setAppState((state) => { - setIsDockedFallback(state.isSidebarDocked); - // bail from update - return null; - }); - } - }, [setAppState, docked]); + useLayoutEffect(() => { + if (docked === undefined) { + // ugly hack to get initial state out of AppState without subscribing + // to it as a whole (once we have granular subscriptions, we'll move + // to that) + // + // NOTE this means that is updated `state.isSidebarDocked` changes outside + // of this compoent, it won't be reflected here. Currently doesn't happen. + setAppState((state) => { + setIsDockedFallback(state.isSidebarDocked); + // bail from update + return null; + }); + } + }, [setAppState, docked]); - useLayoutEffect(() => { - if (!__isInternal) { - setHostSidebarCounters((s) => ({ - rendered: s.rendered + 1, - docked: isDockedFallback ? s.docked + 1 : s.docked, - })); - return () => { - setHostSidebarCounters((s) => ({ - rendered: s.rendered - 1, - docked: isDockedFallback ? s.docked - 1 : s.docked, - })); + useLayoutEffect(() => { + if (!__isInternal) { + setHostSidebarCounters((s) => ({ + rendered: s.rendered + 1, + docked: isDockedFallback ? s.docked + 1 : s.docked, + })); + return () => { + setHostSidebarCounters((s) => ({ + rendered: s.rendered - 1, + docked: isDockedFallback ? s.docked - 1 : s.docked, + })); + }; + } + }, [__isInternal, setHostSidebarCounters, isDockedFallback]); + + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + return () => { + onCloseRef.current?.(); + }; + }, []); + + const headerPropsRef = useRef({}); + headerPropsRef.current.onClose = () => { + setAppState({ openSidebar: null }); }; - } - }, [__isInternal, setHostSidebarCounters, isDockedFallback]); + headerPropsRef.current.onDock = (isDocked) => { + if (docked === undefined) { + setAppState({ isSidebarDocked: isDocked }); + setIsDockedFallback(isDocked); + } + onDock?.(isDocked); + }; + // renew the ref object if the following props change since we want to + // rerender. We can't pass down as component props manually because + // the can be rendered upsream. + headerPropsRef.current = updateObject(headerPropsRef.current, { + docked: docked ?? isDockedFallback, + dockable, + }); - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; + if (hostSidebarCounters.rendered > 0 && __isInternal) { + return null; + } - useEffect(() => { - return () => { - onCloseRef.current?.(); - }; - }, []); - - const headerPropsRef = useRef({}); - headerPropsRef.current.onClose = () => { - setAppState({ openSidebar: null }); - }; - headerPropsRef.current.onDock = (isDocked) => { - if (docked === undefined) { - setAppState({ isSidebarDocked: isDocked }); - setIsDockedFallback(isDocked); - } - onDock?.(isDocked); - }; - // renew the ref object if the following props change since we want to - // rerender. We can't pass down as component props manually because - // the can be rendered upsream. - headerPropsRef.current = updateObject(headerPropsRef.current, { - docked: docked ?? isDockedFallback, - dockable, - }); - - if (hostSidebarCounters.rendered > 0 && __isInternal) { - return null; - } - - return ( - - - - - {children} - - - - ); -}; - -Sidebar.Header = SidebarHeaderComponents.Component; + return ( + + + + + {children} + + + + ); + }, + ), + { + Header: SidebarHeaderComponents.Component, + }, +);