diff --git a/.env b/.env index c3d24015..a4089e79 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ -# dev values +REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com +REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries + REACT_APP_SOCKET_SERVER_URL=http://localhost:3000 REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' diff --git a/.env.production b/.env.production index 248bb347..08022700 100644 --- a/.env.production +++ b/.env.production @@ -1,6 +1,11 @@ REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ -REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 +REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com +REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries + REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' + +# production-only vars +REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 diff --git a/src/actions/actionAddToLibrary.ts b/src/actions/actionAddToLibrary.ts index c952783c..57eacc0c 100644 --- a/src/actions/actionAddToLibrary.ts +++ b/src/actions/actionAddToLibrary.ts @@ -2,22 +2,46 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; import { deepCopyElement } from "../element/newElement"; +import { randomId } from "../random"; +import { t } from "../i18n"; export const actionAddToLibrary = register({ name: "addToLibrary", perform: (elements, appState, _, app) => { - const selectedElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); - - app.library.loadLibrary().then((items) => { - app.library.saveLibrary([ - ...items, - selectedElements.map(deepCopyElement), - ]); - }); - return false; + return app.library + .loadLibrary() + .then((items) => { + return app.library.saveLibrary([ + { + id: randomId(), + status: "unpublished", + elements: getSelectedElements( + getNonDeletedElements(elements), + appState, + ).map(deepCopyElement), + created: Date.now(), + }, + ...items, + ]); + }) + .then(() => { + return { + commitToHistory: false, + appState: { + ...appState, + toastMessage: t("toast.addedToLibrary"), + }, + }; + }) + .catch((error) => { + return { + commitToHistory: false, + appState: { + ...appState, + errorMessage: error.message, + }, + }; + }); }, contextItemLabel: "labels.addToLibrary", }); diff --git a/src/align.ts b/src/align.ts index 745cd13b..622e5c48 100644 --- a/src/align.ts +++ b/src/align.ts @@ -1,13 +1,6 @@ import { ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; -import { getCommonBounds } from "./element"; - -interface Box { - minX: number; - minY: number; - maxX: number; - maxY: number; -} +import { Box, getCommonBoundingBox } from "./element/bounds"; export interface Alignment { position: "start" | "center" | "end"; @@ -88,8 +81,3 @@ const calculateTranslation = ( (groupBoundingBox[min] + groupBoundingBox[max]) / 2, }; }; - -const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => { - const [minX, minY, maxX, maxY] = getCommonBounds(elements); - return { minX, minY, maxX, maxY }; -}; diff --git a/src/components/App.tsx b/src/components/App.tsx index 734021e7..d4a54c28 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -72,7 +72,7 @@ import { import { loadFromBlob } from "../data"; import { isValidLibrary } from "../data/json"; import Library from "../data/library"; -import { restore, restoreElements } from "../data/restore"; +import { restore, restoreElements, restoreLibraryItems } from "../data/restore"; import { dragNewElement, dragSelectedElements, @@ -658,7 +658,7 @@ class App extends React.Component { t("alerts.confirmAddLibrary", { numShapes: json.library.length }), ) ) { - await this.library.importLibrary(blob); + await this.library.importLibrary(blob, "published"); // hack to rerender the library items after import if (this.state.isLibraryOpen) { this.setState({ isLibraryOpen: false }); @@ -732,7 +732,10 @@ class App extends React.Component { try { initialData = (await this.props.initialData) || null; if (initialData?.libraryItems) { - this.libraryItemsFromStorage = initialData.libraryItems; + this.libraryItemsFromStorage = restoreLibraryItems( + initialData.libraryItems, + "unpublished", + ) as LibraryItems; } } catch (error: any) { console.error(error); diff --git a/src/components/CheckboxItem.tsx b/src/components/CheckboxItem.tsx index 41c28b86..a1fe246e 100644 --- a/src/components/CheckboxItem.tsx +++ b/src/components/CheckboxItem.tsx @@ -7,10 +7,11 @@ import "./CheckboxItem.scss"; export const CheckboxItem: React.FC<{ checked: boolean; onChange: (checked: boolean) => void; -}> = ({ children, checked, onChange }) => { + className?: string; +}> = ({ children, checked, onChange, className }) => { return (
{ onChange(!checked); ( diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 022e1ae6..cd7573d5 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -18,7 +18,9 @@ export interface DialogProps { title: React.ReactNode; autofocus?: boolean; theme?: AppState["theme"]; + closeOnClickOutside?: boolean; } + export const Dialog = (props: DialogProps) => { const [islandNode, setIslandNode] = useCallbackRefState(); const [lastActiveElement] = useState(document.activeElement); @@ -82,6 +84,7 @@ export const Dialog = (props: DialogProps) => { maxWidth={props.small ? 550 : 800} onCloseRequest={onClose} theme={props.theme} + closeOnClickOutside={props.closeOnClickOutside} >

diff --git a/src/components/LayerUI.scss b/src/components/LayerUI.scss index e018ec82..5b1ce6d3 100644 --- a/src/components/LayerUI.scss +++ b/src/components/LayerUI.scss @@ -1,42 +1,6 @@ @import "open-color/open-color"; .excalidraw { - .layer-ui__library { - margin: auto; - display: flex; - align-items: center; - justify-content: center; - - .layer-ui__library-header { - display: flex; - align-items: center; - width: 100%; - margin: 2px 0; - - button { - // 2px from the left to account for focus border of left-most button - margin: 0 2px; - } - - a { - margin-inline-start: auto; - // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra - padding-inline-end: 18px; - white-space: nowrap; - } - } - } - - .layer-ui__library-message { - padding: 10px 20px; - max-width: 200px; - } - - .layer-ui__library-items { - max-height: 50vh; - overflow: auto; - } - .layer-ui__wrapper { z-index: var(--zIndex-layerUI); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 0ed16341..9f65f97d 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -1,29 +1,15 @@ import clsx from "clsx"; -import React, { - RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import React, { useCallback } from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES } from "../constants"; import { exportCanvas } from "../data"; -import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import { useIsMobile } from "../components/App"; import { calculateScrollCenter, getSelectedElements } from "../scene"; import { ExportType } from "../scene/types"; -import { - AppProps, - AppState, - ExcalidrawProps, - BinaryFiles, - LibraryItem, - LibraryItems, -} from "../types"; +import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { muteFSAbortError } from "../utils"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; @@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { FixedSideContainer } from "./FixedSideContainer"; import { HintViewer } from "./HintViewer"; -import { exportFile, load, trash } from "./icons"; import { Island } from "./Island"; import "./LayerUI.scss"; -import { LibraryUnit } from "./LibraryUnit"; import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; @@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; -import { ToolButton } from "./ToolButton"; import { Tooltip } from "./Tooltip"; import { UserList } from "./UserList"; import Library from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; import { LibraryButton } from "./LibraryButton"; import { isImageFileHandle } from "../data/blob"; +import { LibraryMenu } from "./LibraryMenu"; interface LayerUIProps { actionManager: ActionManager; @@ -81,302 +65,6 @@ interface LayerUIProps { onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; } -const useOnClickOutside = ( - ref: RefObject, - cb: (event: MouseEvent) => void, -) => { - useEffect(() => { - const listener = (event: MouseEvent) => { - if (!ref.current) { - return; - } - - if ( - event.target instanceof Element && - (ref.current.contains(event.target) || - !document.body.contains(event.target)) - ) { - return; - } - - cb(event); - }; - document.addEventListener("pointerdown", listener, false); - - return () => { - document.removeEventListener("pointerdown", listener); - }; - }, [ref, cb]); -}; - -const LibraryMenuItems = ({ - libraryItems, - onRemoveFromLibrary, - onAddToLibrary, - onInsertShape, - pendingElements, - theme, - setAppState, - setLibraryItems, - libraryReturnUrl, - focusContainer, - library, - files, - id, -}: { - libraryItems: LibraryItems; - pendingElements: LibraryItem; - onRemoveFromLibrary: (index: number) => void; - onInsertShape: (elements: LibraryItem) => void; - onAddToLibrary: (elements: LibraryItem) => void; - theme: AppState["theme"]; - files: BinaryFiles; - setAppState: React.Component["setState"]; - setLibraryItems: (library: LibraryItems) => void; - libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; - focusContainer: () => void; - library: Library; - id: string; -}) => { - const isMobile = useIsMobile(); - const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0); - const CELLS_PER_ROW = isMobile ? 4 : 6; - const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW)); - const rows = []; - let addedPendingElements = false; - - const referrer = - libraryReturnUrl || window.location.origin + window.location.pathname; - - rows.push( -
- { - importLibraryFromJSON(library) - .then(() => { - // Close and then open to get the libraries updated - setAppState({ isLibraryOpen: false }); - setAppState({ isLibraryOpen: true }); - }) - .catch(muteFSAbortError) - .catch((error) => { - setAppState({ errorMessage: error.message }); - }); - }} - /> - {!!libraryItems.length && ( - <> - { - saveLibraryAsJSON(library) - .catch(muteFSAbortError) - .catch((error) => { - setAppState({ errorMessage: error.message }); - }); - }} - /> - { - if (window.confirm(t("alerts.resetLibrary"))) { - library.resetLibrary(); - setLibraryItems([]); - focusContainer(); - } - }} - /> - - )} - - {t("labels.libraries")} - -
, - ); - - for (let row = 0; row < numRows; row++) { - const y = CELLS_PER_ROW * row; - const children = []; - for (let x = 0; x < CELLS_PER_ROW; x++) { - const shouldAddPendingElements: boolean = - pendingElements.length > 0 && - !addedPendingElements && - y + x >= libraryItems.length; - addedPendingElements = addedPendingElements || shouldAddPendingElements; - - children.push( - - - , - ); - } - rows.push( - - {children} - , - ); - } - - return ( - - {rows} - - ); -}; - -const LibraryMenu = ({ - onClickOutside, - onInsertShape, - pendingElements, - onAddToLibrary, - theme, - setAppState, - files, - libraryReturnUrl, - focusContainer, - library, - id, -}: { - pendingElements: LibraryItem; - onClickOutside: (event: MouseEvent) => void; - onInsertShape: (elements: LibraryItem) => void; - onAddToLibrary: () => void; - theme: AppState["theme"]; - files: BinaryFiles; - setAppState: React.Component["setState"]; - libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; - focusContainer: () => void; - library: Library; - id: string; -}) => { - const ref = useRef(null); - useOnClickOutside(ref, (event) => { - // If click on the library icon, do nothing. - if ((event.target as Element).closest(".ToolIcon_type_button__library")) { - return; - } - onClickOutside(event); - }); - - const [libraryItems, setLibraryItems] = useState([]); - - const [loadingState, setIsLoading] = useState< - "preloading" | "loading" | "ready" - >("preloading"); - - const loadingTimerRef = useRef(null); - - useEffect(() => { - Promise.race([ - new Promise((resolve) => { - loadingTimerRef.current = window.setTimeout(() => { - resolve("loading"); - }, 100); - }), - library.loadLibrary().then((items) => { - setLibraryItems(items); - setIsLoading("ready"); - }), - ]).then((data) => { - if (data === "loading") { - setIsLoading("loading"); - } - }); - return () => { - clearTimeout(loadingTimerRef.current!); - }; - }, [library]); - - const removeFromLibrary = useCallback( - async (indexToRemove) => { - const items = await library.loadLibrary(); - const nextItems = items.filter((_, index) => index !== indexToRemove); - library.saveLibrary(nextItems).catch((error) => { - setLibraryItems(items); - setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); - }); - setLibraryItems(nextItems); - }, - [library, setAppState], - ); - - const addToLibrary = useCallback( - async (elements: LibraryItem) => { - if (elements.some((element) => element.type === "image")) { - return setAppState({ - errorMessage: "Support for adding images to the library coming soon!", - }); - } - - const items = await library.loadLibrary(); - const nextItems = [...items, elements]; - onAddToLibrary(); - library.saveLibrary(nextItems).catch((error) => { - setLibraryItems(items); - setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); - }); - setLibraryItems(nextItems); - }, - [onAddToLibrary, library, setAppState], - ); - - return loadingState === "preloading" ? null : ( - - {loadingState === "loading" ? ( -
- {t("labels.libraryLoadingMessage")} -
- ) : ( - - )} -
- ); -}; - const LayerUI = ({ actionManager, appState, @@ -561,12 +249,15 @@ const LayerUI = ({ ); - const closeLibrary = useCallback( - (event) => { - setAppState({ isLibraryOpen: false }); - }, - [setAppState], - ); + const closeLibrary = useCallback(() => { + const isDialogOpen = !!document.querySelector(".Dialog"); + + // Prevent closing if any dialog is open + if (isDialogOpen) { + return; + } + setAppState({ isLibraryOpen: false }); + }, [setAppState]); const deselectItems = useCallback(() => { setAppState({ @@ -578,7 +269,7 @@ const LayerUI = ({ const libraryMenu = appState.isLibraryOpen ? ( ) : null; diff --git a/src/components/LibraryMenu.scss b/src/components/LibraryMenu.scss new file mode 100644 index 00000000..fbd784f7 --- /dev/null +++ b/src/components/LibraryMenu.scss @@ -0,0 +1,55 @@ +@import "open-color/open-color"; + +.excalidraw { + .layer-ui__library { + margin: auto; + display: flex; + align-items: center; + justify-content: center; + + .layer-ui__library-header { + display: flex; + align-items: center; + width: 100%; + margin: 2px 0; + + button { + // 2px from the left to account for focus border of left-most button + margin: 0 2px; + } + + a { + margin-inline-start: auto; + // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra + padding-inline-end: 18px; + white-space: nowrap; + } + } + } + + .layer-ui__library-message { + padding: 10px 20px; + max-width: 200px; + } + + .publish-library-success { + .Dialog__content { + display: flex; + flex-direction: column; + } + + &-close.ToolIcon_type_button { + background-color: $oc-blue-6; + align-self: flex-end; + &:hover { + background-color: $oc-blue-8; + } + .ToolIcon__icon { + width: auto; + font-size: 1rem; + color: $oc-white; + padding: 0 0.5rem; + } + } + } +} diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx new file mode 100644 index 00000000..26bec7ab --- /dev/null +++ b/src/components/LibraryMenu.tsx @@ -0,0 +1,287 @@ +import { useRef, useState, useEffect, useCallback, RefObject } from "react"; +import Library from "../data/library"; +import { t } from "../i18n"; +import { randomId } from "../random"; +import { + LibraryItems, + LibraryItem, + AppState, + BinaryFiles, + ExcalidrawProps, +} from "../types"; +import { Dialog } from "./Dialog"; +import { Island } from "./Island"; +import PublishLibrary from "./PublishLibrary"; +import { ToolButton } from "./ToolButton"; + +import "./LibraryMenu.scss"; +import LibraryMenuItems from "./LibraryMenuItems"; +import { EVENT } from "../constants"; +import { KEYS } from "../keys"; + +const useOnClickOutside = ( + ref: RefObject, + cb: (event: MouseEvent) => void, +) => { + useEffect(() => { + const listener = (event: MouseEvent) => { + if (!ref.current) { + return; + } + + if ( + event.target instanceof Element && + (ref.current.contains(event.target) || + !document.body.contains(event.target)) + ) { + return; + } + + cb(event); + }; + document.addEventListener("pointerdown", listener, false); + + return () => { + document.removeEventListener("pointerdown", listener); + }; + }, [ref, cb]); +}; + +const getSelectedItems = ( + libraryItems: LibraryItems, + selectedItems: LibraryItem["id"][], +) => libraryItems.filter((item) => selectedItems.includes(item.id)); + +export const LibraryMenu = ({ + onClose, + onInsertShape, + pendingElements, + onAddToLibrary, + theme, + setAppState, + files, + libraryReturnUrl, + focusContainer, + library, + id, + appState, +}: { + pendingElements: LibraryItem["elements"]; + onClose: () => void; + onInsertShape: (elements: LibraryItem["elements"]) => void; + onAddToLibrary: () => void; + theme: AppState["theme"]; + files: BinaryFiles; + setAppState: React.Component["setState"]; + libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + focusContainer: () => void; + library: Library; + id: string; + appState: AppState; +}) => { + const ref = useRef(null); + + useOnClickOutside(ref, (event) => { + // If click on the library icon, do nothing. + if ((event.target as Element).closest(".ToolIcon__library")) { + return; + } + onClose(); + }); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === KEYS.ESCAPE) { + onClose(); + } + }; + document.addEventListener(EVENT.KEYDOWN, handleKeyDown); + return () => { + document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); + }; + }, [onClose]); + + const [libraryItems, setLibraryItems] = useState([]); + + const [loadingState, setIsLoading] = useState< + "preloading" | "loading" | "ready" + >("preloading"); + const [selectedItems, setSelectedItems] = useState([]); + const [showPublishLibraryDialog, setShowPublishLibraryDialog] = + useState(false); + const [publishLibSuccess, setPublishLibSuccess] = useState(null); + const loadingTimerRef = useRef(null); + + useEffect(() => { + Promise.race([ + new Promise((resolve) => { + loadingTimerRef.current = window.setTimeout(() => { + resolve("loading"); + }, 100); + }), + library.loadLibrary().then((items) => { + setLibraryItems(items); + setIsLoading("ready"); + }), + ]).then((data) => { + if (data === "loading") { + setIsLoading("loading"); + } + }); + return () => { + clearTimeout(loadingTimerRef.current!); + }; + }, [library]); + + const removeFromLibrary = useCallback(async () => { + const items = await library.loadLibrary(); + + const nextItems = items.filter((item) => !selectedItems.includes(item.id)); + library.saveLibrary(nextItems).catch((error) => { + setLibraryItems(items); + setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); + }); + setSelectedItems([]); + setLibraryItems(nextItems); + }, [library, setAppState, selectedItems, setSelectedItems]); + + const resetLibrary = useCallback(() => { + library.resetLibrary(); + setLibraryItems([]); + focusContainer(); + }, [library, focusContainer]); + + const addToLibrary = useCallback( + async (elements: LibraryItem["elements"]) => { + if (elements.some((element) => element.type === "image")) { + return setAppState({ + errorMessage: "Support for adding images to the library coming soon!", + }); + } + const items = await library.loadLibrary(); + const nextItems: LibraryItems = [ + { + status: "unpublished", + elements, + id: randomId(), + created: Date.now(), + }, + ...items, + ]; + onAddToLibrary(); + library.saveLibrary(nextItems).catch((error) => { + setLibraryItems(items); + setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); + }); + setLibraryItems(nextItems); + }, + [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) => { + 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.saveLibrary(nextLibItems); + setLibraryItems(nextLibItems); + }, + [ + setShowPublishLibraryDialog, + setPublishLibSuccess, + libraryItems, + selectedItems, + library, + ], + ); + + return loadingState === "preloading" ? null : ( + + {showPublishLibraryDialog && ( + setShowPublishLibraryDialog(false)} + libraryItems={getSelectedItems(libraryItems, selectedItems)} + appState={appState} + onSuccess={onPublishLibSuccess} + onError={(error) => window.alert(error)} + updateItemsInStorage={() => library.saveLibrary(libraryItems)} + onRemove={(id: string) => + setSelectedItems(selectedItems.filter((_id) => _id !== id)) + } + /> + )} + {publishLibSuccess && renderPublishSuccess()} + + {loadingState === "loading" ? ( +
+ {t("labels.libraryLoadingMessage")} +
+ ) : ( + { + if (!selectedItems.includes(id)) { + setSelectedItems([...selectedItems, id]); + } else { + setSelectedItems(selectedItems.filter((_id) => _id !== id)); + } + }} + onPublish={() => setShowPublishLibraryDialog(true)} + resetLibrary={resetLibrary} + /> + )} +
+ ); +}; diff --git a/src/components/LibraryMenuItems.scss b/src/components/LibraryMenuItems.scss new file mode 100644 index 00000000..d54baab5 --- /dev/null +++ b/src/components/LibraryMenuItems.scss @@ -0,0 +1,102 @@ +@import "open-color/open-color"; + +.excalidraw { + .library-menu-items-container { + .library-actions { + display: flex; + + 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 { + max-height: 50vh; + overflow: auto; + margin-top: 0.5rem; + } + + .separator { + font-weight: 500; + font-size: 0.9rem; + margin: 0.6em 0.2em; + color: var(--text-primary-color); + } + } +} diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx new file mode 100644 index 00000000..174df240 --- /dev/null +++ b/src/components/LibraryMenuItems.tsx @@ -0,0 +1,322 @@ +import { chunk } from "lodash"; +import { useCallback, useState } from "react"; +import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; +import Library from "../data/library"; +import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { t } from "../i18n"; +import { + AppState, + BinaryFiles, + ExcalidrawProps, + LibraryItem, + LibraryItems, +} from "../types"; +import { muteFSAbortError } from "../utils"; +import { useIsMobile } from "./App"; +import ConfirmDialog from "./ConfirmDialog"; +import { exportToFileIcon, load, publishIcon, trash } from "./icons"; +import { LibraryUnit } from "./LibraryUnit"; +import Stack from "./Stack"; +import { ToolButton } from "./ToolButton"; +import { Tooltip } from "./Tooltip"; + +import "./LibraryMenuItems.scss"; + +const LibraryMenuItems = ({ + libraryItems, + onRemoveFromLibrary, + onAddToLibrary, + onInsertShape, + pendingElements, + theme, + setAppState, + libraryReturnUrl, + library, + files, + id, + selectedItems, + onToggle, + onPublish, + resetLibrary, +}: { + libraryItems: LibraryItems; + pendingElements: LibraryItem["elements"]; + onRemoveFromLibrary: () => void; + onInsertShape: (elements: LibraryItem["elements"]) => void; + onAddToLibrary: (elements: LibraryItem["elements"]) => void; + theme: AppState["theme"]; + files: BinaryFiles; + setAppState: React.Component["setState"]; + libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + library: Library; + id: string; + selectedItems: LibraryItem["id"][]; + onToggle: (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 isMobile = useIsMobile(); + + 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 || !isMobile) && ( + { + importLibraryFromJSON(library) + .then(() => { + // Close and then open to get the libraries updated + setAppState({ isLibraryOpen: false }); + setAppState({ isLibraryOpen: true }); + }) + .catch(muteFSAbortError) + .catch((error) => { + setAppState({ errorMessage: error.message }); + }); + }} + className="library-actions--load" + /> + )} + {!!items.length && ( + <> + { + const libraryItems = itemsSelected + ? items + : await library.loadLibrary(); + 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 && !isPublished && ( + + + {!isMobile && } + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} + + + )} +
+ ); + }; + + const CELLS_PER_ROW = isMobile ? 4 : 6; + + const referrer = + libraryReturnUrl || window.location.origin + window.location.pathname; + const isPublished = selectedItems.some( + (id) => libraryItems.find((item) => item.id === id)?.status === "published", + ); + + const createLibraryItemCompo = (params: { + item: + | LibraryItem + | /* pending library item */ { + id: null; + elements: readonly NonDeleted[]; + } + | null; + onClick?: () => void; + key: string; + }) => { + return ( + + {})} + id={params.item?.id || null} + selected={!!params.item?.id && selectedItems.includes(params.item.id)} + onToggle={() => { + if (params.item?.id) { + onToggle(params.item.id); + } + }} + /> + + ); + }; + + const renderLibrarySection = ( + items: ( + | LibraryItem + | /* pending library item */ { + id: null; + elements: readonly NonDeleted[]; + } + )[], + ) => { + const _items = items.map((item) => { + if (item.id) { + return createLibraryItemCompo({ + item, + onClick: () => onInsertShape(item.elements), + key: item.id, + }); + } + return createLibraryItemCompo({ + key: "__pending__item__", + item, + onClick: () => onAddToLibrary(pendingElements), + }); + }); + + // ensure we render all empty cells if no items are present + let rows = chunk(_items, CELLS_PER_ROW); + if (!rows.length) { + rows = [[]]; + } + + return rows.map((rowItems, index, rows) => { + if (index === rows.length - 1) { + // pad row with empty cells + rowItems = rowItems.concat( + new Array(CELLS_PER_ROW - rowItems.length) + .fill(null) + .map((_, index) => { + return createLibraryItemCompo({ + key: `empty_${index}`, + item: null, + }); + }), + ); + } + return ( + + {rowItems} + + ); + }); + }; + + const publishedItems = libraryItems.filter( + (item) => item.status === "published", + ); + const unpublishedItems = [ + // append pending library item + ...(pendingElements.length + ? [{ id: null, elements: pendingElements }] + : []), + ...libraryItems.filter((item) => item.status !== "published"), + ]; + + return ( +
+ {showRemoveLibAlert && renderRemoveLibAlert()} +
+ {renderLibraryActions()} + + {t("labels.libraries")} + +
+ + <> +
{t("labels.personalLib")}
+ {renderLibrarySection(unpublishedItems)} + + + <> +
{t("labels.excalidrawLib")}
+ + {renderLibrarySection(publishedItems)} + +
+
+ ); +}; + +export default LibraryMenuItems; diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index 4b9b5208..f4e81848 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -1,3 +1,5 @@ +@import "../css/variables.module"; + .excalidraw { .library-unit { align-items: center; @@ -7,6 +9,20 @@ position: relative; width: 63px; height: 63px; // match width + + &--hover { + box-shadow: inset 0px 0px 0px 2px $oc-blue-5; + border-color: $oc-blue-5; + } + + &--selected { + box-shadow: inset 0px 0px 0px 2px $oc-blue-8; + border-color: $oc-blue-8; + } + } + + &.theme--dark .library-unit { + border-color: rgb(48, 48, 48); } .library-unit__dragger { @@ -22,9 +38,9 @@ max-width: 100%; } - .library-unit__removeFromLibrary, - .library-unit__removeFromLibrary:hover, - .library-unit__removeFromLibrary:active { + .library-unit__checkbox-container, + .library-unit__checkbox-container:hover, + .library-unit__checkbox-container:active { align-items: center; background: none; border: none; @@ -32,10 +48,35 @@ display: flex; justify-content: center; margin: 0; - padding: 0; + padding: 0.5rem; position: absolute; - right: 5px; - top: 5px; + left: 2rem; + bottom: 2rem; + cursor: pointer; + + input { + cursor: pointer; + } + } + + .library-unit__checkbox { + position: absolute; + left: 2.3rem; + bottom: 2.3rem; + + .Checkbox-box { + width: 13px; + height: 13px; + border-radius: 2px; + margin: 0.5em 0.5em 0.2em 0.2em; + background-color: $oc-blue-1; + } + + &.Checkbox:hover { + .Checkbox-box { + background-color: $oc-blue-2; + } + } } .library-unit__removeFromLibrary > svg { @@ -43,29 +84,32 @@ width: 16px; } - .library-unit__pulse { + .library-unit__adder { transform: scale(1); - animation: library-unit__pulse-animation 1s ease-in infinite; + animation: library-unit__adder-animation 1s ease-in infinite; } .library-unit__adder { position: absolute; - left: 50%; - top: 50%; - width: 20px; - height: 20px; + left: 40%; + top: 40%; + width: 2rem; + height: 2rem; margin-left: -10px; margin-top: -10px; pointer-events: none; } + .library-unit--hover .library-unit__adder { + color: $oc-blue-7; + } .library-unit__active { cursor: pointer; } - @keyframes library-unit__pulse-animation { + @keyframes library-unit__adder-animation { 0% { - transform: scale(0.95); + transform: scale(0.85); } 50% { @@ -73,7 +117,7 @@ } 100% { - transform: scale(0.95); + transform: scale(0.85); } } } diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 5b22762c..f12c5caf 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,13 +1,12 @@ import clsx from "clsx"; import oc from "open-color"; import { useEffect, useRef, useState } from "react"; -import { close } from "../components/icons"; import { MIME_TYPES } from "../constants"; -import { t } from "../i18n"; import { useIsMobile } from "../components/App"; import { exportToSvg } from "../scene/export"; import { BinaryFiles, LibraryItem } from "../types"; import "./LibraryUnit.scss"; +import { CheckboxItem } from "./CheckboxItem"; // fa-plus const PLUS_ICON = ( @@ -20,17 +19,21 @@ const PLUS_ICON = ( ); export const LibraryUnit = ({ + id, elements, files, - pendingElements, - onRemoveFromLibrary, + isPending, onClick, + selected, + onToggle, }: { - elements?: LibraryItem; + id: LibraryItem["id"] | /** for pending item */ null; + elements?: LibraryItem["elements"]; files: BinaryFiles; - pendingElements?: LibraryItem; - onRemoveFromLibrary: () => void; + isPending?: boolean; onClick: () => void; + selected: boolean; + onToggle: (id: string) => void; }) => { const ref = useRef(null); useEffect(() => { @@ -40,12 +43,11 @@ export const LibraryUnit = ({ } (async () => { - const elementsToRender = elements || pendingElements; - if (!elementsToRender) { + if (!elements) { return; } const svg = await exportToSvg( - elementsToRender, + elements, { exportBackground: false, viewBackgroundColor: oc.white, @@ -58,30 +60,31 @@ export const LibraryUnit = ({ return () => { node.innerHTML = ""; }; - }, [elements, pendingElements, files]); + }, [elements, files]); const [isHovered, setIsHovered] = useState(false); const isMobile = useIsMobile(); - - const adder = (isHovered || isMobile) && pendingElements && ( + const adder = isPending && (
{PLUS_ICON}
); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} >
{ setIsHovered(false); event.dataTransfer.setData( @@ -91,14 +94,12 @@ export const LibraryUnit = ({ }} /> {adder} - {elements && (isHovered || isMobile) && ( - + {id && elements && (isHovered || isMobile || selected) && ( + onToggle(id)} + className="library-unit__checkbox" + /> )}
); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index c78b4eb7..54baa798 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -15,8 +15,9 @@ export const Modal = (props: { onCloseRequest(): void; labelledBy: string; theme?: AppState["theme"]; + closeOnClickOutside?: boolean; }) => { - const { theme = THEME.LIGHT } = props; + const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; const modalRoot = useBodyRoot(theme); if (!modalRoot) { @@ -39,7 +40,10 @@ export const Modal = (props: { onKeyDown={handleKeydown} aria-labelledby={props.labelledBy} > -
+
void; setAppState: React.Component["setState"]; - onInsertChart: (elements: LibraryItem) => void; + onInsertChart: (elements: LibraryItem["elements"]) => void; }) => { const handleClose = React.useCallback(() => { if (onClose) { diff --git a/src/components/ProjectName.tsx b/src/components/ProjectName.tsx index 411cd554..db2b7002 100644 --- a/src/components/ProjectName.tsx +++ b/src/components/ProjectName.tsx @@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => { {props.isNameEditable ? ( { + try { + localStorage.setItem( + LOCAL_STORAGE_KEY_PUBLISH_LIBRARY, + JSON.stringify(data), + ); + } catch (error: any) { + // Unable to access window.localStorage + console.error(error); + } +}; + +const importPublishLibDataFromStorage = () => { + try { + const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); + if (data) { + return JSON.parse(data); + } + } catch (error: any) { + // Unable to access localStorage + console.error(error); + } + + return null; +}; + +const PublishLibrary = ({ + onClose, + libraryItems, + appState, + onSuccess, + onError, + updateItemsInStorage, + onRemove, +}: { + onClose: () => void; + libraryItems: LibraryItems; + appState: AppState; + onSuccess: (data: { + url: string; + authorName: string; + items: LibraryItems; + }) => void; + + onError: (error: Error) => void; + updateItemsInStorage: (items: LibraryItems) => void; + onRemove: (id: string) => void; +}) => { + const [libraryData, setLibraryData] = useState({ + authorName: "", + githubHandle: "", + name: "", + description: "", + twitterHandle: "", + website: "", + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const data = importPublishLibDataFromStorage(); + if (data) { + setLibraryData(data); + } + }, []); + + const [clonedLibItems, setClonedLibItems] = useState( + libraryItems.slice(), + ); + + useEffect(() => { + setClonedLibItems(libraryItems.slice()); + }, [libraryItems]); + + const onInputChange = (event: any) => { + setLibraryData({ + ...libraryData, + [event.target.name]: event.target.value, + }); + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + const erroredLibItems: LibraryItem[] = []; + let isError = false; + clonedLibItems.forEach((libItem) => { + let error = ""; + if (!libItem.name) { + error = t("publishDialog.errors.required"); + isError = true; + } else if (!/^[a-zA-Z\s]+$/i.test(libItem.name)) { + error = t("publishDialog.errors.letter&Spaces"); + isError = true; + } + erroredLibItems.push({ ...libItem, error }); + }); + + if (isError) { + setClonedLibItems(erroredLibItems); + setIsSubmitting(false); + return; + } + const elements: ExcalidrawElement[] = []; + const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + clonedLibItems.forEach((libItem) => { + const boundingBox = getCommonBoundingBox(libItem.elements); + const width = boundingBox.maxX - boundingBox.minX + 30; + const height = boundingBox.maxY - boundingBox.minY + 30; + const offset = { + x: prevBoundingBox.maxX - boundingBox.minX, + y: prevBoundingBox.maxY - boundingBox.minY, + }; + + const itemsWithUpdatedCoords = libItem.elements.map((element) => { + element = mutateElement(element, { + x: element.x + offset.x + 15, + y: element.y + offset.y + 15, + }); + return element; + }); + const items = [ + ...itemsWithUpdatedCoords, + newElement({ + type: "rectangle", + width, + height, + x: prevBoundingBox.maxX, + y: prevBoundingBox.maxY, + strokeColor: "#ced4da", + backgroundColor: "transparent", + strokeStyle: "solid", + opacity: 100, + roughness: 0, + strokeSharpness: "sharp", + fillStyle: "solid", + strokeWidth: 1, + }), + ]; + elements.push(...items); + prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30; + }); + const png = await exportToBlob({ + elements, + mimeType: "image/png", + appState: { + ...appState, + viewBackgroundColor: oc.white, + exportBackground: true, + }, + files: null, + }); + + const libContent: ExportedLibraryData = { + type: EXPORT_DATA_TYPES.excalidrawLibrary, + version: 2, + source: EXPORT_SOURCE, + libraryItems: clonedLibItems, + }; + const content = JSON.stringify(libContent, null, 2); + const lib = new Blob([content], { type: "application/json" }); + + const formData = new FormData(); + formData.append("excalidrawLib", lib); + formData.append("excalidrawPng", png!); + formData.append("title", libraryData.name); + formData.append("authorName", libraryData.authorName); + formData.append("githubHandle", libraryData.githubHandle); + formData.append("name", libraryData.name); + formData.append("description", libraryData.description); + formData.append("twitterHandle", libraryData.twitterHandle); + formData.append("website", libraryData.website); + + fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, { + method: "post", + body: formData, + }) + .then( + (response) => { + if (response.ok) { + return response.json().then(({ url }) => { + // flush data from local storage + localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); + onSuccess({ + url, + authorName: libraryData.authorName, + items: clonedLibItems, + }); + }); + } + return response + .json() + .catch(() => { + throw new Error(response.statusText || "something went wrong"); + }) + .then((error) => { + throw new Error( + error.message || response.statusText || "something went wrong", + ); + }); + }, + (err) => { + console.error(err); + onError(err); + setIsSubmitting(false); + }, + ) + .catch((err) => { + console.error(err); + onError(err); + setIsSubmitting(false); + }); + }; + + const renderLibraryItems = () => { + const items: ReactNode[] = []; + clonedLibItems.forEach((libItem, index) => { + items.push( +
+ { + const items = clonedLibItems.slice(); + items[index].name = val; + setClonedLibItems(items); + }} + onRemove={onRemove} + /> +
, + ); + }); + return
{items}
; + }; + + const onDialogClose = useCallback(() => { + updateItemsInStorage(clonedLibItems); + savePublishLibDataToStorage(libraryData); + onClose(); + }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]); + + const shouldRenderForm = !!libraryItems.length; + return ( + + {shouldRenderForm ? ( +
+
+ {t("publishDialog.noteDescription.pre")} + + {t("publishDialog.noteDescription.link")} + {" "} + {t("publishDialog.noteDescription.post")} +
+ + {t("publishDialog.noteGuidelines.pre")} + + {t("publishDialog.noteGuidelines.link")} + + {t("publishDialog.noteGuidelines.post")} + + +
+ {t("publishDialog.noteItems")} +
+ {renderLibraryItems()} +
+ +