feat: render library into Sidebar
on mobile (#5774)
This commit is contained in:
parent
e9067de173
commit
941b2d7042
@ -1,12 +1,12 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback } from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
@ -26,7 +26,7 @@ import { Section } from "./Section";
|
|||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
@ -40,7 +40,7 @@ import { useDevice } from "../components/App";
|
|||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import { hostSidebarCountersAtom, Sidebar } from "./Sidebar/Sidebar";
|
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
@ -247,42 +247,6 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
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" ? (
|
|
||||||
<LibraryMenu
|
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
|
||||||
onClose={closeLibrary}
|
|
||||||
onInsertLibraryItems={(libraryItems) => {
|
|
||||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
|
||||||
}}
|
|
||||||
onAddToLibrary={deselectItems}
|
|
||||||
setAppState={setAppState}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
focusContainer={focusContainer}
|
|
||||||
library={library}
|
|
||||||
files={files}
|
|
||||||
id={id}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
appState,
|
appState,
|
||||||
@ -381,6 +345,21 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderSidebars = () => {
|
||||||
|
return appState.openSidebar === "customSidebar" ? (
|
||||||
|
renderCustomSidebar?.() || null
|
||||||
|
) : appState.openSidebar === "library" ? (
|
||||||
|
<LibraryMenu
|
||||||
|
appState={appState}
|
||||||
|
onInsertElements={onInsertElements}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
focusContainer={focusContainer}
|
||||||
|
library={library}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -416,7 +395,6 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
libraryMenu={libraryMenu}
|
|
||||||
renderJSONExportDialog={renderJSONExportDialog}
|
renderJSONExportDialog={renderJSONExportDialog}
|
||||||
renderImageExportDialog={renderImageExportDialog}
|
renderImageExportDialog={renderImageExportDialog}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
@ -429,7 +407,7 @@ const LayerUI = ({
|
|||||||
onImageAction={onImageAction}
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
renderCustomSidebar={renderCustomSidebar}
|
renderSidebars={renderSidebars}
|
||||||
device={device}
|
device={device}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -484,26 +462,7 @@ const LayerUI = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{appState.openSidebar === "customSidebar" ? (
|
{renderSidebars()}
|
||||||
renderCustomSidebar?.()
|
|
||||||
) : appState.openSidebar === "library" ? (
|
|
||||||
<Sidebar
|
|
||||||
__isInternal
|
|
||||||
// necessary to remount when switching between internal
|
|
||||||
// and custom (host app) sidebar, so that the `props.onClose`
|
|
||||||
// is colled correctly
|
|
||||||
key="library"
|
|
||||||
onDock={(docked) => {
|
|
||||||
trackEvent(
|
|
||||||
"library",
|
|
||||||
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
|
||||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{libraryMenu}
|
|
||||||
</Sidebar>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.layer-ui__library-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-ui__library {
|
.layer-ui__library {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
.layer-ui__library-header {
|
.layer-ui__library-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -23,16 +29,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
.layer-ui__sidebar {
|
||||||
.layer-ui__library {
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.library-menu-items-container {
|
.library-menu-items-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 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 {
|
.layer-ui__library-message {
|
||||||
padding: 2em 4em;
|
padding: 2em 4em;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
@ -6,29 +6,31 @@ import {
|
|||||||
RefObject,
|
RefObject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import Library, { libraryItemsAtom } from "../data/library";
|
import Library, {
|
||||||
|
distributeLibraryItemsOnSquareGrid,
|
||||||
|
libraryItemsAtom,
|
||||||
|
} from "../data/library";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||||
LibraryItems,
|
|
||||||
LibraryItem,
|
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawProps,
|
|
||||||
} from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import PublishLibrary from "./PublishLibrary";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import "./LibraryMenu.scss";
|
import "./LibraryMenu.scss";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { EVENT } from "../constants";
|
import { EVENT, VERSIONS } from "../constants";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import Spinner from "./Spinner";
|
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 = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@ -58,11 +60,6 @@ const useOnClickOutside = (
|
|||||||
}, [ref, cb]);
|
}, [ref, cb]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedItems = (
|
|
||||||
libraryItems: LibraryItems,
|
|
||||||
selectedItems: LibraryItem["id"][],
|
|
||||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
|
||||||
|
|
||||||
const LibraryMenuWrapper = forwardRef<
|
const LibraryMenuWrapper = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ children: React.ReactNode }
|
{ children: React.ReactNode }
|
||||||
@ -74,94 +71,34 @@ const LibraryMenuWrapper = forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LibraryMenu = ({
|
export const LibraryMenuContent = ({
|
||||||
onClose,
|
|
||||||
onInsertLibraryItems,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
setAppState,
|
setAppState,
|
||||||
files,
|
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
|
||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
appState,
|
appState,
|
||||||
|
selectedItems,
|
||||||
|
onSelectItems,
|
||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onClose: () => void;
|
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
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<LibraryItem["id"][]>([]);
|
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
|
||||||
useState(false);
|
|
||||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
|
||||||
url: string;
|
|
||||||
authorName: string;
|
|
||||||
}>(null);
|
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
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(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
@ -187,60 +124,12 @@ export const LibraryMenu = ({
|
|||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary, library, setAppState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPublishSuccess = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
|
||||||
title={t("publishSuccessDialog.title")}
|
|
||||||
className="publish-library-success"
|
|
||||||
small={true}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{t("publishSuccessDialog.content", {
|
|
||||||
authorName: publishLibSuccess!.authorName,
|
|
||||||
})}{" "}
|
|
||||||
<a
|
|
||||||
href={publishLibSuccess?.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishSuccessDialog.link")}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.close")}
|
|
||||||
aria-label={t("buttons.close")}
|
|
||||||
label={t("buttons.close")}
|
|
||||||
onClick={() => setPublishLibSuccess(null)}
|
|
||||||
data-testid="publish-library-success-close"
|
|
||||||
className="publish-library-success-close"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}, [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 (
|
if (
|
||||||
libraryItemsData.status === "loading" &&
|
libraryItemsData.status === "loading" &&
|
||||||
!libraryItemsData.isInitialized
|
!libraryItemsData.isInitialized
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper>
|
||||||
<div className="layer-ui__library-message">
|
<div className="layer-ui__library-message">
|
||||||
<Spinner size="2em" />
|
<Spinner size="2em" />
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
@ -250,51 +139,168 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper>
|
||||||
{showPublishLibraryDialog && (
|
|
||||||
<PublishLibrary
|
|
||||||
onClose={() => 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()}
|
|
||||||
<LibraryMenuItems
|
<LibraryMenuItems
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
isLoading={libraryItemsData.status === "loading"}
|
||||||
libraryItems={libraryItemsData.libraryItems}
|
libraryItems={libraryItemsData.libraryItems}
|
||||||
onRemoveFromLibrary={() =>
|
|
||||||
removeFromLibrary(libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onAddToLibrary={(elements) =>
|
onAddToLibrary={(elements) =>
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
onInsertLibraryItems={onInsertLibraryItems}
|
||||||
pendingElements={pendingElements}
|
pendingElements={pendingElements}
|
||||||
setAppState={setAppState}
|
|
||||||
appState={appState}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
library={library}
|
|
||||||
theme={appState.theme}
|
|
||||||
files={files}
|
|
||||||
id={id}
|
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
onSelectItems={onSelectItems}
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
|
||||||
resetLibrary={resetLibrary}
|
|
||||||
/>
|
/>
|
||||||
|
<a
|
||||||
|
className="library-menu-browse-button"
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${
|
||||||
|
appState.theme
|
||||||
|
}&version=${VERSIONS.excalidrawLibrary}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
</LibraryMenuWrapper>
|
</LibraryMenuWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<LibraryItem["id"][]>([]);
|
||||||
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(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 (
|
||||||
|
<Sidebar
|
||||||
|
__isInternal
|
||||||
|
// necessary to remount when switching between internal
|
||||||
|
// and custom (host app) sidebar, so that the `props.onClose`
|
||||||
|
// is colled correctly
|
||||||
|
key="library"
|
||||||
|
className="layer-ui__library-sidebar"
|
||||||
|
onDock={(docked) => {
|
||||||
|
trackEvent(
|
||||||
|
"library",
|
||||||
|
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
||||||
|
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Sidebar.Header className="layer-ui__library-header">
|
||||||
|
<LibraryMenuHeader
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={setSelectedItems}
|
||||||
|
library={library}
|
||||||
|
onRemoveFromLibrary={() =>
|
||||||
|
removeFromLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
</Sidebar.Header>
|
||||||
|
<LibraryMenuContent
|
||||||
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
|
onInsertLibraryItems={(libraryItems) => {
|
||||||
|
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||||
|
}}
|
||||||
|
onAddToLibrary={deselectItems}
|
||||||
|
setAppState={setAppState}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
library={library}
|
||||||
|
id={id}
|
||||||
|
appState={appState}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={setSelectedItems}
|
||||||
|
/>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
258
src/components/LibraryMenuHeaderContent.tsx
Normal file
258
src/components/LibraryMenuHeaderContent.tsx
Normal file
@ -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<any, AppState>["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 (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedItems.length) {
|
||||||
|
onRemoveFromLibrary();
|
||||||
|
} else {
|
||||||
|
resetLibrary();
|
||||||
|
}
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<p>{content}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}, [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 | {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
}>(null);
|
||||||
|
const renderPublishSuccess = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||||
|
title={t("publishSuccessDialog.title")}
|
||||||
|
className="publish-library-success"
|
||||||
|
small={true}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("publishSuccessDialog.content", {
|
||||||
|
authorName: publishLibSuccess!.authorName,
|
||||||
|
})}{" "}
|
||||||
|
<a
|
||||||
|
href={publishLibSuccess?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishSuccessDialog.link")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.close")}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
label={t("buttons.close")}
|
||||||
|
onClick={() => setPublishLibSuccess(null)}
|
||||||
|
data-testid="publish-library-success-close"
|
||||||
|
className="publish-library-success-close"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}, [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 (
|
||||||
|
<div className="library-actions">
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
{showPublishLibraryDialog && (
|
||||||
|
<PublishLibrary
|
||||||
|
onClose={() => 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 && (
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={onLibraryImport}
|
||||||
|
className="library-actions--load"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportToFileIcon}
|
||||||
|
onClick={onLibraryExport}
|
||||||
|
className="library-actions--export"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={resetLabel}
|
||||||
|
aria-label={resetLabel}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => setShowRemoveLibAlert(true)}
|
||||||
|
className="library-actions--remove"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{itemsSelected && (
|
||||||
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.publishLibrary")}
|
||||||
|
label={t("buttons.publishLibrary")}
|
||||||
|
icon={publishIcon}
|
||||||
|
className="library-actions--publish"
|
||||||
|
onClick={() => setShowPublishLibraryDialog(true)}
|
||||||
|
>
|
||||||
|
<label>{t("buttons.publishLibrary")}</label>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -6,93 +6,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
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 {
|
&__items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@ -1,223 +1,35 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
import { serializeLibraryAsJSON } from "../data/json";
|
||||||
import Library from "../data/library";
|
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import { LibraryItem, LibraryItems } from "../types";
|
||||||
AppState,
|
import { arrayToMap, chunk } from "../utils";
|
||||||
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 { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { fileOpen } from "../data/filesystem";
|
|
||||||
import { Sidebar } from "./Sidebar/Sidebar";
|
const CELLS_PER_ROW = 4;
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertLibraryItems,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
appState,
|
|
||||||
libraryReturnUrl,
|
|
||||||
library,
|
|
||||||
files,
|
|
||||||
id,
|
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onSelectItems,
|
onSelectItems,
|
||||||
onPublish,
|
|
||||||
resetLibrary,
|
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
appState: AppState;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
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 (
|
|
||||||
<ConfirmDialog
|
|
||||||
onConfirm={() => {
|
|
||||||
if (selectedItems.length) {
|
|
||||||
onRemoveFromLibrary();
|
|
||||||
} else {
|
|
||||||
resetLibrary();
|
|
||||||
}
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<p>{content}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
);
|
|
||||||
}, [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 (
|
|
||||||
<div className="library-actions">
|
|
||||||
{!itemsSelected && (
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={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") });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="library-actions--load"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!items.length && (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportToFileIcon}
|
|
||||||
onClick={async () => {
|
|
||||||
const libraryItems = itemsSelected
|
|
||||||
? items
|
|
||||||
: await library.getLatestLibrary();
|
|
||||||
saveLibraryAsJSON(libraryItems)
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="library-actions--export"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton
|
|
||||||
key="reset"
|
|
||||||
type="button"
|
|
||||||
title={resetLabel}
|
|
||||||
aria-label={resetLabel}
|
|
||||||
icon={trash}
|
|
||||||
onClick={() => setShowRemoveLibAlert(true)}
|
|
||||||
className="library-actions--remove"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{itemsSelected && (
|
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
aria-label={t("buttons.publishLibrary")}
|
|
||||||
label={t("buttons.publishLibrary")}
|
|
||||||
icon={publishIcon}
|
|
||||||
className="library-actions--publish"
|
|
||||||
onClick={onPublish}
|
|
||||||
>
|
|
||||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{device.isMobile && (
|
|
||||||
<div className="library-menu-browse-button--mobile">
|
|
||||||
<a
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
|
||||||
|
|
||||||
const referrer =
|
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
LibraryItem["id"] | null
|
LibraryItem["id"] | null
|
||||||
>(null);
|
>(null);
|
||||||
@ -294,7 +106,6 @@ const LibraryMenuItems = ({
|
|||||||
<Stack.Col key={params.key}>
|
<Stack.Col key={params.key}>
|
||||||
<LibraryUnit
|
<LibraryUnit
|
||||||
elements={params.item?.elements}
|
elements={params.item?.elements}
|
||||||
files={files}
|
|
||||||
isPending={!params.item?.id && !!params.item?.elements}
|
isPending={!params.item?.id && !!params.item?.elements}
|
||||||
onClick={params.onClick || (() => {})}
|
onClick={params.onClick || (() => {})}
|
||||||
id={params.item?.id || null}
|
id={params.item?.id || null}
|
||||||
@ -370,8 +181,21 @@ const LibraryMenuItems = ({
|
|||||||
(item) => item.status === "published",
|
(item) => item.status === "published",
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLibraryMenuItems = () => {
|
return (
|
||||||
return (
|
<div
|
||||||
|
className="library-menu-items-container"
|
||||||
|
style={
|
||||||
|
publishedItems.length || unpublishedItems.length
|
||||||
|
? {
|
||||||
|
flex: "1 1 0",
|
||||||
|
overflowY: "auto",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
marginBottom: "2rem",
|
||||||
|
flex: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
className="library-menu-items-container__items"
|
className="library-menu-items-container__items"
|
||||||
align="start"
|
align="start"
|
||||||
@ -443,8 +267,8 @@ const LibraryMenuItems = ({
|
|||||||
|
|
||||||
<>
|
<>
|
||||||
{(publishedItems.length > 0 ||
|
{(publishedItems.length > 0 ||
|
||||||
(!device.isMobile &&
|
pendingElements.length > 0 ||
|
||||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
unpublishedItems.length > 0) && (
|
||||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||||
)}
|
)}
|
||||||
{publishedItems.length > 0 ? (
|
{publishedItems.length > 0 ? (
|
||||||
@ -466,45 +290,6 @@ const LibraryMenuItems = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryFooter = () => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="library-menu-browse-button"
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="library-menu-items-container"
|
|
||||||
style={
|
|
||||||
device.isMobile
|
|
||||||
? {
|
|
||||||
minHeight: "200px",
|
|
||||||
maxHeight: "70vh",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
|
||||||
{/* NOTE using SidebarHeader here isn't semantic since this may render
|
|
||||||
outside of a sidebar, but for now it doesn't matter */}
|
|
||||||
<Sidebar.Header className="layer-ui__library-header">
|
|
||||||
{renderLibraryActions()}
|
|
||||||
</Sidebar.Header>
|
|
||||||
{renderLibraryMenuItems()}
|
|
||||||
{!device.isMobile && renderLibraryFooter()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,7 +3,7 @@ import oc from "open-color";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
|
|
||||||
@ -23,7 +23,6 @@ const PLUS_ICON = (
|
|||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
id,
|
id,
|
||||||
elements,
|
elements,
|
||||||
files,
|
|
||||||
isPending,
|
isPending,
|
||||||
onClick,
|
onClick,
|
||||||
selected,
|
selected,
|
||||||
@ -32,7 +31,6 @@ export const LibraryUnit = ({
|
|||||||
}: {
|
}: {
|
||||||
id: LibraryItem["id"] | /** for pending item */ null;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
elements?: LibraryItem["elements"];
|
elements?: LibraryItem["elements"];
|
||||||
files: BinaryFiles;
|
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@ -56,7 +54,7 @@ export const LibraryUnit = ({
|
|||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
},
|
},
|
||||||
files,
|
null,
|
||||||
);
|
);
|
||||||
node.innerHTML = svg.outerHTML;
|
node.innerHTML = svg.outerHTML;
|
||||||
})();
|
})();
|
||||||
@ -64,7 +62,7 @@ export const LibraryUnit = ({
|
|||||||
return () => {
|
return () => {
|
||||||
node.innerHTML = "";
|
node.innerHTML = "";
|
||||||
};
|
};
|
||||||
}, [elements, files]);
|
}, [elements]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().isMobile;
|
const isMobile = useDevice().isMobile;
|
||||||
|
@ -28,7 +28,6 @@ type MobileMenuProps = {
|
|||||||
renderImageExportDialog: () => React.ReactNode;
|
renderImageExportDialog: () => React.ReactNode;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
libraryMenu: JSX.Element | null;
|
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
@ -44,14 +43,13 @@ type MobileMenuProps = {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
renderSidebars: () => JSX.Element | null;
|
||||||
device: Device;
|
device: Device;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
libraryMenu,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
renderJSONExportDialog,
|
renderJSONExportDialog,
|
||||||
renderImageExportDialog,
|
renderImageExportDialog,
|
||||||
@ -65,7 +63,7 @@ export const MobileMenu = ({
|
|||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
renderCustomSidebar,
|
renderSidebars,
|
||||||
device,
|
device,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
@ -111,7 +109,6 @@ export const MobileMenu = ({
|
|||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu && <Island padding={2}>{libraryMenu}</Island>}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
@ -184,7 +181,7 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{appState.openSidebar === "customSidebar" && renderCustomSidebar?.()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
{!appState.openMenu && appState.showStats && (
|
{!appState.openMenu && appState.showStats && (
|
||||||
<Stats
|
<Stats
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import { Island } from ".././Island";
|
import { Island } from ".././Island";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
@ -19,103 +25,115 @@ import { updateObject } from "../../utils";
|
|||||||
* the host app may render (mount/unmount) multiple different sidebar */
|
* the host app may render (mount/unmount) multiple different sidebar */
|
||||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||||
|
|
||||||
export const Sidebar = ({
|
export const Sidebar = Object.assign(
|
||||||
children,
|
forwardRef(
|
||||||
onClose,
|
(
|
||||||
onDock,
|
{
|
||||||
docked,
|
children,
|
||||||
dockable = true,
|
onClose,
|
||||||
className,
|
onDock,
|
||||||
__isInternal,
|
docked,
|
||||||
}: SidebarProps<{
|
dockable = true,
|
||||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
className,
|
||||||
// It indicates that this sidebar should have lower precedence over host
|
__isInternal,
|
||||||
// sidebars, if both are open.
|
}: SidebarProps<{
|
||||||
/** @private internal */
|
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||||
__isInternal?: boolean;
|
// It indicates that this sidebar should have lower precedence over host
|
||||||
}>) => {
|
// sidebars, if both are open.
|
||||||
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
/** @private internal */
|
||||||
hostSidebarCountersAtom,
|
__isInternal?: boolean;
|
||||||
jotaiScope,
|
}>,
|
||||||
);
|
ref: React.ForwardedRef<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (docked === undefined) {
|
if (docked === undefined) {
|
||||||
// ugly hack to get initial state out of AppState without susbcribing
|
// 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 it as a whole (once we have granular subscriptions, we'll move
|
||||||
// to that)
|
// to that)
|
||||||
//
|
//
|
||||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||||
setAppState((state) => {
|
setAppState((state) => {
|
||||||
setIsDockedFallback(state.isSidebarDocked);
|
setIsDockedFallback(state.isSidebarDocked);
|
||||||
// bail from update
|
// bail from update
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [setAppState, docked]);
|
}, [setAppState, docked]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!__isInternal) {
|
if (!__isInternal) {
|
||||||
setHostSidebarCounters((s) => ({
|
setHostSidebarCounters((s) => ({
|
||||||
rendered: s.rendered + 1,
|
rendered: s.rendered + 1,
|
||||||
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
||||||
}));
|
}));
|
||||||
return () => {
|
return () => {
|
||||||
setHostSidebarCounters((s) => ({
|
setHostSidebarCounters((s) => ({
|
||||||
rendered: s.rendered - 1,
|
rendered: s.rendered - 1,
|
||||||
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
||||||
}));
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
||||||
|
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onCloseRef.current?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
||||||
|
headerPropsRef.current.onClose = () => {
|
||||||
|
setAppState({ openSidebar: null });
|
||||||
};
|
};
|
||||||
}
|
headerPropsRef.current.onDock = (isDocked) => {
|
||||||
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
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 <Sidebar.Header/> can be rendered upsream.
|
||||||
|
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||||
|
docked: docked ?? isDockedFallback,
|
||||||
|
dockable,
|
||||||
|
});
|
||||||
|
|
||||||
const onCloseRef = useRef(onClose);
|
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
||||||
onCloseRef.current = onClose;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
return () => {
|
<Island
|
||||||
onCloseRef.current?.();
|
padding={2}
|
||||||
};
|
className={clsx("layer-ui__sidebar", className)}
|
||||||
}, []);
|
ref={ref}
|
||||||
|
>
|
||||||
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||||
headerPropsRef.current.onClose = () => {
|
<SidebarHeaderComponents.Context>
|
||||||
setAppState({ openSidebar: null });
|
<SidebarHeaderComponents.Component __isFallback />
|
||||||
};
|
{children}
|
||||||
headerPropsRef.current.onDock = (isDocked) => {
|
</SidebarHeaderComponents.Context>
|
||||||
if (docked === undefined) {
|
</SidebarPropsContext.Provider>
|
||||||
setAppState({ isSidebarDocked: isDocked });
|
</Island>
|
||||||
setIsDockedFallback(isDocked);
|
);
|
||||||
}
|
},
|
||||||
onDock?.(isDocked);
|
),
|
||||||
};
|
{
|
||||||
// renew the ref object if the following props change since we want to
|
Header: SidebarHeaderComponents.Component,
|
||||||
// rerender. We can't pass down as component props manually because
|
},
|
||||||
// the <Sidebar.Header/> can be rendered upsream.
|
);
|
||||||
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
|
||||||
docked: docked ?? isDockedFallback,
|
|
||||||
dockable,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Island padding={2} className={clsx("layer-ui__sidebar", className)}>
|
|
||||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
|
||||||
<SidebarHeaderComponents.Context>
|
|
||||||
<SidebarHeaderComponents.Component __isFallback />
|
|
||||||
{children}
|
|
||||||
</SidebarHeaderComponents.Context>
|
|
||||||
</SidebarPropsContext.Provider>
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Sidebar.Header = SidebarHeaderComponents.Component;
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user