diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 82f826361cee..eb41d5dc8a61 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,10 +9,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js 20.x uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x - name: Install and lint run: | diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 0162d93a00f9..1e4d064c32d9 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -60,6 +60,8 @@ const LibraryMenuContent = memo( theme, selectedItems, onSelectItems, + libraryMenuTitle, + libraryMenuDescription, }: { pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; @@ -71,6 +73,8 @@ const LibraryMenuContent = memo( theme: UIAppState["theme"]; selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; + libraryMenuTitle: ExcalidrawProps["libraryMenuTitle"]; + libraryMenuDescription: ExcalidrawProps["libraryMenuDescription"]; }) => { const [libraryItemsData] = useAtom(libraryItemsAtom); @@ -144,6 +148,8 @@ const LibraryMenuContent = memo( theme={theme} onSelectItems={onSelectItems} selectedItems={selectedItems} + libraryMenuTitle={libraryMenuTitle} + libraryMenuDescription={libraryMenuDescription} /> {showBtn && ( { theme={appState.theme} selectedItems={selectedItems} onSelectItems={setSelectedItems} + libraryMenuTitle={appProps.libraryMenuTitle} + libraryMenuDescription={appProps.libraryMenuDescription} /> ); }); diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index bec31aa36646..4148c245fd67 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -2,14 +2,21 @@ import { useCallback, useState } from "react"; import { t } from "../i18n"; import Trans from "./Trans"; import { useAtom } from "../editor-jotai"; -import type { LibraryItem, LibraryItems, UIAppState } from "../types"; -import { useApp, useExcalidrawSetAppState } from "./App"; +import type { + ExcalidrawProps, + LibraryItem, + LibraryItems, + TaggedLibraryItem, + UIAppState, +} from "../types"; +import { useApp, useAppProps, useExcalidrawSetAppState } from "./App"; import { saveLibraryAsJSON } from "../data/json"; import type Library from "../data/library"; import { libraryItemsAtom } from "../data/library"; import { DotsIcon, ExportIcon, + LibraryIcon, LoadIcon, publishIcon, TrashIcon, @@ -31,6 +38,11 @@ const getSelectedItems = ( selectedItems: LibraryItem["id"][], ) => libraryItems.filter((item) => selectedItems.includes(item.id)); +// Items from a read-only source (a saved or organization library) carry a +// `libraryName`; only untagged "My library" items are writable. +const getWritableItems = (libraryItems: LibraryItems) => + libraryItems.filter((item) => !(item as TaggedLibraryItem).libraryName); + export const LibraryDropdownMenuButton: React.FC<{ setAppState: React.Component["setState"]; selectedItems: LibraryItem["id"][]; @@ -38,6 +50,8 @@ export const LibraryDropdownMenuButton: React.FC<{ onRemoveFromLibrary: () => void; resetLibrary: () => void; onSelectItems: (items: LibraryItem["id"][]) => void; + onSaveAs?: ExcalidrawProps["onLibrarySaveAs"]; + onSaveAsCanvasTemplate?: ExcalidrawProps["onSaveAsCanvasTemplate"]; appState: UIAppState; className?: string; }> = ({ @@ -47,6 +61,8 @@ export const LibraryDropdownMenuButton: React.FC<{ onRemoveFromLibrary, resetLibrary, onSelectItems, + onSaveAs, + onSaveAsCanvasTemplate, appState, className, }) => { @@ -90,6 +106,7 @@ export const LibraryDropdownMenuButton: React.FC<{ selectedItems.includes(item.id), ) : libraryItemsData.libraryItems; + const writableItemsCount = getWritableItems(items).length; const resetLabel = itemsSelected ? t("buttons.remove") : t("buttons.resetLibrary"); @@ -185,6 +202,36 @@ export const LibraryDropdownMenuButton: React.FC<{ }); }; + const onLibrarySaveAs = async () => { + if (!onSaveAs) { + return; + } + // Never save read-only source items into a new library — that would + // snapshot another library's content instead of referencing it live. + const libraryItems = getWritableItems( + itemsSelected ? items : await library.getLatestLibrary(), + ); + if (!libraryItems.length) { + return; + } + try { + await onSaveAs(libraryItems); + } catch (error: any) { + setAppState({ errorMessage: error.message }); + } + }; + + const onCanvasSaveAsTemplate = async () => { + if (!onSaveAsCanvasTemplate) { + return; + } + try { + await onSaveAsCanvasTemplate(); + } catch (error: any) { + setAppState({ errorMessage: error.message }); + } + }; + const renderLibraryMenu = () => { return ( @@ -204,7 +251,7 @@ export const LibraryDropdownMenuButton: React.FC<{ icon={LoadIcon} data-testid="lib-dropdown--load" > - {t("buttons.load")} + {t("buttons.loadLibraryFile")} )} {!!items.length && ( @@ -216,12 +263,22 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.export")} )} - {!!items.length && ( + {onSaveAs && !!writableItemsCount && ( setShowRemoveLibAlert(true)} - icon={TrashIcon} + onSelect={onLibrarySaveAs} + icon={LibraryIcon} + data-testid="lib-dropdown--save-as-library" > - {resetLabel} + {t("buttons.saveAsLibrary")} + + )} + {onSaveAsCanvasTemplate && !itemsSelected && ( + + {t("buttons.saveAsCanvasTemplate")} )} {itemsSelected && ( @@ -233,6 +290,14 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.publishLibrary")} )} + {!!writableItemsCount && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} ); @@ -280,6 +345,7 @@ export const LibraryDropdownMenu = ({ className?: string; }) => { const { library } = useApp(); + const appProps = useAppProps(); const { clearLibraryCache, deleteItemsFromLibraryCache } = useLibraryCache(); const appState = useUIAppState(); const setAppState = useExcalidrawSetAppState(); @@ -300,7 +366,12 @@ export const LibraryDropdownMenu = ({ }; const resetLibrary = () => { - library.resetLibrary(); + // Reset only clears the writable "My library" — items from read-only + // sources (saved/org libraries) stay, they are resolved per board. + const readonlyItems = libraryItemsData.libraryItems.filter( + (item) => !!(item as TaggedLibraryItem).libraryName, + ); + library.setLibrary(readonlyItems); clearLibraryCache(); }; @@ -314,6 +385,8 @@ export const LibraryDropdownMenu = ({ onRemoveFromLibrary={() => removeFromLibrary(libraryItemsData.libraryItems) } + onSaveAs={appProps.onLibrarySaveAs} + onSaveAsCanvasTemplate={appProps.onSaveAsCanvasTemplate} resetLibrary={resetLibrary} className={className} /> diff --git a/packages/excalidraw/components/LibraryMenuItems.scss b/packages/excalidraw/components/LibraryMenuItems.scss index 59cd9f1cf9cd..9b74e7cf5405 100644 --- a/packages/excalidraw/components/LibraryMenuItems.scss +++ b/packages/excalidraw/components/LibraryMenuItems.scss @@ -71,6 +71,33 @@ &--excal { margin-top: 2rem; } + + &--stacked { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + &__description { + color: var(--color-border-outline); + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.35; + } + + &__hint { + margin-left: auto; + font-size: 10px; + color: var(--color-border-outline); + font-weight: 400; + + kbd { + font-family: monospace; + border: 1px solid var(--color-border-outline); + border-radius: 4px; + padding: 1px 3px; + } + } } &__grid { diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index aa2c3e68e65f..eefddc54a34f 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -5,12 +5,14 @@ import React, { useRef, useState, } from "react"; +import clsx from "clsx"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; import type { ExcalidrawProps, LibraryItem, LibraryItems, + TaggedLibraryItem, UIAppState, } from "../types"; import { arrayToMap } from "../utils"; @@ -36,6 +38,11 @@ const ITEMS_RENDERED_PER_BATCH = 17; // speed it up const CACHED_ITEMS_RENDERED_PER_BATCH = 64; +// Items belonging to a read-only source (a saved or organization library) +// carry a `libraryName`. Items without one belong to the writable "My library". +const getLibraryName = (item: LibraryItem): string | undefined => + (item as TaggedLibraryItem).libraryName || undefined; + export default function LibraryMenuItems({ isLoading, libraryItems, @@ -47,6 +54,8 @@ export default function LibraryMenuItems({ libraryReturnUrl, onSelectItems, selectedItems, + libraryMenuTitle, + libraryMenuDescription, }: { isLoading: boolean; libraryItems: LibraryItems; @@ -58,6 +67,8 @@ export default function LibraryMenuItems({ id: string; selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; + libraryMenuTitle: ExcalidrawProps["libraryMenuTitle"]; + libraryMenuDescription: ExcalidrawProps["libraryMenuDescription"]; }) { const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -70,22 +81,40 @@ export default function LibraryMenuItems({ }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); - const unpublishedItems = useMemo( - () => libraryItems.filter((item) => item.status !== "published"), - [libraryItems], + + const isLibraryEmpty = !libraryItems.length && !pendingElements.length; + const hasCustomLibraryMenuHeader = !!( + libraryMenuTitle || libraryMenuDescription ); - const publishedItems = useMemo( - () => libraryItems.filter((item) => item.status === "published"), + // Writable "My library" items (no source tag). + const personalItems = useMemo( + () => libraryItems.filter((item) => !getLibraryName(item)), [libraryItems], ); - const showBtn = !libraryItems.length && !pendingElements.length; + // Read-only sources (saved libraries, organization libraries), grouped by name. + const readonlySections = useMemo(() => { + const byName = new Map(); + for (const item of libraryItems) { + const name = getLibraryName(item); + if (!name) { + continue; + } + const bucket = byName.get(name); + if (bucket) { + bucket.push(item); + } else { + byName.set(name, [item]); + } + } + return [...byName.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([name, items]) => ({ name, items })); + }, [libraryItems]); - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; + const hasPrivateLibraryItems = + pendingElements.length > 0 || personalItems.length > 0; const [lastSelectedItem, setLastSelectedItem] = useState< LibraryItem["id"] | null @@ -95,7 +124,9 @@ export default function LibraryMenuItems({ (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; + // selection (and thus deletion / publishing) only applies to the writable + // "My library" items. + const orderedItems = personalItems; if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { @@ -133,13 +164,7 @@ export default function LibraryMenuItems({ onSelectItems(selectedItems.filter((_id) => _id !== id)); } }, - [ - lastSelectedItem, - onSelectItems, - publishedItems, - selectedItems, - unpublishedItems, - ], + [lastSelectedItem, onSelectItems, personalItems, selectedItems], ); const getInsertedElements = useCallback( @@ -185,6 +210,11 @@ export default function LibraryMenuItems({ [selectedItems], ); + // Read-only section items can be inserted and dragged, but never selected + // (selection drives delete/publish, which only apply to writable items). + const noSelectToggle = useCallback(() => {}, []); + const neverSelected = useCallback(() => false, []); + const onAddToLibraryClick = useCallback(() => { onAddToLibrary(pendingElements); }, [pendingElements, onAddToLibrary]); @@ -207,9 +237,7 @@ export default function LibraryMenuItems({
0 ? 1 : "0 1 auto", + flex: readonlySections.length > 0 ? 1 : "0 1 auto", marginBottom: 0, }} ref={libraryContainerRef} > <> - {!isLibraryEmpty && ( -
- {t("labels.personalLib")} + {(!isLibraryEmpty || hasCustomLibraryMenuHeader) && ( +
+ {libraryMenuTitle || t("labels.personalLib")} + {libraryMenuDescription && ( + + {libraryMenuDescription} + + )}
)} {isLoading && ( @@ -249,15 +287,13 @@ export default function LibraryMenuItems({
)} - {!pendingElements.length && !unpublishedItems.length ? ( + {!hasPrivateLibraryItems && !readonlySections.length ? (
{t("library.noItems")}
- {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} + {t("library.hint_emptyLibrary")}
) : ( @@ -275,7 +311,7 @@ export default function LibraryMenuItems({ )} - <> - {(publishedItems.length > 0 || - pendingElements.length > 0 || - unpublishedItems.length > 0) && ( -
- {t("labels.excalidrawLib")} + {readonlySections.map((section) => ( + +
+ {section.name}
- )} - {publishedItems.length > 0 ? ( - ) : unpublishedItems.length > 0 ? ( -
- {t("library.noItems")} -
- ) : null} - +
+ ))} - {showBtn && ( + {isLibraryEmpty && ( { detectScroll = true, handleKeyboardGlobally = false, onLibraryChange, + libraryMenuTitle, + libraryMenuDescription, + onLibrarySaveAs, + onSaveAsCanvasTemplate, autoFocus = false, generateIdForFile, onLinkOpen, @@ -134,6 +138,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { detectScroll={detectScroll} handleKeyboardGlobally={handleKeyboardGlobally} onLibraryChange={onLibraryChange} + libraryMenuTitle={libraryMenuTitle} + libraryMenuDescription={libraryMenuDescription} + onLibrarySaveAs={onLibrarySaveAs} + onSaveAsCanvasTemplate={onSaveAsCanvasTemplate} autoFocus={autoFocus} generateIdForFile={generateIdForFile} onLinkOpen={onLinkOpen} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f14b797055cb..2f854bf93b4e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -193,6 +193,9 @@ "copyLink": "Copy link", "save": "Save to current file", "saveAs": "Save as", + "saveAsLibrary": "Save as library...", + "saveAsCanvasTemplate": "Save as canvas...", + "loadLibraryFile": "Import .excalidrawlib file...", "load": "Open", "getShareableLink": "Get shareable link", "close": "Close", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index a75c29d21033..5dfcfa977013 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -474,6 +474,13 @@ export type LibraryItem = { error?: string; }; export type LibraryItems = readonly LibraryItem[]; +/** + * A library item the host app may have tagged with the name of the read-only + * source library it was resolved from (rendered as a separate, non-editable + * section). Untagged items belong to the writable personal library and are + * the only ones persisted back. See `onLibrarySaveAs`. + */ +export type TaggedLibraryItem = LibraryItem & { libraryName?: string }; export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1; export type LibraryItemsSource = @@ -553,6 +560,10 @@ export interface ExcalidrawProps { detectScroll?: boolean; handleKeyboardGlobally?: boolean; onLibraryChange?: (libraryItems: LibraryItems) => void | Promise; + libraryMenuTitle?: string; + libraryMenuDescription?: string; + onLibrarySaveAs?: (libraryItems: LibraryItems) => void | Promise; + onSaveAsCanvasTemplate?: () => void | Promise; autoFocus?: boolean; mountCommandPalette?: boolean; generateIdForFile?: (file: File) => string | Promise;